-
Notifications
You must be signed in to change notification settings - Fork 185
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Reworked BlueprintHookManager to better-prevent hooks from corrupting the code or each other #320
base: dev
Are you sure you want to change the base?
Reworked BlueprintHookManager to better-prevent hooks from corrupting the code or each other #320
Conversation
… the code or each other.
Mods/SML/Source/SML/Private/Toolkit/KismetBytecodeDisassembler.cpp
Outdated
Show resolved
Hide resolved
Mods/SML/Source/SML/Private/Toolkit/KismetBytecodeDisassembler.cpp
Outdated
Show resolved
Hide resolved
Mods/SML/Source/SML/Public/Toolkit/KismetBytecodeDisassembler.h
Outdated
Show resolved
Hide resolved
// Now we search all the children of this node for jump instructions that need to be updated | ||
for (auto& Pair : Expression->Values) { | ||
TSharedPtr<FJsonObject>* ExpressionValue; | ||
if (Pair.Value->TryGetObject(ExpressionValue)) { | ||
ModifyJumpTargetsForNewHookOffset(Script, *ExpressionValue, HookOffset); | ||
continue; | ||
} | ||
TArray<TSharedPtr<FJsonValue>>* ArrayValue; | ||
if (Pair.Value->TryGetArray(ArrayValue)) { | ||
for (TSharedPtr<FJsonValue> Value : *ArrayValue) { | ||
if (Value->TryGetObject(ExpressionValue)) { | ||
ModifyJumpTargetsForNewHookOffset(Script, *ExpressionValue, HookOffset); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Have you found any case in which this would do anything? I don't think jump instructions can appear in expressions (only in statements)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I quickly looked over the Kismet compiler in the engine and it only emits EX_JumpIfNot
from compiled statements (KCST_
enumerators). I think the same applies to the other instructions with jump offsets. So this doesn't seem to be necessary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, my first pass didn't have the array handling here and was crashing. Each case in a switch statement contains an OffsetToNextCase that is an absolute offset and needs to be updated, as well. Those are technically jump targets but perhaps the name of the function adds some confusion. I changed it to ModifyOffsetsForNewHookOffset, which is hopefully a little more on the nose?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OffsetToNextCase is an absolute offset? That's weird.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I was surprised, too. Here's a snippet to show it and illustrate why recursing is necessary. This LetBool's Expression is a switch and you can see each CaseValue's OffsetToNextCase is exactly the OpcodeIndex of the subsequent case. Also, the OffsetToNextCase values are way too big to be relative offsets, which was my first clue.
{
"Opcode": 20,
"OpcodeIndex": 258,
"Inst": "LetBool",
"Variable":
{
"Opcode": 72,
"OpcodeIndex": 259,
"Inst": "LocalOutVariable",
"VariableType":
{
"PinCategory": "bool",
"PinSubCategory": "bool"
},
"VariableName": "ReturnValue",
"OpSizeInBytes": 9
},
"Expression":
{
"Opcode": 105,
"OpcodeIndex": 268,
"Inst": "SwitchValue",
"Expression":
{
"Opcode": 0,
"OpcodeIndex": 275,
"Inst": "LocalVariable",
"VariableType":
{
"PinCategory": "byte",
"PinSubCategory": "byte",
"PinSubCategoryObject": "/Script/FactoryGame.ERepresentationType"
},
"VariableName": "Temp_byte_Variable",
"OpSizeInBytes": 9
},
"OffsetToSwitchEnd": 608,
"Cases": [
{
"CaseValue":
{
"Opcode": 36,
"OpcodeIndex": 284,
"Inst": "ByteConst",
"Value": 0,
"OpSizeInBytes": 2
},
"OffsetToNextCase": 299,
"CaseResult":
{
"Opcode": 0,
"OpcodeIndex": 290,
"Inst": "LocalVariable",
"VariableType":
{
"PinCategory": "bool",
"PinSubCategory": "bool"
},
"VariableName": "Temp_bool_Variable_20",
"OpSizeInBytes": 9
}
},
{
"CaseValue":
{
"Opcode": 36,
"OpcodeIndex": 299,
"Inst": "ByteConst",
"Value": 1,
"OpSizeInBytes": 2
},
"OffsetToNextCase": 314,
"CaseResult":
{
"Opcode": 0,
"OpcodeIndex": 305,
"Inst": "LocalVariable",
"VariableType":
{
"PinCategory": "bool",
"PinSubCategory": "bool"
},
"VariableName": "Temp_bool_Variable_19",
"OpSizeInBytes": 9
}
},
{
"CaseValue":
{
"Opcode": 36,
"OpcodeIndex": 314,
"Inst": "ByteConst",
"Value": 2,
"OpSizeInBytes": 2
},
"OffsetToNextCase": 329,
"CaseResult":
{
"Opcode": 0,
"OpcodeIndex": 320,
"Inst": "LocalVariable",
"VariableType":
{
"PinCategory": "bool",
"PinSubCategory": "bool"
},
"VariableName": "Temp_bool_Variable_18",
"OpSizeInBytes": 9
}
},
....
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Which asset is this snippet from? I'd like to inspect it myself
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That is from BPW_MapFilterCategories::CanBeSeenOnCompass and it's the first BeforeHook dump that is created with DEBUG_BLUEPRINT_HOOKING set to 1 (to ensure it was unmodified).
…redefinedHookOffset::Return would result in two separate hooks in the map - though they would be called correctly, if a function from the first hook set a return jump, it would skip the functions from the second hook, which would violate the contract that all hook functions at a location are called when a return jump is set.
… fail more clearly in that case
When testing blueprint hooks, I learned that hooking a function return after hooking a function start would corrupt the start hook and crash upon blueprint function call. I also realized that, because the existing approach sometimes needs to move original function instructions, there's a chance it could move an instruction that an unrelated part of the function tries to jump to, which would also crash. There's a high-level description of the issue and thoughts on a fix in Discord here:
https://discord.com/channels/555424930502541343/862002356626128907/1303244570708152360
This PR implements what I hinted at in Discord - it's a fix that should support arbitrary amounts of arbitrary offset hooking (though the offsets must still be aligned with instructions, of course). Every new hook entry point now moves all instructions after it just enough to make room for the hook jump. It then scans the entire function and updates any affected jump instructions to point to their updated locations. It then installs the hook jump and the new hook code after the function's existing code.
Thoughts/explanations for the reviewer:
I removed KismetBytecodeDisassembler::GetStatementLength as each statement Json now has its size included and this no longer seem to be used; theoretically, some mod author might be using it but I'd rather err on the side of removing dead code.More thoughts
An alternate approach could have been to simply insert the hook calls directly into the function and update affected jump targets in the function. The main downside would be that the amount to update the jump targets would depend on the length of the code needed to invoke the hook, which would make changing that code fragile and error-prone. An unconditional jump will always be the same size even if the hook invocation code changes or needs to vary based on the hook itself for some reason.