keyboard events - Remembering and replaying keystrokes in C# : synchronisation issues -


i speedrunner (somebody likes finish games in fastest way possible) on pc games , i'd record inputs while play in order automatically replay run later. i've created little c# program : basically, starts timer , each time press/release key, saves action (keyup/keydown), key , @ millisecond did that. then, when want play again, launches timer, , when reaches millisecond on keystroke happen, reproduce it.

and works ! well... in fact, nearly works : keys well-reproduced, sometimes, differs little, leading unexpected death succeeded before.

here video showing problem : https://www.youtube.com/watch?v=4rpkcx68hpw&feature=youtu.be

the video on top reproduced keys, video on bottom original play. seems similar, until 3rd room, original play hits "spider" , make turn back, while reproduced keys doesn't touch it, distrubs rest of progression. of course, part of game 100% deterministic, same inputs lead same results. advancing video frame frame in video editor, see 2 frame gap when character climb on first crate, , gap continues grow.

here (heavily commented) code :

keyssaver.cs, class saves inputs

class keyssaver {     public static intptr keyup = (intptr)0x0101; // code of "key up" signal     public static intptr keydown = (intptr)0x0100; // code of "key down" signal     private stopwatch watch; // timer used trace @ millisecond each key have been pressed     private dictionary<long, dictionary<keys, intptr>> savedkeys; // recorded keys activity, indexed millisecond have been pressed. activity indexed concerned key ("keys" type) , associated activity code (0x0101 "key up", 0x0100 "key down").     private intptr hookid; // hook used listen keyboard      private delegate intptr lowlevelkeyboardproc(int ncode, intptr wparam, intptr lparam); // imported type : lowlevelkeyboardproc. can use type.       /*      * constructor       */     public keyssaver()     {         this.savedkeys = new dictionary<long, dictionary<keys, intptr>>();         this.watch = new stopwatch();     }      /*      * method start()      * description : starts save keyboard inputs.      * see : https://msdn.microsoft.com/en-us/library/windows/desktop/ms644990%28v=vs.85%29.aspx      */      public void start()     {         using (process curprocess = process.getcurrentprocess())         using (processmodule curmodule = curprocess.mainmodule) // actual thread         {             // installs hook keyboard (the "13" params means "keyboard", see link above codes), saying "hey, want function 'onactivity' being called @ each activity. can find function in actual thread (getmodulehandle(curmodule.modulename)), , listen keyboard activity of treads (code : 0)             this.hookid = setwindowshookex(13, onactivity, getmodulehandle(curmodule.modulename), 0);         }         this.watch.start(); // starts timer     }      /*      * method stop()      * description : stops save keyboard inputs.      * returns : recorded keys activity since start().      */     public dictionary<long, dictionary<keys, intptr>> stop()     {         this.watch.stop(); // stops timer         unhookwindowshookex(this.hookid); //uninstalls hook of keyboard (the 1 installed in start())         return this.savedkeys;     }      /*      * method onactivity()      * description : function called each time there keyboard activity (key of key down). saves detected activity , time @ moment have been done.      * @ncode : validity code. if >= 0, can use information, otherwise have let it.      * @wparam : activity have been detected (keyup or keydown). must compared keyssaver.keyup , keyssaver.keydown see activity is.      * @lparam : (once read , casted) key of keyboard have been triggered.      * see : https://msdn.microsoft.com/en-us/library/windows/desktop/ms644985%28v=vs.85%29.aspx (for function documentation)      * see : https://msdn.microsoft.com/en-us/library/windows/desktop/ms644974%28v=vs.85%29.aspx (for callnexthookex documentation)      */     private intptr onactivity(int ncode, intptr wparam, intptr lparam)     {         if (ncode >= 0) //we check validity of informations. if >= 0, can use them.         {             long time = this.watch.elapsedmilliseconds; //number of milliseconds elapsed since called start() method             int vkcode = marshal.readint32(lparam); //we read value associated pointer (?)             keys key = (keys)vkcode; //we convert int keys type             if (!this.savedkeys.containskey(time))             {                 // if no key activity have been detected millisecond yet, create entry in savedkeys dictionnary                 this.savedkeys.add(time, new dictionary<keys, intptr>());             }             this.savedkeys[time].add(key, wparam); //saves key , activity         }         return callnexthookex(intptr.zero, ncode, wparam, lparam); //bubbles informations others applications using similar hooks     }      // importation of native libraries     [dllimport("user32.dll", charset = charset.auto, setlasterror = true)]     private static extern intptr setwindowshookex(int idhook, lowlevelkeyboardproc lpfn, intptr hmod, uint dwthreadid);      [dllimport("user32.dll", charset = charset.auto, setlasterror = true)]     [return: marshalas(unmanagedtype.bool)]     private static extern bool unhookwindowshookex(intptr hhk);      [dllimport("user32.dll", charset = charset.auto, setlasterror = true)]     private static extern intptr callnexthookex(intptr hhk, int ncode,         intptr wparam, intptr lparam);      [dllimport("kernel32.dll", charset = charset.auto, setlasterror = true)]     private static extern intptr getmodulehandle(string lpmodulename); } 

keysplayer.cs, 1 simulate key events.

class keysplayer     {         private dictionary<long, dictionary<keys, intptr>> keystoplay; // keys play, timing. see keyssaver.savedkeys more informations.         private dictionary<long, input[]> playedkeys; // inputs played. "translation" of keystoplay, transforming keys inputs.         private stopwatch watch; // timer used respect strokes timing.         private long currentframe; // while playing, keeps last keystoplay frame have been played.          /*          * constructor           */         public keysplayer(dictionary<long, dictionary<keys, intptr>> keystoplay)         {             this.keystoplay = keystoplay;             this.playedkeys = new dictionary<long, input[]>();             this.watch = new stopwatch();             this.currentframe = 0;             this.loadplayedkeys(); //load keys played.         }          /*          * method start()          * description : starts play keyboard inputs.          */         public void start()         {             this.currentframe = 0;  //currentframe 0 @ beginning.             this.watch.reset(); //resets timer             this.watch.start(); //starts timer (yeah, pretty obvious)             ienumerator<long> enumerator = this.playedkeys.keys.getenumerator(); //the playedkeys enumerator. used jump 1 frame another.             long t; //will receive elapsed milliseconds, track desync.             while (enumerator.movenext()) //moves pointer of playedkeys dictionnary next entry (so, next frame).             {                 thread.sleep((int)(enumerator.current - this.currentframe - 1)); //the thread sleeps until millisecond before next frame. exemple, if there input @ 42th millisecond, thread sleep 41st millisecond. seems optionnal, since have "while" waits, allows consume less ressources. also, in long "while", processor tends "forget" thread long time, resulting in desyncs.                 while (this.watch.elapsedmilliseconds < enumerator.current) { } //we wait until precise millisecond want                 t = this.watch.elapsedmilliseconds; //we save actual millisecond                 uint err = sendinput((uint32)this.playedkeys[enumerator.current].length, this.playedkeys[enumerator.current], marshal.sizeof(typeof(input))); //simulate inputs of actual frame                 if (t != enumerator.current) // compare saved time supposed millisecond. if different, have desync, log infos track bug.                 {                     console.writeline("desync : " + t + "/" + enumerator.current + " - inputs : " + err);                 }                 this.currentframe = enumerator.current; //updates currentframe frame played.             }         }          /*          * method stop()          * description : stops play keyboard inputs.          */         public void stop()         {             this.watch.stop(); //stops timer.         }          /*          * method loadplayedkeys()          * description : transforms keystoplay dictionnary sequence of inputs. also, pre-load inputs need (loading takes bit of time lead desyncs).          */         private void loadplayedkeys()         {             foreach (keyvaluepair<long, dictionary<keys, intptr>> kvp in this.keystoplay)             {                 list<input> inputs = new list<input>(); //for each recorded frame, creates list of inputs                 foreach (keyvaluepair<keys, intptr> kvp2 in kvp.value)                 {                     inputs.add(this.loadkey(kvp2.key, this.intptrtoflags(kvp2.value))); //load key played , adds list.                  }                 this.playedkeys.add(kvp.key, inputs.toarray());//transforms list array , adds playedkeys "partition".             }         }          /*          * method intptrtoflags()          * description : translate intptr references activity (keydown/keyup) input flags.          */         private uint32 intptrtoflags(intptr activity)         {             if (activity == keyssaver.keydown) //todo : extended keys             {                 return 0;             }             if (activity == keyssaver.keyup)             {                 return 0x0002;             }             return 0;         }          /*          * method loadkey()          * description : transforms key sendable input (using above structures).          */         private input loadkey(keys key, uint32 flags)         {             return new input             {                 type = 1, //1 = "this keyboad event"                 data =                 {                     keyboard = new keybdinput                     {                         keycode = (uint16)key,                         scan = 0,                         flags = flags,                         time = 0,                         extrainfo = intptr.zero                     }                 }              };         }          // importation of native libraries         [dllimport("user32.dll", setlasterror = true)]         public static extern uint32 sendinput(uint32 numberofinputs, input[] inputs, int32 sizeofinputstructure);          [dllimport("kernel32.dll")]         static extern uint getlasterror();      } } 

all structs used sendinput (those copied inputsimulator script) :

/*      * struct mouseinput      * mouse internal input struct      * see : https://msdn.microsoft.com/en-us/library/windows/desktop/ms646273(v=vs.85).aspx      */     internal struct mouseinput     {         public int32 x;         public int32 y;         public uint32 mousedata;         public uint32 flags;         public uint32 time;         public intptr extrainfo;     }      /*      * struct hardwareinput      * hardware internal input struct      * see : https://msdn.microsoft.com/en-us/library/windows/desktop/ms646269(v=vs.85).aspx      */     internal struct hardwareinput     {         public uint32 msg;         public uint16 paraml;         public uint16 paramh;     }      /*      * struct keybdinput      * keyboard internal input struct (yes, 1 used, need 2 others send inputs)      * see : https://msdn.microsoft.com/en-us/library/windows/desktop/ms646271(v=vs.85).aspx      */     internal struct keybdinput     {         public uint16 keycode; //the keycode of triggered key. see https://msdn.microsoft.com/en-us/library/windows/desktop/dd375731(v=vs.85).aspx         public uint16 scan; //unicode character in keys (when flags saying "hey, unicode"). ununsed in our case.         public uint32 flags; //type of action (keyup or keydown). specifies if key "special" key.         public uint32 time; //timestamp of event. ununsed in our case.         public intptr extrainfo; //extra information (yeah, wasn't hard guess). ununsed in our case.     }      /*      * struct mousekeybdhardwareinput      * union struct key sending       * see : https://msdn.microsoft.com/en-us/library/windows/desktop/ms646270%28v=vs.85%29.aspx      */      [structlayout(layoutkind.explicit)]     internal struct mousekeybdhardwareinput     {         [fieldoffset(0)]         public mouseinput mouse;          [fieldoffset(0)]         public keybdinput keyboard;          [fieldoffset(0)]         public hardwareinput hardware;     }      /*      * struct input      * input internal struct key sending       * see : https://msdn.microsoft.com/en-us/library/windows/desktop/ms646270%28v=vs.85%29.aspx      */      internal struct input     {         public uint32 type; //type of input (0 = mouse, 1 = keyboard, 2 = hardware)         public mousekeybdhardwareinput data; //the union of "mouse/keyboard/hardware". 1 read, depending of type.     } 

and main form :

public partial class taslagrad : form     {         private keyssaver k;         private keysplayer p;          //initialisation          public taslagrad()         {             initializecomponent();             this.k = new keyssaver();         }          /*          * method launchrecording()          * description : starts record keys. called when "record" button triggered.          */         private void launchrecording(object sender, eventargs e)         {             this.k.start(); //starts save keys             startbutton.text = "stop"; //updates button             startbutton.click -= launchrecording;             startbutton.click += stoprecording;         }          /*          * method stoprecording()          * description : stops record keys , logs recorded keys in console. called when "record" button triggered.          */         private void stoprecording(object sender, eventargs e)         {             startbutton.text = "record";//updates button             startbutton.click += launchrecording;             startbutton.click -= stoprecording;             dictionary<long, dictionary<keys, intptr>> keys = this.k.stop(); //gets recorded keys             foreach (keyvaluepair<long, dictionary<keys, intptr>> kvp in keys)             {                 foreach (keyvaluepair<keys, intptr> kvp2 in kvp.value)                 {                     //displays recorded keys in console                     if (kvp2.value == keyssaver.keydown)                     {                         console.writeline(kvp.key + " : (down)" + kvp2.key);                     }                     if (kvp2.value == keyssaver.keyup)                     {                         console.writeline(kvp.key + " : (up)" + kvp2.key);                     }                 }             }             this.p = new keysplayer(keys); //creates new player , gives recorded keys.         }          /*          * method launchplaying()          * description : starts play keys. called when "play" button triggered.          */         private void launchplaying(object sender, eventargs e)         {             this.p.start(); //starts play keys.         } } 

of course, debugs seems work : recorder saves inputs (i tested typing long text) , when compare milliseconds @ keys recorded , @ played, have no difference...

so there problem in way record/play ? stopwatch not precise enough? there more precise/effective way?


Comments