Count occupied restarts as failed missions; sync published Workshop version
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-11-07 01:05:43 +01:00
parent 161100cf08
commit e421dcfc15
14 changed files with 640 additions and 50 deletions
+17
View File
@@ -11,6 +11,23 @@ server, deployment, Git push status, operational disclaimers or footers. Do not
backfill old changes unless requested. Internal workflow-only changes need no
announcement.
Before every push to `origin/main`, fetch the latest **published** Workshop version
before preparing the commit. The user should not need to supply a version number.
On this workstation use the live in-game catalogue (the website can be cached):
```
python3 tools/workshop_changelog.py --sync --native-compat .local/rhs-19-work/compat
```
This runs a bounded, silent query in a disposable game profile and updates only
the changelog version preamble. It does not download or publish an addon. Never
terminate an existing editor to acquire the private prefix. On other machines,
`--sync` without `--native-compat` checks the official Workshop page, verifies the
addon identity and published/approved version, and rejects a downgrade. If a
lookup fails or returns stale data, resolve it through a fresh Workshop query;
do not guess, use a local bundle as publication proof, or ask the user to enter
the version manually. Include the synchronized preamble in the requested push.
After a confirmed successful Workbench Workshop upload, run
`python3 tools/workshop_changelog.py --published` before committing the changelog.
This reads the local publishing manifest and records the version in the changelog
+5 -1
View File
@@ -1,6 +1,10 @@
# Changelog
Latest published Workshop version: **0.0.146**
Latest published Workshop version: **0.0.148**
## 2026-11-07 — Interrupted mission results
- Restarts with players connected now count as failed missions, including recovery after a forced stop. Empty restarts remain excluded.
## 2026-11-07 — Shore boats
@@ -55,6 +55,9 @@ class ERR_TelemetryState
string endpoint;
string previousMissionId;
string previousOutcome;
// Unsent, prebuilt failure for the last occupied snapshot. Only promoted to
// the queue if another instance starts before this mission records its end.
string restartFailure;
int droppedEvents;
ref array<string> pending = {};
}
@@ -157,6 +160,16 @@ class ERR_Telemetry
JsonLoadContext queued = new JsonLoadContext(); string eventId, missionId, serverId;
if (queuedEvent.Length() > 65536 || !queued.LoadFromString(queuedEvent) || !queued.ReadValue("eventId", eventId) || !UUID.IsUUID(eventId) || !queued.ReadValue("missionId", missionId) || !UUID.IsUUID(missionId) || !queued.ReadValue("serverId", serverId) || serverId != state.serverId) return false;
}
if (!state.restartFailure.IsEmpty())
{
JsonLoadContext checkpoint = new JsonLoadContext();
string checkpointId, checkpointMission, checkpointServer, checkpointType, checkpointOutcome;
int checkpointPhase, checkpointPlayers;
if (state.restartFailure.Length() > 65536 || !checkpoint.LoadFromString(state.restartFailure)) return false;
if (!checkpoint.ReadValue("eventId", checkpointId) || !UUID.IsUUID(checkpointId) || !checkpoint.ReadValue("missionId", checkpointMission) || checkpointMission != state.previousMissionId || !UUID.IsUUID(checkpointMission)) return false;
if (!checkpoint.ReadValue("serverId", checkpointServer) || checkpointServer != state.serverId || !checkpoint.ReadValue("type", checkpointType) || checkpointType != "mission_finished") return false;
if (!checkpoint.ReadValue("outcome", checkpointOutcome) || checkpointOutcome != "defeat" || !checkpoint.ReadValue("phase", checkpointPhase) || checkpointPhase != ERR_Phase.DEFEAT || !checkpoint.ReadValue("connectedPlayers", checkpointPlayers) || checkpointPlayers <= 0) return false;
}
return true;
}
protected bool LoadState(string path)
@@ -182,6 +195,7 @@ class ERR_Telemetry
protected void Purge()
{
m_State.pending.Clear(); m_State.previousMissionId = ""; m_State.previousOutcome = "";
m_State.restartFailure = "";
m_Players.Clear(); m_InFlight = ""; m_Callback = null;
// Do not retain personal data in recovery files after opting out.
FileIO.DeleteFile(STATE); FileIO.DeleteFile(STATE + ".bak"); FileIO.DeleteFile(STATE + ".tmp");
@@ -216,6 +230,7 @@ class ERR_Telemetry
int now = System.GetTickCount(); m_LastSample = now; m_NextHeartbeat = now + 300000;
m_Partial = m_Collected || now - m_Started > 1000;
m_Peak = 0; m_TruncatedPlayers = false;
if (!m_Collected) RecoverRestart();
string previous = m_State.previousMissionId;
string outcome = m_State.previousOutcome;
m_State.previousMissionId = m_MissionId; m_State.previousOutcome = "unfinished";
@@ -224,6 +239,27 @@ class ERR_Telemetry
if (m_Collected) Event("telemetry_resumed");
else Event("mission_started", "", previous, outcome);
m_Collected = true;
Checkpoint();
}
protected void RecoverRestart()
{
if (m_State.previousOutcome == "unfinished" && !m_State.restartFailure.IsEmpty())
{
Enqueue(m_State.restartFailure, true);
m_State.previousOutcome = "defeat";
}
// Saved together with the new start and recovered result, before delivery.
m_State.restartFailure = "";
}
protected void Checkpoint()
{
if (!Enabled() || m_Ended || ERR_MissionRules.Terminal(m_Mode.Phase())) return;
if (GetGame().GetPlayerManager().GetPlayerCount() > 0)
Event("mission_finished", "defeat", "", "", true);
else if (!m_State.restartFailure.IsEmpty())
{
m_State.restartFailure = ""; m_Dirty = true; Save();
}
}
protected void Sample()
{
@@ -255,7 +291,7 @@ class ERR_Telemetry
player.connected = true;
ERR_PlayerLife life = m_Mode.Players().Get(connection);
if (life && life.Connected && life.UsedLife) player.participated = true;
if (authenticated && m_Mode.Extraction()) player.escaped = m_Mode.Extraction().DidEscape(account);
if (authenticated && m_Mode.Extraction()) player.escaped = m_Mode.Phase() == ERR_Phase.VICTORY && m_Mode.Extraction().DidEscape(account);
}
// Include disconnected escapees/participants with their final authoritative result.
array<ref ERR_PlayerLife> lives = {}; m_Mode.Players().Participants(lives);
@@ -271,10 +307,10 @@ class ERR_Telemetry
tracked.kills = stats.Kills; tracked.friendlyKills = stats.FriendlyKills;
tracked.deaths = stats.Deaths; tracked.downed = stats.Downed; tracked.recoveries = stats.Recoveries;
}
if (m_Mode.Extraction()) tracked.escaped = m_Mode.Extraction().DidEscape(participant.AccountId);
if (m_Mode.Extraction()) tracked.escaped = m_Mode.Phase() == ERR_Phase.VICTORY && m_Mode.Extraction().DidEscape(participant.AccountId);
}
}
void RosterChanged(int playerId) { Sample(); }
void RosterChanged(int playerId) { Sample(); Checkpoint(); }
void PhaseChanged(ERR_Phase previous, ERR_Phase current)
{
if (!Enabled() || m_Ended) return;
@@ -285,8 +321,10 @@ class ERR_Telemetry
{
Refresh();
if (!Enabled() || m_Ended) return;
if (outcome == "interrupted" && GetGame().GetPlayerManager().GetPlayerCount() > 0) outcome = "defeat";
Sample(); m_Ended = true;
m_State.previousOutcome = outcome;
m_State.restartFailure = "";
Event("mission_finished", outcome);
}
protected string Terrain()
@@ -301,7 +339,7 @@ class ERR_Telemetry
}
return ERR_TerrainDisplayName.Resolve(world);
}
protected void Event(string type, string outcome = "", string previous = "", string previousOutcome = "")
protected void Event(string type, string outcome = "", string previous = "", string previousOutcome = "", bool checkpoint = false)
{
if (!Enabled()) return;
if (!m_Ended) Sample();
@@ -314,7 +352,9 @@ class ERR_Telemetry
json.WriteValue("partial", m_Partial);
int elapsedSeconds = Math.Max(0, (System.GetTickCount() - m_Started) / 1000);
json.WriteValue("elapsedSeconds", elapsedSeconds);
json.WriteValue("phase", m_Mode.Phase()); json.WriteValue("outcome", outcome);
int phase = m_Mode.Phase();
if (outcome == "defeat") phase = ERR_Phase.DEFEAT;
json.WriteValue("phase", phase); json.WriteValue("outcome", outcome);
json.WriteValue("previousMissionId", previous); json.WriteValue("previousOutcome", previousOutcome);
string terrain = Terrain();
json.WriteValue("terrain", terrain);
@@ -325,12 +365,31 @@ class ERR_Telemetry
json.WriteValue("modsTruncated", m_TruncatedMods); json.WriteValue("playersTruncated", m_TruncatedPlayers);
json.WriteValue("connectedPlayers", GetGame().GetPlayerManager().GetPlayerCount());
json.WriteValue("peakPlayers", m_Peak); json.WriteValue("players", m_Players);
json.WriteValue("reason", m_Mode.EndReason()); json.WriteValue("preparationError", m_Mode.PreparationError());
string reason = m_Mode.EndReason();
if (outcome == "defeat" && !ERR_MissionRules.Terminal(m_Mode.Phase())) reason = "Mission interrupted by a restart while players were connected.";
json.WriteValue("reason", reason); json.WriteValue("preparationError", m_Mode.PreparationError());
map<string, int> settings = new map<string, int>();
foreach (string key : ERR_Settings.Keys()) settings.Set(key, m_Mode.Settings().Get(key));
json.WriteValue("settings", settings); json.WriteValue("droppedEvents", m_State.droppedEvents);
string payload = json.SaveToString();
if (payload.Length() > 65536) { m_State.droppedEvents++; m_Dirty = true; return; }
// This candidate is private profile state, never sent during a live mission.
if (checkpoint) { m_State.restartFailure = payload; m_Dirty = true; Save(); return; }
Enqueue(payload, type == "mission_finished");
if (type == "mission_finished")
{
m_FinalEventId = eventId; m_FinalOutcome = outcome; m_FinalTerrain = terrain;
// Finish can arrive while a previous request is in flight. RemoveItem on its
// acknowledgement still removes that exact payload, leaving this final first.
if (outcome == "victory" || outcome == "defeat")
m_Mode.ERR_PublishCommunityStatistics("This map's community record: waiting for the round result to be recorded...");
GetGame().GetCallqueue().CallLater(Pulse, 1, false);
}
m_Dirty = true;
Save();
}
protected void Enqueue(string payload, bool finalResult)
{
// Bound disk and memory during long outages. Prefer retaining result records.
if (m_State.pending.Count() >= 64)
{
@@ -345,19 +404,9 @@ class ERR_Telemetry
if (remove == 0 && !m_InFlight.IsEmpty()) remove = 1;
m_State.pending.Remove(remove); m_State.droppedEvents++;
}
if (type == "mission_finished")
{
m_FinalEventId = eventId; m_FinalOutcome = outcome; m_FinalTerrain = terrain;
// Finish can arrive while a previous request is in flight. RemoveItem on its
// acknowledgement still removes that exact payload, leaving this final first.
m_State.pending.InsertAt(payload, 0);
if (outcome == "victory" || outcome == "defeat")
m_Mode.ERR_PublishCommunityStatistics("This map's community record: waiting for the round result to be recorded...");
GetGame().GetCallqueue().CallLater(Pulse, 1, false);
}
if (finalResult) m_State.pending.InsertAt(payload, 0);
else m_State.pending.Insert(payload);
m_Dirty = true;
Save();
}
protected void Pulse()
{
@@ -368,6 +417,7 @@ class ERR_Telemetry
if (!m_Ended)
{
Sample();
Checkpoint();
if (now >= m_NextHeartbeat) { m_NextHeartbeat = now + 300000; Event("heartbeat"); }
}
if (m_Dirty && !Save()) { CommunityUnavailable(); return; }
+22 -12
View File
@@ -44,23 +44,33 @@ headings inside code examples within an entry. Keep the formatted newest entry
within Discord's 2000-character limit; oversized entries fail without truncation.
The dated files in `publishing/` remain separate Workshop release notes.
After compiling/bundling and successfully publishing in Workbench, run:
Before preparing every push, synchronize the last published version automatically:
```sh
python3 tools/workshop_changelog.py --published
python3 tools/workshop_changelog.py --sync --native-compat .local/rhs-19-work/compat
```
The helper reads `asset.version` from Workbench's local publishing manifest and
updates `Latest published Workshop version: **0.0.109**` above the changelog
entries. It verifies the addon ID against `addons/core/addon.gproj`. Use
`--manifest /path/to/manifest.json` on another installation. Without `--published`
it only displays the bundle version. The flag confirms that you observed a
successful upload; the helper does not upload or query the Workshop.
This workstation uses the game's live public Workshop catalogue because the web
listing can lag behind publication. The helper uses the existing disposable
Proton prefix under `.local`, takes its test-run lock, opens a temporary muted
headless game profile, queries only this addon, and closes its own processes.
It neither downloads addons nor uploads or changes Workshop content. The query
is bounded to 60 seconds and refuses a busy prefix.
Commit that record together with the new player-facing entry and source changes,
then push. Drone reads the version from Git, without needing Workbench files.
Updating only the version does not resend the previous entry. The message labels
it as the last published version, since a Git push can contain newer changes.
On a machine without that native setup, `--sync` alone reads the official
Workshop page. It checks the addon ID and the current version's published and
approved flags. Neither route overwrites a newer recorded publication with an
older response. Lookup/validation failure leaves the changelog unchanged and
returns failure; obtain fresh publication evidence before pushing.
`--published` remains available immediately after an observed successful
Workbench upload. It records the matching local publishing manifest's version;
without either flag the helper only displays the bundled version. Bundling alone
is never publication confirmation.
Commit the synchronized preamble with the requested changes. Updating only that
version does not resend the previous entry. Drone labels it as the last published
version because a Git push can contain changes newer than the Workshop upload.
The notifier compares `DRONE_COMMIT_BEFORE` with `DRONE_COMMIT_SHA`, covering the
whole push, even when a documentation commit follows the changelog commit.
+27 -10
View File
@@ -124,15 +124,15 @@ existing queued payloads retain their original values and event IDs.
| `phase_changed` | An actual nonterminal mission transition. Use elapsed times for milestone durations. |
| `heartbeat` | A snapshot every five minutes while unfinished. |
| `telemetry_resumed` | Re-enabled during the same mission; the mission ID stays the same and `partial` is true. |
| `mission_finished` | Exactly one local result snapshot, before normal teardown. |
| `mission_finished` | One local result snapshot, at teardown or recovered on the next startup. |
| Outcome | Count as a gameplay result? |
| --- | --- |
| `victory` | Yes, successful mission. |
| `defeat` | Yes, failed mission, including administrator abandonment. |
| `defeat` | Yes, failed mission, including administrator abandonment and a restart with players connected. |
| `preparation_failed` | No, startup error; inspect `preparationError`. |
| `restarted_empty` | No, automatic last-disconnect or idle-empty restart. |
| `interrupted` | No, session ended without a gameplay result. |
| `interrupted` | No, an empty session ended without a gameplay result (also used by older clients for occupied interruptions). |
| `unfinished` in `previousOutcome` | No confirmed ending survived. May be a crash/forced stop. Do not infer a defeat. |
Player names and stable game account UUIDs support history across servers. No IP
@@ -161,8 +161,8 @@ summary containing matching map/server/mission/event/outcome identifiers plus
`server` and `allServers` records. Each record has completed rounds, wins,
failures, win rate and partial-round coverage. The mission validates these
identifiers and counts before displaying the current map's record on its result
screen. Retries never increase the totals twice. Startup failures, interrupted
sessions and empty restarts are excluded from win/failure totals. If a summary
screen. Retries never increase the totals twice. Startup failures, events marked `interrupted`, and empty restarts are excluded
from win/failure totals. Occupied restarts now produce explicit `defeat` events. If a summary
cannot be delivered before the next mission starts, the screen reports it as
unavailable and keeps local statistics visible.
Once a confirmed record is displayed, failures uploading older queued events
@@ -209,8 +209,10 @@ retry without edits; retries still use the original event IDs and count once.
Requests have a 15-second timeout, with a 30-second callback watchdog. Retries
back off from 10 seconds to five minutes. A five-second dispatch tick sends one
request at a time. Disk writes occur on events and acknowledgement, not every
player sample. Delivery never blocks mission startup or shutdown.
request at a time. Disk writes occur on events and acknowledgement. While occupied, a private
restart checkpoint is also saved every five seconds and on roster changes; it
is not sent as a result while the mission is running. An empty snapshot clears
that candidate, so earlier attendance alone does not turn a restart into a loss. Delivery never blocks mission startup or shutdown.
The profile queue holds at most 64 events of 64 KiB each. On overflow it discards
queued heartbeats first, then the oldest non-in-flight event; `droppedEvents`
@@ -220,9 +222,15 @@ recovery. An unreadable queue suspends telemetry and preserves evidence for
repair instead of silently replacing the server identity.
Unsent final results survive normal scenario reloads and are retried in the next
enabled instance. Abrupt termination can lose the latest event; the next start's
`previousOutcome` remains unfinished if no result was saved. A permanently
stopped host cannot send further data.
enabled instance. After abrupt termination, the next enabled instance promotes the last saved
occupied checkpoint to a `defeat` for the original mission, with its original
players and statistics. The checkpoint and queue transition are saved together;
retries retain the recovered event ID. Its timestamp and duration reflect the
last sample, not the next startup time. Connection changes in the final five
seconds may be missed if their roster callback or profile write did not complete.
Completed results are never replaced. Empty or legacy states without an occupied
checkpoint remain unknown rather than inventing a result. A permanently stopped
host cannot send further data.
Disabling immediately stops collection/new requests and discards queued events
and recovery copies. It cannot recall an HTTP request already submitted or erase
@@ -232,6 +240,15 @@ discards unsent data instead of forwarding old records to a different recipient.
Native API references: [RestContext](https://community.bistudio.com/wikidata/external-data/arma-reforger/EnfusionScriptAPIPublic/interfaceRestContext.html) and [RestCallback](https://community.bistudio.com/wikidata/external-data/arma-reforger/EnfusionScriptAPIPublic/interfaceRestCallback.html).
### Restart verification
The [native restart fixture](../tests/native/telemetry_restart/README.md) runs
two isolated Workbench processes with a real connected local player. It checks
native shutdown results, JSON/profile persistence, unfinished-state recovery,
completed-state deduplication and receiver validation of the generated events.
Telemetry network dispatch is replaced in the test copy only. This does not
certify a multiplayer AMP deployment or optional asset integrity.
### Round combat counters
Player records additionally include `kills`, `friendlyKills`, `deaths`, `downed`
+3
View File
@@ -287,6 +287,9 @@ snapshots only. Spectators and temporary connection IDs are excluded. A mission
victory does not imply a personal escape. Startup failures, empty restarts and
interrupted sessions do not count as gameplay wins/losses or player attempts.
An interrupted mission can still have raw combat data in its detail snapshot.
New clients report occupied restarts as `defeat`, including recovery from the
last durable occupied checkpoint after a forced stop. Those explicit final
events count as failures and participant attempts; restart links alone do not.
Legacy missing counters remain null, with `missionsWithKills`,
`missionsWithFriendlyKills`, `missionsWithDeaths`, `missionsWithDowned` and
@@ -110,6 +110,32 @@ test('published fractional-duration defeats are accepted, counted and retry idem
assert.equal((await ingest(event({connectedPlayers: 0.5}))).status, 400);
});
test('occupied restart recovery counts one failure and retains the old mission participants', async () => {
const terrain = 'Restart recovery fixture';
const player = {...event().players[0], escaped: false};
const recovered = event({terrain, phase: 6, outcome: 'defeat', players: [player],
reason: 'Mission interrupted by a restart while players were connected.'});
const snapshot = {...recovered, eventId: crypto.randomUUID(), type: 'heartbeat', phase: 3, outcome: ''};
assert.equal((await ingest(snapshot)).status, 201);
const first = await ingest(recovered);
assert.equal(first.status, 201, await first.clone().text());
assert.equal((await ingest(recovered)).status, 200);
// A delayed pre-restart snapshot must not reopen the completed mission.
assert.equal((await ingest({...snapshot, eventId: crypto.randomUUID(), elapsedSeconds: 150})).status, 201);
const next = event({terrain, type: 'mission_restarted', outcome: '', phase: 0,
previousMissionId: recovered.missionId, previousOutcome: 'defeat', players: [], connectedPlayers: 0});
assert.equal((await ingest(next)).status, 201);
const summary = (await (await ingest(recovered)).json()).community;
assert.equal(summary.allServers.losses, 1);
assert.equal(summary.allServers.completed, 1);
const old = await detail(recovered);
assert.equal(old.outcome, 'defeat');
assert.equal(old.players[0].id, player.id);
assert.equal(old.players[0].participated, true);
assert.equal(old.players[0].escaped, false);
assert.equal((await detail(next)).outcome, '');
});
test('out-of-order snapshots and second terminal reports cannot reopen or double count a mission', async () => {
const e = event({players: []});
const heartbeat = {...e, type: 'heartbeat', phase: 4, outcome: '', elapsedSeconds: 100};
@@ -0,0 +1,63 @@
#ifdef WORKBENCH
// Disposable native fixture only. Keep native serialization, profile storage,
// PlayerManager occupancy and production constructor/Finish; replace transport.
modded class ERR_Telemetry
{
protected override void Pulse() {}
bool ERR_TestCheck(bool valid, string detail)
{
if (valid) Print("ERR SMOKE PASS: telemetry " + detail);
else Print("ERR SMOKE FAIL: telemetry " + detail, LogLevel.ERROR);
return valid;
}
int ERR_TestFinalCount(string mission)
{
int count;
foreach (string payload : m_State.pending)
{
JsonLoadContext json = new JsonLoadContext(); string id, type, outcome; int phase;
if (!json.LoadFromString(payload)) return -1;
json.ReadValue("missionId", id); json.ReadValue("type", type);
if (id != mission || type != "mission_finished") continue;
json.ReadValue("outcome", outcome); json.ReadValue("phase", phase);
if (outcome != "defeat" || phase != ERR_Phase.DEFEAT) return -1;
count++;
}
return count;
}
void ERR_TestFreeze()
{
m_Closed = true;
GetGame().GetCallqueue().Remove(Pulse);
}
bool ERR_TestOccupiedRecovery()
{
int connected = GetGame().GetPlayerManager().GetPlayerCount();
PrintFormat("ERR TELEMETRY NATIVE: connected=%1 mission=%2", connected, m_MissionId);
if (!ERR_TestCheck(Enabled() && connected > 0, "uses actual connected native player")) return false;
Checkpoint();
ERR_TelemetryState disk;
if (!ERR_TestCheck(ReadState(STATE, disk) && !disk.restartFailure.IsEmpty() && disk.previousOutcome == "unfinished", "occupied checkpoint round-trips through native JSON and profile storage")) return false;
if (!ERR_TestCheck(ERR_TestFinalCount(m_MissionId) == 0, "live checkpoint is not queued as a failure")) return false;
// Constructor recovery from persisted unfinished state, as after a forced stop.
ref ERR_Telemetry recovered = new ERR_Telemetry(m_Mode);
bool recoveredOK = recovered.ERR_TestFinalCount(m_MissionId) == 1;
recovered.ERR_TestFreeze();
if (!ERR_TestCheck(recoveredOK, "fresh service recovers exactly one defeat for the old mission")) return false;
// Restore the live service's state for the real EndGameMode test below.
if (!ERR_TestCheck(Save(), "restore original live checkpoint for native shutdown")) return false;
return true;
}
bool ERR_TestFinished()
{
ERR_TelemetryState disk;
if (!ERR_TestCheck(m_Ended && m_State.previousOutcome == "defeat" && m_State.restartFailure.IsEmpty() && ERR_TestFinalCount(m_MissionId) == 1, "actual native end records exactly one occupied failure")) return false;
if (!ERR_TestCheck(ReadState(STATE, disk) && disk.previousOutcome == "defeat" && disk.restartFailure.IsEmpty(), "completed result persisted and candidate cleared")) return false;
ref ERR_Telemetry recovered = new ERR_Telemetry(m_Mode);
bool recoveredOK = recovered.ERR_TestFinalCount(m_MissionId) == 1;
recovered.ERR_TestFreeze();
if (!ERR_TestCheck(recoveredOK, "reopening completed state does not duplicate the failure")) return false;
return Save();
}
}
#endif
+43
View File
@@ -0,0 +1,43 @@
# Native telemetry restart test
Run from the repository root with a disposable existing Proton test prefix and
the installed authorized dependency tree:
```sh
python3 tests/native/telemetry_restart/run.py \
--work .local/restart-failure/verified \
--compat .local/rhs-19-work/compat \
--external-addons '/home/jakub/.local/share/Steam/steamapps/compatdata/1874880/pfx/drive_c/users/steamuser/Documents/My Games/ArmaReforger/addons'
```
The work directory must be new. The runner copies the current addon and adds the
fixture only to that copy. It reuses the native preparation-stage shutdown probe
so extraction placement is not a prerequisite. It then explicitly stops and
relaunches its owned Workbench process with the same
private profile. Workbench hosting does not automatically reload this fixture.
Two rounds use the actual native PlayerManager connection count and EndGameMode
callback. Production reload behavior is not changed.
The fixture checks native JSON/profile round-trips, an occupied checkpoint not
being submitted while the round is live, constructor recovery from unfinished
state, a single persisted defeat on shutdown, and completed-state reopening
without another defeat. Forced-stop recovery is simulated by constructing a new
service from the saved unfinished checkpoint; this is not an AMP process-kill
or multiplayer dedicated-server test. Empty occupancy and completion variants
also have production-method tests in `tests/test_telemetry.py`.
The copied test class replaces only the telemetry dispatch tick, preventing
network submissions. A reserved `.invalid` endpoint is an additional safeguard.
The fixture restores the original service's state after each recovery probe.
No test code or test results are included in the published addon.
Native evidence is retained under the work directory's `profile/logs`, including
source hashes and cleanup verification. Queued payloads are in
`profile/profile/EscapistsTelemetryState.json` and can be checked with the
receiver's `validateEvent` function.
The general native report also grades optional asset and backend errors. This
fixture retains that complete report as `telemetry-check.json`, while its result
requires completed telemetry assertions, no script/runtime failures, unchanged
inputs, confirmed process cleanup and valid queued results. It does not certify
Rally/RHS/WCS assets, DLC ownership or the rest of the mission.
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Run isolated native occupancy, JSON persistence, recovery and shutdown checks."""
import argparse
import json
from pathlib import Path
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[3]
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--work', type=Path, required=True, help='New disposable directory; must not exist')
p.add_argument('--compat', type=Path, required=True, help='Existing private test Proton prefix')
p.add_argument('--external-addons', type=Path, required=True)
a = p.parse_args()
work = a.work.resolve()
if work.exists():
p.error('--work must not exist; preserve evidence from earlier runs')
work.mkdir(parents=True)
source, profile = work/'source', work/'profile'
for name in ['addons', 'tools']:
shutil.copytree(ROOT/name, source/name)
(source/'docs').mkdir()
shutil.copy2(ROOT/'docs/external-addons.json', source/'docs/external-addons.json')
def replace(path, before, after):
text = path.read_text()
if text.count(before) != 1:
raise ValueError(f'Fixture insertion point changed in {path.name}')
path.write_text(text.replace(before, after))
scripts = source/'addons/core/Scripts/Game/ERR/00_Core'
shutil.copy2(Path(__file__).with_name('ERR_TelemetryCheckpointTest.c'), scripts/'ERR_TelemetryCheckpointTest.c')
# Modded classes can only access fields/methods of earlier layers. Test accessors
# follow telemetry; the existing native-end fixture is moved after that layer.
replace(scripts/'ERR_TelemetryLifecycle.c', ' void ERR_RefreshTelemetry()',
' bool ERR_TestTelemetryBefore() { return m_ERRTelemetry && m_ERRTelemetry.ERR_TestOccupiedRecovery(); }\n'
' bool ERR_TestTelemetryAfter() { return m_ERRTelemetry && m_ERRTelemetry.ERR_TestFinished(); }\n'
' void ERR_RefreshTelemetry()')
probe = scripts/'ERR_NativeEndProbe.c'
replace(probe, 'm_ERREndData = SCR_GameModeEndData.CreateSimple',
'if (!ERR_EndCheck(ERR_TestTelemetryBefore(), "occupied telemetry snapshot and recovery")) return;\n'
' m_ERREndData = SCR_GameModeEndData.CreateSimple')
replace(probe, 'EndGameMode(m_ERREndData);',
'EndGameMode(m_ERREndData);\n'
' if (!ERR_EndCheck(ERR_TestTelemetryAfter(), "native shutdown records one defeat")) return;')
probe.rename(scripts/'ERR_ZTelemetryNativeEndProbe.c')
runner = source/'tools/headless_workbench.py'
replace(runner, 'if a.roadblock_probe_count is not None: command',
"command += ['-errTelemetryTest', '1']\nif a.roadblock_probe_count is not None: command")
# Use two explicit process launches: Workbench need not auto-reload a hosted game.
replace(runner, "a.project != 'core' or a.rounds != 2 or a.timeout < 120", "a.project != 'core' or a.rounds != 1 or a.timeout < 120")
replace(runner, "if any((profile/name).exists() or (profile/name).is_symlink() for name in fixture_names): p.error('native-end fixture paths must not preexist; preserve prior evidence before retrying')",
'# This isolated fixture intentionally preserves the first instance marker.')
(profile/'profile').mkdir(parents=True)
(profile/'profile/EscapistsTelemetry.json').write_text(
'{"endpoint":"https://telemetry.invalid/api/mission-events",'
'"token":"local-test-only","serverName":"Isolated restart test"}\n')
command = [sys.executable, str(runner), '--compat', str(a.compat.resolve()),
'--profile', str(profile), '--external-addons', str(a.external_addons.resolve()),
'--smoke', 'native-end-preparing', '--project', 'core', '--rounds', '1', '--timeout', '120']
sys.path.insert(0, str(ROOT/'tools'))
from native_report import analyze
for _ in range(2):
old_logs = set((profile/'logs').glob('logs_*'))
result = subprocess.run(command, check=False)
new_logs = set((profile/'logs').glob('logs_*')) - old_logs
assert len(new_logs) == 1, 'Expected exactly one fresh native session'
session = new_logs.pop()
evidence = json.loads((session/'runner.json').read_text())
report = analyze((session/'console.log').read_text(errors='replace'), session/'console.log')
assert result.returncode in (0, 1)
assert evidence['result'] == 'smoke-completed' and evidence['cleanup_confirmed']
assert evidence['source_unchanged_after_run'] and evidence['external_addons_unchanged_after_run']
assert report['smoke_completions'] == 1 and not report['script_or_runtime_failures']
assert not report['missing_end_screen']
# The generic runner also grades all optional asset/backend errors. Preserve
# its complete report; this fixture grades telemetry, not DLC/asset integrity.
report['telemetry_assertions_passed'] = True
(session/'telemetry-check.json').write_text(json.dumps(report, indent=2)+'\n')
if report['engine_errors']:
print(f"Telemetry checks passed; {len(report['engine_errors'])} asset/backend errors retained in {session/'telemetry-check.json'}", flush=True)
state_path = profile/'profile/EscapistsTelemetryState.json'
events = [json.loads(payload) for payload in json.loads(state_path.read_text())['state']['pending']]
finished = [event for event in events if event['type'] == 'mission_finished']
assert len(finished) == 2 and len({event['missionId'] for event in finished}) == 2
assert all(event['outcome'] == 'defeat' and event['phase'] == 6 and event['connectedPlayers'] > 0 for event in finished)
restarts = [event for event in events if event['type'] == 'mission_restarted']
assert len(restarts) == 1 and restarts[0]['previousOutcome'] == 'defeat'
assert restarts[0]['previousMissionId'] in {event['missionId'] for event in finished}
subprocess.run(['node', '--input-type=module', '-e',
"import {readFileSync} from 'node:fs'; import {validateEvent} from "
+ json.dumps((ROOT/'services/telemetry-worker/src/validation.js').as_uri()) + ";"
+ "const {state}=JSON.parse(readFileSync(process.argv[1],'utf8'));"
+ "for(const payload of state.pending) validateEvent(JSON.parse(payload));", str(state_path)], check=True)
print('PASS: two native process instances, real occupied shutdowns, durable recovery, no duplicate results and receiver-valid native payloads')
+38 -7
View File
@@ -3,10 +3,10 @@ from policy_harness import ROOT, body, run
p=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_Telemetry.c'
s=p.read_text()
def cpp(v):
for a,b in [('protected ',''),('m_Config.', 'config.'),('Replication.IsServer()', 'authority'),('m_Mode.Settings()', 'settings'),('FileIO.DeleteFile(', 'DeleteFile('),('ERR_Phase.','Phase::'),('ERR_TelemetryRules.','Rules::'),('ERR_MissionRules.','MissionRules::'),('Math.Min(', 'std::min('),('Math.Pow(', 'power('),('System.GetTickCount()', '::now'),('LogLevel.WARNING','0'),('m_State.', 'm_State->'),('m_Callback = null', 'm_Callback = nullptr'),('m_Context = null', 'm_Context = nullptr'),('m_Mode = null', 'm_Mode = nullptr'),('RestCallback callback', 'RestCallback* callback'),('callback.GetHttpCode()', 'callback->GetHttpCode()'),('GetGame().GetCallqueue().Remove(Pulse)', 'removed++')]: v=v.replace(a,b)
for a,b in [('protected ',''),('JsonLoadContext queued = new JsonLoadContext();', 'JsonLoadContext queued;'),('GetGame().GetPlayerManager().GetPlayerCount()', 'connections'),('m_Mode.Phase()', 'phase'),('m_Config.', 'config.'),('Replication.IsServer()', 'authority'),('m_Mode.Settings()', 'settings'),('FileIO.DeleteFile(', 'DeleteFile('),('ERR_Phase.','Phase::'),('ERR_TelemetryRules.','Rules::'),('ERR_MissionRules.','MissionRules::'),('Math.Min(', 'std::min('),('Math.Pow(', 'power('),('System.GetTickCount()', '::now'),('LogLevel.WARNING','0'),('m_State.', 'm_State->'),('m_Callback = null', 'm_Callback = nullptr'),('m_Context = null', 'm_Context = nullptr'),('m_Mode = null', 'm_Mode = nullptr'),('RestCallback callback', 'RestCallback* callback'),('callback.GetHttpCode()', 'callback->GetHttpCode()'),('GetGame().GetCallqueue().Remove(Pulse)', 'removed++')]: v=v.replace(a,b)
return v
rules='\n'.join(cpp(body(p,x)) for x in ['static bool Acknowledged(', 'static int RetrySeconds(', 'static string Outcome('])
methods='\n'.join(cpp(body(p,x)) for x in ['bool Enabled(', 'void Refresh(', 'protected void Purge(', 'void Finish(', 'void PhaseChanged(', 'protected void Delivered(', 'protected void Failed(', 'void Close('])
methods='\n'.join(cpp(body(p,x)) for x in ['bool Enabled(', 'void Refresh(', 'protected void Purge(', 'void Finish(', 'protected void RecoverRestart(', 'protected void Enqueue(', 'protected void Checkpoint(', 'void RosterChanged(', 'void PhaseChanged(', 'protected void Delivered(', 'protected void Failed(', 'void Close('])
methods=methods.replace('GetGame().GetCallqueue().CallLater(Pulse, 1, false)', 'pulses++')
run(r'''
#include <cassert>
@@ -21,15 +21,16 @@ namespace MissionRules {bool Terminal(int p){return p==Phase::VICTORY||p==Phase:
struct Rules {
'''+rules+r'''
};
struct Pending:std::vector<string>{using std::vector<string>::operator=;void Clear(){clear();}bool IsEmpty(){return empty();}void RemoveItem(string x){erase(std::remove(begin(),end(),x),end());}};
struct State{string previousOutcome,previousMissionId;Pending pending;};
struct Pending:std::vector<string>{using std::vector<string>::operator=;int Count(){return size();}void Insert(string x){push_back(x);}void InsertAt(string x,int at){insert(begin()+at,x);}void Remove(int at){erase(begin()+at);}void Clear(){clear();}bool IsEmpty(){return empty();}void RemoveItem(string x){erase(std::remove(begin(),end(),x),end());}};
struct JsonLoadContext{string value;bool LoadFromString(string x){value=x;return true;}bool ReadValue(string key,string& type){type=value;return key=="type";}};
struct State{int droppedEvents=0;string previousOutcome,previousMissionId,restartFailure;Pending pending;};
struct RestCallback{int code=200;int GetHttpCode(){return code;}};
struct Service {
struct Config{string Endpoint="https://fixture.invalid/";}config;
struct Settings{int enabled=1;int Get(string){return enabled;}explicit operator bool()const{return true;}}settings;
Pending m_Players;bool authority=true,m_Collected=false,m_Partial=false,m_TruncatedPlayers=false,current=true;
string m_MissionId="same-round";string STATE="queue";
int m_Started=now,m_LastSample=0,m_NextHeartbeat=0,m_Peak=0,deletes=0;
int connections=0,phase=Phase::PRISON;int m_Started=now,m_LastSample=0,m_NextHeartbeat=0,m_Peak=0,deletes=0;
void DeleteFile(std::string){deletes++;}
bool m_Enabled=true,m_Closed=false,m_Ended=false,m_Dirty=false,desired=true,saveOK=true;
State state;State* m_State=&state;RestCallback* m_Callback=nullptr;void* m_Context=nullptr;void* m_Mode=nullptr;
@@ -38,7 +39,10 @@ struct Service {
int pulses=0;void UpdateCommunity(RestCallback*){}void CommunityUnavailable(){}
bool Current(){return current;}
void Sample(){samples++;}
void Event(string t,string o="",string p="",string prior=""){type=t;result=o;events++;}
void Event(string t,string o="",string p="",string prior="",bool checkpoint=false){
if(checkpoint){state.restartFailure="snapshot";Save();return;}type=t;result=o;events++;
}
bool Save(){saves++;if(saveOK)m_Dirty=false;return saveOK;}
'''+methods+r'''
};
@@ -59,6 +63,33 @@ int main(){
Service loss;loss.Finish("preparation_failed");loss.Close();loss.Close();
assert(loss.events==1&&loss.result=="preparation_failed"&&loss.removed==1);
Service abandoned;abandoned.Close();assert(abandoned.result=="interrupted");
Service occupied;occupied.connections=2;occupied.Close();
assert(occupied.events==1&&occupied.result=="defeat"&&occupied.state.previousOutcome=="defeat");
occupied.Close();assert(occupied.events==1);
Service emptyRestart;emptyRestart.Finish("restarted_empty");emptyRestart.Close();assert(emptyRestart.result=="restarted_empty");
Service checkpoint;checkpoint.connections=1;checkpoint.RosterChanged(1);
assert(checkpoint.state.restartFailure=="snapshot"&&checkpoint.events==0&&checkpoint.saves==1);
checkpoint.connections=0;checkpoint.RosterChanged(1);
assert(checkpoint.state.restartFailure.IsEmpty()&&checkpoint.events==0&&checkpoint.saves==2);
checkpoint.connections=1;checkpoint.Checkpoint();checkpoint.Finish("victory");checkpoint.Checkpoint();
assert(checkpoint.state.restartFailure.IsEmpty()&&checkpoint.result=="victory");
Service crash;crash.m_Enabled=false;crash.state.previousMissionId="crashed-round";
crash.state.previousOutcome="unfinished";crash.state.restartFailure="persisted-event-with-stable-id";
crash.Refresh();assert(crash.state.pending.size()==1&&crash.state.pending[0]=="persisted-event-with-stable-id");
assert(crash.state.restartFailure.IsEmpty()&&crash.state.previousMissionId=="same-round");
crash.RecoverRestart();assert(crash.state.pending.size()==1);
Service full;full.m_InFlight="in-flight";full.state.pending.push_back("in-flight");
for(int i=1;i<64;i++)full.state.pending.push_back(i==10?"heartbeat":"old");
full.state.previousOutcome="unfinished";full.state.restartFailure="recovered";full.RecoverRestart();
assert(full.state.pending.size()==64&&full.state.pending[0]=="recovered"&&full.state.pending[1]=="in-flight");
assert(std::count(full.state.pending.begin(),full.state.pending.end(),"heartbeat")==0&&full.state.droppedEvents==1);
Service legacy;legacy.state.previousOutcome="unfinished";legacy.RecoverRestart();assert(legacy.state.pending.empty());
for(string result:{"victory","defeat","preparation_failed","restarted_empty","interrupted"}){
Service completed;completed.state.previousOutcome=result;completed.state.restartFailure="stale";completed.RecoverRestart();
assert(completed.state.pending.empty()&&completed.state.previousOutcome==result&&completed.state.restartFailure.IsEmpty());
}
Service optout;optout.state.restartFailure="private-data";optout.state.previousMissionId="old";
optout.settings.enabled=0;optout.Refresh();assert(optout.state.restartFailure.IsEmpty()&&optout.state.pending.empty());
Service disabled;disabled.settings.enabled=0;disabled.Finish("victory");assert(disabled.events==0);
RestCallback old,current;Service delivery;
delivery.state.pending={"first","second"};delivery.m_InFlight="first";delivery.m_Callback=&current;
@@ -94,4 +125,4 @@ assert 'ReadValue("token"' in s and 'WriteValue("token", m_Config.Token)' in s
event=body(p,'protected void Event(')
assert 'Token' not in event and 'Endpoint' not in event
assert 'DidEscape' in body(p,'protected void Sample(')
print('PASS: telemetry outcomes, exactly-once finish, opt-out gate, stale callbacks, HTTP status handling and bounded retry')
print('PASS: telemetry outcomes, occupied/empty restarts, durable recovery, bounded queue, opt-out, exactly-once finish and HTTP retries')
+45
View File
@@ -2,6 +2,9 @@
import importlib.util
from pathlib import Path
import unittest
import json
import io
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('workshop_changelog', ROOT / 'tools/workshop_changelog.py')
@@ -40,5 +43,47 @@ class WorkshopTests(unittest.TestCase):
tool.update_changelog(text, '0.0.109')
class PublishedLookupTests(unittest.TestCase):
def page(self, **overrides):
asset = dict(id='2528466DE220D91C', currentVersionNumber='0.0.147',
gameVersion='1.9.0.37', versions=[dict(version='0.0.147',
assetId='2528466DE220D91C', published=True, approved=True)])
asset.update(overrides)
return '<script id="__NEXT_DATA__" type="application/json">' + json.dumps(
{'props': {'pageProps': {'asset': asset}}}) + '</script>'
def test_public_version_is_not_game_or_bundle_version(self):
self.assertEqual(tool.online_version(self.page(), '2528466DE220D91C'), '0.0.147')
def test_wrong_asset_unpublished_and_ambiguous_pages_are_rejected(self):
cases = [self.page(id='1111111111111111'), self.page(versions=[]),
self.page(private=True), self.page(blocked=True),
self.page(currentVersionNumber='1.9.0.37'),
self.page(versions=[dict(version='0.0.147', assetId='2528466DE220D91C', published=False, approved=True)]),
self.page(versions=[dict(version='0.0.147', assetId='2528466DE220D91C', published=True, approved=False)]),
self.page() + self.page(), '<html>unavailable</html>']
for page in cases:
with self.subTest(page=page), self.assertRaises(ValueError):
tool.online_version(page, '2528466DE220D91C')
def test_stale_response_is_not_a_downgrade(self):
text = tool.update_changelog(TEXT, '0.0.146')
with self.assertRaises(ValueError):
tool.prevent_downgrade(text, '0.0.142')
tool.prevent_downgrade(text, '0.0.146')
tool.prevent_downgrade(text, '0.0.147')
tool.prevent_downgrade(tool.update_changelog(TEXT, '0.0.9'), '0.0.10')
def test_live_sync_validates_response_and_fails_closed(self):
with patch.object(tool.urllib.request, 'urlopen', return_value=io.BytesIO(self.page().encode())):
self.assertEqual(tool.sync_version(PROJECT, TEXT), '0.0.147')
with patch.object(tool.urllib.request, 'urlopen', side_effect=OSError('offline')):
with self.assertRaises(OSError):
tool.sync_version(PROJECT, TEXT)
with patch.object(tool.urllib.request, 'urlopen', return_value=io.BytesIO(self.page().encode())):
with self.assertRaises(ValueError):
tool.sync_version(PROJECT, tool.update_changelog(TEXT, '0.0.148'))
if __name__ == '__main__':
unittest.main()
+84 -3
View File
@@ -1,10 +1,12 @@
#!/usr/bin/env python3
"""Read Workbench's bundled version; record it after a confirmed Workshop upload."""
"""Synchronize the published Workshop version, or inspect a local Workbench bundle."""
import argparse
import json
from pathlib import Path
import re
import sys
import urllib.request
from html.parser import HTMLParser
ROOT = Path(__file__).resolve().parents[1]
VERSION_LINE = 'Latest published Workshop version:'
@@ -30,6 +32,71 @@ def bundled_version(manifest, project):
return version
class WorkshopPage(HTMLParser):
def __init__(self):
super().__init__()
self.active = False
self.payloads = []
def handle_starttag(self, tag, attrs):
if tag == 'script' and dict(attrs).get('id') == '__NEXT_DATA__':
self.active = True
self.payloads.append('')
def handle_endtag(self, tag):
if tag == 'script':
self.active = False
def handle_data(self, data):
if self.active:
self.payloads[-1] += data
def online_version(page, guid):
parser = WorkshopPage()
parser.feed(page)
if len(parser.payloads) != 1:
raise ValueError('Workshop page has no unique version metadata.')
data = json.loads(parser.payloads[0])
asset = data['props']['pageProps']['asset']
if asset.get('id') != guid or asset.get('private') is True or asset.get('blocked') is True:
raise ValueError('Workshop page does not identify the public addon.')
version = asset.get('currentVersionNumber')
if not valid_version(version) or not any(v.get('version') == version and v.get('assetId') == guid
and v.get('published') is True and v.get('approved') is True
for v in asset.get('versions', [])):
raise ValueError('Workshop current version is not confirmed as published and approved.')
return version
def prevent_downgrade(text, version):
existing = re.search(r'^Latest published Workshop version: \*\*(\d+\.\d+\.\d+)\*\*$', text, re.MULTILINE)
if existing and tuple(map(int, version.split('.'))) < tuple(map(int, existing[1].split('.'))):
raise ValueError('Workshop response is older than the recorded publication; refusing stale metadata.')
def sync_version(project, text, native_compat=None):
match = re.search(r'\bGUID\s+"([A-Fa-f0-9]{16})"', project)
if not match:
raise ValueError('Addon GUID is missing.')
guid = match[1].upper()
if native_compat:
from workshop_query import native_version
version = native_version(ROOT, native_compat, guid)
else:
request = urllib.request.Request('https://reforger.armaplatform.com/workshop/' + guid,
headers={'Cache-Control': 'no-cache'})
with urllib.request.urlopen(request, timeout=20) as response:
page = response.read(2 * 1024 * 1024 + 1)
if len(page) > 2 * 1024 * 1024:
raise ValueError('Workshop response exceeds the expected size.')
version = online_version(page.decode('utf-8'), guid)
if not valid_version(version):
raise ValueError('Workshop returned an invalid version.')
prevent_downgrade(text, version)
return version
def update_changelog(text, version):
if not valid_version(version):
raise ValueError('Invalid Workshop version.')
@@ -50,10 +117,24 @@ def update_changelog(text, version):
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--manifest', type=Path, default=DEFAULT_MANIFEST)
parser.add_argument('--published', action='store_true',
action = parser.add_mutually_exclusive_group()
action.add_argument('--sync', action='store_true', help='Fetch the live published version and update CHANGELOG.md')
parser.add_argument('--native-compat', type=Path, help='Use the live game catalogue with this disposable prefix instead of the website')
action.add_argument('--published', action='store_true',
help='Confirm the bundle was successfully uploaded; update CHANGELOG.md')
args = parser.parse_args()
if args.native_compat and not args.sync:
parser.error('--native-compat requires --sync')
try:
if args.sync:
path = ROOT / 'CHANGELOG.md'
before = path.read_text()
version = sync_version((ROOT / 'addons/core/addon.gproj').read_text(), before, args.native_compat)
after = update_changelog(before, version)
if before != after:
path.write_text(after)
print('Verified published Workshop version ' + version + '; CHANGELOG.md synchronized.')
return 0
manifest = json.loads(args.manifest.read_text(encoding='utf-8-sig'))
version = bundled_version(manifest, (ROOT / 'addons/core/addon.gproj').read_text())
if args.published:
@@ -66,7 +147,7 @@ def main():
else:
print('Workbench bundle version: ' + version)
print('Bundling alone does not confirm publication. Use --published after a successful upload.')
except (OSError, ValueError, AttributeError) as error:
except (OSError, ValueError, AttributeError, KeyError, TypeError) as error:
print('Cannot read/record Workshop version: ' + str(error), file=sys.stderr)
return 1
return 0
+104
View File
@@ -0,0 +1,104 @@
"""Query the public Workshop catalogue using a disposable, silent game session."""
import fcntl
import os
from pathlib import Path
import re
import subprocess
import tempfile
import time
from headless_game import assert_prefix_idle, cleanup_owned_run
SCRIPT = '''class ERR_VersionQuery : PageParams
{
override void OnPack() { StoreString("search", "Co10 escape Reintroduced"); }
}
modded class SCR_AddonManager
{
protected ref ERR_VersionQuery m_Query;
protected ref BackendCallback m_Callback;
override void EOnInit(IEntity owner)
{
super.EOnInit(owner);GetGame().GetCallqueue().CallLater(QueryVersion,10000,false);
}
void QueryVersion()
{
m_Query=new ERR_VersionQuery();m_Query.limit=100;m_Query.offset=0;
m_Callback=new BackendCallback();m_Callback.SetOnSuccess(VersionReady);
GetGame().GetBackendApi().GetWorkshop().RequestPage(m_Callback,m_Query,true);
}
void VersionReady(BackendCallback callback)
{
array<WorkshopItem> items={};GetGame().GetBackendApi().GetWorkshop().GetPageItems(items);
foreach(WorkshopItem item:items)
{
if(item.Id()!="2528466DE220D91C")continue;
Revision revision=item.GetLatestRevision();
if(revision)PrintFormat("ERR_WORKSHOP_PUBLISHED %1 %2",item.Id(),revision.GetVersion());
}
}
}
'''
def published_from_log(text, guid):
versions = set(re.findall(r'\bERR_WORKSHOP_PUBLISHED ' + re.escape(guid) +
r' (\d+\.\d+\.\d+)\s*$', text, re.MULTILINE))
if len(versions) > 1:
raise ValueError('Workshop returned conflicting published versions.')
return next(iter(versions), None)
def native_version(root, compat, guid):
if guid != '2528466DE220D91C':
raise ValueError('Native query is restricted to the Escape addon.')
compat = Path(compat).resolve()
steam = Path.home() / '.local/share/Steam'
if not compat.is_relative_to(root / '.local') or not (compat / 'pfx').is_dir():
raise ValueError('Use an existing disposable compat prefix under this checkout/.local.')
proton = steam / 'steamapps/common/Proton - Experimental/proton'
game = steam / 'steamapps/common/Arma Reforger/ArmaReforgerSteam.exe'
if not proton.is_file() or not game.is_file():
raise ValueError('Installed Reforger and Proton Experimental are required for the native query.')
with (compat / '.erri-workbench.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
assert_prefix_idle(compat)
scratch = root / '.local/workshop-version'
scratch.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='query-', dir=scratch) as temp:
work = Path(temp)
addon = work / 'addons/query'
(addon / 'Scripts/Game').mkdir(parents=True)
(addon / 'addon.gproj').write_text('GameProject { ID "ERR_VersionQuery" GUID "8F729DB72D635EF1" Dependencies { "58D0FB3206B6F859" } }\n')
(addon / 'Scripts/Game/VersionQuery.c').write_text(SCRIPT)
profile = work / 'profile'
profile.mkdir()
env = os.environ.copy()
env.update(STEAM_COMPAT_DATA_PATH=str(compat), STEAM_COMPAT_CLIENT_INSTALL_PATH=str(steam),
WINEPREFIX=str(compat / 'pfx'), SteamAppId='1874880', SteamGameId='1874880')
command = ['gamescope', '--backend', 'headless', '-W', '1280', '-H', '720', '-r', '30', '--',
str(proton), 'run', str(game), '-profile', 'Z:' + str(profile),
'-addonDownloadDir', 'Z:' + str(profile), '-addonsDir', 'Z:' + str(work / 'addons'),
'-addons', '8F729DB72D635EF1', '-noSound', '-noSplash', '-noThrow',
'-disableCrashReporter', '-logconsole']
version = None
with (work / 'launch.log').open('w') as output:
child = subprocess.Popen(command, cwd=game.parent, env=env, stdout=output,
stderr=subprocess.STDOUT, start_new_session=True)
try:
deadline = time.monotonic() + 60
while child.poll() is None and time.monotonic() < deadline:
for log in (profile / 'logs').glob('*/console.log'):
version = published_from_log(log.read_text(errors='replace'), guid)
if version:
break
if version:
break
time.sleep(.5)
finally:
cleanup = cleanup_owned_run(child, proton, env, compat)
if not cleanup.get('cleanup_confirmed'):
raise ValueError('Workshop query process cleanup could not be confirmed.')
if not version:
raise ValueError('Live Workshop query did not return a published version within 60 seconds.')
return version