Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New Hack: PortalESP (fixes #776) #835

Merged
merged 31 commits into from Dec 15, 2023
Merged

Conversation

DrMaxNix
Copy link
Contributor

@DrMaxNix DrMaxNix commented Jun 8, 2023

Description

This PR adds a new hack: PortalESP. It highlights nether portals, end portals, end portal frames and end gateways in a similar way that ChestESP does. It is highly customizable and tuned for efficiency.

Screenshots

Screenshot from 2023-06-08 15-10-46
Screenshot from 2023-06-08 15-11-49
Screenshot from 2023-06-08 15-11-54

Summary by CodeRabbit

  • New Features

    • Introduced the PortalEspHack for enhanced in-game portal visibility, allowing players to see different types of portals with customizable colors and styles.
    • Added a new rendering system for the PortalEspHack that includes solid and outlined boxes, and connecting lines for a better visual experience.
    • Implemented a multithreaded chunk search functionality to efficiently locate portal blocks within the game environment.
  • Enhancements

    • Expanded the HackList with the addition of the PortalEspHack to offer players new capabilities.

Please note that these changes are part of a gaming modification and should be used in accordance with the game's terms of service.

@JAXPLE
Copy link
Contributor

JAXPLE commented Jun 13, 2023

sexy

@DrMaxNix
Copy link
Contributor Author

@Alexander01998 please review this in the near future. A lot of people are waiting for this feature, it was also voted for multiple times in the poll for new features. PortalESP has been FULLY implemented and tested by this PR, the only thing left to make this feature a thing in the Wurst Client is a merge of this PR! Please review this ASAP as longer wait time directly leads to possible merge conflicts.

@Alexander01998 Alexander01998 added type:enhancement New feature or request type:new feature A new hack/command/etc. category:render labels Sep 3, 2023
Copy link
Member

@Alexander01998 Alexander01998 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @DrMaxNix,

Sorry for the late reply! A lot was happening when you submitted this and I'm just catching up on older pull requests. Luckily there was only a minor conflict caused by a Minecraft update, which was easy to fix.

More importantly, while examining your PR, I found that it duplicates a lot of code from Search and ChestESP. While this may not affect the functionality right now, maintaining two slightly different versions of the same code could become a problem in the future, as every change would have to be made twice and it's not obvious where the differences are.

Your PortalESP hack definitely seems like a great feature to have, but it's essential that it's integrated in a way that doesn't undermine the rest of the codebase. Ideally these common functionalities should be moved into utility or parent classes, which can then be extended or reused by both the existing modules and your new PortalESP. Unfortunately, that means I can't merge the pull request in its current state because it's going to need a fairly large refactoring first.

Please have patience as I work on integrating your changes into the main codebase in a sustainable and efficient way. If you're interested in helping out with the refactoring process, just let me know.

Thanks for your understanding and for your contributions so far.

@DrMaxNix
Copy link
Contributor Author

DrMaxNix commented Sep 5, 2023

Heyo,
While developing the PortalESP hack, I wasn't sure how much I was allowed to change the existing code base so I tried to touch as little as possible.. Even though the hack uses a lot of lines from other hacks, there have been many performance improvements and bug fixes which are currently only present in the PortalESP version of the duplicate code parts. These should be adapted for all other places where the same code is being used.
It should also be noted that there are countless PRs which already suggest creating a unified codebase for search- and ESP-based hacks (some of them too old though).
If you think the project would benefit from my help, I'd be glad to help out! ^^

@Alexander01998
Copy link
Member

Hey DrMaxNix,

I appreciate your willingness to help! As you can see, the wheels are already in motion to integrate your additions into the codebase. The refactoring should eventually lead to a unified system for Search-like hacks, once all the edge cases have been worked out.

It would be great if you could explain each of the improvements and fixes that you've made in the duplicate code parts. Alternatively, you could also document these changes within the code by adding comments.

This would provide context and help me better understand your changes. Right now these changes can be easy to miss amongst all the unchanged code and sometimes it isn't entirely clear to me why certain changes were made.

One such example is the line itr.remove(); in PortalEspHack.replaceSearchersWithChunkUpdate(). I noticed it's been moved up compared to Search but I'm not sure why. Could you elaborate on what that change accomplishes?

Looking forward to your input on this.

@DrMaxNix
Copy link
Contributor Author

DrMaxNix commented Sep 6, 2023

Sure! I can't remember all of the improvements because it is more than 3 months now that I've written that code.. But I'll go through everything an try to document all the improvements as good as I can!

  1. The itr.remove(); has been moved up because in the original code when the continue was hit the processed items would not get removed from the queue.. This meant that the chunksToUpdate got bigger and bigger (as it is always appended to and never cleared) and after some playtime (especially when moving or changing dimensions a lot) the game would lag A LOT

  2. Another improvement I still can remember is to index searchers by ChunkPos instead of Chunk. When flying away from a chunk the chunk object is destroyed and when returning to the chunk a new chunk object is created for the chunk. This means when comparing the old chunk to the new one they will be different despite having the same position. This lead to multiple active searchers for the same chunk and eventually a lot of lag.

  3. Third thing I still know: I added a dimension check searcherDimensionId == dimensionId which removes searchers which were meant for a different dimension. Before this when changing dimensions a lot you'd end up with multiple searchers for one chunk which lead to lag. I know there already was a check for this in replaceSearchersWithDifferences(), but it didn't work 100% when the player is in the spawn-chunks (probably because they are always loaded and thus don't generate a block/chunk update).

  4. The SearchArea, *EspRender and *EspStyle classes have not been changed AT ALL.

  5. The abstract *EspGroup class has been merged with the *EspBlockGroup class as the PortalESP hack only works on blocks and does not need a second *EspEntityGroup. Makes the code a bit simpler but is probably not required when refactoring.

  6. Instead of sorting found blocks to *BlockGroup classes via a lot of nested if-cases (https://github.com/Wurst-Imperium/Wurst7/blob/master/src/main/java/net/wurstclient/hacks/ChestEspHack.java#L163), I am storing the block type with the PortalEspBlockGroup object and later just use a .parallelStream().filter() to sort the found blocks to their block group (https://github.com/DrMaxNix/Wurst7/blob/master/src/main/java/net/wurstclient/hacks/PortalEspHack.java#L352). I mainly did this to make the code more readable and allow for later additions to be added easier. You could probably even use a parallelStream to get rid of the for(Result result : results) loop and improve performance even more..

These are all the changes I could find by looking through the code again.. In case I missed a change: generally speaking EVERY change I made to snippets used from the existing codebase have a reason! (Why should I change stuff that's already working..?) I still remember about a year ago I had a lot of minecraft crashes when using portals a lot.. These crashes are probably caused by all the previously mentioned bugs and fixing them for all the other hacks would be a HUGE performance boost.. Those optimizations even allow you to increase the chunk radius by quite a lot without the game starting to lag :)

@Alexander01998 Alexander01998 linked an issue Sep 16, 2023 that may be closed by this pull request
@coderabbitai
Copy link

coderabbitai bot commented Sep 17, 2023

Walkthrough

The update introduces a new feature to the Wurst Client, a Minecraft hack client, by adding the PortalEspHack. This hack allows players to visually detect and customize the display of portal blocks (like Nether or End portals) within the game. It includes multithreading for efficient searching of portal blocks in chunks, rendering enhancements, and settings for portal types and colors. The changes are encapsulated in new classes for managing, rendering, and searching portal ESPs.

Changes

File Path Change Summary
.../hack/HackList.java Added PortalEspHack to the list of hacks.
.../hacks/PortalEspHack.java Introduced new PortalEspHack class with settings, event listeners, and rendering for portal ESP.
.../hacks/portalesp/... Added new classes PortalEspBlockGroup and PortalEspRenderer for managing and rendering portal ESPs.
.../util/ChunkSearcherMulti.java Created new ChunkSearcherMulti class for multithreaded portal block searching in chunks.

🐇🌟
In the code's woven warren, a new feature hops in sight,
Portals glow with colors bright, through the Minecraft night.
As autumn leaves do twirl and spin, this update takes its flight,
With threads and buffers, blocks alight, the Wurst now sees the light. 🍂✨


Tips

Chat with CodeRabbit Bot (@coderabbitai)

  • If you reply to a review comment from CodeRabbit, the bot will automatically respond.
  • To engage with CodeRabbit bot directly around the specific lines of code in the PR, mention @coderabbitai in your review comment
  • Note: Review comments are made on code diffs or files, not on the PR overview.
  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.

CodeRabbit Commands (invoked as PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger a review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai help to get help.

Note: For conversation with the bot, please use the review comments on code diffs or files.

CodeRabbit Configration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • The JSON schema for the configuration file is available here.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/coderabbit-overrides.v2.json

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Status

Actionable comments generated: 8

Commits Files that changed from the base of the PR and between 26c65a6 and c1e5b87 commits.
Files ignored due to filter (1)
  • src/main/resources/assets/wurst/lang/en_us.json
Files selected for processing (5)
  • src/main/java/net/wurstclient/hack/HackList.java (1 hunks)
  • src/main/java/net/wurstclient/hacks/PortalEspHack.java (1 hunks)
  • src/main/java/net/wurstclient/hacks/portalesp/PortalEspBlockGroup.java (1 hunks)
  • src/main/java/net/wurstclient/hacks/portalesp/PortalEspRenderer.java (1 hunks)
  • src/main/java/net/wurstclient/util/ChunkSearcherMulti.java (1 hunks)
Files skipped from review due to trivial changes (1)
  • src/main/java/net/wurstclient/hack/HackList.java
Additional comments (Suppressed): 9
src/main/java/net/wurstclient/hacks/PortalEspHack.java (3)
  • 106-117: Ensure that the thread pool is properly initialized and that there are no potential race conditions or deadlocks. Also, consider handling exceptions that may occur during the execution of tasks in the thread pool.

  • 120-131: When disabling the feature, ensure that all resources are properly cleaned up to prevent memory leaks. This includes shutting down the executor service, clearing collections, and closing buffers.

  • 210-241: Ensure that OpenGL settings are correctly set and reset before and after rendering. Incorrect settings can lead to visual glitches or performance issues.

src/main/java/net/wurstclient/hacks/portalesp/PortalEspBlockGroup.java (4)
  • 31-37: Ensure that the enabled CheckboxSetting is not null when passed to this constructor. If it's null, the isEnabled() method will always return true, which might not be the expected behavior.

  • 48-54: The getBox method returns null if the block at pos cannot be clicked. Ensure that all callers of this method handle the null return value appropriately to avoid potential NullPointerExceptions.

  • 61-64: If enabled is null, this method will return true. Make sure this is the intended behavior and that there are no cases where enabled should be checked but is null.

  • 81-84: This method returns an unmodifiable view of the boxes list. Any attempt to modify the returned list will result in an UnsupportedOperationException. Ensure that all callers of this method are aware of this and do not attempt to modify the returned list.

src/main/java/net/wurstclient/hacks/portalesp/PortalEspRenderer.java (2)
  • 33-34: Ensure that the static VertexBuffer objects solidBox and outlinedBox are thread-safe. If multiple threads access these objects simultaneously, it could lead to data races or inconsistent states.

  • 108-123: The prepareBuffers() and closeBuffers() methods are public and static, which means they can be called from anywhere in your codebase. This could potentially lead to issues if they're called while the buffers are being used for rendering. Consider adding synchronization or other concurrency control mechanisms to prevent potential race conditions.

src/main/java/net/wurstclient/hacks/PortalEspHack.java Outdated Show resolved Hide resolved
Comment on lines +119 to +123
public static void closeBuffers()
{
Stream.of(solidBox, outlinedBox).filter(Objects::nonNull)
.forEach(VertexBuffer::close);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closing the VertexBuffer objects without nullifying them could potentially lead to NullPointerExceptions if they are accessed after being closed. After closing the buffers, consider setting them to null.

	public static void closeBuffers()
	{
		Stream.of(solidBox, outlinedBox).filter(Objects::nonNull)
			.forEach(VertexBuffer::close);
+       solidBox = null;
+       outlinedBox = null;
	}

src/main/java/net/wurstclient/util/ChunkSearcherMulti.java Outdated Show resolved Hide resolved
src/main/java/net/wurstclient/util/ChunkSearcherMulti.java Outdated Show resolved Hide resolved
src/main/java/net/wurstclient/util/ChunkSearcherMulti.java Outdated Show resolved Hide resolved
src/main/java/net/wurstclient/util/ChunkSearcherMulti.java Outdated Show resolved Hide resolved
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Status

Actionable comments generated: 4

Commits Files that changed from the base of the PR and between c1e5b87 and 3c87ed1 commits.
Files selected for processing (1)
  • src/main/java/net/wurstclient/hacks/PortalEspHack.java (1 hunks)

src/main/java/net/wurstclient/hacks/PortalEspHack.java Outdated Show resolved Hide resolved
src/main/java/net/wurstclient/hacks/PortalEspHack.java Outdated Show resolved Hide resolved
src/main/java/net/wurstclient/hacks/PortalEspHack.java Outdated Show resolved Hide resolved
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Status

Actionable comments generated: 2

Commits Files that changed from the base of the PR and between 3c87ed1 and 486c98b commits.
Files selected for processing (1)
  • src/main/java/net/wurstclient/hacks/PortalEspHack.java (1 hunks)
Additional comments (Suppressed): 1
src/main/java/net/wurstclient/hacks/PortalEspHack.java (1)
  • 144-177: The onRender() method sets and resets several OpenGL settings. It's important to ensure that these settings are correctly set and reset, as incorrect settings can cause rendering issues. Please verify that these settings are correct for your use case.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Status

Actionable comments generated: 3

Commits Files that changed from the base of the PR and between 486c98b and 2b896bb commits.
Files selected for processing (1)
  • src/main/java/net/wurstclient/hacks/PortalEspHack.java (1 hunks)

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Status

Actionable comments generated: 0

Configuration used: CodeRabbit UI

Commits Files that changed from the base of the PR and between 2b896bb and f6805af.
Files ignored due to filter (1)
  • src/main/resources/assets/wurst/lang/en_us.json
Files selected for processing (1)
  • src/main/java/net/wurstclient/hack/HackList.java (1 hunks)
Files skipped from review due to trivial changes (1)
  • src/main/java/net/wurstclient/hack/HackList.java

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Status

Actionable comments generated: 3

Configuration used: CodeRabbit UI

Commits Files that changed from the base of the PR and between f6805af and 25617f9.
Files selected for processing (2)
  • src/main/java/net/wurstclient/hacks/PortalEspHack.java (1 hunks)
  • src/main/java/net/wurstclient/hacks/portalesp/PortalEspRenderer.java (1 hunks)
Additional comments: 2
src/main/java/net/wurstclient/hacks/PortalEspHack.java (2)
  • 133-140: The onUpdate method checks if the chunk searchers have changed and if the groups are up-to-date. If the coordinator is done and the groups are not up-to-date, it calls updateGroupBoxes. This is a good approach as it ensures that the ESP boxes are only updated when necessary, which can save on performance. However, it's important to ensure that the coordinator.isDone() method accurately reflects the completion state of all chunk searchers to prevent any race conditions or missed updates.

  • 180-195: The updateGroupBoxes method clears the groups and then adds all the found portal blocks to their respective groups. The addToGroupBoxes method checks each result against the block types of the groups and adds the position to the group if it matches. This is a straightforward and efficient way to update the groups. However, it's important to ensure that the clear method of PortalEspBlockGroup is thread-safe if it's possible for updateGroupBoxes to be called from multiple threads.

Comment on lines +1 to +123
import net.minecraft.util.math.BlockPos;
import net.minecraft.util.math.Box;
import net.minecraft.util.math.Vec3d;
import net.wurstclient.util.RegionPos;
import net.wurstclient.util.RenderUtils;
import net.wurstclient.util.RotationUtils;

public final class PortalEspRenderer
{
private static VertexBuffer solidBox;
private static VertexBuffer outlinedBox;

private final MatrixStack matrixStack;
private final RegionPos region;
private final Vec3d start;

public PortalEspRenderer(MatrixStack matrixStack, float partialTicks)
{
this.matrixStack = matrixStack;
region = RenderUtils.getCameraRegion();
start = RotationUtils.getClientLookVec(partialTicks)
.add(RenderUtils.getCameraPos()).subtract(region.toVec3d());
}

public void renderBoxes(PortalEspBlockGroup group)
{
float[] colorF = group.getColorF();

for(Box box : group.getBoxes())
{
matrixStack.push();

matrixStack.translate(box.minX - region.x(), box.minY,
box.minZ - region.z());

matrixStack.scale((float)(box.maxX - box.minX),
(float)(box.maxY - box.minY), (float)(box.maxZ - box.minZ));

Matrix4f viewMatrix = matrixStack.peek().getPositionMatrix();
Matrix4f projMatrix = RenderSystem.getProjectionMatrix();
ShaderProgram shader = RenderSystem.getShader();

RenderSystem.setShaderColor(colorF[0], colorF[1], colorF[2], 0.25F);
solidBox.bind();
solidBox.draw(viewMatrix, projMatrix, shader);
VertexBuffer.unbind();

RenderSystem.setShaderColor(colorF[0], colorF[1], colorF[2], 0.5F);
outlinedBox.bind();
outlinedBox.draw(viewMatrix, projMatrix, shader);
VertexBuffer.unbind();

matrixStack.pop();
}
}

public void renderLines(PortalEspBlockGroup group)
{
Matrix4f matrix = matrixStack.peek().getPositionMatrix();
Tessellator tessellator = RenderSystem.renderThreadTesselator();
BufferBuilder bufferBuilder = tessellator.getBuffer();

float[] colorF = group.getColorF();
RenderSystem.setShaderColor(colorF[0], colorF[1], colorF[2], 0.5F);

bufferBuilder.begin(VertexFormat.DrawMode.DEBUG_LINES,
VertexFormats.POSITION);

for(Box box : group.getBoxes())
{
Vec3d end = box.getCenter().subtract(region.toVec3d());

bufferBuilder
.vertex(matrix, (float)start.x, (float)start.y, (float)start.z)
.next();

bufferBuilder
.vertex(matrix, (float)end.x, (float)end.y, (float)end.z)
.next();
}

tessellator.draw();
}

public static void prepareBuffers()
{
closeBuffers();
solidBox = new VertexBuffer(VertexBuffer.Usage.STATIC);
outlinedBox = new VertexBuffer(VertexBuffer.Usage.STATIC);

Box box = new Box(BlockPos.ORIGIN);
RenderUtils.drawSolidBox(box, solidBox);
RenderUtils.drawOutlinedBox(box, outlinedBox);
}

public static void closeBuffers()
{
Stream.of(solidBox, outlinedBox).filter(Objects::nonNull)
.forEach(VertexBuffer::close);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PortalEspRenderer class is well-structured and follows Java conventions. However, there are a few points to consider:

  • The renderBoxes method (lines 48-78) does not check if the PortalEspBlockGroup is enabled before rendering. If the enabled check is indeed happening outside this method as mentioned by the previous reviewer, then this is acceptable. However, it's typically good practice to ensure that such checks are close to the rendering logic to avoid unnecessary processing and to make the code easier to understand and maintain. If the enabled state can change dynamically, consider adding a check here as well to prevent rendering when not needed.

  • The renderLines method (lines 80-106) uses a debug draw mode (line 89) which might not be intended for production use. Ensure that this is the correct draw mode for the intended visual effect.

  • The prepareBuffers method (lines 108-117) is creating new VertexBuffer objects without checking if they already exist, which could lead to memory leaks. It's good practice to check if the buffers are already initialized before creating new ones. This is especially important in a game context where memory management is crucial for performance.

  • The closeBuffers method (lines 119-123) closes the vertex buffers but does not set them to null, which could potentially lead to NullPointerExceptions if they are accessed after being closed. As per the previous review, setting them to null after closing would be a safer approach.

Here's a proposed fix for the prepareBuffers and closeBuffers methods:

public static void prepareBuffers() {
    closeBuffers();
    solidBox = new VertexBuffer(VertexBuffer.Usage.STATIC);
    outlinedBox = new VertexBuffer(VertexBuffer.Usage.STATIC);

    Box box = new Box(BlockPos.ORIGIN);
    RenderUtils.drawSolidBox(box, solidBox);
    RenderUtils.drawOutlinedBox(box, outlinedBox);
}

public static void closeBuffers() {
    Stream.of(solidBox, outlinedBox).filter(Objects::nonNull)
        .forEach(VertexBuffer::close);
    solidBox = null;
    outlinedBox = null;
}

Comment on lines +144 to +178
public void onRender(MatrixStack matrixStack, float partialTicks)
{
// GL settings
GL11.glEnable(GL11.GL_BLEND);
GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA);
GL11.glEnable(GL11.GL_CULL_FACE);
GL11.glDisable(GL11.GL_DEPTH_TEST);

matrixStack.push();
RenderUtils.applyRegionalRenderOffset(matrixStack);

PortalEspRenderer espRenderer =
new PortalEspRenderer(matrixStack, partialTicks);

if(style.getSelected().hasBoxes())
{
RenderSystem.setShader(GameRenderer::getPositionProgram);
groups.stream().filter(PortalEspBlockGroup::isEnabled)
.forEach(espRenderer::renderBoxes);
}

if(style.getSelected().hasLines())
{
RenderSystem.setShader(GameRenderer::getPositionProgram);
groups.stream().filter(PortalEspBlockGroup::isEnabled)
.forEach(espRenderer::renderLines);
}

matrixStack.pop();

// GL resets
RenderSystem.setShaderColor(1, 1, 1, 1);
GL11.glEnable(GL11.GL_DEPTH_TEST);
GL11.glDisable(GL11.GL_BLEND);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The onRender method sets up the OpenGL environment, pushes the current matrix stack, applies a regional render offset, and then renders the portal ESP boxes and lines if enabled. After rendering, it pops the matrix stack and resets the OpenGL state. This is a standard approach for rendering in Minecraft mods. However, it's important to ensure that the OpenGL state is always properly reset after rendering to avoid affecting other render operations in the game. The use of RenderSystem.setShader(GameRenderer::getPositionProgram) before rendering each group is redundant and could be moved outside of the if statements to avoid setting the shader multiple times unnecessarily.

@Alexander01998 Alexander01998 added this to the v7.40 milestone Dec 15, 2023
@Alexander01998 Alexander01998 merged commit 676d07a into Wurst-Imperium:master Dec 15, 2023
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
category:render type:enhancement New feature or request type:new feature A new hack/command/etc.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Portal ESP
4 participants