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,55 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{csproj,props,targets,sln,json,yml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.cs]
|
||||||
|
max_line_length = off
|
||||||
|
|
||||||
|
csharp_style_namespace_declarations = file_scoped:warning
|
||||||
|
csharp_style_var_for_built_in_types = false:suggestion
|
||||||
|
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||||
|
csharp_style_var_elsewhere = false:suggestion
|
||||||
|
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
|
||||||
|
csharp_style_expression_bodied_properties = true:suggestion
|
||||||
|
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
|
||||||
|
csharp_style_prefer_switch_expression = true:suggestion
|
||||||
|
csharp_style_prefer_pattern_matching = true:suggestion
|
||||||
|
csharp_prefer_braces = false:silent
|
||||||
|
csharp_new_line_before_open_brace = all
|
||||||
|
dotnet_style_qualification_for_field = false:suggestion
|
||||||
|
dotnet_style_qualification_for_property = false:suggestion
|
||||||
|
dotnet_style_qualification_for_method = false:suggestion
|
||||||
|
dotnet_sort_system_directives_first = true
|
||||||
|
dotnet_style_require_accessibility_modifiers = omit_if_default:suggestion
|
||||||
|
|
||||||
|
# Static readonly fields and constants use PascalCase; other private fields use _camelCase.
|
||||||
|
dotnet_naming_rule.static_readonly_fields.symbols = static_readonly_fields
|
||||||
|
dotnet_naming_rule.static_readonly_fields.style = pascal
|
||||||
|
dotnet_naming_rule.static_readonly_fields.severity = suggestion
|
||||||
|
dotnet_naming_symbols.static_readonly_fields.applicable_kinds = field
|
||||||
|
dotnet_naming_symbols.static_readonly_fields.applicable_accessibilities = private
|
||||||
|
dotnet_naming_symbols.static_readonly_fields.required_modifiers = static, readonly
|
||||||
|
|
||||||
|
dotnet_naming_rule.constants.symbols = constants
|
||||||
|
dotnet_naming_rule.constants.style = pascal
|
||||||
|
dotnet_naming_rule.constants.severity = suggestion
|
||||||
|
dotnet_naming_symbols.constants.applicable_kinds = field
|
||||||
|
dotnet_naming_symbols.constants.required_modifiers = const
|
||||||
|
dotnet_naming_style.pascal.capitalization = pascal_case
|
||||||
|
|
||||||
|
dotnet_naming_rule.private_fields.symbols = private_fields
|
||||||
|
dotnet_naming_rule.private_fields.style = underscore_camel
|
||||||
|
dotnet_naming_rule.private_fields.severity = suggestion
|
||||||
|
dotnet_naming_symbols.private_fields.applicable_kinds = field
|
||||||
|
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
|
||||||
|
dotnet_naming_style.underscore_camel.capitalization = camel_case
|
||||||
|
dotnet_naming_style.underscore_camel.required_prefix = _
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.bat text eol=crlf
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
# Build output
|
||||||
|
artifacts/
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
run/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vs/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.rsuser
|
||||||
|
*.userprefs
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
|
# Native build
|
||||||
|
*.obj
|
||||||
|
*.pdb
|
||||||
|
*.ilk
|
||||||
|
*.exp
|
||||||
|
*.iobj
|
||||||
|
*.ipdb
|
||||||
|
|
||||||
|
# Logs and temp
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>net48;net8.0</TargetFrameworks>
|
||||||
|
<PlatformTarget Condition="'$(TargetFramework)' == 'net48'">x86</PlatformTarget>
|
||||||
|
<PlatformTarget Condition="'$(TargetFramework)' == 'net8.0'">x64</PlatformTarget>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
|
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||||
|
<Deterministic>true</Deterministic>
|
||||||
|
<UseArtifactsOutput>true</UseArtifactsOutput>
|
||||||
|
<TerrariaDir Condition="'$(TerrariaDir)' == ''">C:\Program Files (x86)\Steam\steamapps\common\Terraria</TerrariaDir>
|
||||||
|
<TModLoaderDir Condition="'$(TModLoaderDir)' == ''">C:\Program Files (x86)\Steam\steamapps\common\tModLoader</TModLoaderDir>
|
||||||
|
<XnaGac>C:\Windows\Microsoft.NET\assembly\GAC_32</XnaGac>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 leonardovac
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# TerraSharp
|
||||||
|
|
||||||
|
TSharp / T#
|
||||||
|
|
||||||
|
An internal cheat menu for Terraria and tModLoader.
|
||||||
|
|
||||||
|
<img src="https://img.shields.io/badge/Language-C%23-f44c7c?style=flat-square" alt="C#">
|
||||||
|
<img src="https://img.shields.io/badge/Platform-Windows-0078d4?style=flat-square" alt="Windows">
|
||||||
|
<img src="https://img.shields.io/badge/Terraria-1.4.5-4c9c4c?style=flat-square" alt="Terraria 1.4.5">
|
||||||
|
<img src="https://img.shields.io/badge/tModLoader-Supported-4c9c4c?style=flat-square" alt="tModLoader supported">
|
||||||
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=flat-square" alt="MIT License"></a>
|
||||||
|
|
||||||
|
<img src="assets/menu.png" alt="TerraSharp Player menu in Terraria with ESP enabled">
|
||||||
|
|
||||||
|
[Features](#features) · [Getting started](#getting-started) · [Auto-load](#auto-load) · [Acknowledgements](#acknowledgements)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- God mode, infinite health and mana, auto potions, flight and movement controls.
|
||||||
|
- Predictive aimbot, auto attack, shot preview and kill aura.
|
||||||
|
- Item and buff browsers, infinite items, instant break and auto fishing.
|
||||||
|
- ESP, health bars, projectile paths and potion vision effects.
|
||||||
|
- Time, weather and event controls, plus saved waypoints with teleportation.
|
||||||
|
|
||||||
|
Hotkeys are rebindable. Settings, ESP colors and window positions save automatically; waypoints are saved per world.
|
||||||
|
|
||||||
|
Mostly intended for single player. Changes to NPCs and the world only affect your client in multiplayer.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
To build from source, you'll need:
|
||||||
|
|
||||||
|
- Terraria or tModLoader installed. The build script skips games missing from the configured paths; tModLoader is optional.
|
||||||
|
- The .NET 8 SDK, plus the .NET Framework 4.8 Developer Pack for Terraria builds.
|
||||||
|
- Visual Studio 2022 or newer with the C++ build tools.
|
||||||
|
- XNA Framework 4.0 for Terraria builds, installed with the game.
|
||||||
|
|
||||||
|
Set `TerrariaDir` and `TModLoaderDir` in `Directory.Build.props` to your game folders. The build and auto-load scripts use these paths. Environment variables with the same names also work.
|
||||||
|
|
||||||
|
1. Run `build.bat` from the project folder. The files go into `bin\`.
|
||||||
|
2. Start Terraria or tModLoader, then run `bin\TerraSharp.Injector.exe`.
|
||||||
|
3. Press `Insert` to open the menu. You can change this on the Keys tab.
|
||||||
|
|
||||||
|
The same injector works with both games. Keep it beside the DLLs produced by the build.
|
||||||
|
|
||||||
|
## Auto-load
|
||||||
|
|
||||||
|
You can also have the menu load when the game starts. Close the game, run `autoload.bat` and choose Install.
|
||||||
|
|
||||||
|
For Terraria, the script installs the DLL and adds its AppDomainManager entries to the existing game configuration. Uninstall removes those entries and the DLL while preserving other settings. Installation stops if another AppDomainManager is already configured. A game verify/update on Steam may remove the configuration; run the installer again if the menu stops loading.
|
||||||
|
|
||||||
|
For tModLoader, the script prints a launch option to paste into Steam under **Properties > General > Launch Options**. Steam also uses `autoload.bat` as the Windows launcher: it sets `DOTNET_STARTUP_HOOKS`, preserves any existing hooks and runs Steam's game command. Keep the project folder in place while using this option. When updating from an earlier version, run Install again and replace the launch option. Remove the option to stop auto-loading. It does not appear in tModLoader's mod list.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
Settings save to `TerraSharp.json` in the game's save folder:
|
||||||
|
|
||||||
|
- Terraria: `Documents\My Games\Terraria\TerraSharp.json`
|
||||||
|
- tModLoader: `Documents\My Games\Terraria\tModLoader\TerraSharp.json`
|
||||||
|
|
||||||
|
For development with the game running, use `build-and-inject.bat`. It builds and reloads the menu, with logging to `%TEMP%\terrasharp.log`, and opens a live log viewer: gray for information, dark gray for debug messages, yellow for warnings, and red for errors. Repeated builds reuse the viewer. You can also open it with `build-and-inject.bat log`. Closing the viewer leaves the game running.
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
- [glennf](https://www.unknowncheats.me/forum/members/5262305.html) for his [GVoid Terraria External Trainer](https://www.unknowncheats.me/forum/other-mmorpg-and-strategy/756815-terraria-external-v1-4-5-6-a.html).
|
||||||
|
- [MonoMod developers](https://github.com/MonoMod/MonoMod) for RuntimeDetour, which handles the runtime hooks.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/TerraSharp/TerraSharp.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/src/TerraSharp.Injector/">
|
||||||
|
<File Path="src/TerraSharp.Injector/bootstrap-x86.asm" />
|
||||||
|
<File Path="src/TerraSharp.Injector/bootstrap-x64.asm" />
|
||||||
|
<File Path="src/TerraSharp.Injector/bootstrap.h" />
|
||||||
|
<File Path="src/TerraSharp.Injector/embed-bootstrap.ps1" />
|
||||||
|
<File Path="src/TerraSharp.Injector/injector.cpp" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/Solution Items/">
|
||||||
|
<File Path="Directory.Build.props" />
|
||||||
|
<File Path="build.bat" />
|
||||||
|
<File Path="build-and-inject.bat" />
|
||||||
|
<File Path="autoload.bat" />
|
||||||
|
<File Path="README.md" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 237 KiB |
+203
@@ -0,0 +1,203 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal DisableDelayedExpansion
|
||||||
|
set "ROOT=%~dp0"
|
||||||
|
set "DLL=%ROOT%bin\TerraSharp.Vanilla.dll"
|
||||||
|
set "HOOK=%ROOT%bin\TerraSharp.tModLoader.dll"
|
||||||
|
set "TERRASHARP_AUTOLOAD_SCRIPT=%~f0"
|
||||||
|
if not "%~1"=="" goto launcher
|
||||||
|
|
||||||
|
set "GamePathsLoaded="
|
||||||
|
for /f "usebackq tokens=1,* delims==" %%a in (`powershell -NoProfile -NonInteractive -Command "$ErrorActionPreference = 'Stop'; try { $properties = [xml](Get-Content -LiteralPath ($env:ROOT + 'Directory.Build.props') -Raw); foreach ($name in 'TerrariaDir', 'TModLoaderDir') { $value = $properties.SelectSingleNode('/Project/PropertyGroup/' + $name).InnerText; Write-Output ($name + '=' + $value) }; 'GamePathsLoaded=1' } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }"`) do if not defined %%a set "%%a=%%b"
|
||||||
|
if not defined GamePathsLoaded exit /b 1
|
||||||
|
|
||||||
|
echo TerraSharp auto-load
|
||||||
|
echo Terraria folder: "%TerrariaDir%"
|
||||||
|
echo tModLoader folder: "%TModLoaderDir%"
|
||||||
|
echo.
|
||||||
|
echo 1. Install
|
||||||
|
echo 2. Uninstall
|
||||||
|
echo 3. Exit
|
||||||
|
echo.
|
||||||
|
choice /c 123 /n /m "Choose [1-3]: "
|
||||||
|
if errorlevel 3 exit /b 0
|
||||||
|
if errorlevel 2 goto uninstall
|
||||||
|
|
||||||
|
:install
|
||||||
|
echo.
|
||||||
|
if not exist "%TerrariaDir%\Terraria.exe" goto tmodloader
|
||||||
|
if not exist "%DLL%" (echo "%DLL%" not found, run build.bat first & goto fail)
|
||||||
|
echo Terraria:
|
||||||
|
call :configure Install 1
|
||||||
|
if errorlevel 1 goto fail
|
||||||
|
copy /y "%DLL%" "%TerrariaDir%\TerraSharp.Vanilla.dll" >nul || (echo copy failed, close Terraria first & goto fail)
|
||||||
|
call :configure Install
|
||||||
|
if errorlevel 1 goto fail
|
||||||
|
echo installed. Start Terraria normally; run autoload.bat again to remove.
|
||||||
|
|
||||||
|
:tmodloader
|
||||||
|
if not exist "%TModLoaderDir%\tModLoader.dll" goto done
|
||||||
|
echo.
|
||||||
|
echo tModLoader: add this to its Steam launch options (Properties ^> General ^> Launch Options):
|
||||||
|
echo.
|
||||||
|
powershell -NoProfile -NonInteractive -Command "[Console]::WriteLine(' cmd /d /s /c ' + [char]34 + [char]34 + $env:ROOT + 'autoload.bat' + [char]34 + ' %%command%%' + [char]34)"
|
||||||
|
echo.
|
||||||
|
if not exist "%HOOK%" echo (build the tModLoader DLL first: build.bat)
|
||||||
|
goto done
|
||||||
|
|
||||||
|
:uninstall
|
||||||
|
echo.
|
||||||
|
if not defined TerrariaDir goto removeHook
|
||||||
|
call :configure Uninstall
|
||||||
|
if errorlevel 1 goto fail
|
||||||
|
del /q "%TerrariaDir%\TerraSharp.Vanilla.dll" 2>nul
|
||||||
|
if exist "%TerrariaDir%\TerraSharp.Vanilla.dll" (echo Terraria: TerraSharp.Vanilla.dll in use, close Terraria first & goto fail)
|
||||||
|
echo Terraria auto-load removed.
|
||||||
|
:removeHook
|
||||||
|
if defined TModLoaderDir echo tModLoader: remove the TerraSharp launcher from its Steam launch options.
|
||||||
|
goto done
|
||||||
|
|
||||||
|
:fail
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:done
|
||||||
|
pause
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:configure
|
||||||
|
set "TERRASHARP_CONFIG_MODE=%~1"
|
||||||
|
set "TERRASHARP_CONFIG_PATH=%TerrariaDir%\Terraria.exe.config"
|
||||||
|
set "TERRASHARP_CONFIG_VALIDATE=%~2"
|
||||||
|
powershell -NoProfile -NonInteractive -Command "$source = [IO.File]::ReadAllText($env:TERRASHARP_AUTOLOAD_SCRIPT); & ([scriptblock]::Create($source.Substring($source.LastIndexOf('# PowerShell')))) -Mode $env:TERRASHARP_CONFIG_MODE -ConfigPath $env:TERRASHARP_CONFIG_PATH -ValidateOnly:($env:TERRASHARP_CONFIG_VALIDATE -eq '1')"
|
||||||
|
exit /b %errorlevel%
|
||||||
|
|
||||||
|
:launcher
|
||||||
|
if not exist "%HOOK%" (echo TerraSharp: "%HOOK%" not found, run build.bat first & exit /b 1)
|
||||||
|
if not defined DOTNET_STARTUP_HOOKS (
|
||||||
|
set "DOTNET_STARTUP_HOOKS=%HOOK%"
|
||||||
|
goto launch
|
||||||
|
)
|
||||||
|
for %%h in ("%DOTNET_STARTUP_HOOKS:;=" "%") do if /i "%%~h"=="%HOOK%" goto launch
|
||||||
|
set "DOTNET_STARTUP_HOOKS=%DOTNET_STARTUP_HOOKS%;%HOOK%"
|
||||||
|
|
||||||
|
:launch
|
||||||
|
%*
|
||||||
|
exit /b %errorlevel%
|
||||||
|
|
||||||
|
# PowerShell
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateSet('Install', 'Uninstall')]
|
||||||
|
[string]$Mode,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$ConfigPath,
|
||||||
|
[switch]$ValidateOnly
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$managerType = 'TerraSharp.LoaderDomainManager'
|
||||||
|
$assemblyName = 'TerraSharp.Vanilla'
|
||||||
|
$assemblyIdentity = 'TerraSharp.Vanilla, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null'
|
||||||
|
$ConfigPath = [IO.Path]::GetFullPath($ConfigPath)
|
||||||
|
$document = New-Object Xml.XmlDocument
|
||||||
|
$document.PreserveWhitespace = $true
|
||||||
|
$document.XmlResolver = $null
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ([IO.File]::Exists($ConfigPath)) {
|
||||||
|
$readerSettings = New-Object Xml.XmlReaderSettings
|
||||||
|
$readerSettings.DtdProcessing = [Xml.DtdProcessing]::Prohibit
|
||||||
|
$reader = [Xml.XmlReader]::Create($ConfigPath, $readerSettings)
|
||||||
|
try { $document.Load($reader) } finally { $reader.Dispose() }
|
||||||
|
} elseif ($Mode -eq 'Uninstall') {
|
||||||
|
exit 0
|
||||||
|
} else {
|
||||||
|
$document.LoadXml('<?xml version="1.0" encoding="utf-8"?>' + "`r`n<configuration>`r`n</configuration>")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($document.DocumentElement.Name -ne 'configuration' -or $document.DocumentElement.NamespaceURI) {
|
||||||
|
throw 'Expected a configuration root element.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$runtimeNodes = $document.SelectNodes('/configuration/runtime')
|
||||||
|
if ($runtimeNodes.Count -gt 1) {
|
||||||
|
throw 'The configuration contains multiple runtime sections.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$runtime = $document.SelectSingleNode('/configuration/runtime')
|
||||||
|
$typeNodes = @($document.SelectNodes('/configuration/runtime/appDomainManagerType'))
|
||||||
|
$assemblyNodes = @($document.SelectNodes('/configuration/runtime/appDomainManagerAssembly'))
|
||||||
|
|
||||||
|
if ($Mode -eq 'Install') {
|
||||||
|
foreach ($typeNode in $typeNodes) {
|
||||||
|
if ($typeNode.GetAttribute('value') -cne $managerType) {
|
||||||
|
throw 'Another AppDomainManager is configured; its entries were left unchanged.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($assemblyNode in $assemblyNodes) {
|
||||||
|
if ($assemblyNode.GetAttribute('value').Split(',')[0].Trim() -ine $assemblyName) {
|
||||||
|
throw 'Another AppDomainManager assembly is configured; its entries were left unchanged.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($typeNodes.Count -gt 1 -or $assemblyNodes.Count -gt 1) {
|
||||||
|
throw 'The configuration contains duplicate AppDomainManager entries.'
|
||||||
|
}
|
||||||
|
if ($ValidateOnly) {
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed = $false
|
||||||
|
if ($null -eq $runtime) {
|
||||||
|
$runtime = $document.CreateElement('runtime')
|
||||||
|
[void]$document.DocumentElement.AppendChild($runtime)
|
||||||
|
$changed = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($entry in @(@('appDomainManagerType', $managerType), @('appDomainManagerAssembly', $assemblyIdentity))) {
|
||||||
|
$element = $runtime.SelectSingleNode($entry[0])
|
||||||
|
if ($null -eq $element) {
|
||||||
|
$element = $document.CreateElement($entry[0])
|
||||||
|
[void]$runtime.AppendChild($element)
|
||||||
|
}
|
||||||
|
if ($element.GetAttribute('value') -cne $entry[1]) {
|
||||||
|
$element.SetAttribute('value', $entry[1])
|
||||||
|
$changed = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$changed = $false
|
||||||
|
foreach ($typeNode in $typeNodes) {
|
||||||
|
if ($typeNode.GetAttribute('value') -ceq $managerType) {
|
||||||
|
[void]$runtime.RemoveChild($typeNode)
|
||||||
|
$changed = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($assemblyNode in $assemblyNodes) {
|
||||||
|
if ($assemblyNode.GetAttribute('value').Split(',')[0].Trim() -ieq $assemblyName) {
|
||||||
|
[void]$runtime.RemoveChild($assemblyNode)
|
||||||
|
$changed = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $changed) {
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$temporaryPath = Join-Path ([IO.Path]::GetDirectoryName($ConfigPath)) ([Guid]::NewGuid().ToString('N') + '.tmp')
|
||||||
|
try {
|
||||||
|
$document.Save($temporaryPath)
|
||||||
|
if ([IO.File]::Exists($ConfigPath)) {
|
||||||
|
[IO.File]::Replace($temporaryPath, $ConfigPath, [NullString]::Value)
|
||||||
|
} else {
|
||||||
|
[IO.File]::Move($temporaryPath, $ConfigPath)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if ([IO.File]::Exists($temporaryPath)) {
|
||||||
|
[IO.File]::Delete($temporaryPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
[Console]::Error.WriteLine('TerraSharp configuration: ' + $_.Exception.Message)
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
@echo off
|
||||||
|
rem Builds into run\<stamp>\ under a unique assembly name and injects it into whichever of Terraria or tModLoader is running: the game locks the injected DLL and binds simple-named assemblies by name only.
|
||||||
|
setlocal DisableDelayedExpansion
|
||||||
|
set "ROOT=%~dp0"
|
||||||
|
if /i "%~1"=="log" goto log
|
||||||
|
|
||||||
|
set "STAMP=%DATE%%TIME%"
|
||||||
|
for %%c in ("/" ":" "." "," "-" " ") do call set "STAMP=%%STAMP:%%~c=%%"
|
||||||
|
set "OUT=%ROOT%run\%STAMP%"
|
||||||
|
|
||||||
|
set "GamePathsLoaded="
|
||||||
|
for /f "usebackq tokens=1,* delims==" %%a in (`powershell -NoProfile -NonInteractive -Command "$ErrorActionPreference = 'Stop'; try { $properties = [xml](Get-Content -LiteralPath ($env:ROOT + 'Directory.Build.props') -Raw); foreach ($name in 'TerrariaDir', 'TModLoaderDir') { $value = $properties.SelectSingleNode('/Project/PropertyGroup/' + $name).InnerText; Write-Output ($name + '=' + $value) }; 'GamePathsLoaded=1' } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }"`) do if not defined %%a set "%%a=%%b"
|
||||||
|
if not defined GamePathsLoaded exit /b 1
|
||||||
|
|
||||||
|
if not exist "%TerrariaDir%\Terraria.exe" (echo Terraria not found at the configured path, skipping its build & goto tmodloader)
|
||||||
|
echo Terraria: "%TerrariaDir%"
|
||||||
|
dotnet build "%ROOT%src\TerraSharp\TerraSharp.csproj" -c Release -f net48 -o "%OUT%" "-p:TerrariaDir=%TerrariaDir%" -p:AssemblyName=TerraSharp.Vanilla_%STAMP% -p:TerraSharpLogging=true || exit /b 1
|
||||||
|
|
||||||
|
:tmodloader
|
||||||
|
if not exist "%TModLoaderDir%\tModLoader.dll" (echo tModLoader not found at the configured path, skipping its build & goto inject)
|
||||||
|
echo tModLoader: "%TModLoaderDir%"
|
||||||
|
dotnet build "%ROOT%src\TerraSharp\TerraSharp.csproj" -c Release -f net8.0 -o "%OUT%" "-p:TModLoaderDir=%TModLoaderDir%" -p:AssemblyName=TerraSharp.tModLoader_%STAMP% -p:TerraSharpLogging=true || exit /b 1
|
||||||
|
|
||||||
|
:inject
|
||||||
|
if not exist "%TerrariaDir%\Terraria.exe" if not exist "%TModLoaderDir%\tModLoader.dll" (echo Set TerrariaDir or TModLoaderDir in Directory.Build.props to an installed game & exit /b 1)
|
||||||
|
call "%ROOT%build.bat" injector || exit /b 1
|
||||||
|
start "TerraSharp log" "%ComSpec%" /d /c ""%~f0" log"
|
||||||
|
"%ROOT%bin\TerraSharp.Injector.exe" "%OUT%"
|
||||||
|
exit /b %errorlevel%
|
||||||
|
|
||||||
|
:log
|
||||||
|
set "TERRASHARP_LOG_SCRIPT=%~f0"
|
||||||
|
powershell -NoProfile -Command "$source = Get-Content -LiteralPath $env:TERRASHARP_LOG_SCRIPT -Raw; & ([scriptblock]::Create(($source -split '(?m)^# TerraSharp log viewer\r?$', 2)[1]))"
|
||||||
|
exit /b %errorlevel%
|
||||||
|
|
||||||
|
# TerraSharp log viewer
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$createdNew = $false
|
||||||
|
$viewerMutex = [System.Threading.Mutex]::new($true, 'Local\TerraSharp.LogViewer', [ref]$createdNew)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (-not $createdNew) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$Host.UI.RawUI.WindowTitle = 'TerraSharp log'
|
||||||
|
$logPath = Join-Path ([System.IO.Path]::GetTempPath()) 'terrasharp.log'
|
||||||
|
Write-Host "Following $logPath (Ctrl+C to stop)" -ForegroundColor Gray
|
||||||
|
while (-not (Test-Path -LiteralPath $logPath -PathType Leaf)) {
|
||||||
|
Start-Sleep -Milliseconds 200
|
||||||
|
}
|
||||||
|
|
||||||
|
$color = 'Gray'
|
||||||
|
Get-Content -LiteralPath $logPath -Encoding UTF8 -Tail 30 -Wait | ForEach-Object {
|
||||||
|
if ($_ -match '^\d{2}:\d{2}:\d{2}(?:\.\d{3})? (?:\[(DEBUG|INFO|WARNING|ERROR)\] )?') {
|
||||||
|
$color = switch ($Matches[1]) {
|
||||||
|
'DEBUG' { 'DarkGray' }
|
||||||
|
'INFO' { 'Gray' }
|
||||||
|
'WARNING' { 'Yellow' }
|
||||||
|
'ERROR' { 'Red' }
|
||||||
|
default { 'Gray' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host $_ -ForegroundColor $color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ($createdNew) {
|
||||||
|
$viewerMutex.ReleaseMutex()
|
||||||
|
}
|
||||||
|
$viewerMutex.Dispose()
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
@echo off
|
||||||
|
rem Builds the configured game targets and the injector into bin\.
|
||||||
|
setlocal DisableDelayedExpansion
|
||||||
|
set "ROOT=%~dp0"
|
||||||
|
set "OUT=%ROOT%bin"
|
||||||
|
set "OBJ=%ROOT%artifacts\obj\native"
|
||||||
|
set "SRC=%ROOT%src\TerraSharp.Injector"
|
||||||
|
set "NATIVE_ARCH="
|
||||||
|
|
||||||
|
if /i "%~1"=="injector" goto native
|
||||||
|
set "GamePathsLoaded="
|
||||||
|
for /f "usebackq tokens=1,* delims==" %%a in (`powershell -NoProfile -NonInteractive -Command "$ErrorActionPreference = 'Stop'; try { $properties = [xml](Get-Content -LiteralPath ($env:ROOT + 'Directory.Build.props') -Raw); foreach ($name in 'TerrariaDir', 'TModLoaderDir') { $value = $properties.SelectSingleNode('/Project/PropertyGroup/' + $name).InnerText; Write-Output ($name + '=' + $value) }; 'GamePathsLoaded=1' } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }"`) do if not defined %%a set "%%a=%%b"
|
||||||
|
if not defined GamePathsLoaded exit /b 1
|
||||||
|
|
||||||
|
if not exist "%TerrariaDir%\Terraria.exe" (echo Terraria not found at the configured path, skipping its build & goto tmodloader)
|
||||||
|
echo Terraria: "%TerrariaDir%"
|
||||||
|
dotnet build "%ROOT%src\TerraSharp\TerraSharp.csproj" -c Release -f net48 -o "%OUT%" "-p:TerrariaDir=%TerrariaDir%" || exit /b 1
|
||||||
|
|
||||||
|
:tmodloader
|
||||||
|
if not exist "%TModLoaderDir%\tModLoader.dll" (echo tModLoader not found at the configured path, skipping its build & goto native)
|
||||||
|
echo tModLoader: "%TModLoaderDir%"
|
||||||
|
dotnet build "%ROOT%src\TerraSharp\TerraSharp.csproj" -c Release -f net8.0 -o "%OUT%" "-p:TModLoaderDir=%TModLoaderDir%" || exit /b 1
|
||||||
|
|
||||||
|
:native
|
||||||
|
if /i not "%~1"=="injector" if not exist "%TerrariaDir%\Terraria.exe" if not exist "%TModLoaderDir%\tModLoader.dll" (echo Set TerrariaDir or TModLoaderDir in Directory.Build.props to an installed game & exit /b 1)
|
||||||
|
set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||||
|
for /f "usebackq delims=" %%i in (`call "%VSWHERE%" -latest -products * -property installationPath`) do set "VS=%%i"
|
||||||
|
if not defined VS (echo Visual Studio not found & exit /b 1)
|
||||||
|
if not exist "%OUT%" mkdir "%OUT%"
|
||||||
|
call :assemble || exit /b 1
|
||||||
|
call :assembleX64 || exit /b 1
|
||||||
|
call :embed || exit /b 1
|
||||||
|
call :compile || exit /b 1
|
||||||
|
del /q "%OUT%\TerraSharp.Bootstrap.x86.dll" "%OUT%\TerraSharp.Bootstrap.x64.dll" "%OUT%\TerraSharp.tModLoader.runtimeconfig.json" 2>nul
|
||||||
|
echo Built into %OUT%
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:assemble
|
||||||
|
call :check "%OBJ%\x86\bootstrap.obj" "%SRC%\bootstrap-x86.asm"
|
||||||
|
if errorlevel 2 exit /b 1
|
||||||
|
if not errorlevel 1 (echo x86 bootstrap is up to date & exit /b 0)
|
||||||
|
|
||||||
|
call :setup x86 || exit /b 1
|
||||||
|
if not exist "%OBJ%\x86" mkdir "%OBJ%\x86"
|
||||||
|
ml /nologo /c /Fo"%OBJ%\x86\bootstrap.obj" "%SRC%\bootstrap-x86.asm" || (del /q "%OBJ%\x86\bootstrap.obj" 2>nul & exit /b 1)
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:assembleX64
|
||||||
|
call :check "%OBJ%\x64\bootstrap.obj" "%SRC%\bootstrap-x64.asm"
|
||||||
|
if errorlevel 2 exit /b 1
|
||||||
|
if not errorlevel 1 (echo x64 bootstrap is up to date & exit /b 0)
|
||||||
|
|
||||||
|
call :setup x64 || exit /b 1
|
||||||
|
if not exist "%OBJ%\x64" mkdir "%OBJ%\x64"
|
||||||
|
ml64 /nologo /c /Fo"%OBJ%\x64\bootstrap.obj" "%SRC%\bootstrap-x64.asm" || (del /q "%OBJ%\x64\bootstrap.obj" 2>nul & exit /b 1)
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:embed
|
||||||
|
call :check "%OBJ%\bootstrap.generated.h" "%OBJ%\x86\bootstrap.obj" "%OBJ%\x64\bootstrap.obj" "%SRC%\embed-bootstrap.ps1"
|
||||||
|
if errorlevel 2 exit /b 1
|
||||||
|
if not errorlevel 1 (echo Embedded bootstrap header is up to date & exit /b 0)
|
||||||
|
|
||||||
|
powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%SRC%\embed-bootstrap.ps1" -X86Object "%OBJ%\x86\bootstrap.obj" -X64Object "%OBJ%\x64\bootstrap.obj" -Output "%OBJ%\bootstrap.generated.h" || (del /q "%OBJ%\bootstrap.generated.h" 2>nul & exit /b 1)
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:compile
|
||||||
|
call :check "%OUT%\TerraSharp.Injector.exe" "%SRC%\injector.cpp" "%SRC%\bootstrap.h" "%OBJ%\bootstrap.generated.h"
|
||||||
|
if errorlevel 2 exit /b 1
|
||||||
|
if not errorlevel 1 (echo Injector is up to date & exit /b 0)
|
||||||
|
|
||||||
|
call :setup x64 || exit /b 1
|
||||||
|
if not exist "%OBJ%\x64" mkdir "%OBJ%\x64"
|
||||||
|
set "FLAGS=/nologo /W4 /O2 /EHsc /std:c++17 /MT /DUNICODE /D_UNICODE /I"%OBJ%" /Fo"%OBJ%\x64/""
|
||||||
|
cl %FLAGS% "%SRC%\injector.cpp" /Fe"%OUT%\TerraSharp.Injector.exe" /link mscoree.lib || (del /q "%OUT%\TerraSharp.Injector.exe" 2>nul & exit /b 1)
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:check
|
||||||
|
setlocal
|
||||||
|
set "BUILD_CHECK_OUTPUT=%~1"
|
||||||
|
set "BUILD_CHECK_INPUTS=%ROOT%build.bat"
|
||||||
|
shift
|
||||||
|
|
||||||
|
:checkInput
|
||||||
|
if "%~1"=="" goto checkTimestamps
|
||||||
|
set "BUILD_CHECK_INPUTS=%BUILD_CHECK_INPUTS%|%~1"
|
||||||
|
shift
|
||||||
|
goto checkInput
|
||||||
|
|
||||||
|
:checkTimestamps
|
||||||
|
rem Exit codes: 0 up to date, 1 rebuild, 2 check failed.
|
||||||
|
powershell -NoProfile -NonInteractive -Command "$ErrorActionPreference = 'Stop'; try { $inputFiles = @($env:BUILD_CHECK_INPUTS.Split('|') | ForEach-Object { Get-Item -LiteralPath $_ }); if (-not (Test-Path -LiteralPath $env:BUILD_CHECK_OUTPUT -PathType Leaf)) { exit 1 }; $outputTime = (Get-Item -LiteralPath $env:BUILD_CHECK_OUTPUT).LastWriteTimeUtc; foreach ($inputFile in $inputFiles) { if ($inputFile.LastWriteTimeUtc -gt $outputTime) { exit 1 } }; exit 0 } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 2 }"
|
||||||
|
endlocal & exit /b %errorlevel%
|
||||||
|
|
||||||
|
:setup
|
||||||
|
if "%NATIVE_ARCH%"=="%~1" exit /b 0
|
||||||
|
call "%VS%\VC\Auxiliary\Build\vcvarsall.bat" %~1 >nul 2>&1 || exit /b 1
|
||||||
|
set "NATIVE_ARCH=%~1"
|
||||||
|
exit /b 0
|
||||||
@@ -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