369 lines
20 KiB
Python
369 lines
20 KiB
Python
"""Execute production incremental audio preparation with clock/file/audio doubles.
|
|
|
|
These regressions prove bounded script work and lifecycle behavior, not native
|
|
audio latency; the separate Workbench fixture measures native execution.
|
|
"""
|
|
import base64
|
|
import json
|
|
import random
|
|
import re
|
|
from policy_harness import ROOT, body, run
|
|
|
|
SOURCE = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_WelcomeVoice.c'
|
|
|
|
|
|
def cpp(text):
|
|
for old, new in [
|
|
('protected ', ''), ('override ', ''), ('ref ', ''),
|
|
('null', 'nullptr'), ('Math.Min', 'std::min'),
|
|
('Math.Max', 'std::max'), ('System.GetTickCount', 'System::GetTickCount'),
|
|
('array<int> bytes', 'Array<int>& bytes'),
|
|
]:
|
|
text = text.replace(old, new)
|
|
return text
|
|
|
|
|
|
PREAMBLE = r'''
|
|
#include <cassert>
|
|
#include <algorithm>
|
|
#include <string>
|
|
#include <vector>
|
|
#include <map>
|
|
#include <memory>
|
|
namespace System {
|
|
int now=0, increment=0;
|
|
int GetTickCount(){int result=now;now+=increment;return result;}
|
|
}
|
|
struct string:std::string {
|
|
using std::string::string;
|
|
string(std::string value):std::string(value){}
|
|
bool IsEmpty() const{return empty();}
|
|
int Length() const{return size();}
|
|
int ToAscii(int offset=0) const{return static_cast<unsigned char>(at(offset));}
|
|
string Substring(int offset,int count) const{return substr(offset,count);}
|
|
int IndexOf(string value) const{auto p=find(value);return p==npos?-1:int(p);}
|
|
};
|
|
template<class T>struct Array:std::vector<T> {
|
|
using std::vector<T>::vector;
|
|
int Count() const{return this->size();}
|
|
bool IsEmpty() const{return this->empty();}
|
|
void Clear(){this->clear();}
|
|
void Insert(T value){this->push_back(value);}
|
|
bool Contains(T value) const{return std::find(this->begin(),this->end(),value)!=this->end();}
|
|
int Find(T value) const{auto p=std::find(this->begin(),this->end(),value);return p==this->end()?-1:int(p-this->begin());}
|
|
// Native Enforce Remove replaces the removed slot with the last element.
|
|
// Its explicitly ordered variants preserve FIFO order.
|
|
void Remove(int index){(*this)[index]=this->back();this->pop_back();}
|
|
void RemoveItem(T value){int index=Find(value);if(index>=0)Remove(index);}
|
|
void RemoveOrdered(int index){this->erase(this->begin()+index);}
|
|
void RemoveItemOrdered(T value){int index=Find(value);if(index>=0)RemoveOrdered(index);}
|
|
};
|
|
#define Print(...)
|
|
#define PrintFormat(...)
|
|
'''
|
|
|
|
|
|
decoder = cpp(body(SOURCE, 'class ERR_WelcomeVoiceDecoder')).replace(
|
|
'class ERR_WelcomeVoiceDecoder', 'struct ERR_WelcomeVoiceDecoder') + ';'
|
|
|
|
cases = []
|
|
for size in [44, 45, 46, 47, 255, 256, 257, 4094, 4095, 4096, 8192]:
|
|
expected = random.Random(size).randbytes(size)
|
|
encoded = base64.b64encode(expected).decode()
|
|
cases.append('Check(' + json.dumps(encoded) + ', Array<int>{' +
|
|
','.join(map(str, expected)) + '});')
|
|
|
|
run(PREAMBLE + decoder + r'''
|
|
Array<int> DecodeAll(string text,int limit=5460) {
|
|
ERR_WelcomeVoiceDecoder decoder;assert(decoder.Begin(text));
|
|
Array<int> result,chunk;int slices=0,status=0;
|
|
while(status==0){
|
|
status=decoder.Step(chunk,limit,2);
|
|
assert(status>=0&&chunk.Count()<=4095);
|
|
for(int value:chunk)result.Insert(value);
|
|
assert(++slices<10000);
|
|
}
|
|
assert(status==1);return result;
|
|
}
|
|
void Check(string encoded,Array<int> expected) {
|
|
for(int limit:{4,1024,5460})assert(DecodeAll(encoded,limit)==expected);
|
|
}
|
|
int main(){
|
|
''' + '\n'.join(cases) + r'''
|
|
// Maximum PCM transport payload and both legal padding forms survive slicing.
|
|
for(int size:{2097150,2097151,2097152}){
|
|
string encoded((size+2)/3*4,'A');
|
|
if(size%3==1){encoded[encoded.size()-2]='=';encoded.back()='=';}
|
|
if(size%3==2)encoded.back()='=';
|
|
auto bytes=DecodeAll(encoded);assert(bytes.Count()==size);
|
|
for(int value:bytes)assert(value==0);
|
|
}
|
|
// This encoded length is allowed, but its unpadded result exceeds 2 MiB.
|
|
ERR_WelcomeVoiceDecoder over;assert(over.Begin(string(2796204,'A')));
|
|
Array<int> chunk;int status=0;
|
|
while(status==0)status=over.Step(chunk);
|
|
assert(status==-1);
|
|
// Bad alphabet and padding are rejected even in a later slice/block.
|
|
for(int offset:{0,1023,1024,5459,5460,7999}){
|
|
ERR_WelcomeVoiceDecoder bad;string encoded(8000,'A');encoded[offset]='?';
|
|
assert(bad.Begin(encoded));status=0;
|
|
while(status==0)status=bad.Step(chunk);
|
|
assert(status==-1);
|
|
}
|
|
for(string encoded:{string(59,'A'),string(61,'A'),string(2796208,'A'),string()}){
|
|
ERR_WelcomeVoiceDecoder bad;assert(!bad.Begin(encoded));
|
|
}
|
|
for(string tail:{"AA=A","AB==","AAB=","=AAA","A=AA"}){
|
|
ERR_WelcomeVoiceDecoder bad;assert(bad.Begin(string(7996,'A')+tail));status=0;
|
|
while(status==0)status=bad.Step(chunk);
|
|
assert(status==-1);
|
|
}
|
|
{ERR_WelcomeVoiceDecoder bad;string encoded(8000,'A');encoded[1023]='=';
|
|
assert(bad.Begin(encoded));status=0;while(status==0)status=bad.Step(chunk);assert(status==-1);}
|
|
// The byte ceiling is independent of the clock. The elapsed-time guard also
|
|
// yields when individual engine operations cost time.
|
|
ERR_WelcomeVoiceDecoder slow;assert(slow.Begin(string(8000,'A')));
|
|
System::increment=1;status=slow.Step(chunk);System::increment=0;
|
|
assert(status==0&&chunk.Count()>0&&chunk.Count()<4095);
|
|
slow.Clear();assert(slow.Begin(string(60,'A')));assert(slow.Step(chunk)==1&&chunk.Count()==45);
|
|
}
|
|
''')
|
|
|
|
print('PASS: production incremental decoder preserves binary data, bounds each slice, yields on elapsed time and rejects malformed or oversized payloads')
|
|
|
|
|
|
def client_cpp(text):
|
|
text = cpp(text)
|
|
for old, new in [
|
|
('ResourceName', 'string'), ('ERR_BakedSpeech.', 'ERR_BakedSpeech::'), ('ERR_WelcomeVoiceRules.', 'Rules::'), ('ERR_GameMode.Get()', 'ERR_GameMode::Get()'),
|
|
('FileIO.', 'FileIO::'), ('AudioSystem.', 'AudioSystem::'),
|
|
('AudioHandle.Invalid', 'AudioHandle::Invalid'),
|
|
('FileMode.', 'FileMode::'), ('RestContext context', 'RestContext* context'),
|
|
('RestCallback callback', 'RestCallback* callback'),
|
|
('FileHandle file', 'File* file'), ('Array<int>& bytes = {}', 'Array<int> bytes'),
|
|
('GetGame().', 'GetGame()->'), ('GetRestApi().', 'GetRestApi()->'),
|
|
('m_Callback.SetOnSuccess(Downloaded)', 'm_Callback.SetOnSuccess(0)'),
|
|
('m_Callback.SetOnError(Failed)', 'm_Callback.SetOnError(0)'),
|
|
]:
|
|
text = text.replace(old, new)
|
|
for name in ['m_Connection', 'm_Callback', 'm_Decoder', 'm_Output',
|
|
'm_Mode', 'callback', 'context', 'file']:
|
|
text = text.replace(name + '.', name + '->')
|
|
return text
|
|
|
|
|
|
client_methods = '\n'.join(client_cpp(body(SOURCE, signature)) for signature in [
|
|
'protected bool Current()', 'bool PreparingDownload()', 'void Tick(',
|
|
'protected void BeginPreparation(', 'protected void CancelDownload()',
|
|
'bool PrepareDownload()', 'void Stop()',
|
|
]).replace('void Tick(', 'virtual void Tick(').replace('void Stop()', 'virtual void Stop()')
|
|
soldier_source = ROOT / 'addons/core/Scripts/Game/ERR/10_AI/ERR_SoldierSpeech.c'
|
|
soldier_methods = '\n'.join(client_cpp(body(soldier_source, signature)) for signature in [
|
|
'override void Tick(', 'override void Stop()',
|
|
]).replace('super.Tick', 'Client::Tick').replace('super.Stop', 'Client::Stop')
|
|
queue_methods = []
|
|
for signature in ['void ERR_QueueVoiceDownload(', 'void ERR_CancelVoiceDownload(',
|
|
'protected void ERR_PrepareVoiceDownloads()', 'protected void ERR_StopVoiceDownloads()']:
|
|
method = cpp(body(SOURCE, signature)).replace('ERR_WelcomeVoiceClient client', 'Client* client').replace('client.', 'client->')
|
|
method = re.sub(r'^(void) (ERR_\w+)', r'\1 Game::\2', method)
|
|
queue_methods.append(method)
|
|
connection = 'struct Connection { bool m_Attempted=false;' + '\n'.join(
|
|
body(SOURCE, signature) for signature in ['void MainMenu()', 'bool Claim()', 'bool Attempted()']) + '};'
|
|
|
|
# The native WAV parser remains outside this policy harness. A RIFF/WAVE byte
|
|
# fixture exercises the production header/length checks and exact file output.
|
|
wav = b'RIFF' + (9992).to_bytes(4, 'little') + b'WAVE' + random.Random(71).randbytes(9988)
|
|
wav64 = json.dumps(base64.b64encode(wav).decode())
|
|
expected = 'Array<int>{' + ','.join(map(str, wav)) + '}'
|
|
transfer = cpp(body(SOURCE, 'class ERR_VoiceTransfer')).replace('class ERR_VoiceTransfer', 'struct ERR_VoiceTransfer').replace('int m_Total;', 'int m_Total=0;') + ';'
|
|
run(PREAMBLE + transfer + r'''
|
|
int main(){
|
|
for(int size:{60,262140,262144,262148,1600060,2796204}){
|
|
ERR_VoiceTransfer transfer;string expected(size, 'A');int offset=0,requests=0;
|
|
while(offset<size){
|
|
assert(transfer.Part()==offset/262144);
|
|
int length=std::min(262144,size-offset);
|
|
int result=transfer.Accept(offset,size,expected.substr(offset,length));
|
|
offset+=length;requests++;
|
|
assert(result==(offset==size?1:0));assert(requests<=11);
|
|
}
|
|
assert(transfer.Data()==expected);
|
|
assert(transfer.Accept(offset,size,"AAAA")==-1);
|
|
transfer.Clear();assert(transfer.Data().IsEmpty()&&transfer.Part()==0);
|
|
}
|
|
for(int total:{0,59,61,2796208}){
|
|
ERR_VoiceTransfer bad;assert(bad.Accept(0,total,"AAAA")==-1);
|
|
assert(bad.Data().IsEmpty());
|
|
}
|
|
ERR_VoiceTransfer partial;assert(partial.Accept(0,524288,string(262144,'A'))==0);
|
|
assert(partial.Accept(0,524288,string(262144,'A'))==-1); // duplicate
|
|
assert(partial.Accept(524288,786432,string(262144,'A'))==-1); // gap
|
|
assert(partial.Accept(262144,524292,string(262144,'A'))==-1); // changed size
|
|
assert(partial.Accept(262144,524288,string(262140,'A'))==-1); // truncated
|
|
assert(partial.Accept(262144,524288,string(262148,'A'))==-1); // oversized
|
|
assert(partial.Data().Length()==262144);
|
|
assert(partial.Accept(262144,524288,string(262144,'A'))==1);
|
|
}
|
|
''')
|
|
print('PASS: production speech transfer checks order, exact part sizes and stable bounded totals without changing audio')
|
|
run(PREAMBLE + decoder + transfer + connection + r'''
|
|
namespace AudioHandle {const int Invalid=-1;}
|
|
namespace AudioSystem {int stopped=0;void TerminateSound(int){stopped++;}}
|
|
struct ERR_GameMode {static inline ERR_GameMode* live=nullptr;bool active=true;bool ERR_RecordingsEnabled(){return true;}
|
|
static ERR_GameMode* Get(){return live;}bool Active(){return active;}};
|
|
struct RestCallback {string data;void SetOnSuccess(int){}void SetOnError(int){}string GetData(){return data;}};
|
|
int requests=0;
|
|
struct RestContext {void SetTimeout(int){}void GET(RestCallback*,string){requests++;}};
|
|
struct Api {RestContext context;RestContext* GetContext(string){return &context;}};
|
|
struct ERR_BakedSpeech {static string Graph(string){return "";}};
|
|
struct Rules {static bool HTTPReady(){return true;}static bool Source(string value){return URL(value);}
|
|
''' + body(SOURCE, 'static bool URL(') + r'''
|
|
};
|
|
namespace FileMode {enum Type {WRITE,READ};}
|
|
int writes=0,opens=0,deletes=0,closes=0,maxWrite=0;
|
|
bool openWrite=true,openRead=true,shortWrite=false;
|
|
int lengthOverride=-1;
|
|
struct File {
|
|
bool exists=false;Array<int> data;int cursor=0;
|
|
int WriteArray(Array<int>& values,int elementSize){
|
|
assert(exists&&elementSize==1);writes++;maxWrite=std::max(maxWrite,values.Count());
|
|
int count=values.Count()-(shortWrite?1:0);
|
|
for(int i=0;i<count;i++)data.Insert(values[i]);
|
|
return count;
|
|
}
|
|
void Close(){closes++;}
|
|
int GetLength(){return lengthOverride>=0?lengthOverride:data.Count();}
|
|
void Read(string& result,int count){result="";for(int i=0;i<count&&cursor<data.Count();i++)result+=char(data[cursor++]);}
|
|
void Seek(int offset){cursor=offset;}
|
|
};
|
|
namespace FileIO {
|
|
std::map<string,File> files;std::map<string,int> deleteCounts;
|
|
void DeleteFile(string path){deletes++;deleteCounts[path]++;auto& file=files[path];file.exists=false;file.data.Clear();}
|
|
File* OpenFile(string path,FileMode::Type mode){
|
|
opens++;auto& file=files[path];
|
|
if(mode==FileMode::WRITE){if(!openWrite)return nullptr;file.exists=true;file.data.Clear();}
|
|
else if(!openRead||!file.exists)return nullptr;
|
|
file.cursor=0;return &file;
|
|
}
|
|
}
|
|
struct Client;
|
|
struct Game {
|
|
Api api;Array<Client*> m_ERRVoiceDownloads;Api* GetRestApi(){return &api;}
|
|
void ERR_QueueVoiceDownload(Client*);void ERR_CancelVoiceDownload(Client*);
|
|
void ERR_PrepareVoiceDownloads();void ERR_StopVoiceDownloads();
|
|
} game;
|
|
Game* GetGame(){return &game;}
|
|
struct Client {
|
|
ERR_VoiceTransfer* m_Transfer=nullptr;string m_DownloadURL,m_BakedGraph;
|
|
void RequestPart(){requests++;}
|
|
ERR_GameMode* m_Mode=nullptr;string m_Path,m_Text;Connection connection;Connection* m_Connection=&connection;
|
|
bool m_Stopped=false,m_Downloaded=false,m_Started=false;float m_Wait=0;
|
|
RestCallback* m_Callback=nullptr;ERR_WelcomeVoiceDecoder* m_Decoder=nullptr;File* m_Output=nullptr;
|
|
int m_PreparationBytes=0,m_PreparationFrames=0,m_PreparationLongest=0,m_Handle=AudioHandle::Invalid,plays=0;
|
|
explicit Client(string path):m_Mode(ERR_GameMode::live),m_Path(path){}
|
|
bool Permit(){return true;}virtual void Play(){plays++;}
|
|
''' + client_methods + r'''
|
|
void Receive(string encoded){Tick(3,true,"http://voice/clip","hello");BeginPreparation(encoded);}
|
|
};
|
|
''' + '\n'.join(queue_methods) + r'''
|
|
struct Soldier:Client {
|
|
using Client::Client;bool Prepared=false;float m_Age=0;string GraphPath;
|
|
void Play() override{plays++;Prepared=true;} // Native graph/audio boundary.
|
|
''' + soldier_methods + r'''
|
|
};
|
|
void Drain(){int frames=0;while(!game.m_ERRVoiceDownloads.IsEmpty()){game.ERR_PrepareVoiceDownloads();assert(++frames<2000);}}
|
|
int main(){
|
|
ERR_GameMode mode;ERR_GameMode::live=&mode;
|
|
string wav=WAV64;Array<int> expected=EXPECTED;
|
|
// HTTP completion only enqueues; decoding and filesystem work wait for update.
|
|
Client first("first"),second("second");first.Receive(wav);second.Receive(wav);
|
|
assert(game.m_ERRVoiceDownloads.Count()==2&&writes==0&&opens==0);
|
|
assert(first.PreparingDownload()&&second.PreparingDownload());
|
|
// Duplicate advertisements cannot cause multiple preparations in one frame.
|
|
game.ERR_QueueVoiceDownload(&first);assert(game.m_ERRVoiceDownloads.Count()==2);
|
|
game.ERR_PrepareVoiceDownloads();assert(writes==1&&first.m_PreparationFrames==1&&second.m_PreparationFrames==0);
|
|
first.Tick(1,true,"","");assert(first.plays==0&&!first.m_Downloaded);
|
|
game.ERR_PrepareVoiceDownloads();assert(writes==2&&first.m_PreparationFrames==1&&second.m_PreparationFrames==1);
|
|
int beforeWrites=writes;game.ERR_PrepareVoiceDownloads();assert(writes-beforeWrites==1);
|
|
Drain();assert(maxWrite<=4095&&FileIO::files["first"].data==expected&&FileIO::files["second"].data==expected);
|
|
assert(first.m_Downloaded&&second.m_Downloaded&&first.PreparingDownload()&&first.plays==0);
|
|
first.Tick(1,false,"","");assert(first.plays==0);
|
|
first.Tick(1,true,"","");first.Tick(1,true,"","");assert(first.plays==1&&!first.PreparingDownload());
|
|
// Four simultaneous clips expose native unordered-removal behavior which a
|
|
// two-client queue cannot distinguish. Every live client gets one turn before
|
|
// any gets another, even after a middle client is cancelled.
|
|
Client fairA("fairA"),fairB("fairB"),fairC("fairC"),fairD("fairD");
|
|
fairA.Receive(wav);fairB.Receive(wav);fairC.Receive(wav);fairD.Receive(wav);
|
|
game.ERR_PrepareVoiceDownloads();
|
|
assert(fairA.m_PreparationFrames==1&&fairB.m_PreparationFrames==0&&fairC.m_PreparationFrames==0&&fairD.m_PreparationFrames==0);
|
|
assert(game.m_ERRVoiceDownloads==Array<Client*>({&fairB,&fairC,&fairD,&fairA}));
|
|
fairC.Stop();assert(game.m_ERRVoiceDownloads==Array<Client*>({&fairB,&fairD,&fairA}));
|
|
game.ERR_PrepareVoiceDownloads();game.ERR_PrepareVoiceDownloads();
|
|
assert(fairA.m_PreparationFrames==1&&fairB.m_PreparationFrames==1&&fairC.m_PreparationFrames==0&&fairD.m_PreparationFrames==1);
|
|
for(int i=0;i<3;i++)game.ERR_PrepareVoiceDownloads();
|
|
assert(fairA.m_PreparationFrames==2&&fairB.m_PreparationFrames==2&&fairD.m_PreparationFrames==2);
|
|
assert(!fairA.m_Downloaded&&!fairB.m_Downloaded&&!fairD.m_Downloaded);
|
|
Drain();assert(fairA.m_Downloaded&&fairB.m_Downloaded&&fairD.m_Downloaded&&!fairC.m_Downloaded);
|
|
assert(FileIO::files["fairA"].data==expected&&FileIO::files["fairB"].data==expected&&FileIO::files["fairD"].data==expected);
|
|
// Cancelling one job closes/deletes its partial file and preserves other jobs.
|
|
Client stopped("stopped"),survivor("survivor");stopped.Receive(wav);survivor.Receive(wav);
|
|
game.ERR_PrepareVoiceDownloads();int beforeCloses=closes;stopped.Stop();
|
|
assert(closes==beforeCloses+1&&!FileIO::files["stopped"].exists&&!stopped.PreparingDownload());
|
|
assert(game.m_ERRVoiceDownloads.Count()==1);beforeWrites=writes;
|
|
stopped.Stop();stopped.BeginPreparation(wav);
|
|
stopped.Tick(1,true,"","");assert(stopped.plays==0&&writes==beforeWrites&&game.m_ERRVoiceDownloads.Count()==1);
|
|
Drain();assert(survivor.m_Downloaded);
|
|
// Each failure path removes the partial file and never announces readiness.
|
|
for(int failure=0;failure<7;failure++){
|
|
Client bad("bad"+std::to_string(failure));string input=wav;
|
|
if(failure==0)input[input.size()-5]='?'; // malformed final slice
|
|
if(failure==1)input[0]='A'; // decodes, but RIFF signature is invalid
|
|
bad.Receive(input);game.ERR_PrepareVoiceDownloads();
|
|
if(failure==2)shortWrite=true;
|
|
if(failure==3)openRead=false;
|
|
if(failure==4)lengthOverride=43;
|
|
if(failure==5)lengthOverride=2097153;
|
|
if(failure==6)mode.active=false;
|
|
Drain();bad.Tick(1,true,"","");
|
|
assert(!bad.m_Downloaded&&!bad.PreparingDownload()&&bad.plays==0&&!FileIO::files[bad.m_Path].exists);
|
|
shortWrite=false;openRead=true;lengthOverride=-1;mode.active=true;
|
|
}
|
|
Client noFile("noFile");noFile.Receive(wav);openWrite=false;Drain();openWrite=true;
|
|
assert(!noFile.m_Downloaded&&!noFile.PreparingDownload()&&!FileIO::files["noFile"].exists);
|
|
Client invalidSize("invalidSize");invalidSize.Receive("AAAA");assert(!invalidSize.PreparingDownload()&&game.m_ERRVoiceDownloads.IsEmpty());
|
|
// World/main-menu teardown empties every queued job without preparing it.
|
|
Client oldWorld("oldWorld"),otherOldWorld("otherOldWorld");oldWorld.Receive(wav);otherOldWorld.Receive(wav);
|
|
game.ERR_PrepareVoiceDownloads();beforeWrites=writes;game.ERR_StopVoiceDownloads();
|
|
assert(game.m_ERRVoiceDownloads.IsEmpty()&&oldWorld.m_Stopped&&otherOldWorld.m_Stopped&&writes==beforeWrites);
|
|
assert(!FileIO::files["oldWorld"].exists&&!FileIO::files["otherOldWorld"].exists);
|
|
// A stale world is also rejected if its queue entry survives until a tick.
|
|
Client stale("stale");stale.Receive(wav);ERR_GameMode::live=nullptr;Drain();
|
|
assert(!stale.PreparingDownload()&&!stale.m_Downloaded&&!FileIO::files["stale"].exists);
|
|
ERR_GameMode::live=&mode;
|
|
// A completed download may spend more than 30 seconds in the shared queue or
|
|
// wait for playback readiness. Neither state is a stalled HTTP request.
|
|
Soldier soldier("soldier");soldier.Receive(wav);soldier.Tick(31,true,"","");
|
|
assert(!soldier.m_Stopped&&soldier.PreparingDownload()&&!soldier.Prepared&&soldier.plays==0);
|
|
Drain();assert(soldier.m_Downloaded);
|
|
soldier.Tick(31,false,"","");assert(!soldier.m_Stopped&&soldier.m_Downloaded&&soldier.plays==0);
|
|
soldier.Tick(1,true,"","");assert(soldier.Prepared&&soldier.plays==1&&!soldier.m_Stopped);
|
|
soldier.Tick(120,true,"","");assert(soldier.Prepared&&!soldier.m_Stopped&&soldier.plays==1);
|
|
// An HTTP request that never delivers and a failed preparation still expire.
|
|
Soldier stalled("stalled");stalled.Tick(3,true,"http://voice/clip","");
|
|
assert(stalled.m_Started&&!stalled.PreparingDownload());stalled.Tick(28,true,"","");assert(stalled.m_Stopped);
|
|
Soldier failed("failed");failed.Receive("AAAA");failed.Tick(28,true,"","");assert(failed.m_Stopped);
|
|
// A subclass stop invoked through the queue closes both partial WAV and graph
|
|
// exactly once; retained stopped clips are harmless on subsequent updates.
|
|
Soldier stopping("stopping");stopping.GraphPath="stopping.acp";
|
|
FileIO::files[stopping.GraphPath].exists=true;stopping.Receive(wav);game.ERR_PrepareVoiceDownloads();
|
|
stopping.m_Handle=3;int beforeAudioStops=AudioSystem::stopped;
|
|
game.ERR_StopVoiceDownloads();assert(stopping.m_Stopped&&!stopping.Prepared);
|
|
assert(!FileIO::files[stopping.GraphPath].exists&&FileIO::deleteCounts[stopping.GraphPath]==1);
|
|
int beforeDeletes=deletes;stopping.Stop();stopping.Tick(60,true,"","");
|
|
assert(deletes==beforeDeletes&&AudioSystem::stopped==beforeAudioStops+1&&game.m_ERRVoiceDownloads.IsEmpty());
|
|
}
|
|
'''.replace('WAV64', wav64).replace('EXPECTED', expected))
|
|
|
|
print('PASS: production client callbacks only queue; shared updates prepare one bounded slice, preserve bytes, handle file failures and cancel safely before playback')
|
|
print('PASS: production soldier timeout preserves queued/completed preparation, expires stalled requests and removes stopped graphs only once')
|