aboutsummaryrefslogtreecommitdiff
path: root/tools/reproduceriter.py
blob: 197b26ce49cd6072b53fbccda022c41f3cb3768a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
#!/usr/bin/env python

'''

* This is a work in progress *

Reproducer Rewriter
===================

Processes a project and rewrites it so as to generate deterministic,
reproducible automatic results. For example, you can run this on a
game, and then when the game is run it will record user input and
sources of nondeterminism like Math.random(). You can then run
that recording as a benchmark or as a demo, it should give nearly
identical results every time it is run to the data that was
recorded.

Usage:

1. Run this script as

    reproduceriter.py IN_DIR OUT_DIR FIRST_JS [WINDOW_LOCATION] [ON_IDLE]

   IN_DIR should be the project directory, and OUT_DIR will be
   created with the instrumented code (OUT_DIR will be overwritten
   if it exists). FIRST_JS should be a path (relative to IN_DIR) to
   the first JavaScript file loaded by the project (this tool
   will add code to that). 

   You will need to call

    Recorder.start();

   at the right time to start the relevant event loop. For
   example, if your application is a game that starts receiving
   events when in fullscreen, add something like

    if (typeof Recorder != 'undefined') Recorder.start();

   in the button that launches fullscreen. start() will start
   either recording when in record mode, or replaying when
   in replay mode, so you need this in both modes.

2. Run the instrumented project in OUR_DIR and interact with
   the program. When you are done recording, open the web
   console and run

    Recorder.finish();

   This will write out the recorded data into the current tab.
   Save it as

    repro.data

   in OUT_DIR.

3. To re-play the recorded data, run the instrumented build
   with

    &reproduce=repro.data

   Note that as mentioned above you need to call

    Recorder.start();

   when the recorded event loop should start to replay.

Notes:

 * When we start to replay events, the assumption is that
   there is nothing asynchronous that affects execution. So
   asynchronous loading of files should have already
   completed.

   TODO: start running recorded events with some trigger, for example the fullscreen button in BananaBread

Examples

 * BananaBread: Unpack into a directory called bb, then one
   directory up, run

    emscripten/tools/reproduceriter.py bb bench js/game-setup.js game.html?low,low,reproduce=repro.data "function(){ print('triggering click'); document.querySelector('.fullscreen-button.low-res').callEventListeners('click'); window.onIdle = null; }"

   The last parameter specifies what to do when the event loop is idle: We fire an event and then set onIdle (which was this function) to null, so this is a one-time occurence.
'''

import os, sys, shutil, re

assert len(sys.argv) >= 4, 'Usage: reproduceriter.py IN_DIR OUT_DIR FIRST_JS [WINDOW_LOCATION]'

# Process input args

in_dir = sys.argv[1]
out_dir = sys.argv[2]
first_js = sys.argv[3]
window_location = sys.argv[4] if len(sys.argv) >= 5 else ''
on_idle = sys.argv[5] if len(sys.argv) >= 6 else ''

dirs_to_drop = 0 if not os.path.dirname(first_js) else len(os.path.dirname(first_js).split('/'))

if os.path.exists(out_dir):
  shutil.rmtree(out_dir)
assert os.path.exists(os.path.join(in_dir, first_js))

# Copy project

print 'copying tree...'

shutil.copytree(in_dir, out_dir)

# Add customizations in all JS files

print 'add customizations...'

for parent, dirs, files in os.walk(out_dir):
  for filename in files:
    if filename.endswith('.js'):
      fullname = os.path.join(parent, filename)
      print '   ', fullname
      js = open(fullname).read()
      js = re.sub('document\.on(\w+) ?= ?([\w.$]+)', lambda m: 'Recorder.onEvent("' + m.group(1) + '", ' + m.group(2) + ')', js)
      js = re.sub('''([\w.'"\[\]]+)\.addEventListener\(([\w,. $]+)\)''', lambda m: 'Recorder.addListener(' + m.group(1) + ', ' + m.group(2) + ')', js)
      open(fullname, 'w').write(js)

# Add our boilerplate

print 'add boilerplate...'

open(os.path.join(out_dir, first_js), 'w').write('''

// environment for shell
if (typeof nagivator == 'undefined') {
''' + #open(os.path.dirname(__file__) + os.path.sep + 'dom.js').read() +
'''
///*
  var window = {
    location: {
      toString: function() {
        return '%s';
      },
      search: '?%s',
    },
    fakeNow: 0, // we don't use Date.now()
    rafs: [],
    timeouts: [],
    uid: 0,
    requestAnimationFrame: function(func) {
      func.uid = window.uid++;
      print('adding raf ' + func.uid);
      window.rafs.push(func);
    },
    setTimeout: function(func, ms) {
      func.uid = window.uid++;
      print('adding timeout ' + func.uid);
      window.timeouts.push({
        func: func,
        when: window.fakeNow + (ms || 0)
      });
      window.timeouts.sort(function(x, y) { return y.when - x.when });
    },
    onIdle: %s,
    runEventLoop: function() {
      // run forever until an exception stops this replay
      var i = 0;
      while (1) {
        print('event loop: ' + (i++));
        if (window.rafs.length == 0 && window.timeouts.length == 0) {
          if (window.onIdle) {
            window.onIdle();
          } else {
            throw 'main loop is idle!';
          }
        }
        // rafs
        var currRafs = window.rafs;
        window.rafs = [];
        for (var i = 0; i < currRafs.length; i++) {
          var raf = currRafs[i];
          print('calling raf: ' + raf.uid + ': ' + raf.toString().substring(0, 50));
          raf();
        }
        // timeouts
        var now = window.fakeNow;
        while (window.timeouts.length && window.timeouts[window.timeouts.length-1].when <= now) {
          var timeout = window.timeouts.pop();
          print('calling timeout: ' + timeout.func.uid + ': ' + timeout.func.toString().substring(0, 50));
          timeout.func();
        }
        // increment 'time'
        window.fakeNow += 16.666;
      }
    },
    URL: {
      createObjectURL: function(x) {
        return x; // the blob itself is returned
      },
      revokeObjectURL: function(x) {},
    },
  };
  var setTimeout = window.setTimeout;
  var document = {
    eventListeners: {},
    addEventListener: function(id, func) {
      var listeners = this.eventListeners[id];
      if (!listeners) {
        listeners = this.eventListeners[id] = [];
      }
      listeners.push(func);
    },
    callEventListeners: function(id) {
      var listeners = this.eventListeners[id];
      if (listeners) {
        listeners.forEach(function(listener) { listener() });
      }
    },
    getElementById: function(id) {
      switch(id) {
        case 'canvas': {
          return {
            getContext: function(which) {
              switch(which) {
                case 'experimental-webgl': {
                  return {
                    getExtension: function() { return 1 },
                  };
                }
                case '2d': {
                  return {
                    drawImage: function(){},
                  };
                }
                default: throw 'canvas.getContext: ' + which;
              }
            },
            requestPointerLock: function() {
              document.callEventListeners('pointerlockchange');
            },
          };
        }
        case 'status-text': case 'progress': {
          return {};
        }
        default: throw 'getElementById: ' + id;
      }
    },
    createElement: function(what) {
      switch (what) {
        case 'canvas': return document.getElementById(what);
        case 'script': {
          var ret = {};
          window.setTimeout(function() {
            print('loading script: ' + ret.src);
            load(ret.src);
            print('   script loaded.');
            if (ret.onload) {
              window.setTimeout(function() {
                ret.onload(); // yeah yeah this might vanish
              });
            }
          });
          return ret;
        }
        default: throw 'createElement ' + what;
      }
    },
    elements: {},
    querySelector: function(id) {
      if (!document.elements[id]) {
        document.elements[id] = {
          classList: {
            add: function(){},
            remove: function(){},
          },
          eventListeners: {},
          addEventListener: document.addEventListener,
          callEventListeners: document.callEventListeners,
        };
      };
      return document.elements[id];
    },
    styleSheets: [{
      cssRules: [],
      insertRule: function(){},
    }],
    body: {
      appendChild: function(){},
    },
  };
//*/
  var alert = function(x) {
    print(x);
  };
  var performance = {
    now: function() {
      print('performance.now! ' + new Error().stack);
      return Date.now(); // XXX XXX XXX
    },
  };
  function fixPath(path) {
    if (path[0] == '/') path = path.substring(1);
    var dirsToDrop = %d; // go back to root dir if first_js is in a subdir
    for (var i = 0; i < dirsToDrop; i++) {
      path = '../' + path;
    }
    return path
  }
  var XMLHttpRequest = function() {
    return {
      open: function(mode, path, async) {
        path = fixPath(path);
        this.mode = mode;
        this.path = path;
        this.async = async;
      },
      send: function() {
        if (!this.async) {
          this.doSend();
        } else {
          var that = this;
          window.setTimeout(function() {
            that.doSend();
            if (that.onload) that.onload();
          }, 0);
        }
      },
      doSend: function() {
        if (this.responseType == 'arraybuffer') {
          this.response = read(this.path, 'binary');
        } else {
          this.responseText = read(this.path);
        }
      },
    };
  };
  var Audio = function() {
    return { play: function(){} };
  };
  var Image = function() {
    var that = this;
    window.setTimeout(function() {
      that.complete = true;
      that.width = 64;
      that.height = 64;
      if (that.onload) that.onload();
    });
  };
  var Worker = function(workerPath) {
    workerPath = fixPath(workerPath);
    var workerCode = read(workerPath);
    workerCode = workerCode.replace(/Module/g, 'zzModuleyy' + (Worker.id++)). // prevent collision with the global Module object. Note that this becomes global, so we need unique ids
                            replace(/Date.now/g, 'Recorder.dnow'). // recorded values are just for the "main thread" - workers were not recorded, and should not consume
                            replace(/performance.now/g, 'Recorder.pnow').
                            replace(/Math.random/g, 'Recorder.random').
                            replace(/\\nonmessage = /, '\\nvar onmessage = '); // workers commonly do "onmessage = ", we need to varify that to sandbox
    print('loading worker ' + workerPath + ' : ' + workerCode.substring(0, 50));
    eval(workerCode); // will implement onmessage()

    function duplicateJSON(json) {
      function handleTypedArrays(key, value) {
        if (value && value.toString && value.toString().substring(0, 8) == '[object ' && value.length && value.byteLength) {
          return Array.prototype.slice.call(value);
        }
        return value;
      }
      return JSON.parse(JSON.stringify(json, handleTypedArrays))
    }
    this.terminate = function(){};
    this.postMessage = function(msg) {
      msg.messageId = Worker.messageId++;
      print('main thread sending message ' + msg.messageId + ' to worker ' + workerPath);
      window.setTimeout(function() {
        print('worker ' + workerPath + ' receiving message ' + msg.messageId);
        onmessage({ data: duplicateJSON(msg) });
      });
    };
    var thisWorker = this;
    var postMessage = function(msg) {
      msg.messageId = Worker.messageId++;
      print('worker ' + workerPath + ' sending message ' + msg.messageId);
      window.setTimeout(function() {
        print('main thread receiving message ' + msg.messageId + ' from ' + workerPath);
        thisWorker.onmessage({ data: duplicateJSON(msg) });
      });
    };
  };
  Worker.id = 0;
  Worker.messageId = 0;
  var screen = {
    width: 800,
    height: 600,
    availWidth: 800,
    availHeight: 600,
  };
  var console = {
    log: function(x) {
      print(x);
    },
  };
  var MozBlobBuilder = function() {
    this.data = new Uint8Array(0);
    this.append = function(buffer) {
      var data = new Uint8Array(buffer);
      var combined = new Uint8Array(this.data.length + data.length);
      combined.set(this.data);
      combined.set(data, this.data.length);
      this.data = combined;
    };
    this.getBlob = function() {
      return this.data.buffer; // return the buffer as a "blob". XXX We might need to change this if it is not opaque
    };
  };
}

var Recorder = (function() {
  var recorder;
  var init = 'reproduce=';
  var initLocation = window.location.search.indexOf(init);
  var replaying = initLocation >= 0;
  var raf = window['requestAnimationFrame'] ||
            window['mozRequestAnimationFrame'] ||
            window['webkitRequestAnimationFrame'] ||
            window['msRequestAnimationFrame'] ||
            window['oRequestAnimationFrame'];
  if (!replaying) {
    // Prepare to record
    recorder = {};
    // Start
    recorder.frameCounter = 0; // the frame counter is used to know when to replay events
    recorder.start = function() {
      alert("Starting recording! Don't forget to Recorder.finish() afterwards!");
      function count() {
        recorder.frameCounter++;
        raf(count);
      }
      count();
      recorder.started = true;
    };
    // Math.random
    recorder.randoms = [];
    recorder.random = Math.random;
    Math.random = function() {
      var ret = recorder.random();
      recorder.randoms.push(ret);
      return ret;
    };
    // Date.now, performance.now
    recorder.dnows = [];
    recorder.dnow = Date.now;
    Date.now = function() {
      var ret = recorder.dnow();
      recorder.dnows.push(ret);
      return ret;
    };
    recorder.pnows = [];
    recorder.pnow = performance.now;
    performance.now = function() {
      var ret = recorder.pnow();
      recorder.pnows.push(ret);
      return ret;
    };
    // Events
    recorder.devents = []; // document events
    recorder.onEvent = function(which, callback) {
      document['on' + which] = function(event) {
        if (!recorder.started) return true;
        event.frameCounter = recorder.frameCounter;
        recorder.devents.push(event);
        return callback(event); // XXX do we need to record the return value?
      };
    };
    recorder.tevents = []; // custom-target events. Currently we assume a single such custom target (aside from document), e.g., a canvas for the game.
    recorder.addListener = function(target, which, callback, arg) {
      target.addEventListener(which, function(event) {
        if (!recorder.started) return true;
        event.frameCounter = recorder.frameCounter;
        recorder.tevents.push(event);
        return callback(event); // XXX do we need to record the return value?
      }, arg);
    };
    // Finish
    recorder.finish = function() {
      // Reorder data because pop() is faster than shift()
      recorder.randoms.reverse();
      recorder.dnows.reverse();
      recorder.pnows.reverse();
      recorder.devents.reverse();
      recorder.tevents.reverse();
      // Make JSON.stringify work on data from native event objects (and only store relevant ones)
      var importantProperties = {
        type: 1,
        movementX: 1, mozMovementX: 1, webkitMovementX: 1,
        movementY: 1, mozMovementY: 1, webkitMovementY: 1,
        detail: 1,
        wheelDelta: 1,
        pageX: 1,
        pageY: 1,
        button: 1,
        keyCode: 1,
        frameCounter: 1
      };
      function importantize(event) {
        var ret = {};
        for (var prop in importantProperties) {
          if (prop in event) {
            ret[prop] = event[prop];
          }
        }
        return ret;
      }
      recorder.devents = recorder.devents.map(importantize);
      recorder.tevents = recorder.tevents.map(importantize);
      // Write out
      alert('Writing out data, remember to save!');
      setTimeout(function() {
        document.open();
        document.write(JSON.stringify(recorder));
        document.close();
      }, 0);
      return '.';
    };
  } else {
    // Load recording
    var dataPath = window.location.search.substring(initLocation + init.length);
    var baseURL = window.location.toString().replace('://', 'cheez999').split('?')[0].split('/').slice(0, -1).join('/').replace('cheez999', '://');
    if (baseURL[baseURL.length-1] != '/') baseURL += '/';
    var path = baseURL + dataPath;
    alert('Loading replay from ' + path);
    var request = new XMLHttpRequest();
    request.open('GET', path, false);
    request.send();
    var raw = request.responseText;
    raw = raw.substring(raw.indexOf('{'), raw.lastIndexOf('}')+1); // remove <html> etc
    recorder = JSON.parse(raw);
    // prepare to replay
    // Start
    recorder.frameCounter = 0; // the frame counter is used to know when to replay events
    recorder.start = function() {
      function count() {
        recorder.frameCounter++;
        raf(count);
        // replay relevant events for this frame
        while (recorder.devents.length && recorder.devents[recorder.devents.length-1].frameCounter <= recorder.frameCounter) {
          var event = recorder.devents.pop();
          recorder['on' + event.type](event);
        }
        while (recorder.tevents.length && recorder.tevents[recorder.tevents.length-1].frameCounter <= recorder.frameCounter) {
          var event = recorder.tevents.pop();
          recorder['event' + event.type](event);
        }
      }
      count();
    };
    // Math.random
    recorder.random = Math.random;
    Math.random = function() {
      if (recorder.randoms.length > 0) {
        return recorder.randoms.pop();
      } else {
        recorder.finish();
        throw 'consuming too many values!';
      }
    };
    // Date.now, performance.now
    recorder.dnow = Date.now;
    Date.now = function() {
      if (recorder.dnows.length > 0) {
        return recorder.dnows.pop();
      } else {
        recorder.finish();
        throw 'consuming too many values!';
      }
    };
    var pnow = performance.now || performance.webkitNow || performance.mozNow || performance.oNow || performance.msNow || dnow;
    recorder.pnow = function() { return pnow.call(performance) };
    performance.now = function() {
      if (recorder.pnows.length > 0) {
        return recorder.pnows.pop();
      } else {
        recorder.finish();
        throw 'consuming too many values!';
      }
    };
    // Events
    recorder.onEvent = function(which, callback) {
      recorder['on' + which] = callback;
    };
    recorder.eventCallbacks = {};
    recorder.addListener = function(target, which, callback, arg) {
      recorder['event' + which] = callback;
    };
    recorder.onFinish = [];
    // Benchmarking hooks - emscripten specific
    setTimeout(function() {
      var totalTime = 0;
      var totalSquared = 0;
      var iterations = 0;
      var maxTime = 0;
      var curr = 0;
      Module.preMainLoop = function() {
        curr = recorder.pnow();
      }
      Module.postMainLoop = function() {
        var time = recorder.pnow() - curr;
        totalTime += time;
        totalSquared += time*time;
        maxTime = Math.max(maxTime, time);
        iterations++;
      };
      recorder.onFinish.push(function() {
        var mean = totalTime / iterations;
        var meanSquared = totalSquared / iterations;
        console.log('mean frame   : ' + mean + ' ms');
        console.log('frame std dev: ' + Math.sqrt(meanSquared - (mean*mean)) + ' ms');
        console.log('max frame    : ' + maxTime + ' ms');
      });    
    });
    // Finish
    recorder.finish = function() {
      recorder.onFinish.forEach(function(finish) {
        finish();
      });
    };
  }
  recorder.replaying = replaying;
  return recorder;
})();
''' % (window_location, window_location.split('?')[-1], on_idle or 'null', dirs_to_drop) + open(os.path.join(in_dir, first_js)).read() + '''
if (typeof nagivator == 'undefined') {
  window.runEventLoop();
}
''')

print 'done!'