Files
EscapistsReintroduced/tests/test_speech_preparation.py

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')