diff --git a/addons/extension/XEH_prep.sqf b/addons/extension/XEH_prep.sqf index b486114..59bdac1 100644 --- a/addons/extension/XEH_prep.sqf +++ b/addons/extension/XEH_prep.sqf @@ -2,4 +2,5 @@ PREP(sendData); PREP(initSession); +PREP(handleCallback); PREP(newMission); diff --git a/addons/extension/fnc_handleCallback.sqf b/addons/extension/fnc_handleCallback.sqf new file mode 100644 index 0000000..4a987e0 --- /dev/null +++ b/addons/extension/fnc_handleCallback.sqf @@ -0,0 +1,279 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_handleCallback.sqf + +FUNCTION: OCAP_extension_fnc_handleCallback + +Description: + Handles the extension's callbacks, from the ExtensionCallback mission event + handler that adds: storage readiness builds + the mission context, and :MISSION:OK: for the pending registration sets + sessionReady, which lets the recorder send data. + +Parameters: + _name - The extension's name [String] + _function - The callback's function, such as ":MISSION:OK:" [String] + _dataArr - Its data, as an array in a string [String] + +Returns: + Nothing + +Public: + No + +Author: + Fank +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +params ["_name", "_function", "_dataArr"]; + +if (_name != "ocap_recorder") exitWith {}; + +private _data = parseSimpleArray _dataArr; + +TRACE_3("ExtensionCallback",_name,_function,_data); + +if (_function isEqualTo ":SYS:VERSION:") exitWith { + // version return is automatic during extension init process + private _ver = _data#0; + GVAR(dllVersion) = _ver; + publicVariable QGVAR(dllVersion); + INFO_1("Extension version: %1",str _ver); +}; + +if (_function isEqualTo ":SYS:DIR:ARMA:") exitWith { + // arma dir return is automatic during extension init process + private _dir = _data#0; + GVAR(armaDir) = _dir; + INFO_1("Arma directory: %1",_dir); +}; + +if (_function isEqualTo ":SYS:DIR:MODULE:") exitWith { + // module dir return is automatic during extension init process + private _dir = _data#0; + GVAR(addonDir) = _dir; + INFO_1("Addon directory: %1",_dir); +}; + +if (_function isEqualTo ":SYS:DIR:LOG:") exitWith { + // logging dir return is automatic during extension init process + private _dir = _data#0; + GVAR(logPath) = _dir; + INFO_1("Extension logging path: %1",_dir); +}; + +if (_function isEqualTo ":SYS:READY:") exitWith { + INFO("Extension ready."); + // extension is ready, send version + [":SYS:ADDON_VERSION:", [QUOTE(VERSION_STR)], 'ocap_recorder'] call FUNC(sendData); + + // get arma dir and module dir + [":SYS:DIR:ARMA:", [], 'ocap_recorder'] call FUNC(sendData); + [":SYS:DIR:MODULE:", [], 'ocap_recorder'] call FUNC(sendData); + + // get logging dir + [":SYS:DIR:LOG:", [], 'ocap_recorder'] call FUNC(sendData); + + INFO("Initializing storage..."); + [":STORAGE:INIT:", [], 'ocap_recorder'] call FUNC(sendData); +}; + + +if (_function isEqualTo ":STORAGE:ERROR:") exitWith { + private _error = _data#0; + GVAR(storageFailed) = true; // a recording waiting for storage gives up + ERROR_MSG_1("Storage initialization error: %1",_error); +}; + + +if (_function isEqualTo ":STORAGE:OK:") exitWith { + private _engine = _data#0; + INFO_1("Storage initialized: %1",_engine); + if (toLower _engine isEqualTo "sqlite") then { + WARNING("SQLite is used as a local fallback due to Postgres connection error -- the 'migratebackups' command will need to be used to centralize your data!"); + }; + if (toLower _engine isEqualTo "memory") then { + INFO("Memory-only mode active - data will be exported to JSON file"); + }; + + // send mission data + // Write mission and world to DB + + // WORLD + _world = ( configfile >> "CfgWorlds" >> worldName ); + _author = getText( _world >> "author" ); + _name = getText ( _world >> "description" ); + _source = configSourceMod ( _world ); + _workshopID = ''; + { + if ( ( _x#1 ) == _source ) then { + _workshopID = _x#7; + break; + }; + } foreach getLoadedModsInfo; + + GVAR(worldContext) = [createHashMapFromArray [ + ["author", _author], + ["workshopID", _workshopID], + ["displayName", _name], + ["worldName", toLower worldName], + ["worldNameOriginal", worldName], + ["worldSize", getNumber(configFile >> "CfgWorlds" >> worldName >> "worldSize")], + ["latitude", 1 - getNumber(configFile >> "CfgWorlds" >> worldName >> "latitude")], + ["longitude", getNumber(configFile >> "CfgWorlds" >> worldName >> "longitude")] + ]] call CBA_fnc_encodeJSON; + + // MISSION + private _loadedMods = getLoadedModsInfo; + private _addons = []; + { + _addons pushBack [_x#0, _x#7]; // name, workshop id + } forEach _loadedMods; + GVAR(missionContext) = [createHashMapFromArray [ + ["missionName", missionName], + ["briefingName", briefingName], + ["missionNameSource", missionNameSource], + ["onLoadName", getMissionConfigValue ["onLoadName", ""]], + ["author", getMissionConfigValue ["author", ""]], + ["serverName", serverName], + ["serverProfile", profileName], + ["missionStart", nil], + ["worldName", toLower worldName], + ["tag", EGVAR(settings,saveTag)], + ["captureDelay", EGVAR(settings,frameCaptureDelay)], + ["addons", _addons], + ["extensionBuildVersion", EGVAR(extension,version) # 0], + ["extensionBuildCommit", EGVAR(extension,version) # 1], + ["extensionBuildDate", EGVAR(extension,version) # 2], + ["ocapRecorderExtensionVersion", GVAR(dllVersion)], + ["playableSlots", [ + playableSlotsNumber east, + playableSlotsNumber west, + playableSlotsNumber independent, + playableSlotsNumber civilian, + playableSlotsNumber sideLogic + ] + ], + ["sideFriendly", [ + [east, west] call BIS_fnc_sideIsFriendly, + [east, independent] call BIS_fnc_sideIsFriendly, + [west, independent] call BIS_fnc_sideIsFriendly + ]] + ]] call CBA_fnc_encodeJSON; + + // The mission is registered with the extension only when recording + // starts (OCAP_extension_fnc_newMission), so a mission that never meets + // the auto-start conditions leaves nothing behind in storage. The + // websocket storage would otherwise publish it on the web server. + INFO("Mission and world context ready; the mission registers when recording starts"); + TRACE_2("World and mission context",GVAR(worldContext),GVAR(missionContext)); +}; + +// :MISSION:OK: and :MISSION:FAILED: answer the pending registration only +// when they carry its token. An older extension sends none. +private _answersRegistration = { + params ["_token"]; + if (_token isEqualTo (missionNamespace getVariable [QGVAR(registrationToken), ""]) && {GVAR(sessionPending)}) exitWith {true}; + WARNING_1("Ignoring an answer to an earlier mission registration: %1",_function); + false +}; + +if (_function isEqualTo ":MISSION:OK:") exitWith { + // Payload shape: ["OK", token] + if !([_data param [1, missionNamespace getVariable [QGVAR(registrationToken), ""], [""]]] call _answersRegistration) exitWith {}; + INFO_1("Mission registered in %1 s",diag_tickTime - GVAR(initTimer)); + INFO("Mission registered. Starting data send."); + GVAR(sessionPending) = false; + GVAR(sessionReady) = true; +}; + +if (_function isEqualTo ":MISSION:FAILED:") exitWith { + // Payload shape: [error, token] + if !([_data param [1, missionNamespace getVariable [QGVAR(registrationToken), ""], [""]]] call _answersRegistration) exitWith {}; + private _error = _data param [0, "", [""]]; + ERROR_1("The extension could not start the mission; recording will not start: %1",_error); + GVAR(sessionPending) = false; +}; + +if (_function isEqualTo ":MISSION:DISCARDED:") exitWith { + // Payload shapes: ["ok"], ["kept"] (the storage keeps what it recorded), ["error", error] + private _status = _data param [0, "", [""]]; + private _error = _data param [1, "", [""]]; + switch (_status) do { + case "ok": {INFO("Discarded recording dropped by storage")}; + case "kept": {INFO("Discarded recording kept by storage, which cannot drop it")}; + default {WARNING_1("Storage could not drop the discarded recording: %1",_error)}; + }; +}; + +if (_function isEqualTo ":MISSION:SAVED:") exitWith { + // Payload shapes: + // ["ok", path] + // ["partial", path, error] + // ["error", error] + // Type-check each field: if the extension ever sends something + // unexpected, fall back to an empty string rather than crashing + // the callback handler with a type error in the switch below. + private _status = _data param [0, "", [""]]; + private _detail = _data param [1, "", [""]]; + private _extra = _data param [2, "", [""]]; + + // finalDiary appends a final status entry to the OCAPInfo diary + // subject so the interim "being saved" record from fnc_exportData.sqf + // is followed by the authoritative outcome. + private _finalDiary = { + params ["_diaryHtml"]; + [[_diaryHtml], { + params ["_html"]; + player createDiaryRecord [ + "OCAPInfo", + ["Status", _html] + ]; + }] remoteExec ["call", [0, -2] select isDedicated, true]; + }; + + switch (_status) do { + case "ok": { + INFO_1("Mission save complete — path: %1",_detail); + GVAR(lastSaveResult) = ["ok", _detail]; + [ + format["OCAP saved %1 successfully", briefingName], + 2, + [0, 0.8, 0, 1] + ] remoteExec ["CBA_fnc_notify", [0, -2] select isDedicated]; + [format[ + "OCAP capture of %1 has been exported and uploaded successfully.", + briefingName + ]] call _finalDiary; + }; + case "partial": { + WARNING_2("Mission save complete but upload failed — path: %1 error: %2",_detail,_extra); + GVAR(lastSaveResult) = ["partial", _detail, _extra]; + [ + format["OCAP saved locally (%1) but upload failed: %2", _detail, _extra], + 2, + [1, 0.8, 0, 1] + ] remoteExec ["CBA_fnc_notify", [0, -2] select isDedicated]; + [format[ + "OCAP capture of %1 was saved locally to %2, but the upload to the web server failed:
%3", + briefingName, _detail, _extra + ]] call _finalDiary; + }; + case "error": { + ERROR_MSG_1("Mission save failed: %1",_detail); + GVAR(lastSaveResult) = ["error", _detail]; + [ + format["OCAP save failed: %1", _detail], + 2, + [1, 0, 0, 1] + ] remoteExec ["CBA_fnc_notify", [0, -2] select isDedicated]; + [format[ + "OCAP save of %1 failed:
%2", + briefingName, _detail + ]] call _finalDiary; + }; + default { + WARNING_1("Unknown :MISSION:SAVED: status: %1",_status); + }; + }; +}; diff --git a/addons/extension/fnc_initSession.sqf b/addons/extension/fnc_initSession.sqf index 793f841..5444423 100644 --- a/addons/extension/fnc_initSession.sqf +++ b/addons/extension/fnc_initSession.sqf @@ -4,229 +4,12 @@ INFO("Module enabled. Starting up..."); // Initialize session GVAR(sessionReady) = false; +GVAR(sessionPending) = false; -// when the extension is ready, it'll send a callback which will set sessionReady to true and let the other functions being sending data -addMissionEventHandler ["ExtensionCallback", { - params ["_name", "_function", "_dataArr"]; - - if (_name != "ocap_recorder") exitWith {}; - - private _data = parseSimpleArray _dataArr; - - TRACE_3("ExtensionCallback",_name,_function,_data); - - if (_function isEqualTo ":SYS:VERSION:") exitWith { - // version return is automatic during extension init process - private _ver = _data#0; - GVAR(dllVersion) = _ver; - publicVariable QGVAR(dllVersion); - INFO_1("Extension version: %1",str _ver); - }; - - if (_function isEqualTo ":SYS:DIR:ARMA:") exitWith { - // arma dir return is automatic during extension init process - private _dir = _data#0; - GVAR(armaDir) = _dir; - INFO_1("Arma directory: %1",_dir); - }; - - if (_function isEqualTo ":SYS:DIR:MODULE:") exitWith { - // module dir return is automatic during extension init process - private _dir = _data#0; - GVAR(addonDir) = _dir; - INFO_1("Addon directory: %1",_dir); - }; - - if (_function isEqualTo ":SYS:DIR:LOG:") exitWith { - // logging dir return is automatic during extension init process - private _dir = _data#0; - GVAR(logPath) = _dir; - INFO_1("Extension logging path: %1",_dir); - }; - - if (_function isEqualTo ":SYS:READY:") exitWith { - INFO("Extension ready."); - // extension is ready, send version - [":SYS:ADDON_VERSION:", [QUOTE(VERSION_STR)], 'ocap_recorder'] call FUNC(sendData); - - // get arma dir and module dir - [":SYS:DIR:ARMA:", [], 'ocap_recorder'] call FUNC(sendData); - [":SYS:DIR:MODULE:", [], 'ocap_recorder'] call FUNC(sendData); - - // get logging dir - [":SYS:DIR:LOG:", [], 'ocap_recorder'] call FUNC(sendData); - - INFO("Initializing storage..."); - [":STORAGE:INIT:", [], 'ocap_recorder'] call FUNC(sendData); - }; - - - if (_function isEqualTo ":STORAGE:ERROR:") exitWith { - private _error = _data#0; - ERROR_MSG_1("Storage initialization error: %1",_error); - }; - - - if (_function isEqualTo ":STORAGE:OK:") exitWith { - private _engine = _data#0; - INFO_1("Storage initialized: %1",_engine); - if (toLower _engine isEqualTo "sqlite") then { - WARNING("SQLite is used as a local fallback due to Postgres connection error -- the 'migratebackups' command will need to be used to centralize your data!"); - }; - if (toLower _engine isEqualTo "memory") then { - INFO("Memory-only mode active - data will be exported to JSON file"); - }; - - // send mission data - // Write mission and world to DB - - // WORLD - _world = ( configfile >> "CfgWorlds" >> worldName ); - _author = getText( _world >> "author" ); - _name = getText ( _world >> "description" ); - _source = configSourceMod ( _world ); - _workshopID = ''; - { - if ( ( _x#1 ) == _source ) then { - _workshopID = _x#7; - break; - }; - } foreach getLoadedModsInfo; - - GVAR(worldContext) = [createHashMapFromArray [ - ["author", _author], - ["workshopID", _workshopID], - ["displayName", _name], - ["worldName", toLower worldName], - ["worldNameOriginal", worldName], - ["worldSize", getNumber(configFile >> "CfgWorlds" >> worldName >> "worldSize")], - ["latitude", 1 - getNumber(configFile >> "CfgWorlds" >> worldName >> "latitude")], - ["longitude", getNumber(configFile >> "CfgWorlds" >> worldName >> "longitude")] - ]] call CBA_fnc_encodeJSON; - - // MISSION - private _loadedMods = getLoadedModsInfo; - private _addons = []; - { - _addons pushBack [_x#0, _x#7]; // name, workshop id - } forEach _loadedMods; - GVAR(missionContext) = [createHashMapFromArray [ - ["missionName", missionName], - ["briefingName", briefingName], - ["missionNameSource", missionNameSource], - ["onLoadName", getMissionConfigValue ["onLoadName", ""]], - ["author", getMissionConfigValue ["author", ""]], - ["serverName", serverName], - ["serverProfile", profileName], - ["missionStart", nil], - ["worldName", toLower worldName], - ["tag", EGVAR(settings,saveTag)], - ["captureDelay", EGVAR(settings,frameCaptureDelay)], - ["addons", _addons], - ["extensionBuildVersion", EGVAR(extension,version) # 0], - ["extensionBuildCommit", EGVAR(extension,version) # 1], - ["extensionBuildDate", EGVAR(extension,version) # 2], - ["ocapRecorderExtensionVersion", GVAR(dllVersion)], - ["playableSlots", [ - playableSlotsNumber east, - playableSlotsNumber west, - playableSlotsNumber independent, - playableSlotsNumber civilian, - playableSlotsNumber sideLogic - ] - ], - ["sideFriendly", [ - [east, west] call BIS_fnc_sideIsFriendly, - [east, independent] call BIS_fnc_sideIsFriendly, - [west, independent] call BIS_fnc_sideIsFriendly - ]] - ]] call CBA_fnc_encodeJSON; - - // Save mission and world context - INFO("Saving mission and world context"); - TRACE_2("World and mission context",GVAR(worldContext),GVAR(missionContext)); - [":MISSION:START:", [GVAR(worldContext), GVAR(missionContext)], 'ocap_recorder'] call FUNC(sendData); - }; - - if (_function isEqualTo ":MISSION:OK:") exitWith { - INFO_1("Initialization completed in %1ms",diag_tickTime - GVAR(initTimer)); - INFO("Mission registered. Starting data send."); - GVAR(sessionReady) = true; - }; - - if (_function isEqualTo ":MISSION:SAVED:") exitWith { - // Payload shapes: - // ["ok", path] - // ["partial", path, error] - // ["error", error] - // Type-check each field: if the extension ever sends something - // unexpected, fall back to an empty string rather than crashing - // the callback handler with a type error in the switch below. - private _status = _data param [0, "", [""]]; - private _detail = _data param [1, "", [""]]; - private _extra = _data param [2, "", [""]]; - - // finalDiary appends a final status entry to the OCAPInfo diary - // subject so the interim "being saved" record from fnc_exportData.sqf - // is followed by the authoritative outcome. - private _finalDiary = { - params ["_diaryHtml"]; - [[_diaryHtml], { - params ["_html"]; - player createDiaryRecord [ - "OCAPInfo", - ["Status", _html] - ]; - }] remoteExec ["call", [0, -2] select isDedicated, true]; - }; - - switch (_status) do { - case "ok": { - INFO_1("Mission save complete — path: %1",_detail); - GVAR(lastSaveResult) = ["ok", _detail]; - [ - format["OCAP saved %1 successfully", briefingName], - 2, - [0, 0.8, 0, 1] - ] remoteExec ["CBA_fnc_notify", [0, -2] select isDedicated]; - [format[ - "OCAP capture of %1 has been exported and uploaded successfully.", - briefingName - ]] call _finalDiary; - }; - case "partial": { - WARNING_2("Mission save complete but upload failed — path: %1 error: %2",_detail,_extra); - GVAR(lastSaveResult) = ["partial", _detail, _extra]; - [ - format["OCAP saved locally (%1) but upload failed: %2", _detail, _extra], - 2, - [1, 0.8, 0, 1] - ] remoteExec ["CBA_fnc_notify", [0, -2] select isDedicated]; - [format[ - "OCAP capture of %1 was saved locally to %2, but the upload to the web server failed:
%3", - briefingName, _detail, _extra - ]] call _finalDiary; - }; - case "error": { - ERROR_MSG_1("Mission save failed: %1",_detail); - GVAR(lastSaveResult) = ["error", _detail]; - [ - format["OCAP save failed: %1", _detail], - 2, - [1, 0, 0, 1] - ] remoteExec ["CBA_fnc_notify", [0, -2] select isDedicated]; - [format[ - "OCAP save of %1 failed:
%2", - briefingName, _detail - ]] call _finalDiary; - }; - default { - WARNING_1("Unknown :MISSION:SAVED: status: %1",_status); - }; - }; - }; -}]; +// The extension answers through callbacks: storage readiness builds the mission context, and +// :MISSION:OK: for the pending registration sets sessionReady, which lets the recorder send data. +addMissionEventHandler ["ExtensionCallback", {_this call FUNC(handleCallback)}]; INFO("Initializing extension..."); diff --git a/addons/extension/fnc_newMission.sqf b/addons/extension/fnc_newMission.sqf index 3734ded..8f8fe4a 100644 --- a/addons/extension/fnc_newMission.sqf +++ b/addons/extension/fnc_newMission.sqf @@ -4,15 +4,16 @@ FILE: fnc_newMission.sqf FUNCTION: OCAP_extension_fnc_newMission Description: - Re-registers a new mission session with the extension after a previous - recording was exported via fnc_exportData. Reuses the cached world and - mission context from the initial initSession call (same mission is still running). + Registers a recording of the mission with the extension. Uses the world and + mission context initSession cached once storage was initialized. - The existing ExtensionCallback handler (added during initSession) will process - the :MISSION:OK: response and set sessionReady = true. + The ExtensionCallback handler (OCAP_extension_fnc_handleCallback) will process + the :MISSION:OK: response and set sessionReady = true, or :MISSION:FAILED: + and leave it false. sessionPending is true until either arrives. Called from fnc_startRecording when starting a fresh recording with - sessionReady = false. + sessionReady = false: the first recording of the mission, or one after the + previous recording was exported or discarded. Parameters: None @@ -29,9 +30,22 @@ Author: #include "script_component.hpp" if (isNil QGVAR(worldContext) || isNil QGVAR(missionContext)) exitWith { - ERROR("Cannot re-register mission: cached world/mission context is nil. Full initSession required."); + ERROR("Cannot register mission: cached world/mission context is nil. Full initSession required."); + GVAR(sessionPending) = false; // nothing will answer }; -INFO("Re-registering new mission with extension"); +INFO("Registering new mission with extension"); GVAR(initTimer) = diag_tickTime; -[":MISSION:START:", [GVAR(worldContext), GVAR(missionContext)], 'ocap_recorder'] call FUNC(sendData); +GVAR(sessionPending) = true; +// Echoed in :MISSION:OK: and :MISSION:FAILED:, so a late answer to an earlier +// mission's start is not taken for this one's. +GVAR(registrationToken) = format ["%1-%2", floor random 1e6, floor random 1e6]; +GVAR(registrationSentAt) = diag_tickTime; +// Until the recording reaches OCAP_settings_minMissionTime (:MISSION:KEEP:, +// sent by the capture loop), the extension drops it if the game moves on +// without a save or discard, e.g. after #missions or #restart. +GVAR(recordingKept) = EGVAR(recorder,minMissionTime) <= 0; +if (isNil {[":MISSION:START:", [GVAR(worldContext), GVAR(missionContext), GVAR(registrationToken), ["discard", "keep"] select GVAR(recordingKept)], 'ocap_recorder'] call FUNC(sendData)}) then { + ERROR("The extension did not accept the mission; recording will not start."); + GVAR(sessionPending) = false; // nothing will answer +}; diff --git a/addons/recorder/XEH_preInit.sqf b/addons/recorder/XEH_preInit.sqf index 59c1334..065e6ef 100644 --- a/addons/recorder/XEH_preInit.sqf +++ b/addons/recorder/XEH_preInit.sqf @@ -865,7 +865,7 @@ GVAR(allSettings) = [ "CHECKBOX", // setting type [ "Auto-save on MPEnded Event", // Pretty name shown inside the ingame settings menu. Can be stringtable entry. - "If true, automatically save and export the mission when the MPEnded event fires. Default: true" + "If true, automatically save and export the mission when the MPEnded event fires, or discard a recording shorter than the required duration to save. Default: true" ], [COMPONENT_NAME, "Save/Export Settings"], // Pretty name of the category where the setting can be found. Can be stringtable entry. true, // default enabled @@ -902,7 +902,7 @@ GVAR(allSettings) = [ /* CBA Setting: OCAP_settings_minMissionTime Description: - A recording must be at least this long (in minutes) to auto-save. Calling an CBA server event will override this restriction. Default: 20 + A recording must be at least this long (in minutes) to auto-save. With , a shorter recording is discarded when the mission ends, so storage that already holds it (such as the websocket storage) drops it. The websocket storage also drops a shorter recording the game moves on from without saving it, e.g. after #missions or #restart. Calling an CBA server event will override this restriction. Default: 20 Setting Name: Required Duration to Sav @@ -915,7 +915,7 @@ GVAR(allSettings) = [ "SLIDER", // setting type [ "Required Duration to Save", // Pretty name shown inside the ingame settings menu. Can be stringtable entry. - "A recording must be at least this long (in minutes) to auto-save. Calling an 'ocap_exportData' CBA server event will override this restriction. Default: 20" + "A recording must be at least this long (in minutes) to auto-save. With auto-save on MPEnded, a shorter recording is discarded when the mission ends. With websocket storage, so is a shorter one left by #missions or #restart. Calling an 'ocap_exportData' CBA server event will override this restriction. Default: 20" ], [COMPONENT_NAME, "Save/Export Settings"], // Pretty name of the category where the setting can be found. Can be stringtable entry. [ diff --git a/addons/recorder/XEH_prep.sqf b/addons/recorder/XEH_prep.sqf index 944a8b8..86848a7 100644 --- a/addons/recorder/XEH_prep.sqf +++ b/addons/recorder/XEH_prep.sqf @@ -5,7 +5,12 @@ PREP(init); PREP(updateTime); PREP(startRecording); +PREP(announceRecording); PREP(stopRecording); +PREP(removeCaptureHandlers); +PREP(resetRecording); +PREP(discardRecording); +PREP(handleMissionEnded); PREP(autoRestartMonitor); PREP(getAutoStartPlayerCount); PREP(captureLoop); diff --git a/addons/recorder/fnc_addEventMission.sqf b/addons/recorder/fnc_addEventMission.sqf index 66934c6..3994139 100644 --- a/addons/recorder/fnc_addEventMission.sqf +++ b/addons/recorder/fnc_addEventMission.sqf @@ -103,22 +103,18 @@ if (isNil QEGVAR(EH,EntityRespawned)) then { if (isNil QEGVAR(EH,MPEnded)) then { // Event Handler: OCAP_EH_MPEnded - // Handle for the "MPEnded" mission event handler. Fired on the MPEnded mission event. This is used to automatically save and export if is true and was reached. + // Handle for the "MPEnded" mission event handler. Fired on the MPEnded mission event. When is true, this saves and exports the recording if was reached and discards it otherwise. A mission registered with the extension but never captured is discarded regardless. See . EGVAR(EH,MPEnded) = addMissionEventHandler ["MPEnded", { - if (EGVAR(settings,saveMissionEnded) && (GVAR(captureFrameNo) * GVAR(frameCaptureDelay)) / 60 >= GVAR(minMissionTime)) then { - ["Mission ended automatically"] call FUNC(exportData); - }; + call FUNC(handleMissionEnded); }]; OCAPEXTLOG(["Initialized MPEnded EH"]); }; if (isNil QEGVAR(EH,Ended)) then { // Event Handler: OCAP_EH_Ended - // Handle for the "Ended" mission event handler. Fired on the singleplayer Ended mission event. This is used to automatically save and export if is true and was reached. Kept in just in case this event triggers. + // Handle for the "Ended" mission event handler. Fired on the singleplayer Ended mission event. Handled like . Kept in just in case this event triggers. EGVAR(EH,Ended) = addMissionEventHandler ["Ended", { - if (EGVAR(settings,saveMissionEnded) && (GVAR(captureFrameNo) * GVAR(frameCaptureDelay)) / 60 >= GVAR(minMissionTime)) then { - ["Mission ended automatically"] call FUNC(exportData); - }; + call FUNC(handleMissionEnded); }]; OCAPEXTLOG(["Initialized Ended EH"]); }; @@ -276,8 +272,14 @@ if (isNil QEGVAR(listener,counterEvent)) then { Description: Used to start or resume recording. Calls when triggered. + A fresh recording starts asynchronously: the mission registers with the extension first. + The server-local event "OCAP_recordingStarted" fires once the capture runs, and again on each + resume. Custom events before then are not recorded, and an export before then is refused + ("recording hasn't started yet"). + Example: > ["OCAP_record"] call CBA_fnc_serverEvent; + > ["OCAP_recordingStarted", {diag_log "OCAP records"}] call CBA_fnc_addEventHandler; */ if (isNil QEGVAR(listener,record)) then { EGVAR(listener,record) = [QGVARMAIN(record), { @@ -294,6 +296,10 @@ if (isNil QEGVAR(listener,record)) then { Description: Used to pause recording. Calls when triggered. + Before the capture began (for example while the mission registers), it silently holds the + recording back: auto-start and the empty-server auto-restart leave it alone until + is raised. + Example: > ["OCAP_pause"] call CBA_fnc_serverEvent; */ @@ -314,6 +320,11 @@ if (isNil QEGVAR(listener,pause)) then { *Will always bypass *. + At mission end (MPEnded, with ), a recording shorter than + the minimum is discarded one frame later. To save it anyway, raise this event on the server + before the mission ends or in the same frame (e.g. from your own MPEnded handler without + suspending); a later one finds the recording already discarded. + Parameters: 0 - Event name [String] 1 - Event data [Array] diff --git a/addons/recorder/fnc_announceRecording.sqf b/addons/recorder/fnc_announceRecording.sqf new file mode 100644 index 0000000..b91df37 --- /dev/null +++ b/addons/recorder/fnc_announceRecording.sqf @@ -0,0 +1,61 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_announceRecording.sqf + +FUNCTION: OCAP_recorder_fnc_announceRecording + +Description: + Tells players and the recording that recording started: a diary record, the + "Recording started." event, a notification, and the time state. + + Called by once the capture runs. The event + is only recorded then, and the time state only reaches the extension once the + mission is registered. + + Raises the server-local CBA event "OCAP_recordingStarted": recording starts + asynchronously, so a mission script that records its own events or exports + right after can wait for it. + +Parameters: + None + +Returns: + Nothing + +Public: + No + +Author: + Smith +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +private _systemTimeFormat = ["%1-%2-%3T%4:%5:%6.%7"]; +_systemTimeFormat append (systemTimeUTC apply {if (_x < 10) then {"0" + str _x} else {str _x}}); +private _missionDateFormat = ["%1-%2-%3T%4:%5:00"]; +_missionDateFormat append (date apply {if (_x < 10) then {"0" + str _x} else {str _x}}); + +[[format _missionDateFormat, format _systemTimeFormat], { // add diary entry for clients on recording start + [{!isNull player}, { + private _t = round cba_missionTime; + private _elapsedStr = format ["%1:%2", floor (_t / 60), [str (_t mod 60), "0" + str (_t mod 60)] select (_t mod 60 < 10)]; + player setDiarySubjectPicture [ + "OCAPInfo", + "\A3\ui_f\data\igui\cfg\simpleTasks\types\use_ca.paa" + ]; + player createDiaryRecord [ + "OCAPInfo", + [ + "Status", + format["OCAP started recording.
In-Mission Time Elapsed: %1
Mission World Time: %2
System Time UTC: %3
", _elapsedStr, _this#0, _this#1] + ] + ]; + }, _this] call CBA_fnc_waitUntilAndExecute; +}] remoteExec ["call", [0, -2] select isDedicated, true]; + +[QGVARMAIN(customEvent), ["generalEvent", "Recording started."]] call CBA_fnc_serverEvent; +["OCAP began recording", 1, [1, 1, 1, 1]] remoteExecCall ["CBA_fnc_notify", [0, -2] select isDedicated]; + +// Log times +[] call FUNC(updateTime); + +[QGVARMAIN(recordingStarted), []] call CBA_fnc_localEvent; diff --git a/addons/recorder/fnc_autoRestartMonitor.sqf b/addons/recorder/fnc_autoRestartMonitor.sqf index 93a4cc0..6c629a7 100644 --- a/addons/recorder/fnc_autoRestartMonitor.sqf +++ b/addons/recorder/fnc_autoRestartMonitor.sqf @@ -19,6 +19,7 @@ Public: if (!GVAR(autoStart) || {!EGVAR(settings,autoRestartAfterEmpty)}) exitWith {}; if (!GVAR(autoRestartAfterEmptyPending)) exitWith {}; if (GVAR(recording) || {!isNil QGVAR(startTime)}) exitWith {}; +if (missionNamespace getVariable [QGVAR(heldByPause), false]) exitWith {}; // an admin paused if (getClientStateNumber <= 9) exitWith {}; private _playerCount = call FUNC(getAutoStartPlayerCount); diff --git a/addons/recorder/fnc_captureLoop.sqf b/addons/recorder/fnc_captureLoop.sqf index 8225690..1b7c815 100644 --- a/addons/recorder/fnc_captureLoop.sqf +++ b/addons/recorder/fnc_captureLoop.sqf @@ -81,6 +81,16 @@ GVAR(PFHObject) = [ GVAR(captureFrameNo) = GVAR(captureFrameNo) + 1; publicVariable QGVAR(captureFrameNo); + // Long enough to save: if the game now moves on without a save, e.g. after + // #missions, the extension keeps the recording (see OCAP_extension_fnc_newMission). + if ( + !(missionNamespace getVariable [QEGVAR(extension,recordingKept), true]) && + {(GVAR(captureFrameNo) * GVAR(frameCaptureDelay)) / 60 >= GVAR(minMissionTime)} + ) then { + EGVAR(extension,recordingKept) = true; + [":MISSION:KEEP:", []] call EFUNC(extension,sendData); + }; + // 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 { diff --git a/addons/recorder/fnc_discardRecording.sqf b/addons/recorder/fnc_discardRecording.sqf new file mode 100644 index 0000000..c7af017 --- /dev/null +++ b/addons/recorder/fnc_discardRecording.sqf @@ -0,0 +1,87 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_discardRecording.sqf + +FUNCTION: OCAP_recorder_fnc_discardRecording + +Description: + Ends the current recording without saving it, for a mission that ended + before the recording reached . + + Not saving is not enough on its own: with the websocket storage the web + server already holds the streamed recording and would save it as partial + once the game server moves on. :MISSION:DISCARD: tells the extension to + drop it instead; it answers with :MISSION:DISCARDED: (see + ). + + A mission registered with the extension whose capture never began, for + example one paused while it registered, has its registration discarded + without telling players. Does nothing when no recording has started. + +Parameters: + _reason - Why the recording is not saved, shown to players [String] + +Returns: + Nothing + +Examples: + > ["it was shorter than the minimum duration"] call FUNC(discardRecording); + +Public: + No + +Author: + Smith +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +params [["_reason", "", [""]]]; + +if (isNil QGVAR(startTime)) exitWith { + if (!EGVAR(extension,sessionReady)) exitWith { + LOG("Discard requested, but recording of this session hasn't started."); + }; + // The mission is registered with the extension, but nothing was captured: + // drop the registration, players were never told recording started. + INFO_1("Discarding the registered mission; nothing was captured: %1",_reason); + if (isNil {[":MISSION:DISCARD:", []] call EFUNC(extension,sendData)}) then { + WARNING("The extension did not accept the discard; storage may keep the empty recording"); + }; + EGVAR(extension,sessionReady) = false; + GVAR(recording) = false; + publicVariable QGVAR(recording); +}; + +call FUNC(stopRecording); +private _endFrameNumber = GVAR(captureFrameNo); +call FUNC(removeCaptureHandlers); + +INFO_3("Discarding recording — mission: %1 | frames: %2 | reason: %3",GVAR(missionName),_endFrameNumber,_reason); +if (isNil {[":MISSION:DISCARD:", []] call EFUNC(extension,sendData)}) then { + // An older extension, or a save still in progress. No :MISSION:DISCARDED: follows. + WARNING("The extension did not accept the discard; storage may keep the recording"); +}; +OCAPEXTLOG(ARR4("Discarded recording of mission",GVAR(missionName),"because",_reason)); + +[format["OCAP did not save %1: %2", briefingName, _reason], 1, [1, 1, 1, 1]] remoteExec ["CBA_fnc_notify", [0, -2] select isDedicated]; +[[briefingName, _endFrameNumber, _reason], { + params ["_missionName", "_endFrame", "_reason"]; + + player setDiarySubjectPicture [ + "OCAPInfo", + "\A3\ui_f\data\igui\cfg\simpleTasks\types\danger_ca.paa" + ]; + player createDiaryRecord [ + "OCAPInfo", + [ + "Status", + format[ + "OCAP capture of %1 with %2 frames was not saved: %3.", + _missionName, + _endFrame, + _reason + ] + ] + ]; +}] remoteExec ["call", [0, -2] select isDedicated, true]; + +call FUNC(resetRecording); diff --git a/addons/recorder/fnc_exportData.sqf b/addons/recorder/fnc_exportData.sqf index 8ce1c9c..b78fce1 100644 --- a/addons/recorder/fnc_exportData.sqf +++ b/addons/recorder/fnc_exportData.sqf @@ -56,21 +56,24 @@ if (isNil QGVAR(startTime)) exitWith { // if recording hasn't started, there's nothing to save LOG(["Export data call received, but recording of this session hasn't yet started."]); - { + // A short recording discarded at mission end is gone; say so. + private _status = ["OCAP was asked to save, but recording hasn't started yet.", "OCAP was asked to save, but the recording was already discarded when the mission ended."] select (missionNamespace getVariable [QGVAR(discardedAtEnd), false]); + [[_status], { + params ["_status"]; [{!isNull player}, { player createDiaryRecord [ "OCAPInfo", [ "Status", - "OCAP was asked to save, but recording hasn't started yet." + format ["%1", _this] ], taskNull, "", false ]; player setDiarySubjectPicture [ "OCAPInfo", "\A3\ui_f\data\igui\cfg\simpleTasks\types\use_ca.paa" ]; - }] call CBA_fnc_waitUntilAndExecute; - } remoteExecCall ["call", 0, true]; + }, _status] call CBA_fnc_waitUntilAndExecute; + }] remoteExecCall ["call", 0, true]; }; @@ -106,18 +109,7 @@ if (_frameTimeDuration < GVAR(minMissionTime) && !_overrideLimits) exitWith { call FUNC(stopRecording); private _endFrameNumber = GVAR(captureFrameNo); -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; -}; +call FUNC(removeCaptureHandlers); 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}; @@ -135,7 +127,7 @@ INFO_3("Saving recording — mission: %1 | frames: %2 | tag: %3",GVAR(missionNam // Save is now asynchronous — the extension returns immediately and will fire // a :MISSION:SAVED: ExtensionCallback when the write + upload finishes. // The final success/failure notification is driven from that callback in -// fnc_initSession.sqf. +// fnc_handleCallback.sqf. INFO_2("Mission save queued — mission: %1 | frames: %2",GVAR(missionName),_endFrameNumber); [":MISSION:SAVE:", [_saveTag]] call EFUNC(extension,sendData); OCAPEXTLOG(ARR4("Queued recording of mission",GVAR(missionName),"with tag",_saveTag)); @@ -144,7 +136,7 @@ OCAPEXTLOG(ARR4("Queued recording of mission",GVAR(missionName),"with tag",_save [format["OCAP saving %1 (%2 frames) — upload will follow", briefingName, _endFrameNumber], 1, [1, 1, 1, 1]] remoteExec ["CBA_fnc_notify", [0, -2] select isDedicated]; // Interim diary record. The final "exported successfully" / "upload failed" // record is appended from the :MISSION:SAVED: ExtensionCallback handler in -// fnc_initSession.sqf once the async save completes. +// fnc_handleCallback.sqf once the async save completes. [[GVAR(missionName), GVAR(captureFrameNo)], { params ["_missionName", "_endFrame"]; @@ -166,30 +158,4 @@ OCAPEXTLOG(ARR4("Queued recording of mission",GVAR(missionName),"with tag",_save }] remoteExec ["call", [0, -2] select isDedicated, true]; // reset vars in case a new recording is started -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; +call FUNC(resetRecording); diff --git a/addons/recorder/fnc_handleMissionEnded.sqf b/addons/recorder/fnc_handleMissionEnded.sqf new file mode 100644 index 0000000..1cb6008 --- /dev/null +++ b/addons/recorder/fnc_handleMissionEnded.sqf @@ -0,0 +1,54 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_handleMissionEnded.sqf + +FUNCTION: OCAP_recorder_fnc_handleMissionEnded + +Description: + Called by the "MPEnded" and "Ended" mission event handlers. With + , a recording that reached + is saved; a shorter one is discarded, so + storage that already holds it (the websocket storage streams to the web + server as it records) drops it instead of keeping it as partial. + + A mission script may still save a short recording on the same frame + through , which bypasses the minimum, so the discard waits + a frame and only applies to a recording still unsaved by then. + + Whatever the setting, a mission registered with the extension whose + capture never began is discarded: it holds nothing worth keeping. That + includes one still registering, once the extension confirms it. + +Parameters: + None + +Returns: + Nothing + +Public: + No + +Author: + Smith +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +// A recording still waiting for the extension to register the mission never +// starts; see OCAP_recorder_fnc_startRecording. +GVAR(missionEnded) = true; + +if (isNil QGVAR(startTime)) exitWith { + // Registered, but paused before the capture began. + ["the mission ended before the recording began"] call FUNC(discardRecording); +}; + +if (!EGVAR(settings,saveMissionEnded)) exitWith {}; + +if ((GVAR(captureFrameNo) * GVAR(frameCaptureDelay)) / 60 >= GVAR(minMissionTime)) exitWith { + [nil, "Mission ended automatically"] call FUNC(exportData); +}; + +[{ + if (isNil QGVAR(startTime)) exitWith {}; // saved meanwhile, or never started + [format ["the mission ended before the recording reached %1 minutes", GVAR(minMissionTime)]] call FUNC(discardRecording); + GVAR(discardedAtEnd) = true; // a later OCAP_exportData says so +}] call CBA_fnc_execNextFrame; diff --git a/addons/recorder/fnc_init.sqf b/addons/recorder/fnc_init.sqf index 3106be4..a1ec983 100644 --- a/addons/recorder/fnc_init.sqf +++ b/addons/recorder/fnc_init.sqf @@ -246,14 +246,38 @@ call EFUNC(extension,initSession); Conditional Start Recording We'll wait to see if auto-start is enabled and minPlayercount setting is met. This covers scenarios where someone changes the autostart setting during the mission as well, and excludes cases where autostart is disabled. If recording hasn't started already, we'll initialize it here assuming the above conditions are met. - The startRecording function checks internally if recording has already started by other means via whether GVAR(startTime) has been declared or not. + A recording started by other means, whether it already captures (GVAR(startTime) is set) or still registers or holds its registration paused, is left alone. Start recording AFTER Briefing screen, so the beginning of the recording matches the start of the actual mission session. */ [ {(getClientStateNumber > 9 && (call FUNC(getAutoStartPlayerCount)) >= EGVAR(settings,minPlayerCount) && GVAR(autoStart)) || !isNil QGVAR(startTime)}, { - call FUNC(startRecording); - [QGVARMAIN(customEvent), ["generalEvent", "Mission has started!"]] call CBA_fnc_serverEvent; + // Recording started some other way already captures, registers the + // mission, or is held by a pause; a start now would undo an admin's + // pause. + private _held = missionNamespace getVariable [QGVAR(heldByPause), false]; + if ( + isNil QGVAR(startTime) && + {!(missionNamespace getVariable [QEGVAR(extension,sessionPending), false])} && + {!EGVAR(extension,sessionReady)} && + {!_held} + ) then { + call FUNC(startRecording); + }; + // Events are recorded once the capture runs, which waits for the + // extension to register the mission, or for a pause to be lifted. + // Give up when that failed. + [{ + !isNil QGVAR(startTime) || { + !GVAR(recording) && + {!(missionNamespace getVariable [QEGVAR(extension,sessionPending), false])} && + {!EGVAR(extension,sessionReady)} && + {!(missionNamespace getVariable [QGVAR(heldByPause), false])} + } + }, { + if (isNil QGVAR(startTime)) exitWith {}; + [QGVARMAIN(customEvent), ["generalEvent", "Mission has started!"]] call CBA_fnc_serverEvent; + }] call CBA_fnc_waitUntilAndExecute; } ] call CBA_fnc_waitUntilAndExecute; diff --git a/addons/recorder/fnc_removeCaptureHandlers.sqf b/addons/recorder/fnc_removeCaptureHandlers.sqf new file mode 100644 index 0000000..e4f7ad5 --- /dev/null +++ b/addons/recorder/fnc_removeCaptureHandlers.sqf @@ -0,0 +1,35 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_removeCaptureHandlers.sqf + +FUNCTION: OCAP_recorder_fnc_removeCaptureHandlers + +Description: + Removes the per-frame handlers of the capture loop, the server FPS sampler + and the weather sampler, when a recording ends. + +Parameters: + None + +Returns: + Nothing + +Public: + No + +Author: + Dell, Zealot, IndigoFox, TyroneMF (moved from OCAP_recorder_fnc_exportData) +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +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; +}; diff --git a/addons/recorder/fnc_resetRecording.sqf b/addons/recorder/fnc_resetRecording.sqf new file mode 100644 index 0000000..70424c9 --- /dev/null +++ b/addons/recorder/fnc_resetRecording.sqf @@ -0,0 +1,51 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_resetRecording.sqf + +FUNCTION: OCAP_recorder_fnc_resetRecording + +Description: + Resets the recording state once a recording was exported or discarded, so + a new recording can start in the same mission. The next start registers a + new mission with the extension. + +Parameters: + None + +Returns: + Nothing + +Public: + No + +Author: + Dell, Zealot, IndigoFox, TyroneMF (moved from OCAP_recorder_fnc_exportData) +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +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_startRecording.sqf b/addons/recorder/fnc_startRecording.sqf index ea14f33..25de7ea 100644 --- a/addons/recorder/fnc_startRecording.sqf +++ b/addons/recorder/fnc_startRecording.sqf @@ -8,7 +8,11 @@ Description: Called via via direct CBA event or the administrative diary entry, or by a waiter in (see ). - Will not start recording if is true and will notify players. + Will not start recording again while the capture runs, and will notify players. + + A fresh recording first registers the mission with the extension () + and begins the capture once the extension confirms it. Start requests made meanwhile register + nothing more, but undo a pause. Parameters: None @@ -30,8 +34,8 @@ Author: // disregard recording attempts while OCAP is disabled. if (!GVARMAIN(enabled)) exitWith {}; -// if recording started earlier and startTime has been noted, only restart the capture loop with any updated settings. -if (GVAR(recording) && GVAR(captureFrameNo) > 10) exitWith { +// The capture already runs. +if (GVAR(recording) && {!isNil QGVAR(startTime)}) exitWith { OCAPEXTLOG(["OCAP was asked to record and is already recording!"]); [ ["OCAP was asked to record", 1, [1, 1, 1, 1]], @@ -39,55 +43,129 @@ if (GVAR(recording) && GVAR(captureFrameNo) > 10) exitWith { ] remoteExecCall ["CBA_fnc_notify", [0, -2] select isDedicated]; }; +// Another start is registering the mission with the extension; it begins the +// capture once the extension confirms it. A pause meanwhile is undone. A +// :MISSION:START: the extension has not answered for a minute (a lost +// callback) is given up and sent again. Its token is dropped at once, so a +// late answer to it, even one before the new start goes out, is ignored. +// Waiting for storage, nothing was sent yet, so nothing is resent. +private _pending = missionNamespace getVariable [QEGVAR(extension,sessionPending), false]; +private _sentAt = missionNamespace getVariable [QEGVAR(extension,registrationSentAt), -1]; +private _pendingFor = [0, diag_tickTime - _sentAt] select (_sentAt >= 0); +if (_pending && {_pendingFor >= 60}) then { + WARNING_1("The extension has not answered the mission registration for %1 s; registering again",round _pendingFor); + EGVAR(extension,registrationToken) = nil; + EGVAR(extension,sessionPending) = false; + _pending = false; +}; +if (_pending) exitWith { + INFO("Recording start requested while the mission registers with the extension"); + GVAR(heldByPause) = false; + GVAR(recording) = true; + publicVariable QGVAR(recording); + ["OCAP starts recording once the mission is registered", 1, [1, 1, 1, 1]] remoteExecCall ["CBA_fnc_notify", [0, -2] select isDedicated]; +}; + // Any explicit or automatic start consumes a pending empty-server restart. GVAR(autoRestartAfterEmptyPending) = false; +GVAR(heldByPause) = false; GVAR(recording) = true; publicVariable QGVAR(recording); -private _systemTimeFormat = ["%1-%2-%3T%4:%5:%6.%7"]; -_systemTimeFormat append (systemTimeUTC apply {if (_x < 10) then {"0" + str _x} else {str _x}}); -private _missionDateFormat = ["%1-%2-%3T%4:%5:00"]; -_missionDateFormat append (date apply {if (_x < 10) then {"0" + str _x} else {str _x}}); - -[[format _missionDateFormat, format _systemTimeFormat], { // add diary entry for clients on recording start - [{!isNull player}, { - private _t = round cba_missionTime; - private _elapsedStr = format ["%1:%2", floor (_t / 60), [str (_t mod 60), "0" + str (_t mod 60)] select (_t mod 60 < 10)]; - player setDiarySubjectPicture [ - "OCAPInfo", - "\A3\ui_f\data\igui\cfg\simpleTasks\types\use_ca.paa" - ]; - player createDiaryRecord [ - "OCAPInfo", - [ - "Status", - format["OCAP started recording.
In-Mission Time Elapsed: %1
Mission World Time: %2
System Time UTC: %3
", _elapsedStr, _this#0, _this#1] - ] - ]; - }, _this] call CBA_fnc_waitUntilAndExecute; -}] remoteExec ["call", [0, -2] select isDedicated, true]; - -if (GVAR(captureFrameNo) == 0) then { - if (!EGVAR(extension,sessionReady)) then { - // Previous recording was exported — re-register new mission with extension +if (isNil QGVAR(startTime) && {!EGVAR(extension,sessionReady)}) exitWith { + EGVAR(extension,sessionPending) = true; + EGVAR(extension,registrationSentAt) = -1; // set by newMission once sent + + // The mission registers with the extension only now, the first time or + // after the previous recording was exported or discarded, so a mission + // that never starts recording is never stored. Storage initialization + // may still be running and provides the mission context; it may take + // long, for example a websocket server that is slow to answer. + [{ + !isNil QEGVAR(extension,missionContext) || + {missionNamespace getVariable [QEGVAR(extension,storageFailed), false]} || + {missionNamespace getVariable [QGVAR(missionEnded), false]} + }, { + private _ended = missionNamespace getVariable [QGVAR(missionEnded), false]; + if (_ended || {isNil QEGVAR(extension,missionContext)}) exitWith { + EGVAR(extension,sessionPending) = false; + if (!_ended) then { + ERROR("Extension storage could not be initialized. Recording will not start."); + if (GVAR(recording)) then { + ["OCAP failed to start recording: extension storage is not ready", 1, [1, 0, 0, 1]] remoteExecCall ["CBA_fnc_notify", [0, -2] select isDedicated]; + }; + }; + GVAR(recording) = false; + publicVariable QGVAR(recording); + }; + // Paused while storage initialized: nothing to register yet; the next + // start does. + if (!GVAR(recording)) exitWith { + EGVAR(extension,sessionPending) = false; + INFO("Recording paused before the mission registered; it registers when recording resumes"); + }; call EFUNC(extension,newMission); - // Wait for extension to confirm new mission before starting capture - [{EGVAR(extension,sessionReady)}, { + // A start that registers again after an unanswered minute supersedes + // this attempt; its waiters below then leave it to the new one. + private _token = missionNamespace getVariable [QEGVAR(extension,registrationToken), ""]; + + // The extension answers :MISSION:OK: or :MISSION:FAILED:. It may take a + // while, for example while it still ends the previous recording, and + // the capture waits for it: starting another registration meanwhile + // would leave the extension with a mission nothing records. + [{EGVAR(extension,sessionReady) || {!EGVAR(extension,sessionPending)}}, { + params ["_token"]; + if (_token isNotEqualTo (missionNamespace getVariable [QEGVAR(extension,registrationToken), ""])) exitWith {}; + if (!isNil QGVAR(startTime)) exitWith {}; // a start in the meantime began the capture + if (missionNamespace getVariable [QGVAR(missionEnded), false]) exitWith { + ["the mission ended before the recording began"] call FUNC(discardRecording); + GVAR(recording) = false; + publicVariable QGVAR(recording); + }; + if (!EGVAR(extension,sessionReady)) exitWith { + if (GVAR(recording)) then { // a paused start was never announced either + ["OCAP failed to start recording: the extension could not start the mission", 1, [1, 0, 0, 1]] remoteExecCall ["CBA_fnc_notify", [0, -2] select isDedicated]; + }; + GVAR(recording) = false; + publicVariable QGVAR(recording); + }; + // Paused meanwhile: the capture begins when recording resumes. + if (!GVAR(recording)) exitWith { + INFO("Mission registered while recording is paused; the capture begins when it resumes"); + }; call FUNC(flushPlayerConnectionEvents); call FUNC(captureLoop); - }, [], 30, { - ERROR("Timeout waiting for new mission confirmation from extension. Recording will not start."); - ["OCAP failed to start recording: extension did not respond", 1, [1, 0, 0, 1]] remoteExecCall ["CBA_fnc_notify", [0, -2] select isDedicated]; + call FUNC(announceRecording); + }, [_token]] call CBA_fnc_waitUntilAndExecute; + + [{EGVAR(extension,sessionReady) || {!EGVAR(extension,sessionPending)}}, {}, [_token], 30, { + params ["_token"]; + if (_token isNotEqualTo (missionNamespace getVariable [QEGVAR(extension,registrationToken), ""])) exitWith {}; + // Paused, or the mission ended, meanwhile. + if (!GVAR(recording) || {missionNamespace getVariable [QGVAR(missionEnded), false]}) exitWith {}; + WARNING("The extension has not confirmed the mission after 30 seconds; recording starts once it does"); + ["OCAP is waiting for the extension to start recording", 1, [1, 1, 0, 1]] remoteExecCall ["CBA_fnc_notify", [0, -2] select isDedicated]; }] call CBA_fnc_waitUntilAndExecute; - } else { - call FUNC(flushPlayerConnectionEvents); - call FUNC(captureLoop); - }; + }] call CBA_fnc_waitUntilAndExecute; + + [{!isNil QEGVAR(extension,missionContext) || {!EGVAR(extension,sessionPending)}}, {}, [], 60, { + // An extension that never reported its version is not running: nothing + // will initialize storage. + if (isNil QEGVAR(extension,dllVersion)) exitWith { + ERROR("The extension is not running. Recording will not start."); + EGVAR(extension,storageFailed) = true; + }; + if (!GVAR(recording)) exitWith {}; // paused meanwhile + WARNING("Extension storage is not initialized after 60 seconds; recording starts once it is"); + ["OCAP is waiting for its storage to start recording", 1, [1, 1, 0, 1]] remoteExecCall ["CBA_fnc_notify", [0, -2] select isDedicated]; + }] call CBA_fnc_waitUntilAndExecute; }; -[QGVARMAIN(customEvent), ["generalEvent", "Recording started."]] call CBA_fnc_serverEvent; -["OCAP began recording", 1, [1, 1, 1, 1]] remoteExecCall ["CBA_fnc_notify", [0, -2] select isDedicated]; - -// Log times -[] call FUNC(updateTime); +// A paused capture resumes on its own; a registered mission paused before +// its capture began starts it now. +if (isNil QGVAR(startTime)) then { + call FUNC(flushPlayerConnectionEvents); + call FUNC(captureLoop); +}; +call FUNC(announceRecording); diff --git a/addons/recorder/fnc_stopRecording.sqf b/addons/recorder/fnc_stopRecording.sqf index 86cc132..06d3e1e 100644 --- a/addons/recorder/fnc_stopRecording.sqf +++ b/addons/recorder/fnc_stopRecording.sqf @@ -25,6 +25,16 @@ Author: ---------------------------------------------------------------------------- */ #include "script_component.hpp" +// Nothing captures yet, for example while the mission registers with the +// extension: only hold the capture back, players were not told it started. +if (isNil QGVAR(startTime)) exitWith { + INFO("Recording paused before its capture began"); + GVAR(recording) = false; + publicVariable QGVAR(recording); + // Auto-start leaves a held pause alone; the next start request lifts it. + GVAR(heldByPause) = true; +}; + private _systemTimeFormat = ["%1-%2-%3T%4:%5:%6.%7"]; _systemTimeFormat append (systemTimeUTC apply {if (_x < 10) then {"0" + str _x} else {str _x}}); private _missionDateFormat = ["%1-%2-%3T%4:%5:00"]; diff --git a/docs/recording-lifecycle.md b/docs/recording-lifecycle.md index 99c7041..d678fd1 100644 --- a/docs/recording-lifecycle.md +++ b/docs/recording-lifecycle.md @@ -18,6 +18,82 @@ between recordings cannot accumulate or appear in a later recording. Active and paused recordings still receive live connection events. The Steam ID setting and `playerUid` payload format are unchanged. +## Save limits and storage + +`minPlayerCount` and `minMissionTime` are enforced by the addon, so they must +hold for every storage, including the `websocket` storage that streams a +recording to the web server while it records: + +- The mission registers with the extension (`:MISSION:START:`) only when a + recording starts, not at mission load. A mission that never reaches the + auto-start conditions, such as `minPlayerCount`, leaves nothing in storage. + The web server previously listed such missions as empty `partial` + recordings. +- The capture, and with it the "Recording started." and "Mission has started!" + events, begins once the extension answers `:MISSION:OK:`. The addon waits + for that answer, or `:MISSION:FAILED:`, however long the extension takes, for + example while it still saves the previous recording. Further start requests + meanwhile register nothing more, but undo a pause and tell players recording + starts once the mission is registered; after 30 seconds it notifies players + that it is still waiting. A start request while the capture runs only + reports that OCAP is already recording. Each registration carries a token + the extension echoes, so a late answer to an earlier mission's registration + is ignored. Without a running extension (no `:SYS:VERSION:` after 60 + seconds) the recording gives up. A pause meanwhile (`OCAP_pause`) holds the + capture back until recording resumes; a pause while storage still + initializes does not register the mission at all until then. If the mission + ends first, the registered mission is discarded instead, whatever + `saveMissionEnded` says. If the mission unloads before the extension + answers, the extension refuses that start, or, if the start already reached + its storage, drops the empty mission when the next one loads (a web server + older than protocol 3 saves it as an empty `partial` instead). A start + request a minute after an unanswered `:MISSION:START:` sends it again. + Storage that is still initializing is waited for too, until the extension + reports `:STORAGE:ERROR:`; players are notified after 60 seconds. +- A pause before the capture began only holds it back: players were not told + recording started, so they are not told it stopped. Auto-start leaves such a + pause alone; the next start request lifts it. +- `OCAP_recorder_recording` is true from the start request on, while + `OCAP_recorder_startTime` is set once the capture runs, and the server-local + CBA event `OCAP_recordingStarted` fires then. A mission script that starts + recording (`OCAP_record`) and records its own events or exports right away + should wait for that event. Before this change a start recorded at once when + the mission had registered at load. +- When the mission ends (`MPEnded` with `saveMissionEnded`) before the + recording reached `minMissionTime`, the recording is discarded with + `:MISSION:DISCARD:`, one frame later, so a mission script can still save it + through `OCAP_exportData` on the server in the same frame (before `MPEnded`, + or from its own `MPEnded` handler without suspending). A later export, for + example one a client raises, finds the recording already discarded. The `memory` storage drops it, the `websocket` + storage tells the web server to drop it (web protocol version 3; an older web + server still saves it as `partial`). A recording that reached + `minMissionTime` is saved as before. + +This needs an extension with `:MISSION:DISCARD:`, `:MISSION:FAILED:` and +`:STORAGE:ERROR:` (OCAP2/extension#217). An older one rejects the discard, +which the server RPT logs as a warning, and never answers a failed start or a +failed storage, so such a recording keeps waiting. + +When an admin changes the mission, `MPEnded` fires, with `#missions` and with +`#restart`. After `#missions` the discard above runs on the next frame. After +`#restart` the next mission loads first, so it never runs. The extension ends +a recording still open when the next mission loads. Each registration asks it +to drop the recording then (`:MISSION:START:` with `discard`) until the +recording reaches `minMissionTime`. From that frame the capture loop sends +`:MISSION:KEEP:`, and such a recording is kept: the web server saves it as +`partial`. So a recording below `minMissionTime` leaves nothing on the web +either way. This holds for the `websocket` storage, with a web server at +protocol 3. An older extension ignores the request, and the server RPT logs +its rejection of `:MISSION:KEEP:` once per recording. + +With `saveMissionEnded` disabled, a recording is neither saved nor discarded at +mission end. When the next mission loads, the extension drops it if it is +below `minMissionTime` and keeps it as `partial` otherwise. + +A recording still open when the game server process exits is saved by the web +server as `partial`, whatever its length, once its resume grace expires. That +happens after a crash, and with `#shutdown`, which fires no `MPEnded`. + ## Dedicated-server verification These scenarios require Arma 3 and the recorder extension; HEMTT checks and builds @@ -41,3 +117,23 @@ as the server notifications. 5. With headless-client exclusion enabled, verify headless clients do not help reach the start/restart threshold. With it disabled, verify the configured counting behavior, including the documented headless-only restart case. +6. With the `websocket` storage, load a mission and stay below `minPlayerCount`. + Verify the web server shows no recording, live or saved, until the threshold + is reached, and that the extension log has no `WebSocket mission started` + before then. +7. With the `websocket` storage, record and end the mission (`endMission`) + before `minMissionTime`. Verify the "was not saved" notification, a + `Discarded recording dropped by storage` line in the server RPT and that the + web server keeps no recording of it. End a mission past `minMissionTime` and + verify it is saved as before. Repeat with the `memory` storage and verify no + file is written for the short mission. The discard is sent one frame after + `MPEnded`, so this also checks that the server still runs that frame. +8. With auto-start, verify the saved recording holds the "Recording started." + and "Mission has started!" events at its start. +9. With the `websocket` storage, record less than `minMissionTime`, then change + the mission with `#missions` and load another one. Verify that the server + RPT has `Discarding recording` and that the web server keeps no recording + of it. Repeat with `#restart`: the extension's output has `abandoned before + it met the addon's save limits; discarding it` instead. Record past + `minMissionTime` (the extension log has `Received :MISSION:KEEP:`), change + the mission again and verify it is saved. diff --git a/tests/README.md b/tests/README.md index 756fa44..0c7ff3b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,5 +1,22 @@ # Recorder regression tests +## Test build + +The suites replace addon functions with stubs. CBA compiles `PREP`ed functions +final, so a release build cannot run them: `save_limits_regressions.sqf` stops +with an error, and the stubs of the others fail. Build a test copy with +`#define DISABLE_COMPILE_CACHE` above the CBA include in +`addons/main/script_macros.hpp`, which makes `PREP` use plain `compile`, then +`hemtt build`. Never ship that build. + +`recorder_regressions.sqf` and `client_sampler_regressions.sqf` also stub CBA's +own functions (`CBA_fnc_addPerFrameHandler`, `CBA_fnc_waitUntilAndExecute`), +which stay final in any CBA build. Their servicing and client sampler parts +therefore fail with "Attempt to override final function", on `main` as well; +the server-side submission checks run. + +## Suites + `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 @@ -8,7 +25,7 @@ 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/` +player `testCurator` in the editor. Copy the scripts into the mission's `tests/` folder and execute on the server: ```sqf @@ -53,3 +70,27 @@ 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. + +`save_limits_regressions.sqf` checks the save limits: a recording registers the +mission with the extension once, when it starts, and begins the capture and +announces it only once the extension confirms it, however long that takes; a +failed start ends the recording; a mission that ends while it registers is +discarded; a pause while it registers holds the capture back until recording +resumes, and a registered mission never captured is discarded at mission end; +a mission that ends before `minMissionTime` is discarded +(`:MISSION:DISCARD:`) unless a mission script exports it on the same frame; a +longer one is saved; `saveMissionEnded` disabled does neither. It needs no +players and takes about a minute. Execute it on the server once the extension +storage is initialized, with auto-start disabled: + +```sqf +execVM "tests\save_limits_regressions.sqf"; +``` + +The RPT must end with `[OCAP save limits] ALL TESTS PASSED`. + +Lifecycle commands to the extension are intercepted, so nothing is registered +or saved. The suite replaces recording state and resets per-unit recording +variables, and a recording start it simulates also satisfies the auto-start +waiter; restart the mission afterwards. It is an opt-in test artifact, not +included in the release PBOs. diff --git a/tests/save_limits_regressions.sqf b/tests/save_limits_regressions.sqf new file mode 100644 index 0000000..6a9c41a --- /dev/null +++ b/tests/save_limits_regressions.sqf @@ -0,0 +1,242 @@ +/* Run on the server in a disposable multiplayer mission; see tests/README.md. */ +if (!isServer || {!canSuspend}) exitWith {throw "Run the tests with server-side execVM"}; +if ( + missionNamespace getVariable ["OCAP_recorder_recording", false] || + {!isNil "OCAP_recorder_startTime"} || + {missionNamespace getVariable ["OCAP_extension_sessionPending", false]} +) exitWith {throw "Disable auto-start, and run the tests before any recording started"}; +if (isNil "OCAP_extension_missionContext") exitWith {throw "Wait for the extension storage to initialize first"}; +// The stubs below replace addon functions, which CBA compiles final; otherwise +// real lifecycle commands would reach the extension. +if (isFinal OCAP_extension_fnc_sendData) exitWith {throw "Use a test build of the addon (DISABLE_COMPILE_CACHE); see tests/README.md"}; + +private _assert = { + params ["_condition", "_description"]; + if (!_condition) then {throw _description}; + diag_log format ["[OCAP save limits] PASS: %1", _description]; +}; +private _savedFunctions = createHashMap; +{ + _savedFunctions set [_x, missionNamespace getVariable _x]; +} forEach [ + "OCAP_extension_fnc_sendData", + "OCAP_recorder_fnc_captureLoop", "OCAP_recorder_fnc_updateTime", + "OCAP_recorder_fnc_announceRecording" +]; +private _savedSettings = [ + OCAP_settings_saveMissionEnded, OCAP_recorder_minMissionTime, OCAP_recorder_frameCaptureDelay +]; +private _error = ""; + +// Intercept the lifecycle commands, never register or upload a test recording. +OCAP_test_commands = []; +OCAP_test_startModes = []; +OCAP_extension_fnc_sendData = { + params ["_command", "_args"]; + if (_command in [":MISSION:START:", ":MISSION:SAVE:", ":MISSION:DISCARD:", ":MISSION:KEEP:"]) then { + OCAP_test_commands pushBack _command; + }; + if (_command isEqualTo ":MISSION:START:") then {OCAP_test_startModes pushBack (_args param [3, ""])}; + "" +}; +OCAP_recorder_fnc_captureLoop = {OCAP_recorder_startTime = time}; +OCAP_recorder_fnc_updateTime = {}; +// Its events are recorded only while the capture runs. +OCAP_test_announcedCapturing = []; +OCAP_recorder_fnc_announceRecording = {OCAP_test_announcedCapturing pushBack !isNil "OCAP_recorder_startTime"}; +// A fresh recording waiting for the extension to register the mission. +private _registering = { + OCAP_recorder_recording = false; + OCAP_recorder_captureFrameNo = 0; + OCAP_recorder_startTime = nil; + OCAP_extension_sessionReady = false; + OCAP_extension_sessionPending = false; + OCAP_test_commands = []; + OCAP_test_announcedCapturing = []; + call OCAP_recorder_fnc_startRecording; + sleep 1; +}; +// What :MISSION:OK: does, in one frame like the callback handler. +private _confirm = { + isNil { + OCAP_extension_sessionPending = false; + OCAP_extension_sessionReady = true; + }; +}; +private _sent = { + params ["_command"]; + {_x isEqualTo _command} count OCAP_test_commands +}; +// A recording that has run for _minutes, as the capture loop leaves it. +private _recordingOf = { + params ["_minutes"]; + OCAP_recorder_recording = true; + OCAP_recorder_startTime = time; + OCAP_recorder_captureFrameNo = round (_minutes * 60 / OCAP_recorder_frameCaptureDelay); + OCAP_extension_sessionReady = true; + OCAP_test_commands = []; +}; + +try { + OCAP_settings_saveMissionEnded = true; + OCAP_recorder_minMissionTime = 20; + OCAP_recorder_frameCaptureDelay = 1; + + // The mission registers with the extension when recording starts, once, + // however often a start is requested before the extension confirms it. + call _registering; + call OCAP_recorder_fnc_startRecording; + sleep 1; + [[":MISSION:START:"] call _sent == 1, "a start registers the mission once"] call _assert; + [OCAP_test_startModes isEqualTo ["discard"] && {!OCAP_extension_recordingKept}, "a recording the game moves on from is dropped until it is long enough"] call _assert; + [OCAP_extension_sessionPending, "the registration waits for :MISSION:OK:"] call _assert; + [isNil "OCAP_recorder_startTime", "capture waits for the extension"] call _assert; + [OCAP_test_announcedCapturing isEqualTo [], "nothing is announced before the capture runs"] call _assert; + sleep 31; + [OCAP_extension_sessionPending && {OCAP_recorder_recording}, "a slow extension is still waited for"] call _assert; + call _confirm; + sleep 1; + [!isNil "OCAP_recorder_startTime", "capture begins once the extension confirms"] call _assert; + [OCAP_test_announcedCapturing isEqualTo [true], "the start is announced once, while capturing"] call _assert; + call OCAP_recorder_fnc_startRecording; + [OCAP_test_announcedCapturing isEqualTo [true], "another start request does not start the capture again"] call _assert; + + // A mission the extension could not start stops the recording. + call _registering; + OCAP_extension_sessionPending = false; // what :MISSION:FAILED: does + sleep 1; + [isNil "OCAP_recorder_startTime" && {!OCAP_recorder_recording}, "a failed start ends the recording"] call _assert; + [OCAP_test_announcedCapturing isEqualTo [], "a failed start is not announced"] call _assert; + + // A mission that ends while it registers is discarded once registered. + call _registering; + OCAP_recorder_missionEnded = true; + call _confirm; + sleep 1; + OCAP_recorder_missionEnded = nil; + [[":MISSION:DISCARD:"] call _sent == 1, "a mission that ended while registering is discarded"] call _assert; + [isNil "OCAP_recorder_startTime" && {!OCAP_recorder_recording} && {!OCAP_extension_sessionReady}, "its capture never begins"] call _assert; + + // A pause while the mission registers holds the capture back until recording resumes. + call _registering; + call OCAP_recorder_fnc_stopRecording; + call _confirm; + sleep 1; + [isNil "OCAP_recorder_startTime" && {OCAP_test_announcedCapturing isEqualTo []}, "a paused registration does not capture"] call _assert; + call OCAP_recorder_fnc_startRecording; + [!isNil "OCAP_recorder_startTime" && {OCAP_test_announcedCapturing isEqualTo [true]}, "resuming begins the capture"] call _assert; + + // A start request while it registers undoes a pause. + call _registering; + call OCAP_recorder_fnc_stopRecording; + call OCAP_recorder_fnc_startRecording; + [OCAP_recorder_recording && {[":MISSION:START:"] call _sent == 1}, "the latest request wins, still one registration"] call _assert; + call _confirm; + sleep 1; + [!isNil "OCAP_recorder_startTime", "the capture begins once registered"] call _assert; + + // A start a minute after an unanswered registration registers again; the + // first registration's late answers, even before the new start goes out, + // change nothing (the real callback handler). + waitUntil {sleep 1; diag_tickTime > 62}; // a negative send time reads as not sent + call _registering; + private _first = OCAP_extension_registrationToken; + OCAP_extension_registrationSentAt = diag_tickTime - 61; + isNil { + call OCAP_recorder_fnc_startRecording; // registers again in a later frame + ["ocap_recorder", ":MISSION:OK:", str ["OK", _first]] call OCAP_extension_fnc_handleCallback; + }; + [OCAP_extension_sessionPending && {!OCAP_extension_sessionReady}, "a late OK of the given-up registration is ignored"] call _assert; + sleep 1; + [[":MISSION:START:"] call _sent == 2 && {OCAP_extension_registrationToken isNotEqualTo _first}, "the mission registers again with a new token"] call _assert; + ["ocap_recorder", ":MISSION:FAILED:", str ["late", _first]] call OCAP_extension_fnc_handleCallback; + sleep 1; + [OCAP_extension_sessionPending && {OCAP_recorder_recording} && {isNil "OCAP_recorder_startTime"}, "nor is its late failure, and nothing captures yet"] call _assert; + ["ocap_recorder", ":MISSION:OK:", str ["OK", OCAP_extension_registrationToken]] call OCAP_extension_fnc_handleCallback; + sleep 1; + [!isNil "OCAP_recorder_startTime" && {OCAP_test_announcedCapturing isEqualTo [true]}, "the new registration's OK begins the capture, announced once"] call _assert; + + // A registered mission paused before its capture is discarded at mission end. + call _registering; + call OCAP_recorder_fnc_stopRecording; + call _confirm; + sleep 1; + call OCAP_recorder_fnc_handleMissionEnded; + OCAP_recorder_missionEnded = nil; + [[":MISSION:DISCARD:"] call _sent == 1 && {!OCAP_extension_sessionReady}, "a registered mission never captured is discarded"] call _assert; + [OCAP_test_announcedCapturing isEqualTo [], "players are not told about it"] call _assert; + + // A mission that ends before the minimum is discarded, not saved. + [5] call _recordingOf; + call OCAP_recorder_fnc_handleMissionEnded; + sleep 1; + [[":MISSION:DISCARD:"] call _sent == 1, "a short recording is discarded when the mission ends"] call _assert; + [[":MISSION:SAVE:"] call _sent == 0, "a short recording is not saved"] call _assert; + [isNil "OCAP_recorder_startTime" && {!OCAP_extension_sessionReady}, "a discard resets the recording"] call _assert; + + // A mission script saving it on the same frame wins over the discard. + [5] call _recordingOf; + isNil { + call OCAP_recorder_fnc_handleMissionEnded; + [sideUnknown, "Regression", "Regression", true] call OCAP_recorder_fnc_exportData; + }; + sleep 1; + [[":MISSION:SAVE:"] call _sent == 1, "an explicit export of a short recording is saved"] call _assert; + [[":MISSION:DISCARD:"] call _sent == 0, "a saved recording is not discarded"] call _assert; + + // A recording past the minimum is saved as before. + [25] call _recordingOf; + call OCAP_recorder_fnc_handleMissionEnded; + sleep 1; + [[":MISSION:SAVE:"] call _sent == 1, "a long recording is saved when the mission ends"] call _assert; + [[":MISSION:DISCARD:"] call _sent == 0, "a long recording is not discarded"] call _assert; + + // Without auto-save on mission end, nothing happens. + OCAP_settings_saveMissionEnded = false; + [5] call _recordingOf; + call OCAP_recorder_fnc_handleMissionEnded; + sleep 1; + [OCAP_test_commands isEqualTo [], "saveMissionEnded disabled neither saves nor discards"] call _assert; + + // A pause before anything started is held: auto-start leaves it alone + // (OCAP_recorder_fnc_init), and the next start request lifts it. + OCAP_recorder_missionEnded = nil; // set by the mission end checks above + OCAP_recorder_recording = false; + OCAP_recorder_startTime = nil; + OCAP_extension_sessionReady = false; + OCAP_extension_sessionPending = false; + call OCAP_recorder_fnc_stopRecording; + [OCAP_recorder_heldByPause, "a pause before the capture is held"] call _assert; + call _registering; + [!OCAP_recorder_heldByPause && {OCAP_extension_sessionPending}, "a start request lifts it and registers"] call _assert; + call _confirm; + sleep 1; + + // Discarding before a recording started does nothing. + OCAP_recorder_startTime = nil; + OCAP_extension_sessionReady = false; + OCAP_test_commands = []; + ["test"] call OCAP_recorder_fnc_discardRecording; + [OCAP_test_commands isEqualTo [], "no recording, nothing to discard"] call _assert; +} catch { + _error = _exception; +}; + +OCAP_recorder_recording = false; +OCAP_recorder_startTime = nil; +OCAP_recorder_missionEnded = nil; +OCAP_recorder_heldByPause = nil; +OCAP_recorder_captureFrameNo = 0; +OCAP_extension_sessionReady = false; +OCAP_extension_sessionPending = false; +_savedSettings params ["_saveMissionEnded", "_minMissionTime", "_frameCaptureDelay"]; +OCAP_settings_saveMissionEnded = _saveMissionEnded; +OCAP_recorder_minMissionTime = _minMissionTime; +OCAP_recorder_frameCaptureDelay = _frameCaptureDelay; +{missionNamespace setVariable [_x, _y]} forEach _savedFunctions; + +if (_error isEqualTo "") then { + diag_log "[OCAP save limits] ALL TESTS PASSED"; +} else { + diag_log format ["[OCAP save limits] FAILED: %1", _error]; +};