forked from openrs2/openrs2
Adds an implementation of an interpreter that converts JVM bytecode to expression trees and statements as it goes, handling stack manipiulation instructions on the way. Signed-off-by: Gary Tierney <gary.tierney@fastmail.com>feat/deob-ir
parent
1d64a932bb
commit
f4d0d26a87
@ -0,0 +1,27 @@ |
|||||||
|
package dev.openrs2.deob.ir |
||||||
|
|
||||||
|
import com.google.common.graph.MutableValueGraph |
||||||
|
import dev.openrs2.deob.ir.flow.ControlFlowTransfer |
||||||
|
|
||||||
|
typealias BasicBlockGraph = MutableValueGraph<BasicBlock, ControlFlowTransfer> |
||||||
|
|
||||||
|
class BasicBlock( |
||||||
|
internal val graph: BasicBlockGraph |
||||||
|
) { |
||||||
|
val statements = mutableListOf<Stmt>() |
||||||
|
|
||||||
|
fun successors(): Set<BasicBlock> = graph.successors(this) |
||||||
|
fun predecessors(): Set<BasicBlock> = graph.predecessors(this) |
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean { |
||||||
|
return this === other |
||||||
|
} |
||||||
|
|
||||||
|
override fun hashCode(): Int { |
||||||
|
return System.identityHashCode(this) |
||||||
|
} |
||||||
|
|
||||||
|
override fun toString(): String { |
||||||
|
return "BasicBlock(expressions=\n${statements.joinToString("\n")}\n)" |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,52 @@ |
|||||||
|
package dev.openrs2.deob.ir |
||||||
|
|
||||||
|
import dev.openrs2.asm.MemberRef |
||||||
|
import org.objectweb.asm.Type |
||||||
|
|
||||||
|
sealed class Storage |
||||||
|
|
||||||
|
class LocalVarStorage(val slot: Int = -1) : Storage() |
||||||
|
class FieldStorage(val instance: Expr, val member: MemberRef) : Storage() |
||||||
|
class StaticStorage(val member: MemberRef) : Storage() |
||||||
|
|
||||||
|
sealed class UnaryOp { |
||||||
|
object Negate : UnaryOp() |
||||||
|
data class InstanceOf(val type: Type) : UnaryOp() |
||||||
|
data class Cast(val type: Type) : UnaryOp() |
||||||
|
} |
||||||
|
|
||||||
|
enum class BinOp { |
||||||
|
Equals, |
||||||
|
NotEquals, |
||||||
|
LessThan, |
||||||
|
GreaterThanOrEquals, |
||||||
|
GreaterThan, |
||||||
|
LessThanOrEquals, |
||||||
|
Remainder, |
||||||
|
Divide, |
||||||
|
Multiply, |
||||||
|
Add, |
||||||
|
Subtract, |
||||||
|
ShiftLeft, |
||||||
|
ShiftRight, |
||||||
|
UnsignedShiftRight, |
||||||
|
ExclusiveOr |
||||||
|
} |
||||||
|
|
||||||
|
data class BinaryExpr(val lhs: Expr, val rhs: Expr, val op: dev.openrs2.deob.ir.BinOp) : Expr() |
||||||
|
|
||||||
|
sealed class Expr |
||||||
|
|
||||||
|
data class IndexExpr(val array: Expr, val index: Expr) : Expr() |
||||||
|
|
||||||
|
data class UnaryExpr(val operand: Expr, val operator: UnaryOp) : Expr() |
||||||
|
|
||||||
|
data class ConstExpr(val value: Any?) : Expr() |
||||||
|
|
||||||
|
data class CallExpr(val instance: Expr?, val method: MemberRef, val arguments: List<Expr>) : Expr() |
||||||
|
|
||||||
|
data class VarExpr(val storage: Storage) : Expr() { |
||||||
|
companion object { |
||||||
|
fun local(slot: Int) = VarExpr(LocalVarStorage(slot)) |
||||||
|
} |
||||||
|
} |
@ -1,11 +1,11 @@ |
|||||||
package dev.openrs2.deob.ir |
package dev.openrs2.deob.ir |
||||||
|
|
||||||
import com.google.common.graph.MutableGraph |
import com.google.common.graph.MutableValueGraph |
||||||
import dev.openrs2.deob.ir.flow.BasicBlock |
import dev.openrs2.deob.ir.flow.ControlFlowTransfer |
||||||
import org.objectweb.asm.tree.ClassNode |
import org.objectweb.asm.tree.ClassNode |
||||||
import org.objectweb.asm.tree.MethodNode |
import org.objectweb.asm.tree.MethodNode |
||||||
|
|
||||||
class Method(val owner: ClassNode, val method: MethodNode, val entry: BasicBlock) { |
class Method(val owner: ClassNode, val method: MethodNode, val entry: BasicBlock) { |
||||||
val cfg: MutableGraph<BasicBlock> |
val cfg: MutableValueGraph<BasicBlock, ControlFlowTransfer> |
||||||
get() = entry.graph |
get() = entry.graph |
||||||
} |
} |
||||||
|
@ -0,0 +1,22 @@ |
|||||||
|
package dev.openrs2.deob.ir |
||||||
|
|
||||||
|
sealed class Stmt { |
||||||
|
fun accept(visitor: StmtVisitor) = when (this) { |
||||||
|
is AssignmentStmt -> visitor.visitAssignmen(slot, expr) |
||||||
|
is CallStmt -> visitor.visitCall(expr) |
||||||
|
is IfStmt -> visitor.visitIf(expr) |
||||||
|
is ReturnStmt -> visitor.visitReturn(expr) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
data class AssignmentStmt(val slot: VarExpr, val expr: Expr) : Stmt() |
||||||
|
data class CallStmt(val expr: CallExpr) : Stmt() |
||||||
|
data class IfStmt(val expr: BinaryExpr) : Stmt() |
||||||
|
data class ReturnStmt(val expr: Expr?) : Stmt() |
||||||
|
|
||||||
|
interface StmtVisitor { |
||||||
|
fun visitAssignmen(variable: VarExpr, value: Expr) |
||||||
|
fun visitCall(expr: CallExpr) |
||||||
|
fun visitIf(conditional: BinaryExpr) |
||||||
|
fun visitReturn(value: Expr?) |
||||||
|
} |
@ -1,48 +0,0 @@ |
|||||||
package dev.openrs2.deob.ir.flow |
|
||||||
|
|
||||||
import com.google.common.graph.MutableGraph |
|
||||||
import org.objectweb.asm.tree.InsnList |
|
||||||
import org.objectweb.asm.tree.LabelNode |
|
||||||
import org.objectweb.asm.util.Textifier |
|
||||||
import org.objectweb.asm.util.TraceMethodVisitor |
|
||||||
|
|
||||||
class BasicBlock( |
|
||||||
internal val graph: MutableGraph<BasicBlock>, |
|
||||||
private val code: InsnList, |
|
||||||
private var start: Int, |
|
||||||
private var end: Int |
|
||||||
) { |
|
||||||
fun successors() : Set<BasicBlock> = graph.successors(this) |
|
||||||
fun predecessors() : Set<BasicBlock> = graph.predecessors(this) |
|
||||||
|
|
||||||
var label: LabelNode? = null |
|
||||||
|
|
||||||
fun merge(other: BasicBlock) { |
|
||||||
for (succ in other.successors()) { |
|
||||||
graph.putEdge(this, succ) |
|
||||||
} |
|
||||||
|
|
||||||
graph.removeEdge(this, other) |
|
||||||
graph.removeNode(other) |
|
||||||
|
|
||||||
end = other.end |
|
||||||
} |
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean { |
|
||||||
return this === other |
|
||||||
} |
|
||||||
|
|
||||||
override fun hashCode(): Int { |
|
||||||
return System.identityHashCode(this) |
|
||||||
} |
|
||||||
|
|
||||||
override fun toString(): String { |
|
||||||
val textifier = Textifier() |
|
||||||
val methodTracer = TraceMethodVisitor(textifier) |
|
||||||
|
|
||||||
code.accept(methodTracer) |
|
||||||
|
|
||||||
val text = textifier.text.subList(start, end + 1) as List<String> |
|
||||||
return text.reduce { l, r -> l + r } |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,80 @@ |
|||||||
|
package dev.openrs2.deob.ir.flow |
||||||
|
|
||||||
|
import com.google.common.graph.Graph |
||||||
|
import com.google.common.graph.GraphBuilder |
||||||
|
import org.objectweb.asm.tree.MethodNode |
||||||
|
import org.objectweb.asm.tree.analysis.Analyzer |
||||||
|
import org.objectweb.asm.tree.analysis.BasicInterpreter |
||||||
|
import org.objectweb.asm.tree.analysis.BasicValue |
||||||
|
import java.util.Stack |
||||||
|
|
||||||
|
/** |
||||||
|
* A bytecode analyzer that produces a graph representing the basic control structure of a method. |
||||||
|
* |
||||||
|
* @param reduce if the graph should be reduced by coalescing all nodes with an immediate dominator |
||||||
|
* with only a single successor. |
||||||
|
*/ |
||||||
|
class ControlFlowAnalyzer(private val reduce: Boolean = false) : Analyzer<BasicValue>(BasicInterpreter()) { |
||||||
|
val instructionsToBlocks = mutableMapOf<Int, Int>() |
||||||
|
|
||||||
|
private val graph = GraphBuilder |
||||||
|
.directed() |
||||||
|
.allowsSelfLoops(true) |
||||||
|
.build<Int>() |
||||||
|
|
||||||
|
override fun newControlFlowEdge(insnIndex: Int, successorIndex: Int) { |
||||||
|
graph.putEdge(insnIndex, successorIndex) |
||||||
|
} |
||||||
|
|
||||||
|
override fun newControlFlowExceptionEdge(insnIndex: Int, successorIndex: Int): Boolean { |
||||||
|
graph.putEdge(insnIndex, successorIndex) |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
private fun reduceGraph() { |
||||||
|
val nodeQueue = Stack<Int>() |
||||||
|
val nodesVisited = mutableSetOf<Int>() |
||||||
|
|
||||||
|
nodeQueue.push(0) |
||||||
|
|
||||||
|
while (nodeQueue.isNotEmpty()) { |
||||||
|
// Coalesce all nodes that are immediately dominated and are the sole successor |
||||||
|
// of their dominator |
||||||
|
val current = nodeQueue.pop() |
||||||
|
val nextSuccessors = generateSequence { graph.successors(current).singleOrNull() }.iterator() |
||||||
|
|
||||||
|
for (successor in nextSuccessors) { |
||||||
|
val isImmediateDominator = current == graph.predecessors(successor).singleOrNull() |
||||||
|
|
||||||
|
if (!isImmediateDominator || successor == current) { |
||||||
|
break |
||||||
|
} |
||||||
|
|
||||||
|
for (domSuccessor in graph.successors(successor)) { |
||||||
|
graph.putEdge(current, domSuccessor) |
||||||
|
} |
||||||
|
|
||||||
|
graph.removeEdge(current, successor) |
||||||
|
graph.removeNode(successor) |
||||||
|
} |
||||||
|
|
||||||
|
for (successor in graph.successors(current)) { |
||||||
|
if (!nodesVisited.contains(successor)) { |
||||||
|
nodeQueue.push(successor) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
nodesVisited += current |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fun createGraph(owner: String, method: MethodNode): Graph<Int> { |
||||||
|
analyze(owner, method) |
||||||
|
|
||||||
|
if (reduce) { |
||||||
|
reduceGraph() |
||||||
|
} |
||||||
|
|
||||||
|
return graph |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,8 @@ |
|||||||
|
package dev.openrs2.deob.ir.flow |
||||||
|
|
||||||
|
sealed class ControlFlowTransfer { |
||||||
|
|
||||||
|
data class ConditionalJump(val successful: Boolean) : ControlFlowTransfer() |
||||||
|
|
||||||
|
object Goto : ControlFlowTransfer() |
||||||
|
} |
@ -1,7 +1,8 @@ |
|||||||
package dev.openrs2.deob.analysis |
package dev.openrs2.deob.ir.flow.data |
||||||
|
|
||||||
import com.google.common.graph.Graph |
import com.google.common.graph.Graph |
||||||
import com.google.common.graph.Graphs |
import com.google.common.graph.Graphs |
||||||
|
import dev.openrs2.deob.ir.flow.ControlFlowAnalyzer |
||||||
import org.objectweb.asm.tree.AbstractInsnNode |
import org.objectweb.asm.tree.AbstractInsnNode |
||||||
import org.objectweb.asm.tree.MethodNode |
import org.objectweb.asm.tree.MethodNode |
||||||
|
|
@ -1,4 +1,4 @@ |
|||||||
package dev.openrs2.deob.analysis |
package dev.openrs2.deob.ir.flow.data |
||||||
|
|
||||||
import org.objectweb.asm.Opcodes |
import org.objectweb.asm.Opcodes |
||||||
import org.objectweb.asm.tree.AbstractInsnNode |
import org.objectweb.asm.tree.AbstractInsnNode |
@ -0,0 +1,16 @@ |
|||||||
|
package dev.openrs2.deob.ir.translation |
||||||
|
|
||||||
|
import dev.openrs2.deob.ir.Method |
||||||
|
import dev.openrs2.deob.ir.translation.decompiler.IrAnalyzer |
||||||
|
import dev.openrs2.deob.ir.translation.decompiler.IrInterpreter |
||||||
|
import org.objectweb.asm.tree.ClassNode |
||||||
|
import org.objectweb.asm.tree.MethodNode |
||||||
|
|
||||||
|
class BytecodeToIrTranlator { |
||||||
|
fun decompile(owner: ClassNode, method: MethodNode): Method { |
||||||
|
val irAnalyzer = IrAnalyzer(IrInterpreter()) |
||||||
|
val entryBlock = irAnalyzer.decode(owner.name, method) |
||||||
|
|
||||||
|
return Method(owner, method, entryBlock) |
||||||
|
} |
||||||
|
} |
@ -1,89 +0,0 @@ |
|||||||
package dev.openrs2.deob.ir.translation |
|
||||||
|
|
||||||
import com.google.common.graph.GraphBuilder |
|
||||||
import dev.openrs2.deob.analysis.ControlFlowAnalyzer |
|
||||||
import dev.openrs2.deob.ir.Method |
|
||||||
import dev.openrs2.deob.ir.flow.BasicBlock |
|
||||||
import org.objectweb.asm.tree.ClassNode |
|
||||||
import org.objectweb.asm.tree.LabelNode |
|
||||||
import org.objectweb.asm.tree.MethodNode |
|
||||||
import org.objectweb.asm.tree.analysis.Analyzer |
|
||||||
import org.objectweb.asm.tree.analysis.BasicInterpreter |
|
||||||
import org.objectweb.asm.tree.analysis.BasicValue |
|
||||||
import java.util.Stack |
|
||||||
|
|
||||||
class IrDecompiler(private val owner: ClassNode, private val method: MethodNode) : |
|
||||||
Analyzer<BasicValue>(BasicInterpreter()) { |
|
||||||
|
|
||||||
private val graph = GraphBuilder |
|
||||||
.directed() |
|
||||||
.allowsSelfLoops(true) |
|
||||||
.build<BasicBlock>() |
|
||||||
|
|
||||||
private val blocks = mutableMapOf<Int, BasicBlock>() |
|
||||||
private fun bb(index: Int) = blocks.computeIfAbsent(index) { |
|
||||||
val bb = BasicBlock(graph, method.instructions, it, it) |
|
||||||
val leader = method.instructions[index] |
|
||||||
|
|
||||||
if (leader is LabelNode) { |
|
||||||
bb.label = leader |
|
||||||
} |
|
||||||
|
|
||||||
bb |
|
||||||
} |
|
||||||
|
|
||||||
override fun newControlFlowEdge(insnIndex: Int, successorIndex: Int) { |
|
||||||
graph.putEdge(bb(insnIndex), bb(successorIndex)) |
|
||||||
} |
|
||||||
|
|
||||||
override fun newControlFlowExceptionEdge(insnIndex: Int, successorIndex: Int): Boolean { |
|
||||||
// @TODO: Attach basic blocks with exception types. |
|
||||||
return true |
|
||||||
} |
|
||||||
|
|
||||||
fun decompile(): Method { |
|
||||||
analyze(owner.name, method) |
|
||||||
|
|
||||||
val entryBlock = blocks[0] ?: throw IllegalStateException("No method entry block found") |
|
||||||
|
|
||||||
val remainingBlocks = Stack<BasicBlock>() |
|
||||||
remainingBlocks.push(entryBlock) |
|
||||||
|
|
||||||
val visited = mutableSetOf<BasicBlock>() |
|
||||||
|
|
||||||
while (remainingBlocks.isNotEmpty()) { |
|
||||||
val bb = remainingBlocks.pop() |
|
||||||
var next = bb.findTrivialSuccessor() |
|
||||||
|
|
||||||
while (next != null && next != bb) { |
|
||||||
bb.merge(next) |
|
||||||
|
|
||||||
next = bb.findTrivialSuccessor() |
|
||||||
} |
|
||||||
|
|
||||||
for (succ in bb.successors()) { |
|
||||||
if (!visited.contains(succ)) { |
|
||||||
remainingBlocks.push(succ) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
visited.add(bb) |
|
||||||
} |
|
||||||
|
|
||||||
return Method(owner, method, entryBlock) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
private fun BasicBlock.findTrivialSuccessor(): BasicBlock? { |
|
||||||
val successors = successors() |
|
||||||
if (successors.size != 1) return null |
|
||||||
|
|
||||||
val successor = successors.first()!! |
|
||||||
val nextPredecessors = successor.predecessors() |
|
||||||
|
|
||||||
if (nextPredecessors.size == 1) { |
|
||||||
return successor |
|
||||||
} else { |
|
||||||
return null |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,106 @@ |
|||||||
|
package dev.openrs2.deob.ir.translation.decompiler |
||||||
|
|
||||||
|
import com.google.common.graph.ValueGraphBuilder |
||||||
|
import dev.openrs2.deob.ir.AssignmentStmt |
||||||
|
import dev.openrs2.deob.ir.BasicBlock |
||||||
|
import dev.openrs2.deob.ir.BasicBlockGraph |
||||||
|
import dev.openrs2.deob.ir.BinaryExpr |
||||||
|
import dev.openrs2.deob.ir.CallExpr |
||||||
|
import dev.openrs2.deob.ir.CallStmt |
||||||
|
import dev.openrs2.deob.ir.Expr |
||||||
|
import dev.openrs2.deob.ir.IfStmt |
||||||
|
import dev.openrs2.deob.ir.ReturnStmt |
||||||
|
import dev.openrs2.deob.ir.StmtVisitor |
||||||
|
import dev.openrs2.deob.ir.VarExpr |
||||||
|
import dev.openrs2.deob.ir.flow.ControlFlowTransfer |
||||||
|
import org.objectweb.asm.Opcodes |
||||||
|
import org.objectweb.asm.tree.InsnList |
||||||
|
import org.objectweb.asm.tree.JumpInsnNode |
||||||
|
import org.objectweb.asm.tree.MethodNode |
||||||
|
import org.objectweb.asm.tree.analysis.Analyzer |
||||||
|
|
||||||
|
class IrAnalyzer(private val interpreter: IrInterpreter) : StmtVisitor, Analyzer<IrValue>(interpreter) { |
||||||
|
init { |
||||||
|
interpreter.visitor = this |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* A mapping of program counters (instruction offsets) to the [BasicBlock]s |
||||||
|
* that define them. |
||||||
|
*/ |
||||||
|
private lateinit var blocks: MutableMap<Int, BasicBlock> |
||||||
|
|
||||||
|
/** |
||||||
|
* The control flow graph defining the method. |
||||||
|
*/ |
||||||
|
private lateinit var graph: BasicBlockGraph |
||||||
|
|
||||||
|
/** |
||||||
|
* The current [BasicBlock] of the method that is being analyzed. |
||||||
|
*/ |
||||||
|
private lateinit var bb: BasicBlock |
||||||
|
|
||||||
|
/** |
||||||
|
* The code belonging to the current method being analyzed. |
||||||
|
*/ |
||||||
|
private lateinit var code: InsnList |
||||||
|
|
||||||
|
/** |
||||||
|
* Get or create a new basic block that begins at the instruction with the given [index]. |
||||||
|
*/ |
||||||
|
private fun bb(index: Int) = blocks.computeIfAbsent(index) { BasicBlock(graph) } |
||||||
|
|
||||||
|
/** |
||||||
|
* Run the analyzer on the [method] owned by the class with name [owner] and return the [BasicBlock] |
||||||
|
* that defines method entry. |
||||||
|
*/ |
||||||
|
fun decode(owner: String, method: MethodNode): BasicBlock { |
||||||
|
analyze(owner, method) |
||||||
|
return blocks[0] ?: error("No entry BasicBlock found") |
||||||
|
} |
||||||
|
|
||||||
|
override fun init(owner: String, method: MethodNode) { |
||||||
|
graph = ValueGraphBuilder.directed() |
||||||
|
.allowsSelfLoops(true) |
||||||
|
.build() |
||||||
|
|
||||||
|
bb = BasicBlock(graph) |
||||||
|
code = method.instructions |
||||||
|
blocks = mutableMapOf(0 to bb) |
||||||
|
} |
||||||
|
|
||||||
|
override fun newControlFlowEdge(insnIndex: Int, successorIndex: Int) { |
||||||
|
when (val insn = code[insnIndex]) { |
||||||
|
is JumpInsnNode -> { |
||||||
|
val transfer = if (insn.opcode == Opcodes.GOTO) { |
||||||
|
ControlFlowTransfer.ConditionalJump(code[successorIndex] == insn.label) |
||||||
|
} else { |
||||||
|
ControlFlowTransfer.Goto |
||||||
|
} |
||||||
|
|
||||||
|
graph.putEdgeValue(bb, bb(successorIndex), transfer) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
val startsBlock = blocks[insnIndex] |
||||||
|
if (startsBlock != null) { |
||||||
|
bb = startsBlock |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
override fun visitAssignmen(variable: VarExpr, value: Expr) { |
||||||
|
bb.statements.add(AssignmentStmt(variable, value)) |
||||||
|
} |
||||||
|
|
||||||
|
override fun visitCall(expr: CallExpr) { |
||||||
|
bb.statements.add(CallStmt(expr)) |
||||||
|
} |
||||||
|
|
||||||
|
override fun visitIf(conditional: BinaryExpr) { |
||||||
|
bb.statements.add(IfStmt(conditional)) |
||||||
|
} |
||||||
|
|
||||||
|
override fun visitReturn(value: Expr?) { |
||||||
|
bb.statements.add(ReturnStmt(value)) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,334 @@ |
|||||||
|
package dev.openrs2.deob.ir.translation.decompiler |
||||||
|
|
||||||
|
import dev.openrs2.asm.MemberRef |
||||||
|
import dev.openrs2.asm.intConstant |
||||||
|
import dev.openrs2.asm.toPrettyString |
||||||
|
import dev.openrs2.deob.ir.BinOp |
||||||
|
import dev.openrs2.deob.ir.BinaryExpr |
||||||
|
import dev.openrs2.deob.ir.CallExpr |
||||||
|
import dev.openrs2.deob.ir.ConstExpr |
||||||
|
import dev.openrs2.deob.ir.FieldStorage |
||||||
|
import dev.openrs2.deob.ir.IndexExpr |
||||||
|
import dev.openrs2.deob.ir.LocalVarStorage |
||||||
|
import dev.openrs2.deob.ir.StaticStorage |
||||||
|
import dev.openrs2.deob.ir.StmtVisitor |
||||||
|
import dev.openrs2.deob.ir.Storage |
||||||
|
import dev.openrs2.deob.ir.UnaryExpr |
||||||
|
import dev.openrs2.deob.ir.UnaryOp |
||||||
|
import dev.openrs2.deob.ir.VarExpr |
||||||
|
import org.objectweb.asm.Opcodes |
||||||
|
import org.objectweb.asm.Opcodes.ACONST_NULL |
||||||
|
import org.objectweb.asm.Opcodes.ALOAD |
||||||
|
import org.objectweb.asm.Opcodes.ARETURN |
||||||
|
import org.objectweb.asm.Opcodes.ASM7 |
||||||
|
import org.objectweb.asm.Opcodes.ASTORE |
||||||
|
import org.objectweb.asm.Opcodes.CHECKCAST |
||||||
|
import org.objectweb.asm.Opcodes.D2F |
||||||
|
import org.objectweb.asm.Opcodes.D2L |
||||||
|
import org.objectweb.asm.Opcodes.DADD |
||||||
|
import org.objectweb.asm.Opcodes.DCMPG |
||||||
|
import org.objectweb.asm.Opcodes.DDIV |
||||||
|
import org.objectweb.asm.Opcodes.DMUL |
||||||
|
import org.objectweb.asm.Opcodes.DNEG |
||||||
|
import org.objectweb.asm.Opcodes.DREM |
||||||
|
import org.objectweb.asm.Opcodes.DSUB |
||||||
|
import org.objectweb.asm.Opcodes.DUP |
||||||
|
import org.objectweb.asm.Opcodes.F2L |
||||||
|
import org.objectweb.asm.Opcodes.GETFIELD |
||||||
|
import org.objectweb.asm.Opcodes.GETSTATIC |
||||||
|
import org.objectweb.asm.Opcodes.I2B |
||||||
|
import org.objectweb.asm.Opcodes.I2C |
||||||
|
import org.objectweb.asm.Opcodes.I2D |
||||||
|
import org.objectweb.asm.Opcodes.I2F |
||||||
|
import org.objectweb.asm.Opcodes.I2L |
||||||
|
import org.objectweb.asm.Opcodes.I2S |
||||||
|
import org.objectweb.asm.Opcodes.IADD |
||||||
|
import org.objectweb.asm.Opcodes.IALOAD |
||||||
|
import org.objectweb.asm.Opcodes.ICONST_M1 |
||||||
|
import org.objectweb.asm.Opcodes.IDIV |
||||||
|
import org.objectweb.asm.Opcodes.IFEQ |
||||||
|
import org.objectweb.asm.Opcodes.IFGE |
||||||
|
import org.objectweb.asm.Opcodes.IFGT |
||||||
|
import org.objectweb.asm.Opcodes.IFLE |
||||||
|
import org.objectweb.asm.Opcodes.IFLT |
||||||
|
import org.objectweb.asm.Opcodes.IFNE |
||||||
|
import org.objectweb.asm.Opcodes.IFNONNULL |
||||||
|
import org.objectweb.asm.Opcodes.IFNULL |
||||||
|
import org.objectweb.asm.Opcodes.IF_ACMPEQ |
||||||
|
import org.objectweb.asm.Opcodes.IF_ACMPNE |
||||||
|
import org.objectweb.asm.Opcodes.IF_ICMPEQ |
||||||
|
import org.objectweb.asm.Opcodes.IF_ICMPGE |
||||||
|
import org.objectweb.asm.Opcodes.IF_ICMPGT |
||||||
|
import org.objectweb.asm.Opcodes.IF_ICMPLE |
||||||
|
import org.objectweb.asm.Opcodes.IF_ICMPLT |
||||||
|
import org.objectweb.asm.Opcodes.IF_ICMPNE |
||||||
|
import org.objectweb.asm.Opcodes.ILOAD |
||||||
|
import org.objectweb.asm.Opcodes.IMUL |
||||||
|
import org.objectweb.asm.Opcodes.INEG |
||||||
|
import org.objectweb.asm.Opcodes.INSTANCEOF |
||||||
|
import org.objectweb.asm.Opcodes.IREM |
||||||
|
import org.objectweb.asm.Opcodes.IRETURN |
||||||
|
import org.objectweb.asm.Opcodes.ISHL |
||||||
|
import org.objectweb.asm.Opcodes.ISHR |
||||||
|
import org.objectweb.asm.Opcodes.ISTORE |
||||||
|
import org.objectweb.asm.Opcodes.ISUB |
||||||
|
import org.objectweb.asm.Opcodes.IUSHR |
||||||
|
import org.objectweb.asm.Opcodes.IXOR |
||||||
|
import org.objectweb.asm.Opcodes.L2D |
||||||
|
import org.objectweb.asm.Opcodes.L2F |
||||||
|
import org.objectweb.asm.Opcodes.LALOAD |
||||||
|
import org.objectweb.asm.Opcodes.LDC |
||||||
|
import org.objectweb.asm.Opcodes.LOOKUPSWITCH |
||||||
|
import org.objectweb.asm.Opcodes.LSHL |
||||||
|
import org.objectweb.asm.Opcodes.LSHR |
||||||
|
import org.objectweb.asm.Opcodes.LUSHR |
||||||
|
import org.objectweb.asm.Opcodes.LXOR |
||||||
|
import org.objectweb.asm.Opcodes.NEW |
||||||
|
import org.objectweb.asm.Opcodes.PUTFIELD |
||||||
|
import org.objectweb.asm.Opcodes.SIPUSH |
||||||
|
import org.objectweb.asm.Opcodes.SWAP |
||||||
|
import org.objectweb.asm.Opcodes.TABLESWITCH |
||||||
|
import org.objectweb.asm.Type |
||||||
|
import org.objectweb.asm.tree.AbstractInsnNode |
||||||
|
import org.objectweb.asm.tree.FieldInsnNode |
||||||
|
import org.objectweb.asm.tree.JumpInsnNode |
||||||
|
import org.objectweb.asm.tree.LdcInsnNode |
||||||
|
import org.objectweb.asm.tree.LookupSwitchInsnNode |
||||||
|
import org.objectweb.asm.tree.MethodInsnNode |
||||||
|
import org.objectweb.asm.tree.MultiANewArrayInsnNode |
||||||
|
import org.objectweb.asm.tree.TableSwitchInsnNode |
||||||
|
import org.objectweb.asm.tree.TypeInsnNode |
||||||
|
import org.objectweb.asm.tree.VarInsnNode |
||||||
|
import org.objectweb.asm.tree.analysis.Interpreter |
||||||
|
|
||||||
|
class IrInterpreter : Interpreter<IrValue>(ASM7) { |
||||||
|
lateinit var visitor: StmtVisitor |
||||||
|
|
||||||
|
override fun newValue(type: Type?) = IrValue(null) |
||||||
|
|
||||||
|
override fun newEmptyValue(local: Int) = IrValue(VarExpr.local(local)) |
||||||
|
|
||||||
|
override fun newParameterValue(isInstanceMethod: Boolean, local: Int, type: Type) = |
||||||
|
IrValue(VarExpr.local(local), type) |
||||||
|
|
||||||
|
override fun naryOperation(insn: AbstractInsnNode?, values: MutableList<out IrValue>): IrValue? = when (insn) { |
||||||
|
is MethodInsnNode -> handleMethodOperation(insn, values) |
||||||
|
is MultiANewArrayInsnNode -> handleNewMultiArrayOperation(insn, values) |
||||||
|
else -> error("Unrecognized nary operation instruction: $insn") |
||||||
|
} |
||||||
|
|
||||||
|
override fun ternaryOperation( |
||||||
|
insn: AbstractInsnNode?, |
||||||
|
value1: IrValue?, |
||||||
|
value2: IrValue?, |
||||||
|
value3: IrValue? |
||||||
|
): IrValue { |
||||||
|
TODO("Not yet implemented") |
||||||
|
} |
||||||
|
|
||||||
|
override fun merge(value1: IrValue?, value2: IrValue?): IrValue? { |
||||||
|
return value1 |
||||||
|
} |
||||||
|
|
||||||
|
override fun newReturnTypeValue(type: Type?): IrValue? { |
||||||
|
if (type == Type.VOID_TYPE) { |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
return IrValue(null, type) |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* We ignore this, since we catch all of the return instructions (outwith RETURN) in [unaryOperation]. |
||||||
|
*/ |
||||||
|
override fun returnOperation(insn: AbstractInsnNode, value: IrValue, expected: IrValue) {} |
||||||
|
|
||||||
|
/** |
||||||
|
* Interprets a bytecode instruction with a single argument. This method is called for the |
||||||
|
* following opcodes: |
||||||
|
* |
||||||
|
* <p>INEG, LNEG, FNEG, DNEG, IINC, I2L, I2F, I2D, L2I, L2F, L2D, F2I, F2L, F2D, D2I, D2L, D2F, |
||||||
|
* I2B, I2C, I2S, IFEQ, IFNE, IFLT, IFGE, IFGT, IFLE, TABLESWITCH, LOOKUPSWITCH, IRETURN, LRETURN, |
||||||
|
* FRETURN, DRETURN, ARETURN, PUTSTATIC, GETFIELD, NEWARRAY, ANEWARRAY, ARRAYLENGTH, ATHROW, |
||||||
|
* CHECKCAST, INSTANCEOF, MONITORENTER, MONITOREXIT, IFNULL, IFNONNULL |
||||||
|
* |
||||||
|
* @param insn the bytecode instruction to be interpreted. |
||||||
|
* @param value the argument of the instruction to be interpreted. |
||||||
|
* @return the result of the interpretation of the given instruction. |
||||||
|
* @throws AnalyzerException if an error occurred during the interpretation. |
||||||
|
*/ |
||||||
|
override fun unaryOperation(insn: AbstractInsnNode, value: IrValue) = when (insn.opcode) { |
||||||
|
I2B -> handleCastOperation(insn, value, Type.BYTE_TYPE) |
||||||
|
I2C -> handleCastOperation(insn, value, Type.CHAR_TYPE) |
||||||
|
I2S -> handleCastOperation(insn, value, Type.SHORT_TYPE) |
||||||
|
I2D, L2D -> handleCastOperation(insn, value, Type.DOUBLE_TYPE) |
||||||
|
I2L, D2L, F2L -> handleCastOperation(insn, value, Type.LONG_TYPE) |
||||||
|
I2F, D2F, L2F -> handleCastOperation(insn, value, Type.FLOAT_TYPE) |
||||||
|
CHECKCAST -> handleCastOperation(insn, value, (insn as TypeInsnNode).desc.let(Type::getType)) |
||||||
|
INSTANCEOF -> handleCastOperation( |
||||||
|
insn, |
||||||
|
value, |
||||||
|
(insn as TypeInsnNode).desc.let(Type::getType), |
||||||
|
instanceOf = true |
||||||
|
) |
||||||
|
GETFIELD -> handleVarLoadOperation( |
||||||
|
insn, |
||||||
|
FieldStorage(value.expr!!, MemberRef(insn as FieldInsnNode)) |
||||||
|
) |
||||||
|
IFNULL, IFNONNULL -> handleConditionalOperation( |
||||||
|
insn as JumpInsnNode, |
||||||
|
value, |
||||||
|
IrValue(ConstExpr(null), value.type) |
||||||
|
) |
||||||
|
TABLESWITCH -> handleTableSwitchOperation(insn as TableSwitchInsnNode, value) |
||||||
|
LOOKUPSWITCH -> handleLookupSwitchOperation(insn as LookupSwitchInsnNode, value) |
||||||
|
in INEG..DNEG -> handleUnaryOperation(insn,value, UnaryOp.Negate) |
||||||
|
in IFEQ..IFLE -> handleConditionalOperation(insn as JumpInsnNode, value, IrValue(ConstExpr(0), Type.INT_TYPE)) |
||||||
|
in IRETURN..ARETURN -> handleReturnOperation(insn, value) |
||||||
|
else -> error("Invalid instruction for unary expression: ${insn.toPrettyString()}") |
||||||
|
} |
||||||
|
|
||||||
|
private fun handleTableSwitchOperation(tableSwitchInsnNode: TableSwitchInsnNode, value: IrValue): IrValue? { |
||||||
|
TODO("Not yet implemented") |
||||||
|
} |
||||||
|
|
||||||
|
private fun handleLookupSwitchOperation(lookupSwitchInsnNode: LookupSwitchInsnNode, value: IrValue): IrValue? { |
||||||
|
TODO("Not yet implemented") |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Interprets a bytecode instruction with two arguments. This method is called for the following |
||||||
|
* opcodes: |
||||||
|
* |
||||||
|
* <p>IALOAD, LALOAD, FALOAD, DALOAD, AALOAD, BALOAD, CALOAD, SALOAD, IADD, LADD, FADD, DADD, |
||||||
|
* ISUB, LSUB, FSUB, DSUB, IMUL, LMUL, FMUL, DMUL, IDIV, LDIV, FDIV, DDIV, IREM, LREM, FREM, DREM, |
||||||
|
* ISHL, LSHL, ISHR, LSHR, IUSHR, LUSHR, IAND, LAND, IOR, LOR, IXOR, LXOR, LCMP, FCMPL, FCMPG, |
||||||
|
* DCMPL, DCMPG, IF_ICMPEQ, IF_ICMPNE, IF_ICMPLT, IF_ICMPGE, IF_ICMPGT, IF_ICMPLE, IF_ACMPEQ, |
||||||
|
* IF_ACMPNE, PUTFIELD |
||||||
|
* |
||||||
|
* @param insn the bytecode instruction to be interpreted. |
||||||
|
* @param value1 the first argument of the instruction to be interpreted. |
||||||
|
* @param value2 the second argument of the instruction to be interpreted. |
||||||
|
* @return the result of the interpretation of the given instruction. |
||||||
|
* @throws AnalyzerException if an error occurred during the interpretation. |
||||||
|
*/ |
||||||
|
override fun binaryOperation(insn: AbstractInsnNode, lhs: IrValue, rhs: IrValue) = when (insn.opcode) { |
||||||
|
PUTFIELD -> handleVarStoreOperation(insn, FieldStorage(lhs.expr!!, MemberRef(insn as FieldInsnNode)), rhs) |
||||||
|
in IALOAD..LALOAD -> handleArrayLoadOperation(insn, lhs, rhs) |
||||||
|
in IADD..DCMPG -> handleBinaryOperation(insn, lhs, rhs) |
||||||
|
in IF_ICMPEQ..IF_ACMPNE -> handleConditionalOperation(insn as JumpInsnNode, lhs, rhs) |
||||||
|
else -> error("Unhandled binary operation instruction: $insn") |
||||||
|
} |
||||||
|
|
||||||
|
private fun handleArrayLoadOperation(insn: AbstractInsnNode, index: IrValue, array: IrValue): IrValue? = |
||||||
|
IrValue(IndexExpr(array.expr!!, index.expr!!)) |
||||||
|
|
||||||
|
override fun copyOperation(insn: AbstractInsnNode, value: IrValue) = when (insn.opcode) { |
||||||
|
in ISTORE..ASTORE -> { |
||||||
|
handleVarStoreOperation(insn, LocalVarStorage((insn as VarInsnNode).`var`), value) |
||||||
|
value |
||||||
|
} |
||||||
|
in ILOAD..ALOAD -> handleVarLoadOperation(insn, LocalVarStorage((insn as VarInsnNode).`var`)) |
||||||
|
in DUP..SWAP -> { |
||||||
|
val local = LocalVarStorage() |
||||||
|
|
||||||
|
handleVarStoreOperation(insn, local, value) |
||||||
|
handleVarLoadOperation(insn, local) |
||||||
|
} |
||||||
|
else -> error("Invalid copy instruction: $insn") |
||||||
|
} |
||||||
|
|
||||||
|
override fun newOperation(insn: AbstractInsnNode) = when (insn.opcode) { |
||||||
|
NEW -> IrValue(null, (insn as TypeInsnNode).desc.let(Type::getType)) |
||||||
|
ACONST_NULL -> handleConstantOperation(null) |
||||||
|
LDC -> handleConstantOperation((insn as LdcInsnNode).cst) |
||||||
|
GETSTATIC -> handleVarLoadOperation(insn, StaticStorage(MemberRef((insn as FieldInsnNode)))) |
||||||
|
in ICONST_M1..SIPUSH -> handleConstantOperation(insn.intConstant!!) |
||||||
|
else -> error("Unhandled new operation instruction: $insn") |
||||||
|
} |
||||||
|
|
||||||
|
private fun handleConstantOperation(value: Any?) = IrValue(ConstExpr(value)) |
||||||
|
|
||||||
|
fun handleBinaryOperation(insn: AbstractInsnNode, lhs: IrValue, rhs: IrValue): IrValue? { |
||||||
|
val op = when (insn.opcode) { |
||||||
|
in ISUB..DSUB -> BinOp.Subtract |
||||||
|
in IADD..DADD -> BinOp.Add |
||||||
|
in IMUL..DMUL -> BinOp.Multiply |
||||||
|
in IDIV..DDIV -> BinOp.Divide |
||||||
|
in IREM..DREM -> BinOp.Remainder |
||||||
|
in ISHL..LSHL -> BinOp.ShiftLeft |
||||||
|
in ISHR..LSHR -> BinOp.ShiftRight |
||||||
|
in IUSHR..LUSHR -> BinOp.UnsignedShiftRight |
||||||
|
in IXOR..LXOR -> BinOp.ExclusiveOr |
||||||
|
else -> error("Invalid instruction for binary expression: ${insn.toPrettyString()}") |
||||||
|
} |
||||||
|
|
||||||
|
return IrValue(BinaryExpr(lhs.expr!!, rhs.expr!!, op)) |
||||||
|
} |
||||||
|
|
||||||
|
fun handleCastOperation(insn: AbstractInsnNode, value: IrValue, type: Type, instanceOf: Boolean = false): IrValue { |
||||||
|
val op = if (instanceOf) { |
||||||
|
UnaryOp.InstanceOf(type) |
||||||
|
} else { |
||||||
|
UnaryOp.Cast(type) |
||||||
|
} |
||||||
|
|
||||||
|
return IrValue(UnaryExpr(value.expr!!, op)) |
||||||
|
} |
||||||
|
|
||||||
|
private fun handleConditionalOperation(insn: JumpInsnNode, lhs: IrValue, rhs: IrValue): IrValue? { |
||||||
|
val op = when (insn.opcode) { |
||||||
|
IFEQ, IF_ACMPEQ, IF_ICMPEQ, IFNULL -> BinOp.Equals |
||||||
|
IFNE, IF_ACMPNE, IF_ICMPNE, IFNONNULL -> BinOp.NotEquals |
||||||
|
IFLE, IF_ICMPLE -> BinOp.LessThanOrEquals |
||||||
|
IFLT, IF_ICMPLT -> BinOp.LessThan |
||||||
|
IFGE, IF_ICMPGE -> BinOp.GreaterThanOrEquals |
||||||
|
IFGT, IF_ICMPGT -> BinOp.GreaterThan |
||||||
|
else -> error("Not a valid conditional instruction: $insn") |
||||||
|
} |
||||||
|
|
||||||
|
visitor.visitIf(BinaryExpr(lhs.expr!!, rhs.expr!!, op)) |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
fun handleMethodOperation(insn: MethodInsnNode, values: MutableList<out IrValue>): IrValue? { |
||||||
|
val method = MemberRef(insn) |
||||||
|
val returnType = Type.getReturnType(insn.desc) |
||||||
|
val instance = when (insn.opcode) { |
||||||
|
Opcodes.INVOKESTATIC -> null |
||||||
|
else -> values.removeAt(0).expr ?: error("No expression found for isntance slot") |
||||||
|
} |
||||||
|
|
||||||
|
val call = CallExpr(instance, method, values.map { it.expr!! }) |
||||||
|
|
||||||
|
return when (returnType) { |
||||||
|
Type.VOID_TYPE -> { |
||||||
|
visitor.visitCall(call) |
||||||
|
null |
||||||
|
} |
||||||
|
else -> IrValue(call, returnType) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fun handleNewMultiArrayOperation(insn: MultiANewArrayInsnNode, values: MutableList<out IrValue>): IrValue? { |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
fun handleReturnOperation(insn: AbstractInsnNode, value: IrValue?): IrValue? { |
||||||
|
visitor.visitReturn(value?.expr) |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
fun handleUnaryOperation(insn: AbstractInsnNode, value: IrValue, operator: UnaryOp): IrValue { |
||||||
|
return IrValue(UnaryExpr(value.expr!!, operator)) |
||||||
|
} |
||||||
|
|
||||||
|
fun handleVarLoadOperation(insn: AbstractInsnNode, storage: Storage): IrValue? { |
||||||
|
return IrValue(VarExpr(storage)) |
||||||
|
} |
||||||
|
|
||||||
|
fun handleVarStoreOperation(insn: AbstractInsnNode, storage: Storage, value: IrValue): IrValue? { |
||||||
|
visitor.visitAssignmen(VarExpr(storage), value.expr!!) |
||||||
|
return null |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,9 @@ |
|||||||
|
package dev.openrs2.deob.ir.translation.decompiler |
||||||
|
|
||||||
|
import dev.openrs2.deob.ir.Expr |
||||||
|
import org.objectweb.asm.Type |
||||||
|
import org.objectweb.asm.tree.analysis.Value |
||||||
|
|
||||||
|
class IrValue(val expr: Expr?, val type: Type? = null) : Value { |
||||||
|
override fun getSize() = type?.size ?: 1 |
||||||
|
} |
@ -1,29 +0,0 @@ |
|||||||
package dev.openrs2.deob.analysis |
|
||||||
|
|
||||||
import com.google.common.graph.Graph |
|
||||||
import com.google.common.graph.GraphBuilder |
|
||||||
import org.objectweb.asm.tree.MethodNode |
|
||||||
import org.objectweb.asm.tree.analysis.Analyzer |
|
||||||
import org.objectweb.asm.tree.analysis.BasicInterpreter |
|
||||||
import org.objectweb.asm.tree.analysis.BasicValue |
|
||||||
|
|
||||||
class ControlFlowAnalyzer : Analyzer<BasicValue>(BasicInterpreter()) { |
|
||||||
private val graph = GraphBuilder |
|
||||||
.directed() |
|
||||||
.allowsSelfLoops(true) |
|
||||||
.immutable<Int>() |
|
||||||
|
|
||||||
override fun newControlFlowEdge(insnIndex: Int, successorIndex: Int) { |
|
||||||
graph.putEdge(insnIndex, successorIndex) |
|
||||||
} |
|
||||||
|
|
||||||
override fun newControlFlowExceptionEdge(insnIndex: Int, successorIndex: Int): Boolean { |
|
||||||
graph.putEdge(insnIndex, successorIndex) |
|
||||||
return true |
|
||||||
} |
|
||||||
|
|
||||||
fun createGraph(owner: String, method: MethodNode): Graph<Int> { |
|
||||||
analyze(owner, method) |
|
||||||
return graph.build() |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,35 @@ |
|||||||
|
package dev.openrs2.deob.test |
||||||
|
|
||||||
|
import org.objectweb.asm.Opcodes |
||||||
|
import org.objectweb.asm.commons.InstructionAdapter |
||||||
|
import org.objectweb.asm.tree.MethodNode |
||||||
|
|
||||||
|
@DslMarker |
||||||
|
annotation class BytecodeDslMarker |
||||||
|
|
||||||
|
enum class Expectation { |
||||||
|
Removed, |
||||||
|
Added, |
||||||
|
Present |
||||||
|
} |
||||||
|
|
||||||
|
data class BytecodeTest(val code: MethodNode, val expectations: List<Expectation>) |
||||||
|
|
||||||
|
@BytecodeDslMarker |
||||||
|
class BytecodeTestBuilder : InstructionAdapter(Opcodes.ASM7, MethodNode()) { |
||||||
|
val method = mv as MethodNode |
||||||
|
val expectations = mutableListOf<Expectation>() |
||||||
|
|
||||||
|
override fun visitInsn(opcode: Int) { |
||||||
|
super.visitInsn(opcode) |
||||||
|
expectations.add(Expectation.Present) |
||||||
|
} |
||||||
|
|
||||||
|
operator fun Unit.unaryMinus() { |
||||||
|
expectations[expectations.size - 1] = Expectation.Removed |
||||||
|
} |
||||||
|
|
||||||
|
operator fun Unit.unaryPlus() { |
||||||
|
expectations[expectations.size - 1] = Expectation.Added |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue