blob: 2beab2ca52e82eab89704481a58dcdf1486cd955 [file] [log] [blame]
import { DebugTrace, SlotInfo } from '../debug-trace/debug-trace';
// The TraceOp enum must stay in sync with SkSL::SkVMTraceInfo::Op.
enum TraceOp {
Line = 0,
Var = 1,
Enter = 2,
Exit = 3,
Scope = 4,
}
// The NumberKind enum must stay in sync with SkSL::Type::NumberKind.
enum NumberKind {
Float = 0,
Signed = 1,
Unsigned = 2,
Boolean = 3,
Nonnumeric = 4,
}
// Trace data comes in from the JSON as a number[]. We unpack it into a TraceInfo for ease of use.
type TraceInfo = {
op: TraceOp;
data: number[];
};
type StackFrame = {
// A FunctionInfo from trace.functions.
func: number;
// The current line number within the function.
line: number;
// Any variable slots which have been touched in this function.
displayMask: boolean[];
};
type Slot = {
// The current raw value held in this slot (as a 32-bit integer, not bit-punned).
value: number;
// The scope depth associated with this slot (as indicated by trace_scope).
scope: number;
// When was the variable in this slot most recently written? (as a cursor position)
writeTime: number;
};
export type VariableData = {
// A SlotInfo from trace.slots.
slotIndex: number;
// Has this slot been written-to since the last step call?
dirty: boolean;
// The current value held in this slot (properly bit-punned/cast to the expected type)
value: number | boolean;
};
export class DebugTracePlayer {
private trace: DebugTrace | null = null;
// The position of the read head within the trace array.
private cursor: number = 0;
// Tracks the current scope depth (as indicated by trace_scope).
private scope: number = 0;
// Tracks assignments into our data slots.
private slots: Slot[] = [];
// Tracks the trace stack (as indicated by trace_enter and trace_exit).
private stack: StackFrame[] = []; // the execution stack
// Tracks which line numbers are reached by the trace, and the number of times it's reached.
private lineNumbers: Map<number, number> = new Map();
// Tracks all the data slots which have been touched during the current step.
private dirtyMask: boolean[] = [];
// Tracks all the data slots which hold function return values.
private returnValues: boolean[] = [];
// Tracks line numbers that have breakpoints set on them.
private breakpointLines: Set<number> = new Set();
/** Throws an error if a precondition is not met. Indicates a logic bug or invalid trace. */
private check(result: boolean): void {
if (!result) {
throw new Error('check failed');
}
}
/** Copies trace info from the JSON number array into a TraceInfo struct. */
private getTraceInfo(position: number): TraceInfo {
this.check(position < this.trace!.trace.length);
this.check(this.trace!.trace[position][0] in TraceOp);
const info: TraceInfo = {
op: this.trace!.trace[position][0] as TraceOp,
data: this.trace!.trace[position].slice(1),
};
return info;
}
/** Resets playback to the start of the trace. Breakpoints are not cleared. */
public reset(trace: DebugTrace | null): void {
const nslots = trace?.slots?.length ?? 0;
const globalStackFrame: StackFrame = {
func: -1,
line: -1,
displayMask: Array<boolean>(nslots).map(() => (false)),
};
this.trace = trace;
this.cursor = 0;
this.slots = [];
this.stack = [globalStackFrame];
this.dirtyMask = Array<boolean>(nslots).map(() => (false));
this.returnValues = Array<boolean>(nslots).map(() => (false));
if (trace !== null) {
this.slots = trace.slots.map((): Slot => ({
value: 0,
scope: Infinity,
writeTime: 0,
}));
this.returnValues = trace.slots.map((slotInfo: SlotInfo): boolean =>
(slotInfo.retval ?? -1) >= 0
);
// Build a map holding the number of times each line is reached.
this.lineNumbers.clear();
trace.trace.forEach((_, traceIdx: number) => {
const info: TraceInfo = this.getTraceInfo(traceIdx);
if (info.op === TraceOp.Line) {
const lineNumber = info.data[0];
const lineCount = this.lineNumbers.get(lineNumber) ?? 0;
this.lineNumbers.set(lineNumber, lineCount + 1);
}
});
}
}
/** Advances the simulation to the next Line op. */
public step(): void {
this.tidyState();
while (!this.traceHasCompleted()) {
if (this.execute(this.cursor++)) {
break;
}
}
}
/**
* Advances the simulation to the next Line op, skipping past matched Enter/Exit pairs.
* Breakpoints will also stop the simulation even if we haven't reached an Exit.
*/
public stepOver(): void {
this.tidyState();
const initialStackDepth = this.stack.length;
while (!this.traceHasCompleted()) {
const canEscapeFromThisStackDepth = (this.stack.length <= initialStackDepth);
if (this.execute(this.cursor++)) {
if (canEscapeFromThisStackDepth || this.atBreakpoint()) {
break;
}
}
}
}
public stepOut() : void {
this.tidyState();
const initialStackDepth = this.stack.length;
while (!this.traceHasCompleted()) {
if (this.execute(this.cursor++)) {
const hasEscapedFromInitialStackDepth = (this.stack.length < initialStackDepth);
if (hasEscapedFromInitialStackDepth || this.atBreakpoint()) {
break;
}
}
}
}
public run() : void {
this.tidyState();
while (!this.traceHasCompleted()) {
if (this.execute(this.cursor++)) {
if (this.atBreakpoint()) {
break;
}
}
}
}
/**
* Cleans up temporary state between steps, such as the dirty mask and function return values.
*/
private tidyState(): void {
this.dirtyMask.fill(false);
const stackTop = this.stack[this.stack.length - 1];
this.returnValues.forEach((_, slotIdx: number) => {
stackTop.displayMask[slotIdx] &&= !this.returnValues[slotIdx];
});
}
/** Returns true if we have reached the end of the trace. */
public traceHasCompleted(): boolean {
return (this.trace == null) || (this.cursor >= this.trace.trace.length);
}
/** Reports the position of the cursor "read head" within the array of trace instructions. */
public getCursor(): number {
return this.cursor;
}
/** Returns true if the current line has a breakpoint set on it. */
public atBreakpoint(): boolean {
return this.breakpointLines.has(this.getCurrentLine());
}
/** Replaces all current breakpoints with a new set of them. */
public setBreakpoints(breakpointLines: Set<number>): void {
this.breakpointLines = breakpointLines;
}
/** Returns the current set of lines which have a breakpoint. */
public getBreakpoints(): Set<number> {
return this.breakpointLines;
}
/** Adds a breakpoint to a line (if one doesn't exist). */
public addBreakpoint(line: number): void {
this.breakpointLines.add(line);
}
/** Removes a breakpoint from a line (if one exists). */
public removeBreakpoint(line: number): void {
this.breakpointLines.delete(line);
}
/** Retrieves the current line. */
public getCurrentLine(): number {
this.check(this.stack.length > 0);
return this.stack[this.stack.length - 1].line;
}
/**
* Returns every line number reached inside this debug trace, along with the remaining number of
* times that this trace will reach it. e.g. {100, 2} means line 100 will be reached twice.
*/
public getLineNumbersReached(): Map<number, number> {
return this.lineNumbers;
}
/** Returns the call stack as an array of FunctionInfo indices. */
public getCallStack(): number[] {
this.check(this.stack.length > 0);
return this.stack.slice(1).map((frame: StackFrame) => {
return frame.func;
});
}
/** Returns the size of the call stack. */
public getStackDepth(): number {
this.check(this.stack.length > 0);
return this.stack.length - 1;
}
/** Returns a slot's component as a variable-name suffix, e.g. ".x" or "[2][2]". */
public getSlotComponentSuffix(slotIndex: number): string {
const slot: SlotInfo = this.trace!.slots[slotIndex];
if (slot.rows > 1) {
return "[" + Math.floor(slot.index / slot.rows) + "][" + slot.index % slot.rows + "]";
}
if (slot.columns > 1) {
switch (slot.index) {
case 0: return '.x';
case 1: return '.y';
case 2: return '.z';
case 3: return '.w';
default: return '[???]';
}
}
return '';
}
/** Bit-casts a value for a given slot into a double, honoring the slot's NumberKind. */
private interpretValueBits(slotIdx: number, valueBits: number): number | boolean {
const bitArray: Int32Array = new Int32Array(1);
bitArray[0] = valueBits;
switch (this.trace!.slots[slotIdx].kind) {
case NumberKind.Float: return new Float32Array(bitArray.buffer)[0];
case NumberKind.Unsigned: return new Uint32Array(bitArray.buffer)[0];
case NumberKind.Boolean: return (valueBits !== 0);
case NumberKind.Signed: return valueBits;
default: return valueBits;
}
}
/** Returns a vector of the indices and values of each slot that is enabled in `bits`. */
private getVariablesForDisplayMask(displayMask: boolean[]): VariableData[] {
this.check(displayMask.length === this.slots.length);
let vars: VariableData[] = [];
displayMask.forEach((_, slot: number) => {
if (displayMask[slot]) {
const varData: VariableData = {
slotIndex: slot,
dirty: this.dirtyMask[slot],
value: this.interpretValueBits(slot, this.slots[slot].value),
};
vars.push(varData);
}
});
// Order the variable list so that the most recently-written variables are shown at the top.
vars = vars.sort((a: VariableData, b: VariableData) => {
// Order by descending write-time.
const delta = this.slots[b.slotIndex].writeTime - this.slots[a.slotIndex].writeTime;
if (delta !== 0) {
return delta;
}
// If write times match, order by ascending slot index (preserving the existing order).
return a.slotIndex - b.slotIndex;
});
return vars;
}
/** Returns the variables in a given stack frame. */
public getLocalVariables(stackFrameIndex: number): VariableData[] {
// The first entry on the stack is the "global" frame before we enter main, so offset our index
// by one to account for it.
++stackFrameIndex;
this.check(stackFrameIndex > 0);
this.check(stackFrameIndex <= this.stack.length);
return this.getVariablesForDisplayMask(this.stack[stackFrameIndex].displayMask);
}
/** Returns the variables at global scope. */
public getGlobalVariables(): VariableData[] {
if (this.stack.length < 1) {
return [];
}
return this.getVariablesForDisplayMask(this.stack[0].displayMask);
}
/** Updates fWriteTime for the entire variable at a given slot. */
private updateVariableWriteTime(slotIdx: number, cursor: number): void {
// The slotIdx could point to any slot within a variable.
// We want to update the write time on EVERY slot associated with this variable.
// The SlotInfo gives us enough information to find the affected range.
const changedSlot = this.trace!.slots[slotIdx];
slotIdx -= changedSlot.index;
const lastSlotIdx = slotIdx + (changedSlot.columns * changedSlot.rows);
for (; slotIdx < lastSlotIdx; ++slotIdx) {
this.slots[slotIdx].writeTime = cursor;
}
}
/**
* Executes the trace op at the passed-in cursor position. Returns true if we've reached a line
* or exit trace op, which indicate a stopping point.
*/
private execute(position: number): boolean {
const trace = this.getTraceInfo(position);
this.check(this.stack.length > 0);
const stackTop: StackFrame = this.stack[this.stack.length - 1];
switch (trace.op) {
case TraceOp.Line: { // data: line number, (unused)
const lineNumber = trace.data[0];
const lineCount = this.lineNumbers.get(lineNumber) ?? 0;
this.check(lineNumber >= 0);
this.check(lineNumber < this.trace!.source.length);
this.check(lineCount > 0);
stackTop.line = lineNumber;
this.lineNumbers.set(lineNumber, lineCount - 1);
return true;
}
case TraceOp.Var: { // data: slot, value
const slotIdx = trace.data[0];
const value = trace.data[1];
this.check(slotIdx >= 0);
this.check(slotIdx < this.slots.length);
this.slots[slotIdx].value = value;
this.slots[slotIdx].scope = Math.min(this.slots[slotIdx].scope, this.scope);
this.updateVariableWriteTime(slotIdx, position);
if ((this.trace!.slots[slotIdx].retval ?? -1) < 0) {
// Normal variables are associated with the current function.
stackTop.displayMask[slotIdx] = true;
} else {
// Return values are associated with the parent function (since the current function
// is exiting and we won't see them there).
this.check(this.stack.length > 1);
this.stack[this.stack.length - 2].displayMask[slotIdx] = true;
}
this.dirtyMask[slotIdx] = true;
break;
}
case TraceOp.Enter: { // data: function index, (unused)
const fnIdx = trace.data[0];
this.check(fnIdx >= 0);
this.check(fnIdx < this.trace!.functions.length);
const enteredStackFrame: StackFrame = {
func: fnIdx,
line: -1,
displayMask: Array<boolean>(this.slots.length).fill(false),
};
this.stack.push(enteredStackFrame);
break;
}
case TraceOp.Exit: { // data: function index, (unused)
const fnIdx = trace.data[0];
this.check(stackTop.func === fnIdx);
this.stack.pop();
return true;
}
case TraceOp.Scope: { // data: scope delta, (unused)
const scopeDelta = trace.data[0];
this.scope += scopeDelta;
if (scopeDelta < 0) {
// If the scope is being reduced, discard variables that are now out of scope.
this.slots.forEach((_, slotIdx: number) => {
if (this.scope < this.slots[slotIdx].scope) {
this.slots[slotIdx].scope = Infinity;
stackTop.displayMask[slotIdx] = false;
}
});
}
break;
}
default: {
throw new Error('unrecognized trace instruction');
}
}
return false;
}
}