feat: Add TerraSharp for Terraria and tModLoader
This commit is contained in:
commit
58eb32c7ed
70 files changed
+8678
No files matched your search
@@ -0,0 +1,93 @@
|
||||
option casemap:none
|
||||
|
||||
DataStage equ 0
|
||||
DataResult equ 4
|
||||
DataManagedResult equ 8
|
||||
DataCompleted equ 12
|
||||
DataAssembly equ 16
|
||||
DataTypeName equ 24
|
||||
DataMethodName equ 32
|
||||
DataGetRuntimeDelegate equ 64
|
||||
|
||||
StageGetDelegates equ 7
|
||||
StageLoadAssembly equ 8
|
||||
StageGetEntryPoint equ 9
|
||||
StageInvoke equ 10
|
||||
|
||||
DelegateGetFunctionPointer equ 6
|
||||
DelegateLoadAssembly equ 7
|
||||
|
||||
LocalLoadAssembly equ 30h
|
||||
LocalGetFunction equ 38h
|
||||
LocalEntry equ 40h
|
||||
|
||||
.code
|
||||
Bootstrap proc frame
|
||||
push rbx
|
||||
.pushreg rbx
|
||||
sub rsp, 50h
|
||||
.allocstack 50h
|
||||
.endprolog
|
||||
|
||||
mov rbx, rcx
|
||||
mov dword ptr [rbx + DataResult], 80004005h
|
||||
mov dword ptr [rbx + DataManagedResult], -1
|
||||
mov dword ptr [rbx + DataCompleted], 0
|
||||
xor eax, eax
|
||||
mov [rsp + LocalLoadAssembly], rax
|
||||
mov [rsp + LocalGetFunction], rax
|
||||
mov [rsp + LocalEntry], rax
|
||||
|
||||
mov dword ptr [rbx + DataStage], StageGetDelegates
|
||||
xor ecx, ecx
|
||||
mov edx, DelegateLoadAssembly
|
||||
lea r8, [rsp + LocalLoadAssembly]
|
||||
call qword ptr [rbx + DataGetRuntimeDelegate]
|
||||
mov [rbx + DataResult], eax
|
||||
test eax, eax
|
||||
js Complete
|
||||
|
||||
xor ecx, ecx
|
||||
mov edx, DelegateGetFunctionPointer
|
||||
lea r8, [rsp + LocalGetFunction]
|
||||
call qword ptr [rbx + DataGetRuntimeDelegate]
|
||||
mov [rbx + DataResult], eax
|
||||
test eax, eax
|
||||
js Complete
|
||||
|
||||
mov dword ptr [rbx + DataStage], StageLoadAssembly
|
||||
mov rcx, [rbx + DataAssembly]
|
||||
xor edx, edx
|
||||
xor r8d, r8d
|
||||
call qword ptr [rsp + LocalLoadAssembly]
|
||||
mov [rbx + DataResult], eax
|
||||
test eax, eax
|
||||
js Complete
|
||||
|
||||
mov dword ptr [rbx + DataStage], StageGetEntryPoint
|
||||
mov rcx, [rbx + DataTypeName]
|
||||
mov rdx, [rbx + DataMethodName]
|
||||
xor r8d, r8d
|
||||
xor r9d, r9d
|
||||
mov qword ptr [rsp + 20h], 0
|
||||
lea rax, [rsp + LocalEntry]
|
||||
mov [rsp + 28h], rax
|
||||
call qword ptr [rsp + LocalGetFunction]
|
||||
mov [rbx + DataResult], eax
|
||||
test eax, eax
|
||||
js Complete
|
||||
|
||||
mov dword ptr [rbx + DataStage], StageInvoke
|
||||
xor ecx, ecx
|
||||
xor edx, edx
|
||||
call qword ptr [rsp + LocalEntry]
|
||||
mov [rbx + DataManagedResult], eax
|
||||
|
||||
Complete:
|
||||
mov dword ptr [rbx + DataCompleted], 1
|
||||
mov eax, [rbx + DataResult]
|
||||
add rsp, 50h
|
||||
pop rbx
|
||||
ret
|
||||
Bootstrap endp
|
||||
end
|
||||
@@ -0,0 +1,134 @@
|
||||
.386
|
||||
.model flat
|
||||
option casemap:none
|
||||
|
||||
StageOffset EQU 0
|
||||
ResultOffset EQU 4
|
||||
ManagedResultOffset EQU 8
|
||||
CompletedOffset EQU 12
|
||||
AssemblyOffset EQU 16
|
||||
TypeOffset EQU 24
|
||||
MethodOffset EQU 32
|
||||
ArgumentOffset EQU 40
|
||||
VersionOffset EQU 48
|
||||
CreateInstanceOffset EQU 56
|
||||
ClsidMetaOffset EQU 72
|
||||
IidMetaOffset EQU 80
|
||||
IidInfoOffset EQU 88
|
||||
ClsidHostOffset EQU 96
|
||||
IidHostOffset EQU 104
|
||||
|
||||
CreateInstanceStage EQU 1
|
||||
GetRuntimeStage EQU 2
|
||||
GetInterfaceStage EQU 3
|
||||
StartStage EQU 4
|
||||
ExecuteStage EQU 5
|
||||
|
||||
; Vtable offsets come from metahost.h and mscoree.h in the .NET Framework SDK.
|
||||
ReleaseOffset EQU 8
|
||||
GetRuntimeOffset EQU 12
|
||||
GetInterfaceOffset EQU 36
|
||||
StartOffset EQU 12
|
||||
ExecuteOffset EQU 44
|
||||
|
||||
.code
|
||||
Bootstrap PROC
|
||||
push ebp
|
||||
mov ebp, esp
|
||||
push esi
|
||||
push edi
|
||||
sub esp, 12
|
||||
mov edi, [ebp + 8]
|
||||
xor eax, eax
|
||||
mov [ebp - 12], eax
|
||||
mov [ebp - 16], eax
|
||||
mov [ebp - 20], eax
|
||||
|
||||
mov DWORD PTR [edi + StageOffset], CreateInstanceStage
|
||||
lea eax, [ebp - 12]
|
||||
push eax
|
||||
push DWORD PTR [edi + IidMetaOffset]
|
||||
push DWORD PTR [edi + ClsidMetaOffset]
|
||||
call DWORD PTR [edi + CreateInstanceOffset]
|
||||
mov [edi + ResultOffset], eax
|
||||
test eax, eax
|
||||
js cleanup
|
||||
|
||||
mov DWORD PTR [edi + StageOffset], GetRuntimeStage
|
||||
mov esi, [ebp - 12]
|
||||
lea eax, [ebp - 16]
|
||||
push eax
|
||||
push DWORD PTR [edi + IidInfoOffset]
|
||||
push DWORD PTR [edi + VersionOffset]
|
||||
push esi
|
||||
mov eax, [esi]
|
||||
call DWORD PTR [eax + GetRuntimeOffset]
|
||||
mov [edi + ResultOffset], eax
|
||||
test eax, eax
|
||||
js cleanup
|
||||
|
||||
mov DWORD PTR [edi + StageOffset], GetInterfaceStage
|
||||
mov esi, [ebp - 16]
|
||||
lea eax, [ebp - 20]
|
||||
push eax
|
||||
push DWORD PTR [edi + IidHostOffset]
|
||||
push DWORD PTR [edi + ClsidHostOffset]
|
||||
push esi
|
||||
mov eax, [esi]
|
||||
call DWORD PTR [eax + GetInterfaceOffset]
|
||||
mov [edi + ResultOffset], eax
|
||||
test eax, eax
|
||||
js cleanup
|
||||
|
||||
mov DWORD PTR [edi + StageOffset], StartStage
|
||||
mov esi, [ebp - 20]
|
||||
push esi
|
||||
mov eax, [esi]
|
||||
call DWORD PTR [eax + StartOffset]
|
||||
mov [edi + ResultOffset], eax
|
||||
test eax, eax
|
||||
js cleanup
|
||||
|
||||
mov DWORD PTR [edi + StageOffset], ExecuteStage
|
||||
lea eax, [edi + ManagedResultOffset]
|
||||
push eax
|
||||
push DWORD PTR [edi + ArgumentOffset]
|
||||
push DWORD PTR [edi + MethodOffset]
|
||||
push DWORD PTR [edi + TypeOffset]
|
||||
push DWORD PTR [edi + AssemblyOffset]
|
||||
push esi
|
||||
mov eax, [esi]
|
||||
call DWORD PTR [eax + ExecuteOffset]
|
||||
mov [edi + ResultOffset], eax
|
||||
|
||||
cleanup:
|
||||
mov esi, [ebp - 20]
|
||||
test esi, esi
|
||||
jz releaseInfo
|
||||
push esi
|
||||
mov eax, [esi]
|
||||
call DWORD PTR [eax + ReleaseOffset]
|
||||
releaseInfo:
|
||||
mov esi, [ebp - 16]
|
||||
test esi, esi
|
||||
jz releaseMeta
|
||||
push esi
|
||||
mov eax, [esi]
|
||||
call DWORD PTR [eax + ReleaseOffset]
|
||||
releaseMeta:
|
||||
mov esi, [ebp - 12]
|
||||
test esi, esi
|
||||
jz done
|
||||
push esi
|
||||
mov eax, [esi]
|
||||
call DWORD PTR [eax + ReleaseOffset]
|
||||
done:
|
||||
mov DWORD PTR [edi + CompletedOffset], 1
|
||||
mov eax, [edi + ResultOffset]
|
||||
add esp, 12
|
||||
pop edi
|
||||
pop esi
|
||||
pop ebp
|
||||
ret 4
|
||||
Bootstrap ENDP
|
||||
END
|
||||
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
#include <cstddef>
|
||||
|
||||
enum class BootstrapStage : DWORD
|
||||
{
|
||||
Pending,
|
||||
CreateInstance,
|
||||
GetRuntime,
|
||||
GetInterface,
|
||||
Start,
|
||||
Execute,
|
||||
RegisterUnwind,
|
||||
GetDelegates,
|
||||
LoadAssembly,
|
||||
GetEntryPoint,
|
||||
Invoke,
|
||||
RemoveUnwind,
|
||||
};
|
||||
|
||||
// Layout and stages match bootstrap-x86.asm and bootstrap-x64.asm; pointer slots are always 64 bits.
|
||||
struct BootstrapData
|
||||
{
|
||||
BootstrapStage stage;
|
||||
HRESULT result;
|
||||
INT32 managedResult;
|
||||
BOOL completed;
|
||||
UINT64 assembly;
|
||||
UINT64 typeName;
|
||||
UINT64 methodName;
|
||||
UINT64 argument;
|
||||
UINT64 runtimeVersion;
|
||||
UINT64 clrCreateInstance;
|
||||
UINT64 getRuntimeDelegate;
|
||||
UINT64 clsidMetaHost;
|
||||
UINT64 iidMetaHost;
|
||||
UINT64 iidRuntimeInfo;
|
||||
UINT64 clsidRuntimeHost;
|
||||
UINT64 iidRuntimeHost;
|
||||
};
|
||||
|
||||
static_assert(offsetof(BootstrapData, assembly) == 16);
|
||||
static_assert(offsetof(BootstrapData, typeName) == 24);
|
||||
static_assert(offsetof(BootstrapData, methodName) == 32);
|
||||
static_assert(offsetof(BootstrapData, argument) == 40);
|
||||
static_assert(offsetof(BootstrapData, runtimeVersion) == 48);
|
||||
static_assert(offsetof(BootstrapData, clrCreateInstance) == 56);
|
||||
static_assert(offsetof(BootstrapData, getRuntimeDelegate) == 64);
|
||||
static_assert(offsetof(BootstrapData, clsidMetaHost) == 72);
|
||||
static_assert(offsetof(BootstrapData, iidMetaHost) == 80);
|
||||
static_assert(offsetof(BootstrapData, iidRuntimeInfo) == 88);
|
||||
static_assert(offsetof(BootstrapData, clsidRuntimeHost) == 96);
|
||||
static_assert(offsetof(BootstrapData, iidRuntimeHost) == 104);
|
||||
static_assert(sizeof(BootstrapData) == 112);
|
||||
@@ -0,0 +1,155 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$X86Object,
|
||||
[Parameter(Mandatory = $true)][string]$X64Object,
|
||||
[Parameter(Mandatory = $true)][string]$Output
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Assert-Range([byte[]]$Bytes, [long]$Offset, [long]$Size) {
|
||||
if ($Offset -lt 0 -or $Size -lt 0 -or $Offset -gt $Bytes.Length -or $Size -gt $Bytes.Length - $Offset) {
|
||||
throw 'COFF data extends beyond the object file.'
|
||||
}
|
||||
}
|
||||
|
||||
function Read-UInt16([byte[]]$Bytes, [long]$Offset) {
|
||||
Assert-Range $Bytes $Offset 2
|
||||
return [BitConverter]::ToUInt16($Bytes, [int]$Offset)
|
||||
}
|
||||
|
||||
function Read-UInt32([byte[]]$Bytes, [long]$Offset) {
|
||||
Assert-Range $Bytes $Offset 4
|
||||
return [BitConverter]::ToUInt32($Bytes, [int]$Offset)
|
||||
}
|
||||
|
||||
function Read-CoffObject([string]$Path, [uint16]$Machine) {
|
||||
$bytes = [IO.File]::ReadAllBytes([IO.Path]::GetFullPath($Path))
|
||||
Assert-Range $bytes 0 20
|
||||
if ((Read-UInt16 $bytes 0) -ne $Machine -or (Read-UInt16 $bytes 16) -ne 0) {
|
||||
throw "Unexpected COFF object format: $Path"
|
||||
}
|
||||
$sectionCount = Read-UInt16 $bytes 2
|
||||
$symbolOffset = Read-UInt32 $bytes 8
|
||||
$symbolCount = Read-UInt32 $bytes 12
|
||||
$sectionTableEnd = 20 + [long]$sectionCount * 40
|
||||
Assert-Range $bytes 20 ([long]$sectionCount * 40)
|
||||
$sections = @()
|
||||
for ($index = 0; $index -lt $sectionCount; $index++) {
|
||||
$offset = 20 + $index * 40
|
||||
$name = [Text.Encoding]::ASCII.GetString($bytes, $offset, 8).TrimEnd([char]0)
|
||||
if ($name.StartsWith('/')) {
|
||||
$stringTableOffset = [long]$symbolOffset + [long]$symbolCount * 18
|
||||
$stringTableSize = Read-UInt32 $bytes $stringTableOffset
|
||||
Assert-Range $bytes $stringTableOffset $stringTableSize
|
||||
$nameOffset = [uint32]0
|
||||
if (-not [uint32]::TryParse($name.Substring(1), [ref]$nameOffset) -or $nameOffset -lt 4 -or $nameOffset -ge $stringTableSize) { throw "Invalid COFF section name: $Path" }
|
||||
$nameStart = $stringTableOffset + $nameOffset
|
||||
$nameEnd = $nameStart
|
||||
while ($nameEnd -lt $stringTableOffset + $stringTableSize -and $bytes[$nameEnd] -ne 0) { $nameEnd++ }
|
||||
if ($nameEnd -eq $stringTableOffset + $stringTableSize) { throw "Unterminated COFF section name: $Path" }
|
||||
$name = [Text.Encoding]::ASCII.GetString($bytes, [int]$nameStart, [int]($nameEnd - $nameStart))
|
||||
}
|
||||
$size = Read-UInt32 $bytes ($offset + 16)
|
||||
$dataOffset = Read-UInt32 $bytes ($offset + 20)
|
||||
$relocationOffset = Read-UInt32 $bytes ($offset + 24)
|
||||
$relocationCount = Read-UInt16 $bytes ($offset + 32)
|
||||
$characteristics = Read-UInt32 $bytes ($offset + 36)
|
||||
if ($relocationCount -eq 0xffff -or ($characteristics -band 0x01000000) -ne 0) {
|
||||
throw "COFF relocation overflow is unsupported: $Path"
|
||||
}
|
||||
if ($size -gt 0) {
|
||||
Assert-Range $bytes $dataOffset $size
|
||||
if ($dataOffset -lt $sectionTableEnd) { throw "Invalid section data offset: $Path" }
|
||||
}
|
||||
if ($relocationCount -gt 0) {
|
||||
Assert-Range $bytes $relocationOffset ([long]$relocationCount * 10)
|
||||
if ($relocationOffset -lt $sectionTableEnd) { throw "Invalid relocation offset: $Path" }
|
||||
}
|
||||
if (($name -in '.text', '.xdata' -or $name.StartsWith('.text$', [StringComparison]::Ordinal)) -and $relocationCount -ne 0) {
|
||||
throw "$name must be relocation-free: $Path"
|
||||
}
|
||||
$sections += [pscustomobject]@{ Name = $name; Index = $index + 1; Size = $size; DataOffset = $dataOffset; RelocationOffset = $relocationOffset; RelocationCount = $relocationCount }
|
||||
}
|
||||
$symbols = @{}
|
||||
if ($symbolCount -gt 0) {
|
||||
Assert-Range $bytes $symbolOffset ([long]$symbolCount * 18)
|
||||
if ($symbolOffset -lt $sectionTableEnd) { throw "Invalid symbol table offset: $Path" }
|
||||
for ($index = 0; $index -lt $symbolCount; $index++) {
|
||||
$offset = $symbolOffset + [long]$index * 18
|
||||
$auxiliaryCount = $bytes[$offset + 17]
|
||||
if ([long]$index + $auxiliaryCount -ge $symbolCount) { throw "Invalid auxiliary symbol count: $Path" }
|
||||
$symbols[$index] = [pscustomobject]@{ Value = Read-UInt32 $bytes ($offset + 8); Section = [BitConverter]::ToInt16($bytes, [int]($offset + 12)) }
|
||||
$index += $auxiliaryCount
|
||||
}
|
||||
}
|
||||
return [pscustomobject]@{ Path = $Path; Bytes = $bytes; Sections = $sections; Symbols = $symbols }
|
||||
}
|
||||
|
||||
function Get-Section($Object, [string]$Name) {
|
||||
$matches = @($Object.Sections | Where-Object { $_.Size -gt 0 -and ($_.Name -eq $Name -or ($Name -eq '.text' -and $_.Name.StartsWith('.text$', [StringComparison]::Ordinal))) })
|
||||
if ($matches.Count -ne 1) {
|
||||
throw "Expected one nonempty $Name section: $($Object.Path)"
|
||||
}
|
||||
return $matches[0]
|
||||
}
|
||||
|
||||
function Get-SectionBytes($Object, $Section) {
|
||||
$bytes = New-Object byte[] $Section.Size
|
||||
[Array]::Copy($Object.Bytes, [int]$Section.DataOffset, $bytes, 0, [int]$Section.Size)
|
||||
return ,$bytes
|
||||
}
|
||||
|
||||
function Resolve-UnwindReference($Object, $Section, [int]$Offset, [int]$ExpectedSection) {
|
||||
$relocations = @()
|
||||
for ($index = 0; $index -lt $Section.RelocationCount; $index++) {
|
||||
$position = $Section.RelocationOffset + $index * 10
|
||||
if ((Read-UInt32 $Object.Bytes $position) -eq $Offset) { $relocations += $position }
|
||||
}
|
||||
if ($relocations.Count -ne 1) { throw 'Each RUNTIME_FUNCTION field must have exactly one relocation.' }
|
||||
$position = $relocations[0]
|
||||
if ((Read-UInt16 $Object.Bytes ($position + 8)) -ne 3) { throw 'RUNTIME_FUNCTION requires AMD64_ADDR32NB relocations.' }
|
||||
$symbolIndex = Read-UInt32 $Object.Bytes ($position + 4)
|
||||
if (-not $Object.Symbols.ContainsKey([int]$symbolIndex)) { throw 'RUNTIME_FUNCTION references an invalid or auxiliary symbol.' }
|
||||
$symbol = $Object.Symbols[[int]$symbolIndex]
|
||||
if ($symbol.Section -ne $ExpectedSection) { throw 'RUNTIME_FUNCTION references the wrong section.' }
|
||||
return [long]$symbol.Value + (Read-UInt32 $Object.Bytes ($Section.DataOffset + $Offset))
|
||||
}
|
||||
|
||||
function Append-ByteArray([Text.StringBuilder]$Builder, [string]$Name, [byte[]]$Bytes) {
|
||||
[void]$Builder.AppendLine("inline constexpr unsigned char $Name[] =")
|
||||
[void]$Builder.AppendLine('{')
|
||||
for ($index = 0; $index -lt $Bytes.Length; $index += 16) {
|
||||
$values = for ($position = $index; $position -lt [Math]::Min($index + 16, $Bytes.Length); $position++) { '0x{0:X2}' -f $Bytes[$position] }
|
||||
[void]$Builder.AppendLine(' ' + ($values -join ', ') + ',')
|
||||
}
|
||||
[void]$Builder.AppendLine('};')
|
||||
[void]$Builder.AppendLine()
|
||||
}
|
||||
|
||||
$x86 = Read-CoffObject $X86Object 0x014c
|
||||
$x64 = Read-CoffObject $X64Object 0x8664
|
||||
$x86Code = Get-SectionBytes $x86 (Get-Section $x86 '.text')
|
||||
$x64Text = Get-Section $x64 '.text'
|
||||
$x64Unwind = Get-Section $x64 '.xdata'
|
||||
$x64Functions = Get-Section $x64 '.pdata'
|
||||
if ($x64Functions.Size -ne 12 -or $x64Functions.RelocationCount -ne 3) { throw 'The x64 bootstrap must have exactly one RUNTIME_FUNCTION.' }
|
||||
$begin = Resolve-UnwindReference $x64 $x64Functions 0 $x64Text.Index
|
||||
$end = Resolve-UnwindReference $x64 $x64Functions 4 $x64Text.Index
|
||||
$unwindData = Resolve-UnwindReference $x64 $x64Functions 8 $x64Unwind.Index
|
||||
if ($begin -ne 0 -or $end -ne $x64Text.Size -or $unwindData -ne 0) { throw 'The x64 frame must cover all code and reference the first UNWIND_INFO.' }
|
||||
$x64Code = Get-SectionBytes $x64 $x64Text
|
||||
$x64UnwindBytes = Get-SectionBytes $x64 $x64Unwind
|
||||
if ($x64UnwindBytes.Length -lt 4 -or $x64UnwindBytes[0] -ne 1 -or ((4 + 2 * $x64UnwindBytes[2] + 3) -band -4) -gt $x64UnwindBytes.Length) { throw 'Unsupported or truncated x64 UNWIND_INFO.' }
|
||||
$header = New-Object Text.StringBuilder
|
||||
[void]$header.AppendLine('#pragma once')
|
||||
[void]$header.AppendLine()
|
||||
Append-ByteArray $header 'BootstrapX86' $x86Code
|
||||
Append-ByteArray $header 'BootstrapX64' $x64Code
|
||||
Append-ByteArray $header 'BootstrapX64Unwind' $x64UnwindBytes
|
||||
[void]$header.AppendLine('inline constexpr DWORD BootstrapX64Begin = 0x{0:X8};' -f $begin)
|
||||
[void]$header.AppendLine('inline constexpr DWORD BootstrapX64End = 0x{0:X8};' -f $end)
|
||||
[void]$header.AppendLine('inline constexpr DWORD BootstrapX64UnwindData = 0x{0:X8};' -f $unwindData)
|
||||
$outputPath = [IO.Path]::GetFullPath($Output)
|
||||
[void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($outputPath))
|
||||
[IO.File]::WriteAllText($outputPath, $header.ToString(), [Text.Encoding]::ASCII)
|
||||
@@ -0,0 +1,574 @@
|
||||
// Usage: TerraSharp.Injector.exe [TerraSharp.<Vanilla|tModLoader>.dll | build directory]; injects into Terraria (x86, CLR v4) or tModLoader (x64, .NET 8).
|
||||
#include "bootstrap.h"
|
||||
#include "bootstrap.generated.h"
|
||||
#include <metahost.h>
|
||||
#include <tlhelp32.h>
|
||||
#include <psapi.h>
|
||||
#include <winternl.h>
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#ifndef _M_X64
|
||||
#error The injector must be built for x64.
|
||||
#endif
|
||||
|
||||
enum class Game { Terraria, TModLoader };
|
||||
|
||||
struct Target
|
||||
{
|
||||
Game game;
|
||||
const wchar_t* name;
|
||||
const wchar_t* assembly;
|
||||
bool x86;
|
||||
};
|
||||
|
||||
static const Target Targets[] =
|
||||
{
|
||||
{ Game::Terraria, L"Terraria", L"TerraSharp.Vanilla", true },
|
||||
{ Game::TModLoader, L"tModLoader", L"TerraSharp.tModLoader", false },
|
||||
};
|
||||
|
||||
struct Handle
|
||||
{
|
||||
HANDLE value;
|
||||
explicit Handle(HANDLE handle) : value(handle) {}
|
||||
Handle(const Handle&) = delete;
|
||||
Handle& operator=(const Handle&) = delete;
|
||||
~Handle() { if (value && value != INVALID_HANDLE_VALUE) CloseHandle(value); }
|
||||
};
|
||||
|
||||
struct RemoteMemory
|
||||
{
|
||||
HANDLE process;
|
||||
void* address;
|
||||
RemoteMemory(HANDLE target, SIZE_T size, bool x86 = false) : process(target), address(VirtualAllocEx(target, nullptr, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE))
|
||||
{
|
||||
if (address && x86 && reinterpret_cast<UINT64>(address) + size > (UINT64{ 1 } << 32))
|
||||
{
|
||||
VirtualFreeEx(process, address, 0, MEM_RELEASE);
|
||||
address = nullptr;
|
||||
SetLastError(ERROR_INVALID_ADDRESS);
|
||||
}
|
||||
}
|
||||
RemoteMemory(const RemoteMemory&) = delete;
|
||||
RemoteMemory& operator=(const RemoteMemory&) = delete;
|
||||
~RemoteMemory() { if (address) VirtualFreeEx(process, address, 0, MEM_RELEASE); }
|
||||
};
|
||||
|
||||
static bool ContainsNoCase(std::wstring text, std::wstring part)
|
||||
{
|
||||
for (wchar_t& character : text)
|
||||
character = towlower(character);
|
||||
for (wchar_t& character : part)
|
||||
character = towlower(character);
|
||||
return text.find(part) != std::wstring::npos;
|
||||
}
|
||||
|
||||
static std::wstring CommandLine(DWORD processId)
|
||||
{
|
||||
using QueryFunction = NTSTATUS(NTAPI*)(HANDLE, ULONG, PVOID, ULONG, PULONG);
|
||||
static auto query = reinterpret_cast<QueryFunction>(GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtQueryInformationProcess"));
|
||||
const ULONG ProcessCommandLineInformation = 60;
|
||||
Handle process(OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId));
|
||||
if (!process.value || !query)
|
||||
return {};
|
||||
ULONG size = 0;
|
||||
query(process.value, ProcessCommandLineInformation, nullptr, 0, &size);
|
||||
if (!size)
|
||||
return {};
|
||||
std::vector<unsigned char> buffer(size);
|
||||
if (query(process.value, ProcessCommandLineInformation, buffer.data(), size, &size) < 0)
|
||||
return {};
|
||||
auto text = reinterpret_cast<UNICODE_STRING*>(buffer.data());
|
||||
return std::wstring(text->Buffer, text->Length / sizeof(wchar_t));
|
||||
}
|
||||
|
||||
static DWORD FindProcess(Game game)
|
||||
{
|
||||
Handle snapshot(CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0));
|
||||
if (snapshot.value == INVALID_HANDLE_VALUE)
|
||||
return 0;
|
||||
PROCESSENTRY32W entry{ sizeof(entry) };
|
||||
for (BOOL hasProcess = Process32FirstW(snapshot.value, &entry); hasProcess; hasProcess = Process32NextW(snapshot.value, &entry))
|
||||
{
|
||||
if (game == Game::Terraria && _wcsicmp(entry.szExeFile, L"Terraria.exe") == 0)
|
||||
return entry.th32ProcessID;
|
||||
// tModLoader runs as "dotnet tModLoader.dll"; its Steam client helper and dedicated server use the same command.
|
||||
if (game == Game::TModLoader && _wcsicmp(entry.szExeFile, L"dotnet.exe") == 0)
|
||||
{
|
||||
std::wstring command = CommandLine(entry.th32ProcessID);
|
||||
if (ContainsNoCase(command, L"tModLoader.dll") && !ContainsNoCase(command, L"-server") && !ContainsNoCase(command, L"-terrariasteamclient"))
|
||||
return entry.th32ProcessID;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static bool ReadAll(const std::wstring& path, std::vector<unsigned char>& bytes)
|
||||
{
|
||||
std::ifstream file(path, std::ios::binary);
|
||||
if (!file)
|
||||
return false;
|
||||
bytes.assign(std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>());
|
||||
return true;
|
||||
}
|
||||
|
||||
struct PeImage
|
||||
{
|
||||
std::vector<unsigned char> bytes;
|
||||
const IMAGE_NT_HEADERS32* headers = nullptr;
|
||||
|
||||
bool Load(const std::wstring& path)
|
||||
{
|
||||
if (!ReadAll(path, bytes) || bytes.size() < sizeof(IMAGE_DOS_HEADER))
|
||||
return false;
|
||||
auto dosHeader = reinterpret_cast<const IMAGE_DOS_HEADER*>(bytes.data());
|
||||
if (dosHeader->e_magic != IMAGE_DOS_SIGNATURE || dosHeader->e_lfanew <= 0 || static_cast<size_t>(dosHeader->e_lfanew) + sizeof(IMAGE_NT_HEADERS64) > bytes.size())
|
||||
return false;
|
||||
headers = reinterpret_cast<const IMAGE_NT_HEADERS32*>(bytes.data() + dosHeader->e_lfanew);
|
||||
return headers->Signature == IMAGE_NT_SIGNATURE;
|
||||
}
|
||||
|
||||
WORD Machine() const { return headers->FileHeader.Machine; }
|
||||
|
||||
const IMAGE_DATA_DIRECTORY& Directory(int index) const
|
||||
{
|
||||
if (headers->OptionalHeader.Magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC)
|
||||
return reinterpret_cast<const IMAGE_NT_HEADERS64*>(headers)->OptionalHeader.DataDirectory[index];
|
||||
return headers->OptionalHeader.DataDirectory[index];
|
||||
}
|
||||
|
||||
const unsigned char* At(DWORD relativeAddress, DWORD size) const
|
||||
{
|
||||
auto section = IMAGE_FIRST_SECTION(headers);
|
||||
for (WORD i = 0; i < headers->FileHeader.NumberOfSections; i++, section++)
|
||||
{
|
||||
DWORD extent = std::max(section->Misc.VirtualSize, section->SizeOfRawData);
|
||||
if (relativeAddress >= section->VirtualAddress && relativeAddress - section->VirtualAddress + size <= extent)
|
||||
{
|
||||
size_t offset = static_cast<size_t>(section->PointerToRawData) + (relativeAddress - section->VirtualAddress);
|
||||
return offset + size <= bytes.size() ? bytes.data() + offset : nullptr;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Forwarded exports have an RVA inside the export directory; none of the names used here are forwarded.
|
||||
DWORD Export(const char* name) const
|
||||
{
|
||||
const IMAGE_DATA_DIRECTORY& directory = Directory(IMAGE_DIRECTORY_ENTRY_EXPORT);
|
||||
auto exports = reinterpret_cast<const IMAGE_EXPORT_DIRECTORY*>(At(directory.VirtualAddress, sizeof(IMAGE_EXPORT_DIRECTORY)));
|
||||
if (!exports)
|
||||
return 0;
|
||||
auto names = reinterpret_cast<const DWORD*>(At(exports->AddressOfNames, exports->NumberOfNames * sizeof(DWORD)));
|
||||
auto ordinals = reinterpret_cast<const WORD*>(At(exports->AddressOfNameOrdinals, exports->NumberOfNames * sizeof(WORD)));
|
||||
auto functions = reinterpret_cast<const DWORD*>(At(exports->AddressOfFunctions, exports->NumberOfFunctions * sizeof(DWORD)));
|
||||
if (!names || !ordinals || !functions)
|
||||
return 0;
|
||||
for (DWORD i = 0; i < exports->NumberOfNames; i++)
|
||||
{
|
||||
auto exportName = reinterpret_cast<const char*>(At(names[i], 1));
|
||||
if (!exportName || strcmp(exportName, name) != 0 || ordinals[i] >= exports->NumberOfFunctions)
|
||||
continue;
|
||||
DWORD relativeAddress = functions[ordinals[i]];
|
||||
bool forwarded = relativeAddress >= directory.VirtualAddress && relativeAddress < directory.VirtualAddress + directory.Size;
|
||||
return forwarded ? 0 : relativeAddress;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
struct ModuleInfo
|
||||
{
|
||||
unsigned char* address = nullptr;
|
||||
std::wstring path;
|
||||
};
|
||||
|
||||
static std::wstring ModuleFilePath(std::wstring path, bool x86)
|
||||
{
|
||||
if (!x86)
|
||||
return path;
|
||||
wchar_t system[MAX_PATH];
|
||||
UINT systemLength = GetSystemDirectoryW(system, MAX_PATH);
|
||||
if (!systemLength || systemLength >= MAX_PATH || path.size() <= systemLength || path[systemLength] != L'\\' || _wcsnicmp(path.c_str(), system, systemLength) != 0)
|
||||
return path;
|
||||
// WOW64 module paths can retain System32 even when the mapped DLL comes from SysWOW64.
|
||||
wchar_t wow64[MAX_PATH];
|
||||
UINT wow64Length = GetSystemWow64DirectoryW(wow64, MAX_PATH);
|
||||
return wow64Length && wow64Length < MAX_PATH ? std::wstring(wow64, wow64Length) + path.substr(systemLength) : path;
|
||||
}
|
||||
|
||||
static ModuleInfo RemoteModule(HANDLE process, bool x86, const wchar_t* name)
|
||||
{
|
||||
std::vector<HMODULE> modules(1024);
|
||||
DWORD needed = 0;
|
||||
while (true)
|
||||
{
|
||||
if (!EnumProcessModulesEx(process, modules.data(), static_cast<DWORD>(modules.size() * sizeof(HMODULE)), &needed, x86 ? LIST_MODULES_32BIT : LIST_MODULES_64BIT))
|
||||
return {};
|
||||
if (needed <= modules.size() * sizeof(HMODULE))
|
||||
break;
|
||||
modules.resize(needed / sizeof(HMODULE));
|
||||
}
|
||||
for (DWORD i = 0; i < needed / sizeof(HMODULE); i++)
|
||||
{
|
||||
wchar_t base[MAX_PATH];
|
||||
if (GetModuleBaseNameW(process, modules[i], base, MAX_PATH) && _wcsicmp(base, name) == 0)
|
||||
{
|
||||
std::vector<wchar_t> path(32768);
|
||||
DWORD length = GetModuleFileNameExW(process, modules[i], path.data(), static_cast<DWORD>(path.size()));
|
||||
if (length && length < path.size())
|
||||
return { reinterpret_cast<unsigned char*>(modules[i]), ModuleFilePath(std::wstring(path.data(), length), x86) };
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
static void* RemoteExport(HANDLE process, bool x86, const wchar_t* moduleName, const char* name)
|
||||
{
|
||||
ModuleInfo module = RemoteModule(process, x86, moduleName);
|
||||
PeImage image;
|
||||
if (!module.address)
|
||||
return nullptr;
|
||||
if (!image.Load(module.path) || image.Machine() != (x86 ? IMAGE_FILE_MACHINE_I386 : IMAGE_FILE_MACHINE_AMD64))
|
||||
{
|
||||
wprintf(L"cannot read an %s module image: %s\n", x86 ? L"x86" : L"x64", module.path.c_str());
|
||||
return nullptr;
|
||||
}
|
||||
DWORD relativeAddress = image.Export(name);
|
||||
return relativeAddress ? module.address + relativeAddress : nullptr;
|
||||
}
|
||||
|
||||
enum class ThreadResult { NotStarted, Completed, Uncertain };
|
||||
|
||||
static ThreadResult RemoteCall(HANDLE process, void* function, void* argument, DWORD& exitCode)
|
||||
{
|
||||
Handle thread(CreateRemoteThread(process, nullptr, 0, reinterpret_cast<LPTHREAD_START_ROUTINE>(function), argument, 0, nullptr));
|
||||
if (!thread.value)
|
||||
{
|
||||
wprintf(L"CreateRemoteThread failed: %lu\n", GetLastError());
|
||||
return ThreadResult::NotStarted;
|
||||
}
|
||||
if (WaitForSingleObject(thread.value, INFINITE) != WAIT_OBJECT_0 || !GetExitCodeThread(thread.value, &exitCode))
|
||||
{
|
||||
wprintf(L"waiting for the remote thread failed: %lu\n", GetLastError());
|
||||
return ThreadResult::Uncertain;
|
||||
}
|
||||
return ThreadResult::Completed;
|
||||
}
|
||||
|
||||
static const wchar_t* StageName(BootstrapStage stage)
|
||||
{
|
||||
switch (stage)
|
||||
{
|
||||
case BootstrapStage::CreateInstance: return L"CLRCreateInstance";
|
||||
case BootstrapStage::GetRuntime: return L"GetRuntime";
|
||||
case BootstrapStage::GetInterface: return L"GetInterface";
|
||||
case BootstrapStage::Start: return L"Start";
|
||||
case BootstrapStage::Execute: return L"ExecuteInDefaultAppDomain";
|
||||
case BootstrapStage::RegisterUnwind: return L"RtlAddFunctionTable";
|
||||
case BootstrapStage::GetDelegates: return L"hostfxr_get_runtime_delegate";
|
||||
case BootstrapStage::LoadAssembly: return L"load_assembly";
|
||||
case BootstrapStage::GetEntryPoint: return L"get_function_pointer";
|
||||
case BootstrapStage::Invoke: return L"TerraSharp.Entry.Init";
|
||||
case BootstrapStage::RemoveUnwind: return L"RtlDeleteFunctionTable";
|
||||
default: return L"bootstrap";
|
||||
}
|
||||
}
|
||||
|
||||
#pragma pack(push, 1)
|
||||
// Tail-calling RtlAddFunctionTable needs no unwind entry of its own.
|
||||
struct UnwindRegistration
|
||||
{
|
||||
BYTE loadTable[2]{ 0x48, 0xB9 };
|
||||
UINT64 table;
|
||||
BYTE loadCount{ 0xBA };
|
||||
DWORD count{ 1 };
|
||||
BYTE loadBase[2]{ 0x49, 0xB8 };
|
||||
UINT64 base;
|
||||
BYTE loadFunction[2]{ 0x48, 0xB8 };
|
||||
UINT64 function;
|
||||
BYTE tailCall[2]{ 0xFF, 0xE0 };
|
||||
};
|
||||
#pragma pack(pop)
|
||||
static_assert(sizeof(UnwindRegistration) == 37);
|
||||
|
||||
static int Inject(HANDLE process, DWORD processId, const Target& target, const std::wstring& assembly)
|
||||
{
|
||||
BOOL wow64 = FALSE;
|
||||
if (!IsWow64Process(process, &wow64))
|
||||
{
|
||||
wprintf(L"IsWow64Process failed: %lu\n", GetLastError());
|
||||
return 4;
|
||||
}
|
||||
if ((wow64 != FALSE) != target.x86)
|
||||
{
|
||||
wprintf(L"%s must be an %s process\n", target.name, target.x86 ? L"x86" : L"x64");
|
||||
return 4;
|
||||
}
|
||||
BootstrapData data{};
|
||||
data.result = E_PENDING;
|
||||
data.managedResult = -1;
|
||||
void* addFunctionTable = nullptr;
|
||||
void* deleteFunctionTable = nullptr;
|
||||
if (target.x86)
|
||||
{
|
||||
data.clrCreateInstance = reinterpret_cast<UINT64>(RemoteExport(process, true, L"mscoree.dll", "CLRCreateInstance"));
|
||||
if (!data.clrCreateInstance)
|
||||
{
|
||||
wprintf(L"CLRCreateInstance is unavailable in target; wait for Terraria to finish starting\n");
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
data.getRuntimeDelegate = reinterpret_cast<UINT64>(RemoteExport(process, false, L"hostfxr.dll", "hostfxr_get_runtime_delegate"));
|
||||
addFunctionTable = RemoteExport(process, false, L"ntdll.dll", "RtlAddFunctionTable");
|
||||
deleteFunctionTable = RemoteExport(process, false, L"ntdll.dll", "RtlDeleteFunctionTable");
|
||||
if (!data.getRuntimeDelegate || !addFunctionTable || !deleteFunctionTable)
|
||||
{
|
||||
wprintf(L"hostfxr or unwind APIs are unavailable in target; wait for tModLoader to finish starting\n");
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
std::wstring type = L"TerraSharp.Entry";
|
||||
if (!target.x86)
|
||||
{
|
||||
size_t start = assembly.find_last_of(L"\\/") + 1;
|
||||
type += L", " + assembly.substr(start, assembly.size() - start - 4);
|
||||
}
|
||||
const std::wstring version = L"v4.0.30319", method = L"Init", argument;
|
||||
SIZE_T dataSize = sizeof(BootstrapData) + (assembly.size() + type.size() + version.size() + method.size() + argument.size() + 5) * sizeof(wchar_t) + 5 * sizeof(GUID) + 4;
|
||||
SIZE_T codeSize = target.x86 ? sizeof(BootstrapX86) : sizeof(BootstrapX64);
|
||||
SIZE_T unwindOffset = (codeSize + 3) & ~SIZE_T{ 3 };
|
||||
SIZE_T tableOffset = (unwindOffset + sizeof(BootstrapX64Unwind) + 3) & ~SIZE_T{ 3 };
|
||||
SIZE_T registrationOffset = tableOffset + sizeof(RUNTIME_FUNCTION);
|
||||
SIZE_T allocationSize = target.x86 ? codeSize : registrationOffset + sizeof(UnwindRegistration);
|
||||
RemoteMemory remoteData(process, dataSize, target.x86);
|
||||
RemoteMemory remoteCode(process, allocationSize, target.x86);
|
||||
if (!remoteData.address || !remoteCode.address)
|
||||
{
|
||||
wprintf(L"remote allocation failed: %lu\n", GetLastError());
|
||||
return 5;
|
||||
}
|
||||
std::vector<unsigned char> payload(sizeof(BootstrapData));
|
||||
auto append = [&](const void* bytes, SIZE_T size)
|
||||
{
|
||||
UINT64 address = reinterpret_cast<UINT64>(remoteData.address) + payload.size();
|
||||
auto begin = static_cast<const unsigned char*>(bytes);
|
||||
payload.insert(payload.end(), begin, begin + size);
|
||||
return address;
|
||||
};
|
||||
auto text = [&](const std::wstring& value) { return append(value.c_str(), (value.size() + 1) * sizeof(wchar_t)); };
|
||||
data.assembly = text(assembly);
|
||||
data.typeName = text(type);
|
||||
data.methodName = text(method);
|
||||
data.argument = text(argument);
|
||||
data.runtimeVersion = text(version);
|
||||
payload.resize((payload.size() + 3) & ~SIZE_T{ 3 });
|
||||
const GUID guids[]{ CLSID_CLRMetaHost, __uuidof(ICLRMetaHost), __uuidof(ICLRRuntimeInfo), CLSID_CLRRuntimeHost, __uuidof(ICLRRuntimeHost) };
|
||||
data.clsidMetaHost = append(&guids[0], sizeof(GUID));
|
||||
data.iidMetaHost = append(&guids[1], sizeof(GUID));
|
||||
data.iidRuntimeInfo = append(&guids[2], sizeof(GUID));
|
||||
data.clsidRuntimeHost = append(&guids[3], sizeof(GUID));
|
||||
data.iidRuntimeHost = append(&guids[4], sizeof(GUID));
|
||||
memcpy(payload.data(), &data, sizeof(data));
|
||||
|
||||
std::vector<unsigned char> code(allocationSize);
|
||||
memcpy(code.data(), target.x86 ? BootstrapX86 : BootstrapX64, codeSize);
|
||||
auto codeBase = static_cast<unsigned char*>(remoteCode.address);
|
||||
void* functionTable = codeBase + tableOffset;
|
||||
if (!target.x86)
|
||||
{
|
||||
memcpy(code.data() + unwindOffset, BootstrapX64Unwind, sizeof(BootstrapX64Unwind));
|
||||
RUNTIME_FUNCTION function{ BootstrapX64Begin, BootstrapX64End, static_cast<DWORD>(unwindOffset) + BootstrapX64UnwindData };
|
||||
memcpy(code.data() + tableOffset, &function, sizeof(function));
|
||||
UnwindRegistration registration{};
|
||||
registration.table = reinterpret_cast<UINT64>(functionTable);
|
||||
registration.base = reinterpret_cast<UINT64>(codeBase);
|
||||
registration.function = reinterpret_cast<UINT64>(addFunctionTable);
|
||||
memcpy(code.data() + registrationOffset, ®istration, sizeof(registration));
|
||||
}
|
||||
if (!WriteProcessMemory(process, remoteData.address, payload.data(), payload.size(), nullptr) || !WriteProcessMemory(process, remoteCode.address, code.data(), code.size(), nullptr))
|
||||
{
|
||||
wprintf(L"remote write failed: %lu\n", GetLastError());
|
||||
return 5;
|
||||
}
|
||||
|
||||
DWORD oldProtect;
|
||||
if (!VirtualProtectEx(process, remoteCode.address, allocationSize, PAGE_EXECUTE_READ, &oldProtect) || !FlushInstructionCache(process, remoteCode.address, allocationSize))
|
||||
{
|
||||
wprintf(L"preparing bootstrap code failed: %lu\n", GetLastError());
|
||||
return 5;
|
||||
}
|
||||
|
||||
DWORD exitCode = 0;
|
||||
if (!target.x86)
|
||||
{
|
||||
ThreadResult registered = RemoteCall(process, codeBase + registrationOffset, nullptr, exitCode);
|
||||
if (registered != ThreadResult::Completed || !(exitCode & 0xFF))
|
||||
{
|
||||
if (registered == ThreadResult::Uncertain)
|
||||
remoteCode.address = remoteData.address = nullptr;
|
||||
wprintf(L"RtlAddFunctionTable failed\n");
|
||||
return 6;
|
||||
}
|
||||
}
|
||||
ThreadResult ran = RemoteCall(process, codeBase + (target.x86 ? 0 : BootstrapX64Begin), remoteData.address, exitCode);
|
||||
if (ran == ThreadResult::Uncertain)
|
||||
{
|
||||
// A running thread or registered unwind table can still reference these allocations.
|
||||
remoteCode.address = remoteData.address = nullptr;
|
||||
return 6;
|
||||
}
|
||||
bool read = ran == ThreadResult::Completed && ReadProcessMemory(process, remoteData.address, &data, sizeof(data), nullptr);
|
||||
DWORD readError = read ? ERROR_SUCCESS : GetLastError();
|
||||
if (!target.x86)
|
||||
{
|
||||
DWORD removed = 0;
|
||||
if (RemoteCall(process, deleteFunctionTable, functionTable, removed) != ThreadResult::Completed || !(removed & 0xFF))
|
||||
{
|
||||
remoteCode.address = remoteData.address = nullptr;
|
||||
wprintf(L"RtlDeleteFunctionTable failed; keeping the remote bootstrap allocated\n");
|
||||
return 6;
|
||||
}
|
||||
}
|
||||
if (ran == ThreadResult::NotStarted)
|
||||
return 6;
|
||||
if (!read)
|
||||
{
|
||||
wprintf(L"reading bootstrap result failed: %lu\n", readError);
|
||||
return 7;
|
||||
}
|
||||
if (!data.completed)
|
||||
{
|
||||
wprintf(L"bootstrap stopped during %s (exit=0x%08lX)\n", StageName(data.stage), exitCode);
|
||||
return 7;
|
||||
}
|
||||
if (FAILED(data.result))
|
||||
{
|
||||
wprintf(L"%s failed (hr=0x%08lX)\n", StageName(data.stage), static_cast<DWORD>(data.result));
|
||||
return 7;
|
||||
}
|
||||
if (data.managedResult != 0)
|
||||
{
|
||||
wprintf(L"TerraSharp.Entry.Init failed (return=0x%08lX)\n", static_cast<DWORD>(data.managedResult));
|
||||
return 8;
|
||||
}
|
||||
wprintf(L"injected into %s pid %lu\n", target.name, processId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static std::wstring FullPath(const wchar_t* path)
|
||||
{
|
||||
DWORD length = GetFullPathNameW(path, 0, nullptr, nullptr);
|
||||
if (!length)
|
||||
return {};
|
||||
std::vector<wchar_t> buffer(length);
|
||||
DWORD written = GetFullPathNameW(path, length, buffer.data(), nullptr);
|
||||
return written && written < length ? std::wstring(buffer.data(), written) : std::wstring();
|
||||
}
|
||||
|
||||
static bool IsFile(const std::wstring& path)
|
||||
{
|
||||
DWORD attributes = GetFileAttributesW(path.c_str());
|
||||
return attributes != INVALID_FILE_ATTRIBUTES && !(attributes & FILE_ATTRIBUTE_DIRECTORY);
|
||||
}
|
||||
|
||||
static std::wstring WithSlash(std::wstring path)
|
||||
{
|
||||
if (!path.empty() && path.back() != L'\\' && path.back() != L'/')
|
||||
path += L'\\';
|
||||
return path;
|
||||
}
|
||||
|
||||
// Matched by prefix: build-and-inject.bat suffixes the assembly and file name with a per-build stamp.
|
||||
static std::wstring FindAssembly(const std::wstring& directory, const Target& target)
|
||||
{
|
||||
WIN32_FIND_DATAW found;
|
||||
HANDLE search = FindFirstFileW((directory + target.assembly + L"*.dll").c_str(), &found);
|
||||
if (search == INVALID_HANDLE_VALUE)
|
||||
return {};
|
||||
std::wstring result = directory + found.cFileName;
|
||||
bool single = !FindNextFileW(search, &found);
|
||||
FindClose(search);
|
||||
return single ? result : std::wstring();
|
||||
}
|
||||
|
||||
int wmain(int argc, wchar_t** argv)
|
||||
{
|
||||
if (argc > 2)
|
||||
{
|
||||
wprintf(L"usage: TerraSharp.Injector.exe [TerraSharp.<Vanilla|tModLoader>.dll | build directory]\n");
|
||||
return 1;
|
||||
}
|
||||
std::vector<wchar_t> self(32768);
|
||||
DWORD selfLength = GetModuleFileNameW(nullptr, self.data(), static_cast<DWORD>(self.size()));
|
||||
std::wstring exeDirectory = selfLength && selfLength < self.size() ? std::wstring(self.data(), selfLength) : std::wstring();
|
||||
exeDirectory.resize(exeDirectory.find_last_of(L"\\/") + 1);
|
||||
std::wstring input = argc == 2 ? FullPath(argv[1]) : exeDirectory;
|
||||
if (input.empty() || exeDirectory.empty())
|
||||
{
|
||||
wprintf(L"resolving paths failed: %lu\n", GetLastError());
|
||||
return 1;
|
||||
}
|
||||
|
||||
const Target* target = nullptr;
|
||||
DWORD processId = 0;
|
||||
std::wstring assembly;
|
||||
if (IsFile(input))
|
||||
{
|
||||
PeImage image;
|
||||
WORD machine = image.Load(input) ? image.Machine() : 0;
|
||||
target = machine == IMAGE_FILE_MACHINE_I386 ? &Targets[0] : machine == IMAGE_FILE_MACHINE_AMD64 ? &Targets[1] : nullptr;
|
||||
if (!target)
|
||||
{
|
||||
wprintf(L"not an x86 or x64 TerraSharp build: %s\n", input.c_str());
|
||||
return 1;
|
||||
}
|
||||
assembly = input;
|
||||
processId = FindProcess(target->game);
|
||||
}
|
||||
else
|
||||
{
|
||||
DWORD terraria = FindProcess(Game::Terraria), tModLoader = FindProcess(Game::TModLoader);
|
||||
if (terraria && tModLoader)
|
||||
{
|
||||
wprintf(L"Terraria and tModLoader are both running; pass the DLL to inject\n");
|
||||
return 2;
|
||||
}
|
||||
target = tModLoader ? &Targets[1] : &Targets[0];
|
||||
processId = tModLoader ? tModLoader : terraria;
|
||||
std::wstring directory = WithSlash(input);
|
||||
assembly = FindAssembly(directory, *target);
|
||||
if (processId && assembly.empty())
|
||||
{
|
||||
wprintf(L"expected exactly one %s*.dll in %s\n", target->assembly, directory.c_str());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if (!IsFile(assembly))
|
||||
{
|
||||
wprintf(L"managed DLL not found: %s\n", assembly.c_str());
|
||||
return 1;
|
||||
}
|
||||
if (!processId)
|
||||
{
|
||||
wprintf(L"%s is not running\n", target->name);
|
||||
return 2;
|
||||
}
|
||||
Handle process(OpenProcess(PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ, FALSE, processId));
|
||||
if (!process.value)
|
||||
{
|
||||
wprintf(L"OpenProcess failed: %lu\n", GetLastError());
|
||||
return 3;
|
||||
}
|
||||
return Inject(process.value, processId, *target, assembly);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using Terraria;
|
||||
using TerraSharp.Core;
|
||||
|
||||
namespace TerraSharp;
|
||||
|
||||
internal static class Autoload
|
||||
{
|
||||
static int _started;
|
||||
|
||||
public static void Start()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _started, 1) == 0)
|
||||
new Thread(Run) { IsBackground = true, Name = "TerraSharp loader" }.Start();
|
||||
}
|
||||
|
||||
static void Run()
|
||||
{
|
||||
try
|
||||
{
|
||||
WaitForEngine();
|
||||
Load();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error("Autoload failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
static void WaitForEngine()
|
||||
{
|
||||
// Wait for the window before accessing Main to avoid running its static constructor on this thread.
|
||||
using Process process = Process.GetCurrentProcess();
|
||||
while (process.MainWindowHandle == IntPtr.Zero)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
process.Refresh();
|
||||
}
|
||||
|
||||
// Set right before Main.OnEngineLoad on both Terraria and tModLoader.
|
||||
FieldInfo engineLoaded = typeof(Main).GetField("IsEngineLoaded", BindingFlags.Static | BindingFlags.NonPublic)!;
|
||||
while (!(bool)engineLoaded.GetValue(null)!)
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
static void Load()
|
||||
{
|
||||
Log.Info("Autoload " + typeof(Autoload).Assembly.FullName + " from " + typeof(Autoload).Assembly.Location);
|
||||
Plugin.Load();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using TerraSharp.Core;
|
||||
|
||||
namespace TerraSharp;
|
||||
|
||||
// Registered through appDomainManagerType in Terraria.exe.config.
|
||||
public sealed class LoaderDomainManager : AppDomainManager
|
||||
{
|
||||
public override void InitializeNewDomain(AppDomainSetup setup)
|
||||
{
|
||||
try
|
||||
{
|
||||
AppDomain.CurrentDomain.AssemblyResolve += Entry.ResolveEmbedded;
|
||||
Autoload.Start();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error("Autoload init failed", e);
|
||||
}
|
||||
base.InitializeNewDomain(setup);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace TerraSharp.Core;
|
||||
|
||||
// Advanced by the local Player.Update postfix.
|
||||
public static class Clock
|
||||
{
|
||||
public static int Tick { get; private set; }
|
||||
|
||||
public static void Advance() => Tick++;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Terraria;
|
||||
|
||||
namespace TerraSharp.Core;
|
||||
|
||||
// Terraria aims at Main.mouseX/Y + screenPosition.
|
||||
public static class GameMouse
|
||||
{
|
||||
public static (int X, int Y) Save() => (Main.mouseX, Main.mouseY);
|
||||
|
||||
public static void Restore((int X, int Y) saved) => (Main.mouseX, Main.mouseY) = saved;
|
||||
|
||||
public static void PointAt(Vector2 world)
|
||||
{
|
||||
Main.mouseX = (int)(world.X - Main.screenPosition.X);
|
||||
float y = world.Y - Main.screenPosition.Y;
|
||||
Main.mouseY = (int)(Main.LocalPlayer.gravDir == -1f ? Main.screenHeight - y : y);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Terraria;
|
||||
using TerraSharp.Features;
|
||||
using TerraSharp.Features.Combat;
|
||||
using TerraSharp.UI;
|
||||
|
||||
namespace TerraSharp.Core;
|
||||
|
||||
public static class Hotkeys
|
||||
{
|
||||
public sealed record Bind(string Group, string Id, string Label, Action Run);
|
||||
|
||||
const string InterfaceGroup = "Interface";
|
||||
|
||||
static Bind Toggle(string group, string id, string label, Func<bool> get, Action<bool> set) => new(group, id, label, () =>
|
||||
{
|
||||
bool enabled = !get();
|
||||
set(enabled);
|
||||
Notify(label + (enabled ? ": On" : ": Off"), enabled ? Widgets.On : new Color(200, 200, 200));
|
||||
});
|
||||
|
||||
public static readonly IReadOnlyList<Bind> All =
|
||||
[
|
||||
new(InterfaceGroup, "Menu", "Open / close menu", () => WindowManager.Toggle(WindowManager.Menu)),
|
||||
new(InterfaceGroup, "ItemBrowser", "Item Browser", () => WindowManager.Toggle(WindowManager.Items)),
|
||||
new(InterfaceGroup, "BuffBrowser", "Buff Browser", () => WindowManager.Toggle(WindowManager.Buffs)),
|
||||
|
||||
Toggle("Player", "GodMode", "God Mode", () => Settings.GodMode, v => Settings.GodMode = v),
|
||||
Toggle("Player", "AutoHeal", "Auto Heal", () => Settings.AutoHeal, v => Settings.AutoHeal = v),
|
||||
Toggle("Player", "AutoMana", "Auto Mana", () => Settings.AutoMana, v => Settings.AutoMana = v),
|
||||
new("Player", "FullHeal", "Full Heal", PlayerFeatures.FullHeal),
|
||||
new("Player", "TpSpawn", "Teleport to Spawn", PlayerFeatures.TeleportSpawn),
|
||||
new("Player", "TpCursor", "Teleport to Cursor", PlayerFeatures.TeleportCursor),
|
||||
|
||||
Toggle("Movement", "Fly", "Fly", () => Settings.Fly, v => Settings.Fly = v),
|
||||
Toggle("Movement", "SuperSpeed", "Super Speed", () => Settings.SuperSpeed, v => Settings.SuperSpeed = v),
|
||||
Toggle("Movement", "GravityFlip", "Gravity Flip", () => Settings.GravityFlip, v => Settings.GravityFlip = v),
|
||||
Toggle("Movement", "ExtendedReach", "Extended Reach", () => Settings.ExtendedReach, v => Settings.ExtendedReach = v),
|
||||
Toggle("Movement", "HighJump", "High Jump", () => Settings.HighJump, v => Settings.HighJump = v),
|
||||
Toggle("Movement", "WaterWalk", "Water Walk", () => Settings.WaterWalk, v => Settings.WaterWalk = v),
|
||||
Toggle("Movement", "InfFlight", "Infinite Flight", () => Settings.InfFlight, v => Settings.InfFlight = v),
|
||||
|
||||
Toggle("Combat", "Aimbot", "Aimbot", () => Settings.Aimbot, v => Settings.Aimbot = v),
|
||||
Toggle("Combat", "NoSpread", "No Spread", () => Settings.NoSpread, v => Settings.NoSpread = v),
|
||||
Toggle("Combat", "AutoAttack", "Auto Attack", () => Settings.AutoAttack, v => Settings.AutoAttack = v),
|
||||
Toggle("Combat", "KillAura", "Kill Aura", () => Settings.KillAura, v => Settings.KillAura = v),
|
||||
Toggle("Combat", "HitMagnet", "Hit Magnet", () => Settings.HitMagnet, v => Settings.HitMagnet = v),
|
||||
Toggle("Combat", "FreezeEnemies", "Freeze Enemies", () => Settings.FreezeEnemies, v => Settings.FreezeEnemies = v),
|
||||
Toggle("Combat", "WeakenEnemies", "Weaken Enemies", () => Settings.WeakenEnemies, v => Settings.WeakenEnemies = v),
|
||||
Toggle("Combat", "RapidAttack", "Rapid Attack", () => Settings.RapidAttack, v => Settings.RapidAttack = v),
|
||||
new("Combat", "GatherAll", "Gather All Enemies", CombatFeatures.GatherAll),
|
||||
new("Combat", "KillAll", "Kill All Enemies", CombatFeatures.KillAll),
|
||||
|
||||
Toggle("Items", "AutoFish", "Auto Fishing", () => Settings.AutoFish, v => Settings.AutoFish = v),
|
||||
Toggle("Items", "ItemVacuum", "Item Vacuum", () => Settings.ItemVacuum, v => Settings.ItemVacuum = v),
|
||||
Toggle("Items", "InstantBreak", "Instant Break", () => Settings.InstantBreak, v => Settings.InstantBreak = v),
|
||||
Toggle("Items", "InfItems", "Infinite Items", () => Settings.InfItems, v => Settings.InfItems = v),
|
||||
|
||||
Toggle("Visuals", "Esp", "ESP", () => Settings.Esp, v => Settings.Esp = v),
|
||||
Toggle("Visuals", "Spelunker", "Spelunker Vision", () => Settings.Spelunker, v => Settings.Spelunker = v),
|
||||
Toggle("Visuals", "Hunter", "Hunter", () => Settings.Hunter, v => Settings.Hunter = v),
|
||||
Toggle("Visuals", "DangerSense", "Danger Sense", () => Settings.DangerSense, v => Settings.DangerSense = v),
|
||||
Toggle("Visuals", "BiomeSight", "Biome Sight", () => Settings.BiomeSight, v => Settings.BiomeSight = v),
|
||||
Toggle("Visuals", "NightOwl", "Night Owl", () => Settings.NightOwl, v => Settings.NightOwl = v),
|
||||
Toggle("Visuals", "Shine", "Shine", () => Settings.Shine, v => Settings.Shine = v),
|
||||
|
||||
Toggle("World", "FreezeTime", "Freeze Time", () => Settings.FreezeTime, v => Settings.FreezeTime = v),
|
||||
new("World", "SavePosition", "Save Current Position", Waypoints.AddHere),
|
||||
];
|
||||
|
||||
// Windows virtual-key codes for mouse buttons; XNA's Keys leaves them unassigned.
|
||||
const Keys MiddleMouse = (Keys)4;
|
||||
const Keys Mouse4 = (Keys)5;
|
||||
const Keys Mouse5 = (Keys)6;
|
||||
static readonly Keys[] MouseKeys = [MiddleMouse, Mouse4, Mouse5];
|
||||
|
||||
public static Bind? Capturing;
|
||||
static KeyboardState _previousKeyboard;
|
||||
static MouseState _previousMouse;
|
||||
|
||||
public static void Notify(string text, Color color)
|
||||
{
|
||||
if (!Main.gameMenu)
|
||||
CombatText.NewText(Main.LocalPlayer.Hitbox, color, text, false, false);
|
||||
}
|
||||
|
||||
public static string KeyName(Keys key) => key switch
|
||||
{
|
||||
Keys.None => "None",
|
||||
MiddleMouse => "Middle Mouse",
|
||||
Mouse4 => "Mouse 4",
|
||||
Mouse5 => "Mouse 5",
|
||||
_ => key.ToString().Replace("OemTilde", "~").Replace("Oem", "").Replace("NumPad", "Num "),
|
||||
};
|
||||
|
||||
public static Keys Get(string id) => Settings.Binds.TryGetValue(id, out Keys key) ? key : Keys.None;
|
||||
|
||||
public static void Clear(string id) => Settings.Binds[id] = Keys.None;
|
||||
|
||||
static bool IsDown(Keys key, KeyboardState keyboard, MouseState mouse) => key switch
|
||||
{
|
||||
MiddleMouse => mouse.MiddleButton == ButtonState.Pressed,
|
||||
Mouse4 => mouse.XButton1 == ButtonState.Pressed,
|
||||
Mouse5 => mouse.XButton2 == ButtonState.Pressed,
|
||||
_ => keyboard.IsKeyDown(key),
|
||||
};
|
||||
|
||||
static bool Pressed(Keys key, KeyboardState keyboard, MouseState mouse) => IsDown(key, keyboard, mouse) && !IsDown(key, _previousKeyboard, _previousMouse);
|
||||
|
||||
public static void Update()
|
||||
{
|
||||
KeyboardState keyboard = Keyboard.GetState();
|
||||
MouseState mouse = Mouse.GetState();
|
||||
if (Capturing is not null)
|
||||
{
|
||||
Capture(keyboard, mouse);
|
||||
_previousKeyboard = keyboard;
|
||||
_previousMouse = mouse;
|
||||
return;
|
||||
}
|
||||
|
||||
bool blocked = !Main.instance.IsActive || WindowManager.Focus is not null || Main.drawingPlayerChat || Main.editSign || Main.editChest;
|
||||
if (!blocked)
|
||||
foreach (Bind bind in All)
|
||||
{
|
||||
if (Main.gameMenu && bind.Group != InterfaceGroup)
|
||||
continue;
|
||||
|
||||
Keys key = Get(bind.Id);
|
||||
if (key != Keys.None && Pressed(key, keyboard, mouse))
|
||||
bind.Run();
|
||||
}
|
||||
_previousKeyboard = keyboard;
|
||||
_previousMouse = mouse;
|
||||
}
|
||||
|
||||
static void Capture(KeyboardState keyboard, MouseState mouse)
|
||||
{
|
||||
foreach (Keys key in keyboard.GetPressedKeys().Concat(MouseKeys))
|
||||
{
|
||||
if (!Pressed(key, keyboard, mouse))
|
||||
continue;
|
||||
|
||||
switch (key)
|
||||
{
|
||||
case Keys.Escape:
|
||||
break;
|
||||
case Keys.Back or Keys.Delete:
|
||||
Clear(Capturing!.Id);
|
||||
Widgets.Sound(12);
|
||||
break;
|
||||
default:
|
||||
Settings.Binds[Capturing!.Id] = key;
|
||||
Widgets.Sound(12);
|
||||
break;
|
||||
}
|
||||
|
||||
WindowManager.Unfocus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
namespace TerraSharp.Core;
|
||||
|
||||
// Objects parse to Dictionary<string, object>, arrays to List<object>, numbers to double.
|
||||
public static class Json
|
||||
{
|
||||
public static string Serialize(object? value)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
Write(builder, value, 0);
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
public static object? Parse(string text)
|
||||
{
|
||||
var reader = new Reader(text);
|
||||
object? value = reader.Value();
|
||||
reader.End();
|
||||
return value;
|
||||
}
|
||||
|
||||
public static T ConvertTo<T>(object? value) => (T)ConvertTo(typeof(T), value)!;
|
||||
|
||||
static object? ConvertTo(Type type, object? value)
|
||||
{
|
||||
if (value is null)
|
||||
return type.IsValueType ? Activator.CreateInstance(type) : null;
|
||||
if (type.IsInstanceOfType(value))
|
||||
return value;
|
||||
if (type.IsEnum)
|
||||
return value is string stringValue ? Enum.Parse(type, stringValue) : Enum.ToObject(type, Convert.ToInt64(value, CultureInfo.InvariantCulture));
|
||||
if (type.IsPrimitive || type == typeof(string) || type == typeof(decimal))
|
||||
return Convert.ChangeType(value, type, CultureInfo.InvariantCulture);
|
||||
|
||||
if (type.IsArray && value is List<object> items)
|
||||
{
|
||||
Type element = type.GetElementType()!;
|
||||
var array = Array.CreateInstance(element, items.Count);
|
||||
for (int i = 0; i < items.Count; i++)
|
||||
array.SetValue(ConvertTo(element, items[i]), i);
|
||||
return array;
|
||||
}
|
||||
|
||||
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>) && value is List<object> list)
|
||||
{
|
||||
var result = (IList)Activator.CreateInstance(type)!;
|
||||
Type element = type.GetGenericArguments()[0];
|
||||
foreach (object item in list)
|
||||
result.Add(ConvertTo(element, item));
|
||||
return result;
|
||||
}
|
||||
|
||||
if (value is Dictionary<string, object> fields)
|
||||
{
|
||||
object result = Activator.CreateInstance(type)!;
|
||||
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
if (property.CanWrite && fields.TryGetValue(property.Name, out object? field))
|
||||
property.SetValue(result, ConvertTo(property.PropertyType, field));
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new FormatException($"cannot convert {value.GetType().Name} to {type.Name}");
|
||||
}
|
||||
|
||||
static void Write(StringBuilder builder, object? value, int depth)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null:
|
||||
builder.Append("null");
|
||||
break;
|
||||
case bool booleanValue:
|
||||
builder.Append(booleanValue ? "true" : "false");
|
||||
break;
|
||||
case string stringValue:
|
||||
WriteString(builder, stringValue);
|
||||
break;
|
||||
case float floatValue:
|
||||
builder.Append(floatValue.ToString("R", CultureInfo.InvariantCulture));
|
||||
break;
|
||||
case double doubleValue:
|
||||
builder.Append(doubleValue.ToString("R", CultureInfo.InvariantCulture));
|
||||
break;
|
||||
case Enum enumValue:
|
||||
WriteString(builder, enumValue.ToString());
|
||||
break;
|
||||
case IConvertible convertibleValue when convertibleValue.GetTypeCode() is not (TypeCode.Object or TypeCode.Char or TypeCode.DateTime):
|
||||
builder.Append(convertibleValue.ToString(CultureInfo.InvariantCulture));
|
||||
break;
|
||||
case IDictionary dictionary:
|
||||
{
|
||||
var entries = new List<KeyValuePair<string, object?>>();
|
||||
foreach (DictionaryEntry entry in dictionary)
|
||||
entries.Add(new(Convert.ToString(entry.Key, CultureInfo.InvariantCulture)!, entry.Value));
|
||||
|
||||
WriteObject(builder, entries, depth);
|
||||
break;
|
||||
}
|
||||
case IEnumerable items:
|
||||
{
|
||||
List<object?> list = [.. items];
|
||||
if (list.Count == 0)
|
||||
{
|
||||
builder.Append("[]");
|
||||
break;
|
||||
}
|
||||
|
||||
builder.Append('[');
|
||||
for (int i = 0; i < list.Count; i++)
|
||||
{
|
||||
builder.Append(i == 0 ? "" : ",");
|
||||
NewLine(builder, depth + 1);
|
||||
Write(builder, list[i], depth + 1);
|
||||
}
|
||||
|
||||
NewLine(builder, depth);
|
||||
builder.Append(']');
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
var entries = new List<KeyValuePair<string, object?>>();
|
||||
foreach (PropertyInfo property in value.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
if (property.CanRead && property.GetIndexParameters().Length == 0)
|
||||
entries.Add(new(property.Name, property.GetValue(value)));
|
||||
|
||||
WriteObject(builder, entries, depth);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void WriteObject(StringBuilder builder, List<KeyValuePair<string, object?>> entries, int depth)
|
||||
{
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
builder.Append("{}");
|
||||
return;
|
||||
}
|
||||
|
||||
builder.Append('{');
|
||||
for (int i = 0; i < entries.Count; i++)
|
||||
{
|
||||
builder.Append(i == 0 ? "" : ",");
|
||||
NewLine(builder, depth + 1);
|
||||
WriteString(builder, entries[i].Key);
|
||||
builder.Append(": ");
|
||||
Write(builder, entries[i].Value, depth + 1);
|
||||
}
|
||||
|
||||
NewLine(builder, depth);
|
||||
builder.Append('}');
|
||||
}
|
||||
|
||||
static void NewLine(StringBuilder builder, int depth) => builder.Append('\n').Append(' ', depth * 2);
|
||||
|
||||
static void WriteString(StringBuilder builder, string text)
|
||||
{
|
||||
builder.Append('"');
|
||||
foreach (char character in text)
|
||||
{
|
||||
switch (character)
|
||||
{
|
||||
case '"': builder.Append("\\\""); break;
|
||||
case '\\': builder.Append("\\\\"); break;
|
||||
case '\n': builder.Append("\\n"); break;
|
||||
case '\r': builder.Append("\\r"); break;
|
||||
case '\t': builder.Append("\\t"); break;
|
||||
case < ' ': builder.Append("\\u").Append(((int)character).ToString("x4", CultureInfo.InvariantCulture)); break;
|
||||
default: builder.Append(character); break;
|
||||
}
|
||||
}
|
||||
builder.Append('"');
|
||||
}
|
||||
|
||||
sealed class Reader(string text)
|
||||
{
|
||||
int _position;
|
||||
|
||||
public void End()
|
||||
{
|
||||
Skip();
|
||||
if (_position < text.Length)
|
||||
throw Error("trailing characters");
|
||||
}
|
||||
|
||||
public object? Value()
|
||||
{
|
||||
Skip();
|
||||
|
||||
if (_position >= text.Length)
|
||||
throw Error("unexpected end");
|
||||
|
||||
switch (text[_position])
|
||||
{
|
||||
case '{':
|
||||
return Object();
|
||||
case '[':
|
||||
return Array();
|
||||
case '"':
|
||||
return String();
|
||||
case 't':
|
||||
Literal("true");
|
||||
return true;
|
||||
case 'f':
|
||||
Literal("false");
|
||||
return false;
|
||||
case 'n':
|
||||
Literal("null");
|
||||
return null;
|
||||
default:
|
||||
return Number();
|
||||
}
|
||||
}
|
||||
|
||||
Dictionary<string, object> Object()
|
||||
{
|
||||
var result = new Dictionary<string, object>();
|
||||
_position++;
|
||||
if (Peek() == '}')
|
||||
{
|
||||
_position++;
|
||||
return result;
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
Skip();
|
||||
if (Peek() != '"')
|
||||
throw Error("expected property name");
|
||||
|
||||
string key = String();
|
||||
Expect(':');
|
||||
result[key] = Value()!;
|
||||
|
||||
char next = Peek();
|
||||
_position++;
|
||||
if (next == '}')
|
||||
return result;
|
||||
if (next != ',')
|
||||
throw Error("expected ',' or '}'");
|
||||
}
|
||||
}
|
||||
|
||||
List<object> Array()
|
||||
{
|
||||
var result = new List<object>();
|
||||
_position++;
|
||||
if (Peek() == ']')
|
||||
{
|
||||
_position++;
|
||||
return result;
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
result.Add(Value()!);
|
||||
|
||||
char next = Peek();
|
||||
_position++;
|
||||
if (next == ']')
|
||||
return result;
|
||||
if (next != ',')
|
||||
throw Error("expected ',' or ']'");
|
||||
}
|
||||
}
|
||||
|
||||
string String()
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
_position++;
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (_position >= text.Length)
|
||||
throw Error("unterminated string");
|
||||
|
||||
char character = text[_position++];
|
||||
if (character == '"')
|
||||
return builder.ToString();
|
||||
if (character != '\\')
|
||||
{
|
||||
builder.Append(character);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_position >= text.Length)
|
||||
throw Error("unterminated string");
|
||||
|
||||
char escape = text[_position++];
|
||||
builder.Append(escape switch
|
||||
{
|
||||
'b' => '\b',
|
||||
'f' => '\f',
|
||||
'n' => '\n',
|
||||
'r' => '\r',
|
||||
't' => '\t',
|
||||
'u' => Unicode(),
|
||||
'"' or '\\' or '/' => escape,
|
||||
_ => throw Error("bad escape"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
char Unicode()
|
||||
{
|
||||
if (_position + 4 > text.Length || !int.TryParse(text.Substring(_position, 4), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int code))
|
||||
throw Error("bad escape");
|
||||
|
||||
_position += 4;
|
||||
return (char)code;
|
||||
}
|
||||
|
||||
double Number()
|
||||
{
|
||||
int start = _position;
|
||||
while (_position < text.Length && text[_position] is '-' or '+' or '.' or 'e' or 'E' or (>= '0' and <= '9'))
|
||||
_position++;
|
||||
if (_position == start || !double.TryParse(text.Substring(start, _position - start), NumberStyles.Float, CultureInfo.InvariantCulture, out double value))
|
||||
throw Error("bad value");
|
||||
return value;
|
||||
}
|
||||
|
||||
void Literal(string literal)
|
||||
{
|
||||
if (string.CompareOrdinal(text, _position, literal, 0, literal.Length) != 0)
|
||||
throw Error("bad value");
|
||||
|
||||
_position += literal.Length;
|
||||
}
|
||||
|
||||
void Expect(char character)
|
||||
{
|
||||
if (Peek() != character)
|
||||
throw Error($"expected '{character}'");
|
||||
_position++;
|
||||
}
|
||||
|
||||
char Peek()
|
||||
{
|
||||
Skip();
|
||||
return _position < text.Length ? text[_position] : '\0';
|
||||
}
|
||||
|
||||
void Skip()
|
||||
{
|
||||
while (_position < text.Length && text[_position] is ' ' or '\t' or '\r' or '\n' or '')
|
||||
_position++;
|
||||
}
|
||||
|
||||
FormatException Error(string message) => new($"json: {message} at {_position}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace TerraSharp.Core;
|
||||
|
||||
public static class Log
|
||||
{
|
||||
enum Level { Debug, Info, Warning, Error }
|
||||
|
||||
#if TERRASHARP_LOGGING
|
||||
static readonly string LogPath = Path.Combine(Path.GetTempPath(), "terrasharp.log");
|
||||
static readonly object SyncLock = new();
|
||||
static readonly Dictionary<string, int> Seen = [];
|
||||
#endif
|
||||
|
||||
[Conditional("TERRASHARP_LOGGING")]
|
||||
public static void Debug(string message) => Write(message, Level.Debug);
|
||||
|
||||
[Conditional("TERRASHARP_LOGGING")]
|
||||
public static void Info(string message) => Write(message, Level.Info);
|
||||
|
||||
[Conditional("TERRASHARP_LOGGING")]
|
||||
public static void Warning(string message) => Write(message, Level.Warning);
|
||||
|
||||
[Conditional("TERRASHARP_LOGGING")]
|
||||
public static void Error(string message) => Write(message, Level.Error);
|
||||
|
||||
// Per-frame code: each distinct failure is logged at most three times.
|
||||
[Conditional("TERRASHARP_LOGGING")]
|
||||
public static void Error(string where, Exception exception)
|
||||
{
|
||||
#if TERRASHARP_LOGGING
|
||||
lock (SyncLock)
|
||||
{
|
||||
string key = where + exception.GetType().Name + exception.Message;
|
||||
Seen.TryGetValue(key, out int count);
|
||||
if (count >= 3)
|
||||
return;
|
||||
|
||||
Seen[key] = count + 1;
|
||||
Write($"{where}: {exception}", Level.Error);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
[Conditional("TERRASHARP_LOGGING")]
|
||||
static void Write(string message, Level level)
|
||||
{
|
||||
#if TERRASHARP_LOGGING
|
||||
try
|
||||
{
|
||||
lock (SyncLock)
|
||||
File.AppendAllText(LogPath, $"{DateTime.Now:HH:mm:ss.fff} [{level.ToString().ToUpperInvariant()}] {message}{Environment.NewLine}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using TerraSharp.Features;
|
||||
using TerraSharp.Patches;
|
||||
using TerraSharp.UI;
|
||||
|
||||
namespace TerraSharp.Core;
|
||||
|
||||
public static class Plugin
|
||||
{
|
||||
public const string Id = "terrasharp";
|
||||
const string ReloadKey = "TerraSharp.Unload";
|
||||
static readonly object ReloadLock = GetReloadLock();
|
||||
|
||||
static readonly object SyncLock = new();
|
||||
static HookRegistry? _hooks;
|
||||
static readonly ManualResetEventSlim Unloaded = new(true);
|
||||
static volatile bool _installPending, _unloadPending;
|
||||
static volatile bool _active;
|
||||
|
||||
public static bool Active => _active;
|
||||
|
||||
static object GetReloadLock()
|
||||
{
|
||||
lock (AppDomain.CurrentDomain)
|
||||
{
|
||||
const string key = "TerraSharp.ReloadLock";
|
||||
object value = AppDomain.CurrentDomain.GetData(key) ?? new object();
|
||||
AppDomain.CurrentDomain.SetData(key, value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// The injector installs hooks; Tick initializes game state on the main thread.
|
||||
public static void Load()
|
||||
{
|
||||
lock (ReloadLock)
|
||||
{
|
||||
if (Active)
|
||||
return;
|
||||
|
||||
(AppDomain.CurrentDomain.GetData(ReloadKey) as Action)?.Invoke();
|
||||
|
||||
lock (SyncLock)
|
||||
{
|
||||
_hooks = HookRegistry.Install();
|
||||
Unloaded.Reset();
|
||||
_active = true;
|
||||
_installPending = true;
|
||||
AppDomain.CurrentDomain.SetData(ReloadKey, (Action)UnloadForReload);
|
||||
Log.Info("Loaded");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void RequestUnload() => _unloadPending = true;
|
||||
|
||||
static void UnloadForReload()
|
||||
{
|
||||
if (Active)
|
||||
RequestUnload();
|
||||
|
||||
if (!Unloaded.Wait(TimeSpan.FromSeconds(10)))
|
||||
throw new TimeoutException("The previous TerraSharp build did not finish unloading.");
|
||||
}
|
||||
|
||||
// Main.DoUpdate postfix: main thread, outside drawing.
|
||||
public static void Tick()
|
||||
{
|
||||
if (_installPending)
|
||||
{
|
||||
_installPending = false;
|
||||
Settings.Load();
|
||||
InterfaceLayers.Install();
|
||||
WindowManager.Show(WindowManager.Menu);
|
||||
}
|
||||
|
||||
if (_unloadPending)
|
||||
{
|
||||
_unloadPending = false;
|
||||
Unload();
|
||||
}
|
||||
}
|
||||
|
||||
// Injected assemblies remain loaded after their hooks are removed.
|
||||
static void Unload()
|
||||
{
|
||||
if (!Active)
|
||||
return;
|
||||
|
||||
_active = false;
|
||||
Guard.Run("Unload settings", Settings.Flush);
|
||||
Guard.Run("Unload windows", WindowManager.HideAll);
|
||||
Guard.Run("Unload mouse", InterfaceLayers.Unblock);
|
||||
Guard.Run("Unload layers", InterfaceLayers.Uninstall);
|
||||
Guard.Run("Unload item overrides", HeldItemOverrides.Restore);
|
||||
Guard.Run("Unload player overrides", PlayerFeatures.OnUnload);
|
||||
|
||||
HookRegistry hooks = _hooks!;
|
||||
// Hook disposal waits for active calls, including the current update hook.
|
||||
new Thread(() =>
|
||||
{
|
||||
lock (SyncLock)
|
||||
{
|
||||
try
|
||||
{
|
||||
hooks.Dispose();
|
||||
_hooks = null;
|
||||
Unloaded.Set();
|
||||
Log.Info("Unloaded");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error("Hook removal failed", e);
|
||||
}
|
||||
}
|
||||
}) { IsBackground = true }.Start();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
#if TERRASHARP_LOGGING
|
||||
using System.Text;
|
||||
#endif
|
||||
|
||||
namespace TerraSharp.Core;
|
||||
|
||||
public enum Feature { None, Aimbot, AutoAttack, NpcMotion, ShotPreview, Esp }
|
||||
|
||||
public enum Impact { Idle, Negligible, Low, Medium, High }
|
||||
|
||||
// Exclusive main-thread time per feature: a nested scope pauses the one around it.
|
||||
public static class Profiler
|
||||
{
|
||||
public const float FrameBudget = 1000f / 60f;
|
||||
const int WindowTicks = 30;
|
||||
const int MaxDepth = 16;
|
||||
|
||||
static readonly int Count = Enum.GetValues(typeof(Feature)).Length;
|
||||
static readonly double MsPerTimestamp = 1000.0 / Stopwatch.Frequency;
|
||||
static readonly long[] Current = new long[Count], Window = new long[Count], WindowPeak = new long[Count];
|
||||
static readonly float[] Averages = new float[Count], Peaks = new float[Count];
|
||||
static readonly bool[] Ran = new bool[Count];
|
||||
static readonly Feature[] Stack = new Feature[MaxDepth];
|
||||
static int _depth, _overflow, _windowTicks;
|
||||
static Feature _running;
|
||||
static long _since;
|
||||
#if TERRASHARP_LOGGING
|
||||
static int _windows;
|
||||
#endif
|
||||
|
||||
public readonly struct Scope : IDisposable
|
||||
{
|
||||
public void Dispose() => End();
|
||||
}
|
||||
|
||||
public static Scope Measure(Feature feature)
|
||||
{
|
||||
if (_depth == MaxDepth)
|
||||
{
|
||||
_overflow++;
|
||||
return default;
|
||||
}
|
||||
|
||||
long now = Stopwatch.GetTimestamp();
|
||||
Current[(int)_running] += now - _since;
|
||||
Stack[_depth++] = _running;
|
||||
(_running, _since) = (feature, now);
|
||||
return default;
|
||||
}
|
||||
|
||||
static void End()
|
||||
{
|
||||
if (_overflow > 0)
|
||||
{
|
||||
_overflow--;
|
||||
return;
|
||||
}
|
||||
|
||||
long now = Stopwatch.GetTimestamp();
|
||||
Current[(int)_running] += now - _since;
|
||||
_running = _depth > 0 ? Stack[--_depth] : Feature.None;
|
||||
_since = now;
|
||||
}
|
||||
|
||||
// Main.DoUpdate postfix; draws since the previous update count toward this tick.
|
||||
public static void EndTick()
|
||||
{
|
||||
for (int i = 1; i < Count; i++)
|
||||
{
|
||||
Window[i] += Current[i];
|
||||
WindowPeak[i] = Math.Max(WindowPeak[i], Current[i]);
|
||||
Current[i] = 0;
|
||||
}
|
||||
|
||||
if (++_windowTicks < WindowTicks)
|
||||
return;
|
||||
|
||||
for (int i = 1; i < Count; i++)
|
||||
{
|
||||
float average = (float)(Window[i] * MsPerTimestamp / WindowTicks);
|
||||
Averages[i] += (average - Averages[i]) * 0.5f;
|
||||
Peaks[i] = (float)(WindowPeak[i] * MsPerTimestamp);
|
||||
Ran[i] = Window[i] > 0;
|
||||
Window[i] = WindowPeak[i] = 0;
|
||||
}
|
||||
_windowTicks = 0;
|
||||
|
||||
LogSummary();
|
||||
}
|
||||
|
||||
public static float Average(Feature feature) => Averages[(int)feature];
|
||||
|
||||
public static float Peak(Feature feature) => Peaks[(int)feature];
|
||||
|
||||
// A single tick at four times the average budget share stutters as much as a steady cost.
|
||||
public static Impact ImpactOf(Feature feature) => !Ran[(int)feature] ? Impact.Idle : Math.Max(Average(feature), Peak(feature) / 4f) switch
|
||||
{
|
||||
< 0.05f => Impact.Negligible,
|
||||
< 0.5f => Impact.Low,
|
||||
< 2f => Impact.Medium,
|
||||
_ => Impact.High,
|
||||
};
|
||||
|
||||
[Conditional("TERRASHARP_LOGGING")]
|
||||
static void LogSummary()
|
||||
{
|
||||
#if TERRASHARP_LOGGING
|
||||
if (++_windows % 20 != 0)
|
||||
return;
|
||||
|
||||
var text = new StringBuilder("Profiler (ms per tick, avg/peak):");
|
||||
for (int i = 1; i < Count; i++)
|
||||
if (Ran[i])
|
||||
text.Append($" {(Feature)i} {Averages[i]:0.000}/{Peaks[i]:0.00} {ImpactOf((Feature)i)};");
|
||||
Log.Debug(text.ToString());
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TerraSharp.Core;
|
||||
|
||||
public static partial class Settings
|
||||
{
|
||||
static readonly Dictionary<string, string> Paths = new()
|
||||
{
|
||||
[nameof(GodMode)] = "Player.GodMode",
|
||||
[nameof(InfHealth)] = "Player.InfiniteHealth",
|
||||
[nameof(InfMana)] = "Player.InfiniteMana",
|
||||
[nameof(InfBreath)] = "Player.InfiniteBreath",
|
||||
[nameof(InfFlight)] = "Player.InfiniteFlight",
|
||||
[nameof(InstantRespawn)] = "Player.InstantRespawn",
|
||||
[nameof(AutoHeal)] = "Player.AutoHeal.Enabled",
|
||||
[nameof(AutoHealAt)] = "Player.AutoHeal.Threshold",
|
||||
[nameof(AutoMana)] = "Player.AutoMana.Enabled",
|
||||
[nameof(AutoManaAt)] = "Player.AutoMana.Threshold",
|
||||
[nameof(BuffMinutes)] = "Player.Buffs.Minutes",
|
||||
|
||||
[nameof(Fly)] = "Movement.Fly.Enabled",
|
||||
[nameof(FlySpeed)] = "Movement.Fly.Speed",
|
||||
[nameof(SuperSpeed)] = "Movement.SuperSpeed.Enabled",
|
||||
[nameof(SpeedMult)] = "Movement.SuperSpeed.Multiplier",
|
||||
[nameof(HighJump)] = "Movement.HighJump",
|
||||
[nameof(NoFallDamage)] = "Movement.NoFallDamage",
|
||||
[nameof(LavaImmune)] = "Movement.LavaImmune",
|
||||
[nameof(WaterWalk)] = "Movement.WaterWalk",
|
||||
[nameof(NoKnockback)] = "Movement.NoKnockback",
|
||||
[nameof(GravityFlip)] = "Movement.GravityFlip",
|
||||
[nameof(ExtendedReach)] = "Movement.ExtendedReach.Enabled",
|
||||
[nameof(ReachX)] = "Movement.ExtendedReach.Horizontal",
|
||||
[nameof(ReachY)] = "Movement.ExtendedReach.Vertical",
|
||||
|
||||
[nameof(RapidAttack)] = "Combat.Weapon.RapidAttack",
|
||||
[nameof(AutoSwing)] = "Combat.Weapon.AutoSwing",
|
||||
[nameof(DamageBoost)] = "Combat.Weapon.DamageBoost.Enabled",
|
||||
[nameof(DamageMult)] = "Combat.Weapon.DamageBoost.Multiplier",
|
||||
[nameof(NoManaCost)] = "Combat.Weapon.NoManaCost",
|
||||
[nameof(NoSpread)] = "Combat.Weapon.NoSpread",
|
||||
|
||||
[nameof(KillAura)] = "Combat.KillAura.Enabled",
|
||||
[nameof(KillAuraRange)] = "Combat.KillAura.Range",
|
||||
[nameof(KillAuraInterval)] = "Combat.KillAura.Interval",
|
||||
[nameof(HitMagnet)] = "Combat.HitMagnet.Enabled",
|
||||
[nameof(HitMagnetRange)] = "Combat.HitMagnet.Range",
|
||||
[nameof(FreezeEnemies)] = "Combat.FreezeEnemies",
|
||||
[nameof(WeakenEnemies)] = "Combat.WeakenEnemies",
|
||||
|
||||
[nameof(Aimbot)] = "Combat.Aim.Aimbot",
|
||||
[nameof(AimPrediction)] = "Combat.Aim.Prediction",
|
||||
[nameof(AimDrawTarget)] = "Combat.Aim.ShowTarget",
|
||||
[nameof(AutoAttack)] = "Combat.Aim.AutoAttack",
|
||||
[nameof(AimExact)] = "Combat.Aim.ExactCheck",
|
||||
[nameof(ShotPreview)] = "Combat.Aim.ShotPreview",
|
||||
[nameof(AimFov)] = "Combat.Aim.CursorRadius",
|
||||
[nameof(AimRange)] = "Combat.Aim.Range",
|
||||
|
||||
[nameof(InstantBreak)] = "Items.InstantBreak",
|
||||
[nameof(InfItems)] = "Items.InfiniteItems",
|
||||
[nameof(ItemVacuum)] = "Items.Vacuum",
|
||||
|
||||
[nameof(AutoFish)] = "Fishing.Enabled",
|
||||
[nameof(AutoRecast)] = "Fishing.Recast",
|
||||
[nameof(FishEnemies)] = "Fishing.CatchEnemies",
|
||||
[nameof(Sonar)] = "Fishing.Sonar",
|
||||
|
||||
[nameof(Esp)] = "Esp.Enabled",
|
||||
[nameof(EspNames)] = "Esp.Names",
|
||||
[nameof(EspHealth)] = "Esp.HealthBars",
|
||||
[nameof(EspHealthText)] = "Esp.HealthNumbers",
|
||||
[nameof(EspDistance)] = "Esp.Distance",
|
||||
[nameof(EspLines)] = "Esp.Tracers",
|
||||
[nameof(EspProjPath)] = "Esp.ProjectilePaths",
|
||||
[nameof(EspStyle)] = "Esp.Style",
|
||||
[nameof(EspThickness)] = "Esp.Thickness",
|
||||
[nameof(EspMaxDistance)] = "Esp.MaxDistance",
|
||||
[nameof(EspLocal)] = "Esp.LocalPlayer.Enabled",
|
||||
[nameof(ColLocal)] = "Esp.LocalPlayer.Color",
|
||||
[nameof(EspPlayers)] = "Esp.Players.Enabled",
|
||||
[nameof(ColPlayers)] = "Esp.Players.Color",
|
||||
[nameof(EspHostile)] = "Esp.HostileNpcs.Enabled",
|
||||
[nameof(ColHostile)] = "Esp.HostileNpcs.Color",
|
||||
[nameof(EspCritters)] = "Esp.Critters.Enabled",
|
||||
[nameof(ColCritters)] = "Esp.Critters.Color",
|
||||
[nameof(EspTown)] = "Esp.TownNpcs.Enabled",
|
||||
[nameof(ColTown)] = "Esp.TownNpcs.Color",
|
||||
[nameof(EspBossRainbow)] = "Esp.Bosses.Rainbow",
|
||||
[nameof(ColBoss)] = "Esp.Bosses.Color",
|
||||
[nameof(EspItems)] = "Esp.Items.Enabled",
|
||||
[nameof(ColItems)] = "Esp.Items.Color",
|
||||
[nameof(EspProjHostile)] = "Esp.HostileProjectiles.Enabled",
|
||||
[nameof(ColProjHostile)] = "Esp.HostileProjectiles.Color",
|
||||
[nameof(EspProjFriendly)] = "Esp.FriendlyProjectiles.Enabled",
|
||||
[nameof(ColProjFriendly)] = "Esp.FriendlyProjectiles.Color",
|
||||
[nameof(EspWaypoints)] = "Esp.Waypoints.Enabled",
|
||||
[nameof(ColWaypoint)] = "Esp.Waypoints.Color",
|
||||
|
||||
[nameof(Spelunker)] = "Vision.Spelunker",
|
||||
[nameof(Hunter)] = "Vision.Hunter",
|
||||
[nameof(Shine)] = "Vision.Shine",
|
||||
[nameof(NightOwl)] = "Vision.NightOwl",
|
||||
[nameof(DangerSense)] = "Vision.DangerSense",
|
||||
[nameof(BiomeSight)] = "Vision.BiomeSight",
|
||||
|
||||
[nameof(FreezeTime)] = "World.FreezeTime",
|
||||
|
||||
[nameof(LogBallistics)] = "Ballistics.Log",
|
||||
};
|
||||
|
||||
static void SetValue(Dictionary<string, object> document, string path, object value)
|
||||
{
|
||||
string[] parts = path.Split('.');
|
||||
for (int i = 0; i < parts.Length - 1; i++)
|
||||
{
|
||||
if (!document.TryGetValue(parts[i], out object? section))
|
||||
document[parts[i]] = section = new Dictionary<string, object>();
|
||||
document = (Dictionary<string, object>)section;
|
||||
}
|
||||
|
||||
document[parts[parts.Length - 1]] = value;
|
||||
}
|
||||
|
||||
static bool TryGetValue(Dictionary<string, object> document, string path, out object? value)
|
||||
{
|
||||
value = document;
|
||||
foreach (string part in path.Split('.'))
|
||||
if (value is not Dictionary<string, object> section || !section.TryGetValue(part, out value))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static Dictionary<string, object> Section(Dictionary<string, object> document, string path) => TryGetValue(document, path, out object? value) && value is Dictionary<string, object> section ? section : [];
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Terraria;
|
||||
using TerraSharp.Features;
|
||||
using TerraSharp.Simulation;
|
||||
using TerraSharp.UI;
|
||||
|
||||
namespace TerraSharp.Core;
|
||||
|
||||
public enum EspBoxStyle
|
||||
{
|
||||
Outline,
|
||||
Corners,
|
||||
Filled,
|
||||
}
|
||||
|
||||
// Public mutable fields are persisted through Settings.Json.cs.
|
||||
public static partial class Settings
|
||||
{
|
||||
public static bool GodMode, InfHealth, InfMana, InfBreath, InfFlight, InstantRespawn;
|
||||
public static bool AutoHeal, AutoMana;
|
||||
public static float AutoHealAt = 40f, AutoManaAt = 25f, BuffMinutes = 10f;
|
||||
|
||||
public static bool Fly, SuperSpeed, HighJump, NoFallDamage, LavaImmune, WaterWalk, NoKnockback, GravityFlip, ExtendedReach;
|
||||
public static float FlySpeed = 12f, SpeedMult = 3f;
|
||||
public static int ReachX = 35, ReachY = 30;
|
||||
|
||||
public static bool RapidAttack, AutoSwing, DamageBoost, NoManaCost, NoSpread;
|
||||
public static float DamageMult = 5f;
|
||||
public static bool KillAura, HitMagnet, FreezeEnemies, WeakenEnemies;
|
||||
public static float KillAuraRange = 20f, HitMagnetRange = 30f;
|
||||
public static int KillAuraInterval = 10;
|
||||
public static bool Aimbot, AimPrediction = true, AimDrawTarget = true, AutoAttack, AimExact = true, ShotPreview;
|
||||
public static float AimFov = 250f, AimRange = 60f;
|
||||
|
||||
public static bool InstantBreak, InfItems, ItemVacuum;
|
||||
public static bool AutoFish, AutoRecast = true, FishEnemies, Sonar;
|
||||
|
||||
public static bool Esp, EspNames = true, EspHealth = true, EspHealthText, EspDistance, EspLines, EspProjPath = true;
|
||||
public static bool EspLocal, EspPlayers = true, EspHostile = true, EspCritters, EspTown, EspBossRainbow = true, EspItems, EspProjHostile, EspProjFriendly, EspWaypoints = true;
|
||||
public static EspBoxStyle EspStyle;
|
||||
public static float EspThickness = 1f, EspMaxDistance; // tiles, 0 = unlimited
|
||||
public static Color ColLocal = Color.White, ColPlayers = new(70, 200, 255), ColHostile = new(235, 60, 60), ColCritters = new(200, 170, 120), ColTown = new(80, 220, 110), ColBoss = new(255, 120, 40), ColItems = new(255, 210, 70), ColProjHostile = new(255, 80, 200), ColProjFriendly = new(120, 160, 255), ColWaypoint = new(220, 90, 255);
|
||||
public static bool Spelunker, Hunter, Shine, NightOwl, DangerSense, BiomeSight;
|
||||
|
||||
public static bool FreezeTime;
|
||||
|
||||
public static bool LogBallistics = true;
|
||||
|
||||
public static readonly Dictionary<string, Keys> Binds = [];
|
||||
|
||||
static readonly FieldInfo[] Fields = typeof(Settings).GetFields(BindingFlags.Public | BindingFlags.Static);
|
||||
static readonly Dictionary<string, object> Defaults = [];
|
||||
static string? _lastSaved;
|
||||
static int _tick;
|
||||
|
||||
static string FilePath => Path.Combine(Main.SavePath, "TerraSharp.json");
|
||||
|
||||
static Settings()
|
||||
{
|
||||
foreach (FieldInfo field in Fields)
|
||||
if (!field.IsInitOnly)
|
||||
Defaults[field.Name] = field.GetValue(null)!;
|
||||
}
|
||||
|
||||
public static object? Default(string field) => Defaults.TryGetValue(field, out object? value) ? value : null;
|
||||
|
||||
static string Serialize()
|
||||
{
|
||||
var document = new Dictionary<string, object>();
|
||||
foreach (FieldInfo field in Fields)
|
||||
{
|
||||
if (field.IsInitOnly)
|
||||
continue;
|
||||
|
||||
object value = field.GetValue(null) switch
|
||||
{
|
||||
Color color => Hex(color),
|
||||
Enum option => option.ToString(),
|
||||
var setting => setting!,
|
||||
};
|
||||
SetValue(document, Paths[field.Name], value);
|
||||
}
|
||||
|
||||
var binds = new Dictionary<string, string>();
|
||||
foreach (KeyValuePair<string, Keys> bind in Binds)
|
||||
binds[bind.Key] = bind.Value.ToString();
|
||||
document["Binds"] = binds;
|
||||
|
||||
var windows = new Dictionary<string, int[]>();
|
||||
foreach (Window window in WindowManager.All)
|
||||
windows[window.Id] = [(int)window.Pos.X, (int)window.Pos.Y];
|
||||
document["Windows"] = windows;
|
||||
|
||||
document["Waypoints"] = Waypoints.Save();
|
||||
|
||||
var models = new Dictionary<string, Ballistics.SavedModel>();
|
||||
foreach (KeyValuePair<int, Ballistics.SavedModel> model in Ballistics.Save())
|
||||
models[model.Key.ToString(CultureInfo.InvariantCulture)] = model.Value;
|
||||
SetValue(document, "Ballistics.Models", models);
|
||||
|
||||
return Json.Serialize(document);
|
||||
}
|
||||
|
||||
public static void Load()
|
||||
{
|
||||
Binds.Clear();
|
||||
Binds["Menu"] = Keys.Insert;
|
||||
try
|
||||
{
|
||||
if (!File.Exists(FilePath))
|
||||
return;
|
||||
|
||||
var document = Json.Parse(File.ReadAllText(FilePath)) as Dictionary<string, object> ?? throw new FormatException("expected a JSON object");
|
||||
foreach (KeyValuePair<string, string> setting in Paths)
|
||||
if (TryGetValue(document, setting.Value, out object? value))
|
||||
TryLoad(setting.Value, () => Apply(setting.Key, Convert.ToString(value, CultureInfo.InvariantCulture) ?? ""));
|
||||
|
||||
foreach (KeyValuePair<string, object> bind in Section(document, "Binds"))
|
||||
TryLoad("Binds." + bind.Key, () => Binds[bind.Key] = (Keys)Enum.Parse(typeof(Keys), (string)bind.Value));
|
||||
|
||||
foreach (KeyValuePair<string, object> window in Section(document, "Windows"))
|
||||
{
|
||||
TryLoad("Windows." + window.Key, () =>
|
||||
{
|
||||
int[] position = Json.ConvertTo<int[]>(window.Value);
|
||||
foreach (Window target in WindowManager.All)
|
||||
if (target.Id == window.Key)
|
||||
target.Pos = new Vector2(position[0], position[1]);
|
||||
});
|
||||
}
|
||||
|
||||
foreach (KeyValuePair<string, object> world in Section(document, "Waypoints"))
|
||||
TryLoad("Waypoints." + world.Key, () => Waypoints.Load(world.Key, Json.ConvertTo<List<Waypoints.SavedWaypoint>>(world.Value)));
|
||||
|
||||
foreach (KeyValuePair<string, object> model in Section(document, "Ballistics.Models"))
|
||||
TryLoad("Ballistics.Models." + model.Key, () => Ballistics.Load(int.Parse(model.Key, CultureInfo.InvariantCulture), Json.ConvertTo<Ballistics.SavedModel>(model.Value)));
|
||||
|
||||
_lastSaved = Serialize();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error("settings load failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
static void TryLoad(string key, Action load)
|
||||
{
|
||||
try
|
||||
{
|
||||
load();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Warning($"settings: bad entry '{key}': {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
static void Apply(string key, string value)
|
||||
{
|
||||
FieldInfo? field = typeof(Settings).GetField(key, BindingFlags.Public | BindingFlags.Static);
|
||||
if (field is null || field.IsInitOnly)
|
||||
return;
|
||||
|
||||
Type type = field.FieldType;
|
||||
object? parsed = type switch
|
||||
{
|
||||
_ when type == typeof(bool) => bool.Parse(value),
|
||||
_ when type == typeof(int) => int.Parse(value, CultureInfo.InvariantCulture),
|
||||
_ when type == typeof(float) => float.Parse(value, CultureInfo.InvariantCulture),
|
||||
_ when type == typeof(Color) => ParseHex(value),
|
||||
_ when type.IsEnum => Enum.Parse(type, value),
|
||||
_ => null,
|
||||
};
|
||||
if (parsed is not null)
|
||||
field.SetValue(null, parsed);
|
||||
}
|
||||
|
||||
public static void AutoSave()
|
||||
{
|
||||
if (++_tick % 120 == 0)
|
||||
Flush();
|
||||
}
|
||||
|
||||
public static void Flush()
|
||||
{
|
||||
try
|
||||
{
|
||||
string json = Serialize();
|
||||
if (json == _lastSaved)
|
||||
return;
|
||||
|
||||
File.WriteAllText(FilePath, json);
|
||||
_lastSaved = json;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error("settings save", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void ResetAll()
|
||||
{
|
||||
foreach (KeyValuePair<string, object> pair in Defaults)
|
||||
typeof(Settings).GetField(pair.Key)!.SetValue(null, pair.Value);
|
||||
}
|
||||
|
||||
public static string Hex(Color color) => $"{color.R:X2}{color.G:X2}{color.B:X2}{color.A:X2}";
|
||||
|
||||
public static Color ParseHex(string text)
|
||||
{
|
||||
text = text.Trim().TrimStart('#');
|
||||
if (text.Length is not (6 or 8))
|
||||
throw new FormatException("expected RRGGBB or RRGGBBAA");
|
||||
|
||||
uint packed = uint.Parse(text, NumberStyles.HexNumber);
|
||||
return text.Length == 6
|
||||
? new Color((int)(packed >> 16 & 255), (int)(packed >> 8 & 255), (int)(packed & 255))
|
||||
: new Color((int)(packed >> 24 & 255), (int)(packed >> 16 & 255), (int)(packed >> 8 & 255), (int)(packed & 255));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TerraSharp.Core;
|
||||
|
||||
namespace TerraSharp;
|
||||
|
||||
// Native entry point for ExecuteInDefaultAppDomain and hostfxr get_function_pointer.
|
||||
public static class Entry
|
||||
{
|
||||
#if TMODLOADER
|
||||
public static int Init(IntPtr argument, int size) => Run();
|
||||
#else
|
||||
static readonly Dictionary<string, Assembly> Dependencies = new(StringComparer.Ordinal);
|
||||
|
||||
public static int Init(string argument)
|
||||
{
|
||||
AppDomain.CurrentDomain.AssemblyResolve += ResolveEmbedded;
|
||||
return Run();
|
||||
}
|
||||
#endif
|
||||
|
||||
static int Run()
|
||||
{
|
||||
try
|
||||
{
|
||||
Log.Info("Init " + typeof(Entry).Assembly.FullName + " from " + typeof(Entry).Assembly.Location);
|
||||
Load();
|
||||
return 0;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error("Init failed", e);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Dependencies must resolve before MonoMod types are JIT-compiled.
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
static void Load() => Plugin.Load();
|
||||
#if !TMODLOADER
|
||||
|
||||
internal static Assembly? ResolveEmbedded(object? sender, ResolveEventArgs args)
|
||||
{
|
||||
string? name = new AssemblyName(args.Name).Name;
|
||||
if (name is null)
|
||||
return null;
|
||||
|
||||
lock (Dependencies)
|
||||
{
|
||||
if (Dependencies.TryGetValue(name, out Assembly? assembly))
|
||||
return assembly;
|
||||
|
||||
using Stream? stream = typeof(Entry).Assembly.GetManifestResourceStream("Dependencies." + name + ".dll");
|
||||
if (stream is null)
|
||||
return null;
|
||||
|
||||
foreach (Assembly loaded in AppDomain.CurrentDomain.GetAssemblies())
|
||||
if (loaded.GetName().Name == name)
|
||||
return Dependencies[name] = loaded;
|
||||
|
||||
using var reader = new BinaryReader(stream);
|
||||
return Dependencies[name] = Assembly.Load(reader.ReadBytes(checked((int)stream.Length)));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Terraria;
|
||||
using TerraSharp.Core;
|
||||
using TerraSharp.Features.Combat;
|
||||
using TerraSharp.UI;
|
||||
|
||||
namespace TerraSharp.Features;
|
||||
|
||||
// A bite is a bobber with ai[1] < 0; localAI[1] holds the catch (item type, or -npc type for enemy catches).
|
||||
public static class AutoFishing
|
||||
{
|
||||
static Vector2? _castAt;
|
||||
static int _cooldown;
|
||||
|
||||
// Player.ItemCheckWrapped prefix: controls are final, ItemCheck is about to run.
|
||||
public static void PreItemCheck(Player player)
|
||||
{
|
||||
if (!Settings.AutoFish || player.HeldItem.fishingPole <= 0 || player.dead || WindowManager.MouseOver)
|
||||
return;
|
||||
|
||||
if (_cooldown > 0)
|
||||
_cooldown--;
|
||||
|
||||
bool any = false, bite = false;
|
||||
for (int i = 0; i < Main.maxProjectiles; i++)
|
||||
{
|
||||
Projectile projectile = Main.projectile[i];
|
||||
if (!projectile.active || !projectile.bobber || projectile.owner != Main.myPlayer)
|
||||
continue;
|
||||
|
||||
any = true;
|
||||
bite |= projectile.ai[0] == 0f && projectile.ai[1] < 0f && (projectile.localAI[1] > 0f || Settings.FishEnemies);
|
||||
}
|
||||
|
||||
if (!any && player.controlUseItem && player.releaseUseItem)
|
||||
_castAt = Main.MouseWorld;
|
||||
|
||||
if (player.itemAnimation > 0 || _cooldown > 0)
|
||||
return;
|
||||
|
||||
if (bite)
|
||||
{
|
||||
Use(player);
|
||||
}
|
||||
else if (!any && Settings.AutoRecast && _castAt is { } castAt)
|
||||
{
|
||||
AimOverride.Set(castAt);
|
||||
Use(player);
|
||||
}
|
||||
}
|
||||
|
||||
static void Use(Player player)
|
||||
{
|
||||
player.controlUseItem = true;
|
||||
player.releaseUseItem = true;
|
||||
_cooldown = 20;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using TerraSharp.Core;
|
||||
|
||||
namespace TerraSharp.Features.Combat;
|
||||
|
||||
// Restored in the ItemCheckWrapped cleanup.
|
||||
public static class AimOverride
|
||||
{
|
||||
static (int X, int Y)? _saved;
|
||||
|
||||
public static void Set(Vector2 world)
|
||||
{
|
||||
_saved ??= GameMouse.Save();
|
||||
GameMouse.PointAt(world);
|
||||
}
|
||||
|
||||
public static void Restore()
|
||||
{
|
||||
if (_saved is not { } saved)
|
||||
return;
|
||||
GameMouse.Restore(saved);
|
||||
_saved = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Terraria;
|
||||
using Terraria.ID;
|
||||
#if TMODLOADER
|
||||
using Terraria.ModLoader;
|
||||
#endif
|
||||
using TerraSharp.Core;
|
||||
using TerraSharp.Simulation;
|
||||
using TerraSharp.UI;
|
||||
|
||||
namespace TerraSharp.Features.Combat;
|
||||
|
||||
public enum ShotCheck
|
||||
{
|
||||
Unsupported,
|
||||
Miss,
|
||||
Hit,
|
||||
}
|
||||
|
||||
public static class Aimbot
|
||||
{
|
||||
public static NPC? Target { get; private set; }
|
||||
|
||||
public static NPC? Unreachable { get; private set; }
|
||||
|
||||
public static Vector2 AimPoint { get; private set; }
|
||||
|
||||
public static Vector2 Origin { get; private set; }
|
||||
|
||||
public static bool Firing { get; private set; }
|
||||
|
||||
public static bool Verified { get; private set; }
|
||||
|
||||
const int GridX = 3, GridY = 5;
|
||||
const int MaxEvaluated = 3;
|
||||
|
||||
static Vector2 _spot;
|
||||
static int _swingDirection;
|
||||
static int _correctionNpc = -1, _correctionItem;
|
||||
static Vector2 _correction;
|
||||
static readonly Vector2[] PlayerPath = new Vector2[90];
|
||||
static readonly Rectangle[] SwingBoxes = new Rectangle[90], BackSwingBoxes = new Rectangle[90];
|
||||
static readonly Vector2[] Flight = new Vector2[151];
|
||||
static readonly Vector2[] NpcOffsets = new Vector2[151];
|
||||
static readonly bool[,] Reachable = new bool[GridX, GridY];
|
||||
static readonly Vector2[,] Directions = new Vector2[GridX, GridY];
|
||||
|
||||
static bool IsWeapon(Item item) => item is { damage: > 0, pick: 0, axe: 0, hammer: 0, fishingPole: 0, createTile: < 0, createWall: < 0 };
|
||||
|
||||
static bool Shoots(Item item) => IsWeapon(item) && item is { shoot: > 0, shootSpeed: > 0f };
|
||||
|
||||
static bool Swings(Item item) => IsWeapon(item) && item is { useStyle: 1, noMelee: false };
|
||||
|
||||
static bool Stabs(Item item) => Shoots(item) && !item.channel && ContentSamples.ProjectilesByType.TryGetValue(item.shoot, out Projectile? sample) && Sandbox.Stabs(sample);
|
||||
|
||||
// Raw mouse in backbuffer pixels; Main.mouseX during the update tick depends on the last applied zoom.
|
||||
static Vector2 Cursor
|
||||
{
|
||||
get
|
||||
{
|
||||
MouseState mouseState = Mouse.GetState();
|
||||
return new Vector2(mouseState.X, mouseState.Y);
|
||||
}
|
||||
}
|
||||
|
||||
static List<NPC> Candidates(Player player, bool useFov, NPC? previousTarget)
|
||||
{
|
||||
Vector2 cursor = Cursor;
|
||||
float range = Settings.AimRange * 16f;
|
||||
var scored = new List<(NPC Npc, float Score)>();
|
||||
|
||||
for (int i = 0; i < Main.maxNPCs; i++)
|
||||
{
|
||||
NPC npc = Main.npc[i];
|
||||
if (!Hostility.IsDamageable(npc) || Vector2.Distance(npc.Center, player.Center) > range)
|
||||
continue;
|
||||
|
||||
float distance = Vector2.Distance(Render.ToScreen(npc.Center), cursor);
|
||||
if (useFov && Settings.AimFov > 0f && distance > Settings.AimFov && npc != previousTarget)
|
||||
continue;
|
||||
|
||||
scored.Add((npc, distance - (npc == previousTarget ? 80f : 0f)));
|
||||
}
|
||||
|
||||
scored.Sort((a, b) => a.Score.CompareTo(b.Score));
|
||||
List<NPC> result = new(Math.Min(8, scored.Count));
|
||||
for (int i = 0; i < result.Capacity; i++)
|
||||
result.Add(scored[i].Npc);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool BestSpot(NPC npc, int projectileType, float speed, out Vector2 spot, out Vector2 direction)
|
||||
{
|
||||
float cellWidth = npc.width / (float)GridX, cellHeight = npc.height / (float)GridY;
|
||||
float tolerance = Math.Max(4f, Math.Min(cellWidth, cellHeight) * 0.6f);
|
||||
|
||||
Vector2 Cell(int x, int y) => npc.position + new Vector2(cellWidth * (x + 0.5f), cellHeight * (y + 0.5f));
|
||||
|
||||
bool hasReachableSpot = false;
|
||||
for (int x = 0; x < GridX; x++)
|
||||
for (int y = 0; y < GridY; y++)
|
||||
{
|
||||
Reachable[x, y] = Ballistics.Solve(projectileType, speed, Origin, Cell(x, y), Settings.AimPrediction ? npc : null, tolerance, out Directions[x, y]);
|
||||
hasReachableSpot |= Reachable[x, y];
|
||||
}
|
||||
|
||||
spot = npc.Center;
|
||||
direction = Vector2.Zero;
|
||||
if (!hasReachableSpot)
|
||||
return false;
|
||||
|
||||
float bestScore = float.MinValue;
|
||||
for (int x = 0; x < GridX; x++)
|
||||
for (int y = 0; y < GridY; y++)
|
||||
{
|
||||
if (!Reachable[x, y])
|
||||
continue;
|
||||
|
||||
int depth = Math.Min(Math.Min(x + 1, GridX - x), Math.Min(y + 1, GridY - y));
|
||||
for (int blockedX = 0; blockedX < GridX; blockedX++)
|
||||
for (int blockedY = 0; blockedY < GridY; blockedY++)
|
||||
if (!Reachable[blockedX, blockedY])
|
||||
depth = Math.Min(depth, Math.Max(Math.Abs(blockedX - x), Math.Abs(blockedY - y)));
|
||||
|
||||
float score = depth * 10f - (Math.Abs(x - (GridX - 1) / 2f) + Math.Abs(y - (GridY - 1) / 2f));
|
||||
if (score <= bestScore)
|
||||
continue;
|
||||
|
||||
bestScore = score;
|
||||
spot = Cell(x, y);
|
||||
direction = Directions[x, y];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool ShotInfo(Player player, Item item, out int projectileType, out float speed)
|
||||
{
|
||||
projectileType = item.shoot;
|
||||
speed = item.shootSpeed;
|
||||
#if TMODLOADER
|
||||
if (item.useAmmo > 0 && !player.PickAmmo(item, out projectileType, out speed, out _, out _, out _, true))
|
||||
return false;
|
||||
if (item.CountsAsClass(DamageClass.Melee) && !ProjectileID.Sets.NoMeleeSpeedVelocityScaling[projectileType])
|
||||
speed *= player.GetTotalAttackSpeed(DamageClass.Melee);
|
||||
#else
|
||||
if (item.useAmmo > 0)
|
||||
{
|
||||
bool canShoot = false;
|
||||
int damage = item.damage;
|
||||
float knockback = item.knockBack;
|
||||
player.PickAmmo(item, ref projectileType, ref speed, ref canShoot, ref damage, ref knockback, out _, true);
|
||||
if (!canShoot)
|
||||
return false;
|
||||
}
|
||||
if (item.melee && !ProjectileID.Sets.NoMeleeSpeedVelocityScaling[projectileType])
|
||||
speed /= player.meleeSpeed;
|
||||
#endif
|
||||
return projectileType > 0 && speed > 0f && !float.IsInfinity(speed);
|
||||
}
|
||||
|
||||
// Player.CanHit with both entities moved to their predicted positions.
|
||||
static bool CanHit(Player player, int direction, Vector2 playerPosition, NPC npc, Vector2 npcPosition)
|
||||
{
|
||||
Vector2 center = playerPosition + new Vector2(player.width / 2f, player.height / 2f), npcCenter = npcPosition + new Vector2(npc.width / 2f, npc.height / 2f);
|
||||
Vector2 shoulder = center + new Vector2(direction * player.width / 2, player.gravDir * -player.height / 3f);
|
||||
return Collision.CanHit(playerPosition, player.width, player.height, npcPosition, npc.width, npc.height) || Collision.CanHitLine(shoulder, 0, 0, npcCenter + new Vector2(0f, -npc.height / 3f), 0, 0) || Collision.CanHitLine(shoulder, 0, 0, npcCenter, 0, 0) || Collision.CanHitLine(center + new Vector2(direction * player.width / 2, 0f), 0, 0, npcCenter + new Vector2(0f, npc.height / 3), 0, 0);
|
||||
}
|
||||
|
||||
// First swing tick that hits the NPC, following ItemCheck_MeleeHitNPCs.
|
||||
static int SwingHit(Player player, int direction, Rectangle[] boxes, NPC npc, int count, out Vector2 future)
|
||||
{
|
||||
future = npc.Center;
|
||||
for (int t = npc.immune[player.whoAmI]; t < count; t++)
|
||||
{
|
||||
if (boxes[t].IsEmpty)
|
||||
continue;
|
||||
|
||||
Vector2 position = npc.position + (Settings.AimPrediction ? NpcMotion.Offset(npc, t) : Vector2.Zero);
|
||||
if (!boxes[t].Intersects(new Rectangle((int)position.X, (int)position.Y, npc.width, npc.height)))
|
||||
continue;
|
||||
if (!npc.noTileCollide && !CanHit(player, direction, PlayerPath[t], npc, position))
|
||||
continue;
|
||||
|
||||
future = position + new Vector2(npc.width / 2f, npc.height / 2f);
|
||||
return t;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
public static void PreItemCheck(Player player)
|
||||
{
|
||||
NPC? previousTarget = Target;
|
||||
Target = null;
|
||||
Unreachable = null;
|
||||
Firing = false;
|
||||
Verified = false;
|
||||
Ballistics.RecordShot(0, 0f);
|
||||
|
||||
if (player.dead || player.CCed || player.noItems)
|
||||
return;
|
||||
|
||||
Item item = player.HeldItem;
|
||||
bool shoots = Shoots(item), stabs = Stabs(item);
|
||||
int projectileType = 0;
|
||||
float speed = 0f;
|
||||
bool hasShot = shoots && !stabs && ShotInfo(player, item, out projectileType, out speed);
|
||||
bool attacking = player.controlUseItem || player.itemAnimation > 0;
|
||||
if (hasShot && attacking)
|
||||
Ballistics.RecordShot(projectileType, speed);
|
||||
|
||||
if (WindowManager.MouseOver || !(Settings.Aimbot || Settings.AutoAttack))
|
||||
return;
|
||||
|
||||
using var scope = Profiler.Measure(Settings.AutoAttack ? Feature.AutoAttack : Feature.Aimbot);
|
||||
|
||||
bool swings = Swings(item);
|
||||
if (!hasShot && !(stabs || (swings && Settings.AutoAttack)))
|
||||
return;
|
||||
|
||||
Origin = player.RotatedRelativePoint(player.MountedCenter);
|
||||
|
||||
List<NPC> candidates = Candidates(player, Settings.Aimbot && (hasShot || stabs) && !Settings.AutoAttack, previousTarget);
|
||||
if (!PickTarget(player, item, candidates, previousTarget, hasShot, stabs, projectileType, speed))
|
||||
{
|
||||
Unreachable = candidates.FirstOrDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
ShotCheck check = stabs || !hasShot ? ShotCheck.Hit : ShotCheck.Unsupported;
|
||||
Verified = check == ShotCheck.Hit;
|
||||
if (hasShot && Settings.AimExact && (attacking || Settings.AutoAttack))
|
||||
{
|
||||
Vector2 aim = AimPoint;
|
||||
check = Refine(player, item, Target!, _spot, ref aim);
|
||||
if (check != ShotCheck.Unsupported)
|
||||
{
|
||||
AimPoint = aim;
|
||||
Verified = check == ShotCheck.Hit;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasShot && !stabs && Settings.AutoAttack && player.itemAnimation == 0)
|
||||
player.direction = _swingDirection;
|
||||
if (Settings.AutoAttack && check != ShotCheck.Miss)
|
||||
Press(player, item);
|
||||
|
||||
if (hasShot && (player.controlUseItem || player.itemAnimation > 0))
|
||||
Ballistics.RecordShot(projectileType, speed);
|
||||
|
||||
if ((player.controlUseItem || player.itemAnimation > 0) && (Settings.AutoAttack || (Settings.Aimbot && (hasShot || stabs))))
|
||||
{
|
||||
AimOverride.Set(AimPoint);
|
||||
Firing = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Tries the other facing too, since it can still change before the swing starts.
|
||||
static bool PickSwingTarget(Player player, Item item, List<NPC> candidates, NPC? previousTarget)
|
||||
{
|
||||
if (candidates.Count == 0)
|
||||
return false;
|
||||
|
||||
// A swing in progress can't be redirected.
|
||||
if (player.itemAnimation > 0)
|
||||
{
|
||||
if (previousTarget is null || !candidates.Contains(previousTarget))
|
||||
return false;
|
||||
|
||||
Target = previousTarget;
|
||||
AimPoint = previousTarget.Center;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
int ticks = Math.Min(PlayerPath.Length, item.useAnimation * 2 + 1);
|
||||
int count = Sandbox.Swing(player, item, Settings.AimPrediction, PlayerPath, ticks, SwingBoxes, BackSwingBoxes);
|
||||
if (count <= 0)
|
||||
return false;
|
||||
|
||||
for (int pass = 0; pass < 2; pass++)
|
||||
{
|
||||
int direction = pass == 0 ? player.direction : -player.direction;
|
||||
foreach (NPC npc in candidates)
|
||||
{
|
||||
if (SwingHit(player, direction, pass == 0 ? SwingBoxes : BackSwingBoxes, npc, count, out Vector2 future) < 0)
|
||||
continue;
|
||||
|
||||
Target = npc;
|
||||
AimPoint = future;
|
||||
_swingDirection = direction;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool PickTarget(Player player, Item item, List<NPC> candidates, NPC? previousTarget, bool hasShot, bool stabs, int projectileType, float speed)
|
||||
{
|
||||
if (!hasShot && !stabs)
|
||||
return PickSwingTarget(player, item, candidates, previousTarget);
|
||||
|
||||
int evaluated = 0;
|
||||
foreach (NPC npc in candidates)
|
||||
{
|
||||
if (stabs)
|
||||
{
|
||||
if (++evaluated > MaxEvaluated)
|
||||
return false;
|
||||
|
||||
Vector2 aim = npc.Center;
|
||||
if (Refine(player, item, npc, npc.Center, ref aim) != ShotCheck.Hit)
|
||||
continue;
|
||||
|
||||
Target = npc;
|
||||
_spot = npc.Center;
|
||||
AimPoint = aim;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (++evaluated > MaxEvaluated)
|
||||
return false;
|
||||
if (!BestSpot(npc, projectileType, speed, out Vector2 spot, out Vector2 direction))
|
||||
continue;
|
||||
|
||||
Target = npc;
|
||||
_spot = spot;
|
||||
AimPoint = Origin + direction * Math.Max(64f, Vector2.Distance(Origin, spot));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Starts from the correction that hit this NPC with this item last time.
|
||||
static ShotCheck Refine(Player player, Item item, NPC npc, Vector2 spot, ref Vector2 aim)
|
||||
{
|
||||
for (int t = 0; t < NpcOffsets.Length; t++)
|
||||
NpcOffsets[t] = Settings.AimPrediction ? NpcMotion.Offset(npc, t) : Vector2.Zero;
|
||||
|
||||
Vector2 start = aim;
|
||||
if (_correctionNpc == npc.whoAmI && _correctionItem == item.type)
|
||||
aim += _correction;
|
||||
|
||||
Vector2 bestAim = aim;
|
||||
float bestMiss = float.MaxValue;
|
||||
ShotCheck result = ShotCheck.Miss;
|
||||
|
||||
for (int round = 0; round < 3; round++)
|
||||
{
|
||||
List<Sandbox.Shot>? shots = Sandbox.DryRun(player, item, aim);
|
||||
if (shots is null)
|
||||
{
|
||||
if (round == 0)
|
||||
result = ShotCheck.Unsupported;
|
||||
break;
|
||||
}
|
||||
|
||||
bool hit = false;
|
||||
float miss = float.MaxValue;
|
||||
Vector2 correction = Vector2.Zero;
|
||||
|
||||
for (int shotIndex = 0; shotIndex < shots.Count && shotIndex < 3 && !hit; shotIndex++)
|
||||
{
|
||||
Projectile projectile = shots[shotIndex].Projectile;
|
||||
int width = Math.Max(1, projectile.width), height = Math.Max(1, projectile.height);
|
||||
bool primary = shotIndex == 0;
|
||||
int ticks = Math.Min(Flight.Length - 1, (int)(Vector2.Distance(projectile.Center, spot) / Math.Max(1f, projectile.velocity.Length()) * 3f) + 30);
|
||||
|
||||
Sandbox.Fly(shots[shotIndex], ticks, Flight, t =>
|
||||
{
|
||||
Vector2 offset = NpcOffsets[t];
|
||||
var npcBounds = new Rectangle((int)(npc.position.X + offset.X), (int)(npc.position.Y + offset.Y), npc.width, npc.height);
|
||||
var projectileBounds = new Rectangle((int)(Flight[t].X - width / 2f), (int)(Flight[t].Y - height / 2f), width, height);
|
||||
if (npcBounds.Intersects(projectileBounds))
|
||||
return hit = true;
|
||||
if (!primary)
|
||||
return false;
|
||||
|
||||
Vector2 goal = spot + offset;
|
||||
float distance = Vector2.Distance(Flight[t], goal);
|
||||
if (distance < miss)
|
||||
{
|
||||
miss = distance;
|
||||
correction = goal - Flight[t];
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (hit)
|
||||
{
|
||||
bestAim = aim;
|
||||
result = ShotCheck.Hit;
|
||||
break;
|
||||
}
|
||||
|
||||
if (miss < bestMiss)
|
||||
{
|
||||
bestMiss = miss;
|
||||
bestAim = aim;
|
||||
}
|
||||
|
||||
if (correction == Vector2.Zero)
|
||||
break;
|
||||
|
||||
aim += correction;
|
||||
}
|
||||
|
||||
aim = bestAim;
|
||||
if (result == ShotCheck.Hit)
|
||||
(_correctionNpc, _correctionItem, _correction) = (npc.whoAmI, item.type, aim - start);
|
||||
else if (_correctionNpc == npc.whoAmI)
|
||||
_correctionNpc = -1;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static void Press(Player player, Item item)
|
||||
{
|
||||
if (player.itemAnimation > 0 || (item.mana > 0 && player.statMana < item.mana && !Settings.NoManaCost))
|
||||
return;
|
||||
|
||||
player.controlUseItem = true;
|
||||
player.releaseUseItem = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Terraria;
|
||||
using TerraSharp.Core;
|
||||
|
||||
namespace TerraSharp.Features.Combat;
|
||||
|
||||
public static class CombatFeatures
|
||||
{
|
||||
static int _auraTick;
|
||||
|
||||
static float RangeSq(float tiles) => tiles * 16f * tiles * 16f;
|
||||
|
||||
// NPC.AI postfix: after the AI chose its velocity, before UpdateNPC applies it.
|
||||
public static void PostNpcAI(NPC npc)
|
||||
{
|
||||
if (!(Settings.FreezeEnemies || Settings.WeakenEnemies) || Main.gameMenu || !Hostility.IsHostile(npc))
|
||||
return;
|
||||
|
||||
if (Settings.FreezeEnemies)
|
||||
npc.velocity = Vector2.Zero;
|
||||
if (Settings.WeakenEnemies && npc.life > 1)
|
||||
npc.life = 1;
|
||||
}
|
||||
|
||||
public static void PostPlayerUpdate(Player player)
|
||||
{
|
||||
if (player.dead)
|
||||
return;
|
||||
|
||||
if (Settings.KillAura && ++_auraTick >= Settings.KillAuraInterval)
|
||||
{
|
||||
_auraTick = 0;
|
||||
float range = RangeSq(Settings.KillAuraRange);
|
||||
for (int i = 0; i < Main.maxNPCs; i++)
|
||||
{
|
||||
NPC npc = Main.npc[i];
|
||||
if (Hostility.IsDamageable(npc) && Vector2.DistanceSquared(npc.Center, player.Center) <= range)
|
||||
Kill(npc, player);
|
||||
}
|
||||
}
|
||||
|
||||
if (Settings.HitMagnet && player.itemAnimation > 0 && Nearest(player, RangeSq(Settings.HitMagnetRange)) is NPC target)
|
||||
{
|
||||
target.Center = player.Center + new Vector2(player.direction * (player.width / 2f + target.width / 2f), 0f);
|
||||
target.velocity = Vector2.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
static NPC? Nearest(Player player, float maxDistanceSq)
|
||||
{
|
||||
NPC? best = null;
|
||||
float bestDistance = maxDistanceSq;
|
||||
for (int i = 0; i < Main.maxNPCs; i++)
|
||||
{
|
||||
NPC npc = Main.npc[i];
|
||||
if (!Hostility.IsDamageable(npc))
|
||||
continue;
|
||||
|
||||
float distance = Vector2.DistanceSquared(npc.Center, player.Center);
|
||||
if (distance < bestDistance)
|
||||
{
|
||||
bestDistance = distance;
|
||||
best = npc;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
static void Kill(NPC npc, Player player)
|
||||
{
|
||||
int damage = Math.Max(npc.life, npc.lifeMax) * 4 + npc.defense * 2 + 1000;
|
||||
#if TMODLOADER
|
||||
player.ApplyDamageToNPC(npc, damage, 0f, npc.Center.X < player.Center.X ? -1 : 1, false, player.HeldItem.DamageType);
|
||||
#else
|
||||
player.ApplyDamageToNPC(npc, damage, 0f, npc.Center.X < player.Center.X ? -1 : 1, false, null, player.HeldItem.type, 0);
|
||||
#endif
|
||||
}
|
||||
|
||||
public static void GatherAll()
|
||||
{
|
||||
Player player = Main.LocalPlayer;
|
||||
for (int i = 0; i < Main.maxNPCs; i++)
|
||||
{
|
||||
NPC npc = Main.npc[i];
|
||||
if (!Hostility.IsHostile(npc))
|
||||
continue;
|
||||
|
||||
npc.Center = player.Center;
|
||||
npc.velocity = Vector2.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
public static void KillAll()
|
||||
{
|
||||
Player player = Main.LocalPlayer;
|
||||
for (int i = 0; i < Main.maxNPCs; i++)
|
||||
if (Hostility.IsDamageable(Main.npc[i]))
|
||||
Kill(Main.npc[i], player);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Terraria;
|
||||
|
||||
namespace TerraSharp.Features.Combat;
|
||||
|
||||
public static class Hostility
|
||||
{
|
||||
public static bool IsHostile(NPC npc) => npc.active && !npc.friendly && !npc.townNPC && !npc.CountsAsACritter && !npc.immortal && npc.lifeMax > 5;
|
||||
|
||||
public static bool IsCritter(NPC npc) => npc.active && !npc.townNPC && (npc.CountsAsACritter || (!npc.friendly && npc.lifeMax <= 5 && npc.damage == 0));
|
||||
|
||||
public static bool IsDamageable(NPC npc) => IsHostile(npc) && !npc.dontTakeDamage;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Terraria;
|
||||
using Terraria.ID;
|
||||
using TerraSharp.Core;
|
||||
using TerraSharp.Simulation;
|
||||
|
||||
namespace TerraSharp.Features.Combat;
|
||||
|
||||
public static class NpcMotion
|
||||
{
|
||||
const int Horizon = 180;
|
||||
const int MaxReuse = 30;
|
||||
const int SimulationsPerTick = 4;
|
||||
|
||||
static readonly Vector2[] LastVelocities = new Vector2[Main.maxNPCs + 1];
|
||||
static readonly Vector2[] Accelerations = new Vector2[Main.maxNPCs + 1];
|
||||
static readonly int[] SeenTypes = new int[Main.maxNPCs + 1];
|
||||
|
||||
static readonly Vector2[][] Paths = [.. Enumerable.Range(0, Main.maxNPCs + 1).Select(_ => new Vector2[Horizon + 1])];
|
||||
static readonly Vector2[][] Velocities = [.. Enumerable.Range(0, Main.maxNPCs + 1).Select(_ => new Vector2[Horizon + 1])];
|
||||
static readonly int[] Counts = new int[Main.maxNPCs + 1];
|
||||
static readonly int[] SimulatedTicks = [.. Enumerable.Repeat(-1, Main.maxNPCs + 1)];
|
||||
static readonly int[] SimulatedTypes = new int[Main.maxNPCs + 1];
|
||||
static readonly int[] Signatures = new int[Main.maxNPCs + 1];
|
||||
static int _budgetTick = -1, _budget;
|
||||
static int _signatureTick = -1, _signature;
|
||||
|
||||
sealed class Incoming(Projectile projectile, Vector2[] path, int from, int count, int immunity)
|
||||
{
|
||||
public readonly Vector2[] Path = path;
|
||||
public readonly int From = from, Count = count, Width = Math.Max(1, projectile.width), Height = Math.Max(1, projectile.height);
|
||||
public readonly int Owner = projectile.owner, Damage = projectile.damage, Direction = projectile.direction != 0 ? projectile.direction : Math.Sign(projectile.velocity.X);
|
||||
public readonly float Knockback = projectile.knockBack;
|
||||
public readonly bool Local = projectile.usesLocalNPCImmunity || projectile.usesIDStaticNPCImmunity;
|
||||
public readonly int Cooldown = projectile.usesLocalNPCImmunity ? projectile.localNPCHitCooldown : projectile.idStaticNPCHitCooldown;
|
||||
public int Immunity = immunity, Penetrate = projectile.penetrate;
|
||||
}
|
||||
|
||||
static readonly List<Incoming> Projectiles = [];
|
||||
|
||||
public static void Track()
|
||||
{
|
||||
for (int i = 0; i < Main.maxNPCs; i++)
|
||||
{
|
||||
NPC npc = Main.npc[i];
|
||||
if (!npc.active || SeenTypes[i] != npc.type)
|
||||
{
|
||||
SeenTypes[i] = npc.active ? npc.type : -1;
|
||||
LastVelocities[i] = npc.velocity;
|
||||
Accelerations[i] = Vector2.Zero;
|
||||
continue;
|
||||
}
|
||||
|
||||
Vector2 acceleration = npc.velocity - LastVelocities[i];
|
||||
LastVelocities[i] = npc.velocity;
|
||||
|
||||
// Hits, teleports and landings show up as spikes.
|
||||
if (acceleration.Length() > 2f)
|
||||
acceleration = Vector2.Zero;
|
||||
|
||||
var clamped = new Vector2(MathHelper.Clamp(acceleration.X, -0.5f, 0.5f), MathHelper.Clamp(acceleration.Y, -0.5f, 0.5f));
|
||||
Accelerations[i] = Vector2.Lerp(Accelerations[i], clamped, 0.3f);
|
||||
}
|
||||
}
|
||||
|
||||
// Displacement of npc.position after the given number of ticks.
|
||||
public static Vector2 Offset(NPC npc, int ticks)
|
||||
{
|
||||
int count = Simulate(npc, out int from);
|
||||
if (count - from < 2)
|
||||
return Extrapolate(npc.velocity, Accelerations[npc.whoAmI], ticks);
|
||||
|
||||
Vector2[] path = Paths[npc.whoAmI];
|
||||
int index = from + Math.Max(0, ticks);
|
||||
if (index < count)
|
||||
return path[index] - path[from];
|
||||
|
||||
Vector2 last = path[count - 1];
|
||||
return last + (last - path[count - 2]) * (index - count + 1) - path[from];
|
||||
}
|
||||
|
||||
// Runs the NPC's own AI; a path stays in use while the NPC keeps following it. Acceleration extrapolation is the fallback.
|
||||
static int Simulate(NPC npc, out int from)
|
||||
{
|
||||
int i = npc.whoAmI;
|
||||
from = Clock.Tick - SimulatedTicks[i];
|
||||
if (SimulatedTypes[i] == npc.type && from == 0)
|
||||
return Counts[i];
|
||||
if (SimulatedTypes[i] == npc.type && from > 0 && from <= MaxReuse && Follows(npc, from))
|
||||
return Counts[i];
|
||||
|
||||
from = 0;
|
||||
if (_budgetTick != Clock.Tick)
|
||||
(_budgetTick, _budget) = (Clock.Tick, SimulationsPerTick);
|
||||
if (_budget <= 0 || !Sandbox.CanMove(npc))
|
||||
return 0;
|
||||
|
||||
_budget--;
|
||||
using var scope = Profiler.Measure(Feature.NpcMotion);
|
||||
(SimulatedTicks[i], SimulatedTypes[i], Signatures[i]) = (Clock.Tick, npc.type, Signature(npc));
|
||||
CollectProjectiles(npc);
|
||||
Counts[i] = Sandbox.Move(npc, Horizon, Paths[i], Velocities[i], Projectiles.Count > 0 ? ApplyHits : null);
|
||||
|
||||
return Counts[i];
|
||||
}
|
||||
|
||||
// The game's random rolls, hits and AI changes all show up as the NPC leaving its recorded path.
|
||||
static bool Follows(NPC npc, int from)
|
||||
{
|
||||
int i = npc.whoAmI;
|
||||
if (from >= Counts[i] - 1 || Signatures[i] != Signature(npc))
|
||||
return false;
|
||||
|
||||
return Vector2.DistanceSquared(npc.position, Paths[i][from]) < 1f && Vector2.DistanceSquared(npc.velocity, Velocities[i][from]) < 0.01f;
|
||||
}
|
||||
|
||||
// Changes when friendly projectiles that could knock the NPC back appear or disappear.
|
||||
static int Signature(NPC npc)
|
||||
{
|
||||
if (npc.knockBackResist <= 0f || npc.dontTakeDamage)
|
||||
return 0;
|
||||
if (_signatureTick == Clock.Tick)
|
||||
return _signature;
|
||||
|
||||
int signature = 17;
|
||||
for (int i = 0; i < Main.maxProjectiles; i++)
|
||||
{
|
||||
Projectile projectile = Main.projectile[i];
|
||||
if (projectile.active && projectile.friendly && !projectile.hostile && projectile.damage > 0 && projectile.knockBack > 0f)
|
||||
signature = (signature * 31 + i) * 31 + projectile.type;
|
||||
}
|
||||
|
||||
(_signatureTick, _signature) = (Clock.Tick, signature);
|
||||
return signature;
|
||||
}
|
||||
|
||||
// Friendly projectiles in flight, from the cached sandbox paths the ESP also draws.
|
||||
static void CollectProjectiles(NPC npc)
|
||||
{
|
||||
Projectiles.Clear();
|
||||
if (npc.knockBackResist <= 0f || npc.dontTakeDamage)
|
||||
return;
|
||||
|
||||
for (int i = 0; i < Main.maxProjectiles; i++)
|
||||
{
|
||||
Projectile projectile = Main.projectile[i];
|
||||
if (!projectile.active || !projectile.friendly || projectile.hostile || projectile.npcProj || projectile.damage <= 0 || projectile.knockBack <= 0f)
|
||||
continue;
|
||||
|
||||
int count = Sandbox.LivePath(projectile, i, Clock.Tick, out Vector2[] path, out int from);
|
||||
if (count - from < 2)
|
||||
continue;
|
||||
|
||||
int immunity = projectile.usesLocalNPCImmunity ? projectile.localNPCImmunity[npc.whoAmI] : 0;
|
||||
Projectiles.Add(new Incoming(projectile, path, from, count, immunity));
|
||||
}
|
||||
}
|
||||
|
||||
// Hit rules follow Projectile.Damage: owner immunity of 10 ticks unless the projectile tracks its own.
|
||||
static void ApplyHits(NPC npc, int tick)
|
||||
{
|
||||
foreach (Incoming incoming in Projectiles)
|
||||
{
|
||||
if (incoming.Immunity > 0)
|
||||
incoming.Immunity--;
|
||||
|
||||
int index = incoming.From + tick;
|
||||
if (incoming.Penetrate == 0 || index >= incoming.Count)
|
||||
continue;
|
||||
|
||||
Vector2 center = incoming.Path[index];
|
||||
var bounds = new Rectangle((int)(center.X - incoming.Width / 2f), (int)(center.Y - incoming.Height / 2f), incoming.Width, incoming.Height);
|
||||
if (!bounds.Intersects(npc.Hitbox) || (incoming.Local ? incoming.Immunity != 0 : npc.immune[incoming.Owner] > 0))
|
||||
continue;
|
||||
|
||||
int direction = incoming.Direction != 0 ? incoming.Direction : Math.Sign(npc.Center.X - center.X);
|
||||
Knockback(npc, incoming.Knockback, incoming.Damage, direction);
|
||||
|
||||
if (incoming.Local)
|
||||
incoming.Immunity = incoming.Cooldown;
|
||||
else
|
||||
npc.immune[incoming.Owner] = 10;
|
||||
if (incoming.Penetrate > 0)
|
||||
incoming.Penetrate--;
|
||||
}
|
||||
}
|
||||
|
||||
// Knockback part of NPC.StrikeNPC, the same on vanilla and tModLoader.
|
||||
static void Knockback(NPC npc, float knockback, int damage, int hitDirection)
|
||||
{
|
||||
float strength = knockback * npc.knockBackResist * (npc.onFire2 ? 1.1f : 1f);
|
||||
if (strength > 8f)
|
||||
strength = 8f + (strength - 8f) * 0.9f;
|
||||
if (strength > 10f)
|
||||
strength = 10f + (strength - 10f) * 0.8f;
|
||||
if (strength > 12f)
|
||||
strength = 12f + (strength - 12f) * 0.7f;
|
||||
if (strength > 14f)
|
||||
strength = 14f + (strength - 14f) * 0.6f;
|
||||
strength = Math.Min(strength, 16f);
|
||||
|
||||
int taken = Math.Max(1, (int)(damage - npc.defense * 0.5));
|
||||
npc.justHit = true;
|
||||
|
||||
if (taken * (Main.expertMode ? 15 : 10) <= npc.lifeMax)
|
||||
{
|
||||
npc.velocity.Y = -strength * (npc.noGravity ? 0.5f : 0.75f) * npc.knockBackResist;
|
||||
npc.velocity.X = strength * hitDirection * npc.knockBackResist;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hitDirection < 0 && npc.velocity.X > -strength)
|
||||
{
|
||||
if (npc.velocity.X > 0f)
|
||||
npc.velocity.X -= strength;
|
||||
npc.velocity.X = Math.Max(npc.velocity.X - strength, -strength);
|
||||
}
|
||||
else if (hitDirection > 0 && npc.velocity.X < strength)
|
||||
{
|
||||
if (npc.velocity.X < 0f)
|
||||
npc.velocity.X += strength;
|
||||
npc.velocity.X = Math.Min(npc.velocity.X + strength, strength);
|
||||
}
|
||||
|
||||
if (npc.type == NPCID.SnowFlinx)
|
||||
strength *= 1.5f;
|
||||
float lift = strength * (npc.noGravity ? -0.5f : -0.75f);
|
||||
if (npc.velocity.Y > lift)
|
||||
npc.velocity.Y = Math.Max(npc.velocity.Y + lift, lift);
|
||||
}
|
||||
|
||||
// Acceleration extrapolation is capped at 40 ticks.
|
||||
static Vector2 Extrapolate(Vector2 velocity, Vector2 acceleration, int ticks)
|
||||
{
|
||||
float accelerationTicks = Math.Min(ticks, 40);
|
||||
return velocity * ticks + acceleration * (0.5f * accelerationTicks * accelerationTicks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Terraria;
|
||||
using TerraSharp.Core;
|
||||
using TerraSharp.Simulation;
|
||||
using TerraSharp.UI;
|
||||
|
||||
namespace TerraSharp.Features.Combat;
|
||||
|
||||
public static class ShotPreview
|
||||
{
|
||||
public sealed record Path(Vector2[] Points, int Count);
|
||||
|
||||
public static List<Path> Paths { get; } = [];
|
||||
|
||||
static int _lastTick;
|
||||
|
||||
// Runs after the aim override in the ItemCheckWrapped prefix.
|
||||
public static void Update(Player player)
|
||||
{
|
||||
if (!Settings.ShotPreview || player.dead || WindowManager.MouseOver)
|
||||
{
|
||||
Paths.Clear();
|
||||
return;
|
||||
}
|
||||
if (Clock.Tick - _lastTick < 3)
|
||||
return;
|
||||
_lastTick = Clock.Tick;
|
||||
using var scope = Profiler.Measure(Feature.ShotPreview);
|
||||
Paths.Clear();
|
||||
Item item = player.HeldItem;
|
||||
if (!Sandbox.CanDryRun(item) || Sandbox.DryRun(player, item, Main.MouseWorld) is not { } shots)
|
||||
return;
|
||||
for (int k = 0; k < shots.Count && k < 5; k++)
|
||||
{
|
||||
var points = new Vector2[181];
|
||||
int count = Sandbox.Fly(shots[k], 180, points);
|
||||
if (count > 1)
|
||||
Paths.Add(new Path(points, count));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using Terraria;
|
||||
using TerraSharp.Core;
|
||||
|
||||
namespace TerraSharp.Features;
|
||||
|
||||
public static class HeldItemOverrides
|
||||
{
|
||||
const int Unbreakable = 9999;
|
||||
|
||||
static Item? _item;
|
||||
static int _type, _damage, _useTime, _useAnimation, _pick, _axe, _hammer;
|
||||
|
||||
public static void Update(Player player)
|
||||
{
|
||||
bool any = Settings.DamageBoost || Settings.RapidAttack || Settings.InstantBreak;
|
||||
Item? held = any && player.HeldItem.type != 0 ? player.HeldItem : null;
|
||||
if (held != _item || (held is not null && held.type != _type))
|
||||
{
|
||||
Restore();
|
||||
if (held is not null)
|
||||
Capture(held);
|
||||
}
|
||||
|
||||
if (_item is null)
|
||||
return;
|
||||
|
||||
_item.damage = Settings.DamageBoost && _damage > 0 ? (int)(_damage * Settings.DamageMult) : _damage;
|
||||
_item.useTime = Settings.RapidAttack ? Math.Min(_useTime, 4) : _useTime;
|
||||
_item.useAnimation = Settings.RapidAttack ? Math.Min(_useAnimation, 6) : _useAnimation;
|
||||
|
||||
_item.pick = Settings.InstantBreak && _pick > 0 ? Unbreakable : _pick;
|
||||
_item.axe = Settings.InstantBreak && _axe > 0 ? Unbreakable : _axe;
|
||||
_item.hammer = Settings.InstantBreak && _hammer > 0 ? Unbreakable : _hammer;
|
||||
}
|
||||
|
||||
static void Capture(Item item)
|
||||
{
|
||||
_item = item;
|
||||
_type = item.type;
|
||||
_damage = item.damage;
|
||||
_useTime = item.useTime;
|
||||
_useAnimation = item.useAnimation;
|
||||
_pick = item.pick;
|
||||
_axe = item.axe;
|
||||
_hammer = item.hammer;
|
||||
}
|
||||
|
||||
public static void Restore()
|
||||
{
|
||||
// SetDefaults has already replaced the stats when the item type changes.
|
||||
if (_item is not null && _item.type == _type)
|
||||
{
|
||||
_item.damage = _damage;
|
||||
_item.useTime = _useTime;
|
||||
_item.useAnimation = _useAnimation;
|
||||
_item.pick = _pick;
|
||||
_item.axe = _axe;
|
||||
_item.hammer = _hammer;
|
||||
}
|
||||
|
||||
_item = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Terraria;
|
||||
|
||||
namespace TerraSharp.Features;
|
||||
|
||||
public static class ItemFeatures
|
||||
{
|
||||
// 1.4.5 wraps ground items in WorldItem; the removed Item.noGrabDelay no longer applies.
|
||||
public static void PullAll()
|
||||
{
|
||||
Player player = Main.LocalPlayer;
|
||||
for (int i = 0; i < Main.maxItems; i++)
|
||||
{
|
||||
#if TMODLOADER
|
||||
Item drop = Main.item[i];
|
||||
if (!drop.active || drop.type == 0)
|
||||
continue;
|
||||
#else
|
||||
WorldItem drop = Main.item[i];
|
||||
if (drop is not { active: true, inner.type: not 0 })
|
||||
continue;
|
||||
#endif
|
||||
drop.Center = player.Center;
|
||||
drop.velocity = Vector2.Zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using System;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Terraria;
|
||||
using TerraSharp.Core;
|
||||
using TerraSharp.UI;
|
||||
|
||||
namespace TerraSharp.Features;
|
||||
|
||||
public static class PlayerFeatures
|
||||
{
|
||||
const int InventorySlots = 58;
|
||||
|
||||
static Vector2 _flyAnchor;
|
||||
static bool _godApplied, _autoSwingApplied, _autoSwingVanilla;
|
||||
static readonly int[] InventoryTypes = new int[InventorySlots];
|
||||
static readonly int[] InventoryStacks = new int[InventorySlots];
|
||||
static bool _inventoryValid;
|
||||
static int _healCooldown, _manaCooldown;
|
||||
|
||||
public static void PreUpdate(Player player)
|
||||
{
|
||||
_flyAnchor = player.position;
|
||||
HeldItemOverrides.Update(player);
|
||||
ApplyAutoSwing();
|
||||
}
|
||||
|
||||
static void ApplyAutoSwing()
|
||||
{
|
||||
if (Settings.AutoSwing && !_autoSwingApplied)
|
||||
{
|
||||
_autoSwingVanilla = Main.SettingsEnabled_AutoReuseAllItems;
|
||||
_autoSwingApplied = true;
|
||||
}
|
||||
|
||||
if (!_autoSwingApplied)
|
||||
return;
|
||||
|
||||
Main.SettingsEnabled_AutoReuseAllItems = Settings.AutoSwing || _autoSwingVanilla;
|
||||
if (!Settings.AutoSwing)
|
||||
_autoSwingApplied = false;
|
||||
}
|
||||
|
||||
// Player.HorizontalMovement prefix: after ResetEffects/UpdateBuffs/UpdateEquips, before run/jump physics.
|
||||
public static void PreMovement(Player player)
|
||||
{
|
||||
if (Settings.SuperSpeed && !Settings.Fly)
|
||||
{
|
||||
float top = 3f * Settings.SpeedMult;
|
||||
player.maxRunSpeed = top;
|
||||
player.accRunSpeed = Math.Max(player.accRunSpeed, top);
|
||||
if (player.controlRight != player.controlLeft)
|
||||
{
|
||||
float targetSpeed = player.controlRight ? top : -top;
|
||||
if (player.controlRight ? player.velocity.X < targetSpeed : player.velocity.X > targetSpeed)
|
||||
player.velocity.X = targetSpeed;
|
||||
}
|
||||
}
|
||||
|
||||
if (Settings.HighJump)
|
||||
{
|
||||
player.jumpSpeedBoost = 9f;
|
||||
Player.jumpHeight = 45;
|
||||
}
|
||||
|
||||
if (Settings.NoFallDamage)
|
||||
player.noFallDmg = true;
|
||||
|
||||
if (Settings.LavaImmune)
|
||||
{
|
||||
player.lavaImmune = true;
|
||||
player.fireWalk = true;
|
||||
}
|
||||
|
||||
if (Settings.WaterWalk)
|
||||
{
|
||||
player.waterWalk = true;
|
||||
player.waterWalk2 = true;
|
||||
if (player.wet && player.velocity.Y * player.gravDir > 0f)
|
||||
player.velocity.Y = 0f;
|
||||
}
|
||||
|
||||
if (Settings.NoKnockback)
|
||||
player.noKnockback = true;
|
||||
if (Settings.GravityFlip)
|
||||
player.forcedGravity = 10;
|
||||
if (Settings.NoManaCost)
|
||||
player.manaCost = 0f;
|
||||
|
||||
if (Settings.ExtendedReach)
|
||||
{
|
||||
Player.tileRangeX = Settings.ReachX;
|
||||
Player.tileRangeY = Settings.ReachY;
|
||||
}
|
||||
|
||||
ApplyVision(player);
|
||||
}
|
||||
|
||||
static void ApplyVision(Player player)
|
||||
{
|
||||
if (Settings.Spelunker)
|
||||
player.findTreasure = true;
|
||||
if (Settings.Hunter)
|
||||
player.detectCreature = true;
|
||||
if (Settings.NightOwl)
|
||||
player.nightVision = true;
|
||||
if (Settings.DangerSense)
|
||||
player.dangerSense = true;
|
||||
if (Settings.BiomeSight)
|
||||
player.biomeSight = true;
|
||||
if (Settings.Sonar)
|
||||
player.sonarPotion = true;
|
||||
if (Settings.Shine)
|
||||
Lighting.AddLight((int)(player.position.X + player.width / 2f) / 16, (int)(player.position.Y + player.height / 2f) / 16, 0.8f, 0.95f, 1f);
|
||||
}
|
||||
|
||||
public static void PostUpdate(Player player)
|
||||
{
|
||||
ApplyGodMode(player);
|
||||
if (!player.dead)
|
||||
{
|
||||
if (Settings.InfHealth && player.statLife < player.statLifeMax2)
|
||||
player.statLife = player.statLifeMax2;
|
||||
if (Settings.InfMana && player.statMana < player.statManaMax2)
|
||||
player.statMana = player.statManaMax2;
|
||||
if (Settings.InfBreath)
|
||||
player.breath = player.breathMax;
|
||||
AutoPotions(player);
|
||||
}
|
||||
|
||||
if (Settings.InfFlight)
|
||||
{
|
||||
if (player.wingTimeMax > 0)
|
||||
player.wingTime = player.wingTimeMax;
|
||||
player.rocketTime = player.rocketTimeMax;
|
||||
}
|
||||
|
||||
if (Settings.InstantRespawn && player.dead && player.respawnTimer > 1)
|
||||
player.respawnTimer = 1;
|
||||
if (Settings.Fly && !player.dead)
|
||||
Fly(player);
|
||||
|
||||
if (Settings.InfItems)
|
||||
FreezeInventory(player);
|
||||
else
|
||||
_inventoryValid = false;
|
||||
}
|
||||
|
||||
static void ApplyGodMode(Player player)
|
||||
{
|
||||
if (Settings.GodMode)
|
||||
{
|
||||
player.creativeGodMode = true;
|
||||
player.immune = true;
|
||||
player.immuneNoBlink = true;
|
||||
player.immuneTime = Math.Max(player.immuneTime, 30);
|
||||
|
||||
int[] cooldowns = player.hurtCooldowns;
|
||||
for (int i = 0; i < cooldowns.Length; i++)
|
||||
cooldowns[i] = Math.Max(cooldowns[i], 30);
|
||||
|
||||
_godApplied = true;
|
||||
}
|
||||
else if (_godApplied)
|
||||
{
|
||||
player.creativeGodMode = false;
|
||||
player.immuneNoBlink = false;
|
||||
_godApplied = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Movement starts from the position captured before vanilla physics.
|
||||
static void Fly(Player player)
|
||||
{
|
||||
Vector2 direction = Vector2.Zero;
|
||||
if (player.controlUp || player.controlJump)
|
||||
direction.Y -= 1f;
|
||||
if (player.controlDown)
|
||||
direction.Y += 1f;
|
||||
if (player.controlLeft)
|
||||
direction.X -= 1f;
|
||||
if (player.controlRight)
|
||||
direction.X += 1f;
|
||||
|
||||
player.position = _flyAnchor + direction * Settings.FlySpeed;
|
||||
player.velocity = Vector2.Zero;
|
||||
player.fallStart = (int)(player.position.Y / 16f);
|
||||
}
|
||||
|
||||
static void AutoPotions(Player player)
|
||||
{
|
||||
if (_healCooldown > 0)
|
||||
_healCooldown--;
|
||||
if (_manaCooldown > 0)
|
||||
_manaCooldown--;
|
||||
|
||||
if (Settings.AutoHeal && _healCooldown == 0 && player.potionDelay == 0 && player.statLife < player.statLifeMax2 * Settings.AutoHealAt / 100f)
|
||||
{
|
||||
player.QuickHeal();
|
||||
_healCooldown = 30;
|
||||
}
|
||||
|
||||
if (Settings.AutoMana && _manaCooldown == 0 && player.statMana < player.statManaMax2 * Settings.AutoManaAt / 100f)
|
||||
{
|
||||
player.QuickMana();
|
||||
_manaCooldown = 30;
|
||||
}
|
||||
}
|
||||
|
||||
static void FreezeInventory(Player player)
|
||||
{
|
||||
int slots = Math.Min(player.inventory.Length, InventorySlots);
|
||||
for (int i = 0; i < slots; i++)
|
||||
{
|
||||
Item item = player.inventory[i];
|
||||
if (_inventoryValid && item.type != 0 && item.type == InventoryTypes[i] && item.stack < InventoryStacks[i])
|
||||
{
|
||||
item.stack = InventoryStacks[i];
|
||||
continue;
|
||||
}
|
||||
|
||||
InventoryTypes[i] = item.type;
|
||||
InventoryStacks[i] = item.stack;
|
||||
}
|
||||
|
||||
_inventoryValid = true;
|
||||
}
|
||||
|
||||
public static void OnUnload()
|
||||
{
|
||||
if (_godApplied && !Main.gameMenu)
|
||||
{
|
||||
Main.LocalPlayer.creativeGodMode = false;
|
||||
Main.LocalPlayer.immuneNoBlink = false;
|
||||
}
|
||||
|
||||
if (_autoSwingApplied)
|
||||
Main.SettingsEnabled_AutoReuseAllItems = _autoSwingVanilla;
|
||||
|
||||
_autoSwingApplied = false;
|
||||
_godApplied = false;
|
||||
_inventoryValid = false;
|
||||
}
|
||||
|
||||
public static void MaxHp() => Main.LocalPlayer.statLifeMax = 500;
|
||||
|
||||
public static void MaxMana() => Main.LocalPlayer.statManaMax = 200;
|
||||
|
||||
public static void FullHeal()
|
||||
{
|
||||
Player player = Main.LocalPlayer;
|
||||
player.statLife = player.statLifeMax2;
|
||||
player.statMana = player.statManaMax2;
|
||||
}
|
||||
|
||||
public static void AddBuff(int type) => Main.LocalPlayer.AddBuff(type, (int)(Settings.BuffMinutes * 3600f), false);
|
||||
|
||||
public static void TeleportTo(Vector2 topLeft)
|
||||
{
|
||||
Player player = Main.LocalPlayer;
|
||||
player.position = topLeft;
|
||||
player.velocity = Vector2.Zero;
|
||||
player.fallStart = (int)(player.position.Y / 16f);
|
||||
}
|
||||
|
||||
// Matches the Magic Mirror branch of Player.ItemCheck_Inner.
|
||||
public static void TeleportSpawn()
|
||||
{
|
||||
Player player = Main.LocalPlayer;
|
||||
Widgets.Sound(2, (int)player.position.X, (int)player.position.Y, 6);
|
||||
MirrorDust(player, player.velocity * 0.5f);
|
||||
|
||||
player.RemoveAllGrapplingHooks();
|
||||
player.Spawn(PlayerSpawnContext.RecallFromItem);
|
||||
|
||||
MirrorDust(player, Vector2.Zero);
|
||||
}
|
||||
|
||||
static void MirrorDust(Player player, Vector2 velocity)
|
||||
{
|
||||
for (int i = 0; i < 70; i++)
|
||||
Dust.NewDust(player.position, player.width, player.height, 15, velocity.X, velocity.Y, 150, default, 1.5f);
|
||||
}
|
||||
|
||||
public static void TeleportCursor()
|
||||
{
|
||||
Player player = Main.LocalPlayer;
|
||||
TeleportTo(Main.MouseWorld - new Vector2(player.width / 2f, player.height / 2f));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Terraria;
|
||||
|
||||
namespace TerraSharp.Features;
|
||||
|
||||
public sealed class Waypoint(string name, Vector2 position)
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
|
||||
public Vector2 Position { get; } = position;
|
||||
}
|
||||
|
||||
public static class Waypoints
|
||||
{
|
||||
public sealed class SavedWaypoint
|
||||
{
|
||||
public string Name { get; set; } = "Waypoint";
|
||||
public float X { get; set; }
|
||||
public float Y { get; set; }
|
||||
}
|
||||
|
||||
static readonly Dictionary<string, List<Waypoint>> ByWorld = [];
|
||||
|
||||
static string WorldKey => Main.worldID.ToString(CultureInfo.InvariantCulture) + ":" + Main.worldName;
|
||||
|
||||
public static List<Waypoint> Current => ListFor(WorldKey);
|
||||
|
||||
static List<Waypoint> ListFor(string world)
|
||||
{
|
||||
if (!ByWorld.TryGetValue(world, out List<Waypoint>? list))
|
||||
ByWorld[world] = list = [];
|
||||
return list;
|
||||
}
|
||||
|
||||
public static void AddHere()
|
||||
{
|
||||
Player player = Main.LocalPlayer;
|
||||
List<Waypoint> list = Current;
|
||||
list.Add(new Waypoint($"Waypoint {list.Count + 1} ({(int)(player.position.X / 16f)}, {(int)(player.position.Y / 16f)})", player.position));
|
||||
}
|
||||
|
||||
public static Dictionary<string, List<SavedWaypoint>> Save()
|
||||
{
|
||||
var worlds = new Dictionary<string, List<SavedWaypoint>>();
|
||||
foreach (KeyValuePair<string, List<Waypoint>> world in ByWorld)
|
||||
worlds[world.Key] = world.Value.ConvertAll(w => new SavedWaypoint { Name = w.Name, X = w.Position.X, Y = w.Position.Y });
|
||||
return worlds;
|
||||
}
|
||||
|
||||
public static void Load(string world, List<SavedWaypoint> waypoints)
|
||||
{
|
||||
ByWorld[world] = waypoints.ConvertAll(w => new Waypoint(w.Name, new Vector2(w.X, w.Y)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Terraria;
|
||||
using TerraSharp.Core;
|
||||
using Sandstorm = Terraria.GameContent.Events.Sandstorm;
|
||||
|
||||
namespace TerraSharp.Features;
|
||||
|
||||
public static class WorldFeatures
|
||||
{
|
||||
public static readonly IReadOnlyList<string> MoonPhases = ["Full Moon", "Waning Gibbous", "Third Quarter", "Waning Crescent", "New Moon", "Waxing Crescent", "First Quarter", "Waxing Gibbous"];
|
||||
|
||||
public static readonly IReadOnlyList<(string Name, int Id)> Invasions = [("Frost Legion", 2), ("Goblin Army", 1), ("Martian Madness", 4), ("Pirates", 3)];
|
||||
|
||||
static bool _freezing;
|
||||
static double _time;
|
||||
static bool _day;
|
||||
static readonly Action StartSandstorm = (Action)Delegate.CreateDelegate(typeof(Action), typeof(Sandstorm).GetMethod("StartSandstorm", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)!);
|
||||
static readonly Action StopSandstorm = (Action)Delegate.CreateDelegate(typeof(Action), typeof(Sandstorm).GetMethod("StopSandstorm", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)!);
|
||||
|
||||
// Main.DoUpdate postfix.
|
||||
public static void Update()
|
||||
{
|
||||
if (Main.gameMenu)
|
||||
{
|
||||
_freezing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Settings.FreezeTime)
|
||||
{
|
||||
if (!_freezing)
|
||||
{
|
||||
_time = Main.time;
|
||||
_day = Main.dayTime;
|
||||
_freezing = true;
|
||||
}
|
||||
|
||||
Main.time = _time;
|
||||
Main.dayTime = _day;
|
||||
}
|
||||
else
|
||||
{
|
||||
_freezing = false;
|
||||
}
|
||||
|
||||
if (Settings.ItemVacuum)
|
||||
ItemFeatures.PullAll();
|
||||
}
|
||||
|
||||
public static void SetTime(double time, bool day)
|
||||
{
|
||||
Main.dayTime = day;
|
||||
Main.time = time;
|
||||
_time = time;
|
||||
_day = day;
|
||||
}
|
||||
|
||||
#if TMODLOADER
|
||||
// tModLoader's StartRain has no instant flag.
|
||||
public static void StartRain()
|
||||
{
|
||||
Main.StartRain();
|
||||
Main.cloudAlpha = Main.maxRaining;
|
||||
}
|
||||
#else
|
||||
public static void StartRain() => Main.StartRain(true, null, false);
|
||||
#endif
|
||||
|
||||
public static void StopRain()
|
||||
{
|
||||
#if TMODLOADER
|
||||
Main.StopRain();
|
||||
#else
|
||||
Main.StopRain(true);
|
||||
#endif
|
||||
Main.maxRaining = 0f;
|
||||
Main.cloudAlpha = 0f;
|
||||
}
|
||||
|
||||
public static bool SandstormActive => Sandstorm.Happening;
|
||||
|
||||
// Sandstorm.StartSandstorm/StopSandstorm are private on Terraria, public on tModLoader.
|
||||
public static void SetSandstorm(bool start) => (start ? StartSandstorm : StopSandstorm)();
|
||||
|
||||
public static void SetSlimeRain(bool start)
|
||||
{
|
||||
if (start)
|
||||
Main.StartSlimeRain(true);
|
||||
else
|
||||
Main.StopSlimeRain(true);
|
||||
}
|
||||
|
||||
#if TMODLOADER
|
||||
public static void DropMeteor() => WorldGen.dropMeteor();
|
||||
#else
|
||||
public static void DropMeteor() => WorldGen.dropMeteor(false);
|
||||
#endif
|
||||
|
||||
public static void StartInvasion(int id) => Main.StartInvasion(id);
|
||||
|
||||
public static void StopInvasion()
|
||||
{
|
||||
Main.invasionType = 0;
|
||||
Main.invasionSize = 0;
|
||||
}
|
||||
|
||||
public static string? InvasionName
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Main.invasionType == 0)
|
||||
return null;
|
||||
|
||||
foreach ((string name, int id) in Invasions)
|
||||
if (id == Main.invasionType)
|
||||
return name;
|
||||
return "Invasion";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using TerraSharp.Core;
|
||||
|
||||
namespace TerraSharp.Patches;
|
||||
|
||||
internal static class Guard
|
||||
{
|
||||
public static void Run(string where, Action action)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error(where, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Run<T>(string where, Action<T> action, T argument)
|
||||
{
|
||||
try
|
||||
{
|
||||
action(argument);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error(where, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using MonoMod.Cil;
|
||||
using MonoMod.RuntimeDetour;
|
||||
using TerraSharp.Core;
|
||||
|
||||
namespace TerraSharp.Patches;
|
||||
|
||||
internal sealed class HookRegistry : IDisposable
|
||||
{
|
||||
const BindingFlags Methods = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly;
|
||||
static readonly DetourConfig Config = new(Plugin.Id);
|
||||
readonly List<IDisposable> _hooks = [];
|
||||
|
||||
public static HookRegistry Install()
|
||||
{
|
||||
var hooks = new HookRegistry();
|
||||
try
|
||||
{
|
||||
MainPatches.Install(hooks);
|
||||
PlayerPatches.Install(hooks);
|
||||
NpcPatches.Install(hooks);
|
||||
ItemCheckShootPatch.Install(hooks);
|
||||
SandboxGuards.Install(hooks);
|
||||
return hooks;
|
||||
}
|
||||
catch
|
||||
{
|
||||
hooks.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static MethodInfo Method(Type type, string name, Type[]? parameters = null) => (parameters is null ? type.GetMethod(name, Methods) : type.GetMethod(name, Methods, null, parameters, null)) ?? throw new MissingMethodException(type.FullName, name);
|
||||
|
||||
public void Add(Type type, string method, Delegate callback) => Add(Method(type, method), callback);
|
||||
|
||||
public void Add(MethodInfo method, Delegate callback) => _hooks.Add(new Hook(method, callback, Config));
|
||||
|
||||
public void Modify(MethodInfo method, ILContext.Manipulator callback) => _hooks.Add(new ILHook(method, callback, Config));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Exception? failure = null;
|
||||
for (int i = _hooks.Count - 1; i >= 0; i--)
|
||||
{
|
||||
try
|
||||
{
|
||||
_hooks[i].Dispose();
|
||||
_hooks.RemoveAt(i);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
failure ??= e;
|
||||
Log.Error("Hook disposal failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (failure is not null)
|
||||
throw new InvalidOperationException("One or more hooks could not be removed.", failure);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Terraria;
|
||||
using Terraria.UI;
|
||||
using TerraSharp.Core;
|
||||
using TerraSharp.Features;
|
||||
using TerraSharp.Simulation;
|
||||
using TerraSharp.UI;
|
||||
|
||||
namespace TerraSharp.Patches;
|
||||
|
||||
internal static class MainPatches
|
||||
{
|
||||
internal static void Install(HookRegistry hooks)
|
||||
{
|
||||
MainUpdatePatch.Install(hooks);
|
||||
MainSetupLayersPatch.Install(hooks);
|
||||
MainDrawInterfacePatch.Install(hooks);
|
||||
MainDrawMenuPatch.Install(hooks);
|
||||
MainDrawCursorPatch.Install(hooks);
|
||||
UserInterfaceUpdatePatch.Install(hooks);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class MainUpdatePatch
|
||||
{
|
||||
delegate void Original(Main self, ref GameTime gameTime);
|
||||
delegate void UpdateHook(Original original, Main self, ref GameTime gameTime);
|
||||
|
||||
internal static void Install(HookRegistry hooks) => hooks.Add(typeof(Main), "DoUpdate", (UpdateHook)Update);
|
||||
|
||||
static void Update(Original original, Main self, ref GameTime gameTime)
|
||||
{
|
||||
original(self, ref gameTime);
|
||||
Guard.Run("Main.DoUpdate postfix", Tick);
|
||||
}
|
||||
|
||||
static void Tick()
|
||||
{
|
||||
Plugin.Tick();
|
||||
if (!Plugin.Active)
|
||||
return;
|
||||
|
||||
Hotkeys.Update();
|
||||
Sandbox.NewTick();
|
||||
WorldFeatures.Update();
|
||||
Settings.AutoSave();
|
||||
Profiler.EndTick();
|
||||
}
|
||||
}
|
||||
|
||||
// The layer list is rebuilt on first draw and on resolution/UI changes.
|
||||
internal static class MainSetupLayersPatch
|
||||
{
|
||||
delegate void Original(Main self);
|
||||
|
||||
internal static void Install(HookRegistry hooks) => hooks.Add(typeof(Main), "SetupDrawInterfaceLayers", (Action<Original, Main>)SetupLayers);
|
||||
|
||||
static void SetupLayers(Original original, Main self)
|
||||
{
|
||||
original(self);
|
||||
if (Plugin.Active)
|
||||
Guard.Run("SetupDrawInterfaceLayers postfix", InterfaceLayers.Install);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the mouse even if the windows layer did not run.
|
||||
internal static class MainDrawInterfacePatch
|
||||
{
|
||||
delegate void Original(Main self, GameTime gameTime);
|
||||
|
||||
internal static void Install(HookRegistry hooks) => hooks.Add(typeof(Main), "DrawInterface", (Action<Original, Main, GameTime>)DrawInterface);
|
||||
|
||||
static void DrawInterface(Original original, Main self, GameTime gameTime)
|
||||
{
|
||||
try
|
||||
{
|
||||
original(self, gameTime);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Guard.Run("Main.DrawInterface cleanup", InterfaceLayers.Unblock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class MainDrawMenuPatch
|
||||
{
|
||||
delegate void Original(Main self, GameTime gameTime);
|
||||
|
||||
internal static void Install(HookRegistry hooks) => hooks.Add(typeof(Main), "DrawMenu", (Action<Original, Main, GameTime>)DrawMenu);
|
||||
|
||||
static void DrawMenu(Original original, Main self, GameTime gameTime)
|
||||
{
|
||||
Guard.Run("Main.DrawMenu prefix", BeforeDraw);
|
||||
|
||||
try
|
||||
{
|
||||
original(self, gameTime);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Guard.Run("Main.DrawMenu cleanup", AfterDraw);
|
||||
}
|
||||
}
|
||||
|
||||
static void BeforeDraw()
|
||||
{
|
||||
InterfaceLayers.InMenuDraw = true;
|
||||
InterfaceLayers.HideMouse();
|
||||
}
|
||||
|
||||
static void AfterDraw()
|
||||
{
|
||||
InterfaceLayers.InMenuDraw = false;
|
||||
InterfaceLayers.ShowMouse();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class MainDrawCursorPatch
|
||||
{
|
||||
delegate void Original(Vector2 bonus, bool smart);
|
||||
|
||||
internal static void Install(HookRegistry hooks) => hooks.Add(typeof(Main), nameof(Main.DrawCursor), (Action<Original, Vector2, bool>)DrawCursor);
|
||||
|
||||
static void DrawCursor(Original original, Vector2 bonus, bool smart)
|
||||
{
|
||||
Guard.Run("Main.DrawCursor prefix", BeforeDraw);
|
||||
original(bonus, smart);
|
||||
}
|
||||
|
||||
static void BeforeDraw()
|
||||
{
|
||||
if (!InterfaceLayers.InMenuDraw || !Main.gameMenu || !Plugin.Active)
|
||||
return;
|
||||
|
||||
InterfaceLayers.InMenuDraw = false;
|
||||
InterfaceLayers.DrawMenuWindows();
|
||||
}
|
||||
}
|
||||
|
||||
// UIState menus read the mouse in UserInterface.Update.
|
||||
internal static class UserInterfaceUpdatePatch
|
||||
{
|
||||
delegate void Original(UserInterface self, GameTime gameTime);
|
||||
|
||||
internal static void Install(HookRegistry hooks) => hooks.Add(typeof(UserInterface), nameof(UserInterface.Update), (Action<Original, UserInterface, GameTime>)Update);
|
||||
|
||||
static void Update(Original original, UserInterface self, GameTime gameTime)
|
||||
{
|
||||
Guard.Run("UserInterface.Update prefix", InterfaceLayers.HideMouse);
|
||||
|
||||
try
|
||||
{
|
||||
original(self, gameTime);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Guard.Run("UserInterface.Update cleanup", InterfaceLayers.ShowMouse);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Mono.Cecil.Cil;
|
||||
using MonoMod.Cil;
|
||||
using Terraria;
|
||||
using Terraria.Utilities;
|
||||
using TerraSharp.Core;
|
||||
|
||||
namespace TerraSharp.Patches;
|
||||
|
||||
public static class NoSpread
|
||||
{
|
||||
public static bool Active { get; set; }
|
||||
|
||||
public static int Next(UnifiedRandom random, int minimum, int maximum) => Active && minimum < 0 && minimum == 1 - maximum ? 0 : random.Next(minimum, maximum);
|
||||
|
||||
public static float NextFloat(UnifiedRandom random) => Active ? 0.5f : random.NextFloat();
|
||||
|
||||
public static float NextFloatDirection(UnifiedRandom random) => Active ? 0f : random.NextFloatDirection();
|
||||
|
||||
public static Vector2 RotatedByRandom(Vector2 vector, double maximum) => Active ? vector : vector.RotatedByRandom(maximum);
|
||||
}
|
||||
|
||||
// Patch ItemCheck_Shoot call sites because the random helpers may be inlined.
|
||||
internal static class ItemCheckShootPatch
|
||||
{
|
||||
#if TMODLOADER
|
||||
internal static void Install(HookRegistry hooks)
|
||||
{
|
||||
hooks.Modify(HookRegistry.Method(typeof(Player), "ItemCheck_Shoot", [typeof(int), typeof(Item), typeof(int)]), Modify);
|
||||
hooks.Add(typeof(Player), "ItemCheck_Shoot", (Action<Action<Player, int, Item, int>, Player, int, Item, int>)Shoot);
|
||||
}
|
||||
|
||||
static void Shoot(Action<Player, int, Item, int> original, Player player, int playerIndex, Item item, int damage) => WithNoSpread(player, () => original(player, playerIndex, item, damage));
|
||||
#else
|
||||
internal static void Install(HookRegistry hooks)
|
||||
{
|
||||
hooks.Modify(HookRegistry.Method(typeof(Player), "ItemCheck_Shoot", [typeof(int), typeof(Item), typeof(int), typeof(bool)]), Modify);
|
||||
hooks.Add(typeof(Player), "ItemCheck_Shoot", (Action<Action<Player, int, Item, int, bool>, Player, int, Item, int, bool>)Shoot);
|
||||
}
|
||||
|
||||
static void Shoot(Action<Player, int, Item, int, bool> original, Player player, int playerIndex, Item item, int damage, bool withAudioVisualFeedback) => WithNoSpread(player, () => original(player, playerIndex, item, damage, withAudioVisualFeedback));
|
||||
#endif
|
||||
|
||||
static void WithNoSpread(Player player, Action shoot)
|
||||
{
|
||||
bool wasActive = NoSpread.Active;
|
||||
NoSpread.Active = Plugin.Active && Settings.NoSpread && player.whoAmI == Main.myPlayer && !Main.gameMenu;
|
||||
|
||||
try
|
||||
{
|
||||
shoot();
|
||||
}
|
||||
finally
|
||||
{
|
||||
NoSpread.Active = wasActive;
|
||||
}
|
||||
}
|
||||
|
||||
static void Modify(ILContext context)
|
||||
{
|
||||
var wrappers = new Dictionary<MethodBase, MethodInfo>
|
||||
{
|
||||
[HookRegistry.Method(typeof(UnifiedRandom), nameof(UnifiedRandom.Next), [typeof(int), typeof(int)])] = HookRegistry.Method(typeof(NoSpread), nameof(NoSpread.Next)),
|
||||
[HookRegistry.Method(typeof(Utils), nameof(Utils.NextFloat), [typeof(UnifiedRandom)])] = HookRegistry.Method(typeof(NoSpread), nameof(NoSpread.NextFloat)),
|
||||
[HookRegistry.Method(typeof(Utils), nameof(Utils.NextFloatDirection), [typeof(UnifiedRandom)])] = HookRegistry.Method(typeof(NoSpread), nameof(NoSpread.NextFloatDirection)),
|
||||
[HookRegistry.Method(typeof(Utils), nameof(Utils.RotatedByRandom), [typeof(Vector2), typeof(double)])] = HookRegistry.Method(typeof(NoSpread), nameof(NoSpread.RotatedByRandom)),
|
||||
};
|
||||
|
||||
int swapped = 0;
|
||||
foreach (Instruction instruction in context.Body.Instructions)
|
||||
foreach (KeyValuePair<MethodBase, MethodInfo> wrapper in wrappers)
|
||||
{
|
||||
if (!instruction.MatchCallOrCallvirt(wrapper.Key))
|
||||
continue;
|
||||
|
||||
instruction.OpCode = OpCodes.Call;
|
||||
instruction.Operand = context.Import(wrapper.Value);
|
||||
swapped++;
|
||||
break;
|
||||
}
|
||||
|
||||
if (swapped == 0)
|
||||
throw new InvalidOperationException("No Spread: ItemCheck_Shoot contains no matching random calls.");
|
||||
|
||||
Log.Debug($"No Spread: rewired {swapped} random calls in ItemCheck_Shoot");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using Terraria;
|
||||
using TerraSharp.Core;
|
||||
using TerraSharp.Features.Combat;
|
||||
|
||||
namespace TerraSharp.Patches;
|
||||
|
||||
// After the AI chose its velocity, before UpdateNPC applies it.
|
||||
internal static class NpcPatches
|
||||
{
|
||||
delegate void Original(NPC self);
|
||||
|
||||
internal static void Install(HookRegistry hooks) => hooks.Add(typeof(NPC), nameof(NPC.AI), (Action<Original, NPC>)AI);
|
||||
|
||||
static void AI(Original original, NPC self)
|
||||
{
|
||||
original(self);
|
||||
if (Plugin.Active)
|
||||
Guard.Run("NPC.AI postfix", CombatFeatures.PostNpcAI, self);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using Terraria;
|
||||
using TerraSharp.Core;
|
||||
using TerraSharp.Features;
|
||||
using TerraSharp.Features.Combat;
|
||||
using TerraSharp.Simulation;
|
||||
using TerraSharp.UI;
|
||||
|
||||
namespace TerraSharp.Patches;
|
||||
|
||||
internal static class PlayerPatches
|
||||
{
|
||||
internal static void Install(HookRegistry hooks)
|
||||
{
|
||||
PlayerUpdatePatch.Install(hooks);
|
||||
PlayerItemCheckPatch.Install(hooks);
|
||||
PlayerMovementPatch.Install(hooks);
|
||||
}
|
||||
}
|
||||
|
||||
// Player.Update receives whoAmI; ResetEffects and ItemCheck run after the prefix.
|
||||
internal static class PlayerUpdatePatch
|
||||
{
|
||||
delegate void Original(Player self, int whoAmI);
|
||||
|
||||
internal static void Install(HookRegistry hooks) => hooks.Add(typeof(Player), nameof(Player.Update), (Action<Original, Player, int>)Update);
|
||||
|
||||
static bool IsLocal(int whoAmI) => Plugin.Active && whoAmI == Main.myPlayer && !Main.gameMenu;
|
||||
|
||||
static void Update(Original original, Player self, int whoAmI)
|
||||
{
|
||||
if (IsLocal(whoAmI))
|
||||
Guard.Run("Player.Update prefix", PreUpdate, self);
|
||||
|
||||
original(self, whoAmI);
|
||||
|
||||
if (IsLocal(whoAmI))
|
||||
Guard.Run("Player.Update postfix", PostUpdate, self);
|
||||
}
|
||||
|
||||
static void PreUpdate(Player player)
|
||||
{
|
||||
WindowManager.ConsumeInput(player);
|
||||
PlayerFeatures.PreUpdate(player);
|
||||
}
|
||||
|
||||
static void PostUpdate(Player player)
|
||||
{
|
||||
Clock.Advance();
|
||||
PlayerFeatures.PostUpdate(player);
|
||||
CombatFeatures.PostPlayerUpdate(player);
|
||||
Ballistics.Observe();
|
||||
NpcMotion.Track();
|
||||
}
|
||||
}
|
||||
|
||||
// ItemCheckWrapped runs after control input and before ItemCheck.
|
||||
internal static class PlayerItemCheckPatch
|
||||
{
|
||||
delegate void Original(Player self, int whoAmI);
|
||||
|
||||
internal static void Install(HookRegistry hooks) => hooks.Add(typeof(Player), "ItemCheckWrapped", (Action<Original, Player, int>)ItemCheck);
|
||||
|
||||
static void ItemCheck(Original original, Player self, int whoAmI)
|
||||
{
|
||||
if (Plugin.Active && self.whoAmI == Main.myPlayer && !Main.gameMenu)
|
||||
Guard.Run("ItemCheckWrapped prefix", BeforeItemCheck, self);
|
||||
|
||||
try
|
||||
{
|
||||
original(self, whoAmI);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Guard.Run("ItemCheckWrapped cleanup", AimOverride.Restore);
|
||||
}
|
||||
}
|
||||
|
||||
static void BeforeItemCheck(Player player)
|
||||
{
|
||||
AutoFishing.PreItemCheck(player);
|
||||
Aimbot.PreItemCheck(player);
|
||||
ShotPreview.Update(player);
|
||||
}
|
||||
}
|
||||
|
||||
// HorizontalMovement runs after equipment effects and before movement physics.
|
||||
internal static class PlayerMovementPatch
|
||||
{
|
||||
delegate void Original(Player self);
|
||||
|
||||
internal static void Install(HookRegistry hooks) => hooks.Add(typeof(Player), nameof(Player.HorizontalMovement), (Action<Original, Player>)Move);
|
||||
|
||||
static void Move(Original original, Player self)
|
||||
{
|
||||
if (Plugin.Active && self.whoAmI == Main.myPlayer)
|
||||
Guard.Run("HorizontalMovement prefix", PlayerFeatures.PreMovement, self);
|
||||
|
||||
original(self);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Mono.Cecil;
|
||||
using Mono.Cecil.Cil;
|
||||
using MonoMod.Cil;
|
||||
using Terraria;
|
||||
using TerraSharp.Simulation;
|
||||
|
||||
namespace TerraSharp.Patches;
|
||||
|
||||
internal static class SandboxGuards
|
||||
{
|
||||
delegate void KillOriginal(Projectile projectile);
|
||||
delegate void AmmoOriginal(Player player, Item item, ref int type, ref float speed, ref bool canShoot, ref int damage, ref float knockback, ref int usedAmmo, bool dontConsume);
|
||||
delegate void AmmoHook(AmmoOriginal original, Player player, Item item, ref int type, ref float speed, ref bool canShoot, ref int damage, ref float knockback, ref int usedAmmo, bool dontConsume);
|
||||
|
||||
public static void Install(HookRegistry hooks)
|
||||
{
|
||||
hooks.Add(typeof(Projectile), nameof(Projectile.Kill), (Action<KillOriginal, Projectile>)Kill);
|
||||
// tModLoader adds a public out-only PickAmmo that forwards to this one.
|
||||
hooks.Add(HookRegistry.Method(typeof(Player), nameof(Player.PickAmmo), [typeof(Item), typeof(int).MakeByRefType(), typeof(float).MakeByRefType(), typeof(bool).MakeByRefType(), typeof(int).MakeByRefType(), typeof(float).MakeByRefType(), typeof(int).MakeByRefType(), typeof(bool)]), (AmmoHook)PickAmmo);
|
||||
(Type Type, string Method)[] targets =
|
||||
[
|
||||
(typeof(Projectile), nameof(Projectile.Damage)),
|
||||
(typeof(Collision), nameof(Collision.SwitchTiles)),
|
||||
(typeof(Terraria.Graphics.Light.LightingEngine), "AddLight"),
|
||||
(typeof(Terraria.Graphics.Light.LegacyLighting), "AddLight"),
|
||||
(typeof(Terraria.Audio.LegacySoundPlayer), "PlaySound"),
|
||||
(typeof(Terraria.Audio.SoundPlayer), "Play"),
|
||||
#if !TMODLOADER
|
||||
(typeof(Terraria.Audio.SoundPlayer), "PlayLooped"),
|
||||
#endif
|
||||
(typeof(WorldGen), "KillTile"),
|
||||
(typeof(WorldGen), "PlaceTile"),
|
||||
(typeof(WorldGen), "Convert"),
|
||||
(typeof(WorldGen), "KillWall"),
|
||||
(typeof(WorldGen), "OpenDoor"),
|
||||
(typeof(WorldGen), "CloseDoor"),
|
||||
(typeof(WorldGen), "ShiftTallGate"),
|
||||
(typeof(WorldGen), "ShiftTrapdoor"),
|
||||
(typeof(NPC), nameof(NPC.StrikeNPC)),
|
||||
#if !TMODLOADER
|
||||
(typeof(NPC), nameof(NPC.StrikeNPCNoInteraction)),
|
||||
#endif
|
||||
(typeof(Player), nameof(Player.CollideWithNPCs)),
|
||||
(typeof(Player), "TryUsingDiggerCart"),
|
||||
(typeof(Player), "MowTheLawn"),
|
||||
(typeof(NPC), nameof(NPC.checkDead)),
|
||||
(typeof(NPC), nameof(NPC.NPCLoot)),
|
||||
(typeof(NPC), nameof(NPC.Transform)),
|
||||
(typeof(NetMessage), "SendData"),
|
||||
(typeof(NetMessage), "TrySendData"),
|
||||
];
|
||||
const BindingFlags AllMethods = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly;
|
||||
foreach ((Type type, string name) in targets)
|
||||
{
|
||||
int count = 0;
|
||||
foreach (MethodInfo method in type.GetMethods(AllMethods))
|
||||
if (method.Name == name && !method.IsAbstract && !method.ContainsGenericParameters)
|
||||
{
|
||||
hooks.Modify(method, SuppressEffects);
|
||||
count++;
|
||||
}
|
||||
if (count == 0)
|
||||
throw new MissingMethodException(type.FullName, name);
|
||||
}
|
||||
|
||||
// Index Main.maxDust and Main.maxGore is the inactive slot the game itself returns when spawning is off.
|
||||
(Type Type, string Method, int Dummy)[] spawners = [(typeof(Dust), nameof(Dust.NewDust), Main.maxDust), (typeof(Gore), nameof(Gore.NewGore), Main.maxGore)];
|
||||
foreach ((Type type, string name, int dummy) in spawners)
|
||||
foreach (MethodInfo method in type.GetMethods(AllMethods))
|
||||
if (method.Name == name && method.ReturnType == typeof(int))
|
||||
hooks.Modify(method, context => ReturnInSandbox(context, dummy));
|
||||
}
|
||||
|
||||
static void Kill(KillOriginal original, Projectile projectile)
|
||||
{
|
||||
if (Sandbox.Active)
|
||||
projectile.active = false;
|
||||
else
|
||||
original(projectile);
|
||||
}
|
||||
|
||||
static void PickAmmo(AmmoOriginal original, Player player, Item item, ref int type, ref float speed, ref bool canShoot, ref int damage, ref float knockback, ref int usedAmmo, bool dontConsume) => original(player, item, ref type, ref speed, ref canShoot, ref damage, ref knockback, ref usedAmmo, dontConsume || Sandbox.DryRunning);
|
||||
|
||||
static bool AllowEffects() => !Sandbox.Active;
|
||||
|
||||
static void ReturnInSandbox(ILContext context, int value)
|
||||
{
|
||||
var cursor = new ILCursor(context);
|
||||
ILLabel body = cursor.DefineLabel();
|
||||
cursor.EmitDelegate<Func<bool>>(AllowEffects);
|
||||
cursor.Emit(OpCodes.Brtrue, body);
|
||||
cursor.Emit(OpCodes.Ldc_I4, value);
|
||||
cursor.Emit(OpCodes.Ret);
|
||||
cursor.Emit(OpCodes.Nop);
|
||||
body.Target = cursor.Prev;
|
||||
}
|
||||
|
||||
static void SuppressEffects(ILContext context)
|
||||
{
|
||||
var cursor = new ILCursor(context);
|
||||
ILLabel body = cursor.DefineLabel();
|
||||
cursor.EmitDelegate<Func<bool>>(AllowEffects);
|
||||
cursor.Emit(OpCodes.Brtrue, body);
|
||||
if (context.Method.ReturnType.MetadataType != MetadataType.Void)
|
||||
{
|
||||
if (context.Method.ReturnType.IsByReference)
|
||||
throw new NotSupportedException("Sandbox guards do not support ref returns.");
|
||||
var result = new VariableDefinition(context.Method.ReturnType);
|
||||
context.Body.Variables.Add(result);
|
||||
cursor.Emit(OpCodes.Ldloca, result);
|
||||
cursor.Emit(OpCodes.Initobj, result.VariableType);
|
||||
cursor.Emit(OpCodes.Ldloc, result);
|
||||
}
|
||||
cursor.Emit(OpCodes.Ret);
|
||||
cursor.Emit(OpCodes.Nop);
|
||||
body.Target = cursor.Prev;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace System.Runtime.CompilerServices;
|
||||
|
||||
// Lets records and init-only setters compile against .NET Framework.
|
||||
internal static class IsExternalInit
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Terraria;
|
||||
using TerraSharp.Core;
|
||||
using TerraSharp.Features.Combat;
|
||||
|
||||
namespace TerraSharp.Simulation;
|
||||
|
||||
// Life is measured in ticks; zero means unknown.
|
||||
public sealed class FlightModel
|
||||
{
|
||||
public float SpeedMul = 1f, Drag = 1f, Gravity, MaxFall = 16f;
|
||||
public int Delay, Life;
|
||||
public bool TileCollide = true;
|
||||
public int Width = 8, Height = 8;
|
||||
public bool Learned, SpeedLearned, Sandboxed;
|
||||
|
||||
public void Step(int age, ref Vector2 velocity)
|
||||
{
|
||||
if (age <= Delay)
|
||||
return;
|
||||
|
||||
velocity.X *= Drag;
|
||||
if (Gravity > 0f)
|
||||
velocity.Y = Math.Min(velocity.Y + Gravity, Math.Max(MaxFall, velocity.Y));
|
||||
}
|
||||
|
||||
public bool Blocked(Vector2 from, Vector2 to, bool pointOnly = false) =>
|
||||
TileCollide && TileCollision.Sweep(from, to, pointOnly ? 1 : Math.Max(1, Width), pointOnly ? 1 : Math.Max(1, Height));
|
||||
|
||||
public override string ToString() => string.Format(CultureInfo.InvariantCulture, "delay {0} drag {1:0.000} gravity {2:0.000} maxFall {3:0.0} speedMul {4:0.00} life {5}", Delay, Drag, Gravity, MaxFall, SpeedMul, Life);
|
||||
}
|
||||
|
||||
public static class Ballistics
|
||||
{
|
||||
public sealed class SavedModel
|
||||
{
|
||||
public int Delay { get; set; }
|
||||
public float Drag { get; set; }
|
||||
public float Gravity { get; set; }
|
||||
public float MaxFall { get; set; }
|
||||
public float SpeedMul { get; set; }
|
||||
public bool SpeedLearned { get; set; }
|
||||
public int Life { get; set; }
|
||||
}
|
||||
|
||||
sealed class Track
|
||||
{
|
||||
public int Type, Age;
|
||||
public bool Mine, Spoiled, Fresh;
|
||||
public Vector2 Last, LastVelocity;
|
||||
public float LaunchSpeed;
|
||||
public readonly List<Vector2> Velocities = [];
|
||||
}
|
||||
|
||||
sealed record Fit(float Drag, float Gravity, float MaxFall, float SpeedMul, int Delay, int Life);
|
||||
|
||||
const int MaxFits = 7;
|
||||
const int MaxSamples = 240;
|
||||
|
||||
static readonly Dictionary<int, FlightModel> Models = [];
|
||||
static readonly Dictionary<int, List<Fit>> Fits = [];
|
||||
static readonly Dictionary<int, string> Logged = [];
|
||||
static readonly Track?[] Tracks = new Track?[Main.maxProjectiles];
|
||||
static readonly bool[] WasActive = new bool[Main.maxProjectiles];
|
||||
static bool _observedOnce;
|
||||
static int _lastShootType;
|
||||
|
||||
public static float LastShootSpeed { get; private set; }
|
||||
|
||||
public static void RecordShot(int projectileType, float speed)
|
||||
{
|
||||
_lastShootType = projectileType;
|
||||
LastShootSpeed = speed;
|
||||
}
|
||||
|
||||
public static FlightModel Get(int type)
|
||||
{
|
||||
if (Models.TryGetValue(type, out FlightModel? model))
|
||||
return model;
|
||||
|
||||
var sample = new Projectile();
|
||||
sample.SetDefaults(type);
|
||||
model = new FlightModel { SpeedMul = 1 + sample.extraUpdates, TileCollide = sample.tileCollide, Width = sample.width, Height = sample.height };
|
||||
Models[type] = model;
|
||||
|
||||
if (Sandbox.Profile(type, model))
|
||||
LogModel(type, model, "sandbox");
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
public static bool Current(int slot, out Vector2 velocity, out int age)
|
||||
{
|
||||
Track? track = Tracks[slot];
|
||||
velocity = track?.LastVelocity ?? Vector2.Zero;
|
||||
age = track?.Age ?? 0;
|
||||
return track is { Velocities.Count: > 0 };
|
||||
}
|
||||
|
||||
public static void Observe()
|
||||
{
|
||||
for (int i = 0; i < Main.maxProjectiles; i++)
|
||||
{
|
||||
Projectile projectile = Main.projectile[i];
|
||||
bool wasActive = WasActive[i];
|
||||
WasActive[i] = projectile.active;
|
||||
Track? track = Tracks[i];
|
||||
bool flying = projectile is { active: true, bobber: false, minion: false } && !Sandbox.Held(projectile);
|
||||
|
||||
if (track is not null && (!flying || track.Type != projectile.type))
|
||||
{
|
||||
Learn(track);
|
||||
Tracks[i] = track = null;
|
||||
}
|
||||
|
||||
if (!flying)
|
||||
continue;
|
||||
|
||||
if (track is null)
|
||||
{
|
||||
bool mine = projectile.owner == Main.myPlayer && projectile.friendly;
|
||||
// Ignore projectiles already flying when tracking starts.
|
||||
Tracks[i] = new Track { Type = projectile.type, Last = projectile.position, Mine = mine, Fresh = _observedOnce && !wasActive, LaunchSpeed = mine && projectile.type == _lastShootType ? LastShootSpeed : 0f };
|
||||
continue;
|
||||
}
|
||||
|
||||
Vector2 velocity = projectile.position - track.Last;
|
||||
track.Last = projectile.position;
|
||||
track.Age++;
|
||||
|
||||
// Tile hits, bounces and homing turns show up as sharp changes.
|
||||
if (!track.Spoiled && track.Velocities.Count > 0 && (velocity - track.LastVelocity).Length() > Math.Max(4f, track.LastVelocity.Length() * 0.5f))
|
||||
track.Spoiled = true;
|
||||
if (!track.Spoiled && track.Velocities.Count < MaxSamples)
|
||||
track.Velocities.Add(velocity);
|
||||
|
||||
track.LastVelocity = velocity;
|
||||
}
|
||||
|
||||
_observedOnce = true;
|
||||
}
|
||||
|
||||
static void Learn(Track track)
|
||||
{
|
||||
if (track.Velocities.Count < 8 || !track.Fresh || Get(track.Type).Sandboxed)
|
||||
return;
|
||||
|
||||
if (!Fits.TryGetValue(track.Type, out List<Fit>? list))
|
||||
Fits[track.Type] = list = [];
|
||||
list.Add(FitVelocities(track.Velocities, track.Age, track.Mine ? track.LaunchSpeed : 0f));
|
||||
if (list.Count > MaxFits)
|
||||
list.RemoveAt(0);
|
||||
|
||||
Aggregate(track.Type, list);
|
||||
}
|
||||
|
||||
public static bool Apply(FlightModel model, List<Vector2> velocities, int life, float launchSpeed)
|
||||
{
|
||||
if (velocities.Count < 8)
|
||||
return false;
|
||||
|
||||
Fit fit = FitVelocities(velocities, life, launchSpeed);
|
||||
(model.Drag, model.Delay, model.Gravity, model.MaxFall, model.Life) = (fit.Drag, fit.Delay, fit.Gravity, fit.MaxFall, fit.Life);
|
||||
|
||||
if (fit.SpeedMul > 0f)
|
||||
{
|
||||
model.SpeedMul = fit.SpeedMul;
|
||||
model.SpeedLearned = true;
|
||||
}
|
||||
|
||||
model.Learned = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
static float Median(IEnumerable<float> values)
|
||||
{
|
||||
List<float> sorted = [.. values];
|
||||
sorted.Sort();
|
||||
return sorted.Count == 0 ? 0f : sorted[sorted.Count / 2];
|
||||
}
|
||||
|
||||
static Fit FitVelocities(List<Vector2> velocities, int life, float launchSpeed)
|
||||
{
|
||||
int delay = velocities.Count;
|
||||
for (int i = 1; i < velocities.Count; i++)
|
||||
{
|
||||
float verticalChange = velocities[i].Y - velocities[i - 1].Y;
|
||||
bool xChanged = Math.Abs(velocities[i - 1].X) > 0.3f && Math.Abs(velocities[i].X / velocities[i - 1].X - 1f) > 0.004f;
|
||||
if (Math.Abs(verticalChange) > 0.01f || xChanged)
|
||||
{
|
||||
delay = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var drags = new List<float>();
|
||||
var gravities = new List<float>();
|
||||
float terminal = 0f;
|
||||
int flat = 0;
|
||||
for (int i = Math.Max(1, delay); i < velocities.Count; i++)
|
||||
{
|
||||
if (Math.Abs(velocities[i - 1].X) > 0.3f)
|
||||
drags.Add(velocities[i].X / velocities[i - 1].X);
|
||||
|
||||
float verticalChange = velocities[i].Y - velocities[i - 1].Y;
|
||||
// Terminal speed only from an actual plateau of falling ticks.
|
||||
if (velocities[i].Y > 1f && Math.Abs(verticalChange) < 0.01f)
|
||||
{
|
||||
if (++flat >= 3)
|
||||
terminal = Math.Max(terminal, velocities[i].Y);
|
||||
}
|
||||
else
|
||||
{
|
||||
flat = 0;
|
||||
if (verticalChange > 0.005f)
|
||||
gravities.Add(verticalChange);
|
||||
}
|
||||
}
|
||||
|
||||
float speedMul = launchSpeed > 0f ? velocities[0].Length() / launchSpeed : 0f;
|
||||
if (speedMul is < 0.2f or > 20f)
|
||||
speedMul = 0f;
|
||||
|
||||
float maxSeen = velocities.Max(x => x.Y);
|
||||
return new Fit(
|
||||
Drag: drags.Count > 0 ? MathHelper.Clamp(Median(drags), 0.85f, 1.1f) : 1f,
|
||||
Gravity: gravities.Count >= 3 ? Median(gravities) : 0f,
|
||||
// Most vanilla projectiles cap their fall at 16 px/update.
|
||||
MaxFall: terminal > 0f ? terminal : Math.Max(16f, maxSeen),
|
||||
SpeedMul: speedMul,
|
||||
Delay: delay >= velocities.Count ? 0 : delay,
|
||||
Life: life);
|
||||
}
|
||||
|
||||
// Shorter flights may have ended on collision.
|
||||
static void Aggregate(int type, List<Fit> list)
|
||||
{
|
||||
FlightModel model = Get(type);
|
||||
model.Drag = Median(list.Select(x => x.Drag));
|
||||
model.Delay = (int)Median(list.Select(x => (float)x.Delay));
|
||||
model.Gravity = Median(list.Select(x => x.Gravity));
|
||||
model.MaxFall = Median(list.Select(x => x.MaxFall));
|
||||
model.Life = list.Max(x => x.Life);
|
||||
model.Learned = true;
|
||||
|
||||
List<float> speeds = [.. list.Where(x => x.SpeedMul > 0f).Select(x => x.SpeedMul)];
|
||||
if (speeds.Count > 0)
|
||||
{
|
||||
model.SpeedMul = Median(speeds);
|
||||
model.SpeedLearned = true;
|
||||
}
|
||||
|
||||
LogModel(type, model, $"{list.Count} fits");
|
||||
}
|
||||
|
||||
static void LogModel(int type, FlightModel model, string source)
|
||||
{
|
||||
string description = model.ToString();
|
||||
if (Settings.LogBallistics && (!Logged.TryGetValue(type, out string? last) || last != description))
|
||||
Log.Debug($"ballistics {Lang.GetProjectileName(type).Value} ({type}): {description} [{source}]");
|
||||
Logged[type] = description;
|
||||
}
|
||||
|
||||
public static Dictionary<int, SavedModel> Save()
|
||||
{
|
||||
var models = new Dictionary<int, SavedModel>();
|
||||
foreach (KeyValuePair<int, FlightModel> entry in Models)
|
||||
{
|
||||
FlightModel model = entry.Value;
|
||||
if (model.Learned && !model.Sandboxed)
|
||||
models[entry.Key] = new SavedModel { Delay = model.Delay, Drag = model.Drag, Gravity = model.Gravity, MaxFall = model.MaxFall, SpeedMul = model.SpeedMul, SpeedLearned = model.SpeedLearned, Life = model.Life };
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
public static void Load(int type, SavedModel model)
|
||||
{
|
||||
FlightModel current = Get(type);
|
||||
if (current.Sandboxed)
|
||||
return;
|
||||
|
||||
current.Delay = model.Delay;
|
||||
current.Drag = model.Drag;
|
||||
current.Gravity = model.Gravity;
|
||||
current.MaxFall = model.MaxFall;
|
||||
current.SpeedMul = model.SpeedMul;
|
||||
current.SpeedLearned = model.SpeedLearned;
|
||||
current.Life = model.Life;
|
||||
current.Learned = true;
|
||||
|
||||
Fits[type] = [new Fit(current.Drag, current.Gravity, current.MaxFall, current.SpeedLearned ? current.SpeedMul : 0f, current.Delay, current.Life)];
|
||||
Logged[type] = current.ToString();
|
||||
}
|
||||
|
||||
public static void Reset()
|
||||
{
|
||||
Models.Clear();
|
||||
Fits.Clear();
|
||||
Logged.Clear();
|
||||
Log.Info("ballistics reset");
|
||||
}
|
||||
|
||||
public static int Path(FlightModel model, Vector2 position, Vector2 velocity, int age, int ticks, Vector2[] output)
|
||||
{
|
||||
int count = 0;
|
||||
output[count++] = position;
|
||||
|
||||
for (int t = 1; t <= ticks && count < output.Length; t++)
|
||||
{
|
||||
model.Step(age + t, ref velocity);
|
||||
Vector2 next = position + velocity;
|
||||
output[count++] = next;
|
||||
if (model.Blocked(position, next))
|
||||
break;
|
||||
|
||||
position = next;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public static bool Solve(int type, float speed, Vector2 origin, Vector2 target, NPC? mover, float tolerance, out Vector2 direction)
|
||||
{
|
||||
FlightModel model = Get(type);
|
||||
float launchVelocity = Math.Max(0.1f, speed * model.SpeedMul);
|
||||
int maxTicks = model.Life > 0 ? Math.Min(240, model.Life) : 240;
|
||||
Vector2 Goal(int t) => mover is null ? target : target + NpcMotion.Offset(mover, t);
|
||||
|
||||
Vector2 aim = target;
|
||||
direction = Vector2.Normalize(target - origin);
|
||||
float bestMiss = float.MaxValue;
|
||||
bool blocked = false;
|
||||
|
||||
for (int iteration = 0; iteration < 8; iteration++)
|
||||
{
|
||||
Vector2 position = origin, velocity = direction * launchVelocity, best = position;
|
||||
float bestDistance = float.MaxValue;
|
||||
int bestTick = 0;
|
||||
blocked = false;
|
||||
|
||||
for (int t = 1; t <= maxTicks; t++)
|
||||
{
|
||||
model.Step(t, ref velocity);
|
||||
Vector2 next = position + velocity;
|
||||
float distance = Vector2.DistanceSquared(next, Goal(t));
|
||||
if (distance < bestDistance)
|
||||
{
|
||||
bestDistance = distance;
|
||||
best = next;
|
||||
bestTick = t;
|
||||
}
|
||||
|
||||
// The game lets a projectile spawn overlapping ground next to the player.
|
||||
if (model.Blocked(position, next, t <= 2))
|
||||
{
|
||||
// Landing behind an enemy the shot already passed through is fine.
|
||||
blocked = bestDistance > tolerance * tolerance;
|
||||
break;
|
||||
}
|
||||
|
||||
position = next;
|
||||
if (t > bestTick + 30)
|
||||
break;
|
||||
}
|
||||
|
||||
Vector2 miss = Goal(bestTick) - best;
|
||||
bestMiss = miss.Length();
|
||||
if (bestMiss < 2f)
|
||||
break;
|
||||
|
||||
aim += miss;
|
||||
direction = Vector2.Normalize(aim - origin);
|
||||
}
|
||||
|
||||
return !blocked && bestMiss <= tolerance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Reflection.Emit;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Terraria;
|
||||
using Terraria.Utilities;
|
||||
#if TMODLOADER
|
||||
using Terraria.ModLoader;
|
||||
#endif
|
||||
using TerraSharp.Core;
|
||||
|
||||
namespace TerraSharp.Simulation;
|
||||
|
||||
// Side effects are suppressed by Patches/SandboxGuards; dust and gore go to the game's dummy slot.
|
||||
public static class Sandbox
|
||||
{
|
||||
// Animation is the owner's item animation length for stabs, 0 otherwise.
|
||||
public sealed record Shot(Projectile Projectile, int Slot, int Animation);
|
||||
|
||||
public static bool Active { get; private set; }
|
||||
|
||||
public static bool DryRunning { get; private set; }
|
||||
|
||||
const BindingFlags AnyInstance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
|
||||
static readonly Func<object, object> Clone = (Func<object, object>)Delegate.CreateDelegate(typeof(Func<object, object>), typeof(object).GetMethod("MemberwiseClone", AnyInstance)!);
|
||||
static readonly HashSet<int> HeldStyles = [7, 13, 15, 20, 26, 62, 66, 67, 75, 99, 140, 142, 165, 190, 191];
|
||||
static readonly HashSet<int> FailedTypes = [];
|
||||
static readonly HashSet<int> FailedWeapons = [];
|
||||
static readonly UnifiedRandom Random = new(12345);
|
||||
#if TMODLOADER
|
||||
static readonly Action<Player, int, Item, int> Shoot = (Action<Player, int, Item, int>)Delegate.CreateDelegate(typeof(Action<Player, int, Item, int>), typeof(Player).GetMethod("ItemCheck_Shoot", AnyInstance)!);
|
||||
#else
|
||||
static readonly Action<Player, int, Item, int, bool> Shoot = (Action<Player, int, Item, int, bool>)Delegate.CreateDelegate(typeof(Action<Player, int, Item, int, bool>), typeof(Player).GetMethod("ItemCheck_Shoot", AnyInstance)!);
|
||||
#endif
|
||||
static readonly Action<Player, Item> ApplyItemAnimation = (Action<Player, Item>)Delegate.CreateDelegate(typeof(Action<Player, Item>), typeof(Player).GetMethod("ApplyItemAnimation", AnyInstance, null, [typeof(Item)], null)!);
|
||||
static readonly Action<Player, Player> CopyPlayer = BuildFieldCopier<Player>();
|
||||
|
||||
delegate void MeleeHitbox(Player player, Item item, Rectangle heldItemFrame, out bool dontAttack, out Rectangle itemRectangle);
|
||||
|
||||
static readonly MeleeHitbox GetMeleeHitbox = (MeleeHitbox)Delegate.CreateDelegate(typeof(MeleeHitbox), typeof(Player).GetMethod("ItemCheck_GetMeleeHitbox", AnyInstance)!);
|
||||
|
||||
static readonly EntityCopier<Projectile> ProjectileCopier = new();
|
||||
static readonly EntityCopier<NPC> NpcCopier = new();
|
||||
#if TMODLOADER
|
||||
static readonly Action<NPC> UpdateGravity = (Action<NPC>)Delegate.CreateDelegate(typeof(Action<NPC>), typeof(NPC).GetMethod("UpdateNPC_UpdateGravity", AnyInstance)!);
|
||||
#else
|
||||
delegate void GravityStep(NPC npc, out float maxFallSpeed);
|
||||
|
||||
static readonly GravityStep UpdateGravity = (GravityStep)Delegate.CreateDelegate(typeof(GravityStep), typeof(NPC).GetMethod("UpdateNPC_UpdateGravity", AnyInstance)!);
|
||||
static readonly FieldInfo Gravity = typeof(NPC).GetField("gravity", BindingFlags.Static | BindingFlags.NonPublic)!;
|
||||
#endif
|
||||
static readonly Action<NPC> UpdateCollision = (Action<NPC>)Delegate.CreateDelegate(typeof(Action<NPC>), typeof(NPC).GetMethod("UpdateCollision", AnyInstance)!);
|
||||
#if TMODLOADER
|
||||
// NPC.AI runs GlobalNPC hooks, whose per-entity instances are shared with the live NPC.
|
||||
static readonly Action<NPC> RunAI = (Action<NPC>)Delegate.CreateDelegate(typeof(Action<NPC>), typeof(NPC).GetMethod("VanillaAI", AnyInstance, null, Type.EmptyTypes, null)!);
|
||||
#else
|
||||
static readonly Action<NPC> RunAI = (Action<NPC>)Delegate.CreateDelegate(typeof(Action<NPC>), typeof(NPC).GetMethod("AI", AnyInstance, null, Type.EmptyTypes, null)!);
|
||||
#endif
|
||||
static readonly HashSet<int> FailedNpcTypes = [];
|
||||
|
||||
static Projectile[] _projectiles = [];
|
||||
static Projectile[] _realProjectiles = [];
|
||||
static UnifiedRandom? _realRandom;
|
||||
static Player? _savedPlayer;
|
||||
static NPC[] _npcs = [];
|
||||
static NPC[] _realNpcs = [];
|
||||
static Array?[][] _npcArrays = [];
|
||||
static int _npcSnapshotTick = -1;
|
||||
|
||||
static IEnumerable<FieldInfo> InstanceFields(Type? type)
|
||||
{
|
||||
for (; type is not null && type != typeof(object); type = type.BaseType)
|
||||
foreach (FieldInfo field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
|
||||
yield return field;
|
||||
}
|
||||
|
||||
// Reference fields are copied without cloning.
|
||||
static Action<T, T> BuildFieldCopier<T>()
|
||||
{
|
||||
var method = new DynamicMethod("Copy" + typeof(T).Name, null, [typeof(T), typeof(T)], typeof(Sandbox).Module, true);
|
||||
ILGenerator generator = method.GetILGenerator();
|
||||
|
||||
foreach (FieldInfo field in InstanceFields(typeof(T)))
|
||||
{
|
||||
generator.Emit(OpCodes.Ldarg_0);
|
||||
generator.Emit(OpCodes.Ldarg_1);
|
||||
generator.Emit(OpCodes.Ldfld, field);
|
||||
generator.Emit(OpCodes.Stfld, field);
|
||||
}
|
||||
|
||||
generator.Emit(OpCodes.Ret);
|
||||
return (Action<T, T>)method.CreateDelegate(typeof(Action<T, T>));
|
||||
}
|
||||
|
||||
// Field copies whose array fields are never shared with the source.
|
||||
sealed class EntityCopier<T> where T : class
|
||||
{
|
||||
readonly Action<T, T> _copy = BuildFieldCopier<T>();
|
||||
readonly Func<T, Array?>[] _get;
|
||||
readonly Action<T, Array?>[] _set;
|
||||
|
||||
public int ArrayCount => _get.Length;
|
||||
|
||||
public EntityCopier()
|
||||
{
|
||||
FieldInfo[] fields = [.. InstanceFields(typeof(T)).Where(f => f.FieldType.IsArray)];
|
||||
_get = [.. fields.Select(Getter)];
|
||||
_set = [.. fields.Select(Setter)];
|
||||
}
|
||||
|
||||
static Func<T, Array?> Getter(FieldInfo field)
|
||||
{
|
||||
var method = new DynamicMethod("Get" + field.Name, typeof(Array), [typeof(T)], typeof(Sandbox).Module, true);
|
||||
ILGenerator generator = method.GetILGenerator();
|
||||
generator.Emit(OpCodes.Ldarg_0);
|
||||
generator.Emit(OpCodes.Ldfld, field);
|
||||
generator.Emit(OpCodes.Ret);
|
||||
return (Func<T, Array?>)method.CreateDelegate(typeof(Func<T, Array?>));
|
||||
}
|
||||
|
||||
static Action<T, Array?> Setter(FieldInfo field)
|
||||
{
|
||||
var method = new DynamicMethod("Set" + field.Name, null, [typeof(T), typeof(Array)], typeof(Sandbox).Module, true);
|
||||
ILGenerator generator = method.GetILGenerator();
|
||||
generator.Emit(OpCodes.Ldarg_0);
|
||||
generator.Emit(OpCodes.Ldarg_1);
|
||||
generator.Emit(OpCodes.Castclass, field.FieldType);
|
||||
generator.Emit(OpCodes.Stfld, field);
|
||||
generator.Emit(OpCodes.Ret);
|
||||
return (Action<T, Array?>)method.CreateDelegate(typeof(Action<T, Array?>));
|
||||
}
|
||||
|
||||
public T Duplicate(T source)
|
||||
{
|
||||
var copy = (T)Clone(source);
|
||||
for (int k = 0; k < _get.Length; k++)
|
||||
if (_get[k](source) is Array array)
|
||||
_set[k](copy, (Array)array.Clone());
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
// Owned holds the target's arrays between calls.
|
||||
public void Copy(T target, T source, Array?[] owned)
|
||||
{
|
||||
_copy(target, source);
|
||||
for (int k = 0; k < _get.Length; k++)
|
||||
{
|
||||
if (_get[k](source) is not Array array)
|
||||
continue;
|
||||
|
||||
Array? mine = owned[k];
|
||||
if (mine is null || mine.Length != array.Length || mine.GetType() != array.GetType())
|
||||
mine = owned[k] = (Array)array.Clone();
|
||||
else
|
||||
Array.Copy(array, mine, array.Length);
|
||||
_set[k](target, mine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spear and shortsword AI derives position from the owner's item animation.
|
||||
public static bool Stabs(Projectile projectile) => projectile.aiStyle is 19 or 161;
|
||||
|
||||
public static bool Held(Projectile projectile) => Stabs(projectile) || HeldStyles.Contains(projectile.aiStyle) || (projectile.owner is >= 0 and < Main.maxPlayers && Main.player[projectile.owner].heldProj == projectile.whoAmI);
|
||||
|
||||
// Stab simulations advance the owner's item animation.
|
||||
public static bool CanFly(Projectile projectile)
|
||||
{
|
||||
if (projectile.minion || projectile.sentry || projectile.bobber || FailedTypes.Contains(projectile.type))
|
||||
return false;
|
||||
|
||||
return Stabs(projectile) || !Held(projectile);
|
||||
}
|
||||
|
||||
#if TMODLOADER
|
||||
public static bool CanDryRun(Item item) => item.shoot > 0 && item.damage > 0 && !item.CountsAsClass(DamageClass.Summon) && !item.sentry && !item.channel && !FailedWeapons.Contains(item.type);
|
||||
#else
|
||||
public static bool CanDryRun(Item item) => item.shoot > 0 && item.damage > 0 && !item.summon && !item.sentry && !item.channel && !FailedWeapons.Contains(item.type);
|
||||
#endif
|
||||
|
||||
static void SavePlayer(Player player)
|
||||
{
|
||||
_savedPlayer ??= (Player)Clone(player);
|
||||
CopyPlayer(_savedPlayer, player);
|
||||
}
|
||||
|
||||
static void RestorePlayer(Player player) => CopyPlayer(player, _savedPlayer!);
|
||||
|
||||
// Scratch projectiles receive spawns; Main.rand uses a separate generator.
|
||||
static void Enter()
|
||||
{
|
||||
if (_projectiles.Length != Main.projectile.Length)
|
||||
{
|
||||
_projectiles = new Projectile[Main.projectile.Length];
|
||||
for (int i = 0; i < _projectiles.Length; i++)
|
||||
_projectiles[i] = new Projectile();
|
||||
}
|
||||
|
||||
(_realProjectiles, _realRandom) = (Main.projectile, Main.rand);
|
||||
(Main.projectile, Main.rand) = (_projectiles, Random);
|
||||
Active = true;
|
||||
}
|
||||
|
||||
static void Exit()
|
||||
{
|
||||
Active = false;
|
||||
(Main.projectile, Main.rand) = (_realProjectiles, _realRandom);
|
||||
|
||||
foreach (Projectile projectile in _projectiles)
|
||||
if (projectile.active)
|
||||
projectile.active = false;
|
||||
}
|
||||
|
||||
public static int Fly(Shot shot, int ticks, Vector2[] path, Func<int, bool>? stop = null) => Fly(shot.Projectile, shot.Slot, ticks, path, shot.Animation, stop);
|
||||
|
||||
// Both stab styles count their updates in ai[0]; the game decrements itemAnimation before each projectile update.
|
||||
static void Animate(Player player, Projectile stab, int animation)
|
||||
{
|
||||
player.itemAnimationMax = animation;
|
||||
player.itemAnimation = Math.Max(0, animation - (int)stab.ai[0]);
|
||||
}
|
||||
|
||||
// Returns -1 when simulation fails or is already active; stop gets each new path index and ends the flight early.
|
||||
public static int Fly(Projectile copy, int slot, int ticks, Vector2[] path, int animation = 0, Func<int, bool>? stop = null)
|
||||
{
|
||||
if (Active)
|
||||
return -1;
|
||||
|
||||
Player? owner = animation > 0 && Stabs(copy) && copy.owner is >= 0 and < Main.maxPlayers ? Main.player[copy.owner] : null;
|
||||
if (owner is not null)
|
||||
SavePlayer(owner);
|
||||
Enter();
|
||||
Projectile parked = _projectiles[slot];
|
||||
_projectiles[slot] = copy;
|
||||
|
||||
try
|
||||
{
|
||||
int count = 0;
|
||||
path[count++] = copy.Center;
|
||||
|
||||
for (int t = 0; t < ticks && count < path.Length; t++)
|
||||
{
|
||||
if (owner is not null)
|
||||
Animate(owner, copy, animation);
|
||||
copy.Update(slot);
|
||||
if (!copy.active)
|
||||
break;
|
||||
path[count++] = copy.Center;
|
||||
if (stop is not null && stop(count - 1))
|
||||
break;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
FailedTypes.Add(copy.type);
|
||||
Log.Error($"Sandbox fly {copy.type}", e);
|
||||
return -1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_projectiles[slot] = parked;
|
||||
Exit();
|
||||
if (owner is not null)
|
||||
RestorePlayer(owner);
|
||||
}
|
||||
}
|
||||
|
||||
#if TMODLOADER
|
||||
// A ModNPC's Entity is the live NPC, so its AI would move the real one.
|
||||
public static bool CanMove(NPC npc) => npc.active && npc.ModNPC is null && !FailedNpcTypes.Contains(npc.type);
|
||||
#else
|
||||
public static bool CanMove(NPC npc) => npc.active && !FailedNpcTypes.Contains(npc.type);
|
||||
#endif
|
||||
|
||||
// Scratch NPCs get their own copies of the array fields so AI writes stay out of the live NPCs.
|
||||
static void CopyNpc(int slot)
|
||||
{
|
||||
NPC source = _realNpcs[slot], scratch = _npcs[slot];
|
||||
if (source.active)
|
||||
{
|
||||
NpcCopier.Copy(scratch, source, _npcArrays[slot]);
|
||||
return;
|
||||
}
|
||||
|
||||
scratch.active = false;
|
||||
scratch.whoAmI = slot;
|
||||
}
|
||||
|
||||
// All NPCs are copied once per tick; the simulated slot is refreshed before and after each run.
|
||||
static void EnterNpcs(int slot)
|
||||
{
|
||||
_realNpcs = Main.npc;
|
||||
if (_npcs.Length != _realNpcs.Length)
|
||||
{
|
||||
_npcs = new NPC[_realNpcs.Length];
|
||||
_npcArrays = new Array?[_realNpcs.Length][];
|
||||
for (int i = 0; i < _npcs.Length; i++)
|
||||
(_npcs[i], _npcArrays[i]) = (new NPC(), new Array?[NpcCopier.ArrayCount]);
|
||||
_npcSnapshotTick = -1;
|
||||
}
|
||||
|
||||
if (_npcSnapshotTick != Clock.Tick)
|
||||
{
|
||||
_npcSnapshotTick = Clock.Tick;
|
||||
for (int i = 0; i < _npcs.Length; i++)
|
||||
CopyNpc(i);
|
||||
}
|
||||
else
|
||||
{
|
||||
CopyNpc(slot);
|
||||
}
|
||||
|
||||
Main.npc = _npcs;
|
||||
}
|
||||
|
||||
// Movement part of NPC.UpdateNPC; buffs, damage, despawn and network code are left out.
|
||||
static void Step(NPC npc)
|
||||
{
|
||||
#if TMODLOADER
|
||||
UpdateGravity(npc);
|
||||
RunAI(npc);
|
||||
|
||||
if (!npc.noGravity)
|
||||
npc.velocity.Y = Math.Min(npc.velocity.Y + npc.gravity, npc.maxFallSpeed);
|
||||
#else
|
||||
UpdateGravity(npc, out float maxFallSpeed);
|
||||
RunAI(npc);
|
||||
|
||||
if (!npc.noGravity)
|
||||
npc.velocity.Y = Math.Min(npc.velocity.Y + (float)Gravity.GetValue(null), maxFallSpeed);
|
||||
#endif
|
||||
for (int i = 0; i < npc.immune.Length; i++)
|
||||
if (npc.immune[i] > 0)
|
||||
npc.immune[i]--;
|
||||
if (Math.Abs(npc.velocity.X) < 0.005f)
|
||||
npc.velocity.X = 0f;
|
||||
|
||||
if (npc.noTileCollide)
|
||||
{
|
||||
npc.oldPosition = npc.position;
|
||||
npc.position += npc.velocity;
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateCollision(npc);
|
||||
}
|
||||
|
||||
npc.justHit = false;
|
||||
}
|
||||
|
||||
// Records npc.position and npc.velocity per tick; afterTick runs where the game updates projectiles, with the tick number.
|
||||
public static int Move(NPC npc, int ticks, Vector2[] path, Vector2[] velocities, Action<NPC, int>? afterTick = null)
|
||||
{
|
||||
if (Active || !CanMove(npc))
|
||||
return -1;
|
||||
|
||||
int slot = npc.whoAmI;
|
||||
#if !TMODLOADER
|
||||
object gravity = Gravity.GetValue(null);
|
||||
#endif
|
||||
EnterNpcs(slot);
|
||||
Enter();
|
||||
|
||||
try
|
||||
{
|
||||
NPC copy = _npcs[slot];
|
||||
int count = 0;
|
||||
(path[count], velocities[count]) = (copy.position, copy.velocity);
|
||||
count++;
|
||||
|
||||
for (int t = 0; t < ticks && count < path.Length; t++)
|
||||
{
|
||||
Step(copy);
|
||||
if (!copy.active)
|
||||
break;
|
||||
(path[count], velocities[count]) = (copy.position, copy.velocity);
|
||||
count++;
|
||||
afterTick?.Invoke(copy, t + 1);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
FailedNpcTypes.Add(npc.type);
|
||||
Log.Error($"Sandbox move {npc.type}", e);
|
||||
return -1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Exit();
|
||||
Main.npc = _realNpcs;
|
||||
CopyNpc(slot);
|
||||
#if !TMODLOADER
|
||||
Gravity.SetValue(null, gravity);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public static List<Shot>? DryRun(Player player, Item item, Vector2 aimWorld)
|
||||
{
|
||||
if (Active || !CanDryRun(item))
|
||||
return null;
|
||||
|
||||
SavePlayer(player);
|
||||
(int X, int Y) mouse = GameMouse.Save();
|
||||
List<Shot>? shots = [];
|
||||
|
||||
Enter();
|
||||
DryRunning = true;
|
||||
|
||||
try
|
||||
{
|
||||
GameMouse.PointAt(aimWorld);
|
||||
ApplyItemAnimation(player, item);
|
||||
int animation = player.itemAnimationMax;
|
||||
#if TMODLOADER
|
||||
Shoot(player, player.whoAmI, item, player.GetWeaponDamage(item));
|
||||
#else
|
||||
Shoot(player, player.whoAmI, item, player.GetWeaponDamage(item), false);
|
||||
#endif
|
||||
|
||||
var seen = new bool[_projectiles.Length];
|
||||
var stabs = new List<Projectile>();
|
||||
for (int i = 0; i < _projectiles.Length; i++)
|
||||
{
|
||||
Projectile projectile = _projectiles[i];
|
||||
if (!projectile.active || !CanFly(projectile))
|
||||
continue;
|
||||
|
||||
seen[i] = true;
|
||||
bool stab = Stabs(projectile);
|
||||
shots.Add(new Shot(ProjectileCopier.Duplicate(projectile), i, stab ? animation : 0));
|
||||
if (stab)
|
||||
stabs.Add(projectile);
|
||||
}
|
||||
|
||||
// Stabs like the Storm Spear launch their shot from their own update.
|
||||
for (int t = 0; t < Math.Min(60, Math.Max(16, animation)) && stabs.Any(x => x.active); t++)
|
||||
{
|
||||
foreach (Projectile stab in stabs.Where(x => x.active))
|
||||
{
|
||||
Animate(player, stab, animation);
|
||||
stab.Update(stab.whoAmI);
|
||||
}
|
||||
|
||||
for (int i = 0; i < _projectiles.Length; i++)
|
||||
if (!seen[i] && _projectiles[i].active && CanFly(_projectiles[i]) && !Stabs(_projectiles[i]))
|
||||
{
|
||||
seen[i] = true;
|
||||
shots.Add(new Shot(ProjectileCopier.Duplicate(_projectiles[i]), i, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
FailedWeapons.Add(item.type);
|
||||
Log.Error($"Sandbox dry run {item.type}", e);
|
||||
shots = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DryRunning = false;
|
||||
Exit();
|
||||
RestorePlayer(player);
|
||||
GameMouse.Restore(mouse);
|
||||
}
|
||||
|
||||
return shots is { Count: > 0 } ? shots : null;
|
||||
}
|
||||
|
||||
// Held keys stay held; vertical movement is velocity, gravity and tiles only.
|
||||
static void PredictPlayer(Player player, Vector2[] path, int length)
|
||||
{
|
||||
player.itemAnimation = 1;
|
||||
path[0] = player.position;
|
||||
for (int t = 1; t < length; t++)
|
||||
{
|
||||
player.HorizontalMovement();
|
||||
if (!player.controlJump && player.grappling[0] < 0 && !player.mount.Active)
|
||||
player.velocity.Y = player.gravDir > 0f ? Math.Min(player.velocity.Y + player.gravity, player.maxFallSpeed) : Math.Max(player.velocity.Y - player.gravity, -player.maxFallSpeed);
|
||||
player.velocity = Collision.TileCollision(player.position, player.velocity, player.width, player.height, player.controlDown, false, (int)player.gravDir);
|
||||
player.position += player.velocity;
|
||||
path[t] = player.position;
|
||||
}
|
||||
}
|
||||
|
||||
// Fills playerPath, then the melee hitbox of a swing started now per tick, facing the player's way and the other way; Rectangle.Empty where the item can't hit.
|
||||
public static int Swing(Player player, Item item, bool predictMovement, Vector2[] playerPath, int pathLength, Rectangle[] forward, Rectangle[]? backward)
|
||||
{
|
||||
if (Active || FailedWeapons.Contains(item.type))
|
||||
return -1;
|
||||
|
||||
SavePlayer(player);
|
||||
int direction = player.direction, count = 0;
|
||||
Enter();
|
||||
|
||||
try
|
||||
{
|
||||
if (predictMovement)
|
||||
PredictPlayer(player, playerPath, pathLength);
|
||||
else
|
||||
for (int t = 0; t < pathLength; t++)
|
||||
playerPath[t] = player.position;
|
||||
|
||||
for (int pass = 0; pass < 2; pass++)
|
||||
{
|
||||
Rectangle[]? boxes = pass == 0 ? forward : backward;
|
||||
int facing = pass == 0 ? direction : -direction;
|
||||
if (boxes is null)
|
||||
continue;
|
||||
|
||||
RestorePlayer(player);
|
||||
player.direction = facing;
|
||||
player.lastVisualizedSelectedItem = item;
|
||||
ApplyItemAnimation(player, item);
|
||||
#if TMODLOADER
|
||||
int animation = player.itemAnimationMax;
|
||||
#else
|
||||
// Vanilla decrements itemAnimation in the tick the use starts.
|
||||
int animation = player.itemAnimationMax - 1;
|
||||
#endif
|
||||
for (count = 0; count < Math.Min(animation, Math.Min(pathLength, boxes.Length)); count++)
|
||||
{
|
||||
player.itemAnimation = animation - count;
|
||||
player.position = playerPath[count];
|
||||
Rectangle frame = Item.GetDrawHitbox(item.type, player);
|
||||
player.ItemCheck_ApplyUseStyle(player.HeightOffsetHitboxCenter, item, frame);
|
||||
GetMeleeHitbox(player, item, frame, out bool dontAttack, out Rectangle box);
|
||||
boxes[count] = dontAttack ? Rectangle.Empty : box;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
FailedWeapons.Add(item.type);
|
||||
Log.Error($"Sandbox swing {item.type}", e);
|
||||
count = -1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Exit();
|
||||
RestorePlayer(player);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
static readonly Vector2[] ProfilePoints = new Vector2[601];
|
||||
|
||||
public static bool Profile(int type, FlightModel model)
|
||||
{
|
||||
if (FailedTypes.Contains(type) || Main.gameMenu || Active)
|
||||
return false;
|
||||
|
||||
var projectile = new Projectile();
|
||||
projectile.SetDefaults(type);
|
||||
if (!CanFly(projectile) || Stabs(projectile))
|
||||
return false;
|
||||
|
||||
const float LaunchSpeed = 8f;
|
||||
const int MaxTicks = 600;
|
||||
int slot = Main.maxProjectiles - 1;
|
||||
projectile.owner = Main.myPlayer;
|
||||
projectile.whoAmI = slot;
|
||||
projectile.tileCollide = false;
|
||||
projectile.damage = 0;
|
||||
projectile.Center = Main.LocalPlayer.Center;
|
||||
projectile.velocity = new Vector2(LaunchSpeed, 0f);
|
||||
projectile.active = true;
|
||||
|
||||
int count = Fly(projectile, slot, MaxTicks, ProfilePoints);
|
||||
if (count < 9)
|
||||
{
|
||||
if (count >= 0)
|
||||
FailedTypes.Add(type);
|
||||
return false;
|
||||
}
|
||||
|
||||
var velocities = new List<Vector2>(count);
|
||||
for (int i = 1; i < count; i++)
|
||||
velocities.Add(ProfilePoints[i] - ProfilePoints[i - 1]);
|
||||
|
||||
int life = count - 1 >= MaxTicks ? 0 : count - 1;
|
||||
if (!Ballistics.Apply(model, velocities, life, LaunchSpeed))
|
||||
return false;
|
||||
|
||||
model.Sandboxed = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
sealed class CachedPath
|
||||
{
|
||||
public int Type, At, Count;
|
||||
public readonly Vector2[] Points = new Vector2[181];
|
||||
public readonly Projectile Scratch = new();
|
||||
public readonly Array?[] Arrays = new Array?[ProjectileCopier.ArrayCount];
|
||||
}
|
||||
|
||||
static readonly Dictionary<int, CachedPath> Live = [];
|
||||
static int _budget;
|
||||
|
||||
public static void NewTick() => _budget = 4;
|
||||
|
||||
// Cached paths may be up to 10 ticks old.
|
||||
public static int LivePath(Projectile projectile, int slot, int tick, out Vector2[] path, out int from)
|
||||
{
|
||||
path = [];
|
||||
from = 0;
|
||||
if (!CanFly(projectile))
|
||||
return -1;
|
||||
|
||||
Live.TryGetValue(slot, out CachedPath? cached);
|
||||
bool stale = cached is null || cached.Type != projectile.type || tick - cached.At > 10 || tick < cached.At;
|
||||
if (stale && _budget-- > 0)
|
||||
{
|
||||
cached ??= Live[slot] = new CachedPath();
|
||||
ProjectileCopier.Copy(cached.Scratch, projectile, cached.Arrays);
|
||||
int count = Fly(cached.Scratch, slot, Math.Min(180, Math.Max(1, projectile.timeLeft)), cached.Points);
|
||||
if (count < 0)
|
||||
return -1;
|
||||
|
||||
(cached.Type, cached.At, cached.Count) = (projectile.type, tick, count);
|
||||
}
|
||||
else if (stale && (cached is null || cached.Type != projectile.type))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
path = cached!.Points;
|
||||
from = Math.Min(Math.Max(0, tick - cached.At), Math.Max(0, cached.Count - 1));
|
||||
return cached.Count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Terraria;
|
||||
|
||||
namespace TerraSharp.Simulation;
|
||||
|
||||
// Collision.CanHitLine also checks tiles above and below the line.
|
||||
public static class TileCollision
|
||||
{
|
||||
static bool IsSolid(int x, int y, out int top)
|
||||
{
|
||||
top = y * 16;
|
||||
if (x < 0 || y < 0 || x >= Main.maxTilesX || y >= Main.maxTilesY)
|
||||
return false;
|
||||
Tile tile = Main.tile[x, y];
|
||||
#if TMODLOADER
|
||||
if (!tile.HasTile || tile.IsActuated || !Main.tileSolid[tile.TileType] || Main.tileSolidTop[tile.TileType])
|
||||
return false;
|
||||
if (tile.IsHalfBlock)
|
||||
top += 8;
|
||||
#else
|
||||
if (tile is null || !tile.active() || tile.inActive() || !Main.tileSolid[tile.type] || Main.tileSolidTop[tile.type])
|
||||
return false;
|
||||
if (tile.halfBrick())
|
||||
top += 8;
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool BoxSolid(Vector2 center, int width, int height)
|
||||
{
|
||||
float left = center.X - width / 2f, right = center.X + width / 2f, top = center.Y - height / 2f, bottom = center.Y + height / 2f;
|
||||
for (int x = (int)(left / 16f); x <= (int)((right - 0.01f) / 16f); x++)
|
||||
for (int y = (int)(top / 16f); y <= (int)((bottom - 0.01f) / 16f); y++)
|
||||
if (IsSolid(x, y, out int tileTop) && bottom > tileTop)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sweeps the box from 'from' to 'to' in steps of at most 4 px.
|
||||
public static bool Sweep(Vector2 from, Vector2 to, int width, int height)
|
||||
{
|
||||
int steps = Math.Max(1, (int)Math.Ceiling(Vector2.Distance(from, to) / 4f));
|
||||
for (int i = 1; i <= steps; i++)
|
||||
if (BoxSolid(Vector2.Lerp(from, to, i / (float)steps), width, height))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
|
||||
// DOTNET_STARTUP_HOOKS runs before tModLoader's entry point.
|
||||
public static class StartupHook
|
||||
{
|
||||
public static void Initialize()
|
||||
{
|
||||
// tModLoader relaunches itself with -terrariasteamclient, and child processes inherit the environment.
|
||||
if (Array.Exists(Environment.GetCommandLineArgs(), a => a is "-server" or "-terrariasteamclient" or "-tModPorter" or "-build"))
|
||||
return;
|
||||
TerraSharp.Autoload.Start();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<RootNamespace>TerraSharp</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(TerraSharpLogging)' == 'true'">
|
||||
<DefineConstants>$(DefineConstants);TERRASHARP_LOGGING</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(TargetFramework)' == 'net48'">
|
||||
<AssemblyName>TerraSharp.Vanilla</AssemblyName>
|
||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||
<AssemblyName>TerraSharp.tModLoader</AssemblyName>
|
||||
<DefineConstants>$(DefineConstants);TMODLOADER</DefineConstants>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<GenerateDependencyFile>false</GenerateDependencyFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MonoMod.RuntimeDetour" Version="25.3.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="EmbedRuntimeDependencies" AfterTargets="ResolveReferences" BeforeTargets="PrepareResources" Condition="'$(TargetFramework)' == 'net48'">
|
||||
<ItemGroup>
|
||||
<_EmbeddedDependency Include="@(ReferenceCopyLocalPaths)" Condition="'%(ReferenceCopyLocalPaths.NuGetPackageId)' != '' and '%(ReferenceCopyLocalPaths.Extension)' == '.dll'" />
|
||||
<EmbeddedResource Include="@(_EmbeddedDependency)" LogicalName="Dependencies.%(Filename)%(Extension)" Visible="false" />
|
||||
<ReferenceCopyLocalPaths Remove="@(_EmbeddedDependency)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
|
||||
<Compile Remove="StartupHook.cs" />
|
||||
<Reference Include="Terraria" HintPath="$(TerrariaDir)\Terraria.exe" Private="false" />
|
||||
<Reference Include="Microsoft.Xna.Framework" HintPath="$(XnaGac)\Microsoft.Xna.Framework\v4.0_4.0.0.0__842cf8be1de50553\Microsoft.Xna.Framework.dll" Private="false" />
|
||||
<Reference Include="Microsoft.Xna.Framework.Game" HintPath="$(XnaGac)\Microsoft.Xna.Framework.Game\v4.0_4.0.0.0__842cf8be1de50553\Microsoft.Xna.Framework.Game.dll" Private="false" />
|
||||
<Reference Include="Microsoft.Xna.Framework.Graphics" HintPath="$(XnaGac)\Microsoft.Xna.Framework.Graphics\v4.0_4.0.0.0__842cf8be1de50553\Microsoft.Xna.Framework.Graphics.dll" Private="false" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||
<Compile Remove="Autoload\DomainManager.cs" />
|
||||
<Reference Include="tModLoader" HintPath="$(TModLoaderDir)\tModLoader.dll" Private="false" />
|
||||
<Reference Include="FNA" HintPath="$(TModLoaderDir)\Libraries\FNA\1.0.0\FNA.dll" Private="false" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Terraria;
|
||||
using Terraria.ID;
|
||||
#if TMODLOADER
|
||||
using Terraria.ModLoader;
|
||||
#endif
|
||||
using TerraSharp.Core;
|
||||
using TerraSharp.Features;
|
||||
|
||||
namespace TerraSharp.UI;
|
||||
|
||||
public sealed class BuffBrowser() : GridBrowser("buffs", "Buff Browser", new Vector2(660, 160), 480, 540, "Search buffs by name or ID", "buffs", new IdIndex(() => BuffCount, Lang.GetBuffName), Categories)
|
||||
{
|
||||
const string DurationSlider = "buffDuration";
|
||||
|
||||
#if TMODLOADER
|
||||
static int BuffCount => BuffLoader.BuffCount;
|
||||
#else
|
||||
static int BuffCount => BuffID.Count;
|
||||
#endif
|
||||
|
||||
#if TMODLOADER
|
||||
static HashSet<int>? _mountBuffs;
|
||||
#endif
|
||||
|
||||
static readonly BrowserCategory[] Categories = [new("All", _ => true), new("Buffs", type => !Main.debuff[type] && !IsPet(type) && !IsMount(type)), new("Debuffs", type => Main.debuff[type]), new("Pets", IsPet), new("Mounts", IsMount)];
|
||||
|
||||
static bool IsPet(int type) => Main.vanityPet[type] || Main.lightPet[type];
|
||||
|
||||
#if TMODLOADER
|
||||
static bool IsMount(int type)
|
||||
{
|
||||
if (_mountBuffs is null)
|
||||
{
|
||||
_mountBuffs = [];
|
||||
foreach (Mount.MountData? mount in Mount.mounts)
|
||||
if (mount is not null)
|
||||
_mountBuffs.UnionWith([mount.buff, mount.extraBuff]);
|
||||
}
|
||||
return _mountBuffs.Contains(type);
|
||||
}
|
||||
#else
|
||||
static bool IsMount(int type) => BuffID.Sets.MountType[type] >= 0;
|
||||
#endif
|
||||
|
||||
static string FormatTime(int ticks)
|
||||
{
|
||||
int seconds = ticks / 60;
|
||||
return seconds >= 3600 ? $"{seconds / 3600}h" : seconds >= 60 ? $"{seconds / 60}m" : $"{seconds}s";
|
||||
}
|
||||
|
||||
protected override void DrawContent(Rectangle body)
|
||||
{
|
||||
int activeRow = body.Y + 28;
|
||||
Widgets.Text("Active", new Vector2(body.X + 2, body.Y + 2), Widgets.Header, 0.85f);
|
||||
DrawActive(body, activeRow);
|
||||
DrawSearchGrid(body, activeRow + 44, body.Bottom - 40);
|
||||
|
||||
var duration = new Rectangle(body.X + 2, body.Bottom - 34, body.Width - 4, 30);
|
||||
bool hot = Widgets.Hot(duration) || Widgets.ActiveSlider == DurationSlider;
|
||||
Render.TextMid(Widgets.SpriteBatch, $"Duration: {(int)Settings.BuffMinutes} min", duration, hot ? Widgets.Yellow : Color.White, 0.85f);
|
||||
Settings.BuffMinutes = (float)Math.Round(Widgets.ValueBar(DurationSlider, duration, Settings.BuffMinutes, 1f, 60f, 10f));
|
||||
}
|
||||
|
||||
void DrawActive(Rectangle body, int y)
|
||||
{
|
||||
Player player = Main.LocalPlayer;
|
||||
int x = body.X;
|
||||
bool any = false;
|
||||
for (int i = 0; i < player.buffType.Length; i++)
|
||||
{
|
||||
int type = player.buffType[i];
|
||||
if (type <= 0 || player.buffTime[i] <= 0)
|
||||
continue;
|
||||
|
||||
any = true;
|
||||
if (x + 36 > body.Right)
|
||||
break;
|
||||
|
||||
var bounds = new Rectangle(x, y, 36, 36);
|
||||
bool hot = Widgets.Hot(bounds);
|
||||
Widgets.Slot(bounds, hot);
|
||||
Widgets.Icon(GameTextures.Get("Buff", type), new Rectangle(bounds.X + 4, bounds.Y + 4, 28, 28));
|
||||
if (!Main.buffNoTimeDisplay[type])
|
||||
Render.TextCentered(Widgets.SpriteBatch, FormatTime(player.buffTime[i]), new Rectangle(bounds.X, bounds.Bottom - 12, bounds.Width, 14), Color.White, 0.6f);
|
||||
|
||||
if (hot)
|
||||
{
|
||||
Widgets.Tooltip = $"{Index.Name(type)}\n{Lang.GetBuffDescription(type)}\nClick to remove";
|
||||
if (Widgets.Click)
|
||||
{
|
||||
player.DelBuff(i);
|
||||
Widgets.Sound(12);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
x += 40;
|
||||
}
|
||||
|
||||
if (!any)
|
||||
Render.TextMid(Widgets.SpriteBatch, "No active buffs", new Rectangle(body.X + 2, y, body.Width, 36), Widgets.Dim * 0.8f, 0.8f);
|
||||
}
|
||||
|
||||
protected override void DrawSlot(int type, Rectangle bounds, bool hot)
|
||||
{
|
||||
Widgets.Slot(bounds, hot, Array.IndexOf(Main.LocalPlayer.buffType, type) >= 0);
|
||||
Widgets.Icon(GameTextures.Get("Buff", type), new Rectangle(bounds.X + 6, bounds.Y + 6, bounds.Width - 12, bounds.Height - 12));
|
||||
if (!hot)
|
||||
return;
|
||||
|
||||
string kind = Main.debuff[type] ? "Debuff" : "Buff";
|
||||
Widgets.Tooltip = $"{Index.Name(type)}\n{Lang.GetBuffDescription(type)}\n{kind} #{type} Click to apply";
|
||||
if (!Widgets.Click)
|
||||
return;
|
||||
|
||||
PlayerFeatures.AddBuff(type);
|
||||
Widgets.Sound(12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Terraria;
|
||||
using Terraria.GameContent;
|
||||
using TerraSharp.Core;
|
||||
|
||||
namespace TerraSharp.UI;
|
||||
|
||||
// Cache ReLogic Assets through reflection; resource packs can dispose their textures.
|
||||
public static class GameTextures
|
||||
{
|
||||
static readonly Dictionary<Type, PropertyInfo> ValueProperties = [];
|
||||
static readonly Dictionary<string, FieldInfo?> Fields = [];
|
||||
static readonly Dictionary<string, object?> Requested = [];
|
||||
static MethodInfo? _request;
|
||||
static object? _repository, _immediateLoad;
|
||||
|
||||
static Texture2D? Value(object? asset)
|
||||
{
|
||||
if (asset is null)
|
||||
return null;
|
||||
|
||||
Type type = asset.GetType();
|
||||
if (!ValueProperties.TryGetValue(type, out PropertyInfo? property))
|
||||
ValueProperties[type] = property = type.GetProperty("Value")!;
|
||||
return property.GetValue(asset, null) is Texture2D { IsDisposed: false } texture ? texture : null;
|
||||
}
|
||||
|
||||
static FieldInfo? Field(string name)
|
||||
{
|
||||
if (!Fields.TryGetValue(name, out FieldInfo? field))
|
||||
Fields[name] = field = typeof(TextureAssets).GetField(name, BindingFlags.Public | BindingFlags.Static);
|
||||
return field;
|
||||
}
|
||||
|
||||
// TextureAssets.<name>
|
||||
public static Texture2D? Get(string name) => Value(Field(name)?.GetValue(null));
|
||||
|
||||
// TextureAssets.<name>[index], e.g. Buff
|
||||
public static Texture2D? Get(string name, int index) =>
|
||||
Field(name)?.GetValue(null) is Array array && index >= 0 && index < array.Length ? Value(array.GetValue(index)) : null;
|
||||
|
||||
// ReLogic AssetRepository.Request requires reflection.
|
||||
public static Texture2D? Request(string path)
|
||||
{
|
||||
if (!Requested.TryGetValue(path, out object? asset))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_request is null)
|
||||
{
|
||||
FieldInfo assets = typeof(Main).GetField("Assets", BindingFlags.Public | BindingFlags.Static)!;
|
||||
_repository = assets.GetValue(null);
|
||||
MethodInfo request = assets.FieldType.GetMethods().First(m => m.Name == "Request" && m.IsGenericMethodDefinition && m.GetParameters().Length == 2);
|
||||
_request = request.MakeGenericMethod(typeof(Texture2D));
|
||||
_immediateLoad = Enum.Parse(request.GetParameters()[1].ParameterType, "ImmediateLoad");
|
||||
}
|
||||
|
||||
asset = _request.Invoke(_repository, [path, _immediateLoad]);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error($"Texture request {path}", e);
|
||||
asset = null;
|
||||
}
|
||||
|
||||
Requested[path] = asset;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Value(asset);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static Rectangle Frame(Texture2D texture, int horizontalFrames, int verticalFrames, int x, int y)
|
||||
{
|
||||
int width = texture.Width / horizontalFrames, height = texture.Height / verticalFrames;
|
||||
return new Rectangle(width * x, height * y, width, height);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Terraria;
|
||||
|
||||
namespace TerraSharp.UI;
|
||||
|
||||
// Icon is a frame of Images/UI/Creative/Infinite_Icons; -1 draws the name instead.
|
||||
public sealed record BrowserCategory(string Name, Func<int, bool> Fits, int Icon = -1, BrowserCategory[]? Subcategories = null);
|
||||
|
||||
public abstract class GridBrowser(string id, string title, Vector2 position, int width, int height, string placeholder, string noun, IdIndex index, BrowserCategory[] categories)
|
||||
: Window(id, title, position, width, height)
|
||||
{
|
||||
const int SlotSize = 44, Gap = 4, Step = SlotSize + Gap, TabHeight = 32, SubTabHeight = 24, IconTabWidth = 38;
|
||||
|
||||
readonly TextField _search = new(placeholder);
|
||||
readonly Scroll _scroll = new();
|
||||
string _lastQuery = "";
|
||||
int _category, _subcategory;
|
||||
readonly BrowserCategory[]?[] _subTabs = categories.Select(category => category.Subcategories?.Prepend(new BrowserCategory("All", category.Fits)).ToArray()).ToArray();
|
||||
|
||||
protected IdIndex Index { get; } = index;
|
||||
|
||||
public sealed override void DrawBody(Rectangle body)
|
||||
{
|
||||
if (Main.gameMenu)
|
||||
{
|
||||
Render.TextCentered(Widgets.SpriteBatch, "Enter a world to use this.", body, Widgets.Dim, 0.9f);
|
||||
return;
|
||||
}
|
||||
DrawContent(body);
|
||||
}
|
||||
|
||||
protected abstract void DrawContent(Rectangle body);
|
||||
|
||||
protected abstract void DrawSlot(int id, Rectangle bounds, bool hot);
|
||||
|
||||
protected void DrawSearchGrid(Rectangle body, int top, int bottom)
|
||||
{
|
||||
var field = new Rectangle(body.X, top, body.Width - 110, 36);
|
||||
Widgets.Field(_search, field);
|
||||
int gridTop = DrawCategories(body, top + 42);
|
||||
Index.Filter(_search.Text, _subTabs[_category] is { } subTabs ? subTabs[_subcategory].Fits : categories[_category].Fits);
|
||||
if (_search.Text != _lastQuery)
|
||||
{
|
||||
_lastQuery = _search.Text;
|
||||
_scroll.Reset();
|
||||
}
|
||||
Render.TextRight(Widgets.SpriteBatch, $"{Index.Ids.Count} {noun}", new Rectangle(field.Right, top, 106, 36), Widgets.Dim, 0.8f);
|
||||
|
||||
var inner = new Rectangle(body.X, gridTop, body.Width, bottom - gridTop);
|
||||
Widgets.Panel(inner, Widgets.InnerBg);
|
||||
DrawGrid(Index.Ids, new Rectangle(inner.X + 10, inner.Y + 10, inner.Width - 34, inner.Height - 20));
|
||||
}
|
||||
|
||||
int DrawCategories(Rectangle body, int y)
|
||||
{
|
||||
if (DrawTabs(body, categories, ref _category, y, TabHeight, 0.78f))
|
||||
_subcategory = 0;
|
||||
y += TabHeight + 6;
|
||||
if (_subTabs[_category] is not { } subTabs)
|
||||
return y;
|
||||
DrawTabs(body, subTabs, ref _subcategory, y, SubTabHeight, 0.68f);
|
||||
return y + SubTabHeight + 6;
|
||||
}
|
||||
|
||||
bool DrawTabs(Rectangle body, BrowserCategory[] tabs, ref int selected, int y, int height, float scale)
|
||||
{
|
||||
const int TabGap = 4;
|
||||
int iconTabs = tabs.Count(tab => tab.Icon >= 0);
|
||||
int textTabs = tabs.Length - iconTabs;
|
||||
int textWidth = textTabs == 0 ? 0 : (body.Width - TabGap * (tabs.Length - 1) - iconTabs * IconTabWidth) / textTabs;
|
||||
Texture2D? icons = iconTabs > 0 ? GameTextures.Request("Images/UI/Creative/Infinite_Icons") : null;
|
||||
bool changed = false;
|
||||
int x = body.X;
|
||||
for (int i = 0; i < tabs.Length; i++)
|
||||
{
|
||||
BrowserCategory tab = tabs[i];
|
||||
bool iconTab = tab.Icon >= 0;
|
||||
var bounds = new Rectangle(x, y, iconTab ? IconTabWidth : textWidth, height);
|
||||
x += bounds.Width + TabGap;
|
||||
if (Widgets.Button(bounds, iconTab ? "" : tab.Name, i == selected, scale) && i != selected)
|
||||
{
|
||||
selected = i;
|
||||
changed = true;
|
||||
_scroll.Reset();
|
||||
}
|
||||
if (!iconTab)
|
||||
continue;
|
||||
if (icons is not null)
|
||||
{
|
||||
// Journey icon frames include a 2px separator on the right.
|
||||
Rectangle frame = GameTextures.Frame(icons, 11, 1, tab.Icon, 0);
|
||||
frame.Width -= 2;
|
||||
Widgets.Icon(icons, new Rectangle(bounds.X + 4, bounds.Y + 4, bounds.Width - 8, bounds.Height - 8), frame, 1f, i == selected || Widgets.Hot(bounds) ? Color.White : Color.White * 0.7f);
|
||||
}
|
||||
if (Widgets.Hot(bounds))
|
||||
Widgets.Tooltip = tab.Name;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
void DrawGrid(List<int> ids, Rectangle area)
|
||||
{
|
||||
int columns = Math.Max(1, (area.Width + Gap) / Step);
|
||||
int rows = (ids.Count + columns - 1) / columns;
|
||||
int y0 = _scroll.Begin(area);
|
||||
int first = Math.Max(0, (area.Y - y0) / Step - 1);
|
||||
int last = Math.Min(rows - 1, (area.Bottom - y0) / Step + 1);
|
||||
for (int row = first; row <= last; row++)
|
||||
for (int column = 0; column < columns; column++)
|
||||
{
|
||||
int index = row * columns + column;
|
||||
if (index >= ids.Count)
|
||||
break;
|
||||
var bounds = new Rectangle(area.X + column * Step, y0 + row * Step, SlotSize, SlotSize);
|
||||
DrawSlot(ids[index], bounds, Widgets.Hot(bounds));
|
||||
}
|
||||
_scroll.End(area, rows * Step);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TerraSharp.UI;
|
||||
|
||||
public sealed class IdIndex(Func<int> count, Func<int, string?> name)
|
||||
{
|
||||
string[]? _names;
|
||||
string? _lastQuery;
|
||||
Func<int, bool>? _lastFits;
|
||||
|
||||
public List<int> Ids { get; } = [];
|
||||
|
||||
string[] Names
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_names is not null)
|
||||
return _names;
|
||||
_names = new string[count()];
|
||||
for (int i = 1; i < _names.Length; i++)
|
||||
_names[i] = name(i) ?? "";
|
||||
_names[0] = "";
|
||||
return _names;
|
||||
}
|
||||
}
|
||||
|
||||
public string Name(int id) => id > 0 && id < Names.Length ? Names[id] : "";
|
||||
|
||||
public void Filter(string query, Func<int, bool> fits)
|
||||
{
|
||||
query = query.Trim();
|
||||
if (query == _lastQuery && fits == _lastFits)
|
||||
return;
|
||||
(_lastQuery, _lastFits) = (query, fits);
|
||||
string[] names = Names;
|
||||
Ids.Clear();
|
||||
if (query.Length == 0)
|
||||
{
|
||||
for (int i = 1; i < names.Length; i++)
|
||||
if (names[i].Length > 0 && fits(i))
|
||||
Ids.Add(i);
|
||||
return;
|
||||
}
|
||||
if (int.TryParse(query, out int id) && id > 0 && id < names.Length && names[id].Length > 0 && fits(id))
|
||||
Ids.Add(id);
|
||||
foreach (bool prefixPass in (bool[])[true, false])
|
||||
for (int i = 1; i < names.Length; i++)
|
||||
{
|
||||
int at = names[i].IndexOf(query, StringComparison.OrdinalIgnoreCase);
|
||||
if ((prefixPass ? at == 0 : at > 0) && i != id && fits(i))
|
||||
Ids.Add(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Terraria;
|
||||
using Terraria.GameInput;
|
||||
using Terraria.UI;
|
||||
using TerraSharp.Core;
|
||||
using TerraSharp.Visuals;
|
||||
|
||||
namespace TerraSharp.UI;
|
||||
|
||||
public static class InterfaceLayers
|
||||
{
|
||||
const string Prefix = "TerraSharp: ";
|
||||
const int OffScreen = -100000;
|
||||
|
||||
static readonly FieldInfo LayersField = typeof(Main).GetField("_gameInterfaceLayers", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
static readonly FieldInfo OriginalMouseX = typeof(PlayerInput).GetField("_originalMouseX", BindingFlags.Static | BindingFlags.NonPublic)!;
|
||||
static readonly FieldInfo OriginalMouseY = typeof(PlayerInput).GetField("_originalMouseY", BindingFlags.Static | BindingFlags.NonPublic)!;
|
||||
static (int X, int Y) _savedOriginal;
|
||||
static bool _blocked;
|
||||
|
||||
static List<GameInterfaceLayer>? Layers => LayersField.GetValue(Main.instance) as List<GameInterfaceLayer>;
|
||||
|
||||
// Do not modify the layer list while DrawInterface enumerates it.
|
||||
public static void Install()
|
||||
{
|
||||
if (Layers is not { } layers)
|
||||
return;
|
||||
|
||||
layers.RemoveAll(l => l.Name.StartsWith(Prefix, StringComparison.Ordinal));
|
||||
|
||||
layers.Insert(0, new LegacyGameInterfaceLayer(Prefix + "Under UI", DrawUnderUi, InterfaceScaleType.None));
|
||||
|
||||
int healthBarIndex = layers.FindIndex(l => l.Name == "Vanilla: Entity Health Bars");
|
||||
layers.Insert(healthBarIndex < 0 ? 1 : healthBarIndex + 1, new LegacyGameInterfaceLayer(Prefix + "Health Bars", DrawHealthBars, InterfaceScaleType.Game));
|
||||
|
||||
int mouseTextIndex = layers.FindIndex(l => l.Name == "Vanilla: Mouse Text");
|
||||
layers.Insert(mouseTextIndex < 0 ? layers.Count : mouseTextIndex, new LegacyGameInterfaceLayer(Prefix + "Windows", DrawWindows, InterfaceScaleType.UI));
|
||||
}
|
||||
|
||||
public static void Uninstall() => Layers?.RemoveAll(l => l.Name.StartsWith(Prefix, StringComparison.Ordinal));
|
||||
|
||||
static bool DrawUnderUi()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Plugin.Active && !Main.gameMenu)
|
||||
{
|
||||
if (Settings.Esp)
|
||||
Esp.Draw(Main.spriteBatch);
|
||||
AimOverlay.Draw(Main.spriteBatch);
|
||||
}
|
||||
|
||||
Block();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error("Under UI layer", e);
|
||||
Widgets.Recover(Main.spriteBatch, Matrix.Identity);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool DrawHealthBars()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Plugin.Active && !Main.gameMenu && Settings.Esp)
|
||||
Esp.DrawHealthBars();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error("Health bars layer", e);
|
||||
Widgets.Recover(Main.spriteBatch, Main.GameViewMatrix.ZoomMatrix);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool DrawWindows()
|
||||
{
|
||||
Unblock();
|
||||
PlayerInput.SetZoom_UI();
|
||||
|
||||
try
|
||||
{
|
||||
if (Plugin.Active && !Main.gameMenu)
|
||||
WindowManager.Draw(Main.spriteBatch);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error("Windows layer", e);
|
||||
Widgets.Recover(Main.spriteBatch, Main.UIScaleMatrix);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Each layer recomputes Main.mouseX/Y from PlayerInput._originalMouseX/Y.
|
||||
static void Block()
|
||||
{
|
||||
if (!Plugin.Active || !WindowManager.AnyOpen)
|
||||
return;
|
||||
|
||||
int x = (int)OriginalMouseX.GetValue(null)!, y = (int)OriginalMouseY.GetValue(null)!;
|
||||
if (!WindowManager.Contains(new Point((int)(x / Main.UIScale), (int)(y / Main.UIScale))))
|
||||
return;
|
||||
|
||||
_savedOriginal = (x, y);
|
||||
OriginalMouseX.SetValue(null, OffScreen);
|
||||
OriginalMouseY.SetValue(null, OffScreen);
|
||||
_blocked = true;
|
||||
}
|
||||
|
||||
public static void Unblock()
|
||||
{
|
||||
if (!_blocked)
|
||||
return;
|
||||
|
||||
OriginalMouseX.SetValue(null, _savedOriginal.X);
|
||||
OriginalMouseY.SetValue(null, _savedOriginal.Y);
|
||||
_blocked = false;
|
||||
}
|
||||
|
||||
// DrawMenu draws its cursor through Main.DrawCursor.
|
||||
public static bool InMenuDraw { get; set; }
|
||||
|
||||
static (int X, int Y)? _hiddenMouse;
|
||||
|
||||
// Vanilla menus and UIState updates read Main.mouseX/Y directly.
|
||||
public static void HideMouse()
|
||||
{
|
||||
if (_hiddenMouse is not null || !Plugin.Active || !WindowManager.MouseOver)
|
||||
return;
|
||||
|
||||
_hiddenMouse = GameMouse.Save();
|
||||
Main.mouseX = Main.mouseY = OffScreen;
|
||||
}
|
||||
|
||||
public static void ShowMouse()
|
||||
{
|
||||
if (_hiddenMouse is not { } saved)
|
||||
return;
|
||||
|
||||
GameMouse.Restore(saved);
|
||||
_hiddenMouse = null;
|
||||
}
|
||||
|
||||
public static void DrawMenuWindows()
|
||||
{
|
||||
ShowMouse();
|
||||
SpriteBatch spriteBatch = Main.spriteBatch;
|
||||
spriteBatch.End();
|
||||
|
||||
try
|
||||
{
|
||||
spriteBatch.Begin(SpriteSortMode.Deferred, null, null, null, null, null, Main.UIScaleMatrix);
|
||||
WindowManager.Draw(spriteBatch);
|
||||
spriteBatch.End();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error("Menu windows", e);
|
||||
Widgets.Recover(spriteBatch, Main.UIScaleMatrix);
|
||||
spriteBatch.End();
|
||||
}
|
||||
|
||||
spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, Main.SamplerStateForCursor, DepthStencilState.None, RasterizerState.CullCounterClockwise, null, Main.UIScaleMatrix);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Terraria;
|
||||
using Terraria.GameContent.Creative;
|
||||
using Terraria.ID;
|
||||
using Terraria.Localization;
|
||||
using Terraria.UI;
|
||||
#if TMODLOADER
|
||||
using Terraria.ModLoader;
|
||||
#endif
|
||||
|
||||
namespace TerraSharp.UI;
|
||||
|
||||
public sealed class ItemBrowser() : GridBrowser("items", "Item Browser", new Vector2(660, 120), 532, 520, "Search items by name or ID", "items", new IdIndex(() => ItemCount, Lang.GetItemNameValue), Categories())
|
||||
{
|
||||
const int InventorySlots = 50;
|
||||
|
||||
#if TMODLOADER
|
||||
static int ItemCount => ItemLoader.ItemCount;
|
||||
#else
|
||||
static int ItemCount => ItemID.Count;
|
||||
#endif
|
||||
|
||||
enum WeaponClass { Melee, Ranged, Magic, Summon, Other }
|
||||
|
||||
static readonly IItemEntryFilter Weapon = new ItemFilters.Weapon();
|
||||
|
||||
// Journey duplication filters paired with Infinite_Icons frame indices.
|
||||
static readonly (IItemEntryFilter Filter, int Icon)[] Filters = [(new ItemFilters.Tools(), 6), (new ItemFilters.Armor(), 2), (new ItemFilters.Vanity(), 8), (new ItemFilters.Accessories(), 1), (new ItemFilters.MiscAccessories(), 9), (new ItemFilters.Consumables(), 3), (new ItemFilters.BuildingBlock(), 4), (new ItemFilters.Furniture(), 7), (new ItemFilters.Materials(), 10)];
|
||||
|
||||
static BrowserCategory[] Categories() =>
|
||||
[
|
||||
new("All", _ => true),
|
||||
new(Language.GetTextValue(Weapon.GetDisplayNameKey()), type => Fits(Weapon, type), 0, [.. Enum.GetValues(typeof(WeaponClass)).Cast<WeaponClass>().Select(weaponClass => new BrowserCategory(weaponClass.ToString(), type => ContentSample(type) is { } item && ClassOf(item) == weaponClass)), new("Ammo", type => ContentSample(type) is { ammo: > 0 })]),
|
||||
.. Filters.Select(entry => new BrowserCategory(Language.GetTextValue(entry.Filter.GetDisplayNameKey()), type => Fits(entry.Filter, type), entry.Icon)),
|
||||
new(Language.GetTextValue("CreativePowers.TabMisc"), type => !Fits(Weapon, type) && !Filters.Any(entry => Fits(entry.Filter, type)), 5),
|
||||
];
|
||||
|
||||
static Item? ContentSample(int type) => ContentSamples.ItemsByType.TryGetValue(type, out Item? item) ? item : null;
|
||||
|
||||
// Whips must be classified as summon weapons before checking melee.
|
||||
static WeaponClass? ClassOf(Item item)
|
||||
{
|
||||
if (!Weapon.FitsFilter(item) || item.pick > 0 || item.axe > 0 || item.hammer > 0 || item.ammo > 0)
|
||||
return null;
|
||||
#if TMODLOADER
|
||||
if (item.CountsAsClass(DamageClass.Summon))
|
||||
return WeaponClass.Summon;
|
||||
if (item.CountsAsClass(DamageClass.Magic))
|
||||
return WeaponClass.Magic;
|
||||
if (item.CountsAsClass(DamageClass.Ranged))
|
||||
return WeaponClass.Ranged;
|
||||
if (item.CountsAsClass(DamageClass.Melee))
|
||||
return WeaponClass.Melee;
|
||||
#else
|
||||
if (item.summon)
|
||||
return WeaponClass.Summon;
|
||||
if (item.magic)
|
||||
return WeaponClass.Magic;
|
||||
if (item.ranged)
|
||||
return WeaponClass.Ranged;
|
||||
if (item.melee)
|
||||
return WeaponClass.Melee;
|
||||
#endif
|
||||
return WeaponClass.Other;
|
||||
}
|
||||
|
||||
static bool Fits(IItemEntryFilter filter, int type) => ContentSample(type) is { } item && filter.FitsFilter(item);
|
||||
|
||||
readonly Dictionary<int, Item> _samples = [];
|
||||
|
||||
public override void OnOpen() => Main.playerInventory = true;
|
||||
|
||||
Item Sample(int type)
|
||||
{
|
||||
if (!_samples.TryGetValue(type, out Item? item))
|
||||
{
|
||||
item = new Item();
|
||||
item.SetDefaults(type);
|
||||
_samples[type] = item;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
protected override void DrawContent(Rectangle body)
|
||||
{
|
||||
DrawSearchGrid(body, body.Y, body.Bottom - 30);
|
||||
Render.TextMid(Widgets.SpriteBatch, "Click: full stack to cursor Right click: +1 Shift click: to inventory", new Rectangle(body.X + 4, body.Bottom - 26, body.Width, 26), Widgets.Dim, 0.72f);
|
||||
}
|
||||
|
||||
protected override void DrawSlot(int type, Rectangle bounds, bool hot)
|
||||
{
|
||||
Widgets.Slot(bounds, hot);
|
||||
Render.Item(Widgets.SpriteBatch, type, new Rectangle(bounds.X + 6, bounds.Y + 6, bounds.Width - 12, bounds.Height - 12));
|
||||
if (!hot)
|
||||
return;
|
||||
|
||||
#if TMODLOADER
|
||||
Item sample = Sample(type);
|
||||
ItemSlot.MouseHover(ref sample, ItemSlot.Context.CreativeInfinite);
|
||||
#else
|
||||
ItemSlot.MouseHover(Sample(type), ItemSlot.Context.CreativeInfinite);
|
||||
#endif
|
||||
|
||||
if (Widgets.Click)
|
||||
Give(type, Widgets.Shift);
|
||||
else if (Widgets.RightClick)
|
||||
AddOne(type);
|
||||
}
|
||||
|
||||
static void Give(int type, bool toInventory)
|
||||
{
|
||||
if (toInventory)
|
||||
{
|
||||
for (int i = 0; i < InventorySlots; i++)
|
||||
{
|
||||
Item slot = Main.LocalPlayer.inventory[i];
|
||||
if (slot.type != 0)
|
||||
continue;
|
||||
|
||||
slot.SetDefaults(type);
|
||||
slot.stack = slot.maxStack;
|
||||
Widgets.Sound(7);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Main.mouseItem.type != type)
|
||||
{
|
||||
if (Main.mouseItem.type != 0)
|
||||
return;
|
||||
|
||||
Main.mouseItem.SetDefaults(type);
|
||||
}
|
||||
|
||||
Main.mouseItem.stack = Main.mouseItem.maxStack;
|
||||
|
||||
Main.playerInventory = true;
|
||||
Widgets.Sound(7);
|
||||
}
|
||||
|
||||
static void AddOne(int type)
|
||||
{
|
||||
Item cursor = Main.mouseItem;
|
||||
if (cursor.type == 0)
|
||||
{
|
||||
cursor.SetDefaults(type);
|
||||
cursor.stack = 1;
|
||||
}
|
||||
else if (cursor.type == type && cursor.stack < cursor.maxStack)
|
||||
{
|
||||
cursor.stack++;
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Main.playerInventory = true;
|
||||
Widgets.Sound(12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Terraria;
|
||||
using Terraria.GameContent.Events;
|
||||
using TerraSharp.Core;
|
||||
using TerraSharp.Features;
|
||||
using TerraSharp.Features.Combat;
|
||||
using TerraSharp.Simulation;
|
||||
|
||||
namespace TerraSharp.UI;
|
||||
|
||||
public sealed partial class MainWindow
|
||||
{
|
||||
static readonly Hotkeys.Bind[] KeyBinds = [.. Hotkeys.All.GroupBy(bind => bind.Group).SelectMany(group => group.OrderBy(bind => bind.Label, StringComparer.OrdinalIgnoreCase))];
|
||||
|
||||
void PlayerPage()
|
||||
{
|
||||
Group("Survival");
|
||||
Toggle("God Mode", ref Settings.GodMode);
|
||||
Toggle("Infinite Breath", ref Settings.InfBreath);
|
||||
Toggle("Infinite Flight", ref Settings.InfFlight);
|
||||
Toggle("Infinite Health", ref Settings.InfHealth);
|
||||
Toggle("Infinite Mana", ref Settings.InfMana);
|
||||
Toggle("Instant Respawn", ref Settings.InstantRespawn);
|
||||
|
||||
Group("Potions");
|
||||
if (Toggle("Auto Heal", ref Settings.AutoHeal))
|
||||
Sub(() => Slider("Below", ref Settings.AutoHealAt, 5f, 95f, 40f, "{0:0}% health"));
|
||||
if (Toggle("Auto Mana", ref Settings.AutoMana))
|
||||
Sub(() => Slider("Below", ref Settings.AutoManaAt, 5f, 95f, 25f, "{0:0}% mana"));
|
||||
|
||||
Group("Actions");
|
||||
if (Buttons(WindowManager.Buffs.Open ? "Close Buff Browser" : "Buff Browser") == 0)
|
||||
WindowManager.Toggle(WindowManager.Buffs);
|
||||
switch (Buttons("Set Max Health to 500", "Set Max Mana to 200"))
|
||||
{
|
||||
case 0: PlayerFeatures.MaxHp(); break;
|
||||
case 1: PlayerFeatures.MaxMana(); break;
|
||||
}
|
||||
switch (Buttons("Full Heal", "TP to Spawn"))
|
||||
{
|
||||
case 0: PlayerFeatures.FullHeal(); break;
|
||||
case 1: PlayerFeatures.TeleportSpawn(); break;
|
||||
}
|
||||
}
|
||||
|
||||
void MovementPage()
|
||||
{
|
||||
Group("Mobility");
|
||||
if (Toggle("Fly", ref Settings.Fly))
|
||||
Sub(() => Slider("Speed", ref Settings.FlySpeed, 2f, 40f, 12f, "{0:0.0}"));
|
||||
Toggle("Gravity Flip", ref Settings.GravityFlip);
|
||||
Toggle("High Jump", ref Settings.HighJump);
|
||||
if (Toggle("Super Speed", ref Settings.SuperSpeed))
|
||||
Sub(() => Slider("Multiplier", ref Settings.SpeedMult, 1f, 10f, 3f, "x{0:0.0}"));
|
||||
|
||||
Group("Protection");
|
||||
Toggle("Lava Immune", ref Settings.LavaImmune);
|
||||
Toggle("No Fall Damage", ref Settings.NoFallDamage);
|
||||
Toggle("No Knockback", ref Settings.NoKnockback);
|
||||
Toggle("Water Walk", ref Settings.WaterWalk);
|
||||
|
||||
Group("Reach");
|
||||
if (Toggle("Extended Reach", ref Settings.ExtendedReach))
|
||||
Sub(() =>
|
||||
{
|
||||
SliderInt("Horizontal", ref Settings.ReachX, 5, 200, 35, "{0} tiles");
|
||||
SliderInt("Vertical", ref Settings.ReachY, 5, 200, 30, "{0} tiles");
|
||||
});
|
||||
}
|
||||
|
||||
void CombatPage()
|
||||
{
|
||||
Group("Weapon");
|
||||
Toggle("Auto Swing", ref Settings.AutoSwing);
|
||||
if (Toggle("Damage Multiplier", ref Settings.DamageBoost))
|
||||
Sub(() => Slider("Multiplier", ref Settings.DamageMult, 1f, 50f, 5f, "x{0:0.0}"));
|
||||
Toggle("No Mana Cost", ref Settings.NoManaCost);
|
||||
Toggle("No Spread", ref Settings.NoSpread);
|
||||
Toggle("Rapid Attack", ref Settings.RapidAttack);
|
||||
|
||||
Group("Aim");
|
||||
if (Toggle("Aimbot", ref Settings.Aimbot))
|
||||
Sub(() =>
|
||||
{
|
||||
Toggle("Prediction", ref Settings.AimPrediction);
|
||||
Toggle("Exact check", ref Settings.AimExact);
|
||||
Slider("Cursor radius", ref Settings.AimFov, 0f, 800f, 250f, "{0:0} px", v => v < 1f ? "Anywhere" : null);
|
||||
Slider("Max range", ref Settings.AimRange, 5f, 150f, 60f, "{0:0} tiles");
|
||||
Toggle("Show target", ref Settings.AimDrawTarget);
|
||||
});
|
||||
if (Toggle("Auto Attack", ref Settings.AutoAttack))
|
||||
Sub(() =>
|
||||
{
|
||||
Slider("Max range", ref Settings.AimRange, 5f, 150f, 60f, "{0:0} tiles");
|
||||
Toggle("Show target", ref Settings.AimDrawTarget);
|
||||
});
|
||||
Toggle("Shot Preview", ref Settings.ShotPreview);
|
||||
|
||||
Group("Enemies");
|
||||
Note("Ignores critters and target dummies.");
|
||||
Toggle("Freeze Enemies", ref Settings.FreezeEnemies);
|
||||
if (Toggle("Hit Magnet", ref Settings.HitMagnet))
|
||||
Sub(() => Slider("Range", ref Settings.HitMagnetRange, 1f, 150f, 30f, "{0:0} tiles"));
|
||||
if (Toggle("Kill Aura", ref Settings.KillAura))
|
||||
Sub(() =>
|
||||
{
|
||||
Slider("Range", ref Settings.KillAuraRange, 1f, 150f, 20f, "{0:0} tiles");
|
||||
SliderInt("Every", ref Settings.KillAuraInterval, 1, 60, 10, "{0} ticks");
|
||||
});
|
||||
Toggle("Weaken Enemies", ref Settings.WeakenEnemies);
|
||||
switch (Buttons("Gather All Enemies", "Kill All Enemies"))
|
||||
{
|
||||
case 0: CombatFeatures.GatherAll(); break;
|
||||
case 1: CombatFeatures.KillAll(); break;
|
||||
}
|
||||
}
|
||||
|
||||
void ItemsPage()
|
||||
{
|
||||
Group("Items");
|
||||
Toggle("Infinite Items", ref Settings.InfItems);
|
||||
Toggle("Instant Break", ref Settings.InstantBreak);
|
||||
Toggle("Item Vacuum", ref Settings.ItemVacuum);
|
||||
|
||||
Group("Fishing");
|
||||
if (Toggle("Auto Fishing", ref Settings.AutoFish))
|
||||
Sub(() =>
|
||||
{
|
||||
Toggle("Recast", ref Settings.AutoRecast);
|
||||
Toggle("Catch enemies", ref Settings.FishEnemies);
|
||||
});
|
||||
Toggle("Sonar", ref Settings.Sonar);
|
||||
|
||||
Group("Actions");
|
||||
if (Buttons(WindowManager.Items.Open ? "Close Item Browser" : "Item Browser") == 0)
|
||||
WindowManager.Toggle(WindowManager.Items);
|
||||
}
|
||||
|
||||
void VisualsPage()
|
||||
{
|
||||
Group("ESP");
|
||||
Toggle("Enable ESP", ref Settings.Esp);
|
||||
Toggle("Distance", ref Settings.EspDistance);
|
||||
Toggle("Health bars", ref Settings.EspHealth);
|
||||
Toggle("Health numbers", ref Settings.EspHealthText);
|
||||
Slider("Max distance", ref Settings.EspMaxDistance, 0f, 400f, 0f, "{0:0} tiles", v => v < 1f ? "Unlimited" : null);
|
||||
Toggle("Names", ref Settings.EspNames);
|
||||
Toggle("Projectile paths", ref Settings.EspProjPath);
|
||||
Choice("Style", ref Settings.EspStyle, "Outline", "Corners", "Filled");
|
||||
Slider("Thickness", ref Settings.EspThickness, 1f, 4f, 1f, "{0:0}");
|
||||
Toggle("Tracers", ref Settings.EspLines);
|
||||
|
||||
Group("Targets");
|
||||
ToggleColor("Bosses (Rainbow)", ref Settings.EspBossRainbow, ref Settings.ColBoss, nameof(Settings.ColBoss));
|
||||
ToggleColor("Critters", ref Settings.EspCritters, ref Settings.ColCritters, nameof(Settings.ColCritters));
|
||||
ToggleColor("Friendly projectiles", ref Settings.EspProjFriendly, ref Settings.ColProjFriendly, nameof(Settings.ColProjFriendly));
|
||||
ToggleColor("Ground items", ref Settings.EspItems, ref Settings.ColItems, nameof(Settings.ColItems));
|
||||
ToggleColor("Hostile NPCs", ref Settings.EspHostile, ref Settings.ColHostile, nameof(Settings.ColHostile));
|
||||
ToggleColor("Hostile projectiles", ref Settings.EspProjHostile, ref Settings.ColProjHostile, nameof(Settings.ColProjHostile));
|
||||
ToggleColor("Other players", ref Settings.EspPlayers, ref Settings.ColPlayers, nameof(Settings.ColPlayers));
|
||||
ToggleColor("Town and friendly NPCs", ref Settings.EspTown, ref Settings.ColTown, nameof(Settings.ColTown));
|
||||
ToggleColor("Waypoints", ref Settings.EspWaypoints, ref Settings.ColWaypoint, nameof(Settings.ColWaypoint));
|
||||
ToggleColor("Yourself", ref Settings.EspLocal, ref Settings.ColLocal, nameof(Settings.ColLocal));
|
||||
|
||||
Group("Vision");
|
||||
Note("Potion effects without the buff.");
|
||||
Toggle("Biome Sight", ref Settings.BiomeSight);
|
||||
Toggle("Danger Sense", ref Settings.DangerSense);
|
||||
Toggle("Hunter", ref Settings.Hunter);
|
||||
Toggle("Night Owl", ref Settings.NightOwl);
|
||||
Toggle("Shine", ref Settings.Shine);
|
||||
Toggle("Spelunker", ref Settings.Spelunker);
|
||||
}
|
||||
|
||||
void WorldPage()
|
||||
{
|
||||
Group("Time");
|
||||
Toggle("Freeze Time", ref Settings.FreezeTime);
|
||||
switch (Buttons("Dawn", "Noon", "Dusk", "Midnight"))
|
||||
{
|
||||
case 0: WorldFeatures.SetTime(0, true); break;
|
||||
case 1: WorldFeatures.SetTime(27000, true); break;
|
||||
case 2: WorldFeatures.SetTime(0, false); break;
|
||||
case 3: WorldFeatures.SetTime(16200, false); break;
|
||||
}
|
||||
int phase = Main.moonPhase;
|
||||
SliderInt("Moon", ref phase, 0, 7, 0, "{0}", v => WorldFeatures.MoonPhases[(int)Math.Round(v)]);
|
||||
Main.moonPhase = phase;
|
||||
|
||||
Group("Weather");
|
||||
switch (Buttons(Main.raining ? "Stop Rain" : "Start Rain", WorldFeatures.SandstormActive ? "Stop Sandstorm" : "Start Sandstorm", Main.slimeRain ? "Stop Slime Rain" : "Start Slime Rain"))
|
||||
{
|
||||
case 0 when Main.raining: WorldFeatures.StopRain(); break;
|
||||
case 0: WorldFeatures.StartRain(); break;
|
||||
case 1: WorldFeatures.SetSandstorm(!WorldFeatures.SandstormActive); break;
|
||||
case 2: WorldFeatures.SetSlimeRain(!Main.slimeRain); break;
|
||||
}
|
||||
float wind = Main.windSpeedTarget * 100f;
|
||||
Slider("Wind", ref wind, -80f, 80f, 0f, "{0:0}", v => Math.Abs(v) < 1f ? "Calm" : $"{(v < 0 ? "West" : "East")} {Math.Abs((int)v)} mph");
|
||||
if (Math.Abs(wind / 100f - Main.windSpeedTarget) > 0.0001f)
|
||||
Main.windSpeedTarget = Main.windSpeedCurrent = wind / 100f;
|
||||
switch (Buttons("Drop Meteor", LanternNight.LanternsUp ? "End Lantern Night" : "Lantern Night", BirthdayParty.PartyIsUp ? "End Party" : "Start Party"))
|
||||
{
|
||||
case 0: WorldFeatures.DropMeteor(); break;
|
||||
case 1: LanternNight.ToggleManualLanterns(); break;
|
||||
case 2: BirthdayParty.ToggleManualParty(); break;
|
||||
}
|
||||
|
||||
Group("Events");
|
||||
Note("Pumpkin and Frost Moon need nighttime.");
|
||||
switch (Buttons(Main.bloodMoon ? "End Blood Moon" : "Blood Moon", Main.eclipse ? "End Eclipse" : "Solar Eclipse"))
|
||||
{
|
||||
case 0: Main.bloodMoon = !Main.bloodMoon; break;
|
||||
case 1: Main.eclipse = !Main.eclipse; break;
|
||||
}
|
||||
switch (Buttons("Frost Moon", "Pumpkin Moon", "End Moon Event"))
|
||||
{
|
||||
case 0: Main.startSnowMoon(); break;
|
||||
case 1: Main.startPumpkinMoon(); break;
|
||||
case 2: Main.stopMoonEvent(); break;
|
||||
}
|
||||
int invasion = Buttons([.. WorldFeatures.Invasions.Select(x => x.Name)]);
|
||||
if (invasion >= 0)
|
||||
WorldFeatures.StartInvasion(WorldFeatures.Invasions[invasion].Id);
|
||||
if (WorldFeatures.InvasionName is { } active && Buttons($"Stop {active}") == 0)
|
||||
WorldFeatures.StopInvasion();
|
||||
|
||||
Group("Waypoints");
|
||||
Note("Saved per world.");
|
||||
if (Buttons("Save Current Position") == 0)
|
||||
Waypoints.AddHere();
|
||||
List<Waypoint> waypoints = Waypoints.Current;
|
||||
if (waypoints.Count == 0)
|
||||
Note("No waypoints saved.");
|
||||
for (int i = 0; i < waypoints.Count; i++)
|
||||
{
|
||||
var row = new Rectangle(ContentX, _y, ContentWidth, 32);
|
||||
Render.TextMid(Widgets.SpriteBatch, waypoints[i].Name, row, Widgets.Hot(row) ? Widgets.Yellow : Color.White, 0.85f);
|
||||
if (Widgets.Button(new Rectangle(row.Right - 168, row.Y + 2, 80, 28), "Go", false, 0.8f))
|
||||
PlayerFeatures.TeleportTo(waypoints[i].Position);
|
||||
if (Widgets.Button(new Rectangle(row.Right - 82, row.Y + 2, 80, 28), "Remove", false, 0.8f))
|
||||
{
|
||||
waypoints.RemoveAt(i);
|
||||
break;
|
||||
}
|
||||
_y += 36;
|
||||
}
|
||||
}
|
||||
|
||||
void KeysPage()
|
||||
{
|
||||
Note(Hotkeys.Capturing is null ? "Click a key to rebind. Right-click to clear." : "Esc cancels. Backspace or Delete clears.");
|
||||
string? group = null;
|
||||
foreach (Hotkeys.Bind bind in KeyBinds)
|
||||
{
|
||||
if (bind.Group != group)
|
||||
{
|
||||
group = bind.Group;
|
||||
Group(group);
|
||||
}
|
||||
var row = new Rectangle(ContentX, _y, ContentWidth, 30);
|
||||
bool capturing = Hotkeys.Capturing == bind;
|
||||
Render.TextMid(Widgets.SpriteBatch, bind.Label, row, Widgets.Hot(row) ? Widgets.Yellow : Color.White, 0.86f);
|
||||
var key = new Rectangle(row.Right - 150, row.Y + 1, 150, 28);
|
||||
if (Widgets.Button(key, capturing ? "Press a key..." : Hotkeys.KeyName(Hotkeys.Get(bind.Id)), capturing, 0.8f))
|
||||
if (capturing)
|
||||
WindowManager.Unfocus();
|
||||
else
|
||||
Hotkeys.Capturing = bind;
|
||||
if (Widgets.Hot(key) && Widgets.RightClick)
|
||||
Hotkeys.Clear(bind.Id);
|
||||
_y += 32;
|
||||
}
|
||||
|
||||
Group("Settings");
|
||||
Note("Auto-saved to TerraSharp.json in the Terraria save folder.");
|
||||
if (Buttons("Reset Learned Ballistics") == 0)
|
||||
Ballistics.Reset();
|
||||
if (Buttons(_confirmReset ? "Click again to reset everything" : "Reset All Settings") != 0)
|
||||
return;
|
||||
if (_confirmReset)
|
||||
Settings.ResetAll();
|
||||
_confirmReset = !_confirmReset;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Collections.Generic;
|
||||
using TerraSharp.Core;
|
||||
|
||||
namespace TerraSharp.UI;
|
||||
|
||||
public sealed partial class MainWindow
|
||||
{
|
||||
static readonly Dictionary<string, string> Tooltips = new()
|
||||
{
|
||||
["Infinite Health"] = "Refills health every tick.",
|
||||
["Infinite Mana"] = "Refills mana every tick.",
|
||||
["Infinite Flight"] = "Unlimited wing and rocket boot flight time.",
|
||||
["Auto Heal"] = "Uses Quick Heal below the set health level. Waits for potion sickness to end.",
|
||||
["Auto Mana"] = "Uses Quick Mana below the set mana level.",
|
||||
["Fly"] = "Move through blocks with your movement keys. Up or Jump rises; Down descends.",
|
||||
["Gravity Flip"] = "Walk on the ceiling.",
|
||||
["Lava Immune"] = "Also protects against hot blocks.",
|
||||
["Water Walk"] = "Also works on honey and lava.",
|
||||
["Extended Reach"] = "Place, mine and interact with blocks farther away.",
|
||||
["Rapid Attack"] = "Speeds up item use. Hold attack with Auto Swing to keep using it.",
|
||||
["Auto Swing"] = "Hold attack to keep using any item.",
|
||||
["Damage Multiplier"] = "Applies to your held item.",
|
||||
["No Mana Cost"] = "Magic weapons cost no mana.",
|
||||
["No Spread"] = "Removes random shot spread. Shotgun pellets stay together.",
|
||||
["Aimbot"] = "Aims your shots at the enemy nearest your cursor, avoiding blocked spots.",
|
||||
["Prediction"] = "Leads moving enemies and accounts for projectile drop.",
|
||||
["Exact check"] = "Checks and corrects aim before firing. Auto Attack skips known misses. Orange brackets mean an unconfirmed shot.",
|
||||
["Shot Preview"] = "Shows your next shot's path, including spread and homing.",
|
||||
["Auto Attack"] = "Swings at enemies in reach and shoots at enemies your weapon can hit.",
|
||||
["Show target"] = "Shows the cursor radius, target brackets and aim point.",
|
||||
["Kill Aura"] = "Kills enemies in range and drops their loot.",
|
||||
["Hit Magnet"] = "Pulls the nearest enemy in range into your swing.",
|
||||
["Weaken Enemies"] = "Keeps enemies at 1 HP.",
|
||||
["Instant Break"] = "Break blocks in one hit with your held pickaxe, axe or hammer.",
|
||||
["Infinite Items"] = "Keeps inventory stacks from shrinking.",
|
||||
["Item Vacuum"] = "Pulls all dropped items to you.",
|
||||
["Auto Fishing"] = "Reels in on a bite while you hold a fishing pole.",
|
||||
["Recast"] = "Casts again where you last cast by hand.",
|
||||
["Catch enemies"] = "Reels in enemy catches too.",
|
||||
["Sonar"] = "Shows what's on the hook before you reel in.",
|
||||
["Enable ESP"] = "Marks the selected targets on screen.",
|
||||
["Health bars"] = "Also shown at full health.",
|
||||
["Health numbers"] = "Shows current and max health.",
|
||||
["Distance"] = "Shown in feet. 1 tile = 2 ft.",
|
||||
["Tracers"] = "Draws lines from you to each target.",
|
||||
["Projectile paths"] = "Shows projectile paths until they hit a block or expire.",
|
||||
["Bosses (Rainbow)"] = "Cycles boss colors. Turn off to use the chosen color.",
|
||||
["Spelunker"] = "Makes ores, gems and treasure glow.",
|
||||
["Hunter"] = "Makes enemies glow.",
|
||||
["Shine"] = "Lights the area around you.",
|
||||
["Night Owl"] = "Brightens dark areas.",
|
||||
["Danger Sense"] = "Makes traps and hazards glow.",
|
||||
["Biome Sight"] = "Makes Corruption, Crimson and Hallow blocks glow.",
|
||||
["Full Heal"] = "Refills health and mana.",
|
||||
["TP to Spawn"] = "Recalls like a Magic Mirror: to your bed if set, otherwise the world's spawn point.",
|
||||
["Reset Learned Ballistics"] = "Clears learned projectile data. Learns it again as you shoot.",
|
||||
};
|
||||
|
||||
static readonly Dictionary<string, Feature> Impacts = new()
|
||||
{
|
||||
["Aimbot"] = Feature.Aimbot,
|
||||
["Prediction"] = Feature.NpcMotion,
|
||||
["Exact check"] = Feature.Aimbot,
|
||||
["Auto Attack"] = Feature.AutoAttack,
|
||||
["Shot Preview"] = Feature.ShotPreview,
|
||||
["Enable ESP"] = Feature.Esp,
|
||||
["Projectile paths"] = Feature.Esp,
|
||||
};
|
||||
|
||||
static string ImpactTooltip(Feature feature)
|
||||
{
|
||||
Impact impact = Profiler.ImpactOf(feature);
|
||||
if (impact == Impact.Idle)
|
||||
return "Performance: not running.";
|
||||
|
||||
float average = Profiler.Average(feature);
|
||||
return $"Performance: {impact}. {average:0.000} ms per tick ({average / Profiler.FrameBudget:0.0%} of a frame), peak {Profiler.Peak(feature):0.00} ms.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Terraria;
|
||||
using TerraSharp.Core;
|
||||
|
||||
namespace TerraSharp.UI;
|
||||
|
||||
public sealed partial class MainWindow : Window
|
||||
{
|
||||
sealed record Page(string Name, Action Draw, Func<int> ActiveCount);
|
||||
|
||||
const int Indent = 22;
|
||||
const int TabGap = 4;
|
||||
|
||||
readonly Page[] _pages;
|
||||
readonly Scroll[] _scrolls;
|
||||
int _page = 2;
|
||||
|
||||
int _x, _y, _width, _indent;
|
||||
bool _firstGroup;
|
||||
|
||||
string? _openPicker;
|
||||
readonly Dictionary<string, Vector3> _hsl = [];
|
||||
readonly TextField _hex = new("RRGGBBAA");
|
||||
bool _confirmReset;
|
||||
|
||||
public MainWindow() : base("menu", "", new Vector2(80, 120), 600, 560)
|
||||
{
|
||||
Open = true;
|
||||
_pages =
|
||||
[
|
||||
new Page("Player", PlayerPage, () => Count(Settings.GodMode, Settings.InfHealth, Settings.InfMana, Settings.InfBreath, Settings.InfFlight, Settings.InstantRespawn, Settings.AutoHeal, Settings.AutoMana)),
|
||||
new Page("Movement", MovementPage, () => Count(Settings.Fly, Settings.SuperSpeed, Settings.HighJump, Settings.GravityFlip, Settings.NoFallDamage, Settings.LavaImmune, Settings.WaterWalk, Settings.NoKnockback, Settings.ExtendedReach)),
|
||||
new Page("Combat", CombatPage, () => Count(Settings.RapidAttack, Settings.AutoSwing, Settings.DamageBoost, Settings.NoManaCost, Settings.NoSpread, Settings.Aimbot, Settings.AutoAttack, Settings.KillAura, Settings.HitMagnet, Settings.FreezeEnemies, Settings.WeakenEnemies)),
|
||||
new Page("Items", ItemsPage, () => Count(Settings.InstantBreak, Settings.InfItems, Settings.ItemVacuum, Settings.AutoFish, Settings.Sonar)),
|
||||
new Page("Visuals", VisualsPage, () => Count(Settings.Esp, Settings.Spelunker, Settings.Hunter, Settings.Shine, Settings.NightOwl, Settings.DangerSense, Settings.BiomeSight)),
|
||||
new Page("World", WorldPage, () => Count(Settings.FreezeTime)),
|
||||
new Page("Keys", KeysPage, () => 0),
|
||||
];
|
||||
|
||||
_scrolls = new Scroll[_pages.Length];
|
||||
for (int i = 0; i < _scrolls.Length; i++)
|
||||
_scrolls[i] = new Scroll();
|
||||
}
|
||||
|
||||
bool OnKeysPage => _page == _pages.Length - 1;
|
||||
|
||||
static int Count(params bool[] flags)
|
||||
{
|
||||
int count = 0;
|
||||
foreach (bool flag in flags)
|
||||
if (flag)
|
||||
count++;
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
static int ContentTop(Rectangle body) => body.Y + (Main.gameMenu ? 68 : 42);
|
||||
|
||||
public override bool DragHandle(Rectangle body, Point mouse)
|
||||
{
|
||||
if (mouse.Y >= ContentTop(body))
|
||||
return false;
|
||||
|
||||
for (int i = 0; i < _pages.Length; i++)
|
||||
if (TabBounds(body, i).Contains(mouse))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public override void DrawBody(Rectangle body)
|
||||
{
|
||||
DrawTabs(body);
|
||||
int top = ContentTop(body);
|
||||
if (Main.gameMenu)
|
||||
Render.TextMid(Widgets.SpriteBatch, "Enter a world to use actions.", new Rectangle(body.X + 4, body.Y + 42, body.Width, 24), Widgets.Yellow * 0.9f, 0.74f);
|
||||
|
||||
var inner = new Rectangle(body.X, top, body.Width, body.Bottom - top);
|
||||
Widgets.Panel(inner, Widgets.InnerBg);
|
||||
var area = new Rectangle(inner.X + 12, inner.Y + 8, inner.Width - 38, inner.Height - 16);
|
||||
|
||||
Scroll scroll = _scrolls[_page];
|
||||
_y = scroll.Begin(area);
|
||||
int start = _y;
|
||||
(_x, _width, _indent, _firstGroup) = (area.X, area.Width, 0, true);
|
||||
_pages[_page].Draw();
|
||||
scroll.End(area, _y - start + 4);
|
||||
}
|
||||
|
||||
Rectangle TabBounds(Rectangle body, int index)
|
||||
{
|
||||
int tabWidth = (body.Width - TabGap * (_pages.Length - 1)) / _pages.Length;
|
||||
return new Rectangle(body.X + index * (tabWidth + TabGap), body.Y, tabWidth, 34);
|
||||
}
|
||||
|
||||
void DrawTabs(Rectangle body)
|
||||
{
|
||||
for (int i = 0; i < _pages.Length; i++)
|
||||
{
|
||||
Rectangle bounds = TabBounds(body, i);
|
||||
if (!Widgets.Button(bounds, _pages[i].Name, i == _page, 0.78f) || _page == i)
|
||||
continue;
|
||||
|
||||
_page = i;
|
||||
_openPicker = null;
|
||||
WindowManager.Unfocus();
|
||||
}
|
||||
|
||||
for (int i = 0; i < _pages.Length; i++)
|
||||
if (_pages[i].ActiveCount() is var count and > 0)
|
||||
Widgets.Badge(TabBounds(body, i).Right - 6, body.Y + 2, count.ToString());
|
||||
}
|
||||
|
||||
int ContentX => _x + _indent;
|
||||
|
||||
int ContentWidth => _width - _indent;
|
||||
|
||||
void Sub(Action body)
|
||||
{
|
||||
_indent += Indent;
|
||||
body();
|
||||
_indent -= Indent;
|
||||
}
|
||||
|
||||
void Group(string title)
|
||||
{
|
||||
if (!_firstGroup)
|
||||
_y += 12;
|
||||
_firstGroup = false;
|
||||
Widgets.Text(title, new Vector2(ContentX, _y + 2), Widgets.Header, 0.92f);
|
||||
float width = Render.Measure(title, 0.92f).X;
|
||||
Widgets.Separator(ContentX + (int)width + 12, _y + 12, ContentWidth - (int)width - 12);
|
||||
_y += 30;
|
||||
}
|
||||
|
||||
void Note(string text)
|
||||
{
|
||||
Widgets.Text(text, new Vector2(ContentX, _y + 2), Widgets.Dim * 0.8f, 0.72f);
|
||||
_y += 24;
|
||||
}
|
||||
|
||||
bool Toggle(string label, ref bool value, int rightPad = 0)
|
||||
{
|
||||
var row = new Rectangle(ContentX, _y, ContentWidth, 28);
|
||||
bool hot = Widgets.Hot(new Rectangle(row.X, row.Y, row.Width - rightPad, row.Height));
|
||||
if (hot && Widgets.Click)
|
||||
{
|
||||
value = !value;
|
||||
Widgets.Sound(12);
|
||||
}
|
||||
|
||||
if (hot)
|
||||
{
|
||||
Render.FillSoft(Widgets.SpriteBatch, new Rectangle(row.X - 6, row.Y, row.Width + 6, row.Height - 2), Color.White * 0.05f);
|
||||
if (Tooltips.TryGetValue(label, out string? tooltip))
|
||||
Widgets.Tooltip = tooltip;
|
||||
}
|
||||
|
||||
Color color = hot ? Widgets.Yellow : value ? Widgets.On : _indent > 0 ? Widgets.Dim : Color.White;
|
||||
Render.TextMid(Widgets.SpriteBatch, label, row, color, 0.88f);
|
||||
Widgets.ToggleIcon(new Rectangle(row.Right - 28, row.Y + 2, 24, 24), value, hot);
|
||||
if (Impacts.TryGetValue(label, out Feature feature))
|
||||
ImpactDot(new Rectangle(row.Right - rightPad - 82, row.Y + 10, 8, 8), feature);
|
||||
_y += 30;
|
||||
return value;
|
||||
}
|
||||
|
||||
static void ImpactDot(Rectangle bounds, Feature feature)
|
||||
{
|
||||
Widgets.ImpactDot(bounds, Profiler.ImpactOf(feature));
|
||||
if (Widgets.Hot(new Rectangle(bounds.X - 6, bounds.Y - 9, bounds.Width + 12, bounds.Height + 18)))
|
||||
Widgets.Tooltip = ImpactTooltip(feature);
|
||||
}
|
||||
|
||||
void ToggleColor(string label, ref bool value, ref Color color, string settingName)
|
||||
{
|
||||
int top = _y;
|
||||
Toggle(label, ref value, 110);
|
||||
var swatch = new Rectangle(ContentX + ContentWidth - 104, top + 3, 34, 22);
|
||||
bool hot = Widgets.Hot(swatch);
|
||||
bool open = _openPicker == settingName;
|
||||
Widgets.Panel(swatch, Widgets.ButtonBg, hot || open ? Widgets.Yellow : Color.Black);
|
||||
Render.Fill(Widgets.SpriteBatch, new Rectangle(swatch.X + 5, swatch.Y + 5, swatch.Width - 10, swatch.Height - 10), new Color(color.R, color.G, color.B) * (color.A / 255f));
|
||||
|
||||
if (hot)
|
||||
Widgets.Tooltip = "Edit color";
|
||||
if (hot && Widgets.Click)
|
||||
{
|
||||
_openPicker = open ? null : settingName;
|
||||
WindowManager.Unfocus();
|
||||
Widgets.Sound(12);
|
||||
}
|
||||
|
||||
if (_openPicker != settingName)
|
||||
return;
|
||||
|
||||
_indent += Indent;
|
||||
ColorPicker(settingName, ref color);
|
||||
_indent -= Indent;
|
||||
}
|
||||
|
||||
static readonly Color[] Presets = [new(235, 60, 60), new(255, 140, 40), new(255, 210, 70), new(120, 230, 90), new(70, 220, 200), new(70, 200, 255), new(90, 110, 255), new(200, 90, 255), new(255, 110, 200), Color.White];
|
||||
|
||||
void ColorPicker(string settingName, ref Color color)
|
||||
{
|
||||
// Achromatic colors cannot recover the picker's previous hue from RGB.
|
||||
Vector3 hsl = Main.rgbToHsl(color);
|
||||
if (_hsl.TryGetValue(settingName, out Vector3 cached) && Main.hslToRgb(cached.X, cached.Y, cached.Z).PackedValue == new Color(color.R, color.G, color.B).PackedValue)
|
||||
hsl = cached;
|
||||
|
||||
float hue = hsl.X, saturation = hsl.Y, lightness = hsl.Z, opacity = color.A / 255f;
|
||||
Color rgb = Main.hslToRgb(hue, saturation, lightness);
|
||||
hue = GradientSlider("Hue", settingName + "h", hue, p => Main.hslToRgb(p, 1f, 0.5f));
|
||||
saturation = GradientSlider("Saturation", settingName + "s", saturation, p => Main.hslToRgb(hue, p, lightness));
|
||||
lightness = GradientSlider("Lightness", settingName + "l", lightness, p => Main.hslToRgb(hue, saturation, p));
|
||||
opacity = GradientSlider("Opacity", settingName + "a", opacity, p => Color.Lerp(Color.Black, rgb, p));
|
||||
_hsl[settingName] = new Vector3(hue, saturation, lightness);
|
||||
color = Main.hslToRgb(hue, saturation, lightness, (byte)(opacity * 255));
|
||||
|
||||
int size = Math.Min(30, (ContentWidth - 6 * (Presets.Length - 1)) / Presets.Length);
|
||||
for (int i = 0; i < Presets.Length; i++)
|
||||
{
|
||||
var bounds = new Rectangle(ContentX + i * (size + 6), _y + 2, size, size);
|
||||
bool hot = Widgets.Hot(bounds);
|
||||
Widgets.Slot(bounds, hot);
|
||||
Render.Fill(Widgets.SpriteBatch, new Rectangle(bounds.X + 6, bounds.Y + 6, bounds.Width - 12, bounds.Height - 12), Presets[i]);
|
||||
if (!hot || !Widgets.Click)
|
||||
continue;
|
||||
|
||||
color = new Color(Presets[i].R, Presets[i].G, Presets[i].B, color.A);
|
||||
_hsl.Remove(settingName);
|
||||
Widgets.Sound(12);
|
||||
}
|
||||
_y += size + 8;
|
||||
|
||||
if (WindowManager.Focus != _hex)
|
||||
_hex.Text = Settings.Hex(color);
|
||||
Widgets.Field(_hex, new Rectangle(ContentX, _y + 2, ContentWidth - 120, 32));
|
||||
if (WindowManager.Focus == _hex)
|
||||
{
|
||||
try
|
||||
{
|
||||
color = Settings.ParseHex(_hex.Text);
|
||||
_hsl.Remove(settingName);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (Widgets.Button(new Rectangle(ContentX + ContentWidth - 112, _y + 2, 112, 32), "Default", false, 0.8f) && Settings.Default(settingName) is Color fallback)
|
||||
{
|
||||
color = fallback;
|
||||
_hsl.Remove(settingName);
|
||||
}
|
||||
_y += 42;
|
||||
}
|
||||
|
||||
float GradientSlider(string label, string id, float value, Utils.ColorLerpMethod gradient)
|
||||
{
|
||||
var row = new Rectangle(ContentX, _y, ContentWidth, 26);
|
||||
bool hot = Widgets.Hot(row) || Widgets.ActiveSlider == id;
|
||||
Render.TextMid(Widgets.SpriteBatch, label, row, hot ? Widgets.Yellow : Widgets.Dim, 0.8f);
|
||||
value = Widgets.ValueBar(id, row, value, 0f, 1f, value, gradient);
|
||||
_y += 28;
|
||||
return value;
|
||||
}
|
||||
|
||||
void Choice<T>(string label, ref T value, params string[] options) where T : struct, Enum
|
||||
{
|
||||
var row = new Rectangle(ContentX, _y, ContentWidth, 30);
|
||||
Render.TextMid(Widgets.SpriteBatch, label, row, Widgets.Hot(row) ? Widgets.Yellow : Widgets.Dim, 0.85f);
|
||||
const int ButtonWidth = 92, Gap = 4;
|
||||
int x0 = row.Right - options.Length * ButtonWidth - (options.Length - 1) * Gap;
|
||||
int selected = Convert.ToInt32(value);
|
||||
for (int i = 0; i < options.Length; i++)
|
||||
if (Widgets.Button(new Rectangle(x0 + i * (ButtonWidth + Gap), row.Y + 1, ButtonWidth, 28), options[i], selected == i, 0.76f))
|
||||
value = (T)Enum.ToObject(typeof(T), i);
|
||||
|
||||
_y += 34;
|
||||
}
|
||||
|
||||
void Slider(string label, ref float value, float minimum, float maximum, float fallback, string format, Func<float, string?>? display = null)
|
||||
{
|
||||
var row = new Rectangle(ContentX, _y, ContentWidth, 28);
|
||||
string id = label + "|" + format;
|
||||
bool hot = Widgets.Hot(row) || Widgets.ActiveSlider == id;
|
||||
|
||||
if (hot)
|
||||
Widgets.Tooltip = "Drag to change. Right-click to reset.";
|
||||
|
||||
string shown = display?.Invoke(value) ?? string.Format(format, value);
|
||||
Render.TextMid(Widgets.SpriteBatch, $"{label}: {shown}", row, hot ? Widgets.Yellow : Widgets.Dim, 0.85f);
|
||||
value = Widgets.ValueBar(id, row, value, minimum, maximum, fallback);
|
||||
_y += 30;
|
||||
}
|
||||
|
||||
void SliderInt(string label, ref int value, int minimum, int maximum, int fallback, string format, Func<float, string?>? display = null)
|
||||
{
|
||||
float floatValue = value;
|
||||
Slider(label, ref floatValue, minimum, maximum, fallback, format, display);
|
||||
value = (int)Math.Round(floatValue);
|
||||
}
|
||||
|
||||
int Buttons(params string[] labels)
|
||||
{
|
||||
const int Gap = 6;
|
||||
int width = (ContentWidth - Gap * (labels.Length - 1)) / labels.Length;
|
||||
bool enabled = !Main.gameMenu || OnKeysPage;
|
||||
int hit = -1;
|
||||
for (int i = 0; i < labels.Length; i++)
|
||||
{
|
||||
var bounds = new Rectangle(ContentX + i * (width + Gap), _y + 2, width, 30);
|
||||
if (Widgets.Button(bounds, labels[i], false, 0.82f, enabled))
|
||||
hit = i;
|
||||
if (enabled && Widgets.Hot(bounds) && Tooltips.TryGetValue(labels[i], out string? tooltip))
|
||||
Widgets.Tooltip = tooltip;
|
||||
}
|
||||
|
||||
_y += 36;
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Terraria;
|
||||
using Terraria.GameContent;
|
||||
|
||||
namespace TerraSharp.UI;
|
||||
|
||||
public static class Render
|
||||
{
|
||||
static Texture2D? _pixel;
|
||||
|
||||
static Texture2D Pixel
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_pixel is null || _pixel.IsDisposed)
|
||||
{
|
||||
_pixel = new Texture2D(Main.instance.GraphicsDevice, 1, 1);
|
||||
_pixel.SetData([Color.White]);
|
||||
}
|
||||
return _pixel;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Fill(SpriteBatch spriteBatch, Rectangle bounds, Color color) => spriteBatch.Draw(Pixel, bounds, color);
|
||||
|
||||
public static void Outline(SpriteBatch spriteBatch, Rectangle bounds, Color color, int thickness = 1)
|
||||
{
|
||||
Fill(spriteBatch, new Rectangle(bounds.X, bounds.Y, bounds.Width, thickness), color);
|
||||
Fill(spriteBatch, new Rectangle(bounds.X, bounds.Bottom - thickness, bounds.Width, thickness), color);
|
||||
Fill(spriteBatch, new Rectangle(bounds.X, bounds.Y, thickness, bounds.Height), color);
|
||||
Fill(spriteBatch, new Rectangle(bounds.Right - thickness, bounds.Y, thickness, bounds.Height), color);
|
||||
}
|
||||
|
||||
public static void FillSoft(SpriteBatch spriteBatch, Rectangle bounds, Color color)
|
||||
{
|
||||
Fill(spriteBatch, new Rectangle(bounds.X + 1, bounds.Y, bounds.Width - 2, bounds.Height), color);
|
||||
Fill(spriteBatch, new Rectangle(bounds.X, bounds.Y + 1, 1, bounds.Height - 2), color);
|
||||
Fill(spriteBatch, new Rectangle(bounds.Right - 1, bounds.Y + 1, 1, bounds.Height - 2), color);
|
||||
}
|
||||
|
||||
public static void Line(SpriteBatch spriteBatch, Vector2 start, Vector2 end, Color color, float thickness = 1f)
|
||||
{
|
||||
Vector2 delta = end - start;
|
||||
spriteBatch.Draw(Pixel, start, null, color, (float)Math.Atan2(delta.Y, delta.X), new Vector2(0f, 0.5f), new Vector2(delta.Length(), thickness), SpriteEffects.None, 0f);
|
||||
}
|
||||
|
||||
public static void Corners(SpriteBatch spriteBatch, Vector2 topLeft, Vector2 bottomRight, Color color, float thickness)
|
||||
{
|
||||
float cornerWidth = Math.Max(4f, (bottomRight.X - topLeft.X) / 4f), cornerHeight = Math.Max(4f, (bottomRight.Y - topLeft.Y) / 4f);
|
||||
Vector2 topRight = new(bottomRight.X, topLeft.Y), bottomLeft = new(topLeft.X, bottomRight.Y);
|
||||
Line(spriteBatch, topLeft, topLeft + new Vector2(cornerWidth, 0), color, thickness);
|
||||
Line(spriteBatch, topLeft, topLeft + new Vector2(0, cornerHeight), color, thickness);
|
||||
Line(spriteBatch, topRight, topRight - new Vector2(cornerWidth, 0), color, thickness);
|
||||
Line(spriteBatch, topRight, topRight + new Vector2(0, cornerHeight), color, thickness);
|
||||
Line(spriteBatch, bottomLeft, bottomLeft + new Vector2(cornerWidth, 0), color, thickness);
|
||||
Line(spriteBatch, bottomLeft, bottomLeft - new Vector2(0, cornerHeight), color, thickness);
|
||||
Line(spriteBatch, bottomRight, bottomRight - new Vector2(cornerWidth, 0), color, thickness);
|
||||
Line(spriteBatch, bottomRight, bottomRight - new Vector2(0, cornerHeight), color, thickness);
|
||||
}
|
||||
|
||||
public static void TextCenteredAt(SpriteBatch spriteBatch, string text, float centerX, float y, Color color, float scale) => Text(spriteBatch, text, new Vector2(centerX - Measure(text, scale).X / 2f, y), color, scale);
|
||||
|
||||
// FontAssets.MouseText is a ReLogic type and must be accessed through reflection.
|
||||
static FieldInfo? _fontField;
|
||||
static PropertyInfo? _assetValue;
|
||||
static MethodInfo? _measureString;
|
||||
static object? _font;
|
||||
static readonly Dictionary<string, Vector2> Sizes = [];
|
||||
|
||||
static Vector2 MeasureRaw(string text)
|
||||
{
|
||||
if (_measureString is null)
|
||||
{
|
||||
_fontField = typeof(FontAssets).GetField("MouseText", BindingFlags.Public | BindingFlags.Static)!;
|
||||
_assetValue = _fontField.FieldType.GetProperty("Value")!;
|
||||
_measureString = _assetValue.PropertyType.GetMethod("MeasureString", [typeof(string)])!;
|
||||
}
|
||||
|
||||
object font = _assetValue!.GetValue(_fontField!.GetValue(null), null)!;
|
||||
// A resource pack can swap the font.
|
||||
if (!ReferenceEquals(font, _font))
|
||||
{
|
||||
_font = font;
|
||||
Sizes.Clear();
|
||||
}
|
||||
|
||||
if (Sizes.TryGetValue(text, out Vector2 size))
|
||||
return size;
|
||||
|
||||
size = (Vector2)_measureString.Invoke(font, [text])!;
|
||||
if (Sizes.Count > 4096)
|
||||
Sizes.Clear();
|
||||
Sizes[text] = size;
|
||||
return size;
|
||||
}
|
||||
|
||||
public static Vector2 Measure(string text, float scale) => string.IsNullOrEmpty(text) ? Vector2.Zero : MeasureRaw(text) * scale;
|
||||
|
||||
// Visual line height; the font reports a large descent.
|
||||
public static float LineHeight(float scale) => 22f * scale;
|
||||
|
||||
static float CenterY(Rectangle bounds, float scale) => bounds.Y + (bounds.Height - LineHeight(scale)) / 2f + 2f * scale;
|
||||
|
||||
public static void Text(SpriteBatch spriteBatch, string text, Vector2 position, Color color, float scale)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
Utils.DrawBorderString(spriteBatch, text, position, color, scale, 0f, 0f, -1);
|
||||
}
|
||||
|
||||
public static void TextCentered(SpriteBatch spriteBatch, string text, Rectangle bounds, Color color, float scale) => Text(spriteBatch, text, new Vector2(bounds.X + (bounds.Width - Measure(text, scale).X) / 2f, CenterY(bounds, scale)), color, scale);
|
||||
|
||||
public static void TextMid(SpriteBatch spriteBatch, string text, Rectangle bounds, Color color, float scale) => Text(spriteBatch, text, new Vector2(bounds.X, CenterY(bounds, scale)), color, scale);
|
||||
|
||||
public static void TextRight(SpriteBatch spriteBatch, string text, Rectangle bounds, Color color, float scale) => Text(spriteBatch, text, new Vector2(bounds.Right - Measure(text, scale).X, CenterY(bounds, scale)), color, scale);
|
||||
|
||||
public static string Ellipsize(string text, float scale, float maxWidth)
|
||||
{
|
||||
if (Measure(text, scale).X <= maxWidth)
|
||||
return text;
|
||||
|
||||
while (text.Length > 1 && Measure(text + "...", scale).X > maxWidth)
|
||||
text = text.Substring(0, text.Length - 1);
|
||||
return text + "...";
|
||||
}
|
||||
|
||||
public static void Item(SpriteBatch spriteBatch, int type, Rectangle bounds)
|
||||
{
|
||||
if (type <= 0)
|
||||
return;
|
||||
|
||||
Main.instance.LoadItem(type);
|
||||
Main.GetItemDrawFrame(type, out Texture2D texture, out Rectangle frame);
|
||||
if (texture is null)
|
||||
return;
|
||||
|
||||
float scale = Math.Min(1f, Math.Min(bounds.Width / (float)frame.Width, bounds.Height / (float)frame.Height));
|
||||
Vector2 size = new Vector2(frame.Width, frame.Height) * scale;
|
||||
spriteBatch.Draw(texture, new Vector2(bounds.X + (bounds.Width - size.X) / 2f, bounds.Y + (bounds.Height - size.Y) / 2f), frame, Color.White, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
|
||||
}
|
||||
|
||||
public static Vector2 ToScreen(Vector2 world) => Vector2.Transform(world - Main.screenPosition, Main.GameViewMatrix.TransformationMatrix);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using Microsoft.Xna.Framework;
|
||||
|
||||
namespace TerraSharp.UI;
|
||||
|
||||
public sealed class Scroll
|
||||
{
|
||||
float _position, _target;
|
||||
int _contentHeight;
|
||||
float? _grab;
|
||||
|
||||
public float Position => _position;
|
||||
|
||||
public int Begin(Rectangle area)
|
||||
{
|
||||
int maximum = Math.Max(0, _contentHeight - area.Height);
|
||||
if (Widgets.Hot(area) && Widgets.Wheel != 0 && Widgets.ActiveSlider is null)
|
||||
_target -= Widgets.Wheel / 120f * 60f;
|
||||
_target = MathHelper.Clamp(_target, 0, maximum);
|
||||
_position = MathHelper.Lerp(_position, _target, 0.35f);
|
||||
if (Math.Abs(_position - _target) < 0.5f)
|
||||
_position = _target;
|
||||
|
||||
Widgets.BeginClip(area);
|
||||
return area.Y - (int)_position;
|
||||
}
|
||||
|
||||
public void End(Rectangle area, int contentHeight)
|
||||
{
|
||||
_contentHeight = contentHeight;
|
||||
Widgets.EndClip();
|
||||
if (!Widgets.Down)
|
||||
_grab = null;
|
||||
if (_contentHeight <= area.Height)
|
||||
return;
|
||||
|
||||
int maximum = _contentHeight - area.Height;
|
||||
var track = new Rectangle(area.Right + 4, area.Y, 16, area.Height);
|
||||
Widgets.ScrollBar("Images/UI/Scrollbar", track, Color.White);
|
||||
int thumb = Math.Max(28, (track.Height - 8) * area.Height / _contentHeight);
|
||||
int travel = track.Height - 8 - thumb;
|
||||
int y = track.Y + 4 + (int)(travel * (_position / maximum));
|
||||
bool hot = Widgets.Hot(track);
|
||||
|
||||
if (hot && Widgets.Click && Widgets.ActiveSlider is null)
|
||||
_grab = Widgets.Mouse.Y >= y && Widgets.Mouse.Y < y + thumb ? Widgets.Mouse.Y - y : thumb / 2f;
|
||||
if (_grab is { } grab && travel > 0)
|
||||
{
|
||||
_position = _target = MathHelper.Clamp((Widgets.Mouse.Y - grab - track.Y - 4) / travel * maximum, 0, maximum);
|
||||
y = track.Y + 4 + (int)(travel * (_position / maximum));
|
||||
}
|
||||
|
||||
Widgets.ScrollBar("Images/UI/ScrollbarInner", new Rectangle(track.X + 3, y, track.Width - 6, thumb), Color.White * (hot || _grab is not null ? 1f : 0.85f));
|
||||
}
|
||||
|
||||
public void Reset() => _position = _target = 0;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace TerraSharp.UI;
|
||||
|
||||
public sealed class TextField(string placeholder)
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
|
||||
public string Placeholder { get; } = placeholder;
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Terraria;
|
||||
using Terraria.Audio;
|
||||
using TerraSharp.Core;
|
||||
#if TMODLOADER
|
||||
using Microsoft.Xna.Framework.Audio;
|
||||
using TerraSharp.Patches;
|
||||
#endif
|
||||
|
||||
namespace TerraSharp.UI;
|
||||
|
||||
public static class Widgets
|
||||
{
|
||||
public static readonly Color Yellow = new(255, 231, 69); // Main.OurFavoriteColor
|
||||
public static readonly Color WindowBg = new Color(33, 43, 79) * 0.96f; // Bestiary / Journey main panel
|
||||
public static readonly Color InnerBg = new Color(22, 28, 56) * 0.9f;
|
||||
public static readonly Color ButtonBg = new Color(63, 82, 151) * 0.7f; // UIPanel default
|
||||
public static readonly Color ButtonHot = new(73, 94, 171);
|
||||
public static readonly Color Header = new(205, 215, 255);
|
||||
public static readonly Color On = new(120, 235, 120);
|
||||
public static readonly Color Dim = new(175, 175, 200);
|
||||
public static readonly Color Orange = new(255, 150, 40);
|
||||
public static readonly Color Red = new(235, 60, 60);
|
||||
static readonly Color SlotTint = Color.White * 0.86f;
|
||||
static readonly Rectangle NoClip = new(-100000, -100000, 200000, 200000);
|
||||
|
||||
public static SpriteBatch SpriteBatch { get; set; } = null!;
|
||||
public static Point Mouse { get; private set; }
|
||||
public static bool InputOk { get; private set; }
|
||||
public static bool Down { get; private set; }
|
||||
public static bool Click { get; private set; }
|
||||
public static bool RightClick { get; private set; }
|
||||
public static bool Shift { get; private set; }
|
||||
public static int Wheel { get; private set; }
|
||||
public static bool Blocked { get; set; }
|
||||
public static Rectangle Clip { get; private set; } = NoClip;
|
||||
|
||||
// Vanilla OverflowHidden uses the same device scissor rectangle.
|
||||
static Rectangle _gameScissor;
|
||||
public static string? Tooltip { get; set; }
|
||||
public static string? ActiveSlider { get; private set; }
|
||||
|
||||
static MouseState _state, _previous;
|
||||
static readonly RasterizerState Scissor = new() { CullMode = CullMode.None, ScissorTestEnable = true };
|
||||
static readonly Stopwatch Clock = Stopwatch.StartNew();
|
||||
|
||||
public static double Now => Clock.Elapsed.TotalSeconds;
|
||||
|
||||
// Position from Main.mouseX/Y (UI space inside a UI layer); buttons from raw XNA state.
|
||||
public static void Poll()
|
||||
{
|
||||
_previous = _state;
|
||||
_state = Microsoft.Xna.Framework.Input.Mouse.GetState();
|
||||
Mouse = new Point(Main.mouseX, Main.mouseY);
|
||||
InputOk = Main.instance.IsActive;
|
||||
Down = InputOk && _state.LeftButton == ButtonState.Pressed;
|
||||
Click = Down && _previous.LeftButton == ButtonState.Released;
|
||||
RightClick = InputOk && _state.RightButton == ButtonState.Pressed && _previous.RightButton == ButtonState.Released;
|
||||
Wheel = _state.ScrollWheelValue - _previous.ScrollWheelValue;
|
||||
|
||||
KeyboardState keyboard = Keyboard.GetState();
|
||||
Shift = keyboard.IsKeyDown(Keys.LeftShift) || keyboard.IsKeyDown(Keys.RightShift);
|
||||
|
||||
Tooltip = null;
|
||||
if (!Down)
|
||||
ActiveSlider = null;
|
||||
}
|
||||
|
||||
public static bool Hot(Rectangle bounds) => InputOk && !Blocked && Clip.Contains(Mouse) && bounds.Contains(Mouse);
|
||||
|
||||
#if TMODLOADER
|
||||
// tModLoader keeps the legacy id overload internal; the public ones return ReLogic's SlotId.
|
||||
static readonly Func<int, int, int, int, float, float, SoundEffectInstance> PlayLegacy = (Func<int, int, int, int, float, float, SoundEffectInstance>)Delegate.CreateDelegate(typeof(Func<int, int, int, int, float, float, SoundEffectInstance>), HookRegistry.Method(typeof(SoundEngine), nameof(SoundEngine.PlaySound), [typeof(int), typeof(int), typeof(int), typeof(int), typeof(float), typeof(float)]));
|
||||
|
||||
public static void Sound(int type, int x = -1, int y = -1, int style = 1) => PlayLegacy(type, x, y, style, 1f, 0f);
|
||||
#else
|
||||
public static void Sound(int type, int x = -1, int y = -1, int style = 1) => SoundEngine.PlaySound(type, x, y, style, 1f, 0f);
|
||||
#endif
|
||||
|
||||
public static void Panel(Rectangle bounds, Color background, Color? border = null)
|
||||
{
|
||||
Texture2D? back = GameTextures.Request("Images/UI/PanelBackground");
|
||||
if (back is null)
|
||||
{
|
||||
Utils.DrawInvBG(SpriteBatch, bounds, background);
|
||||
return;
|
||||
}
|
||||
|
||||
NineSlice(back, bounds, background, 12, 4);
|
||||
if (GameTextures.Request("Images/UI/PanelBorder") is { } edge)
|
||||
NineSlice(edge, bounds, border ?? Color.Black, 12, 4);
|
||||
}
|
||||
|
||||
static void NineSlice(Texture2D texture, Rectangle bounds, Color color, int corner, int bar)
|
||||
{
|
||||
bounds.Width = Math.Max(bounds.Width, corner * 2);
|
||||
bounds.Height = Math.Max(bounds.Height, corner * 2);
|
||||
int x2 = bounds.Right - corner, y2 = bounds.Bottom - corner, width = bounds.Width - corner * 2, height = bounds.Height - corner * 2, far = corner + bar;
|
||||
|
||||
SpriteBatch.Draw(texture, new Rectangle(bounds.X, bounds.Y, corner, corner), new Rectangle(0, 0, corner, corner), color);
|
||||
SpriteBatch.Draw(texture, new Rectangle(x2, bounds.Y, corner, corner), new Rectangle(far, 0, corner, corner), color);
|
||||
SpriteBatch.Draw(texture, new Rectangle(bounds.X, y2, corner, corner), new Rectangle(0, far, corner, corner), color);
|
||||
SpriteBatch.Draw(texture, new Rectangle(x2, y2, corner, corner), new Rectangle(far, far, corner, corner), color);
|
||||
SpriteBatch.Draw(texture, new Rectangle(bounds.X + corner, bounds.Y, width, corner), new Rectangle(corner, 0, bar, corner), color);
|
||||
SpriteBatch.Draw(texture, new Rectangle(bounds.X + corner, y2, width, corner), new Rectangle(corner, far, bar, corner), color);
|
||||
SpriteBatch.Draw(texture, new Rectangle(bounds.X, bounds.Y + corner, corner, height), new Rectangle(0, corner, corner, bar), color);
|
||||
SpriteBatch.Draw(texture, new Rectangle(x2, bounds.Y + corner, corner, height), new Rectangle(far, corner, corner, bar), color);
|
||||
SpriteBatch.Draw(texture, new Rectangle(bounds.X + corner, bounds.Y + corner, width, height), new Rectangle(corner, corner, bar, bar), color);
|
||||
}
|
||||
|
||||
public static void ScrollBar(string path, Rectangle bounds, Color color)
|
||||
{
|
||||
if (GameTextures.Request(path) is not { } texture)
|
||||
{
|
||||
Render.Fill(SpriteBatch, bounds, color);
|
||||
return;
|
||||
}
|
||||
|
||||
const int CapHeight = 6;
|
||||
SpriteBatch.Draw(texture, new Rectangle(bounds.X, bounds.Y, bounds.Width, CapHeight), new Rectangle(0, 0, texture.Width, CapHeight), color);
|
||||
SpriteBatch.Draw(texture, new Rectangle(bounds.X, bounds.Y + CapHeight, bounds.Width, Math.Max(0, bounds.Height - CapHeight * 2)), new Rectangle(0, CapHeight, texture.Width, 4), color);
|
||||
SpriteBatch.Draw(texture, new Rectangle(bounds.X, bounds.Bottom - CapHeight, bounds.Width, CapHeight), new Rectangle(0, texture.Height - CapHeight, texture.Width, CapHeight), color);
|
||||
}
|
||||
|
||||
// Scissor rectangles are in backbuffer pixels; ours are UI units.
|
||||
public static void BeginClip(Rectangle bounds)
|
||||
{
|
||||
GraphicsDevice graphicsDevice = Main.instance.GraphicsDevice;
|
||||
float scale = Main.UIScale;
|
||||
var pixels = new Rectangle((int)(bounds.X * scale), (int)(bounds.Y * scale), (int)Math.Ceiling(bounds.Width * scale), (int)Math.Ceiling(bounds.Height * scale));
|
||||
|
||||
SpriteBatch.End();
|
||||
_gameScissor = graphicsDevice.ScissorRectangle;
|
||||
graphicsDevice.ScissorRectangle = Rectangle.Intersect(pixels, graphicsDevice.Viewport.Bounds);
|
||||
SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.None, Scissor, null, Main.UIScaleMatrix);
|
||||
Clip = bounds;
|
||||
}
|
||||
|
||||
public static void EndClip()
|
||||
{
|
||||
SpriteBatch.End();
|
||||
|
||||
RestoreScissor();
|
||||
SpriteBatch.Begin(SpriteSortMode.Deferred, null, null, null, null, null, Main.UIScaleMatrix);
|
||||
}
|
||||
|
||||
static void RestoreScissor()
|
||||
{
|
||||
if (Clip == NoClip)
|
||||
return;
|
||||
|
||||
Main.instance.GraphicsDevice.ScissorRectangle = _gameScissor;
|
||||
Clip = NoClip;
|
||||
}
|
||||
|
||||
#if TMODLOADER
|
||||
static readonly FieldInfo? InBeginEndPair = typeof(SpriteBatch).GetField("beginCalled", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
static readonly FieldInfo? SpriteQueueCount = typeof(SpriteBatch).GetField("numSprites", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
#else
|
||||
static readonly FieldInfo? InBeginEndPair = typeof(SpriteBatch).GetField("inBeginEndPair", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
static readonly FieldInfo? SpriteQueueCount = typeof(SpriteBatch).GetField("spriteQueueCount", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
#endif
|
||||
|
||||
// A failed Flush leaves the batch begun and its sprite queue intact.
|
||||
public static void Recover(SpriteBatch spriteBatch, Matrix matrix)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (InBeginEndPair?.GetValue(spriteBatch) is true)
|
||||
{
|
||||
SpriteQueueCount?.SetValue(spriteBatch, 0);
|
||||
spriteBatch.End();
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error("Recover", e);
|
||||
}
|
||||
|
||||
RestoreScissor();
|
||||
spriteBatch.Begin(SpriteSortMode.Deferred, null, null, null, null, null, matrix);
|
||||
}
|
||||
|
||||
public static void Text(string text, Vector2 position, Color color, float scale = 0.9f) => Render.Text(SpriteBatch, text, position, color, scale);
|
||||
|
||||
public static void Separator(int x, int y, int width)
|
||||
{
|
||||
Render.Fill(SpriteBatch, new Rectangle(x, y, width, 2), Color.Black * 0.45f);
|
||||
Render.Fill(SpriteBatch, new Rectangle(x, y + 2, width, 1), Color.White * 0.08f);
|
||||
}
|
||||
|
||||
public static void Badge(int centerX, int centerY, string text)
|
||||
{
|
||||
float width = Math.Max(18, Render.Measure(text, 0.6f).X + 10);
|
||||
var bounds = new Rectangle((int)(centerX - width / 2), centerY - 9, (int)width, 18);
|
||||
Render.FillSoft(SpriteBatch, new Rectangle(bounds.X - 1, bounds.Y - 1, bounds.Width + 2, bounds.Height + 2), Color.Black * 0.8f);
|
||||
Render.FillSoft(SpriteBatch, bounds, new Color(60, 150, 60));
|
||||
Render.TextCentered(SpriteBatch, text, bounds, Color.White, 0.6f);
|
||||
}
|
||||
|
||||
public static bool Button(Rectangle bounds, string label, bool selected = false, float scale = 0.85f, bool enabled = true)
|
||||
{
|
||||
string text = Render.Ellipsize(label, scale, bounds.Width - 12);
|
||||
if (!enabled)
|
||||
{
|
||||
Panel(bounds, ButtonBg * 0.5f, Color.Black * 0.6f);
|
||||
Render.TextCentered(SpriteBatch, text, bounds, Dim * 0.5f, scale);
|
||||
if (Hot(bounds))
|
||||
Tooltip = "Only available in-game";
|
||||
return false;
|
||||
}
|
||||
|
||||
bool hot = Hot(bounds);
|
||||
bool lit = selected || hot;
|
||||
Panel(bounds, lit ? ButtonHot : ButtonBg, lit ? Yellow : Color.Black);
|
||||
Render.TextCentered(SpriteBatch, text, bounds, lit ? Yellow : Color.White, scale);
|
||||
|
||||
if (!hot || !Click)
|
||||
return false;
|
||||
|
||||
Sound(12);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void ToggleIcon(Rectangle bounds, bool on, bool hot)
|
||||
{
|
||||
Render.TextRight(SpriteBatch, on ? "On" : "Off", new Rectangle(bounds.X - 44, bounds.Y, 38, bounds.Height), on ? On : Dim * 0.75f, 0.78f);
|
||||
if (GameTextures.Request("Images/UI/Settings_Toggle") is not { } texture)
|
||||
{
|
||||
if (GameTextures.Get(on ? "InventoryTickOn" : "InventoryTickOff") is { } tick)
|
||||
SpriteBatch.Draw(tick, bounds, Color.White);
|
||||
return;
|
||||
}
|
||||
|
||||
Icon(texture, bounds, GameTextures.Frame(texture, 2, 1, on ? 1 : 0, 0), 100f, on ? On : hot ? Yellow : Color.White * 0.7f);
|
||||
}
|
||||
|
||||
public static void ImpactDot(Rectangle bounds, Impact impact)
|
||||
{
|
||||
Color color = impact switch { Impact.Negligible => On, Impact.Low => Yellow, Impact.Medium => Orange, Impact.High => Red, _ => Dim * 0.35f };
|
||||
Render.Fill(SpriteBatch, bounds, Color.Black * 0.6f);
|
||||
Render.Fill(SpriteBatch, new Rectangle(bounds.X + 1, bounds.Y + 1, bounds.Width - 2, bounds.Height - 2), color);
|
||||
}
|
||||
|
||||
public static void Slot(Rectangle bounds, bool hot, bool selected = false)
|
||||
{
|
||||
if (GameTextures.Get(selected ? "InventoryBack14" : hot ? "InventoryBack15" : "InventoryBack") is { } back)
|
||||
SpriteBatch.Draw(back, bounds, SlotTint);
|
||||
else
|
||||
Panel(bounds, ButtonBg);
|
||||
}
|
||||
|
||||
public static void Icon(Texture2D? texture, Rectangle bounds, Rectangle? frame = null, float maxScale = 1f, Color? color = null)
|
||||
{
|
||||
if (texture is null)
|
||||
return;
|
||||
|
||||
Rectangle source = frame ?? texture.Bounds;
|
||||
float scale = Math.Min(maxScale, Math.Min(bounds.Width / (float)source.Width, bounds.Height / (float)source.Height));
|
||||
var size = new Vector2(source.Width, source.Height) * scale;
|
||||
SpriteBatch.Draw(texture, new Vector2(bounds.X + (bounds.Width - size.X) / 2f, bounds.Y + (bounds.Height - size.Y) / 2f), source, color ?? Color.White, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
|
||||
}
|
||||
|
||||
public static void Field(TextField field, Rectangle bounds)
|
||||
{
|
||||
bool focused = WindowManager.Focus == field;
|
||||
if (Hot(bounds) && Click && !focused)
|
||||
{
|
||||
WindowManager.Focus = field;
|
||||
focused = true;
|
||||
Main.clrInput();
|
||||
|
||||
Sound(12);
|
||||
}
|
||||
|
||||
Panel(bounds, focused ? ButtonHot : InnerBg, focused ? Yellow : Color.Black);
|
||||
Icon(GameTextures.Request("Images/UI/Bestiary/Button_Search"), new Rectangle(bounds.X + 6, bounds.Y + 4, bounds.Height - 8, bounds.Height - 8));
|
||||
var textArea = new Rectangle(bounds.X + bounds.Height + 2, bounds.Y, bounds.Width - bounds.Height - 30, bounds.Height);
|
||||
if (field.Text.Length == 0 && !focused)
|
||||
Render.TextMid(SpriteBatch, field.Placeholder, textArea, Dim * 0.8f, 0.85f);
|
||||
else
|
||||
Render.TextMid(SpriteBatch, field.Text + (focused && (int)(Now * 2.5) % 2 == 0 ? "|" : ""), textArea, Color.White, 0.85f);
|
||||
|
||||
if (field.Text.Length == 0)
|
||||
return;
|
||||
|
||||
var clear = new Rectangle(bounds.Right - 26, bounds.Y + (bounds.Height - 20) / 2, 20, 20);
|
||||
bool clearHot = Hot(clear);
|
||||
Icon(GameTextures.Request("Images/UI/SearchCancel"), clear);
|
||||
if (clearHot)
|
||||
Render.Outline(SpriteBatch, clear, Yellow * 0.6f);
|
||||
if (clearHot && Click)
|
||||
{
|
||||
field.Text = "";
|
||||
|
||||
Sound(12);
|
||||
}
|
||||
}
|
||||
|
||||
public static float ValueBar(string id, Rectangle row, float value, float minimum, float maximum, float fallback, Utils.ColorLerpMethod? gradient = null)
|
||||
{
|
||||
const float BarScale = 0.8f;
|
||||
bool hot = Hot(row);
|
||||
float barWidth = (GameTextures.Get("ColorBar")?.Width ?? 177) * BarScale;
|
||||
IngameOptions.valuePosition = new Vector2(row.Right, row.Y + row.Height / 2f);
|
||||
bool active = ActiveSlider == id;
|
||||
int lockState = active ? 1 : !hot || ActiveSlider is not null ? 2 : 0;
|
||||
float fraction = IngameOptions.DrawValueBar(SpriteBatch, BarScale, (value - minimum) / (maximum - minimum), lockState, gradient ?? Utils.ColorLerp_BlackToWhite);
|
||||
|
||||
var bar = new Rectangle((int)(row.Right - barWidth), row.Y, (int)barWidth, row.Height);
|
||||
if (Hot(bar) && Click && ActiveSlider is null)
|
||||
{
|
||||
ActiveSlider = id;
|
||||
active = true;
|
||||
}
|
||||
|
||||
if (active && Down)
|
||||
value = MathHelper.Clamp(minimum + fraction * (maximum - minimum), minimum, maximum);
|
||||
|
||||
if (hot && RightClick)
|
||||
{
|
||||
value = fallback;
|
||||
|
||||
Sound(12);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
|
||||
namespace TerraSharp.UI;
|
||||
|
||||
public abstract class Window(string id, string title, Vector2 position, int width, int height)
|
||||
{
|
||||
public string Id { get; } = id;
|
||||
|
||||
public string Title { get; } = title;
|
||||
|
||||
public Vector2 Pos { get; set; } = position;
|
||||
|
||||
public int Width { get; } = width;
|
||||
|
||||
public int Height { get; } = height;
|
||||
|
||||
public bool Open { get; set; }
|
||||
|
||||
public Rectangle Bounds => new((int)Pos.X, (int)Pos.Y, Width, Height);
|
||||
|
||||
public abstract void DrawBody(Rectangle body);
|
||||
|
||||
public virtual bool DragHandle(Rectangle body, Point mouse) => false;
|
||||
|
||||
public virtual void OnOpen()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Terraria;
|
||||
using Terraria.GameInput;
|
||||
using TerraSharp.Core;
|
||||
|
||||
namespace TerraSharp.UI;
|
||||
|
||||
public static class WindowManager
|
||||
{
|
||||
public static readonly MainWindow Menu = new();
|
||||
public static readonly ItemBrowser Items = new();
|
||||
public static readonly BuffBrowser Buffs = new();
|
||||
|
||||
const int TitleHeight = 40;
|
||||
const int MaxInputLength = 40;
|
||||
|
||||
static readonly List<Window> Stack = [Items, Buffs, Menu];
|
||||
static Window? _dragging;
|
||||
static Vector2 _dragOffset;
|
||||
|
||||
public static TextField? Focus { get; set; }
|
||||
|
||||
public static bool MouseOver { get; private set; }
|
||||
|
||||
public static bool AnyOpen => Stack.Exists(w => w.Open);
|
||||
|
||||
public static IEnumerable<Window> All => Stack;
|
||||
|
||||
public static bool Contains(Point point) => Stack.Exists(w => w.Open && w.Bounds.Contains(point));
|
||||
|
||||
public static void Show(Window window)
|
||||
{
|
||||
if (!window.Open)
|
||||
{
|
||||
window.Open = true;
|
||||
window.OnOpen();
|
||||
Widgets.Sound(10);
|
||||
}
|
||||
|
||||
BringToFront(window);
|
||||
}
|
||||
|
||||
public static void Hide(Window window)
|
||||
{
|
||||
if (!window.Open)
|
||||
return;
|
||||
|
||||
window.Open = false;
|
||||
Widgets.Sound(11);
|
||||
Unfocus();
|
||||
}
|
||||
|
||||
public static void Toggle(Window window)
|
||||
{
|
||||
if (window.Open)
|
||||
Hide(window);
|
||||
else
|
||||
Show(window);
|
||||
}
|
||||
|
||||
public static void HideAll()
|
||||
{
|
||||
foreach (Window window in Stack)
|
||||
window.Open = false;
|
||||
Unfocus();
|
||||
}
|
||||
|
||||
static void BringToFront(Window window)
|
||||
{
|
||||
Stack.Remove(window);
|
||||
Stack.Add(window);
|
||||
}
|
||||
|
||||
public static void Unfocus()
|
||||
{
|
||||
if (Focus is null && Hotkeys.Capturing is null)
|
||||
return;
|
||||
|
||||
Focus = null;
|
||||
Hotkeys.Capturing = null;
|
||||
Main.blockInput = false;
|
||||
Main.clrInput();
|
||||
}
|
||||
|
||||
public static void ConsumeInput(Player player)
|
||||
{
|
||||
if (MouseOver)
|
||||
{
|
||||
player.mouseInterface = true;
|
||||
PlayerInput.ScrollWheelDelta = 0;
|
||||
}
|
||||
|
||||
if (Hotkeys.Capturing is not null)
|
||||
Main.blockInput = true;
|
||||
if (Focus is null)
|
||||
return;
|
||||
|
||||
Main.blockInput = true;
|
||||
PlayerInput.WritingText = true;
|
||||
Main.instance.HandleIME();
|
||||
string text = Main.GetInputText(Focus.Text, false);
|
||||
Focus.Text = text.Length > MaxInputLength ? text.Substring(0, MaxInputLength) : text;
|
||||
if (Main.inputTextEnter || Main.inputTextEscape)
|
||||
Unfocus();
|
||||
}
|
||||
|
||||
static Vector2 ClampPosition(Window window, Vector2 position) => new(MathHelper.Clamp(position.X, 0f, Math.Max(0f, Main.screenWidth / Main.UIScale - window.Width)), MathHelper.Clamp(position.Y, 0f, Math.Max(0f, Main.screenHeight / Main.UIScale - window.Height)));
|
||||
|
||||
public static void Draw(SpriteBatch spriteBatch)
|
||||
{
|
||||
Widgets.SpriteBatch = spriteBatch;
|
||||
Widgets.Poll();
|
||||
|
||||
foreach (Window window in Stack)
|
||||
window.Pos = ClampPosition(window, window.Pos);
|
||||
|
||||
Window? top = Stack.FindLast(w => w.Open && w.Bounds.Contains(Widgets.Mouse));
|
||||
MouseOver = Widgets.InputOk && top is not null;
|
||||
if (Widgets.Click)
|
||||
if (top is null)
|
||||
Unfocus();
|
||||
else
|
||||
BringToFront(top);
|
||||
|
||||
if (!Widgets.Down)
|
||||
_dragging = null;
|
||||
if (_dragging is not null)
|
||||
{
|
||||
Vector2 position = new Vector2(Widgets.Mouse.X, Widgets.Mouse.Y) + _dragOffset;
|
||||
_dragging.Pos = ClampPosition(_dragging, position);
|
||||
}
|
||||
|
||||
foreach (Window window in Stack.ToArray())
|
||||
{
|
||||
if (!window.Open)
|
||||
continue;
|
||||
|
||||
Widgets.Blocked = window != top || _dragging is not null;
|
||||
DrawFrame(window);
|
||||
}
|
||||
|
||||
Widgets.Blocked = false;
|
||||
|
||||
if (Widgets.Tooltip is not null && MouseOver)
|
||||
Main.instance.MouseText(Widgets.Tooltip, 0, 0, -1, -1, -1, -1, 0);
|
||||
}
|
||||
|
||||
static void DrawFrame(Window window)
|
||||
{
|
||||
Rectangle bounds = window.Bounds;
|
||||
Render.Fill(Widgets.SpriteBatch, new Rectangle(bounds.X + 4, bounds.Y + 4, bounds.Width - 8, bounds.Height - 8), new Color(16, 20, 40) * 0.92f);
|
||||
Widgets.Panel(bounds, Widgets.WindowBg);
|
||||
|
||||
bool hasTitle = window.Title.Length > 0;
|
||||
int bodyTop = hasTitle ? 50 : 12;
|
||||
var body = new Rectangle(bounds.X + 12, bounds.Y + bodyTop, bounds.Width - 24, bounds.Height - bodyTop - 12);
|
||||
var title = new Rectangle(bounds.X, bounds.Y, bounds.Width, TitleHeight);
|
||||
if (hasTitle)
|
||||
{
|
||||
Render.TextMid(Widgets.SpriteBatch, window.Title, new Rectangle(bounds.X + 16, bounds.Y + 2, bounds.Width, TitleHeight), Color.White, 1.0f);
|
||||
Widgets.Separator(bounds.X + 10, bounds.Y + TitleHeight, bounds.Width - 20);
|
||||
}
|
||||
|
||||
bool closeHot = false;
|
||||
if (hasTitle)
|
||||
{
|
||||
var close = new Rectangle(bounds.Right - 40, bounds.Y + 7, 28, 28);
|
||||
closeHot = Widgets.Hot(close);
|
||||
if (closeHot)
|
||||
{
|
||||
Widgets.Panel(close, Widgets.ButtonHot, Widgets.Yellow);
|
||||
Widgets.Tooltip = "Close";
|
||||
}
|
||||
Widgets.Icon(GameTextures.Request("Images/UI/SearchCancel"), new Rectangle(close.X + 4, close.Y + 4, 20, 20), null, 2f, closeHot ? Widgets.Yellow : Color.White);
|
||||
if (closeHot && Widgets.Click)
|
||||
{
|
||||
Hide(window);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bool dragHot = !closeHot && (hasTitle ? Widgets.Hot(title) : Widgets.Hot(bounds) && (!body.Contains(Widgets.Mouse) || window.DragHandle(body, Widgets.Mouse)));
|
||||
if (dragHot)
|
||||
Widgets.Tooltip = "Drag to move";
|
||||
if (dragHot && Widgets.Click)
|
||||
{
|
||||
_dragging = window;
|
||||
_dragOffset = window.Pos - new Vector2(Widgets.Mouse.X, Widgets.Mouse.Y);
|
||||
}
|
||||
|
||||
window.DrawBody(body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Terraria;
|
||||
using TerraSharp.Core;
|
||||
using TerraSharp.Features.Combat;
|
||||
using TerraSharp.UI;
|
||||
|
||||
namespace TerraSharp.Visuals;
|
||||
|
||||
public static class AimOverlay
|
||||
{
|
||||
static readonly Color Unverified = new(255, 150, 40);
|
||||
|
||||
public static void Draw(SpriteBatch spriteBatch)
|
||||
{
|
||||
DrawPreview(spriteBatch);
|
||||
if (!Settings.AimDrawTarget || !(Settings.Aimbot || Settings.AutoAttack))
|
||||
return;
|
||||
|
||||
if (Settings.Aimbot && Settings.AimFov > 0f)
|
||||
DrawRadius(spriteBatch);
|
||||
if (Aimbot.Target is { active: true } target)
|
||||
DrawTarget(spriteBatch, target);
|
||||
else if (Aimbot.Unreachable is { active: true } unreachable)
|
||||
DrawUnreachable(spriteBatch, unreachable);
|
||||
}
|
||||
|
||||
static void DrawPreview(SpriteBatch spriteBatch)
|
||||
{
|
||||
if (ShotPreview.Paths.Count == 0)
|
||||
return;
|
||||
|
||||
using var scope = Profiler.Measure(Feature.ShotPreview);
|
||||
foreach (ShotPreview.Path path in ShotPreview.Paths)
|
||||
{
|
||||
for (int i = 1; i < path.Count; i++)
|
||||
Render.Line(spriteBatch, Render.ToScreen(path.Points[i - 1]), Render.ToScreen(path.Points[i]), Color.White * (0.55f * (1f - i / (float)path.Count * 0.7f)));
|
||||
|
||||
Vector2 end = Render.ToScreen(path.Points[path.Count - 1]);
|
||||
Render.Fill(spriteBatch, new Rectangle((int)end.X - 2, (int)end.Y - 2, 5, 5), Color.White * 0.8f);
|
||||
}
|
||||
}
|
||||
|
||||
// Main.mouseX in this unscaled layer is in backbuffer pixels.
|
||||
static void DrawRadius(SpriteBatch spriteBatch)
|
||||
{
|
||||
const int Segments = 48;
|
||||
var center = new Vector2(Main.mouseX, Main.mouseY);
|
||||
Color color = (Aimbot.Firing ? Widgets.Yellow : Color.White) * 0.25f;
|
||||
Vector2 Point(int segment) => center + new Vector2((float)Math.Cos(MathHelper.TwoPi * segment / Segments), (float)Math.Sin(MathHelper.TwoPi * segment / Segments)) * Settings.AimFov;
|
||||
for (int i = 0; i < Segments; i++)
|
||||
Render.Line(spriteBatch, Point(i), Point(i + 1), color);
|
||||
}
|
||||
|
||||
static void DrawTarget(SpriteBatch spriteBatch, NPC target)
|
||||
{
|
||||
Vector2 aim = Render.ToScreen(Aimbot.AimPoint);
|
||||
if (!Aimbot.Firing)
|
||||
{
|
||||
Render.Corners(spriteBatch, Render.ToScreen(target.position) - new Vector2(5), Render.ToScreen(target.position + target.Size) + new Vector2(5), Color.White * 0.8f, 1.5f);
|
||||
Render.Fill(spriteBatch, new Rectangle((int)aim.X - 2, (int)aim.Y - 2, 4, 4), Color.White * 0.8f);
|
||||
return;
|
||||
}
|
||||
|
||||
float pulse = 6f + 3f * (float)Math.Sin(DateTime.Now.TimeOfDay.TotalSeconds * 12.0);
|
||||
Color color = Aimbot.Verified || !Settings.AimExact ? Widgets.Yellow : Unverified;
|
||||
Render.Line(spriteBatch, Render.ToScreen(Aimbot.Origin), aim, color * 0.45f);
|
||||
Render.Corners(spriteBatch, Render.ToScreen(target.position) - new Vector2(pulse), Render.ToScreen(target.position + target.Size) + new Vector2(pulse), color, 2.5f);
|
||||
Render.Fill(spriteBatch, new Rectangle((int)aim.X - 3, (int)aim.Y - 3, 7, 7), Widgets.Yellow);
|
||||
}
|
||||
|
||||
static void DrawUnreachable(SpriteBatch spriteBatch, NPC npc)
|
||||
{
|
||||
Vector2 topLeft = Render.ToScreen(npc.position) - new Vector2(5), bottomRight = Render.ToScreen(npc.position + npc.Size) + new Vector2(5);
|
||||
Render.Corners(spriteBatch, topLeft, bottomRight, Color.Gray * 0.8f, 1f);
|
||||
Vector2 center = (topLeft + bottomRight) / 2f;
|
||||
Render.Line(spriteBatch, center - new Vector2(5), center + new Vector2(5), Color.Gray, 2f);
|
||||
Render.Line(spriteBatch, center + new Vector2(-5, 5), center + new Vector2(5, -5), Color.Gray, 2f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Terraria;
|
||||
using TerraSharp.Core;
|
||||
using TerraSharp.Features;
|
||||
using TerraSharp.Features.Combat;
|
||||
using TerraSharp.Simulation;
|
||||
using TerraSharp.UI;
|
||||
|
||||
namespace TerraSharp.Visuals;
|
||||
|
||||
public static class Esp
|
||||
{
|
||||
static Vector2 _origin;
|
||||
static float _maxDistanceSq;
|
||||
static Player _player = null!;
|
||||
static readonly Vector2[] Path = new Vector2[181];
|
||||
static readonly List<(Vector2 Position, float Scale, int Life, int Max)> HealthBars = [];
|
||||
|
||||
// Stored colors carry opacity in A (straight alpha); the batch expects premultiplied.
|
||||
static Color Premultiplied(Color color) => new Color(color.R, color.G, color.B) * (color.A / 255f);
|
||||
|
||||
public static void Draw(SpriteBatch spriteBatch)
|
||||
{
|
||||
using var scope = Profiler.Measure(Feature.Esp);
|
||||
_player = Main.LocalPlayer;
|
||||
_origin = Render.ToScreen(_player.Center);
|
||||
_maxDistanceSq = Settings.EspMaxDistance > 0 ? Settings.EspMaxDistance * 16f * Settings.EspMaxDistance * 16f : float.MaxValue;
|
||||
HealthBars.Clear();
|
||||
|
||||
if (Settings.EspProjHostile || Settings.EspProjFriendly)
|
||||
DrawProjectiles(spriteBatch);
|
||||
DrawPlayers(spriteBatch);
|
||||
DrawNpcs(spriteBatch);
|
||||
if (Settings.EspItems)
|
||||
DrawItems(spriteBatch);
|
||||
if (Settings.EspWaypoints)
|
||||
foreach (Waypoint waypoint in Waypoints.Current)
|
||||
Box(spriteBatch, waypoint.Position, _player.width, _player.height, Premultiplied(Settings.ColWaypoint), waypoint.Name, -1, -1, true, true);
|
||||
}
|
||||
|
||||
static void DrawProjectiles(SpriteBatch spriteBatch)
|
||||
{
|
||||
for (int i = 0; i < Main.maxProjectiles; i++)
|
||||
{
|
||||
Projectile projectile = Main.projectile[i];
|
||||
if (!projectile.active || projectile.bobber)
|
||||
continue;
|
||||
|
||||
bool hostile = projectile.hostile;
|
||||
if (hostile ? !Settings.EspProjHostile : !(Settings.EspProjFriendly && projectile.friendly))
|
||||
continue;
|
||||
|
||||
Color color = Premultiplied(hostile ? Settings.ColProjHostile : Settings.ColProjFriendly);
|
||||
if (Box(spriteBatch, projectile.position, projectile.width, projectile.height, color, hostile ? projectile.Name : null, -1, -1, false, false) && Settings.EspProjPath)
|
||||
ProjectilePath(spriteBatch, i, projectile, color);
|
||||
}
|
||||
}
|
||||
|
||||
static void DrawPlayers(SpriteBatch spriteBatch)
|
||||
{
|
||||
for (int i = 0; i < Main.maxPlayers; i++)
|
||||
{
|
||||
Player player = Main.player[i];
|
||||
if (player is not { active: true, dead: false })
|
||||
continue;
|
||||
|
||||
bool local = i == Main.myPlayer;
|
||||
if (local ? Settings.EspLocal : Settings.EspPlayers)
|
||||
Box(spriteBatch, player.position, player.width, player.height, Premultiplied(local ? Settings.ColLocal : Settings.ColPlayers), player.name, player.statLife, player.statLifeMax2, !local, true, PlayerBar(player));
|
||||
}
|
||||
}
|
||||
|
||||
static void DrawNpcs(SpriteBatch spriteBatch)
|
||||
{
|
||||
for (int i = 0; i < Main.maxNPCs; i++)
|
||||
{
|
||||
NPC npc = Main.npc[i];
|
||||
if (!npc.active || NpcColor(npc) is not { } color)
|
||||
continue;
|
||||
|
||||
Box(spriteBatch, npc.position, npc.width, npc.height, Premultiplied(color), npc.GivenOrTypeName, npc.life, npc.lifeMax, true, true, NpcBar(npc));
|
||||
}
|
||||
}
|
||||
|
||||
static Color? NpcColor(NPC npc)
|
||||
{
|
||||
if (Hostility.IsCritter(npc))
|
||||
return Settings.EspCritters ? Settings.ColCritters : null;
|
||||
if (npc.townNPC || npc.friendly)
|
||||
return Settings.EspTown ? Settings.ColTown : null;
|
||||
if (!Settings.EspHostile)
|
||||
return null;
|
||||
|
||||
return npc.boss ? (Settings.EspBossRainbow ? Rainbow() : Settings.ColBoss) : Settings.ColHostile;
|
||||
}
|
||||
|
||||
static void DrawItems(SpriteBatch spriteBatch)
|
||||
{
|
||||
for (int i = 0; i < Main.maxItems; i++)
|
||||
{
|
||||
#if TMODLOADER
|
||||
Item drop = Main.item[i], inner = drop;
|
||||
if (!drop.active || drop.type == 0)
|
||||
continue;
|
||||
#else
|
||||
WorldItem drop = Main.item[i];
|
||||
if (drop is not { active: true, inner: { type: not 0 } inner })
|
||||
continue;
|
||||
#endif
|
||||
|
||||
string label = inner.stack > 1 ? $"{inner.Name} x{inner.stack}" : inner.Name;
|
||||
Box(spriteBatch, drop.position, drop.width, drop.height, Premultiplied(Settings.ColItems), label, -1, -1, true, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Anchors and scale match Main.DrawInterface_14_EntityHealthBars.
|
||||
static (Vector2 Position, float Scale) NpcBar(NPC npc)
|
||||
{
|
||||
Vector2 position = npc.position + npc.netOffset;
|
||||
float y = Main.HealthBarDrawSettings == 2 ? position.Y - 24f - Main.NPCAddHeight(npc) / 2f : position.Y + npc.height + 10f + Main.NPCAddHeight(npc);
|
||||
float scale = npc.type is 4 or 35 or 36 or 50 or 87 or 113 or 114 or (>= 125 and <= 131) or (>= 134 and <= 136) or 222 or (>= 245 and <= 249) or 262 or 266 or 370 or 439 or 454 or 510 or 551 or 564 or 565 or 576 or 577 or 621 ? 1.5f : 1f;
|
||||
return (new Vector2(position.X + npc.width / 2f, y + npc.gfxOffY), scale);
|
||||
}
|
||||
|
||||
static (Vector2 Position, float Scale) PlayerBar(Player player)
|
||||
{
|
||||
#if TMODLOADER
|
||||
Vector2 bottom = player.Bottom;
|
||||
#else
|
||||
Vector2 bottom = player.Bottom + player.netOffset;
|
||||
#endif
|
||||
return (new Vector2(bottom.X, bottom.Y + player.gfxOffY + (Main.HealthBarDrawSettings == 2 ? -20f : 10f)), 1f);
|
||||
}
|
||||
|
||||
public static void DrawHealthBars()
|
||||
{
|
||||
using var scope = Profiler.Measure(Feature.Esp);
|
||||
foreach ((Vector2 position, float scale, int life, int maximum) in HealthBars)
|
||||
Main.instance.DrawHealthBar(position.X, position.Y, life, maximum, 1f, scale, false);
|
||||
}
|
||||
|
||||
static void ProjectilePath(SpriteBatch spriteBatch, int slot, Projectile projectile, Color color)
|
||||
{
|
||||
if (Sandbox.Held(projectile))
|
||||
return;
|
||||
|
||||
Vector2[] path = Path;
|
||||
int from = 0;
|
||||
int count = Sandbox.LivePath(projectile, slot, Clock.Tick, out Vector2[] live, out int liveFrom);
|
||||
if (count > 0)
|
||||
{
|
||||
(path, from) = (live, liveFrom);
|
||||
}
|
||||
else
|
||||
{
|
||||
FlightModel model = Ballistics.Get(projectile.type);
|
||||
if (!Ballistics.Current(slot, out Vector2 velocity, out int age))
|
||||
(velocity, age) = (projectile.velocity * (1 + projectile.extraUpdates), 0);
|
||||
if (velocity.LengthSquared() < 0.01f)
|
||||
return;
|
||||
|
||||
int ticks = Math.Min(180, Math.Max(1, projectile.timeLeft / (1 + projectile.extraUpdates)));
|
||||
if (model.Life > 0)
|
||||
ticks = Math.Min(ticks, Math.Max(1, model.Life - age));
|
||||
count = Ballistics.Path(model, projectile.Center, velocity, age, ticks, Path);
|
||||
}
|
||||
|
||||
for (int i = from + 1; i < count; i++)
|
||||
{
|
||||
float fade = 1f - (i - from) / (float)Math.Max(1, count - from) * 0.8f;
|
||||
Render.Line(spriteBatch, Render.ToScreen(path[i - 1]), Render.ToScreen(path[i]), color * (0.75f * fade), 1.5f);
|
||||
}
|
||||
|
||||
if (count > from + 1)
|
||||
{
|
||||
Vector2 end = Render.ToScreen(path[count - 1]);
|
||||
Render.Fill(spriteBatch, new Rectangle((int)end.X - 2, (int)end.Y - 2, 4, 4), color);
|
||||
}
|
||||
}
|
||||
|
||||
public static Color Rainbow() => Main.hslToRgb((float)(DateTime.Now.TimeOfDay.TotalSeconds * 0.5 % 1.0), 1f, 0.55f, 255);
|
||||
|
||||
static bool Box(SpriteBatch spriteBatch, Vector2 position, int width, int height, Color color, string? label, int life, int lifeMax, bool tracer, bool details, (Vector2 Position, float TextScale)? healthBar = null)
|
||||
{
|
||||
Vector2 center = position + new Vector2(width / 2f, height / 2f);
|
||||
float distanceSq = Vector2.DistanceSquared(center, _player.Center);
|
||||
if (distanceSq > _maxDistanceSq)
|
||||
return false;
|
||||
|
||||
Vector2 topLeft = Render.ToScreen(position), bottomRight = Render.ToScreen(position + new Vector2(width, height));
|
||||
bool onScreen = !(bottomRight.X < 0 || bottomRight.Y < 0 || topLeft.X > Main.screenWidth || topLeft.Y > Main.screenHeight);
|
||||
if (Settings.EspLines && tracer)
|
||||
Render.Line(spriteBatch, _origin, onScreen ? new Vector2((topLeft.X + bottomRight.X) / 2f, bottomRight.Y) : (topLeft + bottomRight) / 2f, color * 0.6f);
|
||||
if (!onScreen)
|
||||
return false;
|
||||
|
||||
int thickness = Math.Max(1, (int)Settings.EspThickness);
|
||||
var bounds = new Rectangle((int)topLeft.X, (int)topLeft.Y, Math.Max(1, (int)(bottomRight.X - topLeft.X)), Math.Max(1, (int)(bottomRight.Y - topLeft.Y)));
|
||||
switch (Settings.EspStyle)
|
||||
{
|
||||
case EspBoxStyle.Corners:
|
||||
Render.Corners(spriteBatch, topLeft, bottomRight, color, Settings.EspThickness);
|
||||
break;
|
||||
case EspBoxStyle.Filled:
|
||||
Render.Fill(spriteBatch, bounds, color * 0.25f);
|
||||
Render.Outline(spriteBatch, bounds, color, thickness);
|
||||
break;
|
||||
default:
|
||||
Render.Outline(spriteBatch, bounds, color, thickness);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!details)
|
||||
return true;
|
||||
|
||||
const float TextScale = 0.7f;
|
||||
float lineHeight = Render.LineHeight(TextScale);
|
||||
float above = bounds.Y - lineHeight - 2;
|
||||
if (Settings.EspNames && !string.IsNullOrEmpty(label))
|
||||
{
|
||||
Render.TextCenteredAt(spriteBatch, label!, bounds.Center.X, above, color, TextScale);
|
||||
above -= lineHeight;
|
||||
}
|
||||
if (Settings.EspHealthText && lifeMax > 0)
|
||||
Render.TextCenteredAt(spriteBatch, $"{life} / {lifeMax}", bounds.Center.X, above, Color.Lerp(new Color(255, 90, 90), new Color(120, 235, 120), MathHelper.Clamp(life / (float)lifeMax, 0f, 1f)), TextScale);
|
||||
|
||||
float below = bounds.Bottom + 4;
|
||||
if (Settings.EspHealth && healthBar is { } bar && lifeMax > 0 && life > 0)
|
||||
{
|
||||
HealthBars.Add((bar.Position, bar.TextScale, life, lifeMax));
|
||||
if (Main.HealthBarDrawSettings != 2)
|
||||
below = Math.Max(below, Render.ToScreen(bar.Position).Y + 14f * bar.TextScale * Main.GameViewMatrix.Zoom.Y);
|
||||
}
|
||||
|
||||
// Terraria's compass and depth meter use 2 feet per tile.
|
||||
if (Settings.EspDistance)
|
||||
Render.TextCenteredAt(spriteBatch, $"{(int)(Math.Sqrt(distanceSq) / 8f)} ft", bounds.Center.X, below, color * 0.9f, TextScale);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user