From 856b23146dda321cbcf59811ff653e80c5201569 Mon Sep 17 00:00:00 2001 From: Smith <13200576+smitt14ua@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:48:03 +0200 Subject: [PATCH 01/14] feat(recorder): honor save limits with streaming storage minPlayerCount and minMissionTime only held because nothing reached storage until :MISSION:SAVE:. The websocket storage streams to the web server as it records, so missions that never met the limits were published anyway: missions that stayed below minPlayerCount showed up as empty partial recordings, short ones as partial recordings. - :MISSION:START: is no longer sent at mission load. The mission registers with the extension when a recording starts, so a mission that never meets the auto-start conditions leaves nothing in storage. A second start while the registration is pending no longer registers the mission twice. - When the mission ends (MPEnded/Ended with saveMissionEnded) before the recording reached minMissionTime, the recording is discarded with the new :MISSION:DISCARD: command instead of being left to the storage. The discard waits a frame, so a mission script exporting the short recording through OCAP_exportData on the same frame still saves it. - The automatic end-of-mission save passed its message as the winning side; it is now passed as the message. The PFH removal and state reset of exportData are shared with the discard as removeCaptureHandlers and resetRecording. --- addons/extension/fnc_initSession.sqf | 23 +++- addons/extension/fnc_newMission.sqf | 16 ++- addons/recorder/XEH_preInit.sqf | 4 +- addons/recorder/XEH_prep.sqf | 4 + addons/recorder/fnc_addEventMission.sqf | 12 +- addons/recorder/fnc_discardRecording.sqf | 68 ++++++++++ addons/recorder/fnc_exportData.sqf | 41 +----- addons/recorder/fnc_handleMissionEnded.sqf | 37 +++++ addons/recorder/fnc_removeCaptureHandlers.sqf | 32 +++++ addons/recorder/fnc_resetRecording.sqf | 48 +++++++ addons/recorder/fnc_startRecording.sqf | 32 +++-- docs/recording-lifecycle.md | 33 +++++ tests/README.md | 13 ++ tests/save_limits_regressions.sqf | 126 ++++++++++++++++++ 14 files changed, 420 insertions(+), 69 deletions(-) create mode 100644 addons/recorder/fnc_discardRecording.sqf create mode 100644 addons/recorder/fnc_handleMissionEnded.sqf create mode 100644 addons/recorder/fnc_removeCaptureHandlers.sqf create mode 100644 addons/recorder/fnc_resetRecording.sqf create mode 100644 tests/save_limits_regressions.sqf diff --git a/addons/extension/fnc_initSession.sqf b/addons/extension/fnc_initSession.sqf index 793f841..477ef52 100644 --- a/addons/extension/fnc_initSession.sqf +++ b/addons/extension/fnc_initSession.sqf @@ -4,6 +4,7 @@ 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 @@ -143,18 +144,32 @@ addMissionEventHandler ["ExtensionCallback", { ]] ]] call CBA_fnc_encodeJSON; - // Save mission and world context - INFO("Saving mission and world context"); + // 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: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_1("Mission registered in %1ms",diag_tickTime - GVAR(initTimer)); INFO("Mission registered. Starting data send."); + GVAR(sessionPending) = false; GVAR(sessionReady) = true; }; + 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] diff --git a/addons/extension/fnc_newMission.sqf b/addons/extension/fnc_newMission.sqf index 3734ded..4f14769 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 :MISSION:OK: response and set sessionReady = true. sessionPending is true + until then. 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,10 @@ 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."); }; -INFO("Re-registering new mission with extension"); +INFO("Registering new mission with extension"); GVAR(initTimer) = diag_tickTime; +GVAR(sessionPending) = true; [":MISSION:START:", [GVAR(worldContext), GVAR(missionContext)], 'ocap_recorder'] call FUNC(sendData); diff --git a/addons/recorder/XEH_preInit.sqf b/addons/recorder/XEH_preInit.sqf index 59c1334..2fd90f9 100644 --- a/addons/recorder/XEH_preInit.sqf +++ b/addons/recorder/XEH_preInit.sqf @@ -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. A shorter recording is discarded when the mission ends, so storage that already holds it (such as the websocket storage) drops it. 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. A shorter recording is discarded when the mission ends. 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..1c6166b 100644 --- a/addons/recorder/XEH_prep.sqf +++ b/addons/recorder/XEH_prep.sqf @@ -6,6 +6,10 @@ PREP(updateTime); PREP(startRecording); 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..fc7d184 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. 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"]); }; diff --git a/addons/recorder/fnc_discardRecording.sqf b/addons/recorder/fnc_discardRecording.sqf new file mode 100644 index 0000000..d6bd67d --- /dev/null +++ b/addons/recorder/fnc_discardRecording.sqf @@ -0,0 +1,68 @@ +/* ---------------------------------------------------------------------------- +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 + ). + + 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 +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +params [["_reason", "", [""]]]; + +if (isNil QGVAR(startTime)) exitWith { + LOG("Discard requested, but recording of this session hasn't started."); +}; + +call FUNC(stopRecording); +private _endFrameNumber = GVAR(captureFrameNo); +call FUNC(removeCaptureHandlers); + +INFO_3("Discarding recording — mission: %1 | frames: %2 | reason: %3",GVAR(missionName),_endFrameNumber,_reason); +[":MISSION:DISCARD:", []] call EFUNC(extension,sendData); +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..1b759a6 100644 --- a/addons/recorder/fnc_exportData.sqf +++ b/addons/recorder/fnc_exportData.sqf @@ -106,18 +106,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}; @@ -166,30 +155,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..2e6595c --- /dev/null +++ b/addons/recorder/fnc_handleMissionEnded.sqf @@ -0,0 +1,37 @@ +/* ---------------------------------------------------------------------------- +FILE: fnc_handleMissionEnded.sqf + +FUNCTION: OCAP_recorder_fnc_handleMissionEnded + +Description: + Called by the "MPEnded" and "Ended" mission event handlers when + is true. 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. + +Parameters: + None + +Returns: + Nothing + +Public: + No +---------------------------------------------------------------------------- */ +#include "script_component.hpp" + +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); +}] call CBA_fnc_execNextFrame; diff --git a/addons/recorder/fnc_removeCaptureHandlers.sqf b/addons/recorder/fnc_removeCaptureHandlers.sqf new file mode 100644 index 0000000..e0a9f5d --- /dev/null +++ b/addons/recorder/fnc_removeCaptureHandlers.sqf @@ -0,0 +1,32 @@ +/* ---------------------------------------------------------------------------- +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 +---------------------------------------------------------------------------- */ +#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..37b8205 --- /dev/null +++ b/addons/recorder/fnc_resetRecording.sqf @@ -0,0 +1,48 @@ +/* ---------------------------------------------------------------------------- +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 +---------------------------------------------------------------------------- */ +#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..aa79724 100644 --- a/addons/recorder/fnc_startRecording.sqf +++ b/addons/recorder/fnc_startRecording.sqf @@ -70,15 +70,29 @@ _missionDateFormat append (date apply {if (_x < 10) then {"0" + str _x} else {st if (GVAR(captureFrameNo) == 0) then { if (!EGVAR(extension,sessionReady)) then { - // Previous recording was exported — re-register new mission with extension - call EFUNC(extension,newMission); - // Wait for extension to confirm new mission before starting capture - [{EGVAR(extension,sessionReady)}, { - 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]; + // Another start is already registering the mission; its waiter begins the capture. + if (missionNamespace getVariable [QEGVAR(extension,sessionPending), false]) exitWith {}; + EGVAR(extension,sessionPending) = true; + + // 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. + [{!isNil QEGVAR(extension,missionContext)}, { + call EFUNC(extension,newMission); + // Wait for extension to confirm new mission before starting capture + [{EGVAR(extension,sessionReady)}, { + call FUNC(flushPlayerConnectionEvents); + call FUNC(captureLoop); + }, [], 30, { + EGVAR(extension,sessionPending) = false; + 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 CBA_fnc_waitUntilAndExecute; + }, [], 60, { + EGVAR(extension,sessionPending) = false; + ERROR("Timeout waiting for extension storage to initialize. Recording will not start."); + ["OCAP failed to start recording: extension storage is not ready", 1, [1, 0, 0, 1]] remoteExecCall ["CBA_fnc_notify", [0, -2] select isDedicated]; }] call CBA_fnc_waitUntilAndExecute; } else { call FUNC(flushPlayerConnectionEvents); diff --git a/docs/recording-lifecycle.md b/docs/recording-lifecycle.md index 99c7041..aef7693 100644 --- a/docs/recording-lifecycle.md +++ b/docs/recording-lifecycle.md @@ -18,6 +18,29 @@ 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. +- 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`. 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`), and `postgres`/`sqlite` keep what they + wrote. A recording that reached `minMissionTime` is saved as before. + +A recording ended without either, for example by an admin changing the mission +(`#missions`, `#restart`) or by a crash, is still saved by the web server as +`partial`, whatever its length. + ## Dedicated-server verification These scenarios require Arma 3 and the recorder extension; HEMTT checks and builds @@ -41,3 +64,13 @@ 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. diff --git a/tests/README.md b/tests/README.md index 756fa44..23c96e7 100644 --- a/tests/README.md +++ b/tests/README.md @@ -49,6 +49,19 @@ 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. +`save_limits_regressions.sqf` checks the save limits: a recording registers the +mission with the extension once, when it starts; 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. 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`. + 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 diff --git a/tests/save_limits_regressions.sqf b/tests/save_limits_regressions.sqf new file mode 100644 index 0000000..3cca8e2 --- /dev/null +++ b/tests/save_limits_regressions.sqf @@ -0,0 +1,126 @@ +/* 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]) exitWith {throw "Disable auto-start and stop recording first"}; +if (isNil "OCAP_extension_missionContext") exitWith {throw "Wait for the extension storage to initialize first"}; + +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_stopRecording", + "OCAP_recorder_fnc_captureLoop", "OCAP_recorder_fnc_updateTime" +]; +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_extension_fnc_sendData = { + params ["_command"]; + if (_command in [":MISSION:START:", ":MISSION:SAVE:", ":MISSION:DISCARD:"]) then { + OCAP_test_commands pushBack _command; + }; + "" +}; +OCAP_recorder_fnc_stopRecording = {OCAP_recorder_recording = false}; +OCAP_recorder_fnc_captureLoop = {OCAP_recorder_startTime = time}; +OCAP_recorder_fnc_updateTime = {}; +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. + OCAP_recorder_captureFrameNo = 0; + OCAP_recorder_startTime = nil; + OCAP_extension_sessionReady = false; + OCAP_extension_sessionPending = false; + call OCAP_recorder_fnc_startRecording; + call OCAP_recorder_fnc_startRecording; + sleep 1; + [[":MISSION:START:"] call _sent == 1, "a start registers the mission once"] 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_extension_sessionPending = false; // what :MISSION:OK: does + OCAP_extension_sessionReady = true; + sleep 1; + [!isNil "OCAP_recorder_startTime", "capture begins once the extension confirms"] 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; + + // Discarding before a recording started does nothing. + OCAP_recorder_startTime = nil; + ["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_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]; +}; From 8fc6c127dceedc7dc994d5fc50ae6ba32c443f2b Mon Sep 17 00:00:00 2001 From: Smith <13200576+smitt14ua@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:49:47 +0200 Subject: [PATCH 02/14] fix(recorder): start the capture only once the mission is registered, never orphan a registration Review fixes: - "Recording started.", "Mission has started!", the start notification, diary record and time state follow the capture (new OCAP_recorder_fnc_announceRecording). With the deferred :MISSION:START: they were sent before the capture ran, and the events were dropped. - The capture waits for :MISSION:OK: or the extension's new :MISSION:FAILED: however long it takes, and further start requests are ignored meanwhile without notifying players. A timeout let a late OK leave the extension with a mission nothing recorded, then saved as partial. A failed start, or storage that never initializes, sets OCAP_recorder_recording back to false. - A mission that ends while it registers discards the registration instead of starting a capture after the end. - A discard the extension rejects is logged as a warning. - Setting and doc texts: the discard happens only with saveMissionEnded; the minimum extension; registration log in seconds. Tests README section moved after the servicing suite's notes. --- addons/extension/fnc_initSession.sqf | 9 +- addons/extension/fnc_newMission.sqf | 4 +- addons/recorder/XEH_preInit.sqf | 4 +- addons/recorder/XEH_prep.sqf | 1 + addons/recorder/fnc_announceRecording.sqf | 52 ++++++++++ addons/recorder/fnc_discardRecording.sqf | 5 +- addons/recorder/fnc_handleMissionEnded.sqf | 4 + addons/recorder/fnc_init.sqf | 7 +- addons/recorder/fnc_startRecording.sqf | 110 ++++++++++----------- docs/recording-lifecycle.md | 20 +++- tests/README.md | 25 +++-- tests/save_limits_regressions.sqf | 46 +++++++-- 12 files changed, 207 insertions(+), 80 deletions(-) create mode 100644 addons/recorder/fnc_announceRecording.sqf diff --git a/addons/extension/fnc_initSession.sqf b/addons/extension/fnc_initSession.sqf index 477ef52..a37cfca 100644 --- a/addons/extension/fnc_initSession.sqf +++ b/addons/extension/fnc_initSession.sqf @@ -153,12 +153,19 @@ addMissionEventHandler ["ExtensionCallback", { }; if (_function isEqualTo ":MISSION:OK:") exitWith { - INFO_1("Mission registered in %1ms",diag_tickTime - GVAR(initTimer)); + 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] + 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, "", [""]]; diff --git a/addons/extension/fnc_newMission.sqf b/addons/extension/fnc_newMission.sqf index 4f14769..c36aaef 100644 --- a/addons/extension/fnc_newMission.sqf +++ b/addons/extension/fnc_newMission.sqf @@ -8,8 +8,8 @@ Description: 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. sessionPending is true - until then. + 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: the first recording of the mission, or one after the diff --git a/addons/recorder/XEH_preInit.sqf b/addons/recorder/XEH_preInit.sqf index 2fd90f9..3205f3e 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 @@ -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. A shorter recording is discarded when the mission ends. 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. 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 1c6166b..86848a7 100644 --- a/addons/recorder/XEH_prep.sqf +++ b/addons/recorder/XEH_prep.sqf @@ -5,6 +5,7 @@ PREP(init); PREP(updateTime); PREP(startRecording); +PREP(announceRecording); PREP(stopRecording); PREP(removeCaptureHandlers); PREP(resetRecording); diff --git a/addons/recorder/fnc_announceRecording.sqf b/addons/recorder/fnc_announceRecording.sqf new file mode 100644 index 0000000..b33a71d --- /dev/null +++ b/addons/recorder/fnc_announceRecording.sqf @@ -0,0 +1,52 @@ +/* ---------------------------------------------------------------------------- +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. + +Parameters: + None + +Returns: + Nothing + +Public: + No +---------------------------------------------------------------------------- */ +#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); diff --git a/addons/recorder/fnc_discardRecording.sqf b/addons/recorder/fnc_discardRecording.sqf index d6bd67d..ec118db 100644 --- a/addons/recorder/fnc_discardRecording.sqf +++ b/addons/recorder/fnc_discardRecording.sqf @@ -40,7 +40,10 @@ private _endFrameNumber = GVAR(captureFrameNo); call FUNC(removeCaptureHandlers); INFO_3("Discarding recording — mission: %1 | frames: %2 | reason: %3",GVAR(missionName),_endFrameNumber,_reason); -[":MISSION:DISCARD:", []] call EFUNC(extension,sendData); +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]; diff --git a/addons/recorder/fnc_handleMissionEnded.sqf b/addons/recorder/fnc_handleMissionEnded.sqf index 2e6595c..c9e25b3 100644 --- a/addons/recorder/fnc_handleMissionEnded.sqf +++ b/addons/recorder/fnc_handleMissionEnded.sqf @@ -25,6 +25,10 @@ Public: ---------------------------------------------------------------------------- */ #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 (!EGVAR(settings,saveMissionEnded)) exitWith {}; if ((GVAR(captureFrameNo) * GVAR(frameCaptureDelay)) / 60 >= GVAR(minMissionTime)) exitWith { diff --git a/addons/recorder/fnc_init.sqf b/addons/recorder/fnc_init.sqf index 3106be4..7c4d0d3 100644 --- a/addons/recorder/fnc_init.sqf +++ b/addons/recorder/fnc_init.sqf @@ -253,7 +253,12 @@ call EFUNC(extension,initSession); {(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; + // Events are recorded once the capture runs, which waits for the + // extension to register the mission. + [{!isNil QGVAR(startTime) || {!GVAR(recording)}}, { + if (isNil QGVAR(startTime)) exitWith {}; // the recording did not start + [QGVARMAIN(customEvent), ["generalEvent", "Mission has started!"]] call CBA_fnc_serverEvent; + }] call CBA_fnc_waitUntilAndExecute; } ] call CBA_fnc_waitUntilAndExecute; diff --git a/addons/recorder/fnc_startRecording.sqf b/addons/recorder/fnc_startRecording.sqf index aa79724..b56dd49 100644 --- a/addons/recorder/fnc_startRecording.sqf +++ b/addons/recorder/fnc_startRecording.sqf @@ -10,6 +10,9 @@ Description: Will not start recording if is true 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 are ignored. + Parameters: None @@ -39,69 +42,64 @@ 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. +if (missionNamespace getVariable [QEGVAR(extension,sessionPending), false]) exitWith { + INFO("Recording start requested while the mission registers with the extension"); +}; + // Any explicit or automatic start consumes a pending empty-server restart. GVAR(autoRestartAfterEmptyPending) = 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 && {!EGVAR(extension,sessionReady)}) exitWith { + EGVAR(extension,sessionPending) = true; + + // 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. + [{!isNil QEGVAR(extension,missionContext)}, { + call EFUNC(extension,newMission); + + // 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)}}, { + if (!EGVAR(extension,sessionReady)) exitWith { + GVAR(recording) = false; + publicVariable QGVAR(recording); + ["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]; + }; + if (missionNamespace getVariable [QGVAR(missionEnded), false]) exitWith { + INFO("Mission ended before its recording started; discarding the registered mission"); + [":MISSION:DISCARD:", []] call EFUNC(extension,sendData); + EGVAR(extension,sessionReady) = false; + GVAR(recording) = false; + publicVariable QGVAR(recording); + }; + call FUNC(flushPlayerConnectionEvents); + call FUNC(captureLoop); + call FUNC(announceRecording); + }] call CBA_fnc_waitUntilAndExecute; -if (GVAR(captureFrameNo) == 0) then { - if (!EGVAR(extension,sessionReady)) then { - // Another start is already registering the mission; its waiter begins the capture. - if (missionNamespace getVariable [QEGVAR(extension,sessionPending), false]) exitWith {}; - EGVAR(extension,sessionPending) = true; - - // 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. - [{!isNil QEGVAR(extension,missionContext)}, { - call EFUNC(extension,newMission); - // Wait for extension to confirm new mission before starting capture - [{EGVAR(extension,sessionReady)}, { - call FUNC(flushPlayerConnectionEvents); - call FUNC(captureLoop); - }, [], 30, { - EGVAR(extension,sessionPending) = false; - 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 CBA_fnc_waitUntilAndExecute; - }, [], 60, { - EGVAR(extension,sessionPending) = false; - ERROR("Timeout waiting for extension storage to initialize. Recording will not start."); - ["OCAP failed to start recording: extension storage is not ready", 1, [1, 0, 0, 1]] remoteExecCall ["CBA_fnc_notify", [0, -2] select isDedicated]; + [{EGVAR(extension,sessionReady) || {!EGVAR(extension,sessionPending)}}, {}, [], 30, { + WARNING("The extension has not confirmed the mission after 30 seconds; recording starts once it does"); }] call CBA_fnc_waitUntilAndExecute; - } else { - call FUNC(flushPlayerConnectionEvents); - call FUNC(captureLoop); - }; + }, [], 60, { + EGVAR(extension,sessionPending) = false; + GVAR(recording) = false; + publicVariable QGVAR(recording); + ERROR("Timeout waiting for extension storage to initialize. Recording will not start."); + ["OCAP failed to start recording: extension storage is not ready", 1, [1, 0, 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); +if (GVAR(captureFrameNo) == 0) then { + call FUNC(flushPlayerConnectionEvents); + call FUNC(captureLoop); +}; +call FUNC(announceRecording); diff --git a/docs/recording-lifecycle.md b/docs/recording-lifecycle.md index aef7693..431cb4e 100644 --- a/docs/recording-lifecycle.md +++ b/docs/recording-lifecycle.md @@ -29,6 +29,12 @@ recording to the web server while it records: 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, and ignores further + start requests meanwhile. If the mission ends first, the registered mission + is discarded instead. - 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 @@ -37,9 +43,15 @@ recording to the web server while it records: server still saves it as `partial`), and `postgres`/`sqlite` keep what they wrote. A recording that reached `minMissionTime` is saved as before. -A recording ended without either, for example by an admin changing the mission -(`#missions`, `#restart`) or by a crash, is still saved by the web server as -`partial`, whatever its length. +This needs an extension with `:MISSION:DISCARD:` and `:MISSION:FAILED:` +(OCAP2/extension#217). An older one rejects the discard, which the server RPT +logs as a warning, and never answers a failed start, so a recording whose +mission did not start keeps waiting. + +A recording ended without a save or a discard is still saved by the web server +as `partial`, whatever its length. That covers an admin changing the mission +(`#missions`, `#restart`), a crash, and every mission end with +`saveMissionEnded` disabled. ## Dedicated-server verification @@ -74,3 +86,5 @@ as the server notifications. 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. +8. With auto-start, verify the saved recording holds the "Recording started." + and "Mission has started!" events at its start. diff --git a/tests/README.md b/tests/README.md index 23c96e7..6a46251 100644 --- a/tests/README.md +++ b/tests/README.md @@ -49,11 +49,19 @@ 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. + `save_limits_regressions.sqf` checks the save limits: a recording registers the -mission with the extension once, when it starts; 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. Execute it on the server once the extension +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 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 @@ -62,7 +70,8 @@ execVM "tests\save_limits_regressions.sqf"; The RPT must end with `[OCAP save limits] ALL TESTS PASSED`. -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. +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 index 3cca8e2..b39d0a9 100644 --- a/tests/save_limits_regressions.sqf +++ b/tests/save_limits_regressions.sqf @@ -13,7 +13,8 @@ private _savedFunctions = createHashMap; _savedFunctions set [_x, missionNamespace getVariable _x]; } forEach [ "OCAP_extension_fnc_sendData", "OCAP_recorder_fnc_stopRecording", - "OCAP_recorder_fnc_captureLoop", "OCAP_recorder_fnc_updateTime" + "OCAP_recorder_fnc_captureLoop", "OCAP_recorder_fnc_updateTime", + "OCAP_recorder_fnc_announceRecording" ]; private _savedSettings = [ OCAP_settings_saveMissionEnded, OCAP_recorder_minMissionTime, OCAP_recorder_frameCaptureDelay @@ -32,6 +33,21 @@ OCAP_extension_fnc_sendData = { OCAP_recorder_fnc_stopRecording = {OCAP_recorder_recording = false}; 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; +}; private _sent = { params ["_command"]; {_x isEqualTo _command} count OCAP_test_commands @@ -53,20 +69,37 @@ try { // The mission registers with the extension when recording starts, once, // however often a start is requested before the extension confirms it. - OCAP_recorder_captureFrameNo = 0; - OCAP_recorder_startTime = nil; - OCAP_extension_sessionReady = false; - OCAP_extension_sessionPending = false; - call OCAP_recorder_fnc_startRecording; + call _registering; call OCAP_recorder_fnc_startRecording; sleep 1; [[":MISSION:START:"] call _sent == 1, "a start registers the mission once"] 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; OCAP_extension_sessionPending = false; // what :MISSION:OK: does OCAP_extension_sessionReady = true; 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; + + // 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; + OCAP_extension_sessionPending = false; // what :MISSION:OK: does + OCAP_extension_sessionReady = true; + 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 mission that ends before the minimum is discarded, not saved. [5] call _recordingOf; @@ -110,6 +143,7 @@ try { OCAP_recorder_recording = false; OCAP_recorder_startTime = nil; +OCAP_recorder_missionEnded = nil; OCAP_recorder_captureFrameNo = 0; OCAP_extension_sessionReady = false; OCAP_extension_sessionPending = false; From 19813dbc29bd5ce240df8acdb586c24b5bd7cb0e Mon Sep 17 00:00:00 2001 From: Smith <13200576+smitt14ua@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:13:56 +0200 Subject: [PATCH 03/14] fix(recorder): honor pauses during registration, announce a start once Second review round: - Init's auto-start waiter no longer calls startRecording again for a recording that already captures, which announced a manual start twice. - A pause while the mission registers holds the capture back until recording resumes; a start request while it registers undoes the pause. A start that began the capture in the frame before the waiter runs is not repeated. - A mission registered with the extension whose capture never began is discarded at mission end without telling players, whatever saveMissionEnded says (discardRecording handles it). - Players are notified when the extension has not confirmed the mission after 30 s; newMission clears the pending flag when it cannot register. - Docs: OCAP_recorder_startTime signals the capture for mission scripts; the one-frame discard delay is part of the manual check. --- addons/extension/fnc_newMission.sqf | 1 + addons/recorder/fnc_discardRecording.sqf | 17 ++++++++++-- addons/recorder/fnc_handleMissionEnded.sqf | 13 +++++++-- addons/recorder/fnc_init.sqf | 17 +++++++++--- addons/recorder/fnc_startRecording.sqf | 16 +++++++---- docs/recording-lifecycle.md | 17 ++++++++---- tests/README.md | 4 ++- tests/save_limits_regressions.sqf | 32 ++++++++++++++++++++++ 8 files changed, 97 insertions(+), 20 deletions(-) diff --git a/addons/extension/fnc_newMission.sqf b/addons/extension/fnc_newMission.sqf index c36aaef..42e45be 100644 --- a/addons/extension/fnc_newMission.sqf +++ b/addons/extension/fnc_newMission.sqf @@ -31,6 +31,7 @@ Author: if (isNil QGVAR(worldContext) || isNil QGVAR(missionContext)) exitWith { ERROR("Cannot register mission: cached world/mission context is nil. Full initSession required."); + GVAR(sessionPending) = false; // nothing will answer }; INFO("Registering new mission with extension"); diff --git a/addons/recorder/fnc_discardRecording.sqf b/addons/recorder/fnc_discardRecording.sqf index ec118db..4c331b1 100644 --- a/addons/recorder/fnc_discardRecording.sqf +++ b/addons/recorder/fnc_discardRecording.sqf @@ -13,7 +13,9 @@ Description: drop it instead; it answers with :MISSION:DISCARDED: (see ). - Does nothing when no recording has started. + 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] @@ -32,7 +34,18 @@ Public: params [["_reason", "", [""]]]; if (isNil QGVAR(startTime)) exitWith { - LOG("Discard requested, but recording of this session hasn't started."); + 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); diff --git a/addons/recorder/fnc_handleMissionEnded.sqf b/addons/recorder/fnc_handleMissionEnded.sqf index c9e25b3..3230cd9 100644 --- a/addons/recorder/fnc_handleMissionEnded.sqf +++ b/addons/recorder/fnc_handleMissionEnded.sqf @@ -4,8 +4,8 @@ FILE: fnc_handleMissionEnded.sqf FUNCTION: OCAP_recorder_fnc_handleMissionEnded Description: - Called by the "MPEnded" and "Ended" mission event handlers when - is true. A recording that reached + 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. @@ -14,6 +14,10 @@ Description: 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 @@ -29,6 +33,11 @@ Public: // 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 { diff --git a/addons/recorder/fnc_init.sqf b/addons/recorder/fnc_init.sqf index 7c4d0d3..e452ecb 100644 --- a/addons/recorder/fnc_init.sqf +++ b/addons/recorder/fnc_init.sqf @@ -252,11 +252,20 @@ call EFUNC(extension,initSession); [ {(getClientStateNumber > 9 && (call FUNC(getAutoStartPlayerCount)) >= EGVAR(settings,minPlayerCount) && GVAR(autoStart)) || !isNil QGVAR(startTime)}, { - call FUNC(startRecording); + // Recording started some other way already captures. + if (isNil QGVAR(startTime)) then { + call FUNC(startRecording); + }; // Events are recorded once the capture runs, which waits for the - // extension to register the mission. - [{!isNil QGVAR(startTime) || {!GVAR(recording)}}, { - if (isNil QGVAR(startTime)) exitWith {}; // the recording did not start + // extension to register the mission. Give up when that failed. + [{ + !isNil QGVAR(startTime) || { + !GVAR(recording) && + {!(missionNamespace getVariable [QEGVAR(extension,sessionPending), false])} && + {!EGVAR(extension,sessionReady)} + } + }, { + if (isNil QGVAR(startTime)) exitWith {}; [QGVARMAIN(customEvent), ["generalEvent", "Mission has started!"]] call CBA_fnc_serverEvent; }] call CBA_fnc_waitUntilAndExecute; } diff --git a/addons/recorder/fnc_startRecording.sqf b/addons/recorder/fnc_startRecording.sqf index b56dd49..6de9346 100644 --- a/addons/recorder/fnc_startRecording.sqf +++ b/addons/recorder/fnc_startRecording.sqf @@ -43,9 +43,11 @@ if (GVAR(recording) && GVAR(captureFrameNo) > 10) exitWith { }; // Another start is registering the mission with the extension; it begins the -// capture once the extension confirms it. +// capture once the extension confirms it. A pause meanwhile is undone. if (missionNamespace getVariable [QEGVAR(extension,sessionPending), false]) exitWith { INFO("Recording start requested while the mission registers with the extension"); + GVAR(recording) = true; + publicVariable QGVAR(recording); }; // Any explicit or automatic start consumes a pending empty-server restart. @@ -74,12 +76,13 @@ if (GVAR(captureFrameNo) == 0 && {!EGVAR(extension,sessionReady)}) exitWith { publicVariable QGVAR(recording); ["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]; }; + if (!isNil QGVAR(startTime)) exitWith {}; // a start in the meantime began the capture if (missionNamespace getVariable [QGVAR(missionEnded), false]) exitWith { - INFO("Mission ended before its recording started; discarding the registered mission"); - [":MISSION:DISCARD:", []] call EFUNC(extension,sendData); - EGVAR(extension,sessionReady) = false; - GVAR(recording) = false; - publicVariable QGVAR(recording); + ["the mission ended before the recording began"] call FUNC(discardRecording); + }; + // 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); @@ -88,6 +91,7 @@ if (GVAR(captureFrameNo) == 0 && {!EGVAR(extension,sessionReady)}) exitWith { [{EGVAR(extension,sessionReady) || {!EGVAR(extension,sessionPending)}}, {}, [], 30, { 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; }, [], 60, { EGVAR(extension,sessionPending) = false; diff --git a/docs/recording-lifecycle.md b/docs/recording-lifecycle.md index 431cb4e..5f7522d 100644 --- a/docs/recording-lifecycle.md +++ b/docs/recording-lifecycle.md @@ -33,8 +33,14 @@ recording to the web server while it records: 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, and ignores further - start requests meanwhile. If the mission ends first, the registered mission - is discarded instead. + start requests meanwhile; after 30 seconds it notifies players that it is + still waiting. A pause meanwhile (`OCAP_pause`) holds the capture back until + recording resumes. If the mission ends first, the registered mission is + discarded instead, whatever `saveMissionEnded` says. +- `OCAP_recorder_recording` is true from the start request on, while + `OCAP_recorder_startTime` is set once the capture runs. A mission script that + starts recording (`OCAP_record`) and records its own events or exports right + away should wait for `OCAP_recorder_startTime`. - 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 @@ -50,8 +56,8 @@ mission did not start keeps waiting. A recording ended without a save or a discard is still saved by the web server as `partial`, whatever its length. That covers an admin changing the mission -(`#missions`, `#restart`), a crash, and every mission end with -`saveMissionEnded` disabled. +(`#missions`, `#restart`), a crash, and a recording that captured anything +when the mission ends with `saveMissionEnded` disabled. ## Dedicated-server verification @@ -85,6 +91,7 @@ as the server notifications. `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. + 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. diff --git a/tests/README.md b/tests/README.md index 6a46251..0f5936a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -58,7 +58,9 @@ in the release PBOs. 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 mission that ends before `minMissionTime` is discarded +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 diff --git a/tests/save_limits_regressions.sqf b/tests/save_limits_regressions.sqf index b39d0a9..82d81e5 100644 --- a/tests/save_limits_regressions.sqf +++ b/tests/save_limits_regressions.sqf @@ -101,6 +101,37 @@ try { [[":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; + OCAP_extension_sessionPending = false; // what :MISSION:OK: does + OCAP_extension_sessionReady = true; + 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; + OCAP_extension_sessionPending = false; + OCAP_extension_sessionReady = true; + sleep 1; + [!isNil "OCAP_recorder_startTime", "the capture begins once registered"] call _assert; + + // A registered mission paused before its capture is discarded at mission end. + call _registering; + call OCAP_recorder_fnc_stopRecording; + OCAP_extension_sessionPending = false; + OCAP_extension_sessionReady = true; + 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; @@ -135,6 +166,7 @@ try { // Discarding before a recording started does nothing. OCAP_recorder_startTime = nil; + OCAP_extension_sessionReady = false; ["test"] call OCAP_recorder_fnc_discardRecording; [OCAP_test_commands isEqualTo [], "no recording, nothing to discard"] call _assert; } catch { From 6735f22d35cecdf6e557d11b495e1e4a03c1dfdb Mon Sep 17 00:00:00 2001 From: Smith <13200576+smitt14ua@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:39:25 +0200 Subject: [PATCH 04/14] fix(recorder): wait for storage until it fails, keep a pause before the capture quiet Third review round: - The wait for the extension's storage has no timeout: it ends when the mission context is built, when :STORAGE:ERROR: arrives (now flagged by initSession, and sent by OCAP2/extension#217) or when the mission ends. Players are notified after 60 s. A slow storage used to lose the whole recording after 60 s. - A mission that ends while waiting for storage does not register at all. - In the registration waiter a mission end is checked before a failed start, so players are not told the start failed. - newMission gives up when the extension rejects :MISSION:START:. - stopRecording before the capture began only holds it back: no "stopped" notification or time state. - Init's auto-start does not start while a registration is pending, so it cannot undo an admin's pause. - The in-game test refuses to run with final functions; the tests README says to allow recompiling. Comments and docs updated. --- addons/extension/fnc_initSession.sqf | 1 + addons/extension/fnc_newMission.sqf | 5 ++- addons/recorder/fnc_addEventMission.sqf | 2 +- addons/recorder/fnc_init.sqf | 5 +-- addons/recorder/fnc_startRecording.sqf | 43 +++++++++++++++++-------- addons/recorder/fnc_stopRecording.sqf | 8 +++++ docs/recording-lifecycle.md | 16 ++++++--- tests/README.md | 6 +++- tests/save_limits_regressions.sqf | 4 +++ 9 files changed, 67 insertions(+), 23 deletions(-) diff --git a/addons/extension/fnc_initSession.sqf b/addons/extension/fnc_initSession.sqf index a37cfca..3ab96b8 100644 --- a/addons/extension/fnc_initSession.sqf +++ b/addons/extension/fnc_initSession.sqf @@ -65,6 +65,7 @@ addMissionEventHandler ["ExtensionCallback", { 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); }; diff --git a/addons/extension/fnc_newMission.sqf b/addons/extension/fnc_newMission.sqf index 42e45be..3a15686 100644 --- a/addons/extension/fnc_newMission.sqf +++ b/addons/extension/fnc_newMission.sqf @@ -37,4 +37,7 @@ if (isNil QGVAR(worldContext) || isNil QGVAR(missionContext)) exitWith { INFO("Registering new mission with extension"); GVAR(initTimer) = diag_tickTime; GVAR(sessionPending) = true; -[":MISSION:START:", [GVAR(worldContext), GVAR(missionContext)], 'ocap_recorder'] call FUNC(sendData); +if (isNil {[":MISSION:START:", [GVAR(worldContext), GVAR(missionContext)], '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/fnc_addEventMission.sqf b/addons/recorder/fnc_addEventMission.sqf index fc7d184..bf9a010 100644 --- a/addons/recorder/fnc_addEventMission.sqf +++ b/addons/recorder/fnc_addEventMission.sqf @@ -103,7 +103,7 @@ 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. When is true, this saves and exports the recording if was reached and discards it otherwise. See . + // 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", { call FUNC(handleMissionEnded); }]; diff --git a/addons/recorder/fnc_init.sqf b/addons/recorder/fnc_init.sqf index e452ecb..0b4bbce 100644 --- a/addons/recorder/fnc_init.sqf +++ b/addons/recorder/fnc_init.sqf @@ -252,8 +252,9 @@ call EFUNC(extension,initSession); [ {(getClientStateNumber > 9 && (call FUNC(getAutoStartPlayerCount)) >= EGVAR(settings,minPlayerCount) && GVAR(autoStart)) || !isNil QGVAR(startTime)}, { - // Recording started some other way already captures. - if (isNil QGVAR(startTime)) then { + // Recording started some other way already captures, or registers the + // mission; a start now would undo an admin's pause. + if (isNil QGVAR(startTime) && {!(missionNamespace getVariable [QEGVAR(extension,sessionPending), false])}) then { call FUNC(startRecording); }; // Events are recorded once the capture runs, which waits for the diff --git a/addons/recorder/fnc_startRecording.sqf b/addons/recorder/fnc_startRecording.sqf index 6de9346..53a6ca1 100644 --- a/addons/recorder/fnc_startRecording.sqf +++ b/addons/recorder/fnc_startRecording.sqf @@ -11,7 +11,8 @@ Description: Will not start recording if is true 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 are ignored. + and begins the capture once the extension confirms it. Start requests made meanwhile register + nothing more, but undo a pause. Parameters: None @@ -62,8 +63,23 @@ if (GVAR(captureFrameNo) == 0 && {!EGVAR(extension,sessionReady)}) exitWith { // 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. - [{!isNil QEGVAR(extension,missionContext)}, { + // may still be running and provides the mission context; it may take + // long, for example a database connection that falls back to SQLite. + [{ + !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; + GVAR(recording) = false; + publicVariable QGVAR(recording); + if (!_ended) then { + ERROR("Extension storage could not be initialized. Recording will not start."); + ["OCAP failed to start recording: extension storage is not ready", 1, [1, 0, 0, 1]] remoteExecCall ["CBA_fnc_notify", [0, -2] select isDedicated]; + }; + }; call EFUNC(extension,newMission); // The extension answers :MISSION:OK: or :MISSION:FAILED:. It may take a @@ -71,15 +87,17 @@ if (GVAR(captureFrameNo) == 0 && {!EGVAR(extension,sessionReady)}) exitWith { // the capture waits for it: starting another registration meanwhile // would leave the extension with a mission nothing records. [{EGVAR(extension,sessionReady) || {!EGVAR(extension,sessionPending)}}, { + 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 { GVAR(recording) = false; publicVariable QGVAR(recording); ["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]; }; - 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); - }; // 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"); @@ -93,12 +111,11 @@ if (GVAR(captureFrameNo) == 0 && {!EGVAR(extension,sessionReady)}) 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; - }, [], 60, { - EGVAR(extension,sessionPending) = false; - GVAR(recording) = false; - publicVariable QGVAR(recording); - ERROR("Timeout waiting for extension storage to initialize. Recording will not start."); - ["OCAP failed to start recording: extension storage is not ready", 1, [1, 0, 0, 1]] remoteExecCall ["CBA_fnc_notify", [0, -2] select isDedicated]; + }] call CBA_fnc_waitUntilAndExecute; + + [{!isNil QEGVAR(extension,missionContext) || {!EGVAR(extension,sessionPending)}}, {}, [], 60, { + 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; }; diff --git a/addons/recorder/fnc_stopRecording.sqf b/addons/recorder/fnc_stopRecording.sqf index 86cc132..e3b2c4f 100644 --- a/addons/recorder/fnc_stopRecording.sqf +++ b/addons/recorder/fnc_stopRecording.sqf @@ -25,6 +25,14 @@ 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); +}; + 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 5f7522d..6d521ad 100644 --- a/docs/recording-lifecycle.md +++ b/docs/recording-lifecycle.md @@ -36,7 +36,13 @@ recording to the web server while it records: start requests meanwhile; after 30 seconds it notifies players that it is still waiting. A pause meanwhile (`OCAP_pause`) holds the capture back until recording resumes. If the mission ends first, the registered mission is - discarded instead, whatever `saveMissionEnded` says. + discarded instead, whatever `saveMissionEnded` says. If the mission unloads + before the extension answers, the extension drops that empty mission when + the next one starts. 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. - `OCAP_recorder_recording` is true from the start request on, while `OCAP_recorder_startTime` is set once the capture runs. A mission script that starts recording (`OCAP_record`) and records its own events or exports right @@ -49,10 +55,10 @@ recording to the web server while it records: server still saves it as `partial`), and `postgres`/`sqlite` keep what they wrote. A recording that reached `minMissionTime` is saved as before. -This needs an extension with `:MISSION:DISCARD:` and `:MISSION:FAILED:` -(OCAP2/extension#217). An older one rejects the discard, which the server RPT -logs as a warning, and never answers a failed start, so a recording whose -mission did not start keeps waiting. +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. A recording ended without a save or a discard is still saved by the web server as `partial`, whatever its length. That covers an admin changing the mission diff --git a/tests/README.md b/tests/README.md index 0f5936a..3ac3eb9 100644 --- a/tests/README.md +++ b/tests/README.md @@ -6,7 +6,11 @@ and restart entry point. They require Arma multiplayer; HEMTT checks/builds do n 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 +disabled, and two connected players with different network owners. The suites +replace addon functions with stubs, which CBA compiles final by default: set +`allowFunctionsRecompile = 1;` in the test mission's `description.ext`, or +otherwise enable CBA function recompiling. `save_limits_regressions.sqf` stops +with an error when its functions are final. 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: diff --git a/tests/save_limits_regressions.sqf b/tests/save_limits_regressions.sqf index 82d81e5..360be45 100644 --- a/tests/save_limits_regressions.sqf +++ b/tests/save_limits_regressions.sqf @@ -2,6 +2,10 @@ if (!isServer || {!canSuspend}) exitWith {throw "Run the tests with server-side execVM"}; if (missionNamespace getVariable ["OCAP_recorder_recording", false]) exitWith {throw "Disable auto-start and stop recording first"}; 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 unless the +// mission allows recompiling them; otherwise real lifecycle commands would reach +// the extension. +if (isFinal OCAP_extension_fnc_sendData) exitWith {throw "Set allowFunctionsRecompile = 1 in the test mission's description.ext"}; private _assert = { params ["_condition", "_description"]; From 54ab0e7cc23e64423dc165c1cf8f20f6212ee1a0 Mon Sep 17 00:00:00 2001 From: Smith <13200576+smitt14ua@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:04:36 +0200 Subject: [PATCH 05/14] fix(recorder): match registration answers by token, keep auto-start off a paused registration Fourth review round: - Each registration sends a token as :MISSION:START:'s third argument (echoed by OCAP2/extension#217); :MISSION:OK:/:MISSION:FAILED: with another token, or with nothing pending, are ignored. A late OK from the previous mission made the next one record into the old registration. - Init's auto-start does not start while a registered mission is held paused, which undid an admin's pause. - A recording gives up when no extension is running (no :SYS:VERSION: after 60 s); a start request during registration notifies players. - The in-game test runs the real stopRecording. Setting text, tests README and init comment corrected. --- addons/extension/fnc_initSession.sqf | 14 +++++++++++++- addons/extension/fnc_newMission.sqf | 5 ++++- addons/recorder/XEH_preInit.sqf | 2 +- addons/recorder/fnc_init.sqf | 13 +++++++++---- addons/recorder/fnc_startRecording.sqf | 7 +++++++ docs/recording-lifecycle.md | 5 ++++- tests/README.md | 2 +- tests/save_limits_regressions.sqf | 3 +-- 8 files changed, 40 insertions(+), 11 deletions(-) diff --git a/addons/extension/fnc_initSession.sqf b/addons/extension/fnc_initSession.sqf index 3ab96b8..1b55109 100644 --- a/addons/extension/fnc_initSession.sqf +++ b/addons/extension/fnc_initSession.sqf @@ -153,7 +153,18 @@ addMissionEventHandler ["ExtensionCallback", { 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; @@ -161,7 +172,8 @@ addMissionEventHandler ["ExtensionCallback", { }; if (_function isEqualTo ":MISSION:FAILED:") exitWith { - // Payload shape: [error] + // 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; diff --git a/addons/extension/fnc_newMission.sqf b/addons/extension/fnc_newMission.sqf index 3a15686..5c1202f 100644 --- a/addons/extension/fnc_newMission.sqf +++ b/addons/extension/fnc_newMission.sqf @@ -37,7 +37,10 @@ if (isNil QGVAR(worldContext) || isNil QGVAR(missionContext)) exitWith { INFO("Registering new mission with extension"); GVAR(initTimer) = diag_tickTime; GVAR(sessionPending) = true; -if (isNil {[":MISSION:START:", [GVAR(worldContext), GVAR(missionContext)], 'ocap_recorder'] call FUNC(sendData)}) then { +// 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", round (diag_tickTime * 1000), floor random 1e6]; +if (isNil {[":MISSION:START:", [GVAR(worldContext), GVAR(missionContext), GVAR(registrationToken)], '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 3205f3e..237fd8f 100644 --- a/addons/recorder/XEH_preInit.sqf +++ b/addons/recorder/XEH_preInit.sqf @@ -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. A shorter recording is discarded when the mission ends, so storage that already holds it (such as the websocket storage) drops it. 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. Calling an CBA server event will override this restriction. Default: 20 Setting Name: Required Duration to Sav diff --git a/addons/recorder/fnc_init.sqf b/addons/recorder/fnc_init.sqf index 0b4bbce..d15cd68 100644 --- a/addons/recorder/fnc_init.sqf +++ b/addons/recorder/fnc_init.sqf @@ -246,15 +246,20 @@ 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)}, { - // Recording started some other way already captures, or registers the - // mission; a start now would undo an admin's pause. - if (isNil QGVAR(startTime) && {!(missionNamespace getVariable [QEGVAR(extension,sessionPending), false])}) then { + // Recording started some other way already captures, registers the + // mission, or holds a registered mission paused; a start now would undo + // an admin's pause. + if ( + isNil QGVAR(startTime) && + {!(missionNamespace getVariable [QEGVAR(extension,sessionPending), false])} && + {!EGVAR(extension,sessionReady)} + ) then { call FUNC(startRecording); }; // Events are recorded once the capture runs, which waits for the diff --git a/addons/recorder/fnc_startRecording.sqf b/addons/recorder/fnc_startRecording.sqf index 53a6ca1..12dc674 100644 --- a/addons/recorder/fnc_startRecording.sqf +++ b/addons/recorder/fnc_startRecording.sqf @@ -49,6 +49,7 @@ if (missionNamespace getVariable [QEGVAR(extension,sessionPending), false]) exit INFO("Recording start requested while the mission registers with the extension"); 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. @@ -114,6 +115,12 @@ if (GVAR(captureFrameNo) == 0 && {!EGVAR(extension,sessionReady)}) exitWith { }] 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; + }; 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; diff --git a/docs/recording-lifecycle.md b/docs/recording-lifecycle.md index 6d521ad..f6c7d49 100644 --- a/docs/recording-lifecycle.md +++ b/docs/recording-lifecycle.md @@ -34,7 +34,10 @@ recording to the web server while it records: for that answer, or `:MISSION:FAILED:`, however long the extension takes, for example while it still saves the previous recording, and ignores further start requests meanwhile; after 30 seconds it notifies players that it is - still waiting. A pause meanwhile (`OCAP_pause`) holds the capture back until + still waiting. 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. If the mission ends first, the registered mission is discarded instead, whatever `saveMissionEnded` says. If the mission unloads before the extension answers, the extension drops that empty mission when diff --git a/tests/README.md b/tests/README.md index 3ac3eb9..a604348 100644 --- a/tests/README.md +++ b/tests/README.md @@ -12,7 +12,7 @@ replace addon functions with stubs, which CBA compiles final by default: set otherwise enable CBA function recompiling. `save_limits_regressions.sqf` stops with an error when its functions are final. 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 diff --git a/tests/save_limits_regressions.sqf b/tests/save_limits_regressions.sqf index 360be45..ea7878d 100644 --- a/tests/save_limits_regressions.sqf +++ b/tests/save_limits_regressions.sqf @@ -16,7 +16,7 @@ private _savedFunctions = createHashMap; { _savedFunctions set [_x, missionNamespace getVariable _x]; } forEach [ - "OCAP_extension_fnc_sendData", "OCAP_recorder_fnc_stopRecording", + "OCAP_extension_fnc_sendData", "OCAP_recorder_fnc_captureLoop", "OCAP_recorder_fnc_updateTime", "OCAP_recorder_fnc_announceRecording" ]; @@ -34,7 +34,6 @@ OCAP_extension_fnc_sendData = { }; "" }; -OCAP_recorder_fnc_stopRecording = {OCAP_recorder_recording = false}; OCAP_recorder_fnc_captureLoop = {OCAP_recorder_startTime = time}; OCAP_recorder_fnc_updateTime = {}; // Its events are recorded only while the capture runs. From 5d9a064a43b7bcc279165713789ef02af8427d73 Mon Sep 17 00:00:00 2001 From: Smith <13200576+smitt14ua@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:25:30 +0200 Subject: [PATCH 06/14] feat(recorder): OCAP_recordingStarted event, start requests idempotent once capturing Fifth review round: - Raise the server-local CBA event OCAP_recordingStarted once the capture runs (and on each resume): OCAP_record now starts a fresh recording asynchronously, so mission scripts can wait for it. Documented on OCAP_record and in the lifecycle docs. - A start request while the capture runs only reports "already recording"; whether to start the capture is decided by startTime instead of the frame number, which restarted it within its first 10 frames. - A pause while storage initializes does not register the mission until recording resumes; the 30 s warning stays quiet while paused. - The registration token is two random numbers. The in-game test confirms registrations atomically and checks a repeated start. Headers of the new functions name their authors; docs no longer cite postgres/sqlite. --- addons/extension/fnc_newMission.sqf | 2 +- addons/recorder/fnc_addEventMission.sqf | 5 ++++ addons/recorder/fnc_announceRecording.sqf | 9 +++++++ addons/recorder/fnc_discardRecording.sqf | 3 +++ addons/recorder/fnc_handleMissionEnded.sqf | 3 +++ addons/recorder/fnc_removeCaptureHandlers.sqf | 3 +++ addons/recorder/fnc_resetRecording.sqf | 3 +++ addons/recorder/fnc_startRecording.sqf | 19 +++++++++++---- docs/recording-lifecycle.md | 23 +++++++++++------- tests/save_limits_regressions.sqf | 24 +++++++++++-------- 10 files changed, 69 insertions(+), 25 deletions(-) diff --git a/addons/extension/fnc_newMission.sqf b/addons/extension/fnc_newMission.sqf index 5c1202f..71e0273 100644 --- a/addons/extension/fnc_newMission.sqf +++ b/addons/extension/fnc_newMission.sqf @@ -39,7 +39,7 @@ GVAR(initTimer) = diag_tickTime; 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", round (diag_tickTime * 1000), floor random 1e6]; +GVAR(registrationToken) = format ["%1-%2", floor random 1e6, floor random 1e6]; if (isNil {[":MISSION:START:", [GVAR(worldContext), GVAR(missionContext), GVAR(registrationToken)], '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/fnc_addEventMission.sqf b/addons/recorder/fnc_addEventMission.sqf index bf9a010..84a7e04 100644 --- a/addons/recorder/fnc_addEventMission.sqf +++ b/addons/recorder/fnc_addEventMission.sqf @@ -272,8 +272,13 @@ 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 and exports before then are not recorded. + Example: > ["OCAP_record"] call CBA_fnc_serverEvent; + > ["OCAP_recordingStarted", {["OCAP_customEvent", ["generalEvent", "Phase 1"]] call CBA_fnc_serverEvent}] call CBA_fnc_addEventHandler; */ if (isNil QEGVAR(listener,record)) then { EGVAR(listener,record) = [QGVARMAIN(record), { diff --git a/addons/recorder/fnc_announceRecording.sqf b/addons/recorder/fnc_announceRecording.sqf index b33a71d..b91df37 100644 --- a/addons/recorder/fnc_announceRecording.sqf +++ b/addons/recorder/fnc_announceRecording.sqf @@ -11,6 +11,10 @@ Description: 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 @@ -19,6 +23,9 @@ Returns: Public: No + +Author: + Smith ---------------------------------------------------------------------------- */ #include "script_component.hpp" @@ -50,3 +57,5 @@ _missionDateFormat append (date apply {if (_x < 10) then {"0" + str _x} else {st // Log times [] call FUNC(updateTime); + +[QGVARMAIN(recordingStarted), []] call CBA_fnc_localEvent; diff --git a/addons/recorder/fnc_discardRecording.sqf b/addons/recorder/fnc_discardRecording.sqf index 4c331b1..1f76d77 100644 --- a/addons/recorder/fnc_discardRecording.sqf +++ b/addons/recorder/fnc_discardRecording.sqf @@ -28,6 +28,9 @@ Examples: Public: No + +Author: + Smith ---------------------------------------------------------------------------- */ #include "script_component.hpp" diff --git a/addons/recorder/fnc_handleMissionEnded.sqf b/addons/recorder/fnc_handleMissionEnded.sqf index 3230cd9..a83ddc2 100644 --- a/addons/recorder/fnc_handleMissionEnded.sqf +++ b/addons/recorder/fnc_handleMissionEnded.sqf @@ -26,6 +26,9 @@ Returns: Public: No + +Author: + Smith ---------------------------------------------------------------------------- */ #include "script_component.hpp" diff --git a/addons/recorder/fnc_removeCaptureHandlers.sqf b/addons/recorder/fnc_removeCaptureHandlers.sqf index e0a9f5d..e4f7ad5 100644 --- a/addons/recorder/fnc_removeCaptureHandlers.sqf +++ b/addons/recorder/fnc_removeCaptureHandlers.sqf @@ -15,6 +15,9 @@ Returns: Public: No + +Author: + Dell, Zealot, IndigoFox, TyroneMF (moved from OCAP_recorder_fnc_exportData) ---------------------------------------------------------------------------- */ #include "script_component.hpp" diff --git a/addons/recorder/fnc_resetRecording.sqf b/addons/recorder/fnc_resetRecording.sqf index 37b8205..70424c9 100644 --- a/addons/recorder/fnc_resetRecording.sqf +++ b/addons/recorder/fnc_resetRecording.sqf @@ -16,6 +16,9 @@ Returns: Public: No + +Author: + Dell, Zealot, IndigoFox, TyroneMF (moved from OCAP_recorder_fnc_exportData) ---------------------------------------------------------------------------- */ #include "script_component.hpp" diff --git a/addons/recorder/fnc_startRecording.sqf b/addons/recorder/fnc_startRecording.sqf index 12dc674..25a200e 100644 --- a/addons/recorder/fnc_startRecording.sqf +++ b/addons/recorder/fnc_startRecording.sqf @@ -34,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]], @@ -58,14 +58,14 @@ GVAR(autoRestartAfterEmptyPending) = false; GVAR(recording) = true; publicVariable QGVAR(recording); -if (GVAR(captureFrameNo) == 0 && {!EGVAR(extension,sessionReady)}) exitWith { +if (isNil QGVAR(startTime) && {!EGVAR(extension,sessionReady)}) exitWith { EGVAR(extension,sessionPending) = true; // 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 database connection that falls back to SQLite. + // long, for example a websocket server that is slow to answer. [{ !isNil QEGVAR(extension,missionContext) || {missionNamespace getVariable [QEGVAR(extension,storageFailed), false]} || @@ -81,6 +81,12 @@ if (GVAR(captureFrameNo) == 0 && {!EGVAR(extension,sessionReady)}) exitWith { ["OCAP failed to start recording: extension storage is not ready", 1, [1, 0, 0, 1]] remoteExecCall ["CBA_fnc_notify", [0, -2] select isDedicated]; }; }; + // 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); // The extension answers :MISSION:OK: or :MISSION:FAILED:. It may take a @@ -109,6 +115,7 @@ if (GVAR(captureFrameNo) == 0 && {!EGVAR(extension,sessionReady)}) exitWith { }] call CBA_fnc_waitUntilAndExecute; [{EGVAR(extension,sessionReady) || {!EGVAR(extension,sessionPending)}}, {}, [], 30, { + if (!GVAR(recording)) exitWith {}; // paused meanwhile 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; @@ -126,7 +133,9 @@ if (GVAR(captureFrameNo) == 0 && {!EGVAR(extension,sessionReady)}) exitWith { }] call CBA_fnc_waitUntilAndExecute; }; -if (GVAR(captureFrameNo) == 0) then { +// 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); }; diff --git a/docs/recording-lifecycle.md b/docs/recording-lifecycle.md index f6c7d49..4ccabed 100644 --- a/docs/recording-lifecycle.md +++ b/docs/recording-lifecycle.md @@ -32,13 +32,16 @@ recording to the web server while it records: - 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, and ignores further - start requests meanwhile; after 30 seconds it notifies players that it is - still waiting. Each registration carries a token the extension echoes, so a + 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. If the mission ends first, the registered mission is + 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 drops that empty mission when the next one starts. Storage that is still initializing is waited for too, @@ -47,16 +50,18 @@ recording to the web server while it records: - A pause before the capture began only holds it back: players were not told recording started, so they are not told it stopped. - `OCAP_recorder_recording` is true from the start request on, while - `OCAP_recorder_startTime` is set once the capture runs. A mission script that - starts recording (`OCAP_record`) and records its own events or exports right - away should wait for `OCAP_recorder_startTime`. + `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`. 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`), and `postgres`/`sqlite` keep what they - wrote. A recording that reached `minMissionTime` is saved as before. + 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, diff --git a/tests/save_limits_regressions.sqf b/tests/save_limits_regressions.sqf index ea7878d..b4f6829 100644 --- a/tests/save_limits_regressions.sqf +++ b/tests/save_limits_regressions.sqf @@ -51,6 +51,13 @@ private _registering = { 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 @@ -81,11 +88,12 @@ try { [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; - OCAP_extension_sessionPending = false; // what :MISSION:OK: does - OCAP_extension_sessionReady = true; + 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; @@ -97,8 +105,7 @@ try { // A mission that ends while it registers is discarded once registered. call _registering; OCAP_recorder_missionEnded = true; - OCAP_extension_sessionPending = false; // what :MISSION:OK: does - OCAP_extension_sessionReady = true; + call _confirm; sleep 1; OCAP_recorder_missionEnded = nil; [[":MISSION:DISCARD:"] call _sent == 1, "a mission that ended while registering is discarded"] call _assert; @@ -107,8 +114,7 @@ try { // A pause while the mission registers holds the capture back until recording resumes. call _registering; call OCAP_recorder_fnc_stopRecording; - OCAP_extension_sessionPending = false; // what :MISSION:OK: does - OCAP_extension_sessionReady = true; + 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; @@ -119,16 +125,14 @@ try { 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; - OCAP_extension_sessionPending = false; - OCAP_extension_sessionReady = true; + call _confirm; sleep 1; [!isNil "OCAP_recorder_startTime", "the capture begins once registered"] call _assert; // A registered mission paused before its capture is discarded at mission end. call _registering; call OCAP_recorder_fnc_stopRecording; - OCAP_extension_sessionPending = false; - OCAP_extension_sessionReady = true; + call _confirm; sleep 1; call OCAP_recorder_fnc_handleMissionEnded; OCAP_recorder_missionEnded = nil; From dd6b8b715c79fa365f397db0170e4f4c0c51fe34 Mon Sep 17 00:00:00 2001 From: Smith <13200576+smitt14ua@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:01:51 +0200 Subject: [PATCH 07/14] fix(recorder): hold a pause before the capture against auto-start; document the test build Sixth review round: - A pause before the capture began is held (OCAP_recorder_heldByPause): auto-start leaves it alone and "Mission has started!" keeps waiting; the next start request lifts it. A pause during storage init otherwise looked like "never started", and auto-start undid it. - The 60 s storage notice stays quiet while paused. - tests/README.md: the suites need a test build with DISABLE_COMPILE_CACHE, since CBA compiles PREPed functions final whatever allowFunctionsRecompile says. The save limits test checks for it, rejects a recording already started, and checks the held pause. I ran it on a local dedicated server with such a build and the branch's extension: all assertions pass. - Docs: an export must come in the frame of the mission end; wording. --- addons/recorder/fnc_init.sqf | 14 ++++++++----- addons/recorder/fnc_startRecording.sqf | 5 ++++- addons/recorder/fnc_stopRecording.sqf | 2 ++ docs/recording-lifecycle.md | 29 ++++++++++++++------------ tests/README.md | 17 ++++++++++----- tests/save_limits_regressions.sqf | 28 ++++++++++++++++++++----- 6 files changed, 66 insertions(+), 29 deletions(-) diff --git a/addons/recorder/fnc_init.sqf b/addons/recorder/fnc_init.sqf index d15cd68..a1ec983 100644 --- a/addons/recorder/fnc_init.sqf +++ b/addons/recorder/fnc_init.sqf @@ -253,22 +253,26 @@ call EFUNC(extension,initSession); {(getClientStateNumber > 9 && (call FUNC(getAutoStartPlayerCount)) >= EGVAR(settings,minPlayerCount) && GVAR(autoStart)) || !isNil QGVAR(startTime)}, { // Recording started some other way already captures, registers the - // mission, or holds a registered mission paused; a start now would undo - // an admin's pause. + // 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)} + {!EGVAR(extension,sessionReady)} && + {!_held} ) then { call FUNC(startRecording); }; // Events are recorded once the capture runs, which waits for the - // extension to register the mission. Give up when that failed. + // 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)} + {!EGVAR(extension,sessionReady)} && + {!(missionNamespace getVariable [QGVAR(heldByPause), false])} } }, { if (isNil QGVAR(startTime)) exitWith {}; diff --git a/addons/recorder/fnc_startRecording.sqf b/addons/recorder/fnc_startRecording.sqf index 25a200e..e442538 100644 --- a/addons/recorder/fnc_startRecording.sqf +++ b/addons/recorder/fnc_startRecording.sqf @@ -8,7 +8,7 @@ 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 @@ -47,6 +47,7 @@ if (GVAR(recording) && {!isNil QGVAR(startTime)}) exitWith { // capture once the extension confirms it. A pause meanwhile is undone. if (missionNamespace getVariable [QEGVAR(extension,sessionPending), false]) 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]; @@ -55,6 +56,7 @@ if (missionNamespace getVariable [QEGVAR(extension,sessionPending), false]) exit // Any explicit or automatic start consumes a pending empty-server restart. GVAR(autoRestartAfterEmptyPending) = false; +GVAR(heldByPause) = false; GVAR(recording) = true; publicVariable QGVAR(recording); @@ -128,6 +130,7 @@ if (isNil QGVAR(startTime) && {!EGVAR(extension,sessionReady)}) 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; diff --git a/addons/recorder/fnc_stopRecording.sqf b/addons/recorder/fnc_stopRecording.sqf index e3b2c4f..06d3e1e 100644 --- a/addons/recorder/fnc_stopRecording.sqf +++ b/addons/recorder/fnc_stopRecording.sqf @@ -31,6 +31,8 @@ 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"]; diff --git a/docs/recording-lifecycle.md b/docs/recording-lifecycle.md index 4ccabed..bf29233 100644 --- a/docs/recording-lifecycle.md +++ b/docs/recording-lifecycle.md @@ -36,19 +36,20 @@ recording to the web server while it records: 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 drops that empty mission when - the next one starts. Storage that is still initializing is waited for too, - until the extension reports `:STORAGE:ERROR:`; players are notified after - 60 seconds. + 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 drops that empty mission when the next one loads. + 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. + 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 @@ -58,7 +59,9 @@ recording to the web server while it records: - 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`. The `memory` storage drops it, the `websocket` + 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. diff --git a/tests/README.md b/tests/README.md index a604348..29cece7 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,16 +1,23 @@ # 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. + +## 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 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. The suites -replace addon functions with stubs, which CBA compiles final by default: set -`allowFunctionsRecompile = 1;` in the test mission's `description.ext`, or -otherwise enable CBA function recompiling. `save_limits_regressions.sqf` stops -with an error when its functions are final. Give one player +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 the scripts into the mission's `tests/` folder and execute on the server: diff --git a/tests/save_limits_regressions.sqf b/tests/save_limits_regressions.sqf index b4f6829..fa85ffd 100644 --- a/tests/save_limits_regressions.sqf +++ b/tests/save_limits_regressions.sqf @@ -1,11 +1,14 @@ /* 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]) exitWith {throw "Disable auto-start and stop recording first"}; +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 unless the -// mission allows recompiling them; otherwise real lifecycle commands would reach -// the extension. -if (isFinal OCAP_extension_fnc_sendData) exitWith {throw "Set allowFunctionsRecompile = 1 in the test mission's description.ext"}; +// 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"]; @@ -171,6 +174,20 @@ try { 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; @@ -183,6 +200,7 @@ try { 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; From e78ad706d52b9e7bfc4f33d78a669604f4259903 Mon Sep 17 00:00:00 2001 From: Smith <13200576+smitt14ua@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:04:51 +0200 Subject: [PATCH 08/14] test(recorder): reset the intercepted commands before the last save limits check --- tests/save_limits_regressions.sqf | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/save_limits_regressions.sqf b/tests/save_limits_regressions.sqf index fa85ffd..67184a1 100644 --- a/tests/save_limits_regressions.sqf +++ b/tests/save_limits_regressions.sqf @@ -191,6 +191,7 @@ try { // 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 { From 4ff8cc380d331abac5ec501ce8ce1feb21caf36c Mon Sep 17 00:00:00 2001 From: Smith <13200576+smitt14ua@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:28:28 +0200 Subject: [PATCH 09/14] fix(recorder): re-register after an unanswered minute, say when a late export finds a discard Seventh review round: - A start request while a registration has gone unanswered for a minute (an older extension, a lost callback) registers again with a new token instead of waiting forever. - An OCAP_exportData that arrives after the mission-end discard tells players the recording was discarded, not that it never started; the event's doc says to export before or in the frame of the mission end. - The empty-server auto-restart leaves a held pause alone, as auto-start does; OCAP_pause documents it. - No "failed to start" notice for a paused start, nor the 30 s notice after the mission ended. Docs updated. --- addons/recorder/fnc_addEventMission.sqf | 11 +++++++- addons/recorder/fnc_autoRestartMonitor.sqf | 1 + addons/recorder/fnc_exportData.sqf | 11 +++++--- addons/recorder/fnc_handleMissionEnded.sqf | 1 + addons/recorder/fnc_startRecording.sqf | 30 +++++++++++++++++----- docs/recording-lifecycle.md | 4 ++- 6 files changed, 45 insertions(+), 13 deletions(-) diff --git a/addons/recorder/fnc_addEventMission.sqf b/addons/recorder/fnc_addEventMission.sqf index 84a7e04..a46e550 100644 --- a/addons/recorder/fnc_addEventMission.sqf +++ b/addons/recorder/fnc_addEventMission.sqf @@ -278,7 +278,7 @@ if (isNil QEGVAR(listener,counterEvent)) then { Example: > ["OCAP_record"] call CBA_fnc_serverEvent; - > ["OCAP_recordingStarted", {["OCAP_customEvent", ["generalEvent", "Phase 1"]] call CBA_fnc_serverEvent}] call CBA_fnc_addEventHandler; + > ["OCAP_recordingStarted", {diag_log "OCAP records"}] call CBA_fnc_addEventHandler; */ if (isNil QEGVAR(listener,record)) then { EGVAR(listener,record) = [QGVARMAIN(record), { @@ -295,6 +295,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; */ @@ -315,6 +319,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_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_exportData.sqf b/addons/recorder/fnc_exportData.sqf index 1b759a6..f0e89af 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]; }; diff --git a/addons/recorder/fnc_handleMissionEnded.sqf b/addons/recorder/fnc_handleMissionEnded.sqf index a83ddc2..1cb6008 100644 --- a/addons/recorder/fnc_handleMissionEnded.sqf +++ b/addons/recorder/fnc_handleMissionEnded.sqf @@ -50,4 +50,5 @@ if ((GVAR(captureFrameNo) * GVAR(frameCaptureDelay)) / 60 >= GVAR(minMissionTime [{ 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_startRecording.sqf b/addons/recorder/fnc_startRecording.sqf index e442538..d22bcff 100644 --- a/addons/recorder/fnc_startRecording.sqf +++ b/addons/recorder/fnc_startRecording.sqf @@ -44,8 +44,18 @@ if (GVAR(recording) && {!isNil QGVAR(startTime)}) exitWith { }; // Another start is registering the mission with the extension; it begins the -// capture once the extension confirms it. A pause meanwhile is undone. -if (missionNamespace getVariable [QEGVAR(extension,sessionPending), false]) exitWith { +// capture once the extension confirms it. A pause meanwhile is undone. One +// the extension has not answered for a minute (an older extension, or a lost +// callback) is given up and registered again: its token makes a late answer +// harmless. +private _pending = missionNamespace getVariable [QEGVAR(extension,sessionPending), false]; +private _pendingFor = diag_tickTime - (missionNamespace getVariable [QGVAR(registrationRequestedAt), diag_tickTime]); +if (_pending && {_pendingFor >= 60}) then { + WARNING_1("The extension has not answered the mission registration for %1 s; registering again",round _pendingFor); + 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; @@ -62,6 +72,7 @@ publicVariable QGVAR(recording); if (isNil QGVAR(startTime) && {!EGVAR(extension,sessionReady)}) exitWith { EGVAR(extension,sessionPending) = true; + GVAR(registrationRequestedAt) = diag_tickTime; // The mission registers with the extension only now, the first time or // after the previous recording was exported or discarded, so a mission @@ -76,12 +87,14 @@ if (isNil QGVAR(startTime) && {!EGVAR(extension,sessionReady)}) exitWith { private _ended = missionNamespace getVariable [QGVAR(missionEnded), false]; if (_ended || {isNil QEGVAR(extension,missionContext)}) exitWith { EGVAR(extension,sessionPending) = false; - GVAR(recording) = false; - publicVariable QGVAR(recording); if (!_ended) then { ERROR("Extension storage could not be initialized. Recording will not start."); - ["OCAP failed to start recording: extension storage is not ready", 1, [1, 0, 0, 1]] remoteExecCall ["CBA_fnc_notify", [0, -2] select isDedicated]; + 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. @@ -103,9 +116,11 @@ if (isNil QGVAR(startTime) && {!EGVAR(extension,sessionReady)}) exitWith { 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); - ["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]; }; // Paused meanwhile: the capture begins when recording resumes. if (!GVAR(recording)) exitWith { @@ -117,7 +132,8 @@ if (isNil QGVAR(startTime) && {!EGVAR(extension,sessionReady)}) exitWith { }] call CBA_fnc_waitUntilAndExecute; [{EGVAR(extension,sessionReady) || {!EGVAR(extension,sessionPending)}}, {}, [], 30, { - if (!GVAR(recording)) exitWith {}; // paused meanwhile + // 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; diff --git a/docs/recording-lifecycle.md b/docs/recording-lifecycle.md index bf29233..4396b8c 100644 --- a/docs/recording-lifecycle.md +++ b/docs/recording-lifecycle.md @@ -44,7 +44,9 @@ recording to the web server while it records: 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 drops that empty mission when the next one loads. + answers, the extension refuses that start, or, if the start already reached + its storage, drops the empty mission when the next one loads. A start + request after a minute without an answer registers the mission 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 From 43d8e75f2adb098c4f81e48f0773b39db4f74528 Mon Sep 17 00:00:00 2001 From: Smith <13200576+smitt14ua@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:05:47 +0200 Subject: [PATCH 10/14] fix(recorder): resend only a :MISSION:START: that was sent, document the suites' CBA stubs Eighth review round: - The one-minute re-registration counts from when newMission sent the start, so a start request during a long storage init no longer adds a second registration of the same mission. - tests/README.md: recorder_regressions.sqf stubs CBA's own functions, which stay final, so its servicing and client sampler parts fail on main as well (seen on a local server with two clients). - Docs and comments: an export before the capture is refused; an older web server saves an abandoned empty mission as partial. --- addons/extension/fnc_initSession.sqf | 3 ++- addons/extension/fnc_newMission.sqf | 1 + addons/recorder/fnc_addEventMission.sqf | 3 ++- addons/recorder/fnc_startRecording.sqf | 13 +++++++------ docs/recording-lifecycle.md | 5 +++-- tests/README.md | 6 ++++++ 6 files changed, 21 insertions(+), 10 deletions(-) diff --git a/addons/extension/fnc_initSession.sqf b/addons/extension/fnc_initSession.sqf index 1b55109..8a17155 100644 --- a/addons/extension/fnc_initSession.sqf +++ b/addons/extension/fnc_initSession.sqf @@ -7,7 +7,8 @@ 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 +// 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", { params ["_name", "_function", "_dataArr"]; diff --git a/addons/extension/fnc_newMission.sqf b/addons/extension/fnc_newMission.sqf index 71e0273..89ff3c4 100644 --- a/addons/extension/fnc_newMission.sqf +++ b/addons/extension/fnc_newMission.sqf @@ -40,6 +40,7 @@ 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; if (isNil {[":MISSION:START:", [GVAR(worldContext), GVAR(missionContext), GVAR(registrationToken)], '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/fnc_addEventMission.sqf b/addons/recorder/fnc_addEventMission.sqf index a46e550..3994139 100644 --- a/addons/recorder/fnc_addEventMission.sqf +++ b/addons/recorder/fnc_addEventMission.sqf @@ -274,7 +274,8 @@ if (isNil QEGVAR(listener,counterEvent)) then { 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 and exports before then are not recorded. + 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; diff --git a/addons/recorder/fnc_startRecording.sqf b/addons/recorder/fnc_startRecording.sqf index d22bcff..1ccef62 100644 --- a/addons/recorder/fnc_startRecording.sqf +++ b/addons/recorder/fnc_startRecording.sqf @@ -44,12 +44,13 @@ if (GVAR(recording) && {!isNil QGVAR(startTime)}) exitWith { }; // Another start is registering the mission with the extension; it begins the -// capture once the extension confirms it. A pause meanwhile is undone. One -// the extension has not answered for a minute (an older extension, or a lost -// callback) is given up and registered again: its token makes a late answer -// harmless. +// 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 makes a late answer +// harmless. Waiting for storage, nothing was sent yet, so nothing is resent. private _pending = missionNamespace getVariable [QEGVAR(extension,sessionPending), false]; -private _pendingFor = diag_tickTime - (missionNamespace getVariable [QGVAR(registrationRequestedAt), diag_tickTime]); +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,sessionPending) = false; @@ -72,7 +73,7 @@ publicVariable QGVAR(recording); if (isNil QGVAR(startTime) && {!EGVAR(extension,sessionReady)}) exitWith { EGVAR(extension,sessionPending) = true; - GVAR(registrationRequestedAt) = diag_tickTime; + 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 diff --git a/docs/recording-lifecycle.md b/docs/recording-lifecycle.md index 4396b8c..49c1624 100644 --- a/docs/recording-lifecycle.md +++ b/docs/recording-lifecycle.md @@ -45,8 +45,9 @@ recording to the web server while it records: 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 start - request after a minute without an answer registers the mission again. + 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 diff --git a/tests/README.md b/tests/README.md index 29cece7..0c7ff3b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -9,6 +9,12 @@ with an error, and the stubs of the others fail. Build a test copy with `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 From 75a16a4f75ec32d915bb1f86217366a1b28d7511 Mon Sep 17 00:00:00 2001 From: Smith <13200576+smitt14ua@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:32:04 +0200 Subject: [PATCH 11/14] feat(recorder): drop a recording left by #missions or #restart before the minimum mission time A mission registers with "discard": until the recording reaches OCAP_settings_minMissionTime, the extension drops it if the game moves on without a save or discard. The capture loop sends :MISSION:KEEP: once it is long enough, after which such a recording is kept as partial, as before. --- addons/extension/fnc_newMission.sqf | 6 +++++- addons/recorder/XEH_preInit.sqf | 4 ++-- addons/recorder/fnc_captureLoop.sqf | 10 ++++++++++ tests/save_limits_regressions.sqf | 7 +++++-- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/addons/extension/fnc_newMission.sqf b/addons/extension/fnc_newMission.sqf index 89ff3c4..14e963a 100644 --- a/addons/extension/fnc_newMission.sqf +++ b/addons/extension/fnc_newMission.sqf @@ -41,7 +41,11 @@ GVAR(sessionPending) = true; // 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; -if (isNil {[":MISSION:START:", [GVAR(worldContext), GVAR(missionContext), GVAR(registrationToken)], 'ocap_recorder'] call FUNC(sendData)}) then { +// 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 237fd8f..065e6ef 100644 --- a/addons/recorder/XEH_preInit.sqf +++ b/addons/recorder/XEH_preInit.sqf @@ -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. With , a shorter recording is discarded when the mission ends, so storage that already holds it (such as the websocket storage) drops it. 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. With auto-save on MPEnded, a shorter recording is discarded when the mission ends. 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/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/tests/save_limits_regressions.sqf b/tests/save_limits_regressions.sqf index 67184a1..54ecf91 100644 --- a/tests/save_limits_regressions.sqf +++ b/tests/save_limits_regressions.sqf @@ -30,11 +30,13 @@ 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"]; - if (_command in [":MISSION:START:", ":MISSION:SAVE:", ":MISSION:DISCARD:"]) then { + 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}; @@ -86,6 +88,7 @@ try { 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; From 36fb09ec2a278562e46f1a3bdadbb5d63ed8f2b9 Mon Sep 17 00:00:00 2001 From: Smith <13200576+smitt14ua@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:34:20 +0200 Subject: [PATCH 12/14] docs(recorder): a recording left by #missions or #restart, and how to verify it --- docs/recording-lifecycle.md | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/docs/recording-lifecycle.md b/docs/recording-lifecycle.md index 49c1624..4a75d83 100644 --- a/docs/recording-lifecycle.md +++ b/docs/recording-lifecycle.md @@ -74,10 +74,19 @@ This needs an extension with `:MISSION:DISCARD:`, `:MISSION:FAILED:` and which the server RPT logs as a warning, and never answers a failed start or a failed storage, so such a recording keeps waiting. -A recording ended without a save or a discard is still saved by the web server -as `partial`, whatever its length. That covers an admin changing the mission -(`#missions`, `#restart`), a crash, and a recording that captured anything -when the mission ends with `saveMissionEnded` disabled. +When an admin changes the mission (`#missions`, `#restart`), the addon gets no +event: the extension ends the recording 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 saved by the web +server as `partial`, as before. This holds for the `websocket` storage, with a +web server at protocol 3; an older extension ignores the request, and logs the +unknown `:MISSION:KEEP:` once per recording. + +A recording ended otherwise without a save or a discard is still saved by the +web server as `partial`, whatever its length: after a crash, after a server +shut down with `#shutdown` or from the mission list, and one that captured +anything when the mission ends with `saveMissionEnded` disabled. ## Dedicated-server verification @@ -115,3 +124,9 @@ as the server notifications. `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 (or `#restart`). Verify + the extension log has `abandoned before it met the addon's save limits; + discarding it` and the web server keeps no recording of it. Record past + `minMissionTime` (the extension log has `Received :MISSION:KEEP:`), change + the mission again and verify it is saved as `partial`. From b7ff07ae21d45d9c5bd51764194e8b58ad484e2e Mon Sep 17 00:00:00 2001 From: Smith <13200576+smitt14ua@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:04:04 +0200 Subject: [PATCH 13/14] fix(recorder): drop a given-up registration's token at once, so its late answers change nothing A start that registers again after an unanswered minute cleared only sessionPending; the old token stayed until the deferred newMission ran, so a late :MISSION:OK: in between began the capture against the old session, and a late :MISSION:FAILED: ended the new attempt. The token is now cleared with the retry, and the waiters of a superseded attempt leave it to the new one. The ExtensionCallback handler moves to OCAP_extension_fnc_handleCallback, so the regression suite delivers real callbacks in that window. --- addons/extension/XEH_prep.sqf | 1 + addons/extension/fnc_handleCallback.sqf | 279 +++++++++++++++++++++++ addons/extension/fnc_initSession.sqf | 255 +-------------------- addons/extension/fnc_newMission.sqf | 2 +- addons/recorder/fnc_discardRecording.sqf | 2 +- addons/recorder/fnc_exportData.sqf | 4 +- addons/recorder/fnc_startRecording.sqf | 17 +- tests/save_limits_regressions.sqf | 21 ++ 8 files changed, 319 insertions(+), 262 deletions(-) create mode 100644 addons/extension/fnc_handleCallback.sqf 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 8a17155..5444423 100644 --- a/addons/extension/fnc_initSession.sqf +++ b/addons/extension/fnc_initSession.sqf @@ -9,260 +9,7 @@ GVAR(sessionPending) = false; // 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", { - 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); - }; - }; - }; -}]; +addMissionEventHandler ["ExtensionCallback", {_this call FUNC(handleCallback)}]; INFO("Initializing extension..."); diff --git a/addons/extension/fnc_newMission.sqf b/addons/extension/fnc_newMission.sqf index 14e963a..8f8fe4a 100644 --- a/addons/extension/fnc_newMission.sqf +++ b/addons/extension/fnc_newMission.sqf @@ -7,7 +7,7 @@ Description: 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 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. diff --git a/addons/recorder/fnc_discardRecording.sqf b/addons/recorder/fnc_discardRecording.sqf index 1f76d77..c7af017 100644 --- a/addons/recorder/fnc_discardRecording.sqf +++ b/addons/recorder/fnc_discardRecording.sqf @@ -11,7 +11,7 @@ Description: 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 diff --git a/addons/recorder/fnc_exportData.sqf b/addons/recorder/fnc_exportData.sqf index f0e89af..b78fce1 100644 --- a/addons/recorder/fnc_exportData.sqf +++ b/addons/recorder/fnc_exportData.sqf @@ -127,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)); @@ -136,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"]; diff --git a/addons/recorder/fnc_startRecording.sqf b/addons/recorder/fnc_startRecording.sqf index 1ccef62..25de7ea 100644 --- a/addons/recorder/fnc_startRecording.sqf +++ b/addons/recorder/fnc_startRecording.sqf @@ -46,13 +46,15 @@ if (GVAR(recording) && {!isNil QGVAR(startTime)}) exitWith { // 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 makes a late answer -// harmless. Waiting for storage, nothing was sent yet, so nothing is resent. +// 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; }; @@ -104,12 +106,17 @@ if (isNil QGVAR(startTime) && {!EGVAR(extension,sessionReady)}) exitWith { INFO("Recording paused before the mission registered; it registers when recording resumes"); }; call EFUNC(extension,newMission); + // 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); @@ -130,9 +137,11 @@ if (isNil QGVAR(startTime) && {!EGVAR(extension,sessionReady)}) exitWith { call FUNC(flushPlayerConnectionEvents); call FUNC(captureLoop); call FUNC(announceRecording); - }] call CBA_fnc_waitUntilAndExecute; + }, [_token]] call CBA_fnc_waitUntilAndExecute; - [{EGVAR(extension,sessionReady) || {!EGVAR(extension,sessionPending)}}, {}, [], 30, { + [{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"); diff --git a/tests/save_limits_regressions.sqf b/tests/save_limits_regressions.sqf index 54ecf91..6a9c41a 100644 --- a/tests/save_limits_regressions.sqf +++ b/tests/save_limits_regressions.sqf @@ -135,6 +135,27 @@ try { 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; From 2ada90d979238d7d547aaf75af271c70306bd75d Mon Sep 17 00:00:00 2001 From: Smith <13200576+smitt14ua@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:17:55 +0200 Subject: [PATCH 14/14] docs(recorder): what #missions, #restart, #shutdown and saveMissionEnded off leave on the web, as seen on a dedicated server --- docs/recording-lifecycle.md | 39 ++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/docs/recording-lifecycle.md b/docs/recording-lifecycle.md index 4a75d83..d678fd1 100644 --- a/docs/recording-lifecycle.md +++ b/docs/recording-lifecycle.md @@ -74,19 +74,25 @@ This needs an extension with `:MISSION:DISCARD:`, `:MISSION:FAILED:` and 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 (`#missions`, `#restart`), the addon gets no -event: the extension ends the recording 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 saved by the web -server as `partial`, as before. This holds for the `websocket` storage, with a -web server at protocol 3; an older extension ignores the request, and logs the -unknown `:MISSION:KEEP:` once per recording. +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. -A recording ended otherwise without a save or a discard is still saved by the -web server as `partial`, whatever its length: after a crash, after a server -shut down with `#shutdown` or from the mission list, and one that captured -anything when the mission ends with `saveMissionEnded` disabled. +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 @@ -125,8 +131,9 @@ as the server notifications. 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 (or `#restart`). Verify - the extension log has `abandoned before it met the addon's save limits; - discarding it` and the web server keeps no recording of it. Record past + 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 as `partial`. + the mission again and verify it is saved.