diff --git a/.gitignore b/.gitignore index 6771533..55ff5e2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ .hemttout/ +releases/ + hemtt.exe .hemttprivatekey diff --git a/CLAUDE.md b/CLAUDE.md index bfdfad2..caaed33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,7 @@ All settings use CBA Settings system (configurable in-game via addon options): - `OCAP_settings_minPlayerCount`: Minimum players to start - `OCAP_settings_frameCaptureDelay`: Capture interval in seconds - `OCAP_settings_excludeClassFromRecord`: Classes to exclude +- Player Detail Tracking (`OCAP_settings_trackPlayerInventory` / `Medical` / `Stamina` / `Radio` and their intervals): see `docs/player-detail-tracking.md` ## Code Conventions diff --git a/README.md b/README.md index 15cbd9a..7d2f17f 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ hemtt release # Release build with signed PBOs ## Configuration All settings are configurable in-game via CBA Settings (Options > Addon Options > OCAP). +Player Detail Tracking names and tooltips are stringtable entries +(`addons/recorder/stringtable.xml`) so they follow the game language. Key settings: - **Recording Enabled** - Master toggle @@ -34,6 +36,45 @@ Key settings: - **Minimum Player Count** - Players required for auto-start - **Frame Capture Delay** - Interval between position captures (seconds) +### Player Detail Tracking + +Optional per-player snapshots (gear, medical, stamina, radios) sampled on each +client and stored as extra events. AI is not sampled. Defaults, intervals and +the `force` block live in [docs/player-detail-tracking.md](docs/player-detail-tracking.md). + +Pinned production values: + +```sqf +force OCAP_settings_trackPlayerInventory = true; +force OCAP_settings_playerInventoryInterval = 120; +force OCAP_settings_trackPlayerInventoryOnDeath = true; +force OCAP_settings_trackPlayerMedical = true; +force OCAP_settings_playerMedicalInterval = 60; +force OCAP_settings_playerMedicalCriticalInterval = 10; +force OCAP_settings_trackPlayerStamina = true; +force OCAP_settings_playerStaminaInterval = 30; +force OCAP_settings_trackPlayerRadio = true; +force OCAP_settings_playerRadioInterval = 120; +force OCAP_settings_trackPlayerSnapshotDiffs = true; +force OCAP_settings_radioShortRangeMeters = 5000; +force OCAP_settings_radioLongRangeMeters = 20000; +force OCAP_settings_trackServerFps = true; +force OCAP_settings_trackWeather = true; +``` + +### Server Performance + +`OCAP_settings_trackServerFps` defaults to `true`. It records server `diag_fps` +at recording start and every 10 seconds for the web playback Stats panel. + +### Weather + +`OCAP_settings_trackWeather` defaults to `true`. It records vanilla weather +(overcast, rain, fog, wind, humidity, moon phase) at recording start and every +60 seconds. When ACE Weather is loaded and `ace_weather_enabled` is on, ACE air +temperature and humidity replace the vanilla readings. The Stats panel shows +the sample at the playhead. + ## License GNU General Public License v3.0 diff --git a/addons/recorder/XEH_preInit.sqf b/addons/recorder/XEH_preInit.sqf index a8cfe7b..59c1334 100644 --- a/addons/recorder/XEH_preInit.sqf +++ b/addons/recorder/XEH_preInit.sqf @@ -447,6 +447,379 @@ GVAR(allSettings) = [ false ], + [ + QEGVAR(settings,trackPlayerInventory), + "CHECKBOX", + [LSTRING(Settings_TrackPlayerInventory), LSTRING(Settings_TrackPlayerInventory_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_PlayerDetail)], + true, + true, + {}, + false + ], + + [ + QEGVAR(settings,playerInventoryInterval), + "SLIDER", + [LSTRING(Settings_PlayerInventoryInterval), LSTRING(Settings_PlayerInventoryInterval_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_PlayerDetail)], + [10, 1800, 120, 0, false], + true, + {}, + false + ], + + [ + QEGVAR(settings,trackPlayerInventoryOnDeath), + "CHECKBOX", + [LSTRING(Settings_TrackPlayerInventoryOnDeath), LSTRING(Settings_TrackPlayerInventoryOnDeath_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_PlayerDetail)], + true, + true, + {}, + false + ], + + [ + QEGVAR(settings,trackPlayerTraits), + "CHECKBOX", + [LSTRING(Settings_TrackPlayerTraits), LSTRING(Settings_TrackPlayerTraits_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_PlayerDetail)], + true, + true, + {}, + false + ], + + [ + QEGVAR(settings,trackPlayerMedical), + "CHECKBOX", + [LSTRING(Settings_TrackPlayerMedical), LSTRING(Settings_TrackPlayerMedical_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_PlayerDetail)], + true, + true, + {}, + false + ], + + [ + QEGVAR(settings,playerMedicalInterval), + "SLIDER", + [LSTRING(Settings_PlayerMedicalInterval), LSTRING(Settings_PlayerMedicalInterval_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_PlayerDetail)], + [5, 300, 60, 0, false], + true, + {}, + false + ], + + [ + QEGVAR(settings,playerMedicalCriticalInterval), + "SLIDER", + [LSTRING(Settings_PlayerMedicalCriticalInterval), LSTRING(Settings_PlayerMedicalCriticalInterval_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_PlayerDetail)], + [1, 120, 10, 0, false], + true, + {}, + false + ], + + [ + QEGVAR(settings,trackPlayerStamina), + "CHECKBOX", + [LSTRING(Settings_TrackPlayerStamina), LSTRING(Settings_TrackPlayerStamina_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_PlayerDetail)], + true, + true, + {}, + false + ], + + [ + QEGVAR(settings,playerStaminaInterval), + "SLIDER", + [LSTRING(Settings_PlayerStaminaInterval), LSTRING(Settings_PlayerStaminaInterval_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_PlayerDetail)], + [5, 300, 30, 0, false], + true, + {}, + false + ], + + [ + QEGVAR(settings,trackPlayerRadio), + "CHECKBOX", + [LSTRING(Settings_TrackPlayerRadio), LSTRING(Settings_TrackPlayerRadio_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_PlayerDetail)], + true, + true, + {}, + false + ], + + [ + QEGVAR(settings,playerRadioInterval), + "SLIDER", + [LSTRING(Settings_PlayerRadioInterval), LSTRING(Settings_PlayerRadioInterval_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_PlayerDetail)], + [10, 1800, 120, 0, false], + true, + {}, + false + ], + + [ + QEGVAR(settings,trackPlayerSnapshotDiffs), + "CHECKBOX", + [LSTRING(Settings_TrackPlayerSnapshotDiffs), LSTRING(Settings_TrackPlayerSnapshotDiffs_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_PlayerDetail)], + true, + true, + {}, + false + ], + + [ + QEGVAR(settings,radioShortRangeMeters), + "SLIDER", + [LSTRING(Settings_RadioShortRangeMeters), LSTRING(Settings_RadioShortRangeMeters_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_PlayerDetail)], + [100, 20000, 5000, 0, false], + true, + {}, + false + ], + + [ + QEGVAR(settings,radioLongRangeMeters), + "SLIDER", + [LSTRING(Settings_RadioLongRangeMeters), LSTRING(Settings_RadioLongRangeMeters_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_PlayerDetail)], + [1000, 100000, 20000, 0, false], + true, + {}, + false + ], + + [ + QEGVAR(settings,trackServerFps), + "CHECKBOX", + [LSTRING(Settings_TrackServerFps), LSTRING(Settings_TrackServerFps_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_ServerPerformance)], + true, + true, + {}, + false + ], + + [ + QEGVAR(settings,trackWeather), + "CHECKBOX", + [LSTRING(Settings_TrackWeather), LSTRING(Settings_TrackWeather_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_Weather)], + true, + true, + {}, + false + ], + + [ + QEGVAR(settings,trackZeus), + "CHECKBOX", + [LSTRING(Settings_TrackZeus), LSTRING(Settings_TrackZeus_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_Zeus)], + true, + true, + {}, + false + ], + + [ + QEGVAR(settings,zeusCameraInterval), + "SLIDER", + [LSTRING(Settings_ZeusCameraInterval), LSTRING(Settings_ZeusCameraInterval_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_Zeus)], + [0.25, 10, 1, 1, false], + true, + {}, + false + ], + + [ + QEGVAR(settings,trackZeusPings), + "CHECKBOX", + [LSTRING(Settings_TrackZeusPings), LSTRING(Settings_TrackZeusPings_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_Zeus)], + true, + true, + {}, + false + ], + + [ + QEGVAR(settings,trackZeusModules), + "CHECKBOX", + [LSTRING(Settings_TrackZeusModules), LSTRING(Settings_TrackZeusModules_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_Zeus)], + true, + true, + {}, + false + ], + + [ + QEGVAR(settings,zeusPingMinInterval), + "SLIDER", + [LSTRING(Settings_ZeusPingMinInterval), LSTRING(Settings_ZeusPingMinInterval_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_Zeus)], + [0, 10, 2, 1, false], + true, + {}, + false + ], + + /* + CBA Setting: OCAP_settings_trackTrenches + Description: + Records ACE3 and GRAD trenches as placed objects once they are fully dug, + so they appear on the playback map as an oriented icon labelled with the + trench type, who dug it, and the mission time. Has no effect when ACE + trenches is not loaded. Default: Yes + + Setting Name: + Track Trenches + + Value Type: Boolean + */ + [ + QEGVAR(settings,trackTrenches), + "CHECKBOX", + [LSTRING(Settings_TrackTrenches), LSTRING(Settings_TrackTrenches_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_Trenches)], + true, + true, + {}, + false + ], + + /* + CBA Setting: OCAP_settings_trackExplosions + Description: + Records every detonation big enough to matter as an `explosion` event + carrying the blast radius read from CfgAmmo, so playback can draw the area + a shell or charge actually covered instead of a bare impact dot. + Default: Yes + + Setting Name: + Track Explosions + + Value Type: Boolean + */ + [ + QEGVAR(settings,trackExplosions), + "CHECKBOX", + [LSTRING(Settings_TrackExplosions), LSTRING(Settings_TrackExplosions_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_Explosions)], + true, + true, + {}, + false + ], + + /* + CBA Setting: OCAP_settings_explosionMinRadius + Description: + Smallest blast radius worth recording, in metres. This is what keeps a + 30 mm HE burst from writing an event per round while still catching hand + grenades, which sit around 5 m. Default: 2 + + Setting Name: + Minimum Explosion Radius + + Value Type: Number + */ + [ + QEGVAR(settings,explosionMinRadius), + "SLIDER", + [LSTRING(Settings_ExplosionMinRadius), LSTRING(Settings_ExplosionMinRadius_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_Explosions)], + [0, 50, 2, 1, false], + true, + {}, + false + ], + + /* + CBA Setting: OCAP_settings_trackServicing + Description: + Records vehicles being repaired, refuelled and rearmed. Repair and refuel + are detected from the vehicle's own synced state, so they work with ACE, + vanilla support vehicles and Zeus alike; rearm needs ACE Rearm, which is + the only source that reports turret ammo to the server. Default: Yes + + Setting Name: + Track Vehicle Servicing + + Value Type: Boolean + */ + [ + QEGVAR(settings,trackServicing), + "CHECKBOX", + [LSTRING(Settings_TrackServicing), LSTRING(Settings_TrackServicing_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_Support)], + true, + true, + {}, + false + ], + + /* + CBA Setting: OCAP_settings_serviceSampleInterval + Description: + How often vehicle fuel and damage are sampled, in seconds. A longer + interval costs less and still catches the job, because each repair or + refuel is written as one event spanning the whole thing rather than one + per sample. Default: 5 + + Setting Name: + Servicing Sample Interval + + Value Type: Number + */ + [ + QEGVAR(settings,serviceSampleInterval), + "SLIDER", + [LSTRING(Settings_ServiceSampleInterval), LSTRING(Settings_ServiceSampleInterval_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_Support)], + [1, 60, 5, 0, false], + true, + {}, + false + ], + + /* + CBA Setting: OCAP_settings_trackConstructions + Description: + Records ACE3 Fortify placements as labelled map objects, and static + weapons assembled from bags or taken apart again as events pointing at the + gun's own entity record. Default: Yes + + Setting Name: + Track Fortifications and Static Weapons + + Value Type: Boolean + */ + [ + QEGVAR(settings,trackConstructions), + "CHECKBOX", + [LSTRING(Settings_TrackConstructions), LSTRING(Settings_TrackConstructions_Desc)], + [COMPONENT_NAME, LSTRING(Settings_Category_Support)], + true, + true, + {}, + false + ], + // Section: Save/Export Settings diff --git a/addons/recorder/XEH_prep.sqf b/addons/recorder/XEH_prep.sqf index b7417fa..28de4f0 100644 --- a/addons/recorder/XEH_prep.sqf +++ b/addons/recorder/XEH_prep.sqf @@ -40,6 +40,11 @@ PREP(entityMonitors); PREP(telemetryLoop); PREP(aceExplosives); +PREP(recordExplosion); +PREP(trackTrenches); +PREP(trackServicing); +PREP(trackConstructions); +PREP(submitStaticWeapon); PREP(addEventIntegrations); PREP(getStaticObjects); @@ -47,6 +52,18 @@ PREP(trackSectors); PREP(handleChatMessage); PREP(radioEvent); +PREP(installPlayerTrackingClients); +PREP(submitPlayerSnapshot); +PREP(diffSnapshotPayload); +PREP(encodeJSON); +PREP(ensureZeusEntity); +PREP(trackZeus); +PREP(submitZeusCamera); +PREP(submitZeusPing); +PREP(submitZeusModule); +PREP(recordRadioPropagation); +PREP(recordWeather); + PREP(exportData); PREP(setFocusStart); diff --git a/addons/recorder/fnc_aceExplosives.sqf b/addons/recorder/fnc_aceExplosives.sqf index 5908a46..7fdb63b 100644 --- a/addons/recorder/fnc_aceExplosives.sqf +++ b/addons/recorder/fnc_aceExplosives.sqf @@ -75,6 +75,17 @@ _explosive addEventHandler ["Explode", { _pos joinString "," // 3: position ]; [QGVARMAIN(handlePlacedEvent), [_eventData]] call CBA_fnc_serverEvent; + + // Blast report. The placer was stamped on the charge below, because the + // detonation itself carries nothing but the object and the position. + [QGVARMAIN(handleExplosion), [ + EGVAR(recorder,captureFrameNo), + _pos joinString ",", + typeOf _explosive, + _explosive getVariable [QGVARMAIN(placerId), -1], + "", + "explosive" + ]] call CBA_fnc_serverEvent; }]; _explosive addEventHandler ["Deleted", { @@ -107,6 +118,9 @@ if (!isServer) exitWith {}; private _unitOcapId = _unit getVariable [QGVARMAIN(id), -1]; if (_unitOcapId isEqualTo -1) exitWith {}; + // Kept for the detonation report, which only ever sees the charge itself. + _explosive setVariable [QGVARMAIN(placerId), _unitOcapId, true]; + // Resolve explosive metadata from config private _explType = typeOf _explosive; private _explosiveMag = getText(configFile >> "CfgAmmo" >> _explType >> "defaultMagazine"); diff --git a/addons/recorder/fnc_addEventIntegrations.sqf b/addons/recorder/fnc_addEventIntegrations.sqf index ded28b1..929a795 100644 --- a/addons/recorder/fnc_addEventIntegrations.sqf +++ b/addons/recorder/fnc_addEventIntegrations.sqf @@ -176,8 +176,14 @@ if (isClass (configFile >> "CfgPatches" >> "ace_medical_status")) then { // PRC-343 is a short-range item radio, all others are long-range private _typeRadio = ["LR", "SR"] select (_baseRadio == "ACRE_PRC343"); - // round frequency from Hz to MHz with 3 decimal places - _freq = (_freq / 1000000) toFixed 3; + // ACRE stores frequencyTX in MHz already -- its own descriptive name does + // format ["%1 MHz", frequencyTX] -- and the presets bear that out: the + // BF-888S runs 400.00-400.63, the PRC-343 2400.00-2402.55, the PRC-152 + // (950+2i)*0.0625 from 59.375. Dividing by a million here (which this line + // used to do, on the assumption the value was in Hz) made every ACRE + // transmission read 0.000 and match no radio at all. Three decimals is the + // finest step any of those presets uses. + _freq = _freq toFixed 3; // store computed radio info on unit for stop event (acre_stoppedSpeaking doesn't provide it) _unit setVariable [QGVAR(acre_lastRadioInfo), [_radioName, _typeRadio, _channel, _freq]]; @@ -226,3 +232,7 @@ if (isClass (configFile >> "CfgPatches" >> "ace_medical_status")) then { missionNamespace setVariable [QGVARMAIN(radioEventsInitialized), true]; } remoteExec ["call", [0, -2] select isDedicated, true]; + +// Player-owned data must be sampled where the player is local. The installer +// sends self-contained code because OCAP is normally loaded server-side only. +call FUNC(installPlayerTrackingClients); diff --git a/addons/recorder/fnc_addUnitEventHandlers.sqf b/addons/recorder/fnc_addUnitEventHandlers.sqf index 0b7199e..3477b19 100644 --- a/addons/recorder/fnc_addUnitEventHandlers.sqf +++ b/addons/recorder/fnc_addUnitEventHandlers.sqf @@ -40,4 +40,28 @@ if ((_entity call BIS_fnc_objectType) # 0 == "Soldier") then { _entity addEventHandler ["FiredMan", { _this call FUNC(eh_firedMan); }] ]; }; + + // WEAPONASSEMBLED / WEAPONDISASSEMBLED + // Both fire only where the unit is local, so these cover units this machine + // owns -- server AI. Players report the same two events through the client + // bootstrap in , because a + // handler added here would never fire on their machine. + if (isNil {_entity getVariable QGVARMAIN(staticWeaponEH)}) then { + _entity setVariable [ + QGVARMAIN(staticWeaponEH), + _entity addEventHandler ["WeaponAssembled", { + params ["_unit", "_static"]; + ["assembled", _unit, _static] call FUNC(submitStaticWeapon); + }] + ]; + // WeaponDisassembled hands over the two bags, not the gun, so this only + // files the claim; the event is written when the gun disappears. + _entity setVariable [ + QGVARMAIN(staticWeaponDisassembledEH), + _entity addEventHandler ["WeaponDisassembled", { + params ["_unit"]; + ["disassembled", _unit, objNull] call FUNC(submitStaticWeapon); + }] + ]; + }; }; diff --git a/addons/recorder/fnc_adminUIcontrol.sqf b/addons/recorder/fnc_adminUIcontrol.sqf index 6f3885f..a1baefd 100644 --- a/addons/recorder/fnc_adminUIcontrol.sqf +++ b/addons/recorder/fnc_adminUIcontrol.sqf @@ -45,7 +45,7 @@ if (!(_userInfo isEqualType []) || {count _userInfo < 11}) exitWith { diag_log text format ["[OCAP] (recorder) WARNING: getUserInfo returned unexpected data for PID %1: type=%2 count=%3 data=%4", _PID, typeName _userInfo, if (_userInfo isEqualType []) then {count _userInfo} else {-1}, _userInfo]; }; _userInfo params ["_playerID", "_owner", "_playerUID"]; -private _unit = _userInfo select 10; +private _unit = _userInfo param [10, objNull, [objNull]]; if (isNull _unit) exitWith { diag_log text format ["[OCAP] (recorder) WARNING: getUserInfo unit (index 10) is null for PID %1 (UID: %2)", _PID, _playerUID]; }; diff --git a/addons/recorder/fnc_captureLoop.sqf b/addons/recorder/fnc_captureLoop.sqf index c00a4ad..8225690 100644 --- a/addons/recorder/fnc_captureLoop.sqf +++ b/addons/recorder/fnc_captureLoop.sqf @@ -33,6 +33,14 @@ if (!isNil QGVAR(PFHObject)) then { [GVAR(PFHObject)] call CBA_fnc_deletePerFrameHandlerObject; GVAR(PFHObject) = nil; }; +if (!isNil QGVAR(serverFpsPFH)) then { + [GVAR(serverFpsPFH)] call CBA_fnc_removePerFrameHandler; + GVAR(serverFpsPFH) = nil; +}; +if (!isNil QGVAR(weatherPFH)) then { + [GVAR(weatherPFH)] call CBA_fnc_removePerFrameHandler; + GVAR(weatherPFH) = nil; +}; if (isNil QGVAR(startTime)) then { GVAR(startTime) = time; publicVariable QGVAR(startTime); @@ -48,6 +56,20 @@ GVAR(trackedUnits) = createHashMap; GVAR(ticketInterval) = round (30 / GVAR(frameCaptureDelay)); GVAR(diaryInterval) = round (320 / GVAR(frameCaptureDelay)); +GVAR(recordServerFps) = { + if !(SHOULDSAVEEVENTS) exitWith {}; + if !(EGVAR(settings,trackServerFps)) exitWith {}; + [":EVENT:GENERAL:", [ + // The PFH fires once before the capture loop has produced a frame, and a + // sample stamped -1 is invisible to a frame-scoped lookup in the web UI. + GVAR(captureFrameNo) max 0, + "serverFps", + [createHashMapFromArray [["fps", diag_fps]]] call FUNC(encodeJSON) + ]] call EFUNC(extension,sendData); +}; +GVAR(serverFpsPFH) = [GVAR(recordServerFps), 10] call CBA_fnc_addPerFrameHandler; +GVAR(weatherPFH) = [{ call FUNC(recordWeather) }, 60] call CBA_fnc_addPerFrameHandler; + // Variable: OCAP_PFHObject // The CBA PerFrameHandler object that is created and used to run the capture loop. GVAR(PFHObject) = [ @@ -59,6 +81,19 @@ GVAR(PFHObject) = [ GVAR(captureFrameNo) = GVAR(captureFrameNo) + 1; publicVariable QGVAR(captureFrameNo); + // Stamp TFAR and ACRE range settings on the first recorded frame so + // playback can reuse the mission's values instead of only the baked defaults. + if (GVAR(captureFrameNo) == 1) then { + call FUNC(recordRadioPropagation); + }; + + // Capture immediately so the Stats panel has a value from the start. + // The dedicated CBA PFHs record subsequent FPS / weather samples. + if (GVAR(captureFrameNo) == 1) then { + call GVAR(recordServerFps); + call FUNC(recordWeather); + }; + if (GVAR(captureFrameNo) == 10 || (GVAR(captureFrameNo) > 10 && EGVAR(settings,trackTimes) && GVAR(captureFrameNo) % EGVAR(settings,trackTimeInterval) == 0)) then { [] call FUNC(updateTime); }; @@ -347,6 +382,8 @@ GVAR(PFHObject) = [ } forEach (keys GVAR(trackedVehicles)); { GVAR(trackedVehicles) deleteAt _x } forEach _toRemove; + call FUNC(trackZeus); + if (GVARMAIN(isDebug)) then { private _logStr = format["Frame %1 processed in %2ms", GVAR(captureFrameNo), diag_tickTime - _loopStart]; OCAPEXTLOG([_logStr]); diff --git a/addons/recorder/fnc_diffSnapshotPayload.sqf b/addons/recorder/fnc_diffSnapshotPayload.sqf new file mode 100644 index 0000000..42d9fbe --- /dev/null +++ b/addons/recorder/fnc_diffSnapshotPayload.sqf @@ -0,0 +1,175 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_diffSnapshotPayload.sqf + +FUNCTION: OCAP_recorder_fnc_diffSnapshotPayload + +Description: + Compares two player-snapshot payloads and returns only what changed. + + Nested HashMaps are walked recursively so a single changed vital does not drag + the whole medical block along. + + An array whose entries are HashMaps carrying a unique identifier ("class" for + gear, "slot" for weapons, "part" for body parts) is patched entry by entry: + + > {"$key": "class", "put": [{"class": "30Rnd_556", "totalRounds": 149}], "del": ["Chemlight_green"]} + + Without that, a single round leaving one magazine rewrote every magazine the + player carried, which was the largest single cost in a recording. Arrays that + have no such identifier are still replaced whole. + +Parameters: + _previous - Payload of the player's previous snapshot of the same kind. [HashMap] + _current - Payload just sampled. [HashMap] + +Returns: + [_changed, _removed] - Partial payload to merge onto the previous one, and the + dot-separated paths of keys that no longer exist. [Array] + +Examples: + > ([_old, _new] call FUNC(diffSnapshotPayload)) params ["_changed", "_removed"]; + +Public: + No + +Author: + IndigoFox +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +params [ + ["_previous", createHashMap, [createHashMap]], + ["_current", createHashMap, [createHashMap]] +]; + +private _hashMapType = createHashMap; +private _removed = []; + +// The field that addresses entries in both arrays, or "" when they cannot be +// paired up: an entry that is not a HashMap, a missing identifier, or the same +// identifier twice all rule out an entry-wise patch. +private _arrayKeyField = { + params ["_old", "_new"]; + + if (((_old + _new) findIf {!(_x isEqualType _hashMapType)}) > -1) exitWith {""}; + + // Identifiers an entry may be addressed by, in the order they are tried. + private _field = ""; + { + private _candidate = _x; + private _oldKeys = _old apply {_x getOrDefault [_candidate, ""]}; + private _newKeys = _new apply {_x getOrDefault [_candidate, ""]}; + if (((_oldKeys + _newKeys) findIf {!(_x isEqualType "") || {_x isEqualTo ""}}) == -1 && + {count (_oldKeys arrayIntersect _oldKeys) == count _oldKeys} && + {count (_newKeys arrayIntersect _newKeys) == count _newKeys}) exitWith { + _field = _candidate; + }; + } forEach ["class", "slot", "part"]; + _field +}; + +private _diffKeyedArray = { + params ["_old", "_new"]; + + // Emptying an array, or filling an empty one, is cheaper written out in full. + if (_old isEqualTo [] || {_new isEqualTo []}) exitWith {nil}; + + private _field = [_old, _new] call _arrayKeyField; + if (_field isEqualTo "") exitWith {nil}; + + private _oldByKey = createHashMap; + {_oldByKey set [_x get _field, _x]} forEach _old; + + private _put = []; + { + private _entry = _x; + private _key = _entry get _field; + private _base = _oldByKey get _key; + if (isNil "_base") then { + _put pushBack _entry; + } else { + // Most entries are untouched between two samples (one magazine lost a + // round, the other twenty did not). A native comparison settles those + // without walking them key by key. + if (_entry isEqualTo _base) then {continue}; + private _mark = count _removed; + private _child = [_base, _entry, ""] call _walk; + if (count _removed > _mark) then { + // The entry dropped a field. A dot path cannot address a position inside + // an array, so the entry is rewritten in full rather than unset. + _removed resize _mark; + _put pushBack _entry; + } else { + if (count _child > 0) then { + _child set [_field, _key]; + _put pushBack _child; + }; + }; + }; + } forEach _new; + + private _newKeys = _new apply {_x get _field}; + private _oldKeys = _old apply {_x get _field}; + private _del = _oldKeys select {!(_x in _newKeys)}; + + // Applying put and del alone keeps surviving entries where they were and adds + // new ones at the end. Where that is not the order sampled, say so: the + // Arsenal export reads assignedItems positionally, so the order is data. + private _applied = _oldKeys select {!(_x in _del)}; + { + private _key = _x get _field; + if (!(_key in _applied)) then {_applied pushBack _key}; + } forEach _put; + private _reordered = _applied isNotEqualTo _newKeys; + + if (_put isEqualTo [] && {_del isEqualTo []} && {!_reordered}) exitWith {nil}; + + private _patch = createHashMapFromArray [["$key", _field]]; + if (_put isNotEqualTo []) then {_patch set ["put", _put]}; + if (_del isNotEqualTo []) then {_patch set ["del", _del]}; + if (_reordered) then {_patch set ["ord", _newKeys]}; + + _patch +}; + +private _walk = { + params ["_old", "_new", "_prefix"]; + + private _changed = createHashMap; + { + private _key = _x; + private _newValue = _y; + + if (!(_key in _old)) then { + _changed set [_key, _newValue]; + } else { + private _oldValue = _old get _key; + // Identical values, nested HashMaps included, need no walk at all. + if (_newValue isEqualTo _oldValue) then {continue}; + if (_newValue isEqualType _hashMapType && {_oldValue isEqualType _hashMapType}) then { + private _child = [_oldValue, _newValue, _prefix + _key + "."] call _walk; + if (count _child > 0) then {_changed set [_key, _child]}; + } else { + if (_newValue isNotEqualTo _oldValue) then { + private _patch = nil; + if (_newValue isEqualType [] && {_oldValue isEqualType []}) then { + _patch = [_oldValue, _newValue] call _diffKeyedArray; + }; + if (isNil "_patch") then { + _changed set [_key, _newValue]; + } else { + _changed set [_key, _patch]; + }; + }; + }; + }; + } forEach _new; + + { + if (!(_x in _new)) then {_removed pushBack (_prefix + _x)}; + } forEach (keys _old); + + _changed +}; + +[[_previous, _current, ""] call _walk, _removed] diff --git a/addons/recorder/fnc_eh_fired_client.sqf b/addons/recorder/fnc_eh_fired_client.sqf index ccb717b..d82951d 100644 --- a/addons/recorder/fnc_eh_fired_client.sqf +++ b/addons/recorder/fnc_eh_fired_client.sqf @@ -170,6 +170,20 @@ if (_weapon == "put") then { _pos joinString "," // 3: position ]; [QGVARMAIN(handlePlacedEvent), [_eventData]] call CBA_fnc_serverEvent; + + // Same blast report as a projectile detonation. A mine is not a tracked + // projectile, so the firer comes off the data stored at placement time. + private _placedData = _projectile getVariable [QGVARMAIN(projectileData), []]; + private _placerId = -1; + if (count _placedData > 2) then {_placerId = _placedData select 2}; + [QGVARMAIN(handleExplosion), [ + EGVAR(recorder,captureFrameNo), + _pos joinString ",", + typeOf _projectile, + _placerId, + "", + "explosive" + ]] call CBA_fnc_serverEvent; }]; _projectile addEventHandler ["Deleted", { diff --git a/addons/recorder/fnc_eh_fired_clientProjectile.sqf b/addons/recorder/fnc_eh_fired_clientProjectile.sqf index c6534dd..044b3d1 100644 --- a/addons/recorder/fnc_eh_fired_clientProjectile.sqf +++ b/addons/recorder/fnc_eh_fired_clientProjectile.sqf @@ -77,7 +77,7 @@ _projectile addEventHandler ["Deflected", { ]]] call CBA_fnc_serverEvent; }]; -// Explode — detonation, log position +// Explode — detonation, log position and report the blast _projectile addEventHandler ["Explode", { params ["_projectile", "_pos", "_velocity"]; TRACE_3("Explode",_projectile,_pos,_velocity); @@ -88,6 +88,18 @@ _projectile addEventHandler ["Explode", { EGVAR(recorder,captureFrameNo), _pos joinString "," ]]] call CBA_fnc_serverEvent; + + // The blast size is a CfgAmmo property the server resolves; all the owner can + // add is which class went off and where. Firer and vehicle are recovered from + // the tracked projectile entry, so nothing about identity is trusted here. + [QGVARMAIN(handleExplosion), [ + EGVAR(recorder,captureFrameNo), + _pos joinString ",", + typeOf _projectile, + -1, + _tempKey, + "projectile" + ]] call CBA_fnc_serverEvent; }]; // Deleted — send done signal to server diff --git a/addons/recorder/fnc_eh_fired_server.sqf b/addons/recorder/fnc_eh_fired_server.sqf index d7894e1..ec4c8b8 100644 --- a/addons/recorder/fnc_eh_fired_server.sqf +++ b/addons/recorder/fnc_eh_fired_server.sqf @@ -119,6 +119,13 @@ GVAR(trackedPlacedObjects) = createHashMap; GVAR(trackedPlacedObjects) set [_placedId, _projectile]; }] call CBA_fnc_addEventHandler; +// Handle blast reports from projectile and placed-object detonations. The +// reporting machine knows only what exploded and where; radius, damage, firer +// and side are all resolved server-side in FUNC(recordExplosion). +[QGVARMAIN(handleExplosion), { + _this call FUNC(recordExplosion); +}] call CBA_fnc_addEventHandler; + // Handle placed object lifecycle events (detonation, deletion) [QGVARMAIN(handlePlacedEvent), { params ["_data"]; @@ -208,6 +215,13 @@ GVAR(trackedPlacedObjects) = createHashMap; [QGVARMAIN(handlePlacedEvent), [[ GVAR(captureFrameNo), _placedId, "detonated", _pos joinString "," ]]] call CBA_fnc_localEvent; + + private _placedData = _projectile getVariable [QGVARMAIN(projectileData), []]; + private _placerId = _projectile getVariable [QGVARMAIN(placerId), -1]; + if (_placerId isEqualTo -1 && {count _placedData > 2}) then {_placerId = _placedData select 2}; + [ + GVAR(captureFrameNo), _pos joinString ",", typeOf _projectile, _placerId, "", "explosive" + ] call FUNC(recordExplosion); }]; _obj addEventHandler ["Deleted", { diff --git a/addons/recorder/fnc_eh_killed.sqf b/addons/recorder/fnc_eh_killed.sqf index 2b5c92c..901f61a 100644 --- a/addons/recorder/fnc_eh_killed.sqf +++ b/addons/recorder/fnc_eh_killed.sqf @@ -30,6 +30,10 @@ if (!SHOULDSAVEEVENTS) exitWith {}; params ["_victim", "_killer", "_instigator"]; +if (isPlayer _victim && {EGVAR(settings,trackPlayerInventoryOnDeath)}) then { + ["death", ["inventory", "medical"], _victim] remoteExecCall ["OCAP_recorder_client_capturePlayerState", _victim]; +}; + // Skip parachutes and ejection seats - these generate noise like "Ejection Seat destroyed by Ejection Seat" if ((_victim getVariable [QGVARMAIN(vehicleClass), ""]) isEqualTo "parachute") exitWith {}; diff --git a/addons/recorder/fnc_encodeJSON.sqf b/addons/recorder/fnc_encodeJSON.sqf new file mode 100644 index 0000000..38d587d --- /dev/null +++ b/addons/recorder/fnc_encodeJSON.sqf @@ -0,0 +1,83 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_encodeJSON.sqf + +FUNCTION: OCAP_recorder_fnc_encodeJSON + +Description: + Serializes a payload to JSON for the general-event message field. A drop-in + for CBA_fnc_encodeJSON on the shapes our events use, at a fraction of the cost. + + CBA_fnc_encodeJSON escapes every string with eight CBA_fnc_replace calls, each + a scripted find/concat loop. A full inventory snapshot carries about 300 + strings, so one encode ran some 2,500 scripted calls and cost several server + frames in one go -- and every player sends one at the start of a recording, + on every keyframe and on every join. Strings are escaped here with the + engine's own `toJSON`, one native call each. + + Numbers and booleans still go through `str`, exactly as CBA writes them, so the + values that reach the recording are unchanged. `toJSON` is not used for them + because its float formatting is undocumented; `str` is the six-significant- + digit form every recording so far was written with. The extension and the + exporter both parse the result with a real JSON decoder, so the separators + CBA put spaces after carry no meaning and are left out. + + HashMaps and arrays are walked in script. Leaves -- the vast majority of the + values -- are handled inline instead of through another call. Anything this + does not know (an object, a namespace, a CBA hash) is handed to + CBA_fnc_encodeJSON, so no input encodes differently from before. + +Parameters: + _value - Value to serialize. [HashMap, Array, String, Number, Boolean or nil] + +Returns: + JSON text. [String] + +Examples: + > [createHashMapFromArray [["fps", diag_fps]]] call FUNC(encodeJSON); + +Public: + No + +Author: + OCAP Improved +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +params ["_value"]; + +private _encode = { + params ["_value"]; + if (isNil "_value") exitWith {"null"}; + + switch (true) do { + case (_value isEqualType ""): {toJSON _value}; + case (_value isEqualTypeAny [0, true]): {str _value}; + case (_value isEqualType createHashMap): { + "{" + (((_value toArray false) apply { + _x params ["_key", "_item"]; + if !(_key isEqualType "") then {_key = str _key}; + (toJSON _key) + ":" + ( + if (isNil "_item") then {"null"} else { + if (_item isEqualType "") then {toJSON _item} else { + if (_item isEqualTypeAny [0, true]) then {str _item} else {[_item] call _encode} + } + } + ) + }) joinString ",") + "}" + }; + // CBA_fnc_isHash, inlined: a legacy CBA hash is a four-element array tagged + // in slot 0, and CBA encodes it as an object, so it is left to CBA. + case (_value isEqualType [] && {count _value != 4 || {(_value select 0) isNotEqualTo "#CBA_HASH#"}}): { + "[" + ((_value apply { + if (isNil "_x") then {"null"} else { + if (_x isEqualType "") then {toJSON _x} else { + if (_x isEqualTypeAny [0, true]) then {str _x} else {[_x] call _encode} + } + } + }) joinString ",") + "]" + }; + default {[_value] call CBA_fnc_encodeJSON}; + }; +}; + +[_value] call _encode diff --git a/addons/recorder/fnc_ensureZeusEntity.sqf b/addons/recorder/fnc_ensureZeusEntity.sqf new file mode 100644 index 0000000..5bba815 --- /dev/null +++ b/addons/recorder/fnc_ensureZeusEntity.sqf @@ -0,0 +1,66 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_ensureZeusEntity.sqf + +FUNCTION: OCAP_recorder_fnc_ensureZeusEntity + +Description: + Allocates a stable OCAP id for a Zeus player the first time they are seen + and writes a event. The addon is server-only; this runs on the + server from the capture loop or from a client camera sample. + + The id is taken from but no :SOLDIER:CREATE: is sent, + so unmodified OCAP readers never see a VIRTUAL unit. Playback synthesizes + the entity from the event. + +Parameters: + _unit - The curator player unit (getAssignedCuratorUnit or remote-control owner). [Object] + +Returns: + The curatorId, or -1 if the unit cannot be identified. [Number] + +Examples: + > [_owner] call FUNC(ensureZeusEntity); + +Public: + No + +Author: + OCAP Improved +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +if (!isServer || {!SHOULDSAVEEVENTS} || {!EGVAR(extension,sessionReady)}) exitWith {-1}; +if !(EGVAR(settings,trackZeus)) exitWith {-1}; + +params [["_unit", objNull, [objNull]]]; +if (isNull _unit) exitWith {-1}; + +private _uid = getPlayerUID _unit; +if (_uid isEqualTo "") exitWith {-1}; + +if (isNil QGVAR(zeusByUid)) then {GVAR(zeusByUid) = createHashMap}; + +private _existing = GVAR(zeusByUid) getOrDefault [_uid, -1]; +if (_existing >= 0) exitWith { + _unit setVariable [QGVAR(zeusId), _existing]; + _existing +}; + +private _id = GVAR(nextId); +GVAR(nextId) = GVAR(nextId) + 1; +GVAR(zeusByUid) set [_uid, _id]; +_unit setVariable [QGVAR(zeusId), _id]; + +private _bodyId = _unit getVariable [QGVARMAIN(id), -1]; +[":EVENT:GENERAL:", [ + GVAR(captureFrameNo) max 0, + "zeusEntity", + [createHashMapFromArray [ + ["curatorId", _id], + ["name", name _unit], + ["playerUid", _uid], + ["bodyUnitId", _bodyId] + ]] call FUNC(encodeJSON) +]] call EFUNC(extension,sendData); + +_id diff --git a/addons/recorder/fnc_exportData.sqf b/addons/recorder/fnc_exportData.sqf index c6f910c..ef70b9e 100644 --- a/addons/recorder/fnc_exportData.sqf +++ b/addons/recorder/fnc_exportData.sqf @@ -110,6 +110,14 @@ if (!isNil QGVAR(PFHObject)) then { [GVAR(PFHObject)] call CBA_fnc_deletePerFrameHandlerObject; GVAR(PFHObject) = nil; }; +if (!isNil QGVAR(serverFpsPFH)) then { + [GVAR(serverFpsPFH)] call CBA_fnc_removePerFrameHandler; + GVAR(serverFpsPFH) = nil; +}; +if (!isNil QGVAR(weatherPFH)) then { + [GVAR(weatherPFH)] call CBA_fnc_removePerFrameHandler; + GVAR(weatherPFH) = nil; +}; private _winSide = if (isNil "_side") then {""} else {if (_side isEqualTo sideUnknown) then {""} else {str _side}}; private _endMessage = if (isNil "_message") then {if (_winSide == "") then {"Mission ended"} else {""}} else {_message}; @@ -161,11 +169,27 @@ OCAPEXTLOG(ARR4("Queued recording of mission",GVAR(missionName),"with tag",_save GVAR(captureFrameNo) = 0; publicVariable QGVAR(captureFrameNo); GVAR(startTime) = nil; +// Clients watch startTime to know a new recording began and their snapshot chain restarted. +publicVariable QGVAR(startTime); +GVAR(playerSnapshotState) = createHashMap; +GVAR(zeusByUid) = createHashMap; +GVAR(zeusLastCamera) = createHashMap; +GVAR(zeusControlledUnits) = createHashMap; +GVAR(zeusPingLast) = createHashMap; +// These trackers survive export, but their entity ids belong to this recording. +GVAR(serviceBaseline) = createHashMap; +GVAR(serviceSessions) = createHashMap; +GVAR(serviceRearmSeen) = createHashMap; +GVAR(staticWeaponSnapshot) = createHashMap; +GVAR(staticDisassembleClaims) = []; +GVAR(staticWeaponSeen) = createHashMap; { _x setVariable [QGVARMAIN(isInitialized), nil]; _x setVariable [QGVARMAIN(exclude), nil]; _x setVariable [QGVARMAIN(id), nil]; _x setVariable [QGVARMAIN(unitType), nil]; + _x setVariable [QGVAR(lastZeusCuratorId), nil]; + _x setVariable [QGVAR(zeusId), nil]; } count (allUnits + allDeadMen + vehicles); GVAR(nextId) = 0; EGVAR(extension,sessionReady) = false; diff --git a/addons/recorder/fnc_init.sqf b/addons/recorder/fnc_init.sqf index 1d8c982..3106be4 100644 --- a/addons/recorder/fnc_init.sqf +++ b/addons/recorder/fnc_init.sqf @@ -57,6 +57,17 @@ GVAR(nextId) = 0; // Only an empty-server auto-save may arm the automatic restart path. GVAR(autoRestartAfterEmptyPending) = false; +/* + VARIABLE: OCAP_recorder_playerSnapshotState + Last full player-detail snapshot per ":", as + [frameNumber, payload, diffsSinceFullSnapshot]. Used to store follow-up + snapshots as diffs. [HashMap] +*/ +GVAR(playerSnapshotState) = createHashMap; +GVAR(zeusByUid) = createHashMap; +GVAR(zeusLastCamera) = createHashMap; +GVAR(zeusControlledUnits) = createHashMap; + // save static setting values so changes during a mission don't interrupt timeline @@ -132,6 +143,11 @@ call FUNC(addEventMission); // Add integration event handlers (chat, ACE, TFAR) call FUNC(addEventIntegrations); call FUNC(eh_fired_server); +call FUNC(trackTrenches); + +call FUNC(trackServicing); + +call FUNC(trackConstructions); call FUNC(telemetryLoop); [] spawn FUNC(getStaticObjects); @@ -141,7 +157,13 @@ call FUNC(telemetryLoop); private _pid = getPlayerID _x; [{ private _info = getUserInfo _this; - !isNil "_info" && {_info isEqualType [] && {count _info >= 11}} + !isNil "_info" && { + _info isEqualType [] && { + count _info >= 11 && { + !isNull (_info param [10, objNull, [objNull]]) + } + } + } }, { [_this, "connect"] call FUNC(adminUIcontrol); }, _pid, 30] call CBA_fnc_waitUntilAndExecute; diff --git a/addons/recorder/fnc_installPlayerTrackingClients.sqf b/addons/recorder/fnc_installPlayerTrackingClients.sqf new file mode 100644 index 0000000..d75697c --- /dev/null +++ b/addons/recorder/fnc_installPlayerTrackingClients.sqf @@ -0,0 +1,1906 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_installPlayerTrackingClients.sqf + +FUNCTION: OCAP_recorder_fnc_installPlayerTrackingClients + +Description: + Ships the whole player-detail sampler to every client, including late joiners. + + OCAP is a server-only addon, so clients have neither our CfgFunctions nor the + extension. Everything a client needs is therefore sent as one code block whose + helpers live in global variables named OCAP_recorder_client_*. Clients sample + only their own local unit and hand the result back to the server, which is the + trust boundary (see ). + +Parameters: + None + +Returns: + Nothing + +Examples: + > call FUNC(installPlayerTrackingClients); + +Public: + No + +Author: + IndigoFox +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +// Clients never load this PBO, so they never register our CBA settings. The +// server therefore publishes the current values under the same variable names +// the sampler already reads. Without this, a dedicated-server interval of 30 s +// is silently ignored and every client falls back to the hardcoded default. +if (isNil QGVAR(playerTrackingSettingNames)) then { + GVAR(playerTrackingSettingNames) = [ + QEGVAR(settings,trackPlayerInventory), + QEGVAR(settings,playerInventoryInterval), + QEGVAR(settings,trackPlayerInventoryOnDeath), + QEGVAR(settings,trackPlayerTraits), + QEGVAR(settings,trackPlayerMedical), + QEGVAR(settings,playerMedicalInterval), + QEGVAR(settings,playerMedicalCriticalInterval), + QEGVAR(settings,trackPlayerStamina), + QEGVAR(settings,playerStaminaInterval), + QEGVAR(settings,trackPlayerRadio), + QEGVAR(settings,playerRadioInterval), + QEGVAR(settings,trackPlayerSnapshotDiffs), + QEGVAR(settings,radioShortRangeMeters), + QEGVAR(settings,radioLongRangeMeters), + QEGVAR(settings,trackZeus), + QEGVAR(settings,zeusCameraInterval), + QEGVAR(settings,trackZeusPings), + QEGVAR(settings,trackZeusModules), + QEGVAR(settings,trackConstructions) + ]; + + GVAR(publishPlayerTrackingSettings) = { + { + missionNamespace setVariable [_x, missionNamespace getVariable _x, true]; + } forEach GVAR(playerTrackingSettingNames); + }; + + ["CBA_SettingChanged", { + params ["_setting"]; + if (_setting in GVAR(playerTrackingSettingNames)) then { + missionNamespace setVariable [_setting, missionNamespace getVariable _setting, true]; + }; + }] call CBA_fnc_addEventHandler; +}; + +call GVAR(publishPlayerTrackingSettings); + +{ + if (!hasInterface || {!isNil "OCAP_recorder_client_playerTrackingPFH"}) exitWith {}; + + OCAP_recorder_client_hasAceMedical = isClass (configFile >> "CfgPatches" >> "ace_medical_engine"); + OCAP_recorder_client_hasKatMedical = isClass (configFile >> "CfgPatches" >> "kat_main"); + OCAP_recorder_client_hasAceFatigue = isClass (configFile >> "CfgPatches" >> "ace_advanced_fatigue"); + OCAP_recorder_client_hasTfar = isClass (configFile >> "CfgPatches" >> "tfar_core"); + OCAP_recorder_client_hasAcre = isClass (configFile >> "CfgPatches" >> "acre_api"); + // The three ACE components that layer their own training level on top of an + // engine trait. Each is optional on its own, so they are checked one by one. + OCAP_recorder_client_hasAceMedicalTreatment = isClass (configFile >> "CfgPatches" >> "ace_medical_treatment"); + OCAP_recorder_client_hasAceRepair = isClass (configFile >> "CfgPatches" >> "ace_repair"); + OCAP_recorder_client_hasAceExplosives = isClass (configFile >> "CfgPatches" >> "ace_explosives"); + + // getAllUnitTraits reports the engine traits with its own capitalisation + // ("Medic", "UavHacker", "CamouflageCoef"), so they are matched lowercased and + // written under one fixed key each. Anything else is a custom trait a mission + // or mod added with setUnitTrait and is kept under its own name. + OCAP_recorder_client_traitKeys = createHashMapFromArray [ + ["medic", "medic"], + ["engineer", "engineer"], + ["explosivespecialist", "explosiveSpecialist"], + ["uavhacker", "uavHacker"], + ["camouflagecoef", "camouflageCoef"], + ["audiblecoef", "audibleCoef"], + ["loadcoef", "loadCoef"] + ]; + + // Everything that decides what a unit is allowed to do: the engine traits and, + // where ACE is loaded, the graded training ACE reads instead of them. The ACE + // values are read exactly the way ace_medical_treatment_fnc_isMedic, + // ace_repair_fnc_isEngineer and ace_common_fnc_isEOD read them, including the + // fall-through to the engine trait when the ACE variable was never set, so the + // recording shows the level the mod actually acted on. + // + // Traits ride inside the inventory snapshot: they are assigned with the + // loadout, change about as often, and the server's diff then carries them only + // when they change. + OCAP_recorder_client_buildTraits = { + params ["_unit"]; + + private _traits = createHashMap; + private _custom = createHashMap; + { + _x params [["_name", "", [""]], "_value"]; + if (_name isEqualTo "" || {isNil "_value"}) then {continue}; + // A custom trait can hold anything; only what JSON can carry is recorded. + if (!(_value isEqualType "" || {_value isEqualType 0} || {_value isEqualType false})) then {continue}; + private _key = OCAP_recorder_client_traitKeys get (toLower _name); + if (isNil "_key") then { + _custom set [_name, _value]; + } else { + _traits set [_key, _value]; + }; + } forEach getAllUnitTraits _unit; + if (count _custom > 0) then {_traits set ["custom", _custom]}; + + private _ace = createHashMap; + if (OCAP_recorder_client_hasAceMedicalTreatment) then { + // 0 none, 1 medic, 2 doctor. + private _level = _unit getVariable ["ace_medical_medicClass", parseNumber (_unit getUnitTrait "medic")]; + if (_level isEqualType false) then {_level = parseNumber _level}; + if (!(_level isEqualType 0)) then {_level = 0}; + _ace set ["medic", _level]; + }; + if (OCAP_recorder_client_hasAceRepair) then { + // 0 none, 1 engineer, 2 advanced engineer. ACE writes `true` for 1. + private _level = _unit getVariable ["ACE_IsEngineer", _unit getUnitTrait "engineer"]; + if (_level isEqualType false) then {_level = parseNumber _level}; + if (!(_level isEqualType 0)) then {_level = 0}; + _ace set ["engineer", _level]; + }; + if (OCAP_recorder_client_hasAceExplosives) then { + _ace set ["eod", (_unit getVariable ["ACE_isEOD", _unit getUnitTrait "explosiveSpecialist"]) in [1, true]]; + }; + if (count _ace > 0) then {_traits set ["ace", _ace]}; + + _traits + }; + + // ACE body part order. Every per-part array in ACE and KAT is indexed by this. + OCAP_recorder_client_bodyParts = ["head", "body", "leftarm", "rightarm", "leftleg", "rightleg"]; + + // Throwables are exactly the magazines the engine's Throw weapon can fire, which + // is how ACE's own arsenal separates grenades from ammunition. + OCAP_recorder_client_throwableClasses = []; + { + { + OCAP_recorder_client_throwableClasses pushBackUnique toLower _x; + } forEach getArray (configFile >> "CfgWeapons" >> "Throw" >> _x >> "magazines"); + } forEach getArray (configFile >> "CfgWeapons" >> "Throw" >> "muzzles"); + + // The web UI used to guess an item's category by substring-matching the `.paa` + // icon path the snapshot carried, which cost megabytes per recording for a + // string no browser can load. The category is resolved here from config instead + // and travels as a short label. "items" is the default and is left off the wire. + OCAP_recorder_client_itemCategoryCache = createHashMap; + OCAP_recorder_client_getItemCategory = { + params [["_className", "", [""]]]; + if (_className isEqualTo "") exitWith {"items"}; + + private _cached = OCAP_recorder_client_itemCategoryCache get _className; + if (!isNil "_cached") exitWith {_cached}; + + // ACE and KAM both flag every medical item with ACE_isMedicalItem, and ACE + // sets it on the two vanilla ones as well. Without ACE, name those directly. + private _category = "items"; + if (_className in ["FirstAidKit", "Medikit"]) then {_category = "medical"}; + { + private _config = configFile >> _x >> _className; + if (isClass _config && {getNumber (_config >> "ACE_isMedicalItem") == 1}) exitWith { + _category = "medical"; + }; + } forEach ["CfgWeapons", "CfgMagazines"]; + + if (_category isEqualTo "items") then { + switch (true) do { + case (toLower _className in OCAP_recorder_client_throwableClasses): {_category = "grenades"}; + case (isClass (configFile >> "CfgMagazines" >> _className)): {_category = "magazines"}; + }; + }; + + OCAP_recorder_client_itemCategoryCache set [_className, _category]; + _category + }; + + // An item's display name is worth recording, but `getText` resolves a + // stringtable reference in the *recording server's* language, which froze Czech + // item names into recordings the web UI then showed to viewers in every locale. + // + // `getTextRaw` hands back what the config actually holds. A mod author who + // typed the name in is giving us English, so that is used as-is. A "$STR_..." + // reference cannot be read in another language from SQF, so the name is derived + // from the class instead, which is English by construction. Either way nothing + // localized reaches the recording. + // + // Keep the derivation in step with ui/src/data/gearDisplayName.ts and + // python/convert_recording_gear.py, which derive the same label. + OCAP_recorder_client_displayNamePrefixes = [ + "uk3cb_baf_", "rhsgref_", "rhsusf_", "rhssaf_", "immersion_", + "uk3cb_", "acex_", "rksl_", "acre_", "tfar_", "ace_", "kat_", "cba_", + "rhs_", "cup_", "tfr_" + ]; + OCAP_recorder_client_displayNameCache = createHashMap; + + // The label an item falls back to when its config name is a stringtable + // reference: the class, cleaned up. Always English, because a class always is. + OCAP_recorder_client_deriveNameFromClass = { + params [["_className", "", [""]]]; + if (_className isEqualTo "") exitWith {""}; + + // TFAR clones each carried handheld into a per-instance class holding its + // channel state (TFAR_anprc152_54); the prototype is the name worth showing. + private _base = _className; + if (_base regexMatch "(?i)(tfar|tf)_.+_\d+") then { + _base = _base regexReplace ["_\d+$", ""]; + }; + + private _lower = toLower _base; + { + if ((_lower find _x) == 0 && {count _base > count _x}) exitWith { + _base = _base select [count _x]; + }; + } forEach OCAP_recorder_client_displayNamePrefixes; + + // "ItemMap", "ItemGPS": the Item prefix is engine bookkeeping, not a name. + // Code-point check, not regex: Arma regexMatch is case-insensitive by default. + private _itemCodes = toArray _base; + if ( + count _itemCodes > 4 && + {(_itemCodes select 0) == 73} && + {(_itemCodes select 1) == 116} && + {(_itemCodes select 2) == 101} && + {(_itemCodes select 3) == 109} && + {(_itemCodes select 4) >= 65 && (_itemCodes select 4) <= 90} + ) then { + _base = _base select [4]; + }; + + // Split on separators and at every lowercase-to-uppercase step, but never + // between a digit and a letter, so L85A2 stays a single word. + // + // Use toArray code points, not regexMatch. Arma regex is case-insensitive + // unless (?-i) is set, so [A-Z] and [a-z] both match every letter and + // "CableTie" was recorded as "C A B L E T I E". + private _words = []; + private _word = ""; + private _lastCode = 0; + { + if (_x == 32 || {_x == 45} || {_x == 95}) then { + if (_word isNotEqualTo "") then {_words pushBack _word; _word = ""}; + _lastCode = 0; + } else { + if (_word isNotEqualTo "" && {_x >= 65 && _x <= 90} && {_lastCode >= 97 && _lastCode <= 122}) then { + _words pushBack _word; + _word = ""; + }; + _word = _word + toString [_x]; + _lastCode = _x; + }; + } forEach toArray _base; + if (_word isNotEqualTo "") then {_words pushBack _word}; + + private _name = (_words apply { + // A word the mod author already capitalised is left exactly as written. + if (_x isNotEqualTo toLower _x) then { + _x + } else { + // anprc152 -> ANPRC152: a letter run followed by a digit run is a designator. + if (_x regexMatch "[A-Za-z]{2,6}\d{2,4}") then { + toUpper _x + } else { + (toUpper (_x select [0, 1])) + (_x select [1]) + }; + }; + }) joinString " "; + + if (_name isEqualTo "") then {_name = _className}; + _name + }; + + // The English display name for an item. Every gear entry gets one, so the + // recording carries names rather than leaving a reader to work them out. + // + // `getText` was the problem: it resolves a stringtable reference in the + // *recording server's* language, which is how Czech item names reached viewers + // in eight locales. `getTextRaw` returns what the config actually holds, and + // that answers one of two ways: + // + // - a literal, which is the text the mod author typed and so is English. On a + // real mission take this covers about 95% of the classes a player carries: + // "5.56mm 30rnd Tracer Mag", "40mm HE Grenade Round (L123)". + // - a "$STR_..." reference, which SQF can only read in the language the game + // is running. There is no way to ask for English, so the name is built from + // the class instead, which is English by construction: "ACE_elasticBandage" + // becomes "Elastic Bandage" and "ItemMap" becomes "Map". + // + // Either way the recording only ever holds English. + OCAP_recorder_client_gearDisplayName = { + params [["_className", "", [""]]]; + if (_className isEqualTo "") exitWith {""}; + + private _cached = OCAP_recorder_client_displayNameCache get _className; + if (!isNil "_cached") exitWith {_cached}; + + private _raw = ""; + { + private _config = configFile >> _x >> _className; + if (isClass _config) exitWith { + _raw = getTextRaw (_config >> "displayName"); + }; + } forEach ["CfgWeapons", "CfgMagazines", "CfgVehicles", "CfgGlasses"]; + + // getTextRaw hands back an array for a config entry written as one. + if (_raw isEqualType []) then {_raw = _raw param [0, ""]}; + if (!(_raw isEqualType "")) then {_raw = ""}; + _raw = trim _raw; + + private _name = if (_raw isEqualTo "" || {(_raw select [0, 1]) isEqualTo "$"}) then { + [_className] call OCAP_recorder_client_deriveNameFromClass + } else { + _raw + }; + + OCAP_recorder_client_displayNameCache set [_className, _name]; + _name + }; + + // Sets "name" on a gear entry. Empty slots have no class and so get no name. + OCAP_recorder_client_setDisplayName = { + params ["_entry", ["_className", "", [""]]]; + private _name = [_className] call OCAP_recorder_client_gearDisplayName; + if (_name isNotEqualTo "") then {_entry set ["name", _name]}; + _entry + }; + + OCAP_recorder_client_buildInventorySnapshot = { + params ["_unit", "_reason"]; + + // The category only reaches the UI for the lists it groups: container cargo + // and magazines. Weapons, attachments and assigned items get their own + // sections, so paying for a category there would be waste. + private _describeItems = { + params ["_items", ["_withCategory", false]]; + + // One counting pass, in first-seen order -- the order arrayIntersect + // gives. Counting with `{...} count _items` once per class re-walked the + // whole container for every distinct item in it. + private _counts = createHashMap; + private _order = []; + { + private _seen = _counts getOrDefault [_x, 0]; + if (_seen == 0) then {_order pushBack _x}; + _counts set [_x, _seen + 1]; + } forEach _items; + + _order apply { + private _entry = [createHashMapFromArray [ + ["class", _x], + ["count", _counts get _x] + ], _x] call OCAP_recorder_client_setDisplayName; + if (_withCategory) then { + private _category = [_x] call OCAP_recorder_client_getItemCategory; + if (_category isNotEqualTo "items") then {_entry set ["cat", _category]}; + }; + _entry + } + }; + + private _describeContainer = { + params ["_className", "_items"]; + [createHashMapFromArray [ + ["class", _className], + ["items", [_items, true] call _describeItems] + ], _className] call OCAP_recorder_client_setDisplayName + }; + + private _describeWorn = { + params ["_className"]; + [createHashMapFromArray [["class", _className]], _className] call + OCAP_recorder_client_setDisplayName + }; + + private _weapons = []; + { + _x params ["_slot", "_className", "_attachments"]; + if (_className isNotEqualTo "") then { + _weapons pushBack ([createHashMapFromArray [ + ["class", _className], + ["slot", _slot], + ["attachments", [_attachments select {_x isNotEqualTo ""}] call _describeItems] + ], _className] call OCAP_recorder_client_setDisplayName); + }; + } forEach [ + ["primary", primaryWeapon _unit, primaryWeaponItems _unit], + ["handgun", handgunWeapon _unit, handgunItems _unit], + ["launcher", secondaryWeapon _unit, secondaryWeaponItems _unit], + ["binocular", binocular _unit, []] + ]; + + // magazinesAmmoFull entries are [class, ammoCount, isLoaded, ...]. Totalled + // per class in one pass, in first-seen order; selecting the matching + // magazines out of the full list once per class re-walked it every time. + private _magazineStats = createHashMap; + private _magazineOrder = []; + { + private _className = _x param [0, ""]; + if (_className isEqualTo "") then {continue}; + private _stats = _magazineStats get _className; + if (isNil "_stats") then { + _stats = [0, 0, 0]; + _magazineStats set [_className, _stats]; + _magazineOrder pushBack _className; + }; + _stats set [0, (_stats select 0) + 1]; + _stats set [1, (_stats select 1) + (_x param [1, 0])]; + if (_x param [2, false]) then {_stats set [2, (_stats select 2) + 1]}; + } forEach magazinesAmmoFull _unit; + + private _magazines = _magazineOrder apply { + (_magazineStats get _x) params ["_count", "_totalRounds", "_loadedCount"]; + private _entry = createHashMapFromArray [ + ["class", _x], + ["count", _count], + ["totalRounds", _totalRounds], + ["loadedCount", _loadedCount] + ]; + [_entry, _x] call OCAP_recorder_client_setDisplayName; + private _category = [_x] call OCAP_recorder_client_getItemCategory; + if (_category isNotEqualTo "items") then {_entry set ["cat", _category]}; + _entry + }; + + private _result = createHashMapFromArray [ + ["reason", _reason], + // loadAbs is in engine mass units (a tenth of a pound each), not kilograms. + ["massUnits", loadAbs _unit], + ["load", load _unit], + ["uniform", [uniform _unit, uniformItems _unit] call _describeContainer], + ["vest", [vest _unit, vestItems _unit] call _describeContainer], + ["backpack", [backpack _unit, backpackItems _unit] call _describeContainer], + ["headgear", [headgear _unit] call _describeWorn], + ["goggles", [goggles _unit] call _describeWorn], + ["weapons", _weapons], + ["magazines", _magazines], + ["assignedItems", [assignedItems _unit] call _describeItems] + ]; + + if (missionNamespace getVariable [QEGVAR(settings,trackPlayerTraits), true]) then { + _result set ["traits", [_unit] call OCAP_recorder_client_buildTraits]; + }; + + _result + }; + + OCAP_recorder_client_aceWoundTypes = [ + "Abrasion", "Avulsion", "Contusion", "Crush", "Cut", + "Laceration", "Velocity Wound", "Puncture Wound", "Thermal Burn" + ]; + OCAP_recorder_client_aceWoundSizes = ["Minor", "Medium", "Large"]; + + // ACE encodes a wound class as woundTypeIndex * 10 + sizeCategory. + OCAP_recorder_client_aceWoundName = { + params [["_classId", -1, [0]]]; + if (_classId < 0) exitWith {"Wound"}; + private _type = OCAP_recorder_client_aceWoundTypes param [floor (_classId / 10), "Wound"]; + private _size = OCAP_recorder_client_aceWoundSizes param [_classId mod 10, ""]; + if (_size isEqualTo "") exitWith {_type}; + format ["%1 %2", _size, _type] + }; + + OCAP_recorder_client_partItem = { + params ["_kind", "_name", ["_count", 0], ["_detail", ""]]; + private _item = createHashMapFromArray [["kind", _kind], ["name", _name]]; + if (_count > 0) then {_item set ["count", _count]}; + if (_detail isNotEqualTo "") then {_item set ["detail", _detail]}; + _item + }; + + // ACE stores [_message, _timeStamp, _arguments, _logType] on the patient and + // localizes at display time. Do the same here so the recording is readable. + OCAP_recorder_client_formatMedicalLog = { + params ["_unit", "_type"]; + private _log = _unit getVariable [format ["ace_medical_log_%1", _type], []]; + _log apply { + _x params ["_message", "_timeStamp", ["_arguments", []]]; + if (_message isEqualType "" && {isLocalized _message}) then { + _message = localize _message; + }; + private _args = _arguments apply { + if (_x isEqualType "" && {isLocalized _x}) then {localize _x} else {_x} + }; + private _text = if (_args isEqualTo []) then {_message} else {format ([_message] + _args)}; + createHashMapFromArray [["time", _timeStamp], ["text", _text]] + } + }; + + /* + Everything that mods attach to, insert into or inject at a specific body part is + collected here. Each collector is [id, code]; the code receives the unit and + returns [[bodyPartIndex, item], ...]. Supporting another treatment or another + medical mod means appending one collector - the snapshot builder and the web UI + both render whatever comes out without knowing what it is. + */ + OCAP_recorder_client_medicalPartCollectors = []; + + if (OCAP_recorder_client_hasAceMedical) then { + OCAP_recorder_client_medicalPartCollectors append [ + ["aceTourniquet", { + params ["_unit"]; + private _result = []; + { + if (_x > 0) then { + _result pushBack [_forEachIndex, ["tourniquet", "Tourniquet"] call OCAP_recorder_client_partItem]; + }; + } forEach (_unit getVariable ["ace_medical_tourniquets", [0, 0, 0, 0, 0, 0]]); + _result + }], + + ["aceFracture", { + params ["_unit"]; + private _result = []; + { + // 1 is an untreated fracture, -1 a splinted one, 0 nothing. + switch (true) do { + case (_x > 0): { + _result pushBack [_forEachIndex, ["fracture", "Fracture"] call OCAP_recorder_client_partItem]; + }; + case (_x < 0): { + _result pushBack [_forEachIndex, ["splint", "Splint"] call OCAP_recorder_client_partItem]; + }; + default {}; + }; + } forEach (_unit getVariable ["ace_medical_fractures", [0, 0, 0, 0, 0, 0]]); + _result + }], + + ["aceOpenWound", { + params ["_unit"]; + private _result = []; + { + private _partIndex = OCAP_recorder_client_bodyParts find _x; + private _wounds = _y; + if (_partIndex >= 0) then { + private _aggregated = createHashMap; + { + _x params [["_classId", -1], ["_amountOf", 0], ["_bloodloss", 0]]; + if (_amountOf > 0) then { + private _name = [_classId] call OCAP_recorder_client_aceWoundName; + private _totals = _aggregated getOrDefault [_name, [0, 0], true]; + _totals set [0, (_totals select 0) + _amountOf]; + _totals set [1, (_totals select 1) + _bloodloss]; + }; + } forEach _wounds; + { + private _detail = ""; + if ((_y select 1) > 0) then {_detail = format ["bleeding %1", (_y select 1) toFixed 3]}; + _result pushBack [_partIndex, ["wound", _x, round (_y select 0), _detail] call OCAP_recorder_client_partItem]; + } forEach _aggregated; + }; + } forEach (_unit getVariable ["ace_medical_openWounds", createHashMap]); + _result + }], + + ["aceBandagedWound", { + params ["_unit"]; + private _result = []; + { + private _partIndex = OCAP_recorder_client_bodyParts find _x; + private _wounds = _y; + if (_partIndex >= 0) then { + private _count = 0; + {_count = _count + (_x param [1, 0])} forEach _wounds; + if (_count > 0) then { + _result pushBack [_partIndex, ["bandage", "Bandaged wound", round _count] call OCAP_recorder_client_partItem]; + }; + }; + } forEach (_unit getVariable ["ace_medical_bandagedWounds", createHashMap]); + _result + }], + + ["aceStitchedWound", { + params ["_unit"]; + private _result = []; + { + private _partIndex = OCAP_recorder_client_bodyParts find _x; + private _wounds = _y; + if (_partIndex >= 0) then { + private _count = 0; + {_count = _count + (_x param [1, 0])} forEach _wounds; + if (_count > 0) then { + _result pushBack [_partIndex, ["suture", "Stitched wound", round _count] call OCAP_recorder_client_partItem]; + }; + }; + } forEach (_unit getVariable ["ace_medical_stitchedWounds", createHashMap]); + _result + }], + + ["aceIv", { + params ["_unit"]; + private _result = []; + { + _x params [["_volume", 0], ["_fluid", ""], ["_partIndex", -1], "", "", ["_itemClass", ""]]; + if (_partIndex >= 0) then { + private _name = _fluid; + if (_itemClass isNotEqualTo "") then { + _name = [_itemClass] call OCAP_recorder_client_gearDisplayName; + }; + _result pushBack [_partIndex, ["iv", _name, 0, format ["%1 ml left", round _volume]] call OCAP_recorder_client_partItem]; + }; + } forEach (_unit getVariable ["ace_medical_ivBags", []]); + _result + }], + + ["aceOccludedMedication", { + params ["_unit"]; + private _result = []; + { + _x params [["_partIndex", -1], ["_className", ""]]; + if (_partIndex >= 0) then { + _result pushBack [_partIndex, ["medicationBlocked", _className, 0, "blocked by tourniquet"] call OCAP_recorder_client_partItem]; + }; + } forEach (_unit getVariable ["ace_medical_occludedMedications", []]); + _result + }] + ]; + }; + + if (OCAP_recorder_client_hasKatMedical) then { + OCAP_recorder_client_katIvAccess = ["", "IO access", "IV access 16g", "IV access with block", "IV access flushed"]; + + OCAP_recorder_client_medicalPartCollectors append [ + ["katIvAccess", { + params ["_unit"]; + private _result = []; + { + private _label = OCAP_recorder_client_katIvAccess param [round _x, ""]; + if (_label isNotEqualTo "") then { + _result pushBack [_forEachIndex, ["ivAccess", _label] call OCAP_recorder_client_partItem]; + }; + } forEach (_unit getVariable ["kat_pharma_IV", [0, 0, 0, 0, 0, 0]]); + _result + }], + + ["katSurgicalFracture", { + params ["_unit"]; + private _result = []; + { + if (_x > 0) then { + _result pushBack [_forEachIndex, ["surgicalFracture", "Fracture (surgical)", 0, format ["stage %1", _x]] call OCAP_recorder_client_partItem]; + }; + } forEach (_unit getVariable ["kat_surgery_fractures", [0, 0, 0, 0, 0, 0]]); + _result + }], + + ["katChestSeal", { + params ["_unit"]; + if !(_unit getVariable ["kat_breathing_activeChestSeal", false]) exitWith {[]}; + // The torso is the only part a chest seal can go on. + [[1, ["chestSeal", "Chest seal"] call OCAP_recorder_client_partItem]] + }], + + ["katAirway", { + params ["_unit"]; + private _item = _unit getVariable ["kat_airway_airway_item", ""]; + if (_item isEqualTo "") exitWith {[]}; + [[0, ["airway", _item] call OCAP_recorder_client_partItem]] + }], + + ["katOverstretch", { + params ["_unit"]; + if !(_unit getVariable ["kat_airway_overstretch", false]) exitWith {[]}; + [[0, ["airway", "Head-tilt chin-lift"] call OCAP_recorder_client_partItem]] + }], + + ["katNasalCannula", { + params ["_unit"]; + if !(_unit getVariable ["kat_breathing_nasalCannula", false]) exitWith {[]}; + [[0, ["oxygen", "Nasal cannula"] call OCAP_recorder_client_partItem]] + }], + + ["katPulseOx", { + params ["_unit"]; + private _result = []; + { + if (_x > 0) then { + _result pushBack [_forEachIndex + 2, ["monitor", "Pulse oximeter"] call OCAP_recorder_client_partItem]; + }; + } forEach (_unit getVariable ["kat_breathing_PulseOximeter_Attached", [0, 0]]); + _result + }], + + ["katSurgicalBlock", { + params ["_unit"]; + private _result = []; + { + if (_x > 0) then { + _result pushBack [_forEachIndex, ["reboa", "REBOA / surgical block"] call OCAP_recorder_client_partItem]; + }; + } forEach (_unit getVariable ["kat_surgery_surgicalBlock", [0, 0, 0, 0, 0, 0]]); + _result + }] + ]; + }; + + /* + Systemic treatments that are not tied to a body part. Same [id, code] shape as + the per-part collectors, but the code returns a plain list of items. + */ + OCAP_recorder_client_medicalTreatmentCollectors = []; + + if (OCAP_recorder_client_hasAceMedical) then { + OCAP_recorder_client_medicalTreatmentCollectors pushBack ["aceMedication", { + params ["_unit"]; + // ACE stores [class, startedAt, timeToMax, maxTimeInSystem, ...]. Remaining + // time is the useful bit on replay; count is aggregated per class. + private _aggregated = createHashMap; + { + _x params [["_className", ""], ["_startedAt", 0], "", ["_maxTime", 0]]; + if (_className isEqualTo "") then {continue}; + private _remaining = (_maxTime - (CBA_missionTime - _startedAt)) max 0; + private _totals = _aggregated getOrDefault [_className, [0, 0], true]; + _totals set [0, (_totals select 0) + 1]; + _totals set [1, (_totals select 1) max _remaining]; + } forEach (_unit getVariable ["ace_medical_medications", []]); + private _result = []; + { + private _name = _x; + private _itemClass = if (isClass (configFile >> "CfgWeapons" >> _x)) then {_x} else { + format ["ACE_%1", _x] + }; + if (isClass (configFile >> "CfgWeapons" >> _itemClass)) then { + _name = [_itemClass] call OCAP_recorder_client_gearDisplayName; + }; + private _detail = ""; + if ((_y select 1) > 0) then {_detail = format ["%1 s left", round (_y select 1)]}; + _result pushBack (["medication", _name, _y select 0, _detail] call OCAP_recorder_client_partItem); + } forEach _aggregated; + _result + }]; + }; + + OCAP_recorder_client_buildMedicalSnapshot = { + params ["_unit", "_reason"]; + + private _partItems = OCAP_recorder_client_bodyParts apply {[]}; + { + _x params ["_id", "_collector"]; + { + _x params ["_index", "_item"]; + if (_index >= 0 && {_index < count _partItems}) then { + (_partItems select _index) pushBack _item; + }; + } forEach ([_unit] call _collector); + } forEach OCAP_recorder_client_medicalPartCollectors; + + private _treatments = []; + { + _x params ["_id", "_collector"]; + _treatments append ([_unit] call _collector); + } forEach OCAP_recorder_client_medicalTreatmentCollectors; + + // ACE is the only source of per-part damage; without it a part carries items only. + private _bodyPartDamage = []; + if (OCAP_recorder_client_hasAceMedical) then { + _bodyPartDamage = _unit getVariable ["ace_medical_bodyPartDamage", [0, 0, 0, 0, 0, 0]]; + }; + private _bodyParts = []; + { + private _entry = createHashMapFromArray [["part", _x], ["items", _partItems select _forEachIndex]]; + if (_bodyPartDamage isNotEqualTo []) then { + _entry set ["damage", _bodyPartDamage param [_forEachIndex, 0]]; + }; + _bodyParts pushBack _entry; + } forEach OCAP_recorder_client_bodyParts; + + private _hitPoints = getAllHitPointsDamage _unit; + private _result = createHashMapFromArray [ + ["reason", _reason], + ["bodyParts", _bodyParts], + ["treatments", _treatments], + ["vanilla", createHashMapFromArray [ + ["damage", damage _unit], + ["alive", alive _unit], + ["lifeState", lifeState _unit], + ["incapacitated", lifeState _unit isEqualTo "INCAPACITATED"], + ["hitPointNames", _hitPoints param [0, []]], + ["hitPointDamage", _hitPoints param [2, []]] + ]] + ]; + + if (OCAP_recorder_client_hasAceMedical) then { + _result set ["ace", createHashMapFromArray [ + ["bloodVolume", _unit getVariable ["ace_medical_bloodVolume", 6]], + ["heartRate", _unit getVariable ["ace_medical_heartRate", 80]], + ["bloodPressure", _unit getVariable ["ace_medical_bloodPressure", [80, 120]]], + ["pain", _unit getVariable ["ace_medical_pain", 0]], + ["painSuppress", _unit getVariable ["ace_medical_painSuppress", 0]], + ["unconscious", _unit getVariable ["ACE_isUnconscious", false]], + ["cardiacArrest", _unit getVariable ["ace_medical_inCardiacArrest", false]], + ["bleedingRate", _unit getVariable ["ace_medical_woundBleeding", 0]], + // 0 healthy through 4 massive blood loss. + ["hemorrhage", _unit getVariable ["ace_medical_hemorrhage", 0]], + ["limping", _unit getVariable ["ace_medical_isLimping", false]], + ["triageLevel", _unit getVariable ["ace_medical_triageLevel", 0]] + ]]; + if (!OCAP_recorder_client_hasKatMedical) then { + (_result get "ace") set ["spo2", _unit getVariable ["ace_medical_spo2", 97]]; + }; + _result set ["activity", [_unit, "activity"] call OCAP_recorder_client_formatMedicalLog]; + _result set ["quickView", [_unit, "quick_view"] call OCAP_recorder_client_formatMedicalLog]; + } else { + _result set ["bodyParts", OCAP_recorder_client_bodyParts apply { + createHashMapFromArray [["part", _x], ["items", _partItems select _forEachIndex]] + }]; + }; + + if (OCAP_recorder_client_hasKatMedical) then { + private _bloodGas = _unit getVariable ["kat_circulation_bloodGas", [40, 90, 0.96, 24, 7.4, 37]]; + private _bodyFluid = _unit getVariable ["kat_circulation_bodyFluid", [2700, 3300, 500, 10000, 6000]]; + _result set ["kat", createHashMapFromArray [ + ["airwaySecured", _unit getVariable ["kat_airway_airway", false]], + ["airwayItem", _unit getVariable ["kat_airway_airway_item", ""]], + ["airwayObstruction", _unit getVariable ["kat_airway_obstruction", false]], + ["airwayOccluded", _unit getVariable ["kat_airway_occluded", false]], + ["pneumothorax", _unit getVariable ["kat_breathing_pneumothorax", 0]], + ["hemopneumothorax", _unit getVariable ["kat_breathing_hemopneumothorax", false]], + ["tensionPneumothorax", _unit getVariable ["kat_breathing_tensionpneumothorax", false]], + ["chestSeal", _unit getVariable ["kat_breathing_activeChestSeal", false]], + ["breathRate", _unit getVariable ["kat_breathing_breathRate", 15]], + // Blood gas is [PaCO2, PaO2, O2 saturation, HCO3, pH, EtCO2]. + ["spo2", (_bloodGas param [2, 0.96]) * 100], + ["etco2", _bloodGas param [5, 37]], + ["paco2", _bloodGas param [0, 40]], + ["pao2", _bloodGas param [1, 90]], + ["ph", _bloodGas param [4, 7.4]], + ["bloodMl", _bodyFluid param [4, 6000]], + ["bloodType", _unit getVariable ["kat_circulation_bloodtype", ""]], + ["internalBleeding", _unit getVariable ["kat_circulation_internalBleeding", 0]] + ]]; + }; + + _result + }; + + // Stance rides along with the stamina snapshot because posture is what the + // stamina drain has to be read against. It is written as a small integer enum + // so a value that is sampled this often costs one number instead of a string: + // 0 unknown (in a vehicle, or an animation with no stance), 1 stand, 2 crouch, 3 prone. + OCAP_recorder_client_stanceCode = { + params ["_unit"]; + switch (toUpper stance _unit) do { + case "STAND": {1}; + case "CROUCH": {2}; + case "PRONE": {3}; + default {0}; + }; + }; + + OCAP_recorder_client_buildStaminaSnapshot = { + params ["_unit", "_reason"]; + + // getStamina is seconds of sprint left, and the ceiling shrinks with carried load, + // so the raw value is only readable next to the maximum it was measured against. + private _staminaDuration = getNumber (configFile >> "CfgVehicles" >> "CAManBase" >> "staminaDuration"); + if (_staminaDuration <= 0) then {_staminaDuration = 60}; + private _load = load _unit; + + private _result = createHashMapFromArray [ + ["reason", _reason], + ["vanilla", createHashMapFromArray [ + ["stamina", getStamina _unit], + ["staminaMax", _staminaDuration * ((1 - _load) max 0)], + ["fatigue", getFatigue _unit], + ["load", _load], + ["massUnits", loadAbs _unit], + ["stance", [_unit] call OCAP_recorder_client_stanceCode] + ]] + ]; + + if (OCAP_recorder_client_hasAceFatigue) then { + _result set ["ace", createHashMapFromArray [ + ["anaerobicReserve", missionNamespace getVariable ["ace_advanced_fatigue_anReservePercentage", 1]], + ["aerobicReserve", missionNamespace getVariable ["ace_advanced_fatigue_aeReservePercentage", 1]], + ["muscleDamage", missionNamespace getVariable ["ace_advanced_fatigue_muscleDamage", 0]], + ["performanceFactor", _unit getVariable ["ace_advanced_fatigue_performanceFactor", missionNamespace getVariable ["ace_advanced_fatigue_performanceFactor", 1]]] + ]]; + }; + + _result + }; + + // Walk tf_parent / config inheritance so an instanced radio like TFAR_anprc152_1 + // still yields the prototype's tf_range. + OCAP_recorder_client_configNumberInherited = { + params ["_config", "_property"]; + private _cursor = _config; + private _value = 0; + while {isClass _cursor && {_value <= 0}} do { + if (isNumber (_cursor >> _property)) then { + _value = getNumber (_cursor >> _property); + }; + if (_value <= 0) then { + private _parentName = getText (_cursor >> "tf_parent"); + if (_parentName isNotEqualTo "" && {_parentName isNotEqualTo configName _cursor}) then { + private _parentConfig = configFile >> "CfgWeapons" >> _parentName; + if (!isClass _parentConfig) then {_parentConfig = configFile >> "CfgVehicles" >> _parentName}; + _cursor = _parentConfig; + } else { + _cursor = inheritsFrom _cursor; + }; + }; + }; + _value + }; + + OCAP_recorder_client_configTextInherited = { + params ["_config", "_property"]; + private _cursor = _config; + private _value = ""; + while {isClass _cursor && {_value isEqualTo ""}} do { + if (isText (_cursor >> _property)) then { + _value = getText (_cursor >> _property); + }; + if (_value isEqualTo "") then { + private _parentName = getText (_cursor >> "tf_parent"); + if (_parentName isNotEqualTo "" && {_parentName isNotEqualTo configName _cursor}) then { + private _parentConfig = configFile >> "CfgWeapons" >> _parentName; + if (!isClass _parentConfig) then {_parentConfig = configFile >> "CfgVehicles" >> _parentName}; + _cursor = _parentConfig; + } else { + _cursor = inheritsFrom _cursor; + }; + }; + }; + _value + }; + + OCAP_recorder_client_tfarMultiplier = { + if (!isNil "TFAR_fnc_getTransmittingDistanceMultiplicator") exitWith { + call TFAR_fnc_getTransmittingDistanceMultiplicator + }; + if (!isNil "TFAR_currentUnit") exitWith { + TFAR_currentUnit getVariable ["tf_sendingDistanceMultiplicator", 1] + }; + 1 + }; + + // Same lookup TFAR uses when it draws a transmit: the radio's own tf_range, + // scaled by the mission sending-distance multiplicator. + OCAP_recorder_client_getTfarRange = { + params ["_radio", "_kind", "_fallback"]; + private _range = 0; + if (_kind isEqualTo "SW") then { + if (!isNil "TFAR_fnc_getWeaponConfigProperty") then { + _range = [_radio, "tf_range", 0] call TFAR_fnc_getWeaponConfigProperty; + }; + if (_range <= 0 && {_radio isEqualType ""}) then { + _range = [configFile >> "CfgWeapons" >> _radio, "tf_range"] call OCAP_recorder_client_configNumberInherited; + }; + } else { + if (!isNil "TFAR_fnc_getLrRadioProperty") then { + _range = [_radio, "tf_range", 0] call TFAR_fnc_getLrRadioProperty; + }; + if (_range <= 0) then { + private _className = if (_radio isEqualType "") then {_radio} else {typeOf _radio}; + _range = [configFile >> "CfgVehicles" >> _className, "tf_range"] call OCAP_recorder_client_configNumberInherited; + }; + }; + if (_range <= 0) then {_range = _fallback}; + _range * (call OCAP_recorder_client_tfarMultiplier) + }; + + // Live TFAR settings can cache an empty code if the side variable was not + // ready when the radio was first programmed. Re-read the config variable + // unless the mission has encryption turned off. + OCAP_recorder_client_getTfarCode = { + params ["_radio", "_kind"]; + if (missionNamespace getVariable ["TFAR_radioCodesDisabled", false]) exitWith {""}; + + private _code = ""; + if (_kind isEqualTo "SW") then { + if (!isNil "TFAR_fnc_getSwRadioCode") then {_code = _radio call TFAR_fnc_getSwRadioCode}; + } else { + if (!isNil "TFAR_fnc_getLrRadioCode") then {_code = _radio call TFAR_fnc_getLrRadioCode}; + }; + if (!(_code isEqualType "") || {_code isNotEqualTo ""}) exitWith { + if (_code isEqualType "") then {_code} else {str _code} + }; + + private _codeVar = ""; + if (_kind isEqualTo "SW") then { + if (!isNil "TFAR_fnc_getWeaponConfigProperty") then { + _codeVar = [_radio, "tf_encryptionCode", ""] call TFAR_fnc_getWeaponConfigProperty; + }; + if (!(_codeVar isEqualType "") || {_codeVar isEqualTo ""}) then { + if (_radio isEqualType "") then { + _codeVar = [configFile >> "CfgWeapons" >> _radio, "tf_encryptionCode"] call OCAP_recorder_client_configTextInherited; + }; + }; + } else { + // The live-code API above takes [object, qualifier], but the property API + // takes only the object. Class names still use the config fallback below. + private _radioObject = if (_radio isEqualType []) then {_radio param [0, objNull]} else {_radio}; + private _className = if (_radioObject isEqualType "") then {_radioObject} else { + if (_radioObject isEqualType objNull) then {typeOf _radioObject} else {""} + }; + if (!isNil "TFAR_fnc_getLrRadioProperty" && {_radioObject isEqualType objNull} && {!isNull _radioObject}) then { + _codeVar = [_radioObject, "tf_encryptionCode", ""] call TFAR_fnc_getLrRadioProperty; + }; + if (!(_codeVar isEqualType "") || {_codeVar isEqualTo ""}) then { + _codeVar = [configFile >> "CfgVehicles" >> _className, "tf_encryptionCode"] call OCAP_recorder_client_configTextInherited; + }; + }; + if (!(_codeVar isEqualType "") || {_codeVar isEqualTo ""}) exitWith {""}; + if (_codeVar isEqualTo "tf_guer_radio_code") then {_codeVar = "tf_independent_radio_code"}; + missionNamespace getVariable [_codeVar, _codeVar] + }; + + // ACRE publishes transmit power, not a circle. 5 W (PRC-152 default) maps to + // 5 km, the same scale as a TFAR 152, via free-space sqrt(power). + OCAP_recorder_client_getAcreRange = { + params ["_power", "_fallback"]; + if (_power > 0) exitWith {70.710678118 * sqrt _power}; + _fallback + }; + + // One carried TFAR SW (and its additional channel, if set). Called for every + // radio TFAR_fnc_radiosList returns, not only the currently selected one. + OCAP_recorder_client_pushTfarSwRadio = { + params ["_radios", "_sw", "_swFallback", "_isActive"]; + if (!(_sw isEqualType "") || {_sw isEqualTo ""} || {isNil "TFAR_fnc_getSwChannel"}) exitWith {}; + + private _channel = _sw call TFAR_fnc_getSwChannel; + private _additional = -1; + if (!isNil "TFAR_fnc_getAdditionalSwChannel") then { + _additional = _sw call TFAR_fnc_getAdditionalSwChannel; + }; + private _entry = [createHashMapFromArray [["class", _sw]], _sw] call + OCAP_recorder_client_setDisplayName; + _entry set ["mod", "TFAR"]; + _entry set ["type", "SW"]; + _entry set ["channel", _channel + 1]; + _entry set ["frequency", parseNumber ([_sw, _channel + 1] call TFAR_fnc_getChannelFrequency)]; + _entry set ["code", [_sw, "SW"] call OCAP_recorder_client_getTfarCode]; + _entry set ["rangeMeters", [_sw, "SW", _swFallback] call OCAP_recorder_client_getTfarRange]; + _entry set ["additional", false]; + _entry set ["active", _isActive]; + _radios pushBack _entry; + + if (!isNil "_additional" && {_additional > -1}) then { + private _additionalEntry = +_entry; + _additionalEntry set ["channel", _additional + 1]; + _additionalEntry set ["frequency", parseNumber ([_sw, _additional + 1] call TFAR_fnc_getChannelFrequency)]; + _additionalEntry set ["additional", true]; + _additionalEntry set ["active", false]; + _radios pushBack _additionalEntry; + }; + }; + + OCAP_recorder_client_buildRadioSnapshot = { + params ["_unit", "_reason"]; + private _radios = []; + + if (OCAP_recorder_client_hasTfar) then { + private _swFallback = missionNamespace getVariable [QEGVAR(settings,radioShortRangeMeters), 5000]; + private _lrFallback = missionNamespace getVariable [QEGVAR(settings,radioLongRangeMeters), 20000]; + + private _activeSw = ""; + if (!isNil "TFAR_fnc_activeSwRadio") then { + private _sw = call TFAR_fnc_activeSwRadio; + if (!isNil "_sw" && {_sw isEqualType ""} && {_sw isNotEqualTo ""}) then { + _activeSw = _sw; + }; + }; + + private _swRadios = []; + if (!isNil "TFAR_fnc_radiosList") then { + _swRadios = _unit call TFAR_fnc_radiosList; + }; + if (!(_swRadios isEqualType [])) then {_swRadios = []}; + if (_swRadios isEqualTo [] && {_activeSw isNotEqualTo ""}) then { + _swRadios = [_activeSw]; + }; + { + [_radios, _x, _swFallback, _x isEqualTo _activeSw] call OCAP_recorder_client_pushTfarSwRadio; + } forEach _swRadios; + + private _lr = []; + if (!isNil "TFAR_fnc_activeLrRadio") then { + _lr = call TFAR_fnc_activeLrRadio; + }; + if (!isNil "_lr" && {_lr isEqualType []} && {count _lr >= 2}) then { + private _channel = _lr call TFAR_fnc_getLrChannel; + private _additional = _lr call TFAR_fnc_getAdditionalLrChannel; + private _radioObject = _lr select 0; + private _className = if (_radioObject isEqualType "") then {_radioObject} else {typeOf _radioObject}; + private _entry = [createHashMapFromArray [["class", _className]], _className] call + OCAP_recorder_client_setDisplayName; + _entry set ["mod", "TFAR"]; + _entry set ["type", "LR"]; + _entry set ["channel", _channel + 1]; + _entry set ["frequency", parseNumber ([_lr, _channel + 1] call TFAR_fnc_getChannelFrequency)]; + _entry set ["code", [_lr, "LR"] call OCAP_recorder_client_getTfarCode]; + _entry set ["rangeMeters", [_radioObject, "LR", _lrFallback] call OCAP_recorder_client_getTfarRange]; + _entry set ["additional", false]; + _entry set ["active", true]; + _radios pushBack _entry; + + if (!isNil "_additional" && {_additional > -1}) then { + private _additionalEntry = +_entry; + _additionalEntry set ["channel", _additional + 1]; + _additionalEntry set ["frequency", parseNumber ([_lr, _additional + 1] call TFAR_fnc_getChannelFrequency)]; + _additionalEntry set ["additional", true]; + _additionalEntry set ["active", false]; + _radios pushBack _additionalEntry; + }; + }; + }; + + // ACRE is initialised per-client. Skip until every radio on the unit has an ID. + if (OCAP_recorder_client_hasAcre && {!isNil "acre_api_fnc_getCurrentRadioList"} && { + isNil "acre_api_fnc_isInitialized" || {[] call acre_api_fnc_isInitialized} + }) then { + private _srFallback = missionNamespace getVariable [QEGVAR(settings,radioShortRangeMeters), 5000]; + private _lrFallback = missionNamespace getVariable [QEGVAR(settings,radioLongRangeMeters), 20000]; + private _active = ""; + if (!isNil "acre_api_fnc_getCurrentRadio") then {_active = call acre_api_fnc_getCurrentRadio}; + + { + private _radioId = _x; + private _baseClass = _radioId; + if (!isNil "acre_api_fnc_getBaseRadio") then { + _baseClass = [_radioId] call acre_api_fnc_getBaseRadio; + }; + // ACRE's own getDisplayName resolves through the client's stringtable, + // so it is not asked; the name comes from config the same way as any + // other item, which keeps it out of the server's language. + private _entry = [createHashMapFromArray [["class", _baseClass]], _baseClass] call + OCAP_recorder_client_setDisplayName; + private _isPack = getNumber (configFile >> "CfgAcreComponents" >> _baseClass >> "isPackRadio") == 1; + _entry set ["mod", "ACRE"]; + _entry set ["type", ["SR", "LR"] select _isPack]; + _entry set ["active", _radioId isEqualTo _active]; + _entry set ["channel", [_radioId] call acre_api_fnc_getRadioChannel]; + _entry set ["frequency", 0]; + _entry set ["code", ""]; + _entry set ["additional", false]; + + private _power = 0; + // The live channel is an ACRE fast hash, which is a location holding variables. + if (!isNil "acre_sys_data_fnc_dataEvent") then { + private _channelData = [_radioId, "getCurrentChannelData"] call acre_sys_data_fnc_dataEvent; + if (!isNil "_channelData" && {_channelData isEqualType locationNull} && {!isNull _channelData}) then { + _entry set ["frequency", _channelData getVariable ["frequencyTX", 0]]; + _power = _channelData getVariable ["power", 0]; + _entry set ["powerMilliwatts", _power]; + _entry set ["code", str (_channelData getVariable ["encryption", ""])]; + }; + }; + _entry set ["rangeMeters", [_power, [_srFallback, _lrFallback] select _isPack] call OCAP_recorder_client_getAcreRange]; + + _radios pushBack _entry; + } forEach (call acre_api_fnc_getCurrentRadioList); + }; + + createHashMapFromArray [["reason", _reason], ["radios", _radios]] + }; + + OCAP_recorder_client_capturePlayerState = { + params [ + ["_reason", "periodic", [""]], + ["_kinds", ["inventory", "medical", "stamina", "radio"], [[]]], + ["_unit", player, [objNull]] + ]; + + // Killed fires before the engine drops the current weapon. Reschedule into + // a scheduled environment and wait a tick so the death kit matches the corpse. + if (_reason isEqualTo "death" && {!canSuspend}) exitWith { + _this spawn OCAP_recorder_client_capturePlayerState; + }; + if (_reason isEqualTo "death") then { sleep 0.3; }; + + private _isRecording = (missionNamespace getVariable [QEGVAR(recorder,recording), false]) && + {(missionNamespace getVariable [QEGVAR(recorder,startTime), -1]) > -1}; + if (!hasInterface || {isNull _unit} || {!local _unit} || {!_isRecording}) exitWith {}; + + { + private _enabled = switch (_x) do { + case "inventory": { + (missionNamespace getVariable [QEGVAR(settings,trackPlayerInventory), true]) || { + _reason isEqualTo "death" && {missionNamespace getVariable [QEGVAR(settings,trackPlayerInventoryOnDeath), true]} + } + }; + case "medical": {missionNamespace getVariable [QEGVAR(settings,trackPlayerMedical), true]}; + case "stamina": {missionNamespace getVariable [QEGVAR(settings,trackPlayerStamina), true]}; + case "radio": { + (missionNamespace getVariable [QEGVAR(settings,trackPlayerRadio), true]) && + {OCAP_recorder_client_hasTfar || OCAP_recorder_client_hasAcre} + }; + default {false}; + }; + + if (_enabled) then { + private _payload = switch (_x) do { + case "inventory": {[_unit, _reason] call OCAP_recorder_client_buildInventorySnapshot}; + case "medical": {[_unit, _reason] call OCAP_recorder_client_buildMedicalSnapshot}; + case "stamina": {[_unit, _reason] call OCAP_recorder_client_buildStaminaSnapshot}; + case "radio": {[_unit, _reason] call OCAP_recorder_client_buildRadioSnapshot}; + }; + + // TFAR and ACRE finish initialising after the first snapshot window, so an + // empty radio list at the start of a recording is a sampler that is not + // ready yet, not a player without a radio. Write nothing until the unit has + // actually carried one; after that an empty list is a real change and is + // recorded, because putting the radio down is worth seeing. + private _write = true; + if (_x isEqualTo "radio") then { + if ((_payload getOrDefault ["radios", []]) isEqualTo []) then { + _write = OCAP_recorder_client_radioSeen; + } else { + OCAP_recorder_client_radioSeen = true; + }; + }; + + if (_write) then { + [_x, _unit, _payload] remoteExecCall [QFUNC(submitPlayerSnapshot), 2]; + }; + }; + } forEach _kinds; + }; + + // Cheap fingerprints so a Zeus-to-slot loadout, a radio frequency change, a + // wake-up, or a new ACE activity / quick-view line is written within one PFH tick. + OCAP_recorder_client_inventoryFingerprint = { + params ["_unit"]; + // Traits are part of the inventory snapshot, so a Zeus promoting someone to + // medic mid-mission has to trip the same diff a loadout change does. + private _traits = []; + if (missionNamespace getVariable [QEGVAR(settings,trackPlayerTraits), true]) then { + _traits = [ + getAllUnitTraits _unit, + _unit getVariable ["ace_medical_medicClass", -1], + _unit getVariable ["ACE_IsEngineer", -1], + _unit getVariable ["ACE_isEOD", -1] + ]; + }; + [ + _traits, + loadAbs _unit, + uniform _unit, + vest _unit, + backpack _unit, + headgear _unit, + goggles _unit, + primaryWeapon _unit, + handgunWeapon _unit, + secondaryWeapon _unit, + primaryWeaponItems _unit, + handgunItems _unit, + secondaryWeaponItems _unit, + assignedItems _unit, + uniformItems _unit, + vestItems _unit, + backpackItems _unit, + magazinesAmmoFull _unit + ] + }; + + OCAP_recorder_client_staminaFingerprint = { + params ["_unit"]; + [loadAbs _unit, load _unit, [_unit] call OCAP_recorder_client_stanceCode] + }; + + OCAP_recorder_client_radioFingerprint = { + params ["_unit"]; + private _fp = []; + if (OCAP_recorder_client_hasTfar) then { + private _activeSw = ""; + if (!isNil "TFAR_fnc_activeSwRadio") then { + private _sw = call TFAR_fnc_activeSwRadio; + if (!isNil "_sw" && {_sw isEqualType ""} && {_sw isNotEqualTo ""}) then { + _activeSw = _sw; + }; + }; + _fp pushBack _activeSw; + + private _swRadios = []; + if (!isNil "TFAR_fnc_radiosList") then { + _swRadios = _unit call TFAR_fnc_radiosList; + }; + if (!(_swRadios isEqualType [])) then {_swRadios = []}; + if (_swRadios isEqualTo [] && {_activeSw isNotEqualTo ""}) then { + _swRadios = [_activeSw]; + }; + { + _fp pushBack _x; + if (_x isEqualType "" && {_x isNotEqualTo ""} && {!isNil "TFAR_fnc_getSwChannel"}) then { + private _channel = _x call TFAR_fnc_getSwChannel; + _fp pushBack _channel; + _fp pushBack ([_x, _channel + 1] call TFAR_fnc_getChannelFrequency); + if (!isNil "TFAR_fnc_getAdditionalSwChannel") then { + _fp pushBack (_x call TFAR_fnc_getAdditionalSwChannel); + }; + }; + } forEach _swRadios; + + if (!isNil "TFAR_fnc_activeLrRadio") then { + private _lr = call TFAR_fnc_activeLrRadio; + _fp pushBack _lr; + if (!isNil "_lr" && {_lr isEqualType []} && {count _lr >= 2}) then { + private _channel = _lr call TFAR_fnc_getLrChannel; + _fp pushBack _channel; + _fp pushBack ([_lr, _channel + 1] call TFAR_fnc_getChannelFrequency); + _fp pushBack (_lr call TFAR_fnc_getAdditionalLrChannel); + }; + }; + }; + if (OCAP_recorder_client_hasAcre && {!isNil "acre_api_fnc_getCurrentRadioList"} && { + isNil "acre_api_fnc_isInitialized" || {[] call acre_api_fnc_isInitialized} + }) then { + { + private _radioId = _x; + _fp pushBack _radioId; + _fp pushBack ([_radioId] call acre_api_fnc_getRadioChannel); + if (!isNil "acre_sys_data_fnc_dataEvent") then { + private _channelData = [_radioId, "getCurrentChannelData"] call acre_sys_data_fnc_dataEvent; + if (!isNil "_channelData" && {_channelData isEqualType locationNull} && {!isNull _channelData}) then { + _fp pushBack (_channelData getVariable ["frequencyTX", 0]); + }; + }; + } forEach (call acre_api_fnc_getCurrentRadioList); + }; + _fp + }; + + // ACE broadcasts these with setVariable true, but the sampler still reads them + // locally: a new bandage should show up in the activity log on the next PFH. + // Unconscious / cardiac arrest / lifeState are here so a wake-up is not delayed + // until the healthy medical interval elapses. + OCAP_recorder_client_medicalFingerprint = { + params ["_unit"]; + [ + lifeState _unit, + _unit getVariable ["ACE_isUnconscious", false], + _unit getVariable ["ace_medical_inCardiacArrest", false], + _unit getVariable ["ace_medical_log_activity", []], + _unit getVariable ["ace_medical_log_quick_view", []] + ] + }; + + OCAP_recorder_client_playerTrackingLast = createHashMapFromArray [ + ["inventory", -1e10], ["medical", -1e10], ["stamina", -1e10], ["radio", -1e10] + ]; + OCAP_recorder_client_playerTrackingFingerprint = createHashMap; + OCAP_recorder_client_playerTrackingSession = -1; + OCAP_recorder_client_playerTrackingUnit = objNull; + OCAP_recorder_client_playerTrackingUnitId = -1; + OCAP_recorder_client_radioSeen = false; + + // [kind, enable setting, fingerprint function], visited one per tick. + OCAP_recorder_client_playerTrackingKinds = [ + ["inventory", QEGVAR(settings,trackPlayerInventory), OCAP_recorder_client_inventoryFingerprint], + ["radio", QEGVAR(settings,trackPlayerRadio), OCAP_recorder_client_radioFingerprint], + ["medical", QEGVAR(settings,trackPlayerMedical), OCAP_recorder_client_medicalFingerprint], + ["stamina", QEGVAR(settings,trackPlayerStamina), OCAP_recorder_client_staminaFingerprint] + ]; + OCAP_recorder_client_playerTrackingSlot = 0; + + // Every kind is still looked at every 5 seconds, as it always was, but the four + // are visited one per 1.25 s tick instead of all in the same frame. Building + // and sending an inventory, a radio, a medical and a stamina snapshot back to + // back was a hitch on the client every five seconds, and the start of a + // recording made every client do it at once and the server take four full + // snapshots per player in one burst. A kind's worst-case delay is unchanged: + // it is checked at most 5 s after it became due, exactly as before. + OCAP_recorder_client_playerTrackingPFH = [{ + private _startTime = missionNamespace getVariable [QEGVAR(recorder,startTime), -1]; + private _isRecording = (missionNamespace getVariable [QEGVAR(recorder,recording), false]) && {_startTime > -1}; + if (!_isRecording || {isNull player}) exitWith {}; + // Death is captured by the killed EH (after the weapon drop). Keep the + // periodic sampler off the corpse so it cannot outlive endFrame. + if (!alive player) exitWith {}; + // The server stamps unitId from this variable. Sampling before it exists + // would only be dropped, and must not consume the first-snapshot window. + private _unitId = player getVariable [QGVARMAIN(id), -1]; + if (_unitId < 0) exitWith {}; + + // New recordings, respawns and team switches need an initial snapshot even + // when the new unit carries exactly the same kit. Keep the staggered turns. + if (_startTime isNotEqualTo OCAP_recorder_client_playerTrackingSession || + {player isNotEqualTo OCAP_recorder_client_playerTrackingUnit} || + {_unitId isNotEqualTo OCAP_recorder_client_playerTrackingUnitId}) then { + OCAP_recorder_client_playerTrackingSession = _startTime; + OCAP_recorder_client_playerTrackingUnit = player; + OCAP_recorder_client_playerTrackingUnitId = _unitId; + {OCAP_recorder_client_playerTrackingLast set [_x, -1e10]} forEach (keys OCAP_recorder_client_playerTrackingLast); + OCAP_recorder_client_playerTrackingFingerprint = createHashMap; + OCAP_recorder_client_radioSeen = false; + }; + + (OCAP_recorder_client_playerTrackingKinds select OCAP_recorder_client_playerTrackingSlot) params ["_kind", "_setting", "_fingerprintFnc"]; + OCAP_recorder_client_playerTrackingSlot = (OCAP_recorder_client_playerTrackingSlot + 1) mod (count OCAP_recorder_client_playerTrackingKinds); + + if !(missionNamespace getVariable [_setting, true]) exitWith {}; + + private _now = CBA_missionTime; + private _interval = switch (_kind) do { + case "inventory": {missionNamespace getVariable [QEGVAR(settings,playerInventoryInterval), 120]}; + case "radio": {missionNamespace getVariable [QEGVAR(settings,playerRadioInterval), 120]}; + case "stamina": {missionNamespace getVariable [QEGVAR(settings,playerStaminaInterval), 30]}; + case "medical": { + if (OCAP_recorder_client_hasAceMedical && { + (player getVariable ["ACE_isUnconscious", false]) || {player getVariable ["ace_medical_inCardiacArrest", false]} + }) then { + missionNamespace getVariable [QEGVAR(settings,playerMedicalCriticalInterval), 10] + } else { + missionNamespace getVariable [QEGVAR(settings,playerMedicalInterval), 60] + } + }; + }; + + private _capture = false; + if (_now - (OCAP_recorder_client_playerTrackingLast getOrDefault [_kind, -1e10]) >= _interval) then { + _capture = true; + OCAP_recorder_client_playerTrackingLast set [_kind, _now]; + }; + + // Inventory, radio, stamina load and medical state sit unchanged for long + // stretches, so the interval is only the worst-case gap. A loadout, frequency, + // wake-up or carried-mass change is written on the kind's next turn as a + // diff; the server drops the payload if it is still identical. + if (_kind isNotEqualTo "radio" || {OCAP_recorder_client_hasTfar || OCAP_recorder_client_hasAcre}) then { + private _fingerprint = [player] call _fingerprintFnc; + if (_fingerprint isNotEqualTo (OCAP_recorder_client_playerTrackingFingerprint getOrDefault [_kind, []])) then { + OCAP_recorder_client_playerTrackingFingerprint set [_kind, _fingerprint]; + _capture = true; + OCAP_recorder_client_playerTrackingLast set [_kind, _now]; + }; + }; + + if (_capture) then { + ["periodic", [_kind], player] call OCAP_recorder_client_capturePlayerState; + }; + }, 1.25] call CBA_fnc_addPerFrameHandler; + + // English Zeus-module labels. `getText` would freeze the Zeus client's + // language into the recording; there is no SQF "localize as English". A + // baked map covers ZEN / ACE / common vanilla classes, getTextRaw is used + // when the config holds a literal, and unknown classes are split from the + // classname the same way gear names are. + OCAP_recorder_client_zeusModuleNames = createHashMapFromArray [ + ["zen_modules_moduleaddfullarsenal", "Add Full Arsenal"], + ["zen_modules_moduleambientanim", "Ambient Animation"], + ["zen_modules_moduleambientflyby", "Ambient Flyby"], + ["zen_modules_moduleanimationviewer", "Animation Viewer"], + ["zen_modules_modulearsenal", "Arsenal"], + ["zen_modules_moduleassignzeus", "Promote To Zeus"], + ["zen_modules_moduleattacheffect", "Attach/Detach Effect"], + ["zen_modules_moduleattachflag", "Attach Flag"], + ["zen_modules_modulebindvariable", "Bind Variable To Object"], + ["zen_modules_modulecasgun", "CAS Gun"], + ["zen_modules_modulecasmissile", "CAS Missiles"], + ["zen_modules_modulecasgunmissile", "CAS Gun and Missiles"], + ["zen_modules_modulecasbomb", "CAS Bomb"], + ["zen_modules_modulechangeheight", "Change Height"], + ["zen_modules_modulechatter", "Chatter"], + ["zen_modules_moduleconvoyparameters", "Convoy Parameters"], + ["zen_modules_modulecreateied", "Create IED"], + ["zen_modules_modulecreateintel", "Create/Edit Intel"], + ["zen_modules_modulecreatelz", "Create LZ"], + ["zen_modules_modulecreateminefield", "Create Minefield"], + ["zen_modules_modulecreaterp", "Create RP"], + ["zen_modules_modulecreatetarget", "Create Target"], + ["zen_modules_modulecreateteleporter", "Create Teleporter"], + ["zen_modules_modulecrewtogunner", "Crew To Gunner"], + ["zen_modules_moduledamagebuildings", "Damage Buildings"], + ["zen_modules_moduleearthquake", "Earthquake"], + ["zen_modules_moduleeditableobjects", "Update Editable Objects"], + ["zen_modules_moduleeffectfire", "Custom Fire"], + ["zen_modules_moduleequipwithecm", "Equip With ECM"], + ["zen_modules_moduleexportmissionsqf", "Export Mission SQF"], + ["zen_modules_moduleexecutecode", "Execute Code"], + ["zen_modules_modulefiremission", "Artillery Fire Mission"], + ["zen_modules_moduleflyheight", "Fly Height"], + ["zen_modules_modulefunctionsviewer", "Functions Viewer"], + ["zen_modules_modulegarrison", "Garrison Group"], + ["zen_modules_moduleglobalaiskill", "Global AI Skill"], + ["zen_modules_moduleglobalhint", "Global Hint"], + ["zen_modules_modulegroupside", "Group Side"], + ["zen_modules_moduleheal", "Heal"], + ["zen_modules_modulehideterrainobjects", "Hide Terrain Objects"], + ["zen_modules_modulehidezeus", "Hide Zeus"], + ["zen_modules_modulelightsource", "Light Source"], + ["zen_modules_modulemakeinvincible", "Make Invincible"], + ["zen_modules_modulenuke", "Atomic Bomb"], + ["zen_modules_modulepatrolarea", "Patrol Area"], + ["zen_modules_moduleremovearsenal", "Remove Arsenal"], + ["zen_modules_modulerotateobject", "Rotate Object"], + ["zen_modules_modulescaleobject", "Scale Object"], + ["zen_modules_modulesearchbuilding", "Search Building"], + ["zen_modules_modulesetdate", "Set Date"], + ["zen_modules_moduleshowinconfig", "Show In Config Viewer"], + ["zen_modules_modulesiderelations", "Side Relations"], + ["zen_modules_modulesimulation", "Toggle Simulation"], + ["zen_modules_modulesitonchair", "Sit On Chair"], + ["zen_modules_modulesmokepillar", "Smoke Pillar"], + ["zen_modules_modulespawncarrier", "USS Freedom"], + ["zen_modules_modulespawndestroyer", "USS Liberty"], + ["zen_modules_modulespawnreinforcements", "Spawn Reinforcements"], + ["zen_modules_modulesuicidebomber", "Suicide Bomber"], + ["zen_modules_modulesuppressivefire", "Suppressive Fire"], + ["zen_modules_moduleteleportplayers", "Teleport Players"], + ["zen_modules_moduletoggleflashlights", "Toggle Flashlights"], + ["zen_modules_moduletoggleirlasers", "Toggle IR Lasers"], + ["zen_modules_moduletogglelamps", "Toggle Lamps"], + ["zen_modules_moduletracers", "Tracers"], + ["zen_modules_moduleturretoptics", "Vehicle Turret Optics"], + ["zen_modules_moduleungarrison", "Un-Garrison Group"], + ["zen_modules_modulevisibility", "Toggle Visibility"], + ["zen_modules_moduleweather", "Change Weather"], + ["zen_remote_control_module", "Remote Control"], + ["zen_doors_module", "Configure Doors"], + ["zen_cover_map_module", "Cover Map"], + ["zen_attached_objects_module", "Attach To"], + ["zen_tasks_module", "Task"], + ["ace_zeus_moduleaddsparetrack", "Add Spare Track"], + ["ace_zeus_moduleaddsparewheel", "Add Spare Wheel"], + ["ace_zeus_modulesetengineer", "Set Engineer"], + ["ace_zeus_modulesetrepairfacility", "Set Repair Facility"], + ["ace_zeus_modulesetrepairvehicle", "Set Repair Vehicle"], + ["ace_zeus_moduleaddorremovefries", "Add/Remove FRIES"], + ["ace_zeus_modulecaptive", "Captive"], + ["ace_zeus_moduleconfigurepylons", "Configure Pylons"], + ["ace_zeus_moduledefendarea", "Defend Area"], + ["ace_zeus_moduleeditableobjects", "Editable Objects"], + ["ace_zeus_moduleglobalsetskill", "Global AI Skill"], + ["ace_zeus_modulegroupside", "Group Side"], + ["ace_zeus_moduleheal", "Heal"], + ["ace_zeus_modulelaytrench", "Lay Trench"], + ["ace_zeus_moduleloadintocargo", "Load Into Cargo"], + ["ace_zeus_moduleunloadfromcargo", "Unload From Cargo"], + ["ace_zeus_modulecargoparadrop", "Cargo Paradrop"], + ["ace_zeus_modulepatrolarea", "Patrol Area"], + ["ace_zeus_modulesearcharea", "Search Area"], + ["ace_zeus_modulesearchnearby", "Search Nearby"], + ["ace_zeus_modulesuppressivefire", "Suppressive Fire"], + ["ace_zeus_modulesetmedic", "Set Medic"], + ["ace_zeus_modulesetmedicalfacility", "Set Medical Facility"], + ["ace_zeus_modulesetmedicalvehicle", "Set Medical Vehicle"], + ["ace_zeus_modulesimulation", "Toggle Simulation"], + ["ace_zeus_modulespectator", "Spectator"], + ["ace_zeus_modulesuicidebomber", "Suicide Bomber"], + ["ace_zeus_modulesurrender", "Surrender"], + ["ace_zeus_moduleteleportplayers", "Teleport Players"], + ["ace_zeus_moduleunconscious", "Unconscious"], + ["ace_zeus_modulegarrison", "Garrison"], + ["ace_zeus_moduleungarrison", "Un-Garrison"], + ["ace_zeus_moduletogglenvg", "Toggle NVG"], + ["ace_zeus_moduletoggleflashlight", "Toggle Flashlight"], + ["ace_zeus_addfullarsenal", "Add Full Arsenal"], + ["ace_zeus_removefullarsenal", "Remove Arsenal"], + ["ace_zeus_addfullacearsenal", "Add Full ACE Arsenal"], + ["ace_zeus_removefullacearsenal", "Remove ACE Arsenal"], + ["ace_zeus_moduleburn", "Burn"], + ["ace_zeus_modulemedicalmenu", "Medical Menu"], + ["modulelightning_f", "Lightning Bolt"], + ["modulecas_f", "CAS"], + ["modulecasgun_f", "CAS Gun"], + ["modulecasmissile_f", "CAS Missiles"], + ["modulecasgunmissile_f", "CAS Gun and Missiles"], + ["modulecasbomb_f", "CAS Bomb"], + ["modulearsenal_f", "Arsenal"], + ["moduleremotecontrol_f", "Remote Control"], + ["moduletracers_f", "Tracers"], + ["modulepostprocess_f", "Postprocess"], + ["moduleskiptime_f", "Skip Time"], + ["moduleweather_f", "Weather"], + ["moduleflare_f", "Flare"], + ["modulechemlight_f", "Chemlight"], + ["moduleordnance_f", "Ordnance"], + ["moduleordnancehowitzer_f", "Howitzer"], + ["moduleordnancemortar_f", "Mortar"], + ["moduleordnancerocket_f", "Rocket"], + ["modulemine_f", "Mine"], + ["modulecovermap_f", "Cover Map"], + ["moduleobjective_f", "Objective"], + ["moduleobjectivemove_f", "Move"], + ["moduleobjectivesector_f", "Sector"], + ["moduleobjectiveattackdefend_f", "Attack / Defend"], + ["moduleobjectiveneutralize_f", "Neutralize"], + ["moduleobjectiveprotect_f", "Protect"], + ["moduleobjectivegetin_f", "Get In"], + ["moduleobjectiveracestart_f", "Race Start"], + ["moduleobjectiveracecp_f", "Race Checkpoint"], + ["moduleobjectiveracefinish_f", "Race Finish"], + ["modulecuratoraddaddons_f", "Add Addons"], + ["modulecuratoraddeditableobjects_f", "Add Editable Objects"], + ["modulecuratorsetcamera_f", "Set Camera"] + ]; + OCAP_recorder_client_zeusModuleCategories = createHashMapFromArray [ + ["zen_modules_ai", "AI"], + ["zen_modules_buildings", "Buildings"], + ["zen_modules_devtools", "Dev Tools"], + ["zen_modules_equipment", "Equipment"], + ["zen_modules_inventory", "Inventory"], + ["zen_modules_objects", "Objects"], + ["zen_modules_players", "Players"], + ["zen_modules_reinforcements", "Reinforcements"], + ["zen_modules_spawn", "Spawn"], + ["ace_zeus_repair", "Repair"], + ["ace_zeus_captive", "Captive"], + ["ace_zeus_utility", "Utility"], + ["ace_zeus_ai", "AI"], + ["ace_zeus_medical", "Medical"], + ["ace_zeus_arsenal", "Arsenal"], + ["curator", "Zeus"], + ["ordnance", "Ordnance"], + ["environment", "Environment"], + ["effects", "Effects"], + ["missionflow", "Mission Flow"], + ["objectives", "Objectives"], + ["ace", "ACE"], + ["chemlights", "Chemlights"] + ]; + OCAP_recorder_client_zeusModulePrefixes = [ + "zen_custom_modules_", + "zen_modules_module", + "zen_modules_", + "zen_remote_control_", + "zen_doors_", + "zen_cover_map_", + "zen_attached_objects_", + "zen_tasks_", + "ace_zeus_module", + "ace_zeus_" + ]; + OCAP_recorder_client_zeusModuleAcronyms = [ + "cas", "ied", "lz", "rp", "ecm", "sqf", "ir", "nvg", "ai", "fries", "uss", "hq", "gps", "ace" + ]; + + OCAP_recorder_client_isEnglishText = { + params [["_text", "", [""]]]; + if (_text isEqualTo "") exitWith {false}; + private _ok = true; + { + private _pass = _x == 32 || {_x == 45} || {_x == 47} || {_x == 38} || {_x == 39} || {_x == 40} || {_x == 41} || {_x == 43} || {_x == 44} || {_x == 46} || {_x >= 48 && _x <= 57} || {_x >= 65 && _x <= 90} || {_x >= 97 && _x <= 122}; + if (!_pass) then {_ok = false}; + } forEach toArray _text; + _ok && {((toUpper _text) select [0, 4]) isNotEqualTo "STR_"} + }; + + OCAP_recorder_client_humanizeZeusModuleClass = { + params [["_className", "", [""]]]; + if (_className isEqualTo "") exitWith {""}; + + private _base = _className; + private _lower = toLower _base; + { + if ((_lower find _x) == 0 && {count _base > count _x}) exitWith { + _base = _base select [count _x]; + _lower = toLower _base; + }; + } forEach OCAP_recorder_client_zeusModulePrefixes; + + if ((_lower select [count _lower - 2, 2]) isEqualTo "_f") then { + _base = _base select [0, count _base - 2]; + _lower = toLower _base; + }; + if ((_lower select [0, 6]) isEqualTo "module" && {count _base > 6}) then { + _base = _base select [6]; + }; + + private _name = [_base] call OCAP_recorder_client_deriveNameFromClass; + if (_name isEqualTo "" || {_name isEqualTo _base && {_base regexMatch "^\d+$"}}) then { + _name = "Custom Module"; + }; + (_name splitString " " apply { + if ((toLower _x) in OCAP_recorder_client_zeusModuleAcronyms) then {toUpper _x} else {_x} + }) joinString " " + }; + + OCAP_recorder_client_zeusModuleDisplayName = { + params ["_object", ["_className", "", [""]]]; + if (_className isEqualTo "") then {_className = typeOf _object}; + + private _cached = OCAP_recorder_client_zeusModuleNames get toLower _className; + if (!isNil "_cached") exitWith {_cached}; + + if (((toLower _className) find "zen_custom_modules_") == 0) exitWith { + private _index = getNumber (configOf _object >> "zen_custom_modules_id") - 1; + private _entry = missionNamespace getVariable ["zen_custom_modules_list", []] param [_index, []]; + private _customName = _entry param [1, ""]; + if ([_customName] call OCAP_recorder_client_isEnglishText) then {_customName} else {"Custom Module"} + }; + + private _raw = getTextRaw (configOf _object >> "displayName"); + if (_raw isEqualType []) then {_raw = _raw param [0, ""]}; + if (!(_raw isEqualType "")) then {_raw = ""}; + _raw = trim _raw; + if (_raw isNotEqualTo "" && {(_raw select [0, 1]) isNotEqualTo "$"} && {[_raw] call OCAP_recorder_client_isEnglishText}) then { + _raw + } else { + [_className] call OCAP_recorder_client_humanizeZeusModuleClass + } + }; + + OCAP_recorder_client_zeusModuleCategory = { + params ["_object"]; + private _raw = getTextRaw (configOf _object >> "category"); + if (_raw isEqualType []) then {_raw = _raw param [0, ""]}; + if (!(_raw isEqualType "")) then {_raw = ""}; + if (_raw isEqualTo "" || {_raw isEqualTo "NO_CATEGORY"}) exitWith {""}; + private _mapped = OCAP_recorder_client_zeusModuleCategories get toLower _raw; + if (!isNil "_mapped") exitWith {_mapped}; + if ([_raw] call OCAP_recorder_client_isEnglishText && {(_raw select [0, 1]) isNotEqualTo "$"}) exitWith {_raw}; + [_raw] call OCAP_recorder_client_humanizeZeusModuleClass + }; + + OCAP_recorder_client_zeusModuleSource = { + params [["_className", "", [""]]]; + private _lower = toLower _className; + if ((_lower select [0, 20]) isEqualTo "zen_custom_modules_") exitWith {"zenCustom"}; + if ((_lower select [0, 4]) isEqualTo "zen_") exitWith {"zen"}; + if ((_lower select [0, 9]) isEqualTo "ace_zeus_") exitWith {"ace"}; + if ((_lower select [0, 6]) isEqualTo "module") exitWith {"vanilla"}; + "other" + }; + + OCAP_recorder_client_reportZeusModule = { + params ["_curator", "_object"]; + if (isNull _object) exitWith {}; + if !(_object isKindOf "Module_F") exitWith {}; + if (typeOf _object isEqualTo "ModuleCurator_F") exitWith {}; + if !(missionNamespace getVariable [QEGVAR(settings,trackZeus), true]) exitWith {}; + if !(missionNamespace getVariable [QEGVAR(settings,trackZeusModules), true]) exitWith {}; + + private _className = typeOf _object; + private _pos = getPosATL _object; + private _attached = attachedTo _object; + if (isNull _attached) then { + curatorMouseOver params ["_kind", "_entity"]; + if (_kind isEqualTo "OBJECT") then {_attached = _entity}; + }; + + private _attachedName = ""; + if (!isNull _attached) then { + if (_attached isKindOf "CAManBase") then { + _attachedName = name _attached; + } else { + _attachedName = [typeOf _attached] call OCAP_recorder_client_gearDisplayName; + }; + }; + + [ + getAssignedCuratorUnit _curator, + _attached, + createHashMapFromArray [ + ["className", _className], + ["displayName", [_object, _className] call OCAP_recorder_client_zeusModuleDisplayName], + ["category", [_object] call OCAP_recorder_client_zeusModuleCategory], + ["source", [_className] call OCAP_recorder_client_zeusModuleSource], + ["x", _pos select 0], + ["y", _pos select 1], + ["attachedName", _attachedName] + ] + ] remoteExecCall [QFUNC(submitZeusModule), 2]; + }; + + // curatorCamera exists only on the Zeus client. The player-detail PFH above + // also exits when the body has no OCAP id, which is the normal case for a + // virtual curator slot — this sampler must stay independent of that. + if (isNil "OCAP_recorder_client_zeusCameraPFH") then { + OCAP_recorder_client_zeusCameraLast = -1e10; + OCAP_recorder_client_zeusCameraSent = []; + OCAP_recorder_client_zeusCameraSentAt = -1e10; + OCAP_recorder_client_zeusCameraSession = -1; + OCAP_recorder_client_zeusPingedLogics = []; + OCAP_recorder_client_zeusCameraPFH = [{ + if (isNull player) exitWith {}; + + // CuratorPinged only ever fires where the curator logic is local, which is + // the Zeus's own machine — the server never sees it. Attach once per logic: + // assignment can happen long after the bootstrap ran, and this must not sit + // behind the recording or curatorCamera guards below, because a Zeus with the + // interface closed still receives pings. + private _logic = getAssignedCuratorLogic player; + if (!isNull _logic && {!(_logic in OCAP_recorder_client_zeusPingedLogics)}) then { + OCAP_recorder_client_zeusPingedLogics pushBack _logic; + _logic addEventHandler ["CuratorPinged", { + params ["_curator", "_pinger"]; + if (isNull _pinger) exitWith {}; + if !(missionNamespace getVariable [QEGVAR(settings,trackZeus), true]) exitWith {}; + if !(missionNamespace getVariable [QEGVAR(settings,trackZeusPings), true]) exitWith {}; + [getAssignedCuratorUnit _curator, _pinger] remoteExecCall [QFUNC(submitZeusPing), 2]; + }]; + // Same locality as pings: the dedicated server never sees this. + _logic addEventHandler ["CuratorObjectPlaced", { + _this call OCAP_recorder_client_reportZeusModule; + }]; + }; + + private _startTime = missionNamespace getVariable [QEGVAR(recorder,startTime), -1]; + private _isRecording = (missionNamespace getVariable [QEGVAR(recorder,recording), false]) && {_startTime > -1}; + if (!_isRecording) exitWith {}; + if !(missionNamespace getVariable [QEGVAR(settings,trackZeus), true]) exitWith {}; + if (isNull curatorCamera) exitWith {}; + if (isNull _logic) exitWith {}; + + private _now = CBA_missionTime; + private _interval = missionNamespace getVariable [QEGVAR(settings,zeusCameraInterval), 1]; + if (_now - OCAP_recorder_client_zeusCameraLast < _interval) exitWith {}; + OCAP_recorder_client_zeusCameraLast = _now; + + private _pos = getPosASL curatorCamera; + private _dir = getDir curatorCamera; + private _fov = getObjectFOV curatorCamera; + private _pitch = (vectorDir curatorCamera) select 2; + + // The server keeps a camera sample only when it differs from the last one + // after this exact rounding (OCAP_recorder_fnc_submitZeusCamera), so a + // parked camera used to cost one network message a second for nothing -- + // on the client that already sends the most traffic. Skip those here. + // A new recording resets the server's copy, so it resets this one too; the + // periodic resend only bounds how long the two could ever disagree, and the + // server still drops it if nothing moved. + if (_startTime isNotEqualTo OCAP_recorder_client_zeusCameraSession) then { + OCAP_recorder_client_zeusCameraSession = _startTime; + OCAP_recorder_client_zeusCameraSent = []; + }; + private _rounded = [round (_pos select 0), round (_pos select 1), round _dir, parseNumber (_fov toFixed 3), parseNumber (_pitch toFixed 2)]; + if (_rounded isEqualTo OCAP_recorder_client_zeusCameraSent && {_now - OCAP_recorder_client_zeusCameraSentAt < 30}) exitWith {}; + OCAP_recorder_client_zeusCameraSent = _rounded; + OCAP_recorder_client_zeusCameraSentAt = _now; + + [player, createHashMapFromArray [ + ["x", _pos select 0], + ["y", _pos select 1], + ["dir", _dir], + ["fov", _fov], + ["pitch", _pitch] + ]] remoteExecCall [QFUNC(submitZeusCamera), 2]; + }, 0.25] call CBA_fnc_addPerFrameHandler; + }; + + // Static weapons. Both signals are local to the machine that builds the gun, + // and neither is visible to the server: + // + // - "WeaponAssembled" and "WeaponDisassembled" are unit event handlers that + // fire only where the unit is local. The server registers the same pair + // for its own AI in . + // - ACE3 CSW never uses the engine command. It builds the gun with + // createVehicle and raises ace_csw_deployWeaponSucceeded as a *local* + // event on the deploying client, so the local player is the builder. + // + // The handlers are re-attached whenever the player's unit changes, which + // covers respawn and team switch; CBA_fnc_addPlayerEventHandler also runs it + // once for the unit the player already has. + if (isNil "OCAP_recorder_client_staticWeaponInit") then { + OCAP_recorder_client_staticWeaponInit = true; + + ["unit", { + params ["_unit"]; + if (isNull _unit) exitWith {}; + if (!isNil {_unit getVariable "OCAP_recorder_client_staticWeaponEH"}) exitWith {}; + + _unit setVariable ["OCAP_recorder_client_staticWeaponEH", _unit addEventHandler ["WeaponAssembled", { + params ["_unit", "_static"]; + if (!(missionNamespace getVariable [QEGVAR(settings,trackConstructions), true])) exitWith {}; + ["assembled", _unit, _static] remoteExecCall [QFUNC(submitStaticWeapon), 2]; + }]]; + + // WeaponDisassembled carries the two bags, not the gun, and the gun is + // already deleted -- the server turns this into a claim and matches it + // against whichever gun actually disappears. + _unit addEventHandler ["WeaponDisassembled", { + params ["_unit"]; + if (!(missionNamespace getVariable [QEGVAR(settings,trackConstructions), true])) exitWith {}; + ["disassembled", _unit, objNull] remoteExecCall [QFUNC(submitStaticWeapon), 2]; + }]; + }, true] call CBA_fnc_addPlayerEventHandler; + + if (isClass (configFile >> "CfgPatches" >> "ace_csw")) then { + ["ace_csw_deployWeaponSucceeded", { + params ["_csw"]; + if (isNull _csw) exitWith {}; + if (!(missionNamespace getVariable [QEGVAR(settings,trackConstructions), true])) exitWith {}; + ["assembled", player, _csw] remoteExecCall [QFUNC(submitStaticWeapon), 2]; + }] call CBA_fnc_addEventHandler; + }; + }; +} remoteExec ["call", [0, -2] select isDedicated, QGVAR(playerTrackingBootstrapJip)]; diff --git a/addons/recorder/fnc_recordExplosion.sqf b/addons/recorder/fnc_recordExplosion.sqf new file mode 100644 index 0000000..92874a5 --- /dev/null +++ b/addons/recorder/fnc_recordExplosion.sqf @@ -0,0 +1,173 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_recordExplosion.sqf + +FUNCTION: OCAP_recorder_fnc_recordExplosion + +Description: + Server-side trust boundary for detonations. Turns a raw "this ammo exploded + here" report into an additive `explosion` general event carrying the blast + radius, so playback can draw the affected area rather than a bare impact dot. + + The report can come from three places, all of which already exist: + + - a tracked non-bullet projectile's "Explode" event handler + (), which runs on the + projectile owner's machine, + - a placed mine's "Explode" handler in + and its server-side counterpart in , + - an ACE3 explosive's "Explode" handler in . + + None of those carry the blast size, so radius and damage are read here from + CfgAmmo and cached: an artillery barrage detonates the same class hundreds of + times and the config lookup is the only expensive part. + + Attribution is resolved here rather than trusted from the caller. A projectile + report passes the temp key it was tracked under and the firer is taken from + ; everything else passes an OCAP id that is + re-read from the object where one is available. + + Blasts smaller than are dropped, which is + what keeps 20 mm HE cannon bursts from burying the event feed. + +Parameters: + _frame - Capture frame the detonation happened on. [Number] + _pos - Detonation position, "x,y,z" as sent by the event handlers. [String or Array] + _ammo - CfgAmmo class of whatever exploded. [String] + _firerId - OCAP id of the firer, or -1 when unknown. [Number, default: -1] + _tempKey - Projectile tracking key, "" for anything not a tracked projectile. [String, default: ""] + _source - "projectile" or "explosive". [String, default: "projectile"] + +Returns: + Nothing + +Examples: + > [GVAR(captureFrameNo), "1234,5678,12", "Sh_155mm_AMOS", -1, _tempKey] call FUNC(recordExplosion); + +Public: + No + +Author: + OCAP Improved +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +if (!isServer) exitWith {}; +if (!SHOULDSAVEEVENTS) exitWith {}; +if (!EGVAR(extension,sessionReady)) exitWith {}; +if (!(missionNamespace getVariable [QEGVAR(settings,trackExplosions), true])) exitWith {}; + +params [ + ["_frame", -1, [0]], + ["_pos", "", ["", []]], + ["_ammo", "", [""]], + ["_firerId", -1, [0]], + ["_tempKey", "", [""]], + ["_source", "projectile", [""]] +]; + +if (_ammo isEqualTo "") exitWith {}; + +// VARIABLE: OCAP_recorder_explosionConfigCache +// CfgAmmo lookups keyed by ammo class: [radius, damage, english name]. A single +// mortar mission fires the same class dozens of times and the config read is the +// only part of this function that is not arithmetic. +if (isNil QGVAR(explosionConfigCache)) then {GVAR(explosionConfigCache) = createHashMap}; + +private _info = GVAR(explosionConfigCache) get _ammo; +if (isNil "_info") then { + private _cfg = configFile >> "CfgAmmo" >> _ammo; + private _radius = getNumber (_cfg >> "indirectHitRange"); + private _damage = getNumber (_cfg >> "indirectHit"); + + // Never store a localized string in a recording: getText resolves in the + // recording server's language, getTextRaw returns the literal the mod author + // typed. A class whose name is only a $STR_ reference falls back to the class + // name, which is English by construction. See AGENTS.md. + private _name = getTextRaw (_cfg >> "displayName"); + if (_name isEqualTo "") then { + private _mag = getText (_cfg >> "defaultMagazine"); + if (_mag isNotEqualTo "") then { + _name = getTextRaw (configFile >> "CfgMagazines" >> _mag >> "displayName"); + }; + }; + if (_name isEqualTo "" || {(_name select [0, 1]) isEqualTo "$"}) then {_name = _ammo}; + + _info = [_radius, _damage, _name]; + GVAR(explosionConfigCache) set [_ammo, _info]; +}; + +_info params ["_radius", "_damage", "_name"]; + +private _minRadius = missionNamespace getVariable [QEGVAR(settings,explosionMinRadius), 2]; +if (_radius < _minRadius) exitWith {}; + +// The event handlers all send "x,y,z"; accept a raw array too so a caller that +// already has one does not have to stringify it first. +private _coords = _pos; +if (_pos isEqualType "") then { + _coords = (_pos splitString ",") apply {parseNumber _x}; +}; +if (count _coords < 2) exitWith {}; + +// A projectile report knows only the key it was tracked under. The firer, the +// vehicle it was fired from and the side all live in the tracked entry, which is +// the same array the :EVENT:PROJECTILE: record is built from. +private _vehicleId = -1; +if (_tempKey isNotEqualTo "" && {!isNil QGVAR(trackedProjectiles)}) then { + private _entry = GVAR(trackedProjectiles) getOrDefault [_tempKey, []]; + if (_entry isNotEqualTo []) then { + private _data = _entry select 0; + if (_firerId isEqualTo -1) then {_firerId = _data select 2}; + _vehicleId = _data select 3; + }; +}; + +// Side is read from the firer rather than sent, so it cannot disagree with the +// entity record. An unattributed blast (Zeus, script, editor mine) has none. +private _side = ""; +if (_firerId > -1) then { + private _firer = objNull; + if (!isNil QGVAR(trackedUnits)) then { + _firer = GVAR(trackedUnits) getOrDefault [_firerId, objNull]; + }; + if (!isNull _firer) then { + _side = str (side group _firer); + } else { + // A vehicle OCAP knows only as a vehicle record has no group, so its own + // side is the answer there. + if (!isNil QGVAR(trackedVehicles)) then { + private _entry = GVAR(trackedVehicles) getOrDefault [_firerId, []]; + if (_entry isNotEqualTo [] && {!isNull (_entry select 0)}) then { + _side = str (side (_entry select 0)); + }; + }; + }; +}; + +[":EVENT:GENERAL:", [ + _frame max 0, + "explosion", + [createHashMapFromArray [ + ["x", round (_coords select 0)], + ["y", round (_coords select 1)], + ["ammo", _ammo], + ["name", _name], + // One decimal, not a whole metre. Rounding to an integer turns any blast + // under half a metre into radius 0, and a reader cannot draw a zero-radius + // circle -- the web client drops those events entirely. That is unreachable + // at the default minimum radius of 2, but it is reachable the moment someone + // lowers the setting, and silently discarding what the recorder went to the + // trouble of writing is the worst way to find out. Still a plain JSON + // number, so recordings written before this read back the same. + ["radius", (round (_radius * 10)) / 10], + ["power", round _damage], + ["firerId", _firerId], + ["vehicleId", _vehicleId], + ["side", _side], + ["source", _source] + ]] call FUNC(encodeJSON) +]] call EFUNC(extension,sendData); + +if (GVARMAIN(isDebug)) then { + OCAPEXTLOG(ARR3("EXPLOSION",_ammo,_radius)); +}; diff --git a/addons/recorder/fnc_recordRadioPropagation.sqf b/addons/recorder/fnc_recordRadioPropagation.sqf new file mode 100644 index 0000000..eecea28 --- /dev/null +++ b/addons/recorder/fnc_recordRadioPropagation.sqf @@ -0,0 +1,116 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_recordRadioPropagation.sqf + +FUNCTION: OCAP_recorder_fnc_recordRadioPropagation + +Description: + Reads the TFAR and ACRE CBA settings that affect radio range on the server + and writes them once as `tfarSettings` and `acreSettings` events. Playback + uses those values for the approximate-range overlay; if a mod is not loaded + on the server its baked defaults are stamped instead. + + The two mods do not share coefficients. TFAR uses + `TFAR_terrain_interception_coefficient` (default 7) and + `TFAR_globalRadioRangeCoef` (default 1). ACRE uses + `acre_sys_core_terrainLoss` (0–1, default 1) plus + `acre_sys_signal_signalModel` (default 2 = LOS Multipath). Approximate + coverage for an ACRE radio must not inherit the TFAR values. + + Server-only. Radio sampling is client-local, but these CBA settings are + registered with the server flag and live in missionNamespace when the + server has the mod loaded. + +Parameters: + None + +Returns: + Nothing + +Examples: + > call FUNC(recordRadioPropagation); + +Public: + No + +Author: + IndigoFox +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +if (!isServer || {!SHOULDSAVEEVENTS} || {!EGVAR(extension,sessionReady)}) exitWith {}; + +private _tfarLoaded = isClass (configFile >> "CfgPatches" >> "tfar_core"); +private _tfarSource = "default"; + +// TFAR writes the live copy to TF_terrain_interception_coefficient from the +// CBA setting TFAR_terrain_interception_coefficient (slider 0–20, default 7). +private _coef = missionNamespace getVariable "TFAR_terrain_interception_coefficient"; +if (isNil "_coef") then { + _coef = missionNamespace getVariable "TF_terrain_interception_coefficient"; +}; +if (isNil "_coef" || {!(_coef isEqualType 0)}) then { + _coef = 7; +} else { + _tfarSource = "cba"; +}; + +private _rangeCoef = missionNamespace getVariable "TFAR_globalRadioRangeCoef"; +if (isNil "_rangeCoef" || {!(_rangeCoef isEqualType 0)}) then { + _rangeCoef = 1; +} else { + _tfarSource = "cba"; +}; + +[":EVENT:GENERAL:", [ + GVAR(captureFrameNo), + "tfarSettings", + [createHashMapFromArray [ + ["terrainInterceptionCoefficient", _coef], + ["globalRadioRangeCoef", _rangeCoef], + ["tfarLoaded", _tfarLoaded], + ["source", _tfarSource] + ]] call FUNC(encodeJSON) +]] call EFUNC(extension,sendData); + +// ACRE's live `acre_sys_signal_terrainScaling` is written only on clients +// (`setLossModelScale` exits on dedicated). The CBA setting itself is global, +// so the dedicated server still has `acre_sys_core_terrainLoss` when ACRE is +// in the server mod list. +private _acreLoaded = isClass (configFile >> "CfgPatches" >> "acre_main"); +private _acreSource = "default"; + +private _terrainLoss = missionNamespace getVariable "acre_sys_core_terrainLoss"; +if (isNil "_terrainLoss" || {!(_terrainLoss isEqualType 0)}) then { + _terrainLoss = 1; +} else { + _acreSource = "cba"; +}; + +private _signalModel = missionNamespace getVariable "acre_sys_signal_signalModel"; +if (isNil "_signalModel" || {!(_signalModel isEqualType 0)}) then { + _signalModel = 2; // SIGNAL_MODEL_LOS_MULTIPATH +} else { + _acreSource = "cba"; + if (_signalModel < 0 || {_signalModel > 3}) then { + _signalModel = 2; + }; +}; + +private _ignoreAntenna = missionNamespace getVariable "acre_sys_core_ignoreAntennaDirection"; +if (isNil "_ignoreAntenna" || {!(_ignoreAntenna isEqualType true)}) then { + _ignoreAntenna = false; +} else { + _acreSource = "cba"; +}; + +[":EVENT:GENERAL:", [ + GVAR(captureFrameNo), + "acreSettings", + [createHashMapFromArray [ + ["terrainLoss", _terrainLoss], + ["signalModel", _signalModel], + ["ignoreAntennaDirection", _ignoreAntenna], + ["acreLoaded", _acreLoaded], + ["source", _acreSource] + ]] call FUNC(encodeJSON) +]] call EFUNC(extension,sendData); diff --git a/addons/recorder/fnc_recordWeather.sqf b/addons/recorder/fnc_recordWeather.sqf new file mode 100644 index 0000000..7961b93 --- /dev/null +++ b/addons/recorder/fnc_recordWeather.sqf @@ -0,0 +1,79 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_recordWeather.sqf + +FUNCTION: OCAP_recorder_fnc_recordWeather + +Description: + Samples the recording server's current weather and writes an additive + `weather` event. Vanilla environment commands cover overcast, rain, fog, + wind, gusts, lightnings, humidity, waves and moon phase. When ACE Weather + is loaded and its CBA `ace_weather_enabled` setting is on, ACE humidity + and air temperature replace the vanilla readings; ACE wind simulation + already writes through `setWind`, so `wind` / `windDir` are the live values + either way. + + Server-only. Weather is global, not client-local. + +Parameters: + None + +Returns: + Nothing + +Examples: + > call FUNC(recordWeather); + +Public: + No + +Author: + IndigoFox +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +if (!isServer || {!SHOULDSAVEEVENTS} || {!EGVAR(extension,sessionReady)}) exitWith {}; +if !(EGVAR(settings,trackWeather)) exitWith {}; + +private _round3 = { + round (_this * 1000) / 1000 +}; + +private _aceWeather = isClass (configFile >> "CfgPatches" >> "ace_weather") + && {missionNamespace getVariable ["ace_weather_enabled", false]}; + +private _humidity = humidity; +if (_aceWeather) then { + private _aceHumidity = missionNamespace getVariable "ace_weather_currentHumidity"; + if (!isNil "_aceHumidity" && {_aceHumidity isEqualType 0}) then { + _humidity = _aceHumidity; + }; +}; + +private _pairs = [ + ["overcast", overcast call _round3], + ["rain", rain call _round3], + ["fog", fog call _round3], + ["windSpeed", round ((vectorMagnitude wind) * 10) / 10], + ["windDir", round windDir], + ["gusts", gusts call _round3], + ["lightnings", lightnings call _round3], + ["humidity", _humidity call _round3], + ["waves", waves call _round3], + ["moonPhase", (moonPhase date) call _round3], + ["ace", _aceWeather] +]; + +if (_aceWeather) then { + private _temperature = missionNamespace getVariable "ace_weather_currentTemperature"; + if (!isNil "_temperature" && {_temperature isEqualType 0}) then { + _pairs pushBack ["temperature", round (_temperature * 10) / 10]; + }; +}; + +[":EVENT:GENERAL:", [ + // The PFH can fire before the capture loop has produced a frame. A sample + // stamped -1 wraps past the end of the recording and never shows in Stats. + GVAR(captureFrameNo) max 0, + "weather", + [createHashMapFromArray _pairs] call FUNC(encodeJSON) +]] call EFUNC(extension,sendData); diff --git a/addons/recorder/fnc_stopRecording.sqf b/addons/recorder/fnc_stopRecording.sqf index f553370..86cc132 100644 --- a/addons/recorder/fnc_stopRecording.sqf +++ b/addons/recorder/fnc_stopRecording.sqf @@ -30,6 +30,13 @@ _systemTimeFormat append (systemTimeUTC apply {if (_x < 10) then {"0" + str _x} private _missionDateFormat = ["%1-%2-%3T%4:%5:00"]; _missionDateFormat append (date apply {if (_x < 10) then {"0" + str _x} else {str _x}}); +// Write out servicing still in progress while events are still being saved. +// A repair that was halfway done when the mission ended is worth having, and +// SHOULDSAVEEVENTS goes false a few lines below. +if (!isNil QGVAR(flushServiceSessions)) then { + call GVAR(flushServiceSessions); +}; + [QGVARMAIN(customEvent), ["generalEvent", "Recording paused."]] call CBA_fnc_serverEvent; ["OCAP stopped recording", 1, [1, 1, 1, 1]] remoteExecCall ["CBA_fnc_notify", [0, -2] select isDedicated]; diff --git a/addons/recorder/fnc_submitPlayerSnapshot.sqf b/addons/recorder/fnc_submitPlayerSnapshot.sqf new file mode 100644 index 0000000..ea73a95 --- /dev/null +++ b/addons/recorder/fnc_submitPlayerSnapshot.sqf @@ -0,0 +1,111 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_submitPlayerSnapshot.sqf + +FUNCTION: OCAP_recorder_fnc_submitPlayerSnapshot + +Description: + Server-side trust boundary for the player-detail snapshots that clients sample + locally. Validates the call, derives the identity from the passed object rather + than the payload, reduces the payload to a diff against the player's previous + snapshot of the same kind, and forwards it to the extension. + + A snapshot that changed nothing is dropped entirely, and every + SNAPSHOT_KEYFRAME_EVERY diffs one full snapshot is written so a lost event + cannot corrupt the rest of the chain. + +Parameters: + _kind - "inventory", "medical", "stamina" or "radio". [String] + _unit - The unit the snapshot describes. [Object] + _payload - Sampled data. [HashMap] + +Returns: + Nothing + +Examples: + > ["medical", player, _payload] remoteExecCall [QFUNC(submitPlayerSnapshot), 2]; + +Public: + No + +Author: + IndigoFox +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +// A full snapshot after this many consecutive diffs bounds how much a dropped +// event can damage, and gives players seeking the recording a cheap resync point. +#define SNAPSHOT_KEYFRAME_EVERY 20 + +if (!isServer || {!SHOULDSAVEEVENTS} || {!EGVAR(extension,sessionReady)}) exitWith {}; + +params [ + ["_kind", "", [""]], + ["_unit", objNull, [objNull]], + ["_payload", createHashMap, [createHashMap]] +]; + +private _eventType = switch (_kind) do { + case "inventory": {"inventorySnapshot"}; + case "medical": {"medicalSnapshot"}; + case "stamina": {"staminaSnapshot"}; + case "radio": {"radioSnapshot"}; + default {""}; +}; +if (_eventType isEqualTo "" || {isNull _unit}) exitWith {}; + +// Object-derived ids only identify the subject; authenticate the sender before +// touching the payload or diff baseline. Keep direct server calls and SP working. +if (isMultiplayer && {isRemoteExecuted} && {remoteExecutedOwner isNotEqualTo owner _unit}) exitWith {}; + +private _ocapId = _unit getVariable [QGVARMAIN(id), -1]; +if (_ocapId < 0) exitWith {}; + +_payload set ["unitId", _ocapId]; +_payload set ["playerUid", getPlayerUID _unit]; + +if (isNil QGVAR(playerSnapshotState)) then {GVAR(playerSnapshotState) = createHashMap}; + +private _frame = GVAR(captureFrameNo); +private _stateKey = format ["%1:%2", _ocapId, _kind]; +private _state = GVAR(playerSnapshotState) get _stateKey; +private _diffCount = 0; +private _writeDiff = EGVAR(settings,trackPlayerSnapshotDiffs) && {!isNil "_state"}; +private _outgoing = _payload; + +if (_writeDiff) then { + _state params ["_baseFrame", "_basePayload", ["_baseDiffCount", 0]]; + _diffCount = _baseDiffCount; + + ([_basePayload, _payload] call FUNC(diffSnapshotPayload)) params ["_changed", "_removed"]; + + switch (true) do { + case (count _changed == 0 && {_removed isEqualTo []}): { + // Identical to the previous snapshot, so there is nothing worth recording. + _outgoing = nil; + }; + case (_diffCount >= SNAPSHOT_KEYFRAME_EVERY): { + _writeDiff = false; + }; + default { + _outgoing = createHashMapFromArray [ + ["unitId", _ocapId], + ["diffOf", _baseFrame], + ["set", _changed] + ]; + if (_removed isNotEqualTo []) then {_outgoing set ["unset", _removed]}; + }; + }; +}; + +if (isNil "_outgoing") exitWith {}; + +GVAR(playerSnapshotState) set [_stateKey, [_frame, _payload, [0, _diffCount + 1] select _writeDiff]]; + +private _command = switch (_kind) do { + case "inventory": {":PLAYER:INVENTORY:"}; + case "medical": {":PLAYER:MEDICAL:"}; + case "stamina": {":PLAYER:STAMINA:"}; + case "radio": {":PLAYER:RADIO:"}; +}; + +[_command, [_frame, _eventType, [_outgoing] call FUNC(encodeJSON)]] call EFUNC(extension,sendData); diff --git a/addons/recorder/fnc_submitStaticWeapon.sqf b/addons/recorder/fnc_submitStaticWeapon.sqf new file mode 100644 index 0000000..1faa85c --- /dev/null +++ b/addons/recorder/fnc_submitStaticWeapon.sqf @@ -0,0 +1,171 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_submitStaticWeapon.sqf + +FUNCTION: OCAP_recorder_fnc_submitStaticWeapon + +Description: + Server-side trust boundary for static weapons being assembled from bags, and + for a unit's claim to have taken one apart. + + The gun itself needs no help from this function: a newly created static weapon + is picked up by like any other vehicle and gets + its own entity record. What the recording is missing is the *act* -- who built + it and when -- which is what the `staticWeapon` event adds, pointing at the + vehicle id so the UI can tie the two together. + + Assembly signals are client-local and reach this function through the standard + round trip described in AGENTS.md: + + - vanilla `WeaponAssembled`, which fires only where the assembling unit is + local. The server registers it for its own AI in + ; players register it through the + client bootstrap in . + - ACE3 CSW, which does not use the engine command at all. Its + `ace_csw_deployWeaponSucceeded` is a local event on the deploying client, + so the bootstrap listens for it and reports the local player as the builder. + + A freshly created static has no OCAP id until the next capture frame, so an + assembly waits for one rather than recording -1 and losing the link to the + entity record. + + Disassembly is not emitted here. `WeaponDisassembled` hands over the two bags + and not the gun, which by then no longer exists, so there is nothing to point + an event at. It is filed as a dated claim instead, and + matches it against the gun that actually + disappears. That also covers ACE CSW's pickup action, which raises no event at + all and leaves the sweep to fall back on the nearest unit. + + Remote callers must own the attributed unit; ids are then read here from the + objects rather than the payload. Direct server calls still cover server AI. + +Parameters: + _action - "assembled" or "disassembled". [String] + _unit - The unit that did it. [Object] + _static - The static weapon. Ignored, and normally objNull, for "disassembled". [Object] + +Returns: + Nothing + +Examples: + > ["assembled", player, _weapon] remoteExecCall [QFUNC(submitStaticWeapon), 2]; + +Public: + No + +Author: + OCAP Improved +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +// How long to wait for the capture loop to hand the new gun an OCAP id. +#define STATIC_ID_TIMEOUT 15 +// One assembly reported through two paths inside this window is one assembly. +#define STATIC_DEDUPE 5 + +if (!isServer) exitWith {}; +if (!SHOULDSAVEEVENTS) exitWith {}; +if (!EGVAR(extension,sessionReady)) exitWith {}; +if (!(missionNamespace getVariable [QEGVAR(settings,trackConstructions), true])) exitWith {}; + +params [ + ["_action", "", [""]], + ["_unit", objNull, [objNull]], + ["_static", objNull, [objNull]] +]; + +if !(_action in ["assembled", "disassembled"]) exitWith {}; +if (isNull _unit) exitWith {}; +if (isMultiplayer && {isRemoteExecuted} && {remoteExecutedOwner isNotEqualTo owner _unit}) exitWith {}; + +private _unitId = _unit getVariable [QGVARMAIN(id), -1]; +private _unitName = name _unit; +private _side = str (side group _unit); + +// A disassembly claim is only half the story: the gun it refers to is already +// gone. Park it for the sweep, which fires the event once it sees which gun +// disappeared. Claims are pruned there by age. +if (_action isEqualTo "disassembled") exitWith { + if (isNil QGVAR(staticDisassembleClaims)) then {GVAR(staticDisassembleClaims) = []}; + GVAR(staticDisassembleClaims) pushBack [_unitId, _unitName, _side, getPosASL _unit, CBA_missionTime]; +}; + +if (isNull _static) exitWith {}; + +if (isNil QGVAR(staticWeaponSeen)) then {GVAR(staticWeaponSeen) = createHashMap}; + +private _now = CBA_missionTime; +private _key = str _static; +if (_now - (GVAR(staticWeaponSeen) getOrDefault [_key, -1e10]) < STATIC_DEDUPE) exitWith {}; +GVAR(staticWeaponSeen) set [_key, _now]; + +// An entry is only ever read inside the STATIC_DEDUPE window that follows it, so +// anything older is dead weight. Without this the map keeps one entry per gun +// ever built for the length of the mission, which is a slow leak on a long +// operation. Swept here rather than on a timer: this is the only writer, and the +// map cannot grow while nothing is being assembled. +private _stale = []; +{ + if (_now - _y >= STATIC_DEDUPE) then {_stale pushBack _x}; +} forEach GVAR(staticWeaponSeen); +{GVAR(staticWeaponSeen) deleteAt _x} forEach _stale; + +private _class = typeOf _static; +// Never store a localized string in a recording -- see AGENTS.md. +private _name = getTextRaw (configFile >> "CfgVehicles" >> _class >> "displayName"); +if (_name isEqualTo "" || {(_name select [0, 1]) isEqualTo "$"}) then {_name = _class}; + +private _emit = { + params ["_static", "_unitId", "_unitName", "_side", "_class", "_name", "_recordingStart"]; + + if (!SHOULDSAVEEVENTS) exitWith {}; + // A queued assembly must not carry old entity ids into a new recording. + if ((missionNamespace getVariable [QGVAR(startTime), -1]) isNotEqualTo _recordingStart) exitWith {}; + + private _vehicleId = -1; + private _pos = [0, 0, 0]; + if (!isNull _static) then { + _vehicleId = _static getVariable [QGVARMAIN(id), -1]; + _pos = getPosASL _static; + }; + + [":EVENT:GENERAL:", [ + GVAR(captureFrameNo) max 0, + "staticWeapon", + [createHashMapFromArray [ + // Always "assembled": the "disassembled" claim exits above and is written + // by the sweep in , which is the + // only place that knows which gun actually vanished. Hardcoded rather than + // read from _action so this cannot quietly start emitting the wrong verb + // if that early exit is ever moved. + ["action", "assembled"], + ["unitId", _unitId], + ["unitName", _unitName], + ["side", _side], + ["vehicleId", _vehicleId], + ["class", _class], + ["name", _name], + ["x", round (_pos select 0)], + ["y", round (_pos select 1)] + ]] call FUNC(encodeJSON) + ]] call EFUNC(extension,sendData); + + if (GVARMAIN(isDebug)) then { + OCAPEXTLOG(ARR2("STATIC WEAPON assembled",_class)); + }; +}; + +// The gun exists but the capture loop has not run since, so wait for the id and +// let the event point at the entity record. On timeout -- an excluded class, or +// a gun deleted again before it was ever captured -- write it anyway without one. +private _fnc_run = { + private _emit = _this select 7; + _this call _emit; +}; + +[ + {(_this select 0) getVariable [QGVARMAIN(id), -1] > -1}, + _fnc_run, + [_static, _unitId, _unitName, _side, _class, _name, GVAR(startTime), _emit], + STATIC_ID_TIMEOUT, + _fnc_run +] call CBA_fnc_waitUntilAndExecute; diff --git a/addons/recorder/fnc_submitZeusCamera.sqf b/addons/recorder/fnc_submitZeusCamera.sqf new file mode 100644 index 0000000..512db93 --- /dev/null +++ b/addons/recorder/fnc_submitZeusCamera.sqf @@ -0,0 +1,70 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_submitZeusCamera.sqf + +FUNCTION: OCAP_recorder_fnc_submitZeusCamera + +Description: + Server-side trust boundary for Zeus camera samples. Clients have no OCAP + functions; they remoteExecCall this with their player object and a payload + sampled from curatorCamera (local to the Zeus machine). + + Identity is derived from the passed object. UAV terminal views are not + sampled — the client only sends when curatorCamera exists. + +Parameters: + _unit - The Zeus player. [Object] + _payload - Camera sample (x, y, dir, fov, pitch). [HashMap] + +Returns: + Nothing + +Examples: + > [player, _payload] remoteExecCall [QFUNC(submitZeusCamera), 2]; + +Public: + No + +Author: + OCAP Improved +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +if (!isServer || {!SHOULDSAVEEVENTS} || {!EGVAR(extension,sessionReady)}) exitWith {}; +if !(EGVAR(settings,trackZeus)) exitWith {}; + +params [ + ["_unit", objNull, [objNull]], + ["_payload", createHashMap, [createHashMap]] +]; + +if (isNull _unit) exitWith {}; +if (isMultiplayer && {isRemoteExecuted} && {remoteExecutedOwner isNotEqualTo owner _unit}) exitWith {}; +if (isNull getAssignedCuratorLogic _unit) exitWith {}; + +private _curatorId = [_unit] call FUNC(ensureZeusEntity); +if (_curatorId < 0) exitWith {}; + +private _xPos = _payload getOrDefault ["x", -1e10]; +private _yPos = _payload getOrDefault ["y", -1e10]; +private _dir = _payload getOrDefault ["dir", 0]; +private _fov = _payload getOrDefault ["fov", 0.75]; +private _pitch = _payload getOrDefault ["pitch", 0]; +if (_xPos isEqualTo -1e10 || {_yPos isEqualTo -1e10}) exitWith {}; + +if (isNil QGVAR(zeusLastCamera)) then {GVAR(zeusLastCamera) = createHashMap}; +private _rounded = [round _xPos, round _yPos, round _dir, parseNumber ((_fov toFixed 3)), parseNumber ((_pitch toFixed 2))]; +if ((GVAR(zeusLastCamera) getOrDefault [_curatorId, []]) isEqualTo _rounded) exitWith {}; +GVAR(zeusLastCamera) set [_curatorId, _rounded]; + +_payload set ["curatorId", _curatorId]; +_payload set ["x", _xPos]; +_payload set ["y", _yPos]; +_payload set ["dir", _dir]; +_payload set ["fov", _fov]; +_payload set ["pitch", _pitch]; + +[":EVENT:GENERAL:", [ + GVAR(captureFrameNo) max 0, + "zeusCamera", + [_payload] call FUNC(encodeJSON) +]] call EFUNC(extension,sendData); diff --git a/addons/recorder/fnc_submitZeusModule.sqf b/addons/recorder/fnc_submitZeusModule.sqf new file mode 100644 index 0000000..2c21a4b --- /dev/null +++ b/addons/recorder/fnc_submitZeusModule.sqf @@ -0,0 +1,98 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_submitZeusModule.sqf + +FUNCTION: OCAP_recorder_fnc_submitZeusModule + +Description: + Server-side trust boundary for Zeus module placements. CuratorObjectPlaced + only fires where the curator logic is local, so the Zeus client reports the + placement through the player-tracking bootstrap. Most ZEN/ACE modules delete + the logic in the same frame, which is why the client samples class, English + name and position before the remoteExec. + + curatorId is derived here from the passed Zeus object. The attached target + id is derived from the passed object, not from the payload. + +Parameters: + _zeus - The curator player whose client observed the placement. [Object] + _attached - Object the module was dropped on, or objNull. [Object] + _payload - Client-sampled class, English display name, category, source, x, y, + and optional attachedName. [HashMap] + +Returns: + Nothing + +Examples: + > [player, _attached, _payload] remoteExecCall [QFUNC(submitZeusModule), 2]; + +Public: + No + +Author: + OCAP Improved +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +if (!isServer || {!SHOULDSAVEEVENTS} || {!EGVAR(extension,sessionReady)}) exitWith {}; +if !(EGVAR(settings,trackZeus)) exitWith {}; +if !(EGVAR(settings,trackZeusModules)) exitWith {}; + +params [ + ["_zeus", objNull, [objNull]], + ["_attached", objNull, [objNull]], + ["_payload", createHashMap, [createHashMap]] +]; + +if (isNull _zeus) exitWith {}; +if (isMultiplayer && {isRemoteExecuted} && {remoteExecutedOwner isNotEqualTo owner _zeus}) exitWith {}; +if (isNull getAssignedCuratorLogic _zeus) exitWith {}; + +private _curatorId = [_zeus] call FUNC(ensureZeusEntity); +if (_curatorId < 0) exitWith {}; + +private _className = _payload getOrDefault ["className", ""]; +private _displayName = _payload getOrDefault ["displayName", ""]; +private _xPos = _payload getOrDefault ["x", -1e10]; +private _yPos = _payload getOrDefault ["y", -1e10]; +if (!(_className isEqualType "") || {_className isEqualTo ""}) exitWith {}; +if (!(_displayName isEqualType "") || {_displayName isEqualTo ""}) exitWith {}; +if (_xPos isEqualTo -1e10 || {_yPos isEqualTo -1e10}) exitWith {}; + +private _source = _payload getOrDefault ["source", "other"]; +if !(_source in ["zen", "zenCustom", "ace", "vanilla", "other"]) then {_source = "other"}; + +private _category = _payload getOrDefault ["category", ""]; +if !(_category isEqualType "") then {_category = ""}; + +private _event = createHashMapFromArray [ + ["curatorId", _curatorId], + ["curatorName", name _zeus], + ["className", _className], + ["displayName", _displayName], + ["category", _category], + ["source", _source], + ["x", _xPos], + ["y", _yPos] +]; + +if (!isNull _attached) then { + private _attachedId = _attached getVariable [QGVARMAIN(id), -1]; + if (_attachedId >= 0) then { + _event set ["attachedUnitId", _attachedId]; + }; + private _attachedName = _payload getOrDefault ["attachedName", ""]; + if (_attachedName isEqualTo "") then { + if (_attached isKindOf "CAManBase") then { + _attachedName = name _attached; + }; + }; + if (_attachedName isNotEqualTo "") then { + _event set ["attachedName", _attachedName]; + }; +}; + +[":EVENT:GENERAL:", [ + GVAR(captureFrameNo) max 0, + "zeusModule", + [_event] call FUNC(encodeJSON) +]] call EFUNC(extension,sendData); diff --git a/addons/recorder/fnc_submitZeusPing.sqf b/addons/recorder/fnc_submitZeusPing.sqf new file mode 100644 index 0000000..3e58390 --- /dev/null +++ b/addons/recorder/fnc_submitZeusPing.sqf @@ -0,0 +1,74 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_submitZeusPing.sqf + +FUNCTION: OCAP_recorder_fnc_submitZeusPing + +Description: + Server-side trust boundary for player-to-Zeus pings. The vanilla + "CuratorPinged" event handler only fires where the curator logic is local, + which is the Zeus's own machine, so the server can never observe a ping + itself. The Zeus client reports it through the player-tracking bootstrap. + + Identity and position are derived here from the passed objects; the client + sends nothing but two object references. + + One keypress can fire on every curator that can edit the pinging unit, and + the key repeats, so both are collapsed by a per-unit dedupe window. + +Parameters: + _zeus - The curator player whose client observed the ping. [Object] + _pinger - The player who pinged. [Object] + +Returns: + Nothing + +Examples: + > [getAssignedCuratorUnit _curator, _pinger] remoteExecCall [QFUNC(submitZeusPing), 2]; + +Public: + No + +Author: + OCAP Improved +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +if (!isServer || {!SHOULDSAVEEVENTS} || {!EGVAR(extension,sessionReady)}) exitWith {}; +if !(EGVAR(settings,trackZeus)) exitWith {}; +if !(EGVAR(settings,trackZeusPings)) exitWith {}; + +params [ + ["_zeus", objNull, [objNull]], + ["_pinger", objNull, [objNull]] +]; + +if (isNull _zeus || {isNull _pinger}) exitWith {}; +if (isMultiplayer && {isRemoteExecuted} && {remoteExecutedOwner isNotEqualTo owner _zeus}) exitWith {}; +if (isNull getAssignedCuratorLogic _zeus) exitWith {}; + +private _unitId = _pinger getVariable [QGVARMAIN(id), -1]; +if (_unitId < 0) exitWith {}; + +if (isNil QGVAR(zeusPingLast)) then {GVAR(zeusPingLast) = createHashMap}; + +private _now = CBA_missionTime; +private _window = EGVAR(settings,zeusPingMinInterval); +if (_now - (GVAR(zeusPingLast) getOrDefault [_unitId, -1e10]) < _window) exitWith {}; +GVAR(zeusPingLast) set [_unitId, _now]; + +private _curatorId = [_zeus] call FUNC(ensureZeusEntity); +if (_curatorId < 0) exitWith {}; +private _pos = getPosATL _pinger; + +[":EVENT:GENERAL:", [ + GVAR(captureFrameNo) max 0, + "zeusPing", + [createHashMapFromArray [ + ["curatorId", _curatorId], + ["unitId", _unitId], + ["name", name _pinger], + ["side", str (side group _pinger)], + ["x", round (_pos select 0)], + ["y", round (_pos select 1)] + ]] call FUNC(encodeJSON) +]] call EFUNC(extension,sendData); diff --git a/addons/recorder/fnc_trackConstructions.sqf b/addons/recorder/fnc_trackConstructions.sqf new file mode 100644 index 0000000..d1a4d4b --- /dev/null +++ b/addons/recorder/fnc_trackConstructions.sqf @@ -0,0 +1,309 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_trackConstructions.sqf + +FUNCTION: OCAP_recorder_fnc_trackConstructions + +Description: + Records field construction: ACE3 Fortify placements as placed objects, and + static weapons that were assembled during the mission being taken apart again. + + Runs entirely on the server. + + Fortify objects -- sandbag walls, bunkers, H-barriers, wire -- are recorded + through the same placed-object pipeline trenches use, so each one becomes an + oriented, labelled map icon and needs no new event type. ACE raises + `acex_fortify_objectPlaced` as a global event and handles it server-side + itself, so unlike the CSW and Fortify *deploy* events, which are client-local, + this one arrives here for free with the builder attached. + + The object is created on the placing client, so its position has not + necessarily synced when the event arrives. ACE has the same problem for its own + map marker and solves it with a one second delay; this does the same, and reads + the settled position afterwards. + + Static weapon *assembly* is reported by the units themselves -- see + . What is handled here is the one case no + event covers: ACE CSW's pickup action deletes the gun without raising anything, + so a gun we recorded being built and which then disappears intact is reported + as taken apart, credited to the nearest unit. A gun that was destroyed is not: + that is already a kill event. + + Objects nobody can be credited for are not recorded. The placed-object parser + reads the owner id as unsigned and rejects a record with -1, and a fortification + that appeared on its own is not the player action this feature is about. + +Parameters: + None + +Returns: + Nothing + +Examples: + > call FUNC(trackConstructions); + +Public: + No + +Author: + OCAP Improved +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +// Let the placing client's position sync reach the server before reading it. +#define FORTIFY_SETTLE_DELAY 1 +// How often the vanished-gun sweep runs, in seconds. +#define STATIC_SWEEP_INTERVAL 3 +// Credit a disassembly to a unit no further than this from the gun. +#define STATIC_PICKUP_RANGE 12 +// How long a unit's disassembly claim waits for a gun to disappear, in seconds. +#define STATIC_CLAIM_TTL 20 + +if (!isServer) exitWith {}; +if (!(missionNamespace getVariable [QEGVAR(settings,trackConstructions), true])) exitWith { + LOG("CONSTRUCTIONS: disabled by setting"); +}; +if (!isNil QGVAR(constructionsInitialized)) exitWith {}; +GVAR(constructionsInitialized) = true; + +// VARIABLE: OCAP_recorder_staticWeaponSnapshot +// OCAP id -> [class, lastPosASL, englishName] for every static weapon that +// existed at the previous sweep. Comparing it against the current set is how a +// disassembly is detected at all -- see the sweep at the bottom of this file. + +/* + VARIABLE: OCAP_recorder_fortifyIcons + + Base class -> marker icon, checked in order with isKindOf so a mission's own + H-barrier variant still lands on the H-barrier icon. Fortify's placeable set is + mission-defined, so there is no fixed class list to match on and anything + unrecognised falls through to the generic icon. +*/ +GVAR(fortifyIcons) = [ + ["Land_BagBunker_Base_F", "fort_bunker"], + ["Land_BagFence_Base_F", "fort_sandbag"], + ["Land_HBarrier_Base_F", "fort_barrier"], + ["Land_HBarrierWall_base_F", "fort_barrier"], + ["Land_Razorwire_F", "fort_wire"], + ["Land_CncBarrier_F", "fort_barrier"], + ["Land_CncWall4_F", "fort_barrier"], + ["StaticWeapon", "fort_static"], + ["Land_TentDome_F", "fort_tent"], + ["Land_MedicalTent_01_base_F", "fort_tent"] +]; + + +/* + Function: OCAP_recorder_fortifyIconFor (local) + Resolves the marker icon for a fortification class. +*/ +GVAR(fortifyIconFor) = { + params ["_object"]; + + private _icon = "fortification"; + { + _x params ["_base", "_candidate"]; + if (_object isKindOf _base) exitWith {_icon = _candidate}; + } forEach GVAR(fortifyIcons); + + _icon +}; + + +/* + Function: OCAP_recorder_submitFortification (local) + Builds and sends the :PLACED:CREATE: record for one finished fortification. + + Indices 0-8 are the shape mines and ACE explosives already use; 9 and 10 are + the additive direction and icon fields trenches introduced, which an older + extension stops reading before. +*/ +GVAR(submitFortification) = { + params ["_object", "_unit"]; + + if (isNull _object || {isNull _unit}) exitWith {}; + if (!SHOULDSAVEEVENTS) exitWith {}; + if (!EGVAR(extension,sessionReady)) exitWith {}; + + private _ownerId = _unit getVariable [QGVARMAIN(id), -1]; + if (_ownerId isEqualTo -1) exitWith { + if (GVARMAIN(isDebug)) then { + OCAPEXTLOG(ARR2("CONSTRUCTIONS: skipping unattributed fortification",typeOf _object)); + }; + }; + + private _class = typeOf _object; + // Never store a localized string in a recording -- see AGENTS.md. + private _name = getTextRaw (configFile >> "CfgVehicles" >> _class >> "displayName"); + if (_name isEqualTo "" || {(_name select [0, 1]) isEqualTo "$"}) then {_name = _class}; + + private _clock = [daytime, "HH:MM"] call BIS_fnc_timeToString; + private _label = format ["%1 - %2 - %3", _name, name _unit, _clock]; + + private _placedData = [ + GVAR(captureFrameNo), // 0: captureFrameNo + -1, // 1: placedId (assigned by handlePlacedData) + _class, // 2: className + _label, // 3: displayName -- becomes the marker text + (getPosASL _object) joinString ",", // 4: position + _ownerId, // 5: firerOcapId (builder) + str (side group _unit), // 6: side + "fortify", // 7: weapon slot, reused as a kind discriminator + "", // 8: magazineIcon -- superseded by index 10 + getDir _object, // 9: direction (additive) + [_object] call GVAR(fortifyIconFor) // 10: marker icon name (additive) + ]; + + if (GVARMAIN(isDebug)) then { + OCAPEXTLOG(ARR3("CONSTRUCTIONS: recording fortification",_class,_label)); + }; + + // Keep the placed-object locality poll from arming mine handlers on this. A + // fortification is not a projectile, "Explode" on it is a script error that + // aborts the poll for every mine after it, and the record never had a + // detonation or removal to report. + _object setVariable [QGVARMAIN(serverPlacedEHs), true]; + + // handlePlacedData is registered server-side in fnc_eh_fired_server.sqf and + // assigns the placed id there, because GVAR(nextId) only exists on the server. + [QGVARMAIN(handlePlacedData), [_placedData, _object]] call CBA_fnc_localEvent; +}; + + +// ACE3 Fortify. The event is global and ACE listens for it on the server too, so +// this needs no client bootstrap. Fires for every completed placement, cancelled +// ones never get here. +if (isClass (configFile >> "CfgPatches" >> "acex_fortify") || + {isClass (configFile >> "CfgPatches" >> "ace_fortify")}) then { + ["acex_fortify_objectPlaced", { + params ["_unit", "_side", "_object"]; + if (isNull _object) exitWith {}; + + [{ + _this call GVAR(submitFortification); + }, [_object, _unit], FORTIFY_SETTLE_DELAY] call CBA_fnc_waitAndExecute; + }] call CBA_fnc_addEventHandler; + + LOG("CONSTRUCTIONS: ACE Fortify listener registered"); +}; + + +// Static weapon disassembly. +// +// Nothing reports it directly. Vanilla's WeaponDisassembled hands over the two +// bags, not the gun, and by the time it fires the gun is already deleted; ACE +// CSW's pickup action raises nothing at all. What both leave behind is the same +// observable fact -- a static weapon that existed one tick ago is gone, and was +// not destroyed. This sweep watches for exactly that, then credits it to the +// unit whose disassembly claim matches, or failing that to whoever was standing +// closest. +// +// A gun that was destroyed is left alone: that is already a kill event, and its +// wreck is not alive at the last sample. +GVAR(staticWeaponSnapshot) = createHashMap; + +[{ + if (!SHOULDSAVEEVENTS) exitWith {}; + + private _now = CBA_missionTime; + private _previous = GVAR(staticWeaponSnapshot); + private _current = createHashMap; + + // Wrecks stay in the snapshot, flagged dead. Dropping them here instead would + // make a destroyed gun's later cleanup look exactly like a disassembly. + // + // `entities` does the StaticWeapon filter in the engine and, with excludeDead + // false, still returns the wrecks. Walking `vehicles` instead ran a scripted + // isKindOf over every vehicle and weapon holder on the map every sweep. + { + private _id = _x getVariable [QGVARMAIN(id), -1]; + if (_id isEqualTo -1) then {continue}; + + private _known = _previous getOrDefault [_id, []]; + private _name = ""; + if (_known isEqualTo []) then { + // Never store a localized string in a recording -- see AGENTS.md. + _name = getTextRaw (configOf _x >> "displayName"); + if (_name isEqualTo "" || {(_name select [0, 1]) isEqualTo "$"}) then {_name = typeOf _x}; + } else { + _name = _known select 2; + }; + + _current set [_id, [typeOf _x, getPosASL _x, _name, alive _x]]; + } forEach (entities [["StaticWeapon"], [], false, false]); + + // Drop claims nobody can match any more before looking for one. + if (isNil QGVAR(staticDisassembleClaims)) then {GVAR(staticDisassembleClaims) = []}; + GVAR(staticDisassembleClaims) = GVAR(staticDisassembleClaims) select { + _now - (_x select 4) < STATIC_CLAIM_TTL + }; + + { + private _staticId = _x; + if (_staticId in _current) then {continue}; + + (_previous get _staticId) params ["_class", "_lastPos", "_name", ["_wasAlive", true]]; + + // Destroyed, not taken apart. The kill event already covers it. + if (!_wasAlive) then {continue}; + + private _unitId = -1; + private _unitName = ""; + private _side = ""; + + // An exact claim beats a guess: the unit said it took a gun apart, and it + // was standing next to this one when it did. + private _claimIndex = GVAR(staticDisassembleClaims) findIf { + ((_x select 3) distance _lastPos) < STATIC_PICKUP_RANGE + }; + + if (_claimIndex > -1) then { + (GVAR(staticDisassembleClaims) deleteAt _claimIndex) params ["_claimId", "_claimName", "_claimSide"]; + _unitId = _claimId; + _unitName = _claimName; + _side = _claimSide; + } else { + // ACE CSW pickup, or a Zeus deletion. Credit the nearest unit, or nobody. + private _best = objNull; + private _bestDistance = STATIC_PICKUP_RANGE; + { + if (!alive _x) then {continue}; + if ((_x getVariable [QGVARMAIN(id), -1]) isEqualTo -1) then {continue}; + private _distance = _x distance _lastPos; + if (_distance < _bestDistance) then { + _bestDistance = _distance; + _best = _x; + }; + } forEach (_lastPos nearEntities ["CAManBase", STATIC_PICKUP_RANGE]); + + if (!isNull _best) then { + _unitId = _best getVariable [QGVARMAIN(id), -1]; + _unitName = name _best; + _side = str (side group _best); + }; + }; + + [":EVENT:GENERAL:", [ + GVAR(captureFrameNo) max 0, + "staticWeapon", + [createHashMapFromArray [ + ["action", "disassembled"], + ["unitId", _unitId], + ["unitName", _unitName], + ["side", _side], + ["vehicleId", _staticId], + ["class", _class], + ["name", _name], + ["x", round (_lastPos select 0)], + ["y", round (_lastPos select 1)] + ]] call FUNC(encodeJSON) + ]] call EFUNC(extension,sendData); + + if (GVARMAIN(isDebug)) then { + OCAPEXTLOG(ARR3("STATIC WEAPON disassembled",_class,_unitId)); + }; + } forEach (keys _previous); + + GVAR(staticWeaponSnapshot) = _current; +}, STATIC_SWEEP_INTERVAL] call CBA_fnc_addPerFrameHandler; + +LOG("CONSTRUCTIONS: fortification and static weapon tracking initialized"); diff --git a/addons/recorder/fnc_trackServicing.sqf b/addons/recorder/fnc_trackServicing.sqf new file mode 100644 index 0000000..42610ce --- /dev/null +++ b/addons/recorder/fnc_trackServicing.sqf @@ -0,0 +1,342 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_trackServicing.sqf + +FUNCTION: OCAP_recorder_fnc_trackServicing + +Description: + Records vehicles being repaired, refuelled and rearmed as additive + `serviceEvent` general events, so a recording shows the logistics side of a + mission instead of a damaged truck silently becoming a healthy one. + + Runs entirely on the server. + + Repair and refuel are detected by watching state, not by hooking a mod. + `damage` and `fuel` are engine properties synchronised to every machine, so + the server sees them change no matter who did the work -- ACE repair, ACE + refuel, a vanilla repair truck, a Zeus heal, or a mission script. Hooking ACE + instead would record only ACE: `ace_repair` publishes its result with + `ace_repair_setVehicleHitPointDamage`, a targetEvent that fires where the + *vehicle* is local, which on a dedicated server is not reliably the server, + and `ace_refuel` raises no completion event at all. + + Rearm is the exception and is taken from ACE, because ammo state is not + readable for a remote turret the way fuel and damage are. + `ace_rearm_rearmSuccessEH` is a serverEvent ACE already sends for every + client-side rearm, and `ace_rearm_rearmSuccessLocalEH` covers the case where + the server itself finished the progress bar. Both carry the same arguments, so + a short dedupe window keeps a vehicle that is local to the server -- which + receives both -- from being recorded twice. + + Servicing is gradual: ACE refuel adds fuel continuously and a repair walks one + hit point at a time. Sampling would therefore emit one event per tick for the + whole minute a refuel takes. Instead each vehicle+kind opens a *session* on the + first improvement, extends it while the improvement continues, and writes one + event when it stops, spanning the whole job. `from` and `to` are that span. + + Attribution is by proximity, deliberately: nothing in the engine says who + repaired a vehicle. The nearest unit outside the vehicle within + SERVICE_ATTRIBUTION_RANGE metres when the session opens is credited, and an + unattributed session (Zeus, script) is recorded with unitId -1 rather than + dropped -- the fuel still went in. ACE rearm carries its own engineer and does + not guess. + + Only overall `damage` is watched, not individual hit points. A wheel repaired + on an otherwise healthy truck can leave overall damage unchanged and is + therefore not recorded; that is a deliberate trade against reading + getAllHitPointsDamage for every vehicle on every tick. + +Parameters: + None + +Returns: + Nothing + +Examples: + > call FUNC(trackServicing); + +Public: + No + +Author: + OCAP Improved +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +#define SERVICE_ATTRIBUTION_RANGE 20 +// Ignore sub-percent noise in the completed session, not in each sample. +#define SERVICE_MIN_STEP 0.01 +// A session is written out once nothing has improved for this long. +#define SERVICE_IDLE_SECONDS 8 +// A rearm reported through both ACE paths inside this window is one rearm. +#define SERVICE_REARM_DEDUPE 3 + +if (!isServer) exitWith {}; +if (!(missionNamespace getVariable [QEGVAR(settings,trackServicing), true])) exitWith { + LOG("SERVICING: disabled by setting"); +}; +if (!isNil QGVAR(servicingPFH)) exitWith {}; + +// VARIABLE: OCAP_recorder_serviceBaseline +// Vehicle OCAP id -> [lastFuel, lastDamage]. The value the next sample is +// compared against, and the value a new session starts from. +GVAR(serviceBaseline) = createHashMap; + +// VARIABLE: OCAP_recorder_serviceSessions +// ":" -> [vehicle, vehicleId, kind, unitId, unitName, side, +// from, to, lastChangeTime]. Only jobs currently in progress live here, so it is +// empty most of the time. +GVAR(serviceSessions) = createHashMap; + +// VARIABLE: OCAP_recorder_serviceRearmSeen +// Rearm dedupe keys -> mission time of the last accepted report. +GVAR(serviceRearmSeen) = createHashMap; + + +/* + Function: OCAP_recorder_serviceAttribution (local) + Credits the nearest unit standing outside the vehicle. Crew are excluded: a + driver sitting in a truck being refuelled by someone else must not take the + credit. Returns [unitId, unitName, side], or [-1, "", ""] when nobody is close. +*/ +GVAR(serviceAttribution) = { + params ["_vehicle"]; + + private _crew = crew _vehicle; + private _best = objNull; + private _bestDistance = SERVICE_ATTRIBUTION_RANGE; + + { + if (_x in _crew) then {continue}; + if (!alive _x) then {continue}; + if ((_x getVariable [QGVARMAIN(id), -1]) isEqualTo -1) then {continue}; + private _distance = _x distance _vehicle; + if (_distance < _bestDistance) then { + _bestDistance = _distance; + _best = _x; + }; + } forEach (_vehicle nearEntities ["CAManBase", SERVICE_ATTRIBUTION_RANGE]); + + if (isNull _best) exitWith {[-1, "", ""]}; + [_best getVariable [QGVARMAIN(id), -1], name _best, str (side group _best)] +}; + + +/* + Function: OCAP_recorder_submitServiceEvent (local) + Writes one serviceEvent. The vehicle display name is resolved with getTextRaw + and cached on the object, because getText would write the recording server's + locale into a file read in eight languages (see AGENTS.md). +*/ +GVAR(submitServiceEvent) = { + params ["_kind", "_vehicle", "_vehicleId", "_unitId", "_unitName", "_side", "_from", "_to", ["_extra", []]]; + + if (!SHOULDSAVEEVENTS) exitWith {}; + if (!EGVAR(extension,sessionReady)) exitWith {}; + if (_vehicleId isEqualTo -1) exitWith {}; + + private _pos = [0, 0, 0]; + private _vehicleName = ""; + if (!isNull _vehicle) then { + _pos = getPosASL _vehicle; + _vehicleName = _vehicle getVariable [QGVAR(serviceDisplayName), ""]; + if (_vehicleName isEqualTo "") then { + _vehicleName = getTextRaw (configOf _vehicle >> "displayName"); + if (_vehicleName isEqualTo "" || {(_vehicleName select [0, 1]) isEqualTo "$"}) then { + _vehicleName = typeOf _vehicle; + }; + _vehicle setVariable [QGVAR(serviceDisplayName), _vehicleName]; + }; + }; + + private _payload = createHashMapFromArray [ + ["kind", _kind], + ["vehicleId", _vehicleId], + ["vehicleName", _vehicleName], + ["unitId", _unitId], + ["unitName", _unitName], + ["side", _side], + ["x", round (_pos select 0)], + ["y", round (_pos select 1)], + ["from", (round (_from * 100)) / 100], + ["to", (round (_to * 100)) / 100] + ]; + + // Rearm adds what was loaded; repair and refuel are fully described by from/to. + { + _payload set [_x select 0, _x select 1]; + } forEach _extra; + + [":EVENT:GENERAL:", [ + GVAR(captureFrameNo) max 0, + "serviceEvent", + [_payload] call FUNC(encodeJSON) + ]] call EFUNC(extension,sendData); + + if (GVARMAIN(isDebug)) then { + OCAPEXTLOG(ARR4("SERVICING",_kind,_vehicleId,_unitId)); + }; +}; + + +/* + Function: OCAP_recorder_closeServiceSession (local) + Emits a finished session and forgets it. Apply the noise threshold to the + whole job so a succession of small improvements is not lost. +*/ +GVAR(closeServiceSession) = { + params ["_key"]; + + private _session = GVAR(serviceSessions) getOrDefault [_key, []]; + if (_session isEqualTo []) exitWith {}; + GVAR(serviceSessions) deleteAt _key; + + _session params ["_vehicle", "_vehicleId", "_kind", "_unitId", "_unitName", "_side", "_from", "_to"]; + private _improvement = if (_kind isEqualTo "refuel") then {_to - _from} else {_from - _to}; + if (_improvement < SERVICE_MIN_STEP) exitWith {}; + + [_kind, _vehicle, _vehicleId, _unitId, _unitName, _side, _from, _to] call GVAR(submitServiceEvent); +}; + + +/* + Function: OCAP_recorder_flushServiceSessions (local) + Writes out every session still open. Called from + before recording is switched off, so a repair still in progress at mission end + is not lost. +*/ +GVAR(flushServiceSessions) = { + { + [_x] call GVAR(closeServiceSession); + } forEach (keys GVAR(serviceSessions)); +}; + + +/* + Function: OCAP_recorder_noteServiceProgress (local) + Opens or extends the session for one vehicle and kind. +*/ +GVAR(noteServiceProgress) = { + params ["_vehicle", "_vehicleId", "_kind", "_from", "_to", "_now"]; + + private _key = format ["%1:%2", _vehicleId, _kind]; + private _session = GVAR(serviceSessions) getOrDefault [_key, []]; + + if (_session isEqualTo []) exitWith { + ([_vehicle] call GVAR(serviceAttribution)) params ["_unitId", "_unitName", "_side"]; + GVAR(serviceSessions) set [_key, [_vehicle, _vehicleId, _kind, _unitId, _unitName, _side, _from, _to, _now]]; + }; + + // Keep the first attribution: the engineer who started the job is the one who + // did it, even if they step away before it finishes. + _session set [7, _to]; + _session set [8, _now]; +}; + + +// The sampler. Walks vehicles rather than GVAR(trackedVehicles), because that +// hashmap only gains an entry once a vehicle's state has changed at least once +// -- a truck parked all mission and then refuelled would never appear in it. +GVAR(servicingPFH) = [{ + if (!SHOULDSAVEEVENTS) exitWith {}; + + private _now = CBA_missionTime; + + { + private _vehicle = _x; + private _vehicleId = _vehicle getVariable [QGVARMAIN(id), -1]; + if (_vehicleId isEqualTo -1) then {continue}; + if (_vehicle getVariable [QGVARMAIN(exclude), false]) then {continue}; + + private _fuel = fuel _vehicle; + private _damage = damage _vehicle; + private _baseline = GVAR(serviceBaseline) getOrDefault [_vehicleId, []]; + + if (_baseline isEqualTo []) then { + GVAR(serviceBaseline) set [_vehicleId, [_fuel, _damage]]; + continue; + }; + + _baseline params ["_lastFuel", "_lastDamage"]; + + // Nearly every vehicle is unchanged on nearly every pass. Skip it before any + // further work or allocation; the baseline it would rewrite is identical. + if (_fuel isEqualTo _lastFuel && {_damage isEqualTo _lastDamage}) then {continue}; + + if (alive _vehicle) then { + // Every positive step keeps the session alive, however small. The session + // retains its original from value and is filtered only when it closes. + if (_fuel > _lastFuel) then { + [_vehicle, _vehicleId, "refuel", _lastFuel, _fuel, _now] call GVAR(noteServiceProgress); + }; + if (_damage < _lastDamage) then { + [_vehicle, _vehicleId, "repair", _lastDamage, _damage, _now] call GVAR(noteServiceProgress); + }; + }; + + _baseline set [0, _fuel]; + _baseline set [1, _damage]; + } forEach vehicles; + + // Close out anything that has stopped progressing, and anything whose vehicle + // is gone -- a destroyed or deleted vehicle still deserves the work already + // done to be recorded. + { + private _session = GVAR(serviceSessions) getOrDefault [_x, []]; + if (_session isEqualTo []) then {continue}; + if (isNull (_session select 0) || {_now - (_session select 8) >= SERVICE_IDLE_SECONDS}) then { + [_x] call GVAR(closeServiceSession); + }; + } forEach (keys GVAR(serviceSessions)); +}, missionNamespace getVariable [QEGVAR(settings,serviceSampleInterval), 5]] call CBA_fnc_addPerFrameHandler; + + +// ACE rearm. Both handlers receive identical arguments; whichever arrives first +// wins and the other is swallowed by the dedupe window. +if (isClass (configFile >> "CfgPatches" >> "ace_rearm")) then { + GVAR(handleAceRearm) = { + params ["_vehicle", "_unit", "_turretPath", "_numMagazines", "_magazineClass", "_numRounds"]; + + if (!SHOULDSAVEEVENTS) exitWith {}; + if (isNull _vehicle) exitWith {}; + + private _vehicleId = _vehicle getVariable [QGVARMAIN(id), -1]; + if (_vehicleId isEqualTo -1) exitWith {}; + + private _now = CBA_missionTime; + private _key = format ["%1:%2:%3", _vehicleId, _magazineClass, _turretPath]; + if (_now - (GVAR(serviceRearmSeen) getOrDefault [_key, -1e10]) < SERVICE_REARM_DEDUPE) exitWith {}; + GVAR(serviceRearmSeen) set [_key, _now]; + + private _unitId = -1; + private _unitName = ""; + private _side = ""; + if (!isNull _unit) then { + _unitId = _unit getVariable [QGVARMAIN(id), -1]; + _unitName = name _unit; + _side = str (side group _unit); + }; + + private _magazineName = getTextRaw (configFile >> "CfgMagazines" >> _magazineClass >> "displayName"); + if (_magazineName isEqualTo "" || {(_magazineName select [0, 1]) isEqualTo "$"}) then { + _magazineName = _magazineClass; + }; + + [ + "rearm", _vehicle, _vehicleId, _unitId, _unitName, _side, 0, 0, + [ + ["magazine", _magazineClass], + ["magazineName", _magazineName], + ["count", _numMagazines], + ["rounds", _numRounds] + ] + ] call GVAR(submitServiceEvent); + }; + + { + [_x, {_this call GVAR(handleAceRearm)}] call CBA_fnc_addEventHandler; + } forEach ["ace_rearm_rearmSuccessEH", "ace_rearm_rearmSuccessLocalEH"]; + + LOG("SERVICING: ACE rearm listeners registered"); +}; + +LOG("SERVICING: repair/refuel/rearm tracking initialized"); diff --git a/addons/recorder/fnc_trackTrenches.sqf b/addons/recorder/fnc_trackTrenches.sqf new file mode 100644 index 0000000..98dada7 --- /dev/null +++ b/addons/recorder/fnc_trackTrenches.sqf @@ -0,0 +1,323 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_trackTrenches.sqf + +FUNCTION: OCAP_recorder_fnc_trackTrenches + +Description: + Records ACE3 and GRAD Trenches through the placed-object pipeline, so each + finished trench becomes a rotated, labelled marker in playback. + + Runs entirely on the server. Detection uses a CBA class "init" event handler + on each concrete trench class, which fires on every machine the object exists + on -- the server included -- regardless of where the trench was created. That + covers every way a player can dig one: + + - ACE3 digging (object is created on the digging client) + - GRAD digging (object is created on the server via spawnTrench) + - GRAD vehicle blade (fnc_vehicleTrenchBuild, a bare createVehicle) + + Neither mod raises a "trench finished" event, so completion is taken from the + public "ace_trenches_progress" variable reaching 1. GRAD republishes it every + second while digging and sets exactly 1 on completion; ACE keeps its per-tick + value local but does publish 0 at placement and 1 on completion, so the 0->1 + edge is visible on the server either way. + + Creator attribution is mod-specific and is resolved after the object appears, + because neither creation signal carries both the object and the digger: + + - ACE3: ace_trenches_placed / ace_trenches_finished global events, which + stamp the digger onto the trench as it is confirmed. + - GRAD: the public "grad_trenches_functions_diggers" array on the trench; + element 0 is the unit that started it. GRAD writes this with a plain + setVariable on the first dig and raises no event, so it has to be read. + + Trenches with no identifiable digger -- editor- or Zeus-placed ones -- are not + recorded at all. The placed-object pipeline parses the owner id as an unsigned + value (ParsePlacedObject -> parseUintFromFloat), so a -1 owner would make the + extension reject the whole record; and a trench nobody dug is not the player + action this feature is about. The owner is therefore stamped onto the trench + as soon as any digger is seen, so it survives that digger disconnecting or + handing the dig over before the trench is finished. + + Removal is deliberately not tracked -- a recorded trench stays on the map for + the rest of the recording. + + Applied during initialization of OCAP in . + +Parameters: + None + +Returns: + Nothing + +Examples: + > call FUNC(trackTrenches); + +Public: + No + +Author: + OCAP-IMPROVED +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +if (!isServer) exitWith {}; +if (!(missionNamespace getVariable [QEGVAR(settings,trackTrenches), true])) exitWith {}; + +// Nothing to do without ACE trenches; GRAD requires it too, so this one check covers both. +if (!isClass (configFile >> "CfgPatches" >> "ace_trenches")) exitWith { + LOG("TRENCHES: ace_trenches not loaded, trench tracking disabled"); +}; + +// VARIABLE: OCAP_recorder_gradTrenches +// True when GRAD Trenches is loaded, which replaces the whole ACE dig flow and +// adds four classes of its own. +GVAR(gradTrenches) = isClass (configFile >> "CfgPatches" >> "grad_trenches_functions"); + +/* + VARIABLE: OCAP_recorder_trenchTypes + + Maps each concrete trench class to [, ]. + + The English name is hardcoded rather than read from config because + `displayName` follows the server's locale, and because GRAD overrides ACE's + two classes with its own wording -- "ACE_envelope_small" reads + "Envelope - Small" on ACE alone and "Trench - Small" once GRAD is loaded. + The config value is still used as a fallback for anything unlisted. + + Only concrete classes are listed. Matching is by `typeOf`, never `isKindOf`: + ACE's placement preview is "ACE_envelope_small_NoGeo", a scope=1 child of the + real class created with createVehicle, so an isKindOf test would record a + ghost trench for every aborted placement. +*/ +GVAR(trenchTypes) = createHashMapFromArray [ + ["ACE_envelope_small", [if (GVAR(gradTrenches)) then {"Trench - Small"} else {"Envelope - Small"}, "trench_small"]], + ["ACE_envelope_big", [if (GVAR(gradTrenches)) then {"Trench - Big"} else {"Envelope - Big"}, "trench_big"]], + ["GRAD_envelope_short", ["Trench - Short", "trench_short"]], + ["GRAD_envelope_long", ["Trench - Long", "trench_long"]], + ["GRAD_envelope_giant", ["Trench - Giant", "trench_giant"]], + ["GRAD_envelope_vehicle", ["Trench - Vehicle", "trench_vehicle"]] +]; + +// VARIABLE: OCAP_recorder_trackedTrenches +// Objects already sent to the extension, so no trench can be recorded twice. +GVAR(trackedTrenches) = []; + +// VARIABLE: OCAP_recorder_pendingTrenches +// Trenches seen but not yet complete, watched by the progress PFH below. +GVAR(pendingTrenches) = []; + + +/* + Function: OCAP_recorder_stampTrenchOwner (local) + Caches whoever is digging a trench onto the trench itself, the first time any + digger is visible. Called repeatedly while the trench is in progress, so the + attribution survives that digger disconnecting, dying, or handing the dig over + before it is finished -- GRAD empties its digger list in all three cases. + Returns the owner unit, or objNull while nobody has claimed it. +*/ +private _fnc_stampTrenchOwner = { + params ["_trench"]; + + // ACE stamps the digger through its own events (registered further down). + private _unit = _trench getVariable [QGVARMAIN(trenchOwner), objNull]; + if (!isNull _unit) exitWith {_unit}; + + // GRAD writes its digger list with a plain public setVariable and no event, + // so it has to be read rather than listened for. + private _diggers = _trench getVariable ["grad_trenches_functions_diggers", []]; + if (_diggers isEqualType [] && {count _diggers > 0}) then { + private _first = _diggers select 0; + if (_first isEqualType objNull && {!isNull _first}) then { + _unit = _first; + _trench setVariable [QGVARMAIN(trenchOwner), _unit]; + }; + }; + + _unit +}; + +GVAR(stampTrenchOwner) = _fnc_stampTrenchOwner; + + +/* + Function: OCAP_recorder_submitTrench (local) + Builds and sends the :PLACED:CREATE: record for a completed trench. + + Indices 0-8 are byte-for-byte the shape mines and ACE explosives already use. + Indices 9 and 10 are additive and optional: an older extension stops reading + after index 8 (ParsePlacedObject guards with `len(data) < 9` and reads by + index), and an older recording simply has no direction or icon. +*/ +private _fnc_submitTrench = { + params ["_trench"]; + + if (isNull _trench) exitWith {}; + if (_trench in GVAR(trackedTrenches)) exitWith {}; + if (!SHOULDSAVEEVENTS) exitWith {}; + + private _class = typeOf _trench; + private _info = GVAR(trenchTypes) getOrDefault [_class, []]; + private _name = ""; + private _icon = "trench"; + if (_info isEqualTo []) then { + // Unlisted class -- fall back to the server's locale and the generic icon. + _name = getText (configFile >> "CfgVehicles" >> _class >> "displayName"); + if (_name isEqualTo "") then {_name = _class}; + } else { + _info params ["_localName", "_localIcon"]; + _name = _localName; + _icon = _localIcon; + }; + + private _owner = [_trench] call GVAR(stampTrenchOwner); + private _ownerId = -1; + if (!isNull _owner) then {_ownerId = _owner getVariable [QGVARMAIN(id), -1]}; + + // No digger, or a digger OCAP never registered as an entity. The placed-object + // parser reads the owner as unsigned and would reject the whole record, so + // drop it here instead of sending something the extension will refuse. + if (_ownerId isEqualTo -1) exitWith { + if (GVARMAIN(isDebug)) then { + OCAPEXTLOG(ARR2("TRENCHES: skipping unattributed trench",_class)); + }; + }; + + // Label shown next to the marker: type, who dug it, and the mission clock at + // the moment it was finished. The marker also carries its own start frame, so + // the timestamp here is purely for readability. + private _clock = [daytime, "HH:MM"] call BIS_fnc_timeToString; + private _label = format ["%1 - %2 - %3", _name, name _owner, _clock]; + + private _side = str (side group _owner); + + private _placedData = [ + GVAR(captureFrameNo), // 0: captureFrameNo + -1, // 1: placedId (assigned by handlePlacedData) + _class, // 2: className + _label, // 3: displayName -- becomes the marker text + (getPosASL _trench) joinString ",", // 4: position + _ownerId, // 5: firerOcapId (digger) + _side, // 6: side + "trench", // 7: weapon slot, reused as a kind discriminator + "", // 8: magazineIcon -- superseded by index 10 + getDir _trench, // 9: direction (additive) + _icon // 10: marker icon name (additive) + ]; + + GVAR(trackedTrenches) pushBackUnique _trench; + + if (GVARMAIN(isDebug)) then { + OCAPEXTLOG(ARR3("TRENCHES: recording",_class,_label)); + }; + + // The placed-object pipeline also polls its objects for a locality change so it + // can re-arm mine handlers on the server, and "Explode" is a projectile-only + // event: on a trench it throws "Unknown enum value" and aborts that poll for + // every mine after it. A trench never explodes and its removal is deliberately + // not tracked (see above), so mark it as already handled. + _trench setVariable [QGVARMAIN(serverPlacedEHs), true]; + + // handlePlacedData is registered server-side in fnc_eh_fired_server.sqf and + // assigns the placed id there, because GVAR(nextId) only exists on the server. + [QGVARMAIN(handlePlacedData), [_placedData, _trench]] call CBA_fnc_localEvent; +}; + +GVAR(submitTrench) = _fnc_submitTrench; + + +/* + Function: OCAP_recorder_watchTrench (local) + Queues a newly seen trench for the completion watcher, ignoring duplicates and + anything already recorded. +*/ +private _fnc_watchTrench = { + params ["_trench"]; + + if (isNull _trench) exitWith {}; + if (!(typeOf _trench in GVAR(trenchTypes))) exitWith {}; + if (_trench in GVAR(trackedTrenches)) exitWith {}; + if (_trench in GVAR(pendingTrenches)) exitWith {}; + + GVAR(pendingTrenches) pushBack _trench; + + if (GVARMAIN(isDebug)) then { + OCAPEXTLOG(ARR2("TRENCHES: watching",typeOf _trench)); + }; +}; + +GVAR(watchTrench) = _fnc_watchTrench; + + +// Class init handlers -- the universal "a trench object now exists" signal. +// Fires on every machine the object exists on, the server included, whichever +// machine actually created it. GRAD relies on the same mechanism in its own +// fnc_initTrench.sqf, which has an isServer branch. +{ + [ + _x, + "init", + {(_this select 0) call GVAR(watchTrench)}, + false, // do not include inherited classes -- keeps the _NoGeo previews out + [], + false // no retroactive sweep: pre-placed trenches have no digger to credit + ] call CBA_fnc_addClassEventHandler; +} forEach (keys GVAR(trenchTypes)); + + +// ACE3 creation events. Both are global events, so they reach the server even +// though the trench object itself is created on the digging client. GRAD +// deletes ACE's self-actions and these never fire when it is loaded, which is +// why the GRAD digger list is read instead. +{ + [_x, { + params ["_unit", "_trench"]; + if (isNull _trench) exitWith {}; + _trench setVariable [QGVARMAIN(trenchOwner), _unit]; + [_trench] call GVAR(watchTrench); + }] call CBA_fnc_addEventHandler; +} forEach ["ace_trenches_placed", "ace_trenches_finished"]; + + +/* + Completion watcher. + + Neither mod raises a completion event. GRAD's closest candidate, + grad_trenches_functions_handleTrenchState, fires both when a dig finishes and + whenever the digger count drops to zero, so it cannot distinguish "built" from + "abandoned". It also fires before the progress value is published, so reading + progress from inside it races the network sync. + + Polling the public progress avoids all of that and is the one mechanism that + works for ACE, GRAD hand-digging, GRAD vehicle digging and pre-placed trenches + alike. The list holds only trenches still being dug, so it is empty most of + the time. +*/ +[{ + if (GVAR(pendingTrenches) isEqualTo []) exitWith {}; + if (!SHOULDSAVEEVENTS) exitWith {}; + + private _stillPending = []; + + { + private _trench = _x; + + // Gone before it was ever finished -- drop it, nothing was recorded. + if (isNull _trench) then {continue}; + + // Remember the digger as soon as one is visible. GRAD clears its digger + // list when a dig is handed over, paused or abandoned, so waiting until the + // trench is finished would lose the attribution. + [_trench] call GVAR(stampTrenchOwner); + + if ((_trench getVariable ["ace_trenches_progress", 0]) >= 1) then { + [_trench] call GVAR(submitTrench); + } else { + _stillPending pushBack _trench; + }; + } forEach GVAR(pendingTrenches); + + GVAR(pendingTrenches) = _stillPending; +}, 2] call CBA_fnc_addPerFrameHandler; + +LOG("TRENCHES: trench tracking initialized"); diff --git a/addons/recorder/fnc_trackZeus.sqf b/addons/recorder/fnc_trackZeus.sqf new file mode 100644 index 0000000..5263c23 --- /dev/null +++ b/addons/recorder/fnc_trackZeus.sqf @@ -0,0 +1,103 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_trackZeus.sqf + +FUNCTION: OCAP_recorder_fnc_trackZeus + +Description: + Server-only poll of Zeus occupancy. Discovers curator players via + allCurators and records start/stop of vanilla/ACE remote control from the + globally broadcast bis_fnc_moduleRemoteControl_owner variable. + + UAV terminal and custom selectPlayer possession are left alone. + +Parameters: + None + +Returns: + Nothing + +Examples: + > call FUNC(trackZeus); + +Public: + No + +Author: + OCAP Improved +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +if (!isServer || {!SHOULDSAVEEVENTS} || {!EGVAR(extension,sessionReady)}) exitWith {}; +if !(EGVAR(settings,trackZeus)) exitWith {}; + +if (isNil QGVAR(zeusByUid)) then {GVAR(zeusByUid) = createHashMap}; + +{ + [getAssignedCuratorUnit _x] call FUNC(ensureZeusEntity); +} forEach allCurators; + +if (isNil QGVAR(trackedUnits)) exitWith {}; + +// VARIABLE: OCAP_recorder_zeusControlledUnits +// OCAP id -> unit, for every unit currently recorded as remote controlled. Those +// are the only units that can still owe a "stopped" event once nobody is able to +// start a new remote control. +if (isNil QGVAR(zeusControlledUnits)) then {GVAR(zeusControlledUnits) = createHashMap}; + +// This runs every capture frame, and trackedUnits keeps every corpse until it is +// deleted -- several hundred on a long mission. Only a living unit can be taken +// over, and only while some curator is assigned to a player; a unit that is +// already controlled is always checked so its release (on death, on the Zeus +// leaving) is still written exactly when the owner variable clears. +private _hasZeus = (allCurators findIf {!isNull getAssignedCuratorUnit _x}) > -1; +// The controlled-unit map is edited inside the loop, so walk a copy of it; it +// holds a handful of units at most. trackedUnits itself is never edited here. +private _candidates = if (_hasZeus) then {GVAR(trackedUnits)} else {+GVAR(zeusControlledUnits)}; + +{ + private _unitId = _x; + private _unit = _y; + if (_unitId < 0) then {continue}; + if (isNull _unit) then { + GVAR(zeusControlledUnits) deleteAt _unitId; + continue; + }; + if (!alive _unit && {!(_unitId in GVAR(zeusControlledUnits))}) then {continue}; + + private _owner = _unit getVariable ["bis_fnc_moduleRemoteControl_owner", objNull]; + private _curatorId = if (isNull _owner) then {-1} else {[_owner] call FUNC(ensureZeusEntity)}; + private _prevId = _unit getVariable [QGVAR(lastZeusCuratorId), -1]; + if (_curatorId isEqualTo _prevId) then {continue}; + _unit setVariable [QGVAR(lastZeusCuratorId), _curatorId]; + if (_curatorId >= 0) then { + GVAR(zeusControlledUnits) set [_unitId, _unit]; + } else { + GVAR(zeusControlledUnits) deleteAt _unitId; + }; + + if (_prevId >= 0) then { + [":EVENT:GENERAL:", [ + GVAR(captureFrameNo) max 0, + "zeusRemoteControl", + [createHashMapFromArray [ + ["curatorId", _prevId], + ["unitId", _unitId], + ["active", false] + ]] call FUNC(encodeJSON) + ]] call EFUNC(extension,sendData); + }; + + if (_curatorId >= 0) then { + [":EVENT:GENERAL:", [ + GVAR(captureFrameNo) max 0, + "zeusRemoteControl", + [createHashMapFromArray [ + ["curatorId", _curatorId], + ["unitId", _unitId], + ["playerUid", getPlayerUID _owner], + ["playerName", name _owner], + ["active", true] + ]] call FUNC(encodeJSON) + ]] call EFUNC(extension,sendData); + }; +} forEach _candidates; diff --git a/addons/recorder/stringtable.xml b/addons/recorder/stringtable.xml new file mode 100644 index 0000000..f7ee39a --- /dev/null +++ b/addons/recorder/stringtable.xml @@ -0,0 +1,431 @@ + + + + + + Player Detail Tracking + Player Detail Tracking + Sledování detailů hráče + Suivi détaillé des joueurs + Tracciamento dettagli giocatore + Детальное отслеживание игроков + Spielerdetail-Erfassung + Pelaajatietojen seuranta + + + Inventory Snapshot Interval + Inventory Snapshot Interval + Interval snímku inventáře + Intervalle d'inventaire + Intervallo snapshot inventario + Интервал снимка инвентаря + Inventar-Snapshot-Intervall + Varustetallenteen väli + + + Seconds between inventory snapshots. Unchanged gear costs nothing to sample, so short intervals are cheap. Default: 120 + Seconds between inventory snapshots. Unchanged gear costs nothing to sample, so short intervals are cheap. Default: 120 + Sekundy mezi snímky inventáře. Nezměněná výstroj se zahodí, krátké intervaly jsou proto levné. Výchozí: 120 + Secondes entre les instantanés d'inventaire. Un équipement inchangé n'est pas écrit, les intervalles courts restent peu coûteux. Défaut : 120 + Secondi tra gli snapshot dell'inventario. L'equipaggiamento invariato non viene scritto, quindi gli intervalli brevi costano poco. Predefinito: 120 + Секунды между снимками инвентаря. Неизменённое снаряжение не пишется, короткие интервалы дёшевы. По умолчанию: 120 + Sekunden zwischen Inventar-Snapshots. Unveränderte Ausrüstung wird verworfen, kurze Intervalle sind daher günstig. Standard: 120 + Sekunnit varustetallenteiden välillä. Muuttumaton varustus hylätään, joten lyhyet välit ovat edullisia. Oletus: 120 + + + Critical Medical Snapshot Interval + Critical Medical Snapshot Interval + Interval kritického zdravotního snímku + Intervalle médical critique + Intervallo medico critico + Интервал критического медснимка + Kritisches Medizin-Intervall + Kriittisen lääkintätallenteen väli + + + Seconds between medical snapshots while ACE reports the player unconscious or in cardiac arrest. Default: 10 + Seconds between medical snapshots while ACE reports the player unconscious or in cardiac arrest. Default: 10 + Sekundy mezi snímky, když ACE hlásí bezvědomí nebo srdeční zástavu. Výchozí: 10 + Secondes entre les instantanés lorsque ACE signale une inconscience ou un arrêt cardiaque. Défaut : 10 + Secondi tra gli snapshot quando ACE segnala incoscienza o arresto cardiaco. Predefinito: 10 + Секунды между снимками, когда ACE сообщает о потере сознания или остановке сердца. По умолчанию: 10 + Sekunden zwischen Snapshots, wenn ACE Bewusstlosigkeit oder Herzstillstand meldet. Standard: 10 + Sekunnit tallenteiden välillä, kun ACE ilmoittaa tajuttomuudesta tai sydänpysähdyksestä. Oletus: 10 + + + Medical Snapshot Interval + Medical Snapshot Interval + Interval zdravotního snímku + Intervalle médical + Intervallo snapshot medico + Интервал медицинского снимка + Medizin-Snapshot-Intervall + Lääkintätallenteen väli + + + Seconds between medical snapshots while the player is healthy. Default: 60 + Seconds between medical snapshots while the player is healthy. Default: 60 + Sekundy mezi snímky, když je hráč zdravý. Výchozí: 60 + Secondes entre les instantanés médicaux tant que le joueur est en bonne santé. Défaut : 60 + Secondi tra gli snapshot medici mentre il giocatore è sano. Predefinito: 60 + Секунды между снимками, пока игрок здоров. По умолчанию: 60 + Sekunden zwischen medizinischen Snapshots, solange der Spieler gesund ist. Standard: 60 + Sekunnit lääkintätallenteiden välillä pelaajan ollessa terve. Oletus: 60 + + + Radio Snapshot Interval + Radio Snapshot Interval + Interval snímku vysílačky + Intervalle radio + Intervallo snapshot radio + Интервал снимка рации + Funk-Snapshot-Intervall + Radiotallenteen väli + + + Seconds between radio setting snapshots. Frequencies rarely change, and unchanged snapshots are dropped. Default: 120 + Seconds between radio setting snapshots. Frequencies rarely change, and unchanged snapshots are dropped. Default: 120 + Sekundy mezi snímky vysílačky. Frekvence se mění zřídka a nezměněné snímky se zahodí. Výchozí: 120 + Secondes entre les instantanés radio. Les fréquences changent peu, les instantanés identiques sont ignorés. Défaut : 120 + Secondi tra gli snapshot radio. Le frequenze cambiano di rado e gli snapshot identici vengono scartati. Predefinito: 120 + Секунды между снимками рации. Частоты меняются редко, одинаковые снимки отбрасываются. По умолчанию: 120 + Sekunden zwischen Funk-Snapshots. Frequenzen ändern sich selten, unveränderte Snapshots werden verworfen. Standard: 120 + Sekunnit radiotallenteiden välillä. Taajuudet muuttuvat harvoin, muuttumattomat tallenteet hylätään. Oletus: 120 + + + Stamina Snapshot Interval + Stamina Snapshot Interval + Interval snímku výdrže + Intervalle d'endurance + Intervallo snapshot stamina + Интервал снимка выносливости + Ausdauer-Snapshot-Intervall + Kestävyystallenteen väli + + + Seconds between stamina snapshots. Default: 30 + Seconds between stamina snapshots. Default: 30 + Sekundy mezi snímky výdrže. Výchozí: 30 + Secondes entre les instantanés d'endurance. Défaut : 30 + Secondi tra gli snapshot di stamina. Predefinito: 30 + Секунды между снимками выносливости. По умолчанию: 30 + Sekunden zwischen Ausdauer-Snapshots. Standard: 30 + Sekunnit kestävyystallenteiden välillä. Oletus: 30 + + + Fallback TFAR LR Range + Fallback TFAR LR Range + Záložní dosah TFAR LR + Portée TFAR LR de secours + Raggio TFAR LR di riserva + Резервная дальность TFAR LR + Fallback TFAR-LR-Reichweite + TFAR LR -varakantama + + + Map-circle radius in metres used only when a long-range radio's config exposes no tf_range. Default: 20000 + Map-circle radius in metres used only when a long-range radio's config exposes no tf_range. Default: 20000 + Poloměr kruhu na mapě v metrech, jen když LR vysílačka nemá tf_range. Výchozí: 20000 + Rayon du cercle carte en mètres, uniquement si la radio LR n'a pas de tf_range. Défaut : 20000 + Raggio del cerchio sulla mappa in metri, solo se la radio LR non ha tf_range. Predefinito: 20000 + Радиус круга на карте в метрах, только если у LR-рации нет tf_range. По умолчанию: 20000 + Kartenkreisradius in Metern, nur wenn das LR-Funkgerät kein tf_range hat. Standard: 20000 + Karttaympyrän säde metreinä, vain jos LR-radiolla ei ole tf_range-arvoa. Oletus: 20000 + + + Fallback TFAR SW Range + Fallback TFAR SW Range + Záložní dosah TFAR SW + Portée TFAR SW de secours + Raggio TFAR SW di riserva + Резервная дальность TFAR SW + Fallback TFAR-SW-Reichweite + TFAR SW -varakantama + + + Map-circle radius in metres used only when a short-range radio's config exposes no tf_range. Default: 5000 + Map-circle radius in metres used only when a short-range radio's config exposes no tf_range. Default: 5000 + Poloměr kruhu na mapě v metrech, jen když SW vysílačka nemá tf_range. Výchozí: 5000 + Rayon du cercle carte en mètres, uniquement si la radio SW n'a pas de tf_range. Défaut : 5000 + Raggio del cerchio sulla mappa in metri, solo se la radio SW non ha tf_range. Predefinito: 5000 + Радиус круга на карте в метрах, только если у SW-рации нет tf_range. По умолчанию: 5000 + Kartenkreisradius in Metern, nur wenn das SW-Funkgerät kein tf_range hat. Standard: 5000 + Karttaympyrän säde metreinä, vain jos SW-radiolla ei ole tf_range-arvoa. Oletus: 5000 + + + Track Player Inventories + Track Player Inventories + Sledovat inventáře hráčů + Enregistrer les inventaires + Registra gli inventari + Записывать инвентари игроков + Spielerinventare erfassen + Tallenna pelaajien varusteet + + + Snapshot Inventory on Death + Snapshot Inventory on Death + Snímek inventáře při smrti + Instantané d'inventaire à la mort + Snapshot inventario alla morte + Снимок инвентаря при смерти + Inventar beim Tod erfassen + Varustetallenne kuolemassa + + + Immediately record a player's inventory and medical state when they die, independently of the periodic timers. Default: true + Immediately record a player's inventory and medical state when they die, independently of the periodic timers. Default: true + Okamžitě zaznamená inventář a zdravotní stav hráče při smrti, nezávisle na časovačích. Výchozí: ano + Enregistre immédiatement l'inventaire et l'état médical à la mort, indépendamment des minuteurs. Défaut : oui + Registra subito inventario e stato medico alla morte, indipendentemente dai timer. Predefinito: sì + Сразу записывает инвентарь и медсостояние при смерти, независимо от таймеров. По умолчанию: да + Erfasst Inventar und medizinischen Zustand sofort beim Tod, unabhängig von den Timern. Standard: ja + Tallentaa varusteet ja lääkintätilan heti kuolemassa, ajastimista riippumatta. Oletus: kyllä + + + Record a structured gear snapshot for every player. Default: true + Record a structured gear snapshot for every player. Default: true + Zaznamená strukturovaný snímek výstroje každého hráče. Výchozí: ano + Enregistre un instantané d'équipement structuré pour chaque joueur. Défaut : oui + Registra uno snapshot dell'equipaggiamento di ogni giocatore. Predefinito: sì + Записывает структурированный снимок снаряжения каждого игрока. По умолчанию: да + Speichert einen strukturierten Ausrüstungs-Snapshot für jeden Spieler. Standard: ja + Tallentaa rakenteellisen varustetallenteen jokaisesta pelaajasta. Oletus: kyllä + + + Track Player Medical State + Track Player Medical State + Sledovat zdravotní stav hráčů + Suivre l'état médical + Registra lo stato medico + Записывать медсостояние игроков + Medizinischen Zustand erfassen + Tallenna pelaajien lääkintätila + + + Record vanilla medical state plus relevant ACE Medical and KAT data when detected. Default: true + Record vanilla medical state plus relevant ACE Medical and KAT data when detected. Default: true + Zaznamená vanilkový zdravotní stav a data ACE Medical / KAT, pokud jsou načteny. Výchozí: ano + Enregistre l'état vanilla ainsi que ACE Medical et KAT s'ils sont chargés. Défaut : oui + Registra lo stato vanilla e i dati ACE Medical / KAT se presenti. Predefinito: sì + Пишет ванильное состояние и данные ACE Medical / KAT при их наличии. По умолчанию: да + Speichert den Vanilla-Zustand sowie ACE Medical und KAT, sofern geladen. Standard: ja + Tallentaa vaniljatilan sekä ACE Medical- ja KAT-tiedot, jos ne on ladattu. Oletus: kyllä + + + Track Player Radio Settings + Track Player Radio Settings + Sledovat nastavení vysílaček + Suivre les radios + Registra le radio + Записывать настройки раций + Funkgeräte erfassen + Tallenna pelaajien radiot + + + Record active TFAR or ACRE2 radios, frequencies, channels and encryption codes. Default: true + Record active TFAR or ACRE2 radios, frequencies, channels and encryption codes. Default: true + Zaznamená aktivní TFAR/ACRE2 vysílačky, frekvence, kanály a kódy. Výchozí: ano + Enregistre les radios TFAR ou ACRE2 actives, fréquences, canaux et codes. Défaut : oui + Registra radio TFAR o ACRE2 attive, frequenze, canali e codici. Predefinito: sì + Пишет активные рации TFAR/ACRE2, частоты, каналы и коды. По умолчанию: да + Speichert aktive TFAR- oder ACRE2-Funkgeräte, Frequenzen, Kanäle und Codes. Standard: ja + Tallentaa aktiiviset TFAR- tai ACRE2-radiot, taajuudet, kanavat ja koodit. Oletus: kyllä + + + Store Player Snapshots as Diffs + Store Player Snapshots as Diffs + Ukládat snímky jako rozdíly + Enregistrer les instantanés en diffs + Salva gli snapshot come diff + Хранить снимки как различия + Spieler-Snapshots als Diffs speichern + Tallenna pelaajatallenteet eroin + + + Record only what changed since the player's previous snapshot of the same kind, which shrinks recordings dramatically. Turn off to write every snapshot in full for third-party tools that cannot merge diffs. Default: true + Record only what changed since the player's previous snapshot of the same kind, which shrinks recordings dramatically. Turn off to write every snapshot in full for third-party tools that cannot merge diffs. Default: true + Zaznamená jen změny od předchozího snímku stejného typu, což výrazně zmenší záznam. Vypněte, pokud nástroje třetích stran nedokážou sloučit rozdíly. Výchozí: ano + N'enregistre que ce qui a changé depuis le précédent instantané du même type. Désactivez si un outil tiers ne sait pas fusionner les diffs. Défaut : oui + Registra solo ciò che è cambiato rispetto allo snapshot precedente dello stesso tipo. Disattiva se gli strumenti di terze parti non uniscono i diff. Predefinito: sì + Пишет только изменения с прошлого снимка того же типа — записи сильно меньше. Выключите, если сторонние инструменты не умеют сливать различия. По умолчанию: да + Speichert nur Änderungen seit dem letzten Snapshot derselben Art und verkleinert Aufnahmen stark. Deaktivieren, wenn Dritttools keine Diffs mergen können. Standard: ja + Tallentaa vain muutokset edellisestä saman tyypin tallenteesta. Poista käytöstä, jos kolmannen osapuolen työkalut eivät osaa yhdistää eroavaisuuksia. Oletus: kyllä + + + Track Player Stamina + Track Player Stamina + Sledovat výdrž hráčů + Suivre l'endurance + Registra la stamina + Записывать выносливость + Spielerausdauer erfassen + Tallenna pelaajien kestävyys + + + Record vanilla stamina, carried weight, and ACE Advanced Fatigue factors when detected. Default: true + Record vanilla stamina, carried weight, and ACE Advanced Fatigue factors when detected. Default: true + Zaznamená vanilkovou výdrž, nesenou hmotnost a ACE Advanced Fatigue, pokud je načten. Výchozí: ano + Enregistre l'endurance vanilla, le poids porté et ACE Advanced Fatigue s'il est chargé. Défaut : oui + Registra stamina vanilla, peso trasportato e ACE Advanced Fatigue se presente. Predefinito: sì + Пишет ванильную выносливость, переносимый вес и ACE Advanced Fatigue при наличии. По умолчанию: да + Speichert Vanilla-Ausdauer, getragenes Gewicht und ACE Advanced Fatigue, sofern geladen. Standard: ja + Tallentaa vaniljakestävyyden, kannetun painon ja ACE Advanced Fatiguen, jos se on ladattu. Oletus: kyllä + + + Track Player Traits + Track Player Traits + Sledovat vlastnosti hráčů + Suivre les traits des joueurs + Traccia i tratti dei giocatori + Записывать навыки игроков + Spielerfähigkeiten erfassen + Tallenna pelaajien taidot + + + Record each player's engine traits (medic, engineer, explosive specialist, UAV hacker, coefficients, custom traits) and ACE training levels (medic/doctor, engineer/advanced, EOD) with their inventory snapshot. Default: true + Record each player's engine traits (medic, engineer, explosive specialist, UAV hacker, coefficients, custom traits) and ACE training levels (medic/doctor, engineer/advanced, EOD) with their inventory snapshot. Default: true + Zaznamená vlastnosti hráče (zdravotník, ženista, pyrotechnik, UAV operátor, koeficienty, vlastní) a úrovně výcviku ACE (zdravotník/doktor, ženista/pokročilý, EOD) spolu se snímkem inventáře. Výchozí: ano + Enregistre les traits du joueur (médecin, ingénieur, démineur, pilote de drone, coefficients, traits personnalisés) et les niveaux de formation ACE (médecin/docteur, ingénieur/avancé, EOD) avec l'instantané d'inventaire. Défaut : oui + Registra i tratti del giocatore (medico, ingegnere, artificiere, hacker UAV, coefficienti, tratti personalizzati) e i livelli di addestramento ACE (medico/dottore, ingegnere/avanzato, EOD) con lo snapshot dell'inventario. Predefinito: sì + Записывает навыки игрока (медик, инженер, сапёр, оператор БПЛА, коэффициенты, пользовательские) и уровни подготовки ACE (медик/врач, инженер/продвинутый, EOD) вместе со снимком инвентаря. По умолчанию: да + Erfasst die Fähigkeiten des Spielers (Sanitäter, Pionier, Sprengstoffexperte, UAV-Hacker, Koeffizienten, eigene) und ACE-Ausbildungsstufen (Sanitäter/Arzt, Pionier/erweitert, EOD) zusammen mit dem Inventar. Standard: ja + Tallentaa pelaajan taidot (lääkintämies, pioneeri, räjähdeasiantuntija, UAV-hakkeri, kertoimet, mukautetut) ja ACE-koulutustasot (lääkintämies/lääkäri, pioneeri/edistynyt, EOD) varustetallenteen mukana. Oletus: kyllä + + + + + Server Performance + Server Performance + + + Track Server FPS + Track Server FPS + + + Record the server FPS at mission start and every 10 seconds. Default: true + Record the server FPS at mission start and every 10 seconds. Default: true + + + + + Weather + Weather + + + Track Weather + Track Weather + + + Record vanilla weather (overcast, rain, fog, wind, humidity, moon phase) at recording start and every 60 seconds. ACE Weather temperature and humidity replace the vanilla readings when ACE Weather is enabled. Default: true + Record vanilla weather (overcast, rain, fog, wind, humidity, moon phase) at recording start and every 60 seconds. ACE Weather temperature and humidity replace the vanilla readings when ACE Weather is enabled. Default: true + + + + + Explosion Tracking + Explosion Tracking + + + Support & Construction Tracking + Support & Construction Tracking + + + Trench Tracking + Trench Tracking + + + Zeus Tracking + Zeus Tracking + + + Minimum Explosion Radius + Minimum Explosion Radius + + + Smallest blast radius worth recording, in metres. Keeps HE cannon bursts out of the event feed while still catching hand grenades. Default: 2 + Smallest blast radius worth recording, in metres. Keeps HE cannon bursts out of the event feed while still catching hand grenades. Default: 2 + + + Servicing Sample Interval + Servicing Sample Interval + + + How often vehicle fuel and damage are sampled, in seconds. Each job is written as one event spanning the whole thing, not one per sample. Default: 5 + How often vehicle fuel and damage are sampled, in seconds. Each job is written as one event spanning the whole thing, not one per sample. Default: 5 + + + Track Fortifications and Static Weapons + Track Fortifications and Static Weapons + + + Record ACE3 Fortify placements as labelled map objects, and static weapons assembled or taken apart as events. Default: Yes + Record ACE3 Fortify placements as labelled map objects, and static weapons assembled or taken apart as events. Default: Yes + + + Track Explosions + Track Explosions + + + Record every detonation as an event carrying the blast radius from CfgAmmo, so playback can draw the area it covered. Default: Yes + Record every detonation as an event carrying the blast radius from CfgAmmo, so playback can draw the area it covered. Default: Yes + + + Track Vehicle Servicing + Track Vehicle Servicing + + + Record vehicles being repaired, refuelled and rearmed. Repair and refuel are read from the vehicle's own state and work with any mod; rearm requires ACE Rearm. Default: Yes + Record vehicles being repaired, refuelled and rearmed. Repair and refuel are read from the vehicle's own state and work with any mod; rearm requires ACE Rearm. Default: Yes + + + Track Trenches + Track Trenches + + + Record ACE3 and GRAD trenches once they are fully dug. Each one becomes an oriented map icon labelled with the trench type, who dug it, and the mission time. No effect without ACE trenches. Default: Yes + Record ACE3 and GRAD trenches once they are fully dug. Each one becomes an oriented map icon labelled with the trench type, who dug it, and the mission time. No effect without ACE trenches. Default: Yes + + + Track Zeus Remote Control + Track Zeus Remote Control + + + Track Zeus Modules + Track Zeus Modules + + + Record each Zeus module a curator places, with an English name. Reported by the Zeus client, since fire-and-forget modules never reach the server. Requires Track Zeus. Default: Yes + Record each Zeus module a curator places, with an English name. Reported by the Zeus client, since fire-and-forget modules never reach the server. Requires Track Zeus. Default: Yes + + + Track Zeus Pings + Track Zeus Pings + + + Record when a player pings Zeus. Reported by the Zeus client, since the server never sees the ping. Requires Track Zeus. Default: Yes + Record when a player pings Zeus. Reported by the Zeus client, since the server never sees the ping. Requires Track Zeus. Default: Yes + + + Record when Zeus remote-controls a unit, and sample the curator camera while the Zeus interface is open. Default: true + Record when Zeus remote-controls a unit, and sample the curator camera while the Zeus interface is open. Default: true + + + Zeus Camera Interval + Zeus Camera Interval + + + Seconds between curator-camera samples while Zeus is flying the map. Default: 1 + Seconds between curator-camera samples while Zeus is flying the map. Default: 1 + + + Zeus Ping Minimum Interval + Zeus Ping Minimum Interval + + + Seconds a player must wait before another of their pings is recorded. Also collapses the duplicate reports a single ping produces when several Zeuses can edit that player. Default: 2 + Seconds a player must wait before another of their pings is recorded. Also collapses the duplicate reports a single ping produces when several Zeuses can edit that player. Default: 2 + + + + diff --git a/docs/player-detail-tracking.md b/docs/player-detail-tracking.md new file mode 100644 index 0000000..b087256 --- /dev/null +++ b/docs/player-detail-tracking.md @@ -0,0 +1,165 @@ +# Player Detail Tracking + +OCAP can record a player's gear, medical state, stamina and radios as extra events +on the existing timeline. The playback card on the web UI reads those events; it +never fetches per-player data itself. + +## Why the sampler runs on the client + +The OCAP PBO and `callExtension` exist only on the **server**. For a remote unit, +`magazinesAmmoFull`, ACE/KAT `getVariable`s, `getStamina` and every `TFAR_fnc_*` / +`acre_api_fnc_*` call return empty, stale or default values. + +The server therefore ships the sampler as a code block +(`fnc_installPlayerTrackingClients.sqf`) with a JIP id. Each client samples its +own local unit and sends the payload back with +`remoteExecCall [OCAP_recorder_fnc_submitPlayerSnapshot, 2]`. The server stamps +`unitId` and `playerUid` itself and is the only machine that talks to the +extension. + +AI units are not sampled. The web profile still opens for them, but the Gear / +Medical / Stamina / Radios tabs stay hidden unless a snapshot of that kind exists. + +## Settings + +All of these live under **Options → Addon Options → OCAP - Recorder → Player Detail Tracking**. +They are server settings (`force` in `cba_settings.sqf`) and are publicVariabled +to clients after init. + +| Setting | Variable | Default | Notes | +|---|---|---|---| +| Track Player Inventories | `OCAP_settings_trackPlayerInventory` | true | Containers, weapons, magazines, assigned items, load | +| Inventory Snapshot Interval | `OCAP_settings_playerInventoryInterval` | 120 s | Also writes immediately when the kit changes. Range 10–1800 | +| Snapshot Inventory on Death | `OCAP_settings_trackPlayerInventoryOnDeath` | true | Inventory + medical after the weapon-drop tick | +| Track Player Medical State | `OCAP_settings_trackPlayerMedical` | true | Vanilla hit points; ACE and KAT when loaded | +| Medical Snapshot Interval | `OCAP_settings_playerMedicalInterval` | 60 s | While healthy. Range 5–300 | +| Critical Medical Snapshot Interval | `OCAP_settings_playerMedicalCriticalInterval` | 10 s | ACE unconscious / cardiac arrest. Range 1–120. A wake-up also writes on the next PFH. | +| Track Player Stamina | `OCAP_settings_trackPlayerStamina` | true | Vanilla pool + ACE Advanced Fatigue when present. Also writes when carried load/mass changes. | +| Stamina Snapshot Interval | `OCAP_settings_playerStaminaInterval` | 30 s | Range 5–300 | +| Track Player Radio Settings | `OCAP_settings_trackPlayerRadio` | true | TFAR and/or ACRE2. Every carried TFAR SW is probed; nothing is written until the unit has actually carried a radio | +| Radio Snapshot Interval | `OCAP_settings_playerRadioInterval` | 120 s | Also writes on frequency / channel / carried-SW change. Range 10–1800 | +| Store Player Snapshots as Diffs | `OCAP_settings_trackPlayerSnapshotDiffs` | true | Unchanged snapshots are dropped; arrays are patched entry by entry; a full keyframe every 20 diffs | +| Fallback TFAR SW Range | `OCAP_settings_radioShortRangeMeters` | 5000 | Used only when the radio has no `tf_range`. Range 100–20 000 | +| Fallback TFAR LR Range | `OCAP_settings_radioLongRangeMeters` | 20000 | Same, for long-range radios. Range 1 000–100 000 | + +Pinned production values: + +```sqf +force OCAP_settings_trackPlayerInventory = true; +force OCAP_settings_playerInventoryInterval = 120; +force OCAP_settings_trackPlayerInventoryOnDeath = true; +force OCAP_settings_trackPlayerMedical = true; +force OCAP_settings_playerMedicalInterval = 60; +force OCAP_settings_playerMedicalCriticalInterval = 10; +force OCAP_settings_trackPlayerStamina = true; +force OCAP_settings_playerStaminaInterval = 30; +force OCAP_settings_trackPlayerRadio = true; +force OCAP_settings_playerRadioInterval = 120; +force OCAP_settings_trackPlayerSnapshotDiffs = true; +force OCAP_settings_radioShortRangeMeters = 5000; +force OCAP_settings_radioLongRangeMeters = 20000; +force OCAP_settings_trackServerFps = true; +force OCAP_settings_trackWeather = true; +``` + +If a deployment restricts `CfgRemoteExec`, whitelist +`OCAP_recorder_fnc_submitPlayerSnapshot` for clients → server or the snapshots +never arrive. + +## What a gear entry carries + +An item on the wire is its class, its count, and — for the lists the profile card +groups, meaning container cargo and magazines — a `cat` of `magazines`, +`grenades` or `medical`. `items` is the default and is left off. + +Two things it deliberately does **not** carry: + +- **The icon path.** The web UI used to guess an item's category by + substring-matching the `.paa` path, which cost about 3 MB per recording for a + path no browser can load. The recorder resolves the category from config + instead: ACE's `ACE_isMedicalItem` (which ACE also sets on the vanilla + `FirstAidKit` and `Medikit`, and which KAM sets on its own items), the + magazines listed under the engine's `Throw` muzzles, and `CfgMagazines` + membership. That is the same source ACE's own arsenal uses. +An item also carries its **display name**, but read with `getTextRaw` rather than +`getText`. `getText` resolves a stringtable reference in the *recording server's* +language, so a Czech server froze "Mapa" and "Obvaz (Elastický)" into recordings +shown to viewers in eight locales. `getTextRaw` returns what the config actually +holds: + +- a **literal** — the text the mod author typed, which is English — and that is + what gets recorded. On a real mission take this covers about 95% of classes: + "5.56mm 30rnd Tracer Mag", "40mm HE Grenade Round (L123)", "Bergen MTP + Rifleman H (B) [BAF]". +- a **`$STR_...` reference** — which SQF cannot read in any language but the one + the game is running, and there is no way to ask for English. The name is built + from the class instead, which is English by construction: `ACE_elasticBandage` + becomes "Elastic Bandage" and `ItemMap` becomes "Map". This is what happens for + vanilla and ACE items. + +Either way the recording holds an English name for every item, including the two +medical labels the recorder resolves itself (IV bags and medications). The web UI +only derives a name when a recording does not carry one at all. + +Recordings made before this change are converted by +`python/convert_recording_gear.py`, which translates a recorded name into English +through the mods' own stringtables and keeps it exactly as recorded when they have +no English for it. + +## Snapshot diffs + +A follow-up snapshot carries only what changed. Nested HashMaps are walked, and +an array whose entries have a unique `class`, `slot` or `part` is patched entry +by entry: + +```json +{"diffOf": 40, "unitId": 7, "set": { + "magazines": {"$key": "class", + "put": [{"class": "UK3CB_BAF_556_30Rnd", "totalRounds": 269}], + "del": ["Chemlight_green"]}}} +``` + +`put` upserts (merging onto the entry already there), `del` removes, and `ord` +appears only when put and del alone would leave the entries out of order — +`assignedItems` is read positionally by the Arsenal export, so its order is data. +Arrays with no unique identifier are still replaced whole. + +Before this, one round leaving a magazine rewrote the player's entire magazine +list; it was the single largest cost in a recording. + +## Medical layering + +Vitals are written vanilla → ACE → KAT. ACE replaces the vanilla hit-point list +in the UI. When KAT is loaded, ACE's `spo2` is not written at all (it sits at +the default 97 and would drown KAT's pulse-ox reading). + +Body treatments (wounds, bandages, IVs, tourniquets, airway items, …) are +generic `{kind, name, count, detail}` items on each body part so a new treatment +does not need a UI change. + +## Radio range + +Every carried TFAR SW from `TFAR_fnc_radiosList` is recorded (the selected one +flagged `active`), plus the active LR. TFAR range is `tf_range` from the radio +(walking `tf_parent` when needed) times the mission sending-distance +multiplicator. ACRE range is `70.71 * sqrt(power_mW)` from the live TX power. +The two fallback sliders are last-resort only. + +On the first capture frame the server also writes `tfarSettings` and +`acreSettings` events with the live CBA values that change coverage. +TFAR stamps `TFAR_terrain_interception_coefficient` (default 7) and +`TFAR_globalRadioRangeCoef` (default 1). ACRE stamps +`acre_sys_core_terrainLoss` (0–1, default 1) and +`acre_sys_signal_signalModel` (default 2 = LOS Multipath). If a mod is +not loaded on the server those defaults are stamped anyway. Approximate +range for an ACRE radio uses ACRE's terrain-loss model, not TFAR's +coefficient. **Simple range** is still the recorded `rangeMeters` circle. + +ACE blood pressure is stored `[diastolic, systolic]` and shown as `120/80`. + +## Playback + +Left panel → Units → click a player row. The card is frame-scoped: it shows the +latest snapshot at or before the playhead. Diffs are rebuilt before display. +Recordings that never wrote these events still play; the extra tabs simply do +not appear. diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..756fa44 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,55 @@ +# Recorder regression tests + +`recorder_regressions.sqf` and `client_sampler_regressions.sqf` exercise the +installed addon's actual submission functions, sampling callbacks, export reset +and restart entry point. They require Arma multiplayer; HEMTT checks/builds do not +execute them. + +Use a **disposable** CBA-only test mission with OCAP on the server only, auto-start +disabled, and two connected players with different network owners. Give one player +an assigned Zeus curator. Name the non-curator player `testClient` and the curator +player `testCurator` in the editor. Copy both scripts into the mission's `tests/` +folder and execute on the server: + +```sqf +[testClient, testCurator] execVM "tests\recorder_regressions.sqf"; +``` + +Run once on a dedicated server and once on a listen server with `testClient` as +the host. Allow `call` and the five tested submission endpoints in the test +mission's `CfgRemoteExec`; the synchronization barrier also uses `call`. + +The RPT must end with `[OCAP regression] ALL TESTS PASSED`, with no script errors. +The suite checks that a real remote client cannot overwrite another player's +medical diff baseline, impersonate a curator, or submit Zeus reports without +curator assignment. Static-weapon assembly/disassembly spoofing must leave no +claim or dedupe state. Positive controls cover legitimate static-weapon owners, +direct server AI calls, snapshot ownership and curator submissions. + +The client subtest uses the actual installed sampler callback from CBA's timed +PFH registry (`CBA_common_PFHhandles` / `CBA_common_perFrameHandlerArray`). It +switches control between identical-kit units, checks independent object and +entity-id changes, and verifies that all caches and radio readiness are reset. +With death snapshots disabled and the inventory interval still at 120 seconds, +each new id must immediately receive a full initial inventory on its next turn; +an unchanged unit must not resend early. The server checks the accepted snapshot +baselines and emitted full payloads. The client restores the original player and +sampler afterwards. + +The same client subtest exercises the installed TFAR LR-code helper with mocked +TFAR APIs and an active `[radioObject, qualifier]` tuple. An empty live code with +codes enabled must query the object's `tf_encryptionCode` and resolve the side +variable. It checks the complete tuple reaches the live-code API, only the object +reaches the property API, and empty side codes, non-empty live codes and globally +disabled codes remain safe. TFAR is not required; mocks are restored afterwards. + +The suite also samples 100 successive fuel/damage improvements of `0.005`, +checks idle closure and whole-session noise suppression, then exports and restarts +with different static membership and reused vehicle ids. Pending disassembly +claims, dedupe state and an assembly callback from the old recording must not +produce events in the new one. + +Extension writes are intercepted, and the servicing clock is advanced only in an +unscheduled block. The suite replaces recording state and removes the servicing +PFH; restart the mission afterwards. It is an opt-in test artifact, not included +in the release PBOs. diff --git a/tests/client_sampler_regressions.sqf b/tests/client_sampler_regressions.sqf new file mode 100644 index 0000000..0cb6d51 --- /dev/null +++ b/tests/client_sampler_regressions.sqf @@ -0,0 +1,151 @@ +/* Sent as code by recorder_regressions.sqf; clients do not load OCAP functions. */ +_this spawn { + params ["_first", "_second", "_startTime"]; + private _originalPlayer = player; + private _tick = {}; + private _removedPFH = false; + private _error = ""; + private _assert = { + params ["_condition", "_description"]; + if (!_condition) then {throw _description}; + diag_log format ["[OCAP regression client] PASS: %1", _description]; + }; + private _names = [ + "OCAP_recorder_recording", "OCAP_recorder_startTime", + "OCAP_settings_trackPlayerInventory", "OCAP_settings_trackPlayerInventoryOnDeath", + "OCAP_settings_trackPlayerMedical", "OCAP_settings_trackPlayerStamina", "OCAP_settings_trackPlayerRadio", + "OCAP_settings_playerInventoryInterval", "OCAP_recorder_client_capturePlayerState", + "OCAP_recorder_client_playerTrackingLast", "OCAP_recorder_client_playerTrackingFingerprint", + "OCAP_recorder_client_playerTrackingSession", "OCAP_recorder_client_playerTrackingUnit", + "OCAP_recorder_client_playerTrackingUnitId", "OCAP_recorder_client_playerTrackingSlot", + "OCAP_recorder_client_radioSeen", "TFAR_radioCodesDisabled", + "TFAR_fnc_activeLrRadio", "TFAR_fnc_getLrRadioCode", "TFAR_fnc_getLrRadioProperty" + ]; + private _saved = createHashMap; + {_saved set [_x, missionNamespace getVariable _x]} forEach _names; + + try { + [!isNil "OCAP_recorder_client_playerTrackingPFH", "server bootstrap installed the client sampler"] call _assert; + // Read the actual callback registered by the addon. This layout comes from + // CBA common/fnc_addPerFrameHandler.sqf, for a non-zero interval handler. + private _index = CBA_common_PFHhandles select OCAP_recorder_client_playerTrackingPFH; + [_index >= 0, "sampler is a timed CBA handler"] call _assert; + _tick = (CBA_common_perFrameHandlerArray select _index) select 0; + [OCAP_recorder_client_playerTrackingPFH] call CBA_fnc_removePerFrameHandler; + _removedPFH = true; + + OCAP_recorder_recording = true; + OCAP_recorder_startTime = _startTime; + OCAP_settings_trackPlayerInventory = true; + OCAP_settings_trackPlayerInventoryOnDeath = false; + OCAP_settings_playerInventoryInterval = 120; + OCAP_settings_trackPlayerMedical = false; + OCAP_settings_trackPlayerStamina = false; + OCAP_settings_trackPlayerRadio = false; + OCAP_test_client_captures = []; + OCAP_test_client_captureOriginal = OCAP_recorder_client_capturePlayerState; + OCAP_recorder_client_capturePlayerState = { + OCAP_test_client_captures pushBack (player getVariable ["OCAP_id", -1]); + _this call OCAP_test_client_captureOriginal; + }; + + private _deadline = diag_tickTime + 10; + waitUntil {sleep 0.05; (local _first && {local _second} && { + (_first getVariable ["OCAP_id", -1]) == 30 && {(_second getVariable ["OCAP_id", -1]) == 31} + }) || {diag_tickTime > _deadline}}; + [local _first && {local _second} && {(_first getVariable ["OCAP_id", -1]) == 30} && { + (_second getVariable ["OCAP_id", -1]) == 31 + }, "replacement units and their server ids reached the client"] call _assert; + private _began = CBA_missionTime; + private _id = _second getVariable ["OCAP_id", -1]; + isNil { + selectPlayer _first; + OCAP_recorder_client_playerTrackingSlot = 0; + for "_i" from 1 to 4 do {call _tick}; + [OCAP_test_client_captures isEqualTo [30], "first unit receives one initial inventory"] call _assert; + for "_i" from 1 to 4 do {call _tick}; + [OCAP_test_client_captures isEqualTo [30], "unchanged unit does not resend before its interval"] call _assert; + private _fingerprint = [player] call OCAP_recorder_client_inventoryFingerprint; + // Populate every old-unit cache, including radio readiness, to verify that + // the replacement invalidates all kinds rather than just inventory. + { + OCAP_recorder_client_playerTrackingLast set [_x, CBA_missionTime]; + if (_x isNotEqualTo "inventory") then { + OCAP_recorder_client_playerTrackingFingerprint set [_x, ["old unit"]]; + }; + } forEach ["inventory", "medical", "stamina", "radio"]; + OCAP_recorder_client_radioSeen = true; + selectPlayer _second; + [_fingerprint isEqualTo ([player] call OCAP_recorder_client_inventoryFingerprint), "replacement has an identical inventory fingerprint"] call _assert; + for "_i" from 1 to 4 do {call _tick}; + [OCAP_test_client_captures isEqualTo [30, 31], "identical kit on a new player unit sends an initial inventory"] call _assert; + { + [(OCAP_recorder_client_playerTrackingLast get _x) == -1e10 && { + !(_x in OCAP_recorder_client_playerTrackingFingerprint) + }, format ["unit replacement resets %1 caches", _x]] call _assert; + } forEach ["medical", "stamina", "radio"]; + [!OCAP_recorder_client_radioSeen, "unit replacement clears radio-seen state"] call _assert; + + // Isolate the object check: timestamps, id and session match, but the + // cached object is from the old life. The next inventory turn is due. + OCAP_recorder_client_playerTrackingUnit = _first; + for "_i" from 1 to 4 do {call _tick}; + [OCAP_test_client_captures isEqualTo [30, 31, 31], "player-object changes invalidate caches independently of id changes"] call _assert; + }; + + // Change only the server-issued entity id on the same controlled object. + [[_second, _id + 1], { + params ["_unit", "_id"]; + _unit setVariable ["OCAP_id", _id, true]; + }] remoteExecCall ["call", 2]; + _deadline = diag_tickTime + 10; + waitUntil {sleep 0.05; (_second getVariable ["OCAP_id", -1]) == _id + 1 || {diag_tickTime > _deadline}}; + [(_second getVariable ["OCAP_id", -1]) == 32, "new server entity id reached the client"] call _assert; + isNil { + for "_i" from 1 to 4 do {call _tick}; + [OCAP_test_client_captures isEqualTo [30, 31, 31, 32], "same player object with a new entity id receives initial inventory"] call _assert; + [CBA_missionTime - _began < 120, "all replacements occurred before the old inventory interval elapsed"] call _assert; + + // Exercise the installed LR helper with an active-radio tuple. These mocks + // enforce TFAR's different API contracts; no TFAR mod is needed in the mission. + OCAP_test_lrRadioObject = _first; + _first setVariable ["tf_encryptionCode", "OCAP_test_lrSideCode"]; + OCAP_test_lrSideCode = "regression-LR-code"; + TFAR_radioCodesDisabled = false; + TFAR_fnc_activeLrRadio = {[OCAP_test_lrRadioObject, "radio_settings"]}; + TFAR_fnc_getLrRadioCode = {OCAP_test_lrCodeArgument = +_this; ""}; + TFAR_fnc_getLrRadioProperty = { + params ["_radio", "_property", ["_default", ""]]; + if !(_radio isEqualType objNull) then {throw "LR property API received a tuple instead of an object"}; + OCAP_test_lrPropertyArguments = +_this; + _radio getVariable [_property, _default] + }; + private _lr = call TFAR_fnc_activeLrRadio; + private _code = [_lr, "LR"] call OCAP_recorder_client_getTfarCode; + [_code isEqualTo "regression-LR-code", "empty live LR code recovers its configured side code"] call _assert; + [OCAP_test_lrCodeArgument isEqualTo _lr, "live LR code API receives the complete active-radio tuple"] call _assert; + [OCAP_test_lrPropertyArguments isEqualTo [_first, "tf_encryptionCode", ""], "LR property API receives the radio object and property"] call _assert; + OCAP_test_lrSideCode = ""; + [([_lr, "LR"] call OCAP_recorder_client_getTfarCode) isEqualTo "", "empty configured side code is accepted without a type error"] call _assert; + TFAR_fnc_getLrRadioCode = {OCAP_test_lrCodeArgument = +_this; "live-LR-code"}; + OCAP_test_lrPropertyArguments = nil; + [([_lr, "LR"] call OCAP_recorder_client_getTfarCode) isEqualTo "live-LR-code" && { + isNil "OCAP_test_lrPropertyArguments" + }, "non-empty live LR code bypasses the property fallback"] call _assert; + TFAR_radioCodesDisabled = true; + OCAP_test_lrCodeArgument = nil; + [([_lr, "LR"] call OCAP_recorder_client_getTfarCode) isEqualTo "" && { + isNil "OCAP_test_lrCodeArgument" && {isNil "OCAP_test_lrPropertyArguments"} + }, "globally disabled codes bypass both LR APIs"] call _assert; + }; + } catch { + _error = _exception; + }; + + selectPlayer _originalPlayer; + {missionNamespace setVariable [_x, _saved get _x]} forEach _names; + if (_removedPFH) then { + OCAP_recorder_client_playerTrackingPFH = [_tick, 1.25] call CBA_fnc_addPerFrameHandler; + }; + [_error, {OCAP_test_samplerError = _this; OCAP_test_done = true}] remoteExecCall ["call", 2]; +}; diff --git a/tests/recorder_regressions.sqf b/tests/recorder_regressions.sqf new file mode 100644 index 0000000..0aab700 --- /dev/null +++ b/tests/recorder_regressions.sqf @@ -0,0 +1,306 @@ +/* Run on the server in a disposable multiplayer mission; see tests/README.md. */ +params ["_client", "_curator"]; +if (!isServer || {!canSuspend}) exitWith {throw "Run the tests with server-side execVM"}; +if (owner _client isEqualTo owner _curator) exitWith {throw "Two different player owners are required"}; +if (!isNull getAssignedCuratorLogic _client || {isNull getAssignedCuratorLogic _curator}) exitWith { + throw "Pass a non-curator client and an assigned curator"; +}; +if (missionNamespace getVariable ["OCAP_recorder_recording", false]) exitWith {throw "Disable auto-start and stop recording first"}; + +private _assert = { + params ["_condition", "_description"]; + if (!_condition) then {throw _description}; + diag_log format ["[OCAP regression] PASS: %1", _description]; +}; +private _savedFunctions = createHashMap; +{ + _savedFunctions set [_x, missionNamespace getVariable _x]; +} forEach [ + "CBA_fnc_addPerFrameHandler", "CBA_fnc_waitUntilAndExecute", + "OCAP_extension_fnc_sendData", "OCAP_recorder_fnc_stopRecording", + "OCAP_recorder_fnc_captureLoop", "OCAP_recorder_fnc_updateTime" +]; +private _vehicle = objNull; +private _gun = objNull; +private _newGun = objNull; +private _authGun = objNull; +private _serverAi = objNull; +private _aiGroup = grpNull; +private _samplerGroup = grpNull; +private _samplerUnits = []; +private _error = ""; +private _savedTime = CBA_missionTime; + +// Intercept extension output, never upload a test recording. Execute the actual +// production PFH callbacks explicitly so sample timing is deterministic. +OCAP_test_commands = []; +OCAP_extension_fnc_sendData = { + params ["_command", ["_args", []]]; + // Ignore unrelated telemetry and lifecycle events from the running mission. + if (_command in [":PLAYER:MEDICAL:", ":PLAYER:INVENTORY:"] || { + _command isEqualTo ":EVENT:GENERAL:" && {(_args param [1, ""]) in [ + "zeusEntity", "zeusCamera", "zeusPing", "zeusModule", "serviceEvent", "staticWeapon" + ]} + }) then {OCAP_test_commands pushBack +_this}; + "" +}; +CBA_fnc_addPerFrameHandler = {OCAP_test_pfh = _this select 0; -1}; +if (!isNil "OCAP_recorder_servicingPFH") then { + [OCAP_recorder_servicingPFH] call CBA_fnc_removePerFrameHandler; +}; +OCAP_recorder_servicingPFH = nil; +OCAP_settings_trackServicing = true; +OCAP_settings_trackConstructions = true; +OCAP_settings_trackZeus = true; +OCAP_settings_trackZeusPings = true; +OCAP_settings_trackZeusModules = true; +OCAP_settings_trackPlayerSnapshotDiffs = true; +// A listen host shares the server namespace. Keep its real periodic sampler +// quiet during endpoint tests; the client subtest enables inventory explicitly. +OCAP_settings_trackPlayerInventory = false; +OCAP_settings_trackPlayerInventoryOnDeath = false; +OCAP_settings_trackPlayerMedical = false; +OCAP_settings_trackPlayerStamina = false; +OCAP_settings_trackPlayerRadio = false; +OCAP_recorder_recording = true; +OCAP_recorder_startTime = 1; +OCAP_recorder_captureFrameNo = 100; +OCAP_recorder_nextId = 10; +OCAP_extension_sessionReady = true; +OCAP_isDebug = false; + +try { + call OCAP_recorder_fnc_trackServicing; + private _sample = OCAP_test_pfh; + OCAP_recorder_constructionsInitialized = nil; + call OCAP_recorder_fnc_trackConstructions; + private _sweep = OCAP_test_pfh; + CBA_fnc_addPerFrameHandler = _savedFunctions get "CBA_fnc_addPerFrameHandler"; + + // Real remoteExec calls: a client spoofs another player's object, including + // an actual curator. A following message from that same sender is the barrier. + _client setVariable ["OCAP_id", 0]; + _curator setVariable ["OCAP_id", 1]; + OCAP_recorder_playerSnapshotState = createHashMap; + ["medical", _curator, createHashMapFromArray [["test", "original"]]] call OCAP_recorder_fnc_submitPlayerSnapshot; + private _before = str OCAP_recorder_playerSnapshotState; + OCAP_recorder_zeusByUid = createHashMap; + OCAP_recorder_zeusLastCamera = createHashMap; + OCAP_recorder_zeusPingLast = createHashMap; + OCAP_recorder_staticDisassembleClaims = []; + OCAP_recorder_staticWeaponSeen = createHashMap; + _authGun = createVehicle ["B_HMG_01_F", [140, 140, 0], [], 0, "NONE"]; + _authGun setVariable ["OCAP_id", 25]; + OCAP_test_commands = []; + OCAP_test_done = false; + [[_client, _curator, _authGun], { + params ["_client", "_curator", "_gun"]; + ["medical", _curator, createHashMapFromArray [["test", "forged"]]] remoteExecCall ["OCAP_recorder_fnc_submitPlayerSnapshot", 2]; + // Both impersonating a curator and claiming curator powers for oneself fail. + { + [_x, createHashMapFromArray [["x", 100], ["y", 200]]] remoteExecCall ["OCAP_recorder_fnc_submitZeusCamera", 2]; + [_x, _curator] remoteExecCall ["OCAP_recorder_fnc_submitZeusPing", 2]; + [_x, objNull, createHashMapFromArray [ + ["className", "Module_F"], ["displayName", "Test"], ["x", 100], ["y", 200] + ]] remoteExecCall ["OCAP_recorder_fnc_submitZeusModule", 2]; + } forEach [_curator, _client]; + ["assembled", _curator, _gun] remoteExecCall ["OCAP_recorder_fnc_submitStaticWeapon", 2]; + ["disassembled", _curator, objNull] remoteExecCall ["OCAP_recorder_fnc_submitStaticWeapon", 2]; + {OCAP_test_done = true} remoteExecCall ["call", 2]; + }] remoteExecCall ["call", owner _client]; + private _deadline = diag_tickTime + 15; + waitUntil {sleep 0.05; OCAP_test_done || {diag_tickTime > _deadline}}; + [OCAP_test_done, "spoofing client completed its requests"] call _assert; + [str OCAP_recorder_playerSnapshotState isEqualTo _before, "spoof cannot replace another player's diff baseline"] call _assert; + [OCAP_test_commands isEqualTo [], "spoofed snapshots, Zeus and static submissions emit nothing"] call _assert; + [count OCAP_recorder_staticWeaponSeen == 0 && {OCAP_recorder_staticDisassembleClaims isEqualTo []}, "static spoofing leaves no assembly dedupe entry or disassembly claim"] call _assert; + [count OCAP_recorder_zeusByUid == 0 && {count OCAP_recorder_zeusLastCamera == 0} && {count OCAP_recorder_zeusPingLast == 0}, "rejected Zeus submissions leave no entity or dedupe state"] call _assert; + + // Positive controls use the same remote endpoint and original client code path. + OCAP_test_done = false; + [_client, { + ["medical", _this, createHashMapFromArray [["test", "own"]]] remoteExecCall ["OCAP_recorder_fnc_submitPlayerSnapshot", 2]; + {OCAP_test_done = true} remoteExecCall ["call", 2]; + }] remoteExecCall ["call", owner _client]; + _deadline = diag_tickTime + 15; + waitUntil {sleep 0.05; OCAP_test_done || {diag_tickTime > _deadline}}; + [OCAP_test_done && {"0:medical" in OCAP_recorder_playerSnapshotState}, "owner can submit their own snapshot (including a listen host)"] call _assert; + OCAP_test_done = false; + [_curator, { + [_this, createHashMapFromArray [["x", 100], ["y", 200]]] remoteExecCall ["OCAP_recorder_fnc_submitZeusCamera", 2]; + [_this, _this] remoteExecCall ["OCAP_recorder_fnc_submitZeusPing", 2]; + [_this, objNull, createHashMapFromArray [ + ["className", "Module_F"], ["displayName", "Test"], ["x", 100], ["y", 200] + ]] remoteExecCall ["OCAP_recorder_fnc_submitZeusModule", 2]; + {OCAP_test_done = true} remoteExecCall ["call", 2]; + }] remoteExecCall ["call", owner _curator]; + _deadline = diag_tickTime + 15; + waitUntil {sleep 0.05; OCAP_test_done || {diag_tickTime > _deadline}}; + [OCAP_test_done && {count OCAP_recorder_zeusLastCamera == 1}, "assigned curator can submit their own camera"] call _assert; + [count OCAP_recorder_zeusPingLast == 1 && {count (OCAP_test_commands select { + (_x select 0) isEqualTo ":EVENT:GENERAL:" && {((_x select 1) select 1) in ["zeusCamera", "zeusPing", "zeusModule"]} + }) == 3}, "assigned curator can submit all three Zeus report types"] call _assert; + + OCAP_test_commands = []; + OCAP_test_done = false; + [[_client, _authGun], { + params ["_unit", "_gun"]; + ["assembled", _unit, _gun] remoteExecCall ["OCAP_recorder_fnc_submitStaticWeapon", 2]; + ["disassembled", _unit, objNull] remoteExecCall ["OCAP_recorder_fnc_submitStaticWeapon", 2]; + {OCAP_test_done = true} remoteExecCall ["call", 2]; + }] remoteExecCall ["call", owner _client]; + _deadline = diag_tickTime + 15; + waitUntil {sleep 0.05; (OCAP_test_done && {count OCAP_test_commands == 1}) || {diag_tickTime > _deadline}}; + [OCAP_test_done && {count OCAP_test_commands == 1} && {count OCAP_recorder_staticDisassembleClaims == 1}, "legitimate remote owner can submit static assembly and disassembly"] call _assert; + [(OCAP_recorder_staticDisassembleClaims select 0 select 0) == 0, "remote owner's disassembly claim retains their unit id"] call _assert; + OCAP_recorder_staticDisassembleClaims = []; + OCAP_recorder_staticWeaponSeen = createHashMap; + OCAP_test_commands = []; + _aiGroup = createGroup [west, true]; + _serverAi = _aiGroup createUnit ["B_Soldier_F", [130, 130, 0], [], 0, "NONE"]; + _serverAi setVariable ["OCAP_id", 26]; + [local _serverAi, "positive control AI is server-local"] call _assert; + ["assembled", _serverAi, _authGun] call OCAP_recorder_fnc_submitStaticWeapon; + ["disassembled", _serverAi, objNull] call OCAP_recorder_fnc_submitStaticWeapon; + _deadline = diag_tickTime + 15; + waitUntil {sleep 0.05; count OCAP_test_commands == 1 || {diag_tickTime > _deadline}}; + [count OCAP_test_commands == 1 && {count OCAP_recorder_staticDisassembleClaims == 1}, "direct server AI calls can submit static assembly and disassembly"] call _assert; + [(OCAP_recorder_staticDisassembleClaims select 0 select 0) == 26, "direct server claim retains the AI unit id"] call _assert; + + // Replace the client's controlled unit with an identical-kit unit, then change + // only its server-issued id. The client runs the actual installed sampler. + _samplerGroup = createGroup [west, true]; + { + private _unit = _samplerGroup createUnit ["B_Soldier_F", [110 + _x, 110, 0], [], 0, "NONE"]; + _unit setVariable ["OCAP_id", _x, true]; + _unit disableAI "ALL"; + _samplerUnits pushBack _unit; + } forEach [30, 31]; + _samplerGroup setGroupOwner (owner _client); + OCAP_test_commands = []; + OCAP_test_done = false; + OCAP_test_samplerError = ""; + [[_samplerUnits select 0, _samplerUnits select 1, OCAP_recorder_startTime], + compile preprocessFileLineNumbers "tests\client_sampler_regressions.sqf" + ] remoteExecCall ["call", owner _client]; + _deadline = diag_tickTime + 30; + waitUntil {sleep 0.05; OCAP_test_done || {diag_tickTime > _deadline}}; + [OCAP_test_done, "client unit-replacement regression finished"] call _assert; + [OCAP_test_samplerError isEqualTo "", format ["client sampler checks: %1", OCAP_test_samplerError]] call _assert; + private _inventories = OCAP_test_commands select {(_x select 0) isEqualTo ":PLAYER:INVENTORY:"}; + [count _inventories == 3, "server accepts one initial inventory per new id and drops the duplicate"] call _assert; + { + private _key = format ["%1:inventory", _x]; + private _state = OCAP_recorder_playerSnapshotState getOrDefault [_key, []]; + [_state isNotEqualTo [] && {(_state select 2) == 0} && {((_state select 1) get "unitId") == _x}, format ["entity %1 has a full initial inventory", _x]] call _assert; + } forEach [30, 31, 32]; + [{((_x select 1 select 2) find """diffOf""") == -1} count _inventories == 3, "new-id inventory events contain full payloads"] call _assert; + + _vehicle = createVehicle ["B_Quadbike_01_F", [100, 100, 0], [], 0, "NONE"]; + _vehicle setVariable ["OCAP_id", 20]; + _vehicle enableSimulationGlobal false; + // Keep this block unscheduled: the real CBA clock and surviving construction + // PFH cannot interleave with these synthetic sample times. + isNil { + _vehicle setFuel 0.5; + _vehicle setDamage 0.5; + CBA_missionTime = 0; + call _sample; + OCAP_test_commands = []; + for "_i" from 1 to 100 do { + _vehicle setFuel (0.5 + _i * 0.005); + _vehicle setDamage (0.5 - _i * 0.005); + CBA_missionTime = _i; + call _sample; + }; + [count OCAP_recorder_serviceSessions == 2, "sub-threshold steps keep refuel and repair sessions open"] call _assert; + [OCAP_test_commands isEqualTo [], "ongoing slow servicing emits no partial jobs"] call _assert; + private _refuel = OCAP_recorder_serviceSessions get "20:refuel"; + private _repair = OCAP_recorder_serviceSessions get "20:repair"; + [abs ((_refuel select 6) - 0.5) < 0.0001 && {abs ((_refuel select 7) - 1) < 0.0001}, "slow refuel retains the whole half-tank span"] call _assert; + [abs ((_repair select 6) - 0.5) < 0.0001 && {abs (_repair select 7) < 0.0001}, "slow repair retains the whole damage span"] call _assert; + CBA_missionTime = 109; + call _sample; + [count OCAP_test_commands == 2 && {count OCAP_recorder_serviceSessions == 0}, "idle timeout emits one event per completed job"] call _assert; + OCAP_test_commands = []; + _vehicle setFuel 0.5; + call _sample; + _vehicle setFuel 0.505; + CBA_missionTime = 110; + call _sample; + call OCAP_recorder_flushServiceSessions; + [OCAP_test_commands isEqualTo [], "a whole job below the noise threshold is dropped"] call _assert; + _vehicle setFuel 0.75; + call _sample; + call OCAP_recorder_flushServiceSessions; + [count OCAP_test_commands == 1, "a normal single-step refuel still records"] call _assert; + OCAP_test_commands = []; + _vehicle setFuel 0.755; + call _sample; + _vehicle setFuel 0.5; + call _sample; + _vehicle setFuel 0.505; + call _sample; + call OCAP_recorder_flushServiceSessions; + [OCAP_test_commands isEqualTo [], "a reversed session cannot record a negative refuel"] call _assert; + CBA_missionTime = _savedTime; + }; + + // Queue an assembly in the first recording without letting CBA execute it. + CBA_fnc_waitUntilAndExecute = {OCAP_test_wait = +_this}; + _gun = createVehicle ["B_HMG_01_F", [150, 150, 0], [], 0, "NONE"]; + ["assembled", _client, _gun] call OCAP_recorder_fnc_submitStaticWeapon; + CBA_fnc_waitUntilAndExecute = _savedFunctions get "CBA_fnc_waitUntilAndExecute"; + _gun setVariable ["OCAP_id", 21]; + call _sweep; + [21 in OCAP_recorder_staticWeaponSnapshot, "first recording tracks its static weapon"] call _assert; + ["disassembled", _client, objNull] call OCAP_recorder_fnc_submitStaticWeapon; + OCAP_recorder_serviceRearmSeen set ["20:mag:[]", CBA_missionTime]; + OCAP_recorder_fnc_stopRecording = { + call OCAP_recorder_flushServiceSessions; + OCAP_recorder_recording = false; + }; + [sideUnknown, "Regression", "Regression", true] call OCAP_recorder_fnc_exportData; + { + [count (missionNamespace getVariable _x) == 0, format ["export clears %1", _x]] call _assert; + } forEach [ + "OCAP_recorder_serviceBaseline", "OCAP_recorder_serviceSessions", "OCAP_recorder_serviceRearmSeen", + "OCAP_recorder_staticWeaponSnapshot", "OCAP_recorder_staticDisassembleClaims", "OCAP_recorder_staticWeaponSeen", + "OCAP_recorder_zeusPingLast" + ]; + + // Start through the real entry point, stubbing only the capture loop/time IO. + OCAP_recorder_fnc_captureLoop = {OCAP_recorder_startTime = 2}; + OCAP_recorder_fnc_updateTime = {}; + OCAP_extension_sessionReady = true; + call OCAP_recorder_fnc_startRecording; + // Changed membership and ordering: a new gun takes the old vehicle id, while + // the old vehicle takes the old gun id with very different fuel/damage values. + deleteVehicle _gun; + _newGun = createVehicle ["B_GMG_01_F", [180, 180, 0], [], 0, "NONE"]; + _newGun setVariable ["OCAP_id", 20]; + _vehicle setVariable ["OCAP_id", 21]; + _vehicle setFuel 1; + _vehicle setDamage 0; + OCAP_test_commands = []; + call _sample; + call _sweep; + call OCAP_recorder_flushServiceSessions; + // Exercise both the success and timeout paths of the old queued callback. + (OCAP_test_wait select 2) call (OCAP_test_wait select 1); + (OCAP_test_wait select 2) call (OCAP_test_wait select 4); + [OCAP_test_commands isEqualTo [], "export then start with reused ids emits no fictitious servicing, disassembly or queued assembly"] call _assert; +} catch { + _error = _exception; +}; + +OCAP_recorder_recording = false; +CBA_missionTime = _savedTime; +{missionNamespace setVariable [_x, _y]} forEach _savedFunctions; +{if (!isNull _x) then {deleteVehicle _x}} forEach ([_vehicle, _gun, _newGun, _authGun, _serverAi] + _samplerUnits); +{if (!isNull _x) then {deleteGroup _x}} forEach [_aiGroup, _samplerGroup]; +if (_error isNotEqualTo "") then { + diag_log format ["[OCAP regression] FAIL: %1", _error]; + throw _error; +}; +diag_log "[OCAP regression] ALL TESTS PASSED";