diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index c01e824f..93ed5d0b 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -42,7 +42,7 @@ jobs:
 
     - name: Configure CMake
       run: |
-        ${{ matrix.config.prefixes }} cmake -B ${{ github.workspace }}/build ${{ matrix.config.extra_flags }}
+        ${{ matrix.config.prefixes }} cmake -B ${{ github.workspace }}/build ${{ matrix.config.extra_flags }} -DGEODE_DONT_PACKAGE_RESOURCES
 
     - name: Build
       run: |
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 00000000..b4a0a1f3
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "loader/md4c"]
+	path = loader/md4c
+	url = https://github.com/mity/md4c
diff --git a/cmake/GeodeFile.cmake b/cmake/GeodeFile.cmake
index 03581218..3c040da9 100644
--- a/cmake/GeodeFile.cmake
+++ b/cmake/GeodeFile.cmake
@@ -23,3 +23,18 @@ function(create_geode_file proname)
     endif()
 
 endfunction()
+
+function(package_geode_resources proname src dest prefix)
+    message(STATUS "Packaging resources from ${src} with prefix ${prefix} into ${dest}")
+
+    if(GEODE_CLI STREQUAL "GEODE_CLI-NOTFOUND")
+        message(WARNING "package_geode_resources called, but Geode CLI was not found - You will need to manually package the resources")
+    else()
+
+        add_custom_target(${proname}_PACKAGE ALL
+            DEPENDS ${proname}
+            COMMAND ${GEODE_CLI} resources ${src} ${dest} --prefix ${prefix} --cached
+            VERBATIM USES_TERMINAL
+        )
+    endif()
+endfunction()
diff --git a/loader/CMakeLists.txt b/loader/CMakeLists.txt
index cf645617..4f8021f7 100644
--- a/loader/CMakeLists.txt
+++ b/loader/CMakeLists.txt
@@ -3,9 +3,11 @@ cmake_minimum_required(VERSION 3.21 FATAL_ERROR)
 project(geode-loader VERSION 0.2.0 LANGUAGES C CXX)
 set(PROJECT_VERSION_TYPE Alpha)
 
+# Package info file for internal representation
 file(READ resources/about.md LOADER_ABOUT_MD)
 configure_file(src/internal/about.hpp.in ${CMAKE_CURRENT_SOURCE_DIR}/src/internal/about.hpp)
 
+# Source files
 file(GLOB CORE_SOURCES
 	src/cocos2d-ext/*.cpp
 	src/core/*.cpp
@@ -22,6 +24,13 @@ file(GLOB CORE_SOURCES
 	src/utils/*.cpp
 	src/utils/windows/*.cpp
 	src/utils/zip/*.cpp
+	src/index/*.cpp
+	src/ui/nodes/*.cpp
+	src/ui/internal/credits/*.cpp
+	src/ui/internal/dev/*.cpp
+	src/ui/internal/info/*.cpp
+	src/ui/internal/list/*.cpp
+	src/ui/internal/settings/*.cpp
 )
 
 file(GLOB OBJC_SOURCES
@@ -33,7 +42,7 @@ file(GLOB OBJC_SOURCES
 	src/utils/mac/*.mm
 )
 
-# embed version info in binary
+# Embed version info in binary
 if (WIN32)
 	configure_file(src/internal/windows/info.rc.in info.rc)
 	set(CORE_SOURCES ${CORE_SOURCES} ${CMAKE_CURRENT_BINARY_DIR}/info.rc)
@@ -55,6 +64,7 @@ set_target_properties(${PROJECT_NAME} PROPERTIES
 	ARCHIVE_OUTPUT_DIRECTORY "${GEODE_BIN_PATH}"
 )
 
+# Move compiled binary into `bin/nightly` directory
 if (APPLE)
 	add_custom_command(
 		COMMAND 
@@ -73,10 +83,23 @@ elseif(WIN32)
 	)
 endif()
 
+if (NOT GEODE_DONT_PACKAGE_RESOURCES)
+	# Package resources for UI
+	package_geode_resources(
+		${PROJECT_NAME}
+		${CMAKE_CURRENT_SOURCE_DIR}/resources
+		${GEODE_BIN_PATH}/nightly/resources
+		"geode.loader"
+	)
+endif()
+
 target_include_directories(${PROJECT_NAME} PRIVATE
 	src/internal/
 	src/platform/
 	src/gui/
+	src/index/
+	md4c/src/
+	hash/
 	./ # lilac
 )
 
@@ -85,10 +108,15 @@ set_property(TARGET ${PROJECT_NAME} PROPERTY RULE_LAUNCH_COMPILE "${CMAKE_COMMAN
 
 target_compile_definitions(${PROJECT_NAME} PUBLIC GEODE_EXPORTING GEODE_PLATFORM_CONSOLE)
 
+# Markdown support
+add_subdirectory(md4c)
+target_link_libraries(${PROJECT_NAME} md4c)
+
+# Lilac (hooking)
 add_subdirectory(lilac)
+target_link_libraries(${PROJECT_NAME} z lilac_hook geode-sdk)
 
-target_link_libraries(${PROJECT_NAME} z lilac_hook geode-sdk) # lilac
-
+# Use precompiled headers for faster builds
 set_source_files_properties(${OBJC_SOURCES} PROPERTIES SKIP_PRECOMPILE_HEADERS ON)
 target_precompile_headers(${PROJECT_NAME} PRIVATE
 	"${CMAKE_CURRENT_SOURCE_DIR}/include/Geode/DefaultInclude.hpp"
@@ -96,6 +124,7 @@ target_precompile_headers(${PROJECT_NAME} PRIVATE
 	"${CMAKE_CURRENT_SOURCE_DIR}/include/Geode/cocos/cocos2dx/include/cocos2d.h"
 )
 
+# Create launcher
 if (APPLE) 
 	if(CMAKE_SYSTEM_PROCESSOR MATCHES "arm" OR GEODE_TARGET_PLATFORM STREQUAL "iOS")
 		add_custom_command(TARGET geode-loader
@@ -116,8 +145,12 @@ elseif (WIN32)
 	target_link_libraries(${PROJECT_NAME} dbghelp)
 endif()
 
+# Build test mods if needed
 if(NOT GEODE_DONT_BUILD_TEST_MODS)
 	if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
 		add_subdirectory(test)
 	endif()
 endif()
+
+# Build index hashing algorithm test program
+add_subdirectory(hash)
diff --git a/loader/hash/CMakeLists.txt b/loader/hash/CMakeLists.txt
new file mode 100644
index 00000000..8d1fe4ac
--- /dev/null
+++ b/loader/hash/CMakeLists.txt
@@ -0,0 +1,9 @@
+cmake_minimum_required(VERSION 3.0 FATAL_ERROR)
+set(CMAKE_CXX_STANDARD 17)
+set(CMAKE_CXX_STANDARD_REQUIRED On)
+
+project(GeodeChecksum VERSION 1.0)
+
+add_executable(${PROJECT_NAME} hash.cpp)
+
+message(STATUS "Building Checksum Exe")
diff --git a/loader/hash/LICENSE b/loader/hash/LICENSE
new file mode 100644
index 00000000..76a17d78
--- /dev/null
+++ b/loader/hash/LICENSE
@@ -0,0 +1,373 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+    means each individual or legal entity that creates, contributes to
+    the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+    means the combination of the Contributions of others (if any) used
+    by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+    means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+    means Source Code Form to which the initial Contributor has attached
+    the notice in Exhibit A, the Executable Form of such Source Code
+    Form, and Modifications of such Source Code Form, in each case
+    including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+    means
+
+    (a) that the initial Contributor has attached the notice described
+        in Exhibit B to the Covered Software; or
+
+    (b) that the Covered Software was made available under the terms of
+        version 1.1 or earlier of the License, but not also under the
+        terms of a Secondary License.
+
+1.6. "Executable Form"
+    means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+    means a work that combines Covered Software with other material, in
+    a separate file or files, that is not Covered Software.
+
+1.8. "License"
+    means this document.
+
+1.9. "Licensable"
+    means having the right to grant, to the maximum extent possible,
+    whether at the time of the initial grant or subsequently, any and
+    all of the rights conveyed by this License.
+
+1.10. "Modifications"
+    means any of the following:
+
+    (a) any file in Source Code Form that results from an addition to,
+        deletion from, or modification of the contents of Covered
+        Software; or
+
+    (b) any new file in Source Code Form that contains any Covered
+        Software.
+
+1.11. "Patent Claims" of a Contributor
+    means any patent claim(s), including without limitation, method,
+    process, and apparatus claims, in any patent Licensable by such
+    Contributor that would be infringed, but for the grant of the
+    License, by the making, using, selling, offering for sale, having
+    made, import, or transfer of either its Contributions or its
+    Contributor Version.
+
+1.12. "Secondary License"
+    means either the GNU General Public License, Version 2.0, the GNU
+    Lesser General Public License, Version 2.1, the GNU Affero General
+    Public License, Version 3.0, or any later versions of those
+    licenses.
+
+1.13. "Source Code Form"
+    means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+    means an individual or a legal entity exercising rights under this
+    License. For legal entities, "You" includes any entity that
+    controls, is controlled by, or is under common control with You. For
+    purposes of this definition, "control" means (a) the power, direct
+    or indirect, to cause the direction or management of such entity,
+    whether by contract or otherwise, or (b) ownership of more than
+    fifty percent (50%) of the outstanding shares or beneficial
+    ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+    Licensable by such Contributor to use, reproduce, make available,
+    modify, display, perform, distribute, and otherwise exploit its
+    Contributions, either on an unmodified basis, with Modifications, or
+    as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+    for sale, have made, import, and otherwise transfer either its
+    Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+    or
+
+(b) for infringements caused by: (i) Your and any other third party's
+    modifications of Covered Software, or (ii) the combination of its
+    Contributions with other software (except as part of its Contributor
+    Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+    its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+    Form, as described in Section 3.1, and You must inform recipients of
+    the Executable Form how they can obtain a copy of such Source Code
+    Form by reasonable means in a timely manner, at a charge no more
+    than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+    License, or sublicense it under different terms, provided that the
+    license for the Executable Form does not attempt to limit or alter
+    the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+*                                                                      *
+*  6. Disclaimer of Warranty                                           *
+*  -------------------------                                           *
+*                                                                      *
+*  Covered Software is provided under this License on an "as is"       *
+*  basis, without warranty of any kind, either expressed, implied, or  *
+*  statutory, including, without limitation, warranties that the       *
+*  Covered Software is free of defects, merchantable, fit for a        *
+*  particular purpose or non-infringing. The entire risk as to the     *
+*  quality and performance of the Covered Software is with You.        *
+*  Should any Covered Software prove defective in any respect, You     *
+*  (not any Contributor) assume the cost of any necessary servicing,   *
+*  repair, or correction. This disclaimer of warranty constitutes an   *
+*  essential part of this License. No use of any Covered Software is   *
+*  authorized under this License except under this disclaimer.         *
+*                                                                      *
+************************************************************************
+
+************************************************************************
+*                                                                      *
+*  7. Limitation of Liability                                          *
+*  --------------------------                                          *
+*                                                                      *
+*  Under no circumstances and under no legal theory, whether tort      *
+*  (including negligence), contract, or otherwise, shall any           *
+*  Contributor, or anyone who distributes Covered Software as          *
+*  permitted above, be liable to You for any direct, indirect,         *
+*  special, incidental, or consequential damages of any character      *
+*  including, without limitation, damages for lost profits, loss of    *
+*  goodwill, work stoppage, computer failure or malfunction, or any    *
+*  and all other commercial damages or losses, even if such party      *
+*  shall have been informed of the possibility of such damages. This   *
+*  limitation of liability shall not apply to liability for death or   *
+*  personal injury resulting from such party's negligence to the       *
+*  extent applicable law prohibits such limitation. Some               *
+*  jurisdictions do not allow the exclusion or limitation of           *
+*  incidental or consequential damages, so this exclusion and          *
+*  limitation may not apply to You.                                    *
+*                                                                      *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+  This Source Code Form is subject to the terms of the Mozilla Public
+  License, v. 2.0. If a copy of the MPL was not distributed with this
+  file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+  This Source Code Form is "Incompatible With Secondary Licenses", as
+  defined by the Mozilla Public License, v. 2.0.
diff --git a/loader/hash/hash.cpp b/loader/hash/hash.cpp
new file mode 100644
index 00000000..d55dbdba
--- /dev/null
+++ b/loader/hash/hash.cpp
@@ -0,0 +1,11 @@
+#include <iostream>
+#include "hash.hpp"
+
+int main(int ac, char* av[]) {
+    if (ac < 2) {
+        std::cout << "Usage: \"checksum <file>\"\n";
+        return 1;
+    }
+    std::cout << calculateHash(av[1]) << std::hex;
+    return 0;
+}
diff --git a/loader/hash/hash.hpp b/loader/hash/hash.hpp
new file mode 100644
index 00000000..b6caa528
--- /dev/null
+++ b/loader/hash/hash.hpp
@@ -0,0 +1,14 @@
+#pragma once
+
+#include <string>
+#include <fstream>
+#include <ciso646>
+#include "picosha3.h"
+#include <vector>
+
+static std::string calculateHash(std::string const& path) {
+    std::vector<uint8_t> s(picosha3::bits_to_bytes(256));
+    auto sha3_256 = picosha3::get_sha3_generator<256>();
+    std::ifstream file(path);
+    return sha3_256.get_hex_string(file);
+}
diff --git a/loader/hash/picosha3.h b/loader/hash/picosha3.h
new file mode 100644
index 00000000..54ac7b59
--- /dev/null
+++ b/loader/hash/picosha3.h
@@ -0,0 +1,361 @@
+#ifndef PICOSHA3_H
+#define PICOSHA3_H
+
+#include <array>
+#include <cassert>
+#include <fstream>
+#include <iomanip>
+#include <sstream>
+
+namespace picosha3 {
+    constexpr size_t bits_to_bytes(size_t bits) { return bits / 8; };
+    constexpr static size_t b_bytes = bits_to_bytes(1600);
+    constexpr static uint64_t RC[24] = {
+      0x0000000000000001ull, 0x0000000000008082ull, 0x800000000000808Aull,
+      0x8000000080008000ull, 0x000000000000808Bull, 0x0000000080000001ull,
+      0x8000000080008081ull, 0x8000000000008009ull, 0x000000000000008Aull,
+      0x0000000000000088ull, 0x0000000080008009ull, 0x000000008000000Aull,
+      0x000000008000808Bull, 0x800000000000008Bull, 0x8000000000008089ull,
+      0x8000000000008003ull, 0x8000000000008002ull, 0x8000000000000080ull,
+      0x000000000000800Aull, 0x800000008000000Aull, 0x8000000080008081ull,
+      0x8000000000008080ull, 0x0000000080000001ull, 0x8000000080008008ull};
+
+    using byte_t = uint8_t;
+    using state_t = std::array<std::array<uint64_t, 5>, 5>;
+
+    inline void theta(state_t& A) {
+        uint64_t C[5] = {0, 0, 0, 0, 0};
+        for(size_t x = 0; x < 5; ++x) {
+            C[x] = A[x][0] ^ A[x][1] ^ A[x][2] ^ A[x][3] ^ A[x][4];
+        };
+        uint64_t D[5] = {0, 0, 0, 0, 0};
+        D[0] = C[4] ^ (C[1] << 1 | C[1] >> (64 - 1));
+        D[1] = C[0] ^ (C[2] << 1 | C[2] >> (64 - 1));
+        D[2] = C[1] ^ (C[3] << 1 | C[3] >> (64 - 1));
+        D[3] = C[2] ^ (C[4] << 1 | C[4] >> (64 - 1));
+        D[4] = C[3] ^ (C[0] << 1 | C[0] >> (64 - 1));
+        for(size_t x = 0; x < 5; ++x) {
+            for(size_t y = 0; y < 5; ++y) {
+                A[x][y] ^= D[x];
+            }
+        }
+    };
+
+    inline void rho(state_t& A) {
+        size_t x{1};
+        size_t y{0};
+        for(size_t t = 0; t < 24; ++t) {
+            size_t offset = ((t + 1) * (t + 2) / 2) % 64;
+            A[x][y] = (A[x][y] << offset) | (A[x][y] >> (64 - offset));
+            size_t tmp{y};
+            y = (2 * x + 3 * y) % 5;
+            x = tmp;
+        };
+    };
+
+    inline void pi(state_t& A) {
+        state_t tmp{A};
+        for(size_t x = 0; x < 5; ++x) {
+            for(size_t y = 0; y < 5; ++y) {
+                A[x][y] = tmp[(x + 3 * y) % 5][x];
+            }
+        }
+    };
+
+    inline void chi(state_t& A) {
+        state_t tmp{A};
+        for(size_t x = 0; x < 5; ++x) {
+            for(size_t y = 0; y < 5; ++y) {
+                A[x][y] =
+                  tmp[x][y] ^ (~(tmp[(x + 1) % 5][y]) & tmp[(x + 2) % 5][y]);
+            }
+        }
+    };
+
+    inline void iota(state_t& A, size_t round_index) {
+        A[0][0] ^= RC[round_index];
+    };
+
+    inline void keccak_p(state_t& A) {
+        for(size_t round_index = 0; round_index < 24; ++round_index) {
+            theta(A);
+            rho(A);
+            pi(A);
+            chi(A);
+            iota(A, round_index);
+        }
+    };
+
+    namespace {
+        inline void next(size_t& x, size_t& y, size_t& i) {
+            if(++i != 8) {
+                return;
+            }
+            i = 0;
+            if(++x != 5) {
+                return;
+            }
+            x = 0;
+            if(++y != 5) {
+                return;
+            }
+        }
+    } // namespace
+
+    template <typename InIter>
+    void absorb(InIter first, InIter last, state_t& A) {
+        size_t x = 0;
+        size_t y = 0;
+        size_t i = 0;
+        for(; first != last && y < 5; ++first) {
+            auto tmp = static_cast<uint64_t>(*first);
+            A[x][y] ^= (tmp << (i * 8));
+            next(x, y, i);
+        };
+    }
+
+    template <typename InContainer>
+    void absorb(const InContainer& src, state_t& A) {
+        absorb(src.cbegin(), src.cend(), A);
+    };
+
+    template <typename OutIter>
+    OutIter squeeze(const state_t& A, OutIter first, OutIter last,
+                    size_t rate_bytes) {
+        size_t x = 0;
+        size_t y = 0;
+        size_t i = 0;
+        for(size_t read_bytes = 0;
+            first != last && y < 5 && read_bytes < rate_bytes;
+            ++read_bytes, ++first) {
+            auto tmp = static_cast<uint64_t>(A[x][y]);
+            auto p = reinterpret_cast<byte_t*>(&tmp);
+            *first = *(p + i);
+            next(x, y, i);
+        }
+        return first;
+    };
+
+    template <typename OutContainer>
+    typename OutContainer::iterator
+    squeeze(const state_t& A, OutContainer& dest, size_t rate_bytes) {
+        return squeeze(A, dest.begin(), dest.end(), rate_bytes);
+    }
+
+    enum class PaddingType {
+        SHA,
+        SHAKE,
+    };
+
+    template <typename InIter>
+    std::string bytes_to_hex_string(InIter first, InIter last) {
+        std::stringstream ss;
+        ss << std::hex;
+        for(; first != last; ++first) {
+            ss << std::setw(2) << std::setfill('0')
+               << static_cast<uint64_t>(*first);
+        }
+        return ss.str();
+    }
+
+    template <typename InContainer>
+    std::string bytes_to_hex_string(const InContainer& src) {
+        return bytes_to_hex_string(src.cbegin(), src.cend());
+    }
+
+    template <size_t rate_bytes, size_t d_bytes, PaddingType padding_type>
+    class HashGenerator {
+    public:
+        HashGenerator()
+          : buffer_{}, buffer_pos_{buffer_.begin()}, A_{}, hash_{},
+            is_finished_{false} {}
+
+        void clear() {
+            clear_state();
+            clear_buffer();
+            is_finished_ = false;
+        }
+
+        template <typename InIter>
+        void process(InIter first, InIter last) {
+            static_assert(
+              sizeof(typename std::iterator_traits<InIter>::value_type) == 1,
+              "The size of input iterator value_type must be one byte.");
+
+            for(; first != last; ++first) {
+                *buffer_pos_ = *first;
+                if(++buffer_pos_ == buffer_.end()) {
+                    absorb(buffer_, A_);
+                    keccak_p(A_);
+                    clear_buffer();
+                }
+            }
+        };
+
+        void finish() {
+            add_padding();
+            absorb(buffer_, A_);
+            keccak_p(A_);
+            squeeze_();
+            is_finished_ = true;
+        };
+
+        template <typename OutIter>
+        void get_hash_bytes(OutIter first, OutIter last) {
+            if(!is_finished_) {
+                throw std::runtime_error("Not finished!");
+            }
+            std::copy(hash_.cbegin(), hash_.cend(), first);
+        };
+
+        template <typename OutCotainer>
+        void get_hash_bytes(OutCotainer& dest) {
+            get_hash_bytes(dest.begin(), dest.end());
+        };
+
+        template <typename InIter, typename OutIter>
+        void operator()(InIter in_first, InIter in_last, OutIter out_first,
+                        OutIter out_last) {
+            static_assert(
+              sizeof(typename std::iterator_traits<InIter>::value_type) == 1,
+              "The size of input iterator value_type must be one byte.");
+            static_assert(
+              sizeof(typename std::iterator_traits<OutIter>::value_type) == 1,
+              "The size of output iterator value_type must be one byte.");
+            process(in_first, in_last);
+            finish();
+            std::copy(hash_.cbegin(), hash_.cend(), out_first);
+            clear();
+        };
+
+        template <typename InIter, typename OutCotainer>
+        void operator()(InIter in_first, InIter in_last, OutCotainer& dest) {
+            operator()(in_first, in_last, dest.begin(), dest.end());
+        };
+
+        template <typename InContainer, typename OutIter>
+        void operator()(const InContainer& src, OutIter out_first,
+                        OutIter out_last) {
+            operator()(src.cbegin(), src.cend(), out_first, out_last);
+        };
+
+        template <typename InContainer, typename OutContainer>
+        void operator()(const InContainer& src, OutContainer& dest) {
+            operator()(src.cbegin(), src.cend(), dest.begin(), dest.end());
+        };
+
+        template <typename OutIter>
+        void operator()(std::ifstream& ifs, OutIter out_first,
+                        OutIter out_last) {
+            auto in_first = std::istreambuf_iterator<char>(ifs);
+            auto in_last = std::istreambuf_iterator<char>();
+            operator()(in_first, in_last, out_first, out_last);
+        };
+
+        template <typename OutCotainer>
+        void operator()(std::ifstream& ifs, OutCotainer& dest) {
+            operator()(ifs, dest.begin(), dest.end());
+        };
+
+        std::string get_hex_string() {
+            if(!is_finished_) {
+                throw std::runtime_error("Not finished!");
+            }
+            return bytes_to_hex_string(hash_);
+        };
+
+        template <typename InIter>
+        std::string get_hex_string(InIter in_first, InIter in_last) {
+            process(in_first, in_last);
+            finish();
+            auto hash = get_hex_string();
+            clear();
+            return hash;
+        };
+
+        template <typename InContainer>
+        std::string get_hex_string(const InContainer& src) {
+            return get_hex_string(src.cbegin(), src.cend());
+        };
+
+        std::string get_hex_string(std::ifstream& ifs) {
+            auto in_first = std::istreambuf_iterator<char>(ifs);
+            auto in_last = std::istreambuf_iterator<char>();
+            return get_hex_string(in_first, in_last);
+        };
+
+    private:
+        void clear_buffer() {
+            buffer_.fill(0);
+            buffer_pos_ = buffer_.begin();
+        };
+
+        void clear_state() {
+            for(auto& row : A_) {
+                row.fill(0);
+            }
+        };
+
+        void add_padding() {
+            const auto q =
+              buffer_.size() - std::distance(buffer_pos_, buffer_.begin());
+
+            if(padding_type == PaddingType::SHA) {
+                if(q == 1) {
+                    *buffer_pos_ = 0x86;
+                } else {
+                    *buffer_pos_ = 0x06;
+                    buffer_.back() = 0x80;
+                }
+            } else if(padding_type == PaddingType::SHAKE) {
+                if(q == 1) {
+                    *buffer_pos_ = 0x9F;
+                } else {
+                    *buffer_pos_ = 0x1F;
+                    buffer_.back() = 0x80;
+                }
+            }
+        };
+
+        void squeeze_() {
+            auto first = hash_.begin();
+            auto last = hash_.end();
+            first = squeeze(A_, first, last, rate_bytes);
+            while(first != last) {
+                keccak_p(A_);
+                first = squeeze(A_, first, last, rate_bytes);
+            }
+        };
+
+        std::array<byte_t, rate_bytes> buffer_;
+        typename decltype(buffer_)::iterator buffer_pos_;
+        state_t A_;
+        std::array<byte_t, d_bytes> hash_;
+        bool is_finished_;
+    };
+
+    template <size_t d_bits>
+    auto get_sha3_generator() {
+        static_assert(
+          d_bits == 224 or d_bits == 256 or d_bits == 384 or d_bits == 512,
+          "SHA3 only accepts digest message length 224, 256 384 or 512 bits.");
+        constexpr auto d_bytes = bits_to_bytes(d_bits);
+        constexpr auto capacity_bytes = d_bytes * 2;
+        constexpr auto rate_bytes = b_bytes - capacity_bytes;
+        return HashGenerator<rate_bytes, d_bytes, PaddingType::SHA>{};
+    }
+
+    template <size_t strength_bits, size_t d_bits>
+    auto get_shake_generator() {
+        static_assert(strength_bits == 128 or strength_bits == 256,
+                      "SHAKE only accepts strength 128 or 256 bits.");
+        constexpr auto strength_bytes = bits_to_bytes(strength_bits);
+        constexpr auto capacity_bytes = strength_bytes * 2;
+        constexpr auto rate_bytes = b_bytes - capacity_bytes;
+        constexpr auto d_bytes = bits_to_bytes(d_bits);
+        return HashGenerator<rate_bytes, d_bytes, PaddingType::SHAKE>{};
+    }
+
+} // namespace picosha3
+
+#endif
diff --git a/loader/include/Geode/Geode.hpp b/loader/include/Geode/Geode.hpp
index 67aa9999..19315f47 100644
--- a/loader/include/Geode/Geode.hpp
+++ b/loader/include/Geode/Geode.hpp
@@ -3,4 +3,5 @@
 #include "Bindings.hpp"
 #include "Utils.hpp"
 #include "Loader.hpp"
-#include "Modify.hpp"
\ No newline at end of file
+#include "Modify.hpp"
+#include "UI.hpp"
diff --git a/loader/include/Geode/UI.hpp b/loader/include/Geode/UI.hpp
new file mode 100644
index 00000000..4e8522ee
--- /dev/null
+++ b/loader/include/Geode/UI.hpp
@@ -0,0 +1,15 @@
+#pragma once
+
+#include "ui/BasedButton.hpp"
+#include "ui/BasedButtonSprite.hpp"
+#include "ui/IconButtonSprite.hpp"
+#include "ui/InputNode.hpp"
+#include "ui/ListView.hpp"
+#include "ui/MDTextArea.hpp"
+#include "ui/MenuInputNode.hpp"
+#include "ui/Notification.hpp"
+#include "ui/Popup.hpp"
+#include "ui/SceneManager.hpp"
+#include "ui/Scrollbar.hpp"
+#include "ui/ScrollLayer.hpp"
+#include "ui/TextRenderer.hpp"
diff --git a/loader/include/Geode/Utils.hpp b/loader/include/Geode/Utils.hpp
index 22f578fb..c7f80fb0 100644
--- a/loader/include/Geode/Utils.hpp
+++ b/loader/include/Geode/Utils.hpp
@@ -13,4 +13,5 @@
 #include "utils/ext.hpp"
 #include "utils/convert.hpp"
 #include "utils/cocos.hpp"
-#include "utils/operators.hpp"
\ No newline at end of file
+#include "utils/operators.hpp"
+#include "utils/Ref.hpp"
diff --git a/loader/include/Geode/ui/BasedButton.hpp b/loader/include/Geode/ui/BasedButton.hpp
new file mode 100644
index 00000000..58ea43ac
--- /dev/null
+++ b/loader/include/Geode/ui/BasedButton.hpp
@@ -0,0 +1,24 @@
+#pragma once
+
+#include "BasedButtonSprite.hpp"
+
+#pragma warning(disable : 4275)
+
+namespace geode {
+    class GEODE_DLL TabButton : public CCMenuItemToggler {
+     public:
+        static TabButton* create(
+            TabBaseColor unselected,
+            TabBaseColor selected,
+            const char* text,
+            cocos2d::CCObject* target,
+            cocos2d::SEL_MenuHandler callback
+        );
+        
+        static TabButton* create(
+            const char* text,
+            cocos2d::CCObject* target,
+            cocos2d::SEL_MenuHandler callback
+        );
+    };
+} // namespace geode
diff --git a/loader/include/Geode/ui/BasedButtonSprite.hpp b/loader/include/Geode/ui/BasedButtonSprite.hpp
new file mode 100644
index 00000000..a492cc68
--- /dev/null
+++ b/loader/include/Geode/ui/BasedButtonSprite.hpp
@@ -0,0 +1,135 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+
+namespace geode {
+    enum class CircleBaseSize {
+        Tiny = 0,    // Equivalent to the tiny delete button
+        Small = 1,   // Equivalent to most circular buttons in the editor
+        Small2 = 2,  // Equivalent to the trash button in the editor
+        Medium = 3,  // Equivalent to most buttons
+        Medium2 = 4, // Equivalent to the bottom buttons in MenuLayer
+        Big = 5,     // Equivalent to the New button
+        Big2 = 6,    // Equivalent to the Account button
+        Large = 7,   // Equivalent to the big Play Button
+    };
+
+    enum class CrossBaseSize {
+        Small = 0,
+        Huge = 1,
+    };
+
+    enum class CircleBaseColor {
+        Green = 0,
+        Pink = 1,
+        Gray = 2,
+        Blue = 3,
+        Cyan = 4,
+        Geode = 5,
+    };
+
+    enum class AccountBaseColor {
+        Blue = 0,
+        Gray = 1,
+        Purple = 2,
+    };
+
+    enum class IconSelectBaseColor {
+        Gray = 0,
+        Selected = 1,
+    };
+
+    enum class EditorBaseColor {
+        LightBlue = 0,
+        Green = 1,
+        Orange = 2,
+        DarkGray = 3,
+        Gray = 4,
+        Pink = 5,
+        Teal = 6,
+        Aqua = 7,
+        Cyan = 8,
+    };
+
+    enum class TabBaseColor {
+        Unselected = 0,
+        Selected = 1,
+        UnselectedDark = 2,
+    };
+
+    enum class BaseType {
+        Circle = 0,
+        Cross = 1,
+        Account = 2,
+        IconSelect = 3,
+        GlobalThing = 4,
+        Editor = 5,
+        Tab = 6,
+    };
+
+    /**
+     * Represents a GD button sprite where there's 
+     * an icon sprite on top another default sprite. 
+     * You know, it has a base. It's based.
+     * lmao trademark lizbith
+     */
+    class GEODE_DLL BasedButtonSprite : public cocos2d::CCSprite {
+    protected:
+        int m_type;
+        int m_size;
+        int m_color;
+        cocos2d::CCNode* m_onTop = nullptr;
+
+        bool init(cocos2d::CCNode* ontop, int type, int size, int color);
+        bool initWithSprite(const char* sprName, float sprScale, int type, int size, int color);
+        bool initWithSpriteFrameName(const char* sprName, float sprScale, int type, int size, int color);
+
+        cocos2d::CCPoint getTopOffset() const;
+
+        virtual ~BasedButtonSprite();
+    
+    public:
+        static BasedButtonSprite* create(cocos2d::CCNode* ontop, int type, int size, int color);
+    };
+
+    class GEODE_DLL CircleButtonSprite : public BasedButtonSprite {
+    public:
+        static CircleButtonSprite* create(
+            cocos2d::CCNode* top,
+            CircleBaseColor color = CircleBaseColor::Green,
+            CircleBaseSize size = CircleBaseSize::Medium
+        );
+        static CircleButtonSprite* createWithSprite(
+            const char* sprName,
+            float sprScale = 1.f,
+            CircleBaseColor color = CircleBaseColor::Green,
+            CircleBaseSize size = CircleBaseSize::Medium
+        );
+        static CircleButtonSprite* createWithSpriteFrameName(
+            const char* sprName,
+            float sprScale = 1.f,
+            CircleBaseColor color = CircleBaseColor::Green,
+            CircleBaseSize size = CircleBaseSize::Medium
+        );
+    };
+
+    class GEODE_DLL EditorButtonSprite : public BasedButtonSprite {
+    public:
+        static EditorButtonSprite* create(cocos2d::CCNode* top, EditorBaseColor color);
+        static EditorButtonSprite* createWithSprite(
+            const char* sprName,
+            float sprScale = 1.f,
+            EditorBaseColor color = EditorBaseColor::Green
+        );
+        static EditorButtonSprite* createWithSpriteFrameName(
+            const char* sprName,
+            float sprScale = 1.f,
+            EditorBaseColor color = EditorBaseColor::Green
+        );
+    };
+
+    class GEODE_DLL TabButtonSprite : public BasedButtonSprite {
+    public:
+        static TabButtonSprite* create(const char* text, TabBaseColor color);
+    };
+}
diff --git a/loader/include/Geode/ui/IconButtonSprite.hpp b/loader/include/Geode/ui/IconButtonSprite.hpp
new file mode 100644
index 00000000..6d823646
--- /dev/null
+++ b/loader/include/Geode/ui/IconButtonSprite.hpp
@@ -0,0 +1,51 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+
+namespace geode {
+    class GEODE_DLL IconButtonSprite :
+        public cocos2d::CCSprite,
+        public cocos2d::CCLabelProtocol
+    {
+    protected:
+        cocos2d::extension::CCScale9Sprite* m_bg = nullptr;
+        cocos2d::CCLabelBMFont* m_label = nullptr;
+        cocos2d::CCNode* m_icon = nullptr;
+
+        bool init(
+            const char* bg,
+            bool bgIsFrame,
+            cocos2d::CCNode* icon,
+            const char* text,
+            const char* font
+        );
+
+        void updateLayout();
+
+        IconButtonSprite() = default;
+        IconButtonSprite(IconButtonSprite&&) = delete;
+        IconButtonSprite& operator=(IconButtonSprite&&) = delete;
+    
+    public:
+        static IconButtonSprite* create(
+            const char* bg,
+            cocos2d::CCNode* icon,
+            const char* text,
+            const char* font
+        );
+        static IconButtonSprite* createWithSpriteFrameName(
+            const char* bg,
+            cocos2d::CCNode* icon,
+            const char* text,
+            const char* font
+        );
+
+        void setBG(const char* bg, bool isFrame);
+
+        void setIcon(cocos2d::CCNode* icon);
+        cocos2d::CCNode* getIcon() const;
+
+        void setString(const char* label) override;
+        const char* getString() override;
+    };
+}
diff --git a/loader/include/Geode/ui/InputNode.hpp b/loader/include/Geode/ui/InputNode.hpp
new file mode 100644
index 00000000..3819d706
--- /dev/null
+++ b/loader/include/Geode/ui/InputNode.hpp
@@ -0,0 +1,53 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+
+namespace geode {
+    class GEODE_DLL InputNode : public cocos2d::CCNode {
+     protected:
+        cocos2d::extension::CCScale9Sprite* m_bgSprite;
+        CCTextInputNode* m_input;
+
+        bool init(float, float, const char*, const char*, std::string const&, int);
+        bool init(float, const char*, const char*, std::string const&, int);
+
+     public:
+        static InputNode* create(
+            float width,
+            const char* placeholder,
+            const char* fontFile,
+            std::string const& filter,
+            int limit
+        );
+        static InputNode* create(
+            float width,
+            const char* placeholder,
+            std::string const& filter,
+            int limit
+        );
+        static InputNode* create(
+            float width,
+            const char* placeholder,
+            std::string const& filter
+        );
+        static InputNode* create(
+            float width,
+            const char* placeholder,
+            const char* fontFile
+        );
+        static InputNode* create(
+            float width,
+            const char* placeholder
+        );
+
+        CCTextInputNode* getInputNode() const;
+        cocos2d::extension::CCScale9Sprite* getBGSprite() const;
+
+        void setEnabled(bool);
+
+        void setString(const char*);
+        const char* getString();
+    };
+}
+
+
diff --git a/loader/include/Geode/ui/ListView.hpp b/loader/include/Geode/ui/ListView.hpp
new file mode 100644
index 00000000..f436dda9
--- /dev/null
+++ b/loader/include/Geode/ui/ListView.hpp
@@ -0,0 +1,48 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+
+namespace geode {
+    class GEODE_DLL GenericListCell : public TableViewCell {
+    protected:
+        GenericListCell(const char* name, cocos2d::CCSize size);
+
+        void draw() override;
+
+    public:
+        static GenericListCell* create(const char* key, cocos2d::CCSize size);
+
+        void updateBGColor(int index);
+    };
+
+    /**
+     * Class for a generic scrollable list of 
+     * items like the level list in GD
+     */
+    class GEODE_DLL ListView : public CustomListView {
+    protected:
+        void setupList() override;
+        TableViewCell* getListCell(const char* key) override;
+        void loadCell(TableViewCell* cell, unsigned int index) override;
+
+    public:
+        /**
+         * Create a generic scrollable list of 
+         * items
+         * @param items Nodes to add as children
+         * @param itemHeight Height of each child
+         * @param width Width of the list
+         * @param height Height of the list
+         * @returns The created ListView, or nullptr 
+         * on error
+         */
+        static ListView* create(
+            cocos2d::CCArray* items,
+            float itemHeight = 40.f,
+            float width = 358.f,
+            float height = 220.f
+        );
+    };
+}
+
+
diff --git a/loader/include/Geode/ui/MDTextArea.hpp b/loader/include/Geode/ui/MDTextArea.hpp
new file mode 100644
index 00000000..184e921c
--- /dev/null
+++ b/loader/include/Geode/ui/MDTextArea.hpp
@@ -0,0 +1,74 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+#include "TextRenderer.hpp"
+#include "ScrollLayer.hpp"
+
+struct MDParser;
+
+namespace geode {
+    /**
+     * TextArea for static markdown content. Supports the 
+     * following features:
+     *  - Links
+     *  - Images (sprites & spritesheets)
+     *  - Headings
+     *  - Paragraphs
+     *  - Code blocks
+     *  - Code spans
+     *  - TextArea color tags (<cr>, <cy>, etc.)
+     *  - Strikethrough
+     *  - Underline
+     *  - Bold & italic
+     *  - Horizontal rules
+     *  - Lists
+     * Note that links also have some special protocols. 
+     * Use `user:<id>` or `user:<name>` to link to a GD 
+     * account; `level:<id>` to link to a GD level and 
+     * `mod:<id>` to link to another Geode mod.
+     */
+    class GEODE_DLL MDTextArea :
+        public cocos2d::CCLayer,
+        public cocos2d::CCLabelProtocol,
+        public FLAlertLayerProtocol
+    {
+    protected:
+        std::string m_text;
+        cocos2d::CCSize m_size;
+        cocos2d::extension::CCScale9Sprite* m_bgSprite = nullptr;
+        cocos2d::CCMenu* m_content = nullptr;
+        CCScrollLayerExt* m_scrollLayer = nullptr;
+        TextRenderer* m_renderer = nullptr;
+
+        bool init(std::string const& str, cocos2d::CCSize const& size);
+
+        virtual ~MDTextArea();
+
+        void onLink(CCObject*);
+        void onGDProfile(CCObject*);
+        void FLAlert_Clicked(FLAlertLayer*, bool btn) override;
+
+        friend struct ::MDParser;
+
+    public:
+        /**
+         * Create a markdown text area. See class
+         * documentation for details on supported
+         * features & notes.
+         * @param str String to render
+         * @param size Size of the textarea
+         */
+        static MDTextArea* create(std::string const& str, cocos2d::CCSize const& size);
+
+        /**
+         * Update the label's content; call
+         * sparingly as rendering may be slow
+         */
+        void updateLabel();
+
+        void setString(const char* text) override;
+        const char* getString() override;
+
+        CCScrollLayerExt* getScrollLayer() const;
+    };
+}
diff --git a/loader/include/Geode/ui/MenuInputNode.hpp b/loader/include/Geode/ui/MenuInputNode.hpp
new file mode 100644
index 00000000..c046226d
--- /dev/null
+++ b/loader/include/Geode/ui/MenuInputNode.hpp
@@ -0,0 +1,39 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+
+namespace geode {
+    /**
+     * Simple wrapper around CCTextInputNode that 
+     * turns it into a CCMenuItem that can be used 
+     * in a CCMenu. Can help with touch dispatcher 
+     * issues. Also comes with a background sprite
+     */
+    class GEODE_DLL MenuInputNode : public cocos2d::CCMenuItem {
+    protected:
+        cocos2d::extension::CCScale9Sprite* m_bgSprite = nullptr;
+        CCTextInputNode* m_input;
+
+        bool init(
+            float width,
+            float height,
+            const char* placeholder,
+            const char* font,
+            bool bg
+        );
+
+    public:
+        static MenuInputNode* create(
+            float width,
+            float height,
+            const char* placeholder,
+            const char* font,
+            bool bg = false
+        );
+
+        void selected() override;
+
+        CCTextInputNode* getInput() const;
+    };
+}
+
diff --git a/loader/include/Geode/ui/Notification.hpp b/loader/include/Geode/ui/Notification.hpp
new file mode 100644
index 00000000..dc4b2a13
--- /dev/null
+++ b/loader/include/Geode/ui/Notification.hpp
@@ -0,0 +1,188 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+#include "SceneManager.hpp"
+#include <chrono>
+#include "../utils/Ref.hpp"
+
+namespace geode {
+    enum class NotificationLocation {
+        TopLeft,
+        TopCenter,
+        TopRight,
+        BottomLeft,
+        BottomCenter,
+        BottomRight,
+    };
+
+    static constexpr float DEFAULT_NOTIFICATION_TIME = 4.f;
+    static constexpr NotificationLocation PLATFORM_NOTIFICATION_LOCATION = 
+    #ifdef GEODE_IS_DESKTOP 
+        NotificationLocation::BottomRight;
+    #else
+        NotificationLocation::TopCenter;
+    #endif;
+
+    class Notification;
+    class NotificationManager;
+
+    struct GEODE_DLL NotificationBuilder {
+        Mod* m_owner = Mod::get();
+        std::string m_title = "";
+        std::string m_text = "";
+        std::string m_icon = "GJ_infoIcon_001.png";
+        Ref<cocos2d::CCNode> m_iconNode = nullptr;
+        std::string m_bg = "GJ_square02.png";
+        std::function<void(Notification*)> m_callback = nullptr;
+        float m_time = DEFAULT_NOTIFICATION_TIME;
+        NotificationLocation m_location = PLATFORM_NOTIFICATION_LOCATION;
+        bool m_hideOnClick = true;
+
+        inline NotificationBuilder& from(Mod* owner) {
+            m_owner = owner;
+            return *this;
+        }
+        inline NotificationBuilder& title(std::string const& title) {
+            m_title = title;
+            return *this;
+        }
+        inline NotificationBuilder& text(std::string const& text) {
+            m_text = text;
+            return *this;
+        }
+        inline NotificationBuilder& icon(std::string const& icon) {
+            m_icon = icon;
+            m_iconNode = nullptr;
+            return *this;
+        }
+        inline NotificationBuilder& icon(cocos2d::CCNode* icon) {
+            m_icon = "";
+            m_iconNode = icon;
+            return *this;
+        }
+        inline NotificationBuilder& loading() {
+            auto spr = cocos2d::CCSprite::create("loadingCircle.png");
+            spr->runAction(cocos2d::CCRepeat::create(
+                cocos2d::CCRotateBy::create(1.f, 360.f), 40000
+            ));
+            spr->setBlendFunc({ GL_ONE, GL_ONE });
+            return this->icon(spr);
+        }
+        inline NotificationBuilder& bg(std::string const& bg) {
+            m_bg = bg;
+            return *this;
+        }
+        inline NotificationBuilder& location(NotificationLocation location) {
+            m_location = location;
+            return *this;
+        }
+        inline NotificationBuilder& time(float time) {
+            m_time = time;
+            return *this;
+        }
+        inline NotificationBuilder& stay() {
+            m_time = .0f;
+            return *this;
+        }
+        inline NotificationBuilder& clicked(
+            std::function<void(Notification*)> cb,
+            bool hide = true
+        ) {
+            m_callback = cb;
+            m_hideOnClick = hide;
+            return *this;
+        }
+        Notification* show();
+    };
+
+    class GEODE_DLL Notification : public cocos2d::CCLayer {
+    protected:
+        Mod* m_owner;
+        std::function<void(Notification*)> m_callback = nullptr;
+        cocos2d::extension::CCScale9Sprite* m_bg;
+        cocos2d::CCNode* m_icon = nullptr;
+        cocos2d::CCLabelBMFont* m_title = nullptr;
+        Ref<cocos2d::CCArray> m_labels = nullptr;
+        cocos2d::CCPoint m_showDest;
+        cocos2d::CCPoint m_hideDest;
+        cocos2d::CCPoint m_posAtTouchStart;
+        NotificationLocation m_location;
+        float m_time;
+        bool m_hiding = false;
+        bool m_clicking;
+        bool m_hovered;
+        bool m_hideOnClicked = true;
+        float m_targetScale = 1.f;
+
+        bool init(
+            Mod* owner,
+            std::string const& title,
+            std::string const& text,
+            cocos2d::CCNode* icon,
+            const char* bg,
+            std::function<void(Notification*)> callback,
+            bool hideOnClick
+        );
+
+        Notification();
+        virtual ~Notification();
+
+        bool ccTouchBegan(
+            cocos2d::CCTouch* touch, cocos2d::CCEvent* event
+        ) override;
+        void ccTouchEnded(
+            cocos2d::CCTouch* touch, cocos2d::CCEvent* event
+        ) override;
+        void ccTouchMoved(
+            cocos2d::CCTouch* touch, cocos2d::CCEvent* event
+        ) override;
+        void registerWithTouchDispatcher() override;
+
+        void clicked();
+
+        void animateIn();
+        void animateOut();
+        void animateOutClicked();
+        void animateClicking();
+
+        void hidden();
+        void showForReal();
+
+        friend class NotificationManager;
+
+    public:
+        static Notification* create(
+            Mod* owner,
+            std::string const& title,
+            std::string const& text,
+            cocos2d::CCNode* icon,
+            const char* bg,
+            std::function<void(Notification*)> callback,
+            bool hideOnClick
+        );
+        static NotificationBuilder build();
+
+        void show(
+            NotificationLocation = PLATFORM_NOTIFICATION_LOCATION,
+            float time = DEFAULT_NOTIFICATION_TIME
+        );
+        void hide();
+    };
+
+    class NotificationManager {
+    protected:
+        std::unordered_map<
+            NotificationLocation, std::vector<Ref<Notification>>
+        > m_notifications;
+
+        void push(Notification*);
+        void pop(Notification*);
+
+        bool isInQueue(Notification*);
+
+        friend class Notification;
+    
+    public:
+        static NotificationManager* get();
+    };
+}
diff --git a/loader/include/Geode/ui/Popup.hpp b/loader/include/Geode/ui/Popup.hpp
new file mode 100644
index 00000000..127d04a5
--- /dev/null
+++ b/loader/include/Geode/ui/Popup.hpp
@@ -0,0 +1,74 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+
+namespace geode {
+    template<typename T, typename... InitArgs>
+    class GEODE_DLL Popup : public FLAlertLayer {
+    protected:
+        cocos2d::CCSize m_size;
+        cocos2d::extension::CCScale9Sprite* m_bgSprite;
+
+        bool init(
+            float width,
+            float height,
+            InitArgs... args,
+            const char* bg = "GJ_square01.png"
+        ) {
+            auto winSize = cocos2d::CCDirector::sharedDirector()->getWinSize();
+            m_size = cocos2d::CCSize{width, height};
+
+            if (!this->initWithColor({0, 0, 0, 105})) return false;
+            m_mainLayer = cocos2d::CCLayer::create();
+            this->addChild(m_mainLayer);
+
+            m_bgSprite = cocos2d::extension::CCScale9Sprite::create(bg, {0.0f, 0.0f, 80.0f, 80.0f});
+            m_bgSprite->setContentSize(m_size);
+            m_bgSprite->setPosition(winSize.width / 2, winSize.height / 2);
+            m_mainLayer->addChild(m_bgSprite);
+
+            m_buttonMenu = cocos2d::CCMenu::create();
+            m_mainLayer->addChild(m_buttonMenu);
+
+            cocos2d::CCDirector::sharedDirector()->getTouchDispatcher()->incrementForcePrio(2);
+            this->registerWithTouchDispatcher();
+
+            this->setup(args...);
+
+            auto closeSpr = cocos2d::CCSprite::createWithSpriteFrameName("GJ_closeBtn_001.png");
+            closeSpr->setScale(.8f);
+
+            auto closeBtn = CCMenuItemSpriteExtra::create(closeSpr, this, (cocos2d::SEL_MenuHandler)(&Popup::onClose));
+            closeBtn->setPosition(-m_size.width / 2 + 3.f, m_size.height / 2 - 3.f);
+            m_buttonMenu->addChild(closeBtn);
+
+            this->setKeypadEnabled(true);
+            this->setTouchEnabled(true);
+
+            return true;
+        }
+
+        virtual void setup(InitArgs... args) = 0;
+
+        void keyDown(cocos2d::enumKeyCodes key) {
+            if (key == cocos2d::enumKeyCodes::KEY_Escape) return this->onClose(nullptr);
+            if (key == cocos2d::enumKeyCodes::KEY_Space) return;
+            return FLAlertLayer::keyDown(key);
+        }
+
+        virtual void onClose(cocos2d::CCObject*) {
+            this->setKeyboardEnabled(false);
+            this->removeFromParentAndCleanup(true);
+        }
+    };
+
+    void GEODE_DLL createQuickPopup(
+        const char* title,
+        std::string const& content,
+        const char* btn1,
+        const char* btn2,
+        std::function<void(FLAlertLayer*, bool)> selected
+    );
+}
+
+
diff --git a/loader/include/Geode/ui/SceneManager.hpp b/loader/include/Geode/ui/SceneManager.hpp
new file mode 100644
index 00000000..36b1aef1
--- /dev/null
+++ b/loader/include/Geode/ui/SceneManager.hpp
@@ -0,0 +1,22 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+
+namespace geode {
+    class GEODE_DLL SceneManager {
+    protected:
+        cocos2d::CCArray* m_persistedNodes;
+
+        bool setup();
+
+        virtual ~SceneManager();
+
+    public:
+        static SceneManager* get();
+
+        void keepAcrossScenes(cocos2d::CCNode* node);
+        void forget(cocos2d::CCNode* node);
+
+        void willSwitchToScene(cocos2d::CCScene* scene);
+    };
+}
diff --git a/loader/include/Geode/ui/ScrollLayer.hpp b/loader/include/Geode/ui/ScrollLayer.hpp
new file mode 100644
index 00000000..3a49c78d
--- /dev/null
+++ b/loader/include/Geode/ui/ScrollLayer.hpp
@@ -0,0 +1,46 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+
+namespace geode {
+    /**
+     * CCContentLayer expects all of its children 
+     * to be TableViewCells, which is not ideal for 
+     * a generic content layer
+     */
+    class GEODE_DLL GenericContentLayer : public CCContentLayer {
+    public:
+        static GenericContentLayer* create(
+            float width, float height
+        );
+
+        void setPosition(cocos2d::CCPoint const& pos) override;
+    };
+
+    class GEODE_DLL ScrollLayer : public CCScrollLayerExt {
+    protected:
+        bool m_scrollWheelEnabled;
+
+        ScrollLayer(
+            cocos2d::CCRect const& rect,
+            bool scrollWheelEnabled,
+            bool vertical
+        );
+
+    public:
+        static ScrollLayer* create(
+            cocos2d::CCRect const& rect,
+            bool scrollWheelEnabled = true,
+            bool vertical = true
+        );
+        static ScrollLayer* create(
+            cocos2d::CCSize const& size,
+            bool scrollWheelEnabled = true,
+            bool vertical = true
+        );
+
+        void scrollWheel(float y, float) override;
+        void enableScrollWheel(bool enable = true);
+    };
+}
+
diff --git a/loader/include/Geode/ui/Scrollbar.hpp b/loader/include/Geode/ui/Scrollbar.hpp
new file mode 100644
index 00000000..5b7c8ea9
--- /dev/null
+++ b/loader/include/Geode/ui/Scrollbar.hpp
@@ -0,0 +1,34 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+
+namespace geode {
+    class GEODE_DLL Scrollbar :
+        public cocos2d::CCLayer
+        // public ExtMouseDelegate
+    {
+    protected:
+        CCScrollLayerExt* m_target = nullptr;
+        cocos2d::extension::CCScale9Sprite* m_track;
+        cocos2d::extension::CCScale9Sprite* m_thumb;
+        cocos2d::CCPoint m_clickOffset;
+        float m_width;
+        bool m_resizeThumb;
+        bool m_trackIsRotated;
+        bool m_hoverHighlight;
+
+        // bool mouseDownExt(MouseEvent, cocos2d::CCPoint const&) override;
+        // bool mouseUpExt(MouseEvent, cocos2d::CCPoint const&) override;
+        // void mouseMoveExt(cocos2d::CCPoint const&) override;
+        void scrollWheel(float y, float x) override;
+    
+        void draw() override;
+
+        bool init(CCScrollLayerExt*);
+
+    public:
+        void setTarget(CCScrollLayerExt* list);
+
+        static Scrollbar* create(CCScrollLayerExt* list);
+    };
+}
diff --git a/loader/include/Geode/ui/TextRenderer.hpp b/loader/include/Geode/ui/TextRenderer.hpp
new file mode 100644
index 00000000..a5f664b0
--- /dev/null
+++ b/loader/include/Geode/ui/TextRenderer.hpp
@@ -0,0 +1,398 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+
+namespace geode {
+    enum class TextAlignment {
+        Begin,
+        Center,
+        End,
+    };
+
+    enum class TextCapitalization {
+        Normal,
+        AllUpper,
+        AllLower,
+    };
+
+    // enum only as these are flags
+    enum TextStyle {
+        TextStyleRegular = 0b0,
+        TextStyleBold    = 0b1,
+        TextStyleItalic  = 0b10,
+    };
+
+    // enum only as these are flags
+    enum TextDecoration {
+        TextDecorationNone         = 0b0,
+        TextDecorationUnderline    = 0b1,
+        TextDecorationStrikethrough= 0b10,
+    };
+
+    class TextDecorationWrapper;
+    class TextLinkedButtonWrapper;
+
+    /**
+     * Utility class for creating rich text content. 
+     * Use to incrementally render strings, and push 
+     * variables to modify the renderer's state. Use 
+     * `begin` to start rendering to a target and 
+     * `end` to finish rendering.
+     * 
+     * Works for any type of label, although relies 
+     * heavily on content sizes for labels and nodes.
+     * 
+     * Not too well-performant and the rendering is 
+     * done linearly without so this is not suitable 
+     * for dynamic content. For something like a 
+     * static rich text -area though this can prove 
+     * useful. Used in MDTextArea.
+     */
+    class GEODE_DLL TextRenderer : public cocos2d::CCObject {
+    public:
+        /**
+         * Represents a label. As CCLabelBMFont and 
+         * CCLabelTTF have different inheritance 
+         * structures, this class can handle either 
+         * one universally. All relevant vtables are 
+         * stored in-class to avoid needing to 
+         * `dynamic_cast` everything.
+         */
+        struct Label {
+            /**
+             * Label's CCNode vtable
+             */
+            cocos2d::CCNode* m_node;
+            /**
+             * Label's CCLabelProtocol vtable
+             */
+            cocos2d::CCLabelProtocol* m_labelProtocol;
+            /**
+             * Label's CCRGBAProtocol vtable
+             */
+            cocos2d::CCRGBAProtocol* m_rgbaProtocol;
+            /**
+             * Line height. If 0, the renderer will dynamically 
+             * calculate line height based on content size.
+             */
+            float m_lineHeight;
+
+            explicit inline Label() {
+                m_node = nullptr;
+                m_labelProtocol = nullptr;
+                m_rgbaProtocol = nullptr;
+                m_lineHeight = .0f;
+            }
+
+            template<class T>
+            Label(T* label, float lineHeight = .0f) {
+                static_assert(std::is_base_of_v<cocos2d::CCNode, T>, "Label must inherit from CCNode!");
+                static_assert(std::is_base_of_v<cocos2d::CCLabelProtocol, T>, "Label must inherit from CCLabelProtocol!");
+                static_assert(std::is_base_of_v<cocos2d::CCRGBAProtocol, T>, "Label must inherit from CCRGBAProtocol!");
+                m_node = label;
+                m_labelProtocol = label;
+                m_rgbaProtocol = label;
+                if (lineHeight) {
+                    m_lineHeight = lineHeight;
+                } else {
+                    if constexpr (std::is_same_v<cocos2d::CCLabelBMFont, T>) {
+                        m_lineHeight = label->getConfiguration()->m_nCommonHeight / cocos2d::CC_CONTENT_SCALE_FACTOR();
+                    }
+                }
+            }
+        };
+        /**
+         * Label generator function. The `int` parameter 
+         * represents the current text style flags. Use 
+         * to distinguish between bold, italic and 
+         * regular text.
+         */
+        using Font = std::function<Label(int)>;
+
+    protected:
+        cocos2d::CCPoint m_origin = cocos2d::CCPointZero;
+        cocos2d::CCSize m_size = cocos2d::CCSizeZero;
+        cocos2d::CCPoint m_cursor = cocos2d::CCPointZero;
+        cocos2d::CCNode* m_target = nullptr;
+        std::vector<Font> m_fontStack;
+        std::vector<float> m_scaleStack;
+        std::vector<int> m_styleStack;
+        std::vector<cocos2d::ccColor3B> m_colorStack;
+        std::vector<GLubyte> m_opacityStack;
+        std::vector<int> m_decorationStack;
+        std::vector<TextCapitalization> m_capsStack;
+        std::vector<Label> m_lastRendered;
+        std::vector<float> m_indentationStack;
+        std::vector<float> m_wrapOffsetStack;
+        std::vector<TextAlignment> m_hAlignmentStack;
+        std::vector<TextAlignment> m_vAlignmentStack;
+        std::vector<cocos2d::CCNode*> m_renderedLine;
+        cocos2d::CCNode* m_lastRenderedNode = nullptr;
+
+        bool init();
+
+        Label addWrappers(
+            Label const& label,
+            bool isButton,
+            cocos2d::CCObject* target,
+            cocos2d::SEL_MenuHandler callback
+        );
+        bool render(std::string const& word, cocos2d::CCNode* to, cocos2d::CCLabelProtocol* label);
+        float adjustLineAlignment();
+
+    public:
+        /**
+         * Create a TextRenderer
+         * @returns Created TextRenderer
+         */
+        static TextRenderer* create();
+        virtual ~TextRenderer();
+
+        /**
+         * Initialize renderer
+         * @param target Target node to render to. If nullptr, 
+         * a new CCNode will be created.
+         * @param pos Position to render to
+         * @param size Size of the render area. Needed for 
+         * text wrapping & alignment
+         */
+        void begin(
+            cocos2d::CCNode* target,
+            cocos2d::CCPoint const& pos = cocos2d::CCPointZero,
+            cocos2d::CCSize const& size = cocos2d::CCSizeZero
+        );
+        /**
+         * Finish rendering and clean up renderer
+         * @param fitToContent Resize the target's content 
+         * size to match the rendered content
+         * @param horizontalAlign Horizontal alignment of 
+         * the rendered text
+         * @param verticalAlign Vertical alignment of 
+         * the rendered text
+         * @returns Target that was rendered onto
+         */
+        cocos2d::CCNode* end(
+            bool fitToContent = true,
+            TextAlignment horizontalAlign = TextAlignment::Begin,
+            TextAlignment verticalAlign = TextAlignment::Begin
+        );
+
+        /**
+         * Render a string with specific settings, bypassing 
+         * current variable stacks.
+         * @param str String to render
+         * @param font Font function to use
+         * @param scale Scale of label
+         * @param color Label color
+         * @param opacity Label opacity
+         * @param style Label style (TextStyle enum)
+         * @param deco Label decorations (TextDecoration enum)
+         * @param caps String capitalization
+         * @param addToTarget Whether to add the created label(s) 
+         * onto the target
+         * @param isButton If the label should be created as an 
+         * interactive linked button
+         * @param buttonTarget Target for the label if isButton is 
+         * true, defaults to current renderer target
+         * @param callback Callback for the label if isButton is 
+         * true
+         * @returns Vector of rendered labels. The label may be 
+         * split on multiple lines if it exceeds bounds
+         */
+        std::vector<Label> renderStringEx(
+            std::string const& str,
+            Font font,
+            float scale,
+            cocos2d::ccColor3B color = { 255, 255, 255 },
+            GLubyte opacity = 255,
+            int style = TextStyleRegular,
+            int deco = TextDecorationNone,
+            TextCapitalization caps = TextCapitalization::Normal,
+            bool addToTarget = true,
+            bool isButton = false,
+            cocos2d::CCObject* buttonTarget = nullptr,
+            cocos2d::SEL_MenuHandler callback = nullptr
+        );
+        /**
+         * Render a string to target. Uses current variable stacks 
+         * for styling and parameters
+         * @param str String to render
+         * @returns Vector of rendered labels. The label may be 
+         * split on multiple lines if it exceeds bounds
+         */
+        std::vector<Label> renderString(std::string const& str);
+        /**
+         * Render a string to target as a button. Note that the 
+         * target should be a CCMenu for the button to do 
+         * anything. Uses current variable stacks  for styling 
+         * and parameters
+         * @param str String to render
+         * @param buttonTarget Target for the label if isButton is 
+         * true, defaults to current renderer target
+         * @param callback Callback for the label if isButton is 
+         * true
+         * @returns Vector of rendered labels. The label may be 
+         * split on multiple lines if it exceeds bounds
+         */
+        std::vector<Label> renderStringInteractive(
+            std::string const& str,
+            cocos2d::CCObject* buttonTarget,
+            cocos2d::SEL_MenuHandler callback
+        );
+        /**
+         * Render a node to the current target, use for adding 
+         * images & other content in the middle of text
+         * @param node Node to render
+         * @returns Rendered node
+         */
+        cocos2d::CCNode* renderNode(cocos2d::CCNode* node);
+        /**
+         * Start next line
+         * @param y Y offset amount from previous line. If 0, 
+         * will dynamically figure out based on content size
+         */
+        void breakLine(float y = .0f);
+
+        /**
+         * Helper for pushing a CCLabelBMFont. Make 
+         * sure the const char* outlives the renderer.
+         */
+        void pushBMFont(const char* bmFont);
+        void pushFont(Font const& font);
+        void popFont();
+        Font getCurrentFont() const;
+
+        void pushScale(float scale);
+        void popScale();
+        float getCurrentScale() const;
+
+        void pushStyleFlags(int style);
+        void popStyleFlags();
+        int getCurrentStyle() const;
+
+        void pushColor(cocos2d::ccColor3B const& color);
+        void popColor();
+        cocos2d::ccColor3B getCurrentColor() const;
+
+        void pushOpacity(GLubyte opacity);
+        void popOpacity();
+        GLubyte getCurrentOpacity() const;
+
+        void pushDecoFlags(int deco);
+        void popDecoFlags();
+        int getCurrentDeco() const;
+
+        void pushCaps(TextCapitalization caps);
+        void popCaps();
+        TextCapitalization getCurrentCaps() const;
+
+        void pushIndent(float indent);
+        void popIndent();
+        float getCurrentIndent() const;
+
+        void pushWrapOffset(float wrapOffset);
+        void popWrapOffset();
+        float getCurrentWrapOffset() const;
+
+        void pushVerticalAlign(TextAlignment align);
+        void popVerticalAlign();
+        TextAlignment getCurrentVerticalAlign() const;
+
+        void pushHorizontalAlign(TextAlignment align);
+        void popHorizontalAlign();
+        TextAlignment getCurrentHorizontalAlign() const;
+
+        void moveCursor(cocos2d::CCPoint const& pos);
+        cocos2d::CCPoint const& getCursorPos();
+    };
+
+    /**
+     * Wrapper node for adding decorations (strikethrough, 
+     * underline) to an arbitary label. Is not agnostic of 
+     * font and as such will always render simple lines
+     */
+    class TextDecorationWrapper : public cocos2d::CCNodeRGBA, public cocos2d::CCLabelProtocol {
+    protected:
+        int m_deco;
+        TextRenderer::Label m_label;
+
+        bool init(
+            TextRenderer::Label const& label,
+            int decoration,
+            cocos2d::ccColor3B const& color,
+            GLubyte opacity
+        );
+
+        void draw() override;
+
+    public:
+        static TextDecorationWrapper* create(
+            TextRenderer::Label const& label,
+            int decoration,
+            cocos2d::ccColor3B const& color,
+            GLubyte opacity
+        );
+        static TextDecorationWrapper* wrap(
+            TextRenderer::Label const& label,
+            int decoration,
+            cocos2d::ccColor3B const& color,
+            GLubyte opacity
+        );
+
+        void setColor(cocos2d::ccColor3B const& color) override;
+        void setOpacity(GLubyte opacity) override;
+        void updateDisplayedColor(cocos2d::ccColor3B const& color) override;
+        void updateDisplayedOpacity(GLubyte opacity) override;
+
+        void setString(const char* text) override;
+        const char* getString() override;
+    };
+
+    /**
+     * Wrapper node for making a label clickable. 
+     * Note that this should always be the top 
+     * wrapper above all other wrappers
+     */
+    class TextLinkedButtonWrapper :
+        public cocos2d::CCMenuItemSprite,
+        public cocos2d::CCLabelProtocol
+    {
+    protected:
+        TextRenderer::Label m_label;
+        GLubyte m_opacity;
+        cocos2d::ccColor3B m_color;
+        std::vector<TextLinkedButtonWrapper*> m_linked;
+
+        bool init(
+            TextRenderer::Label const& label,
+            cocos2d::CCObject* target,
+            cocos2d::SEL_MenuHandler handler
+        );
+    
+    public:
+        static TextLinkedButtonWrapper* create(
+            TextRenderer::Label const& label,
+            cocos2d::CCObject* target,
+            cocos2d::SEL_MenuHandler handler
+        );
+        static TextLinkedButtonWrapper* wrap(
+            TextRenderer::Label const& label,
+            cocos2d::CCObject* target,
+            cocos2d::SEL_MenuHandler handler
+        );
+
+        void link(TextLinkedButtonWrapper* other);
+
+        void selectedWithoutPropagation(bool selected);
+        void selected() override;
+        void unselected() override;
+
+        void setColor(cocos2d::ccColor3B const& color) override;
+        void setOpacity(GLubyte opacity) override;
+        void updateDisplayedColor(cocos2d::ccColor3B const& color) override;
+        void updateDisplayedOpacity(GLubyte opacity) override;
+
+        void setString(const char* text) override;
+        const char* getString() override;
+    };
+}
diff --git a/loader/include/Geode/utils/Ref.hpp b/loader/include/Geode/utils/Ref.hpp
new file mode 100644
index 00000000..94fac82e
--- /dev/null
+++ b/loader/include/Geode/utils/Ref.hpp
@@ -0,0 +1,97 @@
+#pragma once
+
+#include <cocos2d.h>
+
+namespace geode {
+    /**
+     * A smart pointer to a managed CCObject-deriving class. Retains shared 
+     * ownership over the managed instance. Releases the object when the Ref 
+     * is destroyed, or assigned another object or nullptr.
+     * 
+     * Use-cases include, for example, non-CCNode class members, or nodes that 
+     * are not always in the scene tree.
+     * 
+     * @example class MyNode : public CCNode {
+     * protected:
+     *      // no need to manually call retain or 
+     *      // release on this array; Ref manages it 
+     *      // for you :3
+     *      Ref<CCArray> m_list = CCArray::create();
+     * };
+     */
+    template<class T>
+    class Ref final {
+        static_assert(
+            std::is_base_of_v<cocos2d::CCObject, T>,
+            "Ref can only be used with a CCObject-inheriting class!"
+        );
+
+        T* m_obj = nullptr;
+
+    public:
+        /**
+         * Construct a Ref of an object. The object will be retained and 
+         * managed until Ref goes out of scope
+         */
+        Ref(T* obj) : m_obj(obj) {
+            CC_SAFE_RETAIN(obj);
+        }
+        Ref(Ref<T> const& other) : Ref(other.data()) {}
+        Ref(Ref<T>&& other) : m_obj(other.m_obj) {
+            other.m_obj = nullptr;
+        }
+        /**
+         * Construct an empty Ref (the managed object will be null)
+         */
+        Ref() = default;
+        ~Ref() {
+            CC_SAFE_RELEASE(m_obj);
+        }
+
+        /**
+         * Swap the managed object with another object. The managed object 
+         * will be released, and the new object retained
+         * @param other The new object to swap to
+         */
+        void swap(T* other) {
+            CC_SAFE_RELEASE(m_obj);
+            m_obj = other;
+            CC_SAFE_RETAIN(other);
+        }
+        /**
+         * Return the managed object
+         * @returns The managed object
+         */
+        T* data() const {
+            return m_obj;
+        }
+
+        operator T*() const {
+            return m_obj;
+        }
+        T* operator*() const {
+            return m_obj;
+        }
+        T* operator->() const {
+            return m_obj;
+        }
+        T* operator=(T* obj) {
+            this->swap(obj);
+            return obj;
+        }
+        Ref<T>& operator=(Ref<T> const& other) {
+            this->swap(other.data());
+            return *this;
+        }
+        Ref<T>& operator=(Ref<T>&& other) {
+            this->swap(other.data());
+            return *this;
+        }
+        bool operator==(T* other) const {
+            return m_obj == other;
+        }
+        bool operator==(Ref<T> const& other) const {
+            return m_obj == other.m_obj;
+        }
+    };
+}
diff --git a/loader/md4c b/loader/md4c
new file mode 160000
index 00000000..e9ff661f
--- /dev/null
+++ b/loader/md4c
@@ -0,0 +1 @@
+Subproject commit e9ff661ff818ee94a4a231958d9b6768dc6882c9
diff --git a/loader/resources/blanks/GEODE_blank00_00_00-uhd.png b/loader/resources/blanks/GEODE_blank00_00_00-uhd.png
new file mode 100644
index 00000000..1d0a28d7
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_00_00-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_00_01-uhd.png b/loader/resources/blanks/GEODE_blank00_00_01-uhd.png
new file mode 100644
index 00000000..02f86ec8
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_00_01-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_00_02-uhd.png b/loader/resources/blanks/GEODE_blank00_00_02-uhd.png
new file mode 100644
index 00000000..1bd93285
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_00_02-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_00_03-uhd.png b/loader/resources/blanks/GEODE_blank00_00_03-uhd.png
new file mode 100644
index 00000000..f1d70b2b
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_00_03-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_00_04-uhd.png b/loader/resources/blanks/GEODE_blank00_00_04-uhd.png
new file mode 100644
index 00000000..f953b2fc
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_00_04-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_01_00-uhd.png b/loader/resources/blanks/GEODE_blank00_01_00-uhd.png
new file mode 100644
index 00000000..8f1690ec
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_01_00-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_01_01-uhd.png b/loader/resources/blanks/GEODE_blank00_01_01-uhd.png
new file mode 100644
index 00000000..fd80a292
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_01_01-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_01_02-uhd.png b/loader/resources/blanks/GEODE_blank00_01_02-uhd.png
new file mode 100644
index 00000000..de8b71dc
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_01_02-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_01_03-uhd.png b/loader/resources/blanks/GEODE_blank00_01_03-uhd.png
new file mode 100644
index 00000000..00e86d9e
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_01_03-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_01_04-uhd.png b/loader/resources/blanks/GEODE_blank00_01_04-uhd.png
new file mode 100644
index 00000000..83c21119
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_01_04-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_02_00-uhd.png b/loader/resources/blanks/GEODE_blank00_02_00-uhd.png
new file mode 100644
index 00000000..831acf9b
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_02_00-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_02_01-uhd.png b/loader/resources/blanks/GEODE_blank00_02_01-uhd.png
new file mode 100644
index 00000000..d139fd9c
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_02_01-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_02_02-uhd.png b/loader/resources/blanks/GEODE_blank00_02_02-uhd.png
new file mode 100644
index 00000000..dcc712c6
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_02_02-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_02_03-uhd.png b/loader/resources/blanks/GEODE_blank00_02_03-uhd.png
new file mode 100644
index 00000000..958f148e
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_02_03-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_02_04-uhd.png b/loader/resources/blanks/GEODE_blank00_02_04-uhd.png
new file mode 100644
index 00000000..c777f6ab
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_02_04-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_03_00-uhd.png b/loader/resources/blanks/GEODE_blank00_03_00-uhd.png
new file mode 100644
index 00000000..b50d38f7
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_03_00-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_03_01-uhd.png b/loader/resources/blanks/GEODE_blank00_03_01-uhd.png
new file mode 100644
index 00000000..cf609277
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_03_01-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_03_02-uhd.png b/loader/resources/blanks/GEODE_blank00_03_02-uhd.png
new file mode 100644
index 00000000..e2b59434
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_03_02-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_03_03-uhd.png b/loader/resources/blanks/GEODE_blank00_03_03-uhd.png
new file mode 100644
index 00000000..fb174e72
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_03_03-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_03_04-uhd.png b/loader/resources/blanks/GEODE_blank00_03_04-uhd.png
new file mode 100644
index 00000000..374fef8f
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_03_04-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_04_00-uhd.png b/loader/resources/blanks/GEODE_blank00_04_00-uhd.png
new file mode 100644
index 00000000..de9bcacd
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_04_00-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_04_01-uhd.png b/loader/resources/blanks/GEODE_blank00_04_01-uhd.png
new file mode 100644
index 00000000..2734e40b
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_04_01-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_04_02-uhd.png b/loader/resources/blanks/GEODE_blank00_04_02-uhd.png
new file mode 100644
index 00000000..ceb93598
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_04_02-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_04_03-uhd.png b/loader/resources/blanks/GEODE_blank00_04_03-uhd.png
new file mode 100644
index 00000000..667a1f59
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_04_03-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_04_04-uhd.png b/loader/resources/blanks/GEODE_blank00_04_04-uhd.png
new file mode 100644
index 00000000..61ffcd7d
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_04_04-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_05_00-uhd.png b/loader/resources/blanks/GEODE_blank00_05_00-uhd.png
new file mode 100644
index 00000000..4bec4026
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_05_00-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_05_01-uhd.png b/loader/resources/blanks/GEODE_blank00_05_01-uhd.png
new file mode 100644
index 00000000..eade39db
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_05_01-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_05_02-uhd.png b/loader/resources/blanks/GEODE_blank00_05_02-uhd.png
new file mode 100644
index 00000000..85ce3a8b
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_05_02-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_05_03-uhd.png b/loader/resources/blanks/GEODE_blank00_05_03-uhd.png
new file mode 100644
index 00000000..21957129
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_05_03-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_05_04-uhd.png b/loader/resources/blanks/GEODE_blank00_05_04-uhd.png
new file mode 100644
index 00000000..a06d3814
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_05_04-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_06_00-uhd.png b/loader/resources/blanks/GEODE_blank00_06_00-uhd.png
new file mode 100644
index 00000000..7c042572
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_06_00-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_06_01-uhd.png b/loader/resources/blanks/GEODE_blank00_06_01-uhd.png
new file mode 100644
index 00000000..886024e3
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_06_01-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_06_02-uhd.png b/loader/resources/blanks/GEODE_blank00_06_02-uhd.png
new file mode 100644
index 00000000..ea73faba
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_06_02-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_06_03-uhd.png b/loader/resources/blanks/GEODE_blank00_06_03-uhd.png
new file mode 100644
index 00000000..205e4fdd
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_06_03-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_06_04-uhd.png b/loader/resources/blanks/GEODE_blank00_06_04-uhd.png
new file mode 100644
index 00000000..d5a0ad42
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_06_04-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_07_00-uhd.png b/loader/resources/blanks/GEODE_blank00_07_00-uhd.png
new file mode 100644
index 00000000..b8c12356
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_07_00-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_07_01-uhd.png b/loader/resources/blanks/GEODE_blank00_07_01-uhd.png
new file mode 100644
index 00000000..efd3874a
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_07_01-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_07_02-uhd.png b/loader/resources/blanks/GEODE_blank00_07_02-uhd.png
new file mode 100644
index 00000000..0cd467cc
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_07_02-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_07_03-uhd.png b/loader/resources/blanks/GEODE_blank00_07_03-uhd.png
new file mode 100644
index 00000000..fa5d25f6
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_07_03-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank00_07_04-uhd.png b/loader/resources/blanks/GEODE_blank00_07_04-uhd.png
new file mode 100644
index 00000000..50d04df2
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank00_07_04-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank01_00_00-uhd.png b/loader/resources/blanks/GEODE_blank01_00_00-uhd.png
new file mode 100644
index 00000000..1d44a8d3
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank01_00_00-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank01_01_00-uhd.png b/loader/resources/blanks/GEODE_blank01_01_00-uhd.png
new file mode 100644
index 00000000..619eb833
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank01_01_00-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank02_00_00-uhd.png b/loader/resources/blanks/GEODE_blank02_00_00-uhd.png
new file mode 100644
index 00000000..559538ec
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank02_00_00-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank02_00_01-uhd.png b/loader/resources/blanks/GEODE_blank02_00_01-uhd.png
new file mode 100644
index 00000000..74a96ca2
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank02_00_01-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank02_00_02-uhd.png b/loader/resources/blanks/GEODE_blank02_00_02-uhd.png
new file mode 100644
index 00000000..97b90e4f
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank02_00_02-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank03_00_00-uhd.png b/loader/resources/blanks/GEODE_blank03_00_00-uhd.png
new file mode 100644
index 00000000..5dd3b93c
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank03_00_00-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank03_00_01-uhd.png b/loader/resources/blanks/GEODE_blank03_00_01-uhd.png
new file mode 100644
index 00000000..02d2ac37
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank03_00_01-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank04_00_00-uhd.png b/loader/resources/blanks/GEODE_blank04_00_00-uhd.png
new file mode 100644
index 00000000..ded180c0
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank04_00_00-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank05_00_00-uhd.png b/loader/resources/blanks/GEODE_blank05_00_00-uhd.png
new file mode 100644
index 00000000..00ccfe5d
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank05_00_00-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank05_00_01-uhd.png b/loader/resources/blanks/GEODE_blank05_00_01-uhd.png
new file mode 100644
index 00000000..9162a71f
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank05_00_01-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank05_00_02-uhd.png b/loader/resources/blanks/GEODE_blank05_00_02-uhd.png
new file mode 100644
index 00000000..b2ddde8d
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank05_00_02-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank05_00_03-uhd.png b/loader/resources/blanks/GEODE_blank05_00_03-uhd.png
new file mode 100644
index 00000000..a8ed3f93
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank05_00_03-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank05_00_04-uhd.png b/loader/resources/blanks/GEODE_blank05_00_04-uhd.png
new file mode 100644
index 00000000..faeeb5a7
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank05_00_04-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank05_00_05-uhd.png b/loader/resources/blanks/GEODE_blank05_00_05-uhd.png
new file mode 100644
index 00000000..8a5abb5c
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank05_00_05-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank05_00_06-uhd.png b/loader/resources/blanks/GEODE_blank05_00_06-uhd.png
new file mode 100644
index 00000000..4b492b41
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank05_00_06-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank05_00_07-uhd.png b/loader/resources/blanks/GEODE_blank05_00_07-uhd.png
new file mode 100644
index 00000000..1feb76a2
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank05_00_07-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank05_00_08-uhd.png b/loader/resources/blanks/GEODE_blank05_00_08-uhd.png
new file mode 100644
index 00000000..c726ca74
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank05_00_08-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank06_00_00-uhd.png b/loader/resources/blanks/GEODE_blank06_00_00-uhd.png
new file mode 100644
index 00000000..6d64eb61
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank06_00_00-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank06_00_01-uhd.png b/loader/resources/blanks/GEODE_blank06_00_01-uhd.png
new file mode 100644
index 00000000..ef2b6e25
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank06_00_01-uhd.png differ
diff --git a/loader/resources/blanks/GEODE_blank06_00_02-uhd.png b/loader/resources/blanks/GEODE_blank06_00_02-uhd.png
new file mode 100644
index 00000000..e7966ad4
Binary files /dev/null and b/loader/resources/blanks/GEODE_blank06_00_02-uhd.png differ
diff --git a/loader/resources/filters.png b/loader/resources/filters.png
new file mode 100644
index 00000000..f4b718ce
Binary files /dev/null and b/loader/resources/filters.png differ
diff --git a/loader/resources/fonts/Ubuntu-Bold.ttf b/loader/resources/fonts/Ubuntu-Bold.ttf
new file mode 100644
index 00000000..c2293d5c
Binary files /dev/null and b/loader/resources/fonts/Ubuntu-Bold.ttf differ
diff --git a/loader/resources/fonts/Ubuntu-BoldItalic.ttf b/loader/resources/fonts/Ubuntu-BoldItalic.ttf
new file mode 100644
index 00000000..ce6e784d
Binary files /dev/null and b/loader/resources/fonts/Ubuntu-BoldItalic.ttf differ
diff --git a/loader/resources/fonts/Ubuntu-Italic.ttf b/loader/resources/fonts/Ubuntu-Italic.ttf
new file mode 100644
index 00000000..a599244e
Binary files /dev/null and b/loader/resources/fonts/Ubuntu-Italic.ttf differ
diff --git a/loader/resources/fonts/Ubuntu-Regular.ttf b/loader/resources/fonts/Ubuntu-Regular.ttf
new file mode 100644
index 00000000..f98a2dab
Binary files /dev/null and b/loader/resources/fonts/Ubuntu-Regular.ttf differ
diff --git a/loader/resources/fonts/UbuntuMono-Regular.ttf b/loader/resources/fonts/UbuntuMono-Regular.ttf
new file mode 100644
index 00000000..4977028d
Binary files /dev/null and b/loader/resources/fonts/UbuntuMono-Regular.ttf differ
diff --git a/loader/resources/images/GE_button_01.png b/loader/resources/images/GE_button_01.png
new file mode 100644
index 00000000..1d7a451a
Binary files /dev/null and b/loader/resources/images/GE_button_01.png differ
diff --git a/loader/resources/images/GE_button_02.png b/loader/resources/images/GE_button_02.png
new file mode 100644
index 00000000..03c9d787
Binary files /dev/null and b/loader/resources/images/GE_button_02.png differ
diff --git a/loader/resources/images/GE_button_03.png b/loader/resources/images/GE_button_03.png
new file mode 100644
index 00000000..c482845c
Binary files /dev/null and b/loader/resources/images/GE_button_03.png differ
diff --git a/loader/resources/images/GE_button_04.png b/loader/resources/images/GE_button_04.png
new file mode 100644
index 00000000..0911ae63
Binary files /dev/null and b/loader/resources/images/GE_button_04.png differ
diff --git a/loader/resources/images/scrollbar.png b/loader/resources/images/scrollbar.png
new file mode 100644
index 00000000..27bd3cb7
Binary files /dev/null and b/loader/resources/images/scrollbar.png differ
diff --git a/loader/resources/info-alert.png b/loader/resources/info-alert.png
new file mode 100644
index 00000000..75a1f799
Binary files /dev/null and b/loader/resources/info-alert.png differ
diff --git a/loader/resources/info-warning.png b/loader/resources/info-warning.png
new file mode 100644
index 00000000..64fab516
Binary files /dev/null and b/loader/resources/info-warning.png differ
diff --git a/loader/resources/install.png b/loader/resources/install.png
new file mode 100644
index 00000000..b009173a
Binary files /dev/null and b/loader/resources/install.png differ
diff --git a/loader/resources/logos/geode-circle.png b/loader/resources/logos/geode-circle.png
new file mode 100644
index 00000000..0378f7e8
Binary files /dev/null and b/loader/resources/logos/geode-circle.png differ
diff --git a/loader/resources/logos/geode-logo-color.png b/loader/resources/logos/geode-logo-color.png
new file mode 100644
index 00000000..77200dde
Binary files /dev/null and b/loader/resources/logos/geode-logo-color.png differ
diff --git a/loader/resources/logos/geode-logo-outline-gold.png b/loader/resources/logos/geode-logo-outline-gold.png
new file mode 100644
index 00000000..8e100790
Binary files /dev/null and b/loader/resources/logos/geode-logo-outline-gold.png differ
diff --git a/loader/resources/logos/geode-logo-outline.png b/loader/resources/logos/geode-logo-outline.png
new file mode 100644
index 00000000..f09f936b
Binary files /dev/null and b/loader/resources/logos/geode-logo-outline.png differ
diff --git a/loader/resources/logos/geode-logo.png b/loader/resources/logos/geode-logo.png
new file mode 100644
index 00000000..0b861859
Binary files /dev/null and b/loader/resources/logos/geode-logo.png differ
diff --git a/loader/resources/logos/logo-base.png b/loader/resources/logos/logo-base.png
new file mode 100644
index 00000000..adce6cc9
Binary files /dev/null and b/loader/resources/logos/logo-base.png differ
diff --git a/loader/resources/logos/no-logo.png b/loader/resources/logos/no-logo.png
new file mode 100644
index 00000000..af4fcf8c
Binary files /dev/null and b/loader/resources/logos/no-logo.png differ
diff --git a/loader/resources/resources.json b/loader/resources/resources.json
new file mode 100644
index 00000000..f212e27d
--- /dev/null
+++ b/loader/resources/resources.json
@@ -0,0 +1,39 @@
+{
+    "fonts": {
+        "mdFont": {
+            "path": "fonts/Ubuntu-Regular.ttf",
+            "size": 80
+        },
+        "mdFontB": {
+            "path": "fonts/Ubuntu-Bold.ttf",
+            "size": 80
+        },
+        "mdFontI": {
+            "path": "fonts/Ubuntu-Italic.ttf",
+            "size": 80
+        },
+        "mdFontBI": {
+            "path": "fonts/Ubuntu-BoldItalic.ttf",
+            "size": 80
+        },
+        "mdFontMono": {
+            "path": "fonts/UbuntuMono-Regular.ttf",
+            "size": 80
+        }
+    },
+    "files": [
+        "images/*.png",
+        "sounds/*.ogg"
+    ],
+    "spritesheets": {
+        "LogoSheet": [
+            "logos/*.png"
+        ],
+        "APISheet": [
+            "*.png"
+        ],
+        "BlankSheet": [
+            "blanks/*.png"
+        ]
+    }
+}
\ No newline at end of file
diff --git a/loader/resources/sounds/byeNotif00.ogg b/loader/resources/sounds/byeNotif00.ogg
new file mode 100644
index 00000000..89774d54
Binary files /dev/null and b/loader/resources/sounds/byeNotif00.ogg differ
diff --git a/loader/resources/sounds/newNotif00.ogg b/loader/resources/sounds/newNotif00.ogg
new file mode 100644
index 00000000..af2613b2
Binary files /dev/null and b/loader/resources/sounds/newNotif00.ogg differ
diff --git a/loader/resources/sounds/newNotif01.ogg b/loader/resources/sounds/newNotif01.ogg
new file mode 100644
index 00000000..02f667ce
Binary files /dev/null and b/loader/resources/sounds/newNotif01.ogg differ
diff --git a/loader/resources/sounds/newNotif02.ogg b/loader/resources/sounds/newNotif02.ogg
new file mode 100644
index 00000000..9fe0de2c
Binary files /dev/null and b/loader/resources/sounds/newNotif02.ogg differ
diff --git a/loader/resources/sounds/newNotif03.ogg b/loader/resources/sounds/newNotif03.ogg
new file mode 100644
index 00000000..a69143e1
Binary files /dev/null and b/loader/resources/sounds/newNotif03.ogg differ
diff --git a/loader/resources/updates-available.png b/loader/resources/updates-available.png
new file mode 100644
index 00000000..1272026c
Binary files /dev/null and b/loader/resources/updates-available.png differ
diff --git a/loader/resources/updates-failed.png b/loader/resources/updates-failed.png
new file mode 100644
index 00000000..d301d465
Binary files /dev/null and b/loader/resources/updates-failed.png differ
diff --git a/loader/resources/updates-installed.png b/loader/resources/updates-installed.png
new file mode 100644
index 00000000..fb2c4b9a
Binary files /dev/null and b/loader/resources/updates-installed.png differ
diff --git a/loader/src/hooks/LoadingLayer.cpp b/loader/src/hooks/LoadingLayer.cpp
new file mode 100644
index 00000000..5ff4f5d5
--- /dev/null
+++ b/loader/src/hooks/LoadingLayer.cpp
@@ -0,0 +1,29 @@
+#include <Geode/Geode.hpp>
+#include <array>
+
+USE_GEODE_NAMESPACE();
+
+class $modify(CustomLoadingLayer, LoadingLayer) {
+    bool init(bool fromReload) {
+        if (!LoadingLayer::init(fromReload))
+            return false;
+
+        auto winSize = CCDirector::sharedDirector()->getWinSize();
+
+        auto count = Loader::get()->getAllMods().size();
+
+        auto label = CCLabelBMFont::create(
+            CCString::createWithFormat(
+                "Geode: Loaded %lu mods",
+                static_cast<unsigned long>(count)
+            )->getCString(),
+            "goldFont.fnt"
+        );
+        label->setPosition(winSize.width / 2, 30.f);
+        label->setScale(.45f);
+        label->setTag(5);
+        this->addChild(label);
+
+        return true;
+    }
+};
diff --git a/loader/src/hooks/MenuLayer.cpp b/loader/src/hooks/MenuLayer.cpp
new file mode 100644
index 00000000..2f7e9088
--- /dev/null
+++ b/loader/src/hooks/MenuLayer.cpp
@@ -0,0 +1,221 @@
+#include <Geode/Geode.hpp>
+#include "../ui/internal/list/ModListLayer.hpp"
+#include <Geode/utils/WackyGeodeMacros.hpp>
+#include <Geode/ui/BasedButtonSprite.hpp>
+#include <Index.hpp>
+#include <Geode/ui/Notification.hpp>
+
+USE_GEODE_NAMESPACE();
+
+class CustomMenuLayer;
+
+static Ref<Notification> g_indexUpdateNotif = nullptr;
+static Ref<CCSprite> g_geodeButton = nullptr;
+
+static void addUpdateIcon(const char* icon = "updates-available.png"_spr) {
+	if (g_geodeButton && Index::get()->areUpdatesAvailable()) {
+		auto updateIcon = CCSprite::createWithSpriteFrameName(icon);
+		updateIcon->setPosition(
+			g_geodeButton->getContentSize() - CCSize { 10.f, 10.f }
+		);
+		updateIcon->setZOrder(99);
+		updateIcon->setScale(.5f);
+		g_geodeButton->addChild(updateIcon);
+	}
+}
+
+static void updateModsProgress(
+	UpdateStatus status,
+	std::string const& info,
+	uint8_t progress
+) {
+	if (status == UpdateStatus::Failed) {
+		g_indexUpdateNotif->hide();
+		g_indexUpdateNotif = nullptr;
+		NotificationBuilder()
+			.title("Some Updates Failed")
+			.text("Some mods failed to update, click for details")
+			.icon("info-alert.png"_spr)
+			.clicked([info](auto) -> void {
+				FLAlertLayer::create("Info", info, "OK")->show();
+			})
+			.show();
+		addUpdateIcon("updates-failed.png"_spr);
+	}
+
+	if (status == UpdateStatus::Finished) {
+		g_indexUpdateNotif->hide();
+		g_indexUpdateNotif = nullptr;
+		NotificationBuilder()
+			.title("Updates Installed")
+			.text(
+				"Mods have been updated, please "
+				"restart to apply changes"
+			)
+			.icon("updates-available.png"_spr)
+			.clicked([info](auto) -> void {
+				FLAlertLayer::create("Info", info, "OK")->show();
+			})
+			.show();
+	}
+}
+
+static void updateIndexProgress(
+	UpdateStatus status,
+	std::string const& info,
+	uint8_t progress
+) {
+	if (status == UpdateStatus::Failed) {
+		g_indexUpdateNotif->hide();
+		g_indexUpdateNotif = nullptr;
+		NotificationBuilder()
+			.title("Index Update")
+			.text("Index update failed :(")
+			.icon("info-alert.png"_spr)
+			.show();
+		addUpdateIcon("updates-failed.png"_spr);
+	}
+
+	if (status == UpdateStatus::Finished) {
+		g_indexUpdateNotif->hide();
+		g_indexUpdateNotif = nullptr;
+		if (Index::get()->areUpdatesAvailable()) {
+			// todo: uncomment and fix crash
+			// if (Mod::get()->getDataStore()["enable-auto-updates"]) {
+			// 	auto ticket = Index::get()->installUpdates(updateModsProgress);
+			// 	if (!ticket) {
+			// 		NotificationBuilder()
+			// 			.title("Unable to auto-update")
+			// 			.text("Unable to update mods :(")
+			// 			.icon("updates-failed.png"_spr)
+			// 			.show();
+			// 	} else {
+			// 		g_indexUpdateNotif = NotificationBuilder()
+			// 			.title("Installing updates")
+			// 			.text("Installing updates...")
+			// 			.clicked([ticket](auto) -> void {
+			// 				createQuickPopup(
+			// 					"Cancel Updates",
+			// 					"Do you want to <cr>cancel</c> updates?",
+			// 					"Don't Cancel", "Cancel Updates",
+			// 					[ticket](auto, bool btn2) -> void {
+			// 						if (g_indexUpdateNotif && btn2) {
+			// 							ticket.value()->cancel();
+			// 						}
+			// 					}
+			// 				);
+			// 			}, false)
+			// 			.loading()
+			// 			.stay()
+			// 			.show();
+			// 	}
+			// } else {
+			// 	NotificationBuilder()
+			// 		.title("Updates available")
+			// 		.text("Some mods have updates available!")
+			// 		.icon("updates-available.png"_spr)
+			// 		.clicked([](auto) -> void {
+			// 			ModListLayer::scene();
+			// 		})
+			// 		.show();
+			// }
+			// addUpdateIcon();
+		}
+	}
+}
+
+class $modify(CustomMenuLayer, MenuLayer) {
+	void destructor() {
+		g_geodeButton = nullptr;
+		MenuLayer::~MenuLayer();
+	}
+
+	bool init() {
+		if (!MenuLayer::init())
+			return false;
+
+		CCMenu* bottomMenu = nullptr;
+
+		size_t indexCounter = 0;
+		for (size_t i = 0; i < this->getChildren()->count(); i++) {
+			auto obj = typeinfo_cast<CCMenu*>(this->getChildren()->objectAtIndex(i));
+			if (obj != nullptr) {
+				++indexCounter;
+				if (indexCounter == 2) {
+					bottomMenu = obj;
+					break;
+				}
+			}
+		}
+
+		// if (!GameManager::sharedState()->m_clickedGarage) {
+		// 	bottomMenu = getChild<CCMenu*>(this, 4);
+		// }
+		// else {
+		// 	bottomMenu = getChild<CCMenu*>(this, 3);
+		// }
+
+		auto chest = getChild<>(bottomMenu, -1);
+		if (chest) {
+			chest->retain();
+			chest->removeFromParent();
+		}
+		
+		auto y = getChild<>(bottomMenu, 0)->getPositionY();
+
+		g_geodeButton = CircleButtonSprite::createWithSpriteFrameName(
+			"geode-logo-outline-gold.png"_spr,
+			1.0f,
+			CircleBaseColor::Green,
+			CircleBaseSize::Medium2
+		);
+		if (!g_geodeButton) {
+			g_geodeButton = ButtonSprite::create("!!");
+		}
+		addUpdateIcon();
+		auto btn = CCMenuItemSpriteExtra::create(
+			g_geodeButton.data(), this, menu_selector(CustomMenuLayer::onGeode)
+		);
+		bottomMenu->addChild(btn);
+
+		bottomMenu->alignItemsHorizontallyWithPadding(3.f);
+
+		CCARRAY_FOREACH_B_TYPE(bottomMenu->getChildren(), node, CCNode) {
+			node->setPositionY(y);
+		}
+		if (chest) {
+			bottomMenu->addChild(chest);
+			chest->release();
+		}
+
+		auto failed = Loader::get()->getFailedMods();
+		if (failed.size()) {
+            auto layer = FLAlertLayer::create(
+				"Notice",
+				"Some mods failed to load; see <cy>Geode</c> for details",
+				"OK"
+			);
+			layer->m_scene = this;
+			layer->m_noElasticity = true;
+			layer->show();
+        }
+
+		if (!g_indexUpdateNotif && !Index::get()->isIndexUpdated()) {
+			g_indexUpdateNotif = NotificationBuilder()
+				.title("Index Update")
+				.text("Updating index...")
+				.loading()
+				.stay()
+				.show();
+
+			Index::get()->updateIndex(updateIndexProgress);
+		}
+
+		return true;
+	}
+
+	void onGeode(CCObject*) {
+		ModListLayer::scene();
+	}
+};
+
diff --git a/loader/src/hooks/persist.cpp b/loader/src/hooks/persist.cpp
new file mode 100644
index 00000000..274f3081
--- /dev/null
+++ b/loader/src/hooks/persist.cpp
@@ -0,0 +1,11 @@
+#include <Geode/Geode.hpp>
+#include <Geode/ui/SceneManager.hpp>
+
+USE_GEODE_NAMESPACE();
+
+class $modify(AchievementNotifier) {
+    void willSwitchToScene(CCScene* scene) {
+        AchievementNotifier::willSwitchToScene(scene);
+        SceneManager::get()->willSwitchToScene(scene);
+    }
+};
diff --git a/loader/src/hooks/update_resources.cpp b/loader/src/hooks/updateResources.cpp
similarity index 100%
rename from loader/src/hooks/update_resources.cpp
rename to loader/src/hooks/updateResources.cpp
diff --git a/loader/src/index/Index.cpp b/loader/src/index/Index.cpp
new file mode 100644
index 00000000..672d676e
--- /dev/null
+++ b/loader/src/index/Index.cpp
@@ -0,0 +1,655 @@
+#include "Index.hpp"
+#include <thread>
+#include <Geode/utils/json.hpp>
+#include "fetch.hpp"
+
+#define GITHUB_DONT_RATE_LIMIT_ME_PLS 0
+
+static Result<nlohmann::json> readJSON(ghc::filesystem::path const& path) {
+    auto indexJsonData = file_utils::readString(path);
+    if (!indexJsonData) {
+        return Err("Unable to read " + path.string());
+    }
+    try {
+        return Ok(nlohmann::json::parse(indexJsonData.value()));
+    } catch(std::exception& e) {
+        return Err("Error parsing JSON: " + std::string(e.what()));
+    }
+}
+
+static Result<> unzipTo(
+    ghc::filesystem::path const& from,
+    ghc::filesystem::path const& to
+) {
+    // unzip downloaded
+    auto unzip = ZipFile(from.string());
+    if (!unzip.isLoaded()) {
+        return Err("Unable to unzip index.zip");
+    }
+
+    for (auto file : unzip.getAllFiles()) {
+        // this is a very bad check for seeing 
+        // if file is a directory. it seems to 
+        // work on windows at least. idk why 
+        // getAllFiles returns the directories 
+        // aswell now
+        if (
+            string_utils::endsWith(file, "\\") ||
+            string_utils::endsWith(file, "/")
+        ) continue;
+
+        auto zipPath = file;
+
+        // dont include the github repo folder
+        file = file.substr(file.find_first_of("/") + 1);
+
+        auto path = ghc::filesystem::path(file);
+        if (path.has_parent_path()) {
+            if (
+                !ghc::filesystem::exists(to / path.parent_path()) &&
+                !ghc::filesystem::create_directories(to / path.parent_path())
+            ) {
+                return Err(
+                    "Unable to create directories \"" + 
+                    path.parent_path().string() + "\""
+                );
+            }
+        }
+        unsigned long size;
+        auto data = unzip.getFileData(zipPath, &size);
+        if (!data || !size) {
+            return Err("Unable to read \"" + std::string(zipPath) + "\"");
+        }
+        auto wrt = file_utils::writeBinary(
+            to / file,
+            byte_array(data, data + size)
+        );
+        if (!wrt) {
+            return Err("Unable to write \"" + file + "\": " + wrt.error());
+        }
+    }
+
+    return Ok();
+}
+
+
+Index* Index::get() {
+    static auto ret = new Index();
+    return ret;
+}
+
+bool Index::isIndexUpdated() const {
+    return m_upToDate;
+}
+
+
+void Index::updateIndexThread(bool force) {
+    auto indexDir = Loader::get()->getGeodeDirectory() / "index";
+
+    // download index
+
+#if GITHUB_DONT_RATE_LIMIT_ME_PLS == 0
+
+    indexUpdateProgress(
+        UpdateStatus::Progress, "Fetching index metadata", 0
+    );
+
+    // get all commits in index repo
+    auto commit = fetchJSON(
+        "https://api.github.com/repos/geode-sdk/mods/commits"
+    );
+    if (!commit) {
+        return indexUpdateProgress(UpdateStatus::Failed, commit.error());
+    }
+    auto json = commit.value();
+    if (
+        json.is_object() &&
+        json.contains("documentation_url") &&
+        json.contains("message")
+    ) {
+        // whoops! got rate limited
+        return indexUpdateProgress(
+            UpdateStatus::Failed,
+            json["message"].get<std::string>()
+        );
+    }
+
+    indexUpdateProgress(
+        UpdateStatus::Progress, "Checking index status", 25
+    );
+
+    // read sha of latest commit
+
+    if (!json.is_array()) {
+        return indexUpdateProgress(
+            UpdateStatus::Failed,
+            "Fetched commits, expected 'array', got '" +
+                std::string(json.type_name()) + "'. "
+            "Report this bug to the Geode developers!"
+        );
+    }
+
+    if (!json.at(0).is_object()) {
+        return indexUpdateProgress(
+            UpdateStatus::Failed,
+            "Fetched commits, expected 'array.object', got 'array." +
+                std::string(json.type_name()) + "'. "
+            "Report this bug to the Geode developers!"
+        );
+    }
+
+    if (!json.at(0).contains("sha")) {
+        return indexUpdateProgress(
+            UpdateStatus::Failed,
+            "Fetched commits, missing '0.sha'. "
+            "Report this bug to the Geode developers!"
+        );
+    }
+
+    auto upcomingCommitSHA = json.at(0)["sha"];
+
+    // read sha of currently installed commit
+    std::string currentCommitSHA = "";
+    if (ghc::filesystem::exists(indexDir / "current")) {
+        auto data = file_utils::readString(indexDir / "current");
+        if (data) {
+            currentCommitSHA = data.value();
+        }
+    }
+
+    // update if forced or latest commit has 
+    // different sha
+    if (force || currentCommitSHA != upcomingCommitSHA) {
+        // save new sha in file
+        file_utils::writeString(indexDir / "current", upcomingCommitSHA);
+
+        // download latest commit (by downloading 
+        // the repo as a zip)
+
+        indexUpdateProgress(
+            UpdateStatus::Progress,
+            "Downloading index",
+            50
+        );
+        auto gotZip = fetchFile(
+            "https://github.com/geode-sdk/mods/zipball/main",
+            indexDir / "index.zip"
+        );
+        if (!gotZip) {
+            return indexUpdateProgress(
+                UpdateStatus::Failed,
+                gotZip.error()
+            );
+        }
+
+        // delete old index
+        if (ghc::filesystem::exists(indexDir / "index")) {
+            ghc::filesystem::remove_all(indexDir / "index");
+        }
+
+        auto unzip = unzipTo(indexDir / "index.zip", indexDir);
+        if (!unzip) {
+            return indexUpdateProgress(
+                UpdateStatus::Failed, unzip.error()
+            );
+        }
+    }
+#endif
+
+    // update index
+
+    indexUpdateProgress(
+        UpdateStatus::Progress,
+        "Updating index",
+        75
+    );
+    this->updateIndexFromLocalCache();
+
+    m_upToDate = true;
+    m_updating = false;
+
+    indexUpdateProgress(
+        UpdateStatus::Finished,
+        "",
+        100
+    );
+}
+
+void Index::indexUpdateProgress(
+    UpdateStatus status,
+    std::string const& info,
+    uint8_t percentage
+) {
+    Loader::get()->queueInGDThread([this, status, info, percentage]() -> void {
+        m_callbacksMutex.lock();
+        for (auto& d : m_callbacks) {
+            d(status, info, percentage);
+        }
+        if (
+            status == UpdateStatus::Finished ||
+            status == UpdateStatus::Failed
+        ) {
+            m_callbacks.clear();
+        }
+        m_callbacksMutex.unlock();
+    });
+}
+
+void Index::updateIndex(IndexUpdateCallback callback, bool force) {
+    // if already updated and no force, let 
+    // delegate know
+    if (!force && m_upToDate) {
+        if (callback) {
+            callback(
+                UpdateStatus::Finished,
+                "Index already updated",
+                100
+            );
+        }
+        return;
+    }
+
+    // add delegate thread-safely if it's not 
+    // added already
+    if (callback) {
+        m_callbacksMutex.lock();
+        m_callbacks.push_back(callback);
+        m_callbacksMutex.unlock();
+    }
+
+    // if already updating, let delegate know 
+    // and return
+    if (m_updating) {
+        if (callback) {
+            callback(
+                UpdateStatus::Progress,
+                "Waiting for update info",
+                0
+            );
+        }
+        return;
+    }
+    m_updating = true;
+
+    // create directory for the local clone of 
+    // the index
+    auto indexDir = Loader::get()->getGeodeDirectory() / "index";
+    ghc::filesystem::create_directories(indexDir);
+
+    // update index in another thread to avoid 
+    // pausing UI
+    std::thread(&Index::updateIndexThread, this, force).detach();
+}
+
+PlatformID platformFromString(std::string const& str) {
+    switch (hash(string_utils::trim(string_utils::toLower(str)).c_str())) {
+        default:
+        case hash("unknown"): return PlatformID::Unknown;
+        case hash("windows"): return PlatformID::Windows;
+        case hash("macos"): return PlatformID::MacOS;
+        case hash("ios"): return PlatformID::iOS;
+        case hash("android"): return PlatformID::Android;
+        case hash("linux"): return PlatformID::Linux;
+    }
+}
+
+void Index::addIndexItemFromFolder(ghc::filesystem::path const& dir) {
+    if (ghc::filesystem::exists(dir / "index.json")) {
+
+        auto readJson = readJSON(dir / "index.json");
+        if (!readJson) {
+            Log::get() << Severity::Warning
+                << "Error reading index.json: "
+                << readJson.error() << ", skipping";
+            return;
+        }
+        auto json = readJson.value();
+        if (!json.is_object()) {
+            Log::get() << Severity::Warning
+                << "[index.json] is not an object, skipping";
+            return;
+        }
+
+        auto readModJson = readJSON(dir / "mod.json");
+        if (!readModJson) {
+            Log::get() << Severity::Warning
+                << "Error reading mod.json: "
+                << readModJson.error() << ", skipping";
+            return;
+        }
+        auto info = ModInfo::create(readModJson.value());
+        if (!info) {
+            Log::get() << Severity::Warning
+                << info.error() << ", skipping";
+            return;
+        }
+
+        IndexItem item;
+
+        item.m_path = dir;
+        item.m_info = info.value();
+
+        if (json.contains("download") && json["download"].is_object()) {
+            auto download = json["download"];
+            std::set<PlatformID> unsetPlatforms = {
+                PlatformID::Windows,
+                PlatformID::MacOS,
+                PlatformID::iOS,
+                PlatformID::Android,
+                PlatformID::Linux,
+            };
+            for (auto& key : std::initializer_list<std::string> {
+                "windows",
+                "macos",
+                "android",
+                "ios",
+                "*",
+            }) {
+                if (download.contains(key)) {
+                    auto platformDownload = download[key];
+                    if (!platformDownload.is_object()) {
+                        Log::get() << Severity::Warning
+                            << "[index.json].download."
+                            << key
+                            << " is not an object, skipping";
+                        return;
+                    }
+                    if (
+                        !platformDownload.contains("url") ||
+                        !platformDownload["url"].is_string()
+                    ) {
+                        Log::get() << Severity::Warning
+                            << "[index.json].download."
+                            << key
+                            << ".url is not a string, skipping";
+                        return;
+                    }
+                    if (
+                        !platformDownload.contains("name") ||
+                        !platformDownload["name"].is_string()
+                    ) {
+                        Log::get() << Severity::Warning
+                            << "[index.json].download."
+                            << key
+                            << ".name is not a string, skipping";
+                        return;
+                    }
+                    if (
+                        !platformDownload.contains("hash") ||
+                        !platformDownload["hash"].is_string()
+                    ) {
+                        Log::get() << Severity::Warning
+                            << "[index.json].download."
+                            << key
+                            << ".hash is not a string, skipping";
+                        return;
+                    }
+                    IndexItem::Download down;
+                    down.m_url = platformDownload["url"];
+                    down.m_filename = platformDownload["name"];
+                    down.m_hash = platformDownload["hash"];
+                    if (key == "*") {
+                        for (auto& platform : unsetPlatforms) {
+                            item.m_download[platform] = down;
+                        }
+                    } else {
+                        auto id = platformFromString(key);
+                        item.m_download[id] = down;
+                        unsetPlatforms.erase(id);
+                    }
+                }
+            }
+        } else {
+            Log::get() << Severity::Warning
+                << "[index.json] is missing \"download\", adding anyway";
+        }
+
+        m_items.push_back(item);
+
+    } else {
+        Log::get() << Severity::Warning << "Index directory "
+            << dir << " is missing index.json, skipping";
+    }
+}
+
+void Index::updateIndexFromLocalCache() {
+    m_items.clear();
+    auto indexDir = Loader::get()->getGeodeDirectory() / "index" / "index";
+    for (auto const& dir : ghc::filesystem::directory_iterator(indexDir)) {
+        if (ghc::filesystem::is_directory(dir)) {
+            this->addIndexItemFromFolder(dir);
+        }
+    }
+}
+
+std::vector<IndexItem> const& Index::getItems() const {
+    return m_items;
+}
+
+std::vector<IndexItem> Index::getUninstalledItems() const {
+    std::vector<IndexItem> items;
+    for (auto& item : m_items) {
+        if (!Loader::get()->isModInstalled(item.m_info.m_id)) {
+            if (item.m_download.count(GEODE_PLATFORM_TARGET)) {
+                items.push_back(item);
+            }
+        }
+    }
+    return items;
+}
+
+bool Index::isKnownItem(std::string const& id) const {
+    for (auto& item : m_items) {
+        if (item.m_info.m_id == id) return true;
+    }
+    return false;
+}
+
+IndexItem Index::getKnownItem(std::string const& id) const {
+    for (auto& item : m_items) {
+        if (item.m_info.m_id == id) {
+            return item;
+        }
+    }
+    return IndexItem();
+}
+
+struct UninstalledDependency {
+    std::string m_id;
+    bool m_isInIndex;
+};
+
+static void getUninstalledDependenciesRecursive(
+    ModInfo const& info,
+    std::vector<UninstalledDependency>& deps
+) {
+    for (auto& dep : info.m_dependencies) {
+        UninstalledDependency d;
+        d.m_isInIndex = Index::get()->isKnownItem(dep.m_id);
+        if (!Loader::get()->isModInstalled(dep.m_id)) {
+            d.m_id = dep.m_id;
+            deps.push_back(d);
+        }
+        if (d.m_isInIndex) {
+            getUninstalledDependenciesRecursive(
+                Index::get()->getKnownItem(dep.m_id).m_info,
+                deps
+            );
+        }
+    }
+}
+
+Result<std::vector<std::string>> Index::checkDependenciesForItem(
+    IndexItem const& item
+) {
+    // todo: check versions
+    std::vector<UninstalledDependency> deps;
+    getUninstalledDependenciesRecursive(item.m_info, deps);
+    if (deps.size()) {
+        std::vector<std::string> unknownDeps;
+        for (auto& dep : deps) {
+            if (!dep.m_isInIndex) {
+                unknownDeps.push_back(dep.m_id);
+            }
+        }
+        if (unknownDeps.size()) {
+            std::string list = "";
+            for (auto& ud : unknownDeps) {
+                list += "<cp>" + ud + "</c>, ";
+            }
+            list.pop_back();
+            list.pop_back();
+            return Err(
+                "This mod or its dependencies <cb>depends</c> on the "
+                "following unknown mods: " + list + ". You will have "
+                "to manually install these mods before you can install "
+                "this one."
+            );
+        }
+        std::vector<std::string> list = {};
+        for (auto& d : deps) {
+            list.push_back(d.m_id);
+        }
+        list.push_back(item.m_info.m_id);
+        return Ok(list);
+    } else {
+        return Ok<std::vector<std::string>>({ item.m_info.m_id });
+    }
+}
+
+Result<InstallTicket*> Index::installItems(
+    std::vector<IndexItem> const& items,
+    ItemInstallCallback progress
+) {
+    std::vector<std::string> ids {};
+    for (auto& item : items) {
+        if (!item.m_download.count(GEODE_PLATFORM_TARGET)) {
+            return Err(
+                "This mod is not available on your "
+                "current platform \"" GEODE_PLATFORM_NAME "\" - Sorry! :("
+            );
+        }
+        auto download = item.m_download.at(GEODE_PLATFORM_TARGET);
+        if (!download.m_url.size()) {
+            return Err(
+                "Download URL not set! Report this bug to "
+                "the Geode developers - this should not happen, ever."
+            );
+        }
+        if (!download.m_filename.size()) {
+            return Err(
+                "Download filename not set! Report this bug to "
+                "the Geode developers - this should not happen, ever."
+            );
+        }
+        if (!download.m_hash.size()) {
+            return Err(
+                "Checksum not set! Report this bug to "
+                "the Geode developers - this should not happen, ever."
+            );
+        }
+        auto list = checkDependenciesForItem(item);
+        if (!list) {
+            return Err(list.error());
+        }
+        vector_utils::push(ids, list.value());
+    }
+    return Ok(new InstallTicket(this, ids, progress));
+}
+
+Result<InstallTicket*> Index::installItem(
+    IndexItem const& item,
+    ItemInstallCallback progress
+) {
+    return this->installItems({ item }, progress);
+}
+
+bool Index::isUpdateAvailableForItem(std::string const& id) const {
+    if (!Loader::get()->isModInstalled(id)) {
+        return false;
+    }
+    if (!this->isKnownItem(id)) {
+        return false;
+    }
+    return
+        this->getKnownItem(id).m_info.m_version > 
+        Loader::get()->getInstalledMod(id)->getVersion();
+}
+
+bool Index::isUpdateAvailableForItem(IndexItem const& item) const {
+    if (!Loader::get()->isModInstalled(item.m_info.m_id)) {
+        return false;
+    }
+    return
+        item.m_info.m_version > 
+        Loader::get()->getInstalledMod(item.m_info.m_id)->getVersion();
+}
+
+bool Index::areUpdatesAvailable() const {
+    for (auto& item : m_items) {
+        if (this->isUpdateAvailableForItem(item.m_info.m_id)) {
+            return true;
+        }
+    }
+    return false;
+}
+
+Result<InstallTicket*> Index::installUpdates(
+    IndexUpdateCallback callback, bool force
+) {
+    // find items that need updating
+    std::vector<IndexItem> itemsToUpdate {};
+    for (auto& item : m_items) {
+        if (this->isUpdateAvailableForItem(item)) {
+            itemsToUpdate.push_back(item);
+        }
+    }
+
+    // generate ticket
+    auto ticket = this->installItems(
+        itemsToUpdate,
+        [itemsToUpdate, callback](
+            InstallTicket*,
+            UpdateStatus status,
+            std::string const& info,
+            uint8_t progress
+        ) -> void {
+            switch (status) {
+                case UpdateStatus::Failed: {
+                    callback(
+                        UpdateStatus::Failed,
+                        "Updating failed: " + info,
+                        0
+                    );
+                } break;
+
+                case UpdateStatus::Finished: {
+                    std::string updatedStr = "";
+                    for (auto& item : itemsToUpdate) {
+                        updatedStr += item.m_info.m_name + " (" + 
+                            item.m_info.m_id + ")\n";
+                    }
+                    callback(
+                        UpdateStatus::Finished,
+                        "Updated the following mods: " +
+                        updatedStr +
+                        "Please restart to apply changes.",
+                        100
+                    );
+                } break;
+
+                case UpdateStatus::Progress: {
+                    callback(UpdateStatus::Progress, info, progress);
+                } break;
+            }
+        }
+    );
+    if (!ticket) {
+        return Err(ticket.error());
+    }
+
+    // install updates concurrently
+    ticket.value()->start(InstallMode::Concurrent);
+
+    return ticket;
+}
diff --git a/loader/src/index/Index.hpp b/loader/src/index/Index.hpp
new file mode 100644
index 00000000..058a79e8
--- /dev/null
+++ b/loader/src/index/Index.hpp
@@ -0,0 +1,156 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+#include <mutex>
+
+USE_GEODE_NAMESPACE();
+
+class Index;
+struct ModInstallUpdate;
+class InstallTicket;
+
+// todo: make index use events
+
+enum class UpdateStatus {
+    Progress,
+    Failed,
+    Finished,
+};
+
+using ItemInstallCallback = std::function<void(
+    InstallTicket*, UpdateStatus, std::string const&, uint8_t
+)>;
+using IndexUpdateCallback = std::function<void(
+    UpdateStatus, std::string const&, uint8_t
+)>;
+
+struct IndexItem {
+    struct Download {
+        std::string m_url;
+        std::string m_filename;
+        std::string m_hash;
+    };
+
+    ghc::filesystem::path m_path;
+    ModInfo m_info;
+    std::unordered_map<PlatformID, Download> m_download;
+    std::string m_installFailed;
+};
+
+enum class InstallMode {
+    Order,      // download & install one-by-one
+    Concurrent, // download & install all simultaneously
+};
+
+/**
+ * Used for working with a currently 
+ * happening mod installation from 
+ * the index. Note that once the 
+ * installation is finished / failed, 
+ * the ticket will free its own memory, 
+ * so make sure to let go of any 
+ * pointers you may have to it.
+ */
+class InstallTicket {
+protected:
+    ItemInstallCallback m_progress;
+    const std::vector<std::string> m_installList;
+    mutable std::mutex m_cancelMutex;
+    bool m_cancelling = false;
+    bool m_installing = false;
+    bool m_replaceFiles = true;
+    Index* m_index;
+
+    void postProgress(
+        UpdateStatus status,
+        std::string const& info = "",
+        uint8_t progress = 0
+    );
+    void install(std::string const& id);
+
+    friend class Index;
+
+public:
+    /**
+     * Create a new ticket for installing a list of mods. This method 
+     * should not be called manually; instead, you should always use 
+     * `Index::installItem`. Note that once the installation is 
+     * finished / failed, the ticket will free its own memory, so make 
+     * sure to let go of any pointers you may have to it.
+     */
+    InstallTicket(
+        Index* index,
+        std::vector<std::string> const& list,
+        ItemInstallCallback progress
+    );
+
+    /**
+     * Get list of mods to install
+     */
+    std::vector<std::string> const& getInstallList() const;
+
+    /**
+     * Cancel all pending installations and revert finished ones. This 
+     * function is thread-safe
+     */
+    void cancel();
+
+    /**
+     * Begin installation. Note that this function is *not* 
+     * thread-safe
+     * @param mode Whether to install the list of mods 
+     * provided concurrently or in order
+     * @note Use InstallTicket::cancel to cancel the 
+     * installation
+     */
+    void start(InstallMode mode = InstallMode::Concurrent);
+};
+
+class Index {
+protected:
+    bool m_upToDate = false;
+    bool m_updating = false;
+    mutable std::mutex m_callbacksMutex;
+    std::vector<IndexUpdateCallback> m_callbacks;
+    std::vector<IndexItem> m_items;
+
+    void indexUpdateProgress(
+        UpdateStatus status,
+        std::string const& info = "",
+        uint8_t percentage = 0
+    );
+
+    void updateIndexThread(bool force);
+    void addIndexItemFromFolder(ghc::filesystem::path const& dir);
+    void updateIndexFromLocalCache();
+
+    Result<std::vector<std::string>> checkDependenciesForItem(
+        IndexItem const& item
+    );
+
+public:
+    static Index* get();
+
+    std::vector<IndexItem> const& getItems() const;
+    std::vector<IndexItem> getUninstalledItems() const;
+    bool isKnownItem(std::string const& id) const;
+    IndexItem getKnownItem(std::string const& id) const;
+    Result<InstallTicket*> installItems(
+        std::vector<IndexItem> const& item,
+        ItemInstallCallback progress = nullptr
+    );
+    Result<InstallTicket*> installItem(
+        IndexItem const& item,
+        ItemInstallCallback progress = nullptr
+    );
+    bool isUpdateAvailableForItem(std::string const& id) const;
+    bool isUpdateAvailableForItem(IndexItem const& item) const;
+    bool areUpdatesAvailable() const;
+    Result<InstallTicket*> installUpdates(
+        IndexUpdateCallback callback = nullptr,
+        bool force = false
+    );
+
+    bool isIndexUpdated() const;
+    void updateIndex(IndexUpdateCallback callback, bool force = false);
+};
diff --git a/loader/src/index/InstallTicket.cpp b/loader/src/index/InstallTicket.cpp
new file mode 100644
index 00000000..f9cc7b1b
--- /dev/null
+++ b/loader/src/index/InstallTicket.cpp
@@ -0,0 +1,196 @@
+#include "Index.hpp"
+#include <thread>
+#include <Geode/utils/json.hpp>
+#include <hash.hpp>
+#include "fetch.hpp"
+
+void InstallTicket::postProgress(
+    UpdateStatus status,
+    std::string const& info,
+    uint8_t percentage
+) {
+    if (m_progress) {
+        Loader::get()->queueInGDThread([this, status, info, percentage]() -> void {
+            m_progress(this, status, info, percentage);
+        });
+    }
+    if (status == UpdateStatus::Failed || status == UpdateStatus::Finished) {
+        Log::get() << "Deleting InstallTicket at " << this;
+        // clean up own memory
+        delete this;
+    }
+}
+
+InstallTicket::InstallTicket(
+    Index* index,
+    std::vector<std::string> const& list,
+    ItemInstallCallback progress
+) : m_index(index),
+    m_installList(list),
+    m_progress(progress) {}
+
+std::vector<std::string> const& InstallTicket::getInstallList() const {
+    return m_installList;
+}
+
+void InstallTicket::install(std::string const& id) {
+    // run installing in another thread in order 
+    // to render progress on screen while installing
+    auto indexDir = Loader::get()->getGeodeDirectory() / "index";
+
+    auto item = m_index->getKnownItem(id);
+    auto download = item.m_download.at(GEODE_PLATFORM_TARGET);
+
+    this->postProgress(UpdateStatus::Progress, "Checking status", 0);
+    
+    // download to temp file in index dir
+    auto tempFile = indexDir / download.m_filename;
+
+    this->postProgress(UpdateStatus::Progress, "Fetching binary", 0);
+    auto res = fetchFile(
+        download.m_url,
+        tempFile,
+        [this, tempFile](double now, double total) -> int {
+            // check if cancelled
+            m_cancelMutex.lock();
+            if (m_cancelling) {
+                try { ghc::filesystem::remove(tempFile); } catch(...) {}
+                m_cancelMutex.unlock();
+                return 1;
+            }
+            m_cancelMutex.unlock();
+
+            this->postProgress(
+                UpdateStatus::Progress,
+                "Downloading binary",
+                static_cast<uint8_t>(now / total * 100.0)
+            );
+            return 0;
+        }
+    );
+    if (!res) {
+        try { ghc::filesystem::remove(tempFile); } catch(...) {}
+        return this->postProgress(
+            UpdateStatus::Failed,
+            "Downloading failed: " + res.error()
+        );
+    }
+
+    // check if cancelled
+    m_cancelMutex.lock();
+    if (m_cancelling) {
+        ghc::filesystem::remove(tempFile);
+        m_cancelMutex.unlock();
+        return;
+    }
+    m_cancelMutex.unlock();
+
+    // check for 404
+    auto notFound = file_utils::readString(tempFile);
+    if (notFound && notFound.value() == "Not Found") {
+        try { ghc::filesystem::remove(tempFile); } catch(...) {}
+        return this->postProgress(
+            UpdateStatus::Failed,
+            "Binary file download returned \"Not found\". Report "
+            "this to the Geode development team."
+        );
+    }
+
+    // verify checksum
+    this->postProgress(UpdateStatus::Progress, "Verifying", 100);
+
+    auto checksum = ::calculateHash(tempFile.string());
+
+    if (checksum != download.m_hash) {
+        try { ghc::filesystem::remove(tempFile); } catch(...) {}
+        return this->postProgress(
+            UpdateStatus::Failed,
+            "Checksum mismatch! (Downloaded file did not match what "
+            "was expected. Try again, and if the download fails another time, "
+            "report this to the Geode development team."
+        );
+    }
+
+    // move temp file to geode directory
+    try {
+        auto modDir = Loader::get()->getGeodeDirectory() / "mods";
+        auto targetFile = modDir / download.m_filename;
+
+        // find valid filename that doesn't exist yet
+        if (!m_replaceFiles) {
+            auto filename = ghc::filesystem::path(
+                download.m_filename
+            ).replace_extension("").string();
+
+            size_t number = 0;
+            while (ghc::filesystem::exists(targetFile)) {
+                targetFile = modDir /
+                    (filename + std::to_string(number) + ".geode");
+                number++;
+            }
+        }
+
+        // move file
+        ghc::filesystem::rename(tempFile, targetFile);
+    } catch(std::exception& e) {
+        try { ghc::filesystem::remove(tempFile); } catch(...) {}
+        return this->postProgress(
+            UpdateStatus::Failed,
+            "Unable to move downloaded file to mods directory: \"" + 
+            std::string(e.what()) + " \" "
+            "(This might be due to insufficient permissions to "
+            "write files under SteamLibrary, try running GD as "
+            "administrator)"
+        );
+    }
+
+    // call next in queue or post finish message
+    Loader::get()->queueInGDThread([this, id]() -> void {
+        // todo: Loader::get()->refreshMods(m_updateMod);
+        // where the Loader unloads the mod binary and 
+        // reloads it from disk (this should prolly be 
+        // done only for the installed mods)
+        Loader::get()->refreshMods();
+        // already in GD thread, so might aswell do the 
+        // progress posting manually
+        if (m_progress) {
+            (m_progress)(
+                this, UpdateStatus::Finished, "", 100
+            );
+        }
+        // clean up memory
+        delete this;
+    });
+}
+
+void InstallTicket::cancel() {
+    m_cancelMutex.lock();
+    m_cancelling = true;
+    m_cancelMutex.unlock();
+}
+
+void InstallTicket::start(InstallMode mode) {
+    if (m_installing) return;
+    // make sure we have stuff to install
+    if (!m_installList.size()) {
+        return this->postProgress(
+            UpdateStatus::Failed, "Nothing to install", 0
+        );
+    }
+    m_installing = true;
+    switch (mode) {
+        case InstallMode::Concurrent: {
+            for (auto& id : m_installList) {
+                std::thread(&InstallTicket::install, this, id).detach();
+            }
+        } break;
+
+        case InstallMode::Order: {
+            std::thread([this]() -> void {
+                for (auto& id : m_installList) {
+                    this->install(id);
+                }
+            }).detach();
+        } break;
+    }
+}
diff --git a/loader/src/index/fetch.cpp b/loader/src/index/fetch.cpp
new file mode 100644
index 00000000..c6504ee6
--- /dev/null
+++ b/loader/src/index/fetch.cpp
@@ -0,0 +1,98 @@
+#include "fetch.hpp"
+#include <curl/curl.h>
+
+namespace fetch_utils {
+    static size_t writeData(char* data, size_t size, size_t nmemb, void* str) {
+        as<std::string*>(str)->append(data, size * nmemb);
+        return size * nmemb;
+    }
+
+    static size_t writeBinaryData(char* data, size_t size, size_t nmemb, void* file) {
+        as<std::ofstream*>(file)->write(data, size * nmemb);
+        return size * nmemb;
+    }
+
+    static int progress(void* ptr, double total, double now, double, double) {
+        return (*as<std::function<int(double, double)>*>(ptr))(now, total);
+    }
+}
+
+Result<> fetchFile(
+    std::string const& url,
+    ghc::filesystem::path const& into,
+    std::function<int(double, double)> prog
+) {
+    auto curl = curl_easy_init();
+    
+    if (!curl) return Err("Curl not initialized!");
+
+    std::ofstream file(into, std::ios::out | std::ios::binary);
+
+    if (!file.is_open()) {
+        return Err("Unable to open output file");
+    }
+
+    curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
+    curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0);
+    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &file);
+    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, fetch_utils::writeBinaryData);
+    curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1);
+    curl_easy_setopt(curl, CURLOPT_USERAGENT, "github_api/1.0");
+    if (prog) {
+        curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0);
+        curl_easy_setopt(curl, CURLOPT_PROGRESSFUNCTION, fetch_utils::progress);
+        curl_easy_setopt(curl, CURLOPT_PROGRESSDATA, &prog);
+    }
+    auto res = curl_easy_perform(curl);
+    if (res != CURLE_OK) {
+        curl_easy_cleanup(curl);
+        return Err("Fetch failed");
+    }
+
+    char* ct;
+    res = curl_easy_getinfo(curl, CURLINFO_CONTENT_TYPE, &ct);
+    if ((res == CURLE_OK) && ct) {
+        curl_easy_cleanup(curl);
+        return Ok();
+    }
+    curl_easy_cleanup(curl);
+    return Err("Error getting info: " + std::string(curl_easy_strerror(res)));
+}
+
+Result<std::string> fetch(std::string const& url) {
+    auto curl = curl_easy_init();
+    
+    if (!curl) return Err("Curl not initialized!");
+
+    std::string ret;
+    curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
+    curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0);
+    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ret);
+    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, fetch_utils::writeData);
+    curl_easy_setopt(curl, CURLOPT_USERAGENT, "github_api/1.0");
+    auto res = curl_easy_perform(curl);
+    if (res != CURLE_OK) {
+        curl_easy_cleanup(curl);
+        return Err("Fetch failed");
+    }
+
+    char* ct;
+    res = curl_easy_getinfo(curl, CURLINFO_CONTENT_TYPE, &ct);
+    if ((res == CURLE_OK) && ct) {
+        curl_easy_cleanup(curl);
+        return Ok(ret);
+    }
+    curl_easy_cleanup(curl);
+    return Err("Error getting info: " + std::string(curl_easy_strerror(res)));
+}
+
+Result<nlohmann::json> fetchJSON(std::string const& url) {
+    auto res = fetch(url);
+    if (!res) return Err(res.error());
+    try {
+        return Ok(nlohmann::json::parse(res.value()));
+    } catch(std::exception& e) {
+        return Err(e.what());
+    }
+}
+
diff --git a/loader/src/index/fetch.hpp b/loader/src/index/fetch.hpp
new file mode 100644
index 00000000..f64a9687
--- /dev/null
+++ b/loader/src/index/fetch.hpp
@@ -0,0 +1,15 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+
+USE_GEODE_NAMESPACE();
+
+Result<std::string> fetch(std::string const& url);
+Result<nlohmann::json> fetchJSON(std::string const& url);
+Result<> fetchFile(
+    std::string const& url,
+    ghc::filesystem::path const& into,
+    std::function<int(double, double)> prog = nullptr
+);
+
+
diff --git a/loader/src/internal/InternalMod.cpp b/loader/src/internal/InternalMod.cpp
index 8a76f2f3..1823d058 100644
--- a/loader/src/internal/InternalMod.cpp
+++ b/loader/src/internal/InternalMod.cpp
@@ -1,7 +1,7 @@
 #include "InternalMod.hpp"
 #include "about.hpp"
 
-ModInfo getInternalModInfo() {
+static ModInfo getInternalModInfo() {
     ModInfo info;
     
     info.m_id          = "geode.loader";
@@ -11,6 +11,11 @@ ModInfo getInternalModInfo() {
     info.m_details     = LOADER_ABOUT_MD;
     info.m_version     = LOADER_VERSION;
     info.m_supportsDisabling = false;
+    info.m_spritesheets = {
+        "geode.loader_LogoSheet",
+        "geode.loader_APISheet",
+        "geode.loader_BlankSheet"
+    };
 
     return info;
 }
diff --git a/loader/src/internal/InternalMod.hpp b/loader/src/internal/InternalMod.hpp
index 72713705..5b3eb199 100644
--- a/loader/src/internal/InternalMod.hpp
+++ b/loader/src/internal/InternalMod.hpp
@@ -6,12 +6,8 @@ class InternalMod;
 
 USE_GEODE_NAMESPACE();
 
-class InternalLoader;
-
 class InternalMod : public Mod {
     protected:
-        friend class InternalLoade;
-
         InternalMod();
         virtual ~InternalMod();
 
diff --git a/loader/src/load/Loader.cpp b/loader/src/load/Loader.cpp
index 0a0c1b88..4f567560 100644
--- a/loader/src/load/Loader.cpp
+++ b/loader/src/load/Loader.cpp
@@ -62,8 +62,11 @@ void Loader::addModResourcesPath(Mod* mod) {
 }
 
 void Loader::updateResourcePaths() {
+    // add own resources directory
 	auto resDir = this->getGeodeDirectory() / GEODE_RESOURCE_DIRECTORY;
     CCFileUtils::sharedFileUtils()->addSearchPath(resDir.string().c_str());
+
+    // add mods' resources directories
     for (auto const& [_, mod] : m_mods) {
         this->addModResourcesPath(mod);
     }
@@ -97,6 +100,10 @@ void Loader::updateModResources(Mod* mod) {
 }
 
 void Loader::updateResources() {
+    // add own spritesheets
+    this->updateModResources(InternalMod::get());
+
+    // add mods' spritesheets
     for (auto const& [_, mod] : m_mods) {
         this->updateModResources(mod);
     }
@@ -316,6 +323,11 @@ bool Loader::setup() {
     this->loadSettings();
     this->refreshMods();
 
+    // add resources on startup
+    this->queueInGDThread([]() {
+        Loader::get()->updateResourcePaths();
+    });
+
     if (crashlog::setupPlatformHandler()) {
         InternalMod::get()->log()
             << Severity::Debug
diff --git a/loader/src/ui/internal/credits/link.cpp b/loader/src/ui/internal/credits/link.cpp
new file mode 100644
index 00000000..22daa335
--- /dev/null
+++ b/loader/src/ui/internal/credits/link.cpp
@@ -0,0 +1,28 @@
+#include <Geode/Geode.hpp>
+
+USE_GEODE_NAMESPACE();
+
+std::string expandKnownLink(std::string const& link) {
+    switch (hash(string_utils::toLower(link).c_str())) {
+        case hash("github"):
+            if (!string_utils::contains(link, "/")) {
+                return "https://github.com/" + link;
+            } break;
+
+        case hash("youtube"):
+            if (!string_utils::contains(link, "/")) {
+                return "https://youtube.com/channel/" + link;
+            } break;
+
+        case hash("twitter"):
+            if (!string_utils::contains(link, "/")) {
+                return "https://twitter.com/" + link;
+            } break;
+
+        case hash("newgrounds"):
+            if (!string_utils::contains(link, "/")) {
+                return "https://" + link + ".newgrounds.com";
+            } break;
+    }
+    return link;
+}
diff --git a/loader/src/ui/internal/dev/HookListLayer.cpp b/loader/src/ui/internal/dev/HookListLayer.cpp
new file mode 100644
index 00000000..3750a073
--- /dev/null
+++ b/loader/src/ui/internal/dev/HookListLayer.cpp
@@ -0,0 +1,26 @@
+#include "HookListLayer.hpp"
+
+bool HookListLayer::init(Mod* mod) {
+    if (!GJDropDownLayer::init("Hooks", 220.f))
+        return false;
+    
+    auto winSize = CCDirector::sharedDirector()->getWinSize();
+    auto hooks = CCArray::create();
+    for (auto const& hook : mod->getHooks()) {
+        hooks->addObject(new HookItem(hook));
+    }
+    this->m_listLayer->m_listView = HookListView::create(hooks, mod, 356.f, 220.f);
+    this->m_listLayer->addChild(this->m_listLayer->m_listView);
+
+    return true;
+}
+
+HookListLayer* HookListLayer::create(Mod* mod) {
+    auto ret = new HookListLayer;
+    if (ret && ret->init(mod)) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
diff --git a/loader/src/ui/internal/dev/HookListLayer.hpp b/loader/src/ui/internal/dev/HookListLayer.hpp
new file mode 100644
index 00000000..c63961f0
--- /dev/null
+++ b/loader/src/ui/internal/dev/HookListLayer.hpp
@@ -0,0 +1,11 @@
+#pragma once
+
+#include "HookListView.hpp"
+
+class HookListLayer : public GJDropDownLayer {
+    protected:
+        bool init(Mod* mod);
+
+    public:
+        static HookListLayer* create(Mod* mod);
+};
diff --git a/loader/src/ui/internal/dev/HookListView.cpp b/loader/src/ui/internal/dev/HookListView.cpp
new file mode 100644
index 00000000..c366cf5e
--- /dev/null
+++ b/loader/src/ui/internal/dev/HookListView.cpp
@@ -0,0 +1,142 @@
+#include "HookListView.hpp"
+
+HookCell::HookCell(const char* name, CCSize size) :
+    TableViewCell(name, size.width, size.height) {}
+
+void HookCell::draw() {
+    reinterpret_cast<StatsCell*>(this)->StatsCell::draw();
+}
+
+
+void HookCell::updateBGColor(int index) {
+	if (index & 1) m_backgroundLayer->setColor(ccc3(0xc2, 0x72, 0x3e));
+    else m_backgroundLayer->setColor(ccc3(0xa1, 0x58, 0x2c));
+    m_backgroundLayer->setOpacity(0xff);
+}
+
+void HookCell::onEnable(CCObject* pSender) {
+    auto toggle = as<CCMenuItemToggler*>(pSender);
+    if (!toggle->isToggled()) {
+        auto res = this->m_mod->enableHook(this->m_hook);
+        if (!res) {
+            FLAlertLayer::create(
+                nullptr, "Error Enabling Hook",
+                std::string(res.error()),
+                "OK", nullptr,
+                280.f
+            )->show();
+        }
+    } else {
+        auto res = this->m_mod->disableHook(this->m_hook);
+        if (!res) {
+            FLAlertLayer::create(
+                nullptr, "Error Disabling Hook",
+                std::string(res.error()),
+                "OK", nullptr,
+                280.f
+            )->show();
+        }
+    }
+    toggle->toggle(!this->m_hook->isEnabled());
+}
+
+void HookCell::loadFromHook(Hook* hook, Mod* Mod) {
+    this->m_hook = hook;
+    this->m_mod = Mod;
+
+    this->m_mainLayer->setVisible(true);
+    this->m_backgroundLayer->setOpacity(255);
+    
+    auto menu = CCMenu::create();
+    menu->setPosition(this->m_width - this->m_height, this->m_height / 2);
+    this->m_mainLayer->addChild(menu);
+
+    auto enableBtn = CCMenuItemToggler::createWithStandardSprites(
+        this, menu_selector(HookCell::onEnable), .6f
+    );
+    enableBtn->setPosition(0, 0);
+    enableBtn->toggle(hook->isEnabled());
+    menu->addChild(enableBtn);
+
+    std::stringstream moduleName;
+    
+
+    // #ifdef GEODE_IS_WINDOWS // add other platforms?
+    // HMODULE module;
+    // if (GetModuleHandleExA(
+    //     GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
+    //     GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
+    //     as<LPCSTR>(hook->getAddress()),
+    //     &module
+    // )) {
+    //     addr -= as<uintptr_t>(module);
+    //     char name[MAX_PATH];
+    //     if (GetModuleFileNameA(
+    //         module, name, sizeof name
+    //     )) {
+    //         auto fileName = std::filesystem::path(name).stem();
+    //         moduleName << fileName.string() << " + ";
+    //     }
+    // }
+    // #endif
+    if (hook->getDisplayName() != "")
+    	moduleName << hook->getDisplayName();
+    else 
+    	moduleName << "0x" << std::hex << hook->getAddress();    
+    auto label = CCLabelBMFont::create(moduleName.str().c_str(), "chatFont.fnt");
+    label->setPosition(this->m_height / 2, this->m_height / 2);
+    label->setScale(.7f);
+    label->setAnchorPoint({ .0f, .5f });
+    this->m_mainLayer->addChild(label);
+}
+
+HookCell* HookCell::create(const char* key, CCSize size) {
+    auto pRet = new HookCell(key, size);
+    if (pRet) {
+        return pRet;
+    }
+    CC_SAFE_DELETE(pRet);
+    return nullptr;
+}
+
+
+void HookListView::setupList() {
+    this->m_itemSeparation = 30.0f;
+
+    if (!this->m_entries->count()) return;
+
+    this->m_tableView->reloadData();
+
+    if (this->m_entries->count() == 1)
+        this->m_tableView->moveToTopWithOffset(this->m_itemSeparation);
+    
+    this->m_tableView->moveToTop();
+}
+
+TableViewCell* HookListView::getListCell(const char* key) {
+    return HookCell::create(key, CCSize { this->m_width, this->m_itemSeparation });
+}
+
+void HookListView::loadCell(TableViewCell* cell, unsigned int index) {
+    as<HookCell*>(cell)->loadFromHook(
+        as<HookItem*>(this->m_entries->objectAtIndex(index))->m_hook,
+        this->m_mod
+    );
+    as<HookCell*>(cell)->updateBGColor(index);
+}
+
+HookListView* HookListView::create(
+    CCArray* hooks, Mod* Mod,
+    float width, float height
+) {
+    auto pRet = new HookListView;
+    if (pRet) {
+        pRet->m_mod = Mod;
+        if (pRet->init(hooks, kBoomListType_Hooks, width, height)) {
+            pRet->autorelease();
+            return pRet;
+        }
+    }
+    CC_SAFE_DELETE(pRet);
+    return nullptr;
+}
diff --git a/loader/src/ui/internal/dev/HookListView.hpp b/loader/src/ui/internal/dev/HookListView.hpp
new file mode 100644
index 00000000..3c59f4c1
--- /dev/null
+++ b/loader/src/ui/internal/dev/HookListView.hpp
@@ -0,0 +1,49 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+
+USE_GEODE_NAMESPACE();
+
+static constexpr const BoomListType kBoomListType_Hooks
+    = static_cast<BoomListType>(0x358);
+
+struct HookItem : public CCObject {
+    Hook* m_hook;
+
+    HookItem(Hook* h) : m_hook(h) {
+        this->autorelease();
+    }
+};
+
+class HookCell : public TableViewCell {
+    protected:
+        Mod* m_mod;
+        Hook* m_hook;
+
+		HookCell(const char* name, CCSize size);
+
+        void draw() override;
+        
+
+        void onEnable(CCObject*);
+	
+	public:
+		void updateBGColor(int index);
+        void loadFromHook(Hook*, Mod*);
+
+		static HookCell* create(const char* key, CCSize size);
+};
+
+class HookListView : public CustomListView {
+    protected:
+        Mod* m_mod;
+
+        void setupList() override;
+        TableViewCell* getListCell(const char* key) override;
+        void loadCell(TableViewCell* cell, unsigned int index) override;
+    
+    public:
+        static HookListView* create(
+            CCArray* hooks, Mod* Mod, float width, float height
+        );
+};
diff --git a/loader/src/ui/internal/dev/HotReloadLayer.cpp b/loader/src/ui/internal/dev/HotReloadLayer.cpp
new file mode 100644
index 00000000..54eabd6b
--- /dev/null
+++ b/loader/src/ui/internal/dev/HotReloadLayer.cpp
@@ -0,0 +1,47 @@
+#include "HotReloadLayer.hpp"
+
+bool HotReloadLayer::init(std::string const& name) {
+	if (!CCLayer::init())
+		return false;
+
+    auto winSize = CCDirector::sharedDirector()->getWinSize();
+
+	auto bg = CCSprite::create("GJ_gradientBG.png");
+	auto bgSize = bg->getTextureRect().size;
+
+	bg->setAnchorPoint({ 0.0f, 0.0f });
+	bg->setScaleX((winSize.width + 10.0f) / bgSize.width);
+	bg->setScaleY((winSize.height + 10.0f) / bgSize.height);
+	bg->setPosition({ -5.0f, -5.0f });
+	bg->setColor({ 0, 102, 255 });
+
+	this->addChild(bg);
+
+	std::string text = "Reloading " + name + "...";
+	auto label = CCLabelBMFont::create(text.c_str(), "bigFont.fnt");
+	label->setPosition(this->getContentSize() / 2);
+	label->setZOrder(1);
+	label->setScale(.5f);
+	this->addChild(label);
+
+	return true;
+}
+
+HotReloadLayer* HotReloadLayer::create(std::string const& name) {
+	auto ret = new HotReloadLayer;
+	if (ret && ret->init(name)) {
+		ret->autorelease();
+		return ret;
+	}
+	CC_SAFE_DELETE(ret);
+	return nullptr;
+}
+
+HotReloadLayer* HotReloadLayer::scene(std::string const& name) {
+	auto scene = CCScene::create();
+	auto layer = HotReloadLayer::create(name);
+	scene->addChild(layer);
+	CCDirector::sharedDirector()->replaceScene(scene);
+	return layer;
+}
+
diff --git a/loader/src/ui/internal/dev/HotReloadLayer.hpp b/loader/src/ui/internal/dev/HotReloadLayer.hpp
new file mode 100644
index 00000000..cab1cd7a
--- /dev/null
+++ b/loader/src/ui/internal/dev/HotReloadLayer.hpp
@@ -0,0 +1,14 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+
+USE_GEODE_NAMESPACE();
+
+class HotReloadLayer : public CCLayer {
+protected:
+	bool init(std::string const& name);
+
+public:
+	static HotReloadLayer* create(std::string const& name);
+	static HotReloadLayer* scene(std::string const& name);
+};
diff --git a/loader/src/ui/internal/info/ModInfoLayer.cpp b/loader/src/ui/internal/info/ModInfoLayer.cpp
new file mode 100644
index 00000000..8ef27bd7
--- /dev/null
+++ b/loader/src/ui/internal/info/ModInfoLayer.cpp
@@ -0,0 +1,708 @@
+#include "ModInfoLayer.hpp"
+#include "../dev/HookListLayer.hpp"
+#include "../settings/ModSettingsLayer.hpp"
+#include <Geode/ui/BasedButton.hpp>
+#include <Geode/ui/MDTextArea.hpp>
+#include "../list/ModListView.hpp"
+#include <Geode/ui/Scrollbar.hpp>
+#include <Geode/utils/WackyGeodeMacros.hpp>
+// #include <settings/Setting.hpp>
+#include <Geode/ui/IconButtonSprite.hpp>
+
+// TODO: die
+#undef min
+#undef max
+
+static constexpr const int TAG_CONFIRM_INSTALL = 4;
+static constexpr const int TAG_CONFIRM_UNINSTALL = 5;
+static constexpr const int TAG_DELETE_SAVEDATA = 6;
+
+bool DownloadStatusNode::init() {
+    if (!CCNode::init())
+        return false;
+    
+    this->setContentSize({ 150.f, 25.f });
+
+    auto bg = CCScale9Sprite::create(
+        "square02b_001.png", { 0.0f, 0.0f, 80.0f, 80.0f }
+    );
+    bg->setScale(.33f);
+    bg->setColor({ 0, 0, 0 });
+    bg->setOpacity(75);
+    bg->setContentSize(m_obContentSize * 3);
+    this->addChild(bg);
+
+    m_bar = Slider::create(this, nullptr, .6f);
+    m_bar->setValue(.0f);
+    m_bar->updateBar();
+    m_bar->setPosition(0.f, -5.f);
+    m_bar->m_touchLogic->m_thumb->setVisible(false);
+    this->addChild(m_bar);
+
+    m_label = CCLabelBMFont::create("", "bigFont.fnt");
+    m_label->setAnchorPoint({ .0f, .5f });
+    m_label->setScale(.45f);
+    m_label->setPosition(-m_obContentSize.width / 2 + 15.f, 5.f);
+    this->addChild(m_label);
+
+    return true;
+}
+
+DownloadStatusNode* DownloadStatusNode::create() {
+    auto ret = new DownloadStatusNode();
+    if (ret && ret->init()) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
+void DownloadStatusNode::setProgress(uint8_t progress) {
+    m_bar->setValue(progress / 100.f);
+    m_bar->updateBar();
+}
+
+void DownloadStatusNode::setStatus(std::string const& text) {
+    m_label->setString(text.c_str());
+    m_label->limitLabelWidth(m_obContentSize.width - 30.f, .5f, .1f);
+}
+
+
+bool ModInfoLayer::init(ModObject* obj, ModListView* list) {
+    m_noElasticity = true;
+    m_list = list;
+    m_mod = obj->m_mod;
+
+    bool isInstalledMod;
+    switch (obj->m_type) {
+        case ModObjectType::Mod: {
+            m_info = obj->m_mod->getModInfo();
+            isInstalledMod = true;
+        } break;
+
+        case ModObjectType::Index: {
+            m_info = obj->m_index.m_info;
+            isInstalledMod = false;
+        } break;
+
+        default: return false;
+    }
+
+    auto winSize = CCDirector::sharedDirector()->getWinSize();
+	CCSize size { 440.f, 290.f };
+
+    if (!this->initWithColor({ 0, 0, 0, 105 })) return false;
+    m_mainLayer = CCLayer::create();
+    this->addChild(m_mainLayer);
+
+    auto bg = CCScale9Sprite::create(
+        "GJ_square01.png", { 0.0f, 0.0f, 80.0f, 80.0f }
+    );
+    bg->setContentSize(size);
+    bg->setPosition(winSize.width / 2, winSize.height / 2);
+    m_mainLayer->addChild(bg);
+
+    m_buttonMenu = CCMenu::create();
+    m_mainLayer->addChild(m_buttonMenu);
+
+    constexpr float logoSize = 40.f;
+    constexpr float logoOffset = 10.f;
+
+    auto nameLabel = CCLabelBMFont::create(
+        m_info.m_name.c_str(), "bigFont.fnt"
+    );
+    nameLabel->setScale(.7f);
+    nameLabel->setAnchorPoint({ .0f, .5f });
+    m_mainLayer->addChild(nameLabel, 2); 
+
+    auto logoSpr = this->createLogoSpr(obj);
+    logoSpr->setScale(logoSize / logoSpr->getContentSize().width);
+    m_mainLayer->addChild(logoSpr);
+
+    auto developerStr = "by " + m_info.m_developer;
+    auto developerLabel = CCLabelBMFont::create(
+        developerStr.c_str(), "goldFont.fnt"
+    );
+    developerLabel->setScale(.5f);
+    developerLabel->setAnchorPoint({ .0f, .5f });
+    m_mainLayer->addChild(developerLabel);
+
+    auto logoTitleWidth = std::max(
+        nameLabel->getScaledContentSize().width,
+        developerLabel->getScaledContentSize().width
+    ) + logoSize + logoOffset;
+
+    nameLabel->setPosition(
+        winSize.width / 2 - logoTitleWidth / 2 + logoSize + logoOffset,
+        winSize.height / 2 + 125.f
+    );
+    logoSpr->setPosition({
+        winSize.width / 2 - logoTitleWidth / 2 + logoSize / 2,
+        winSize.height / 2 + 115.f
+    });
+    developerLabel->setPosition(
+        winSize.width / 2 - logoTitleWidth / 2 + logoSize + logoOffset,
+        winSize.height / 2 + 105.f
+    );
+    
+    auto versionLabel = CCLabelBMFont::create(
+        m_info.m_version.toString().c_str(), "bigFont.fnt"
+    );
+    versionLabel->setAnchorPoint({ .0f, .5f });
+    versionLabel->setScale(.4f);
+    versionLabel->setPosition(
+        nameLabel->getPositionX() + nameLabel->getScaledContentSize().width + 5.f,
+        winSize.height / 2 + 125.f
+    );
+    versionLabel->setColor({ 0, 255, 0 });
+    m_mainLayer->addChild(versionLabel);
+
+
+    CCDirector::sharedDirector()->getTouchDispatcher()->incrementForcePrio(2);
+    this->registerWithTouchDispatcher();
+
+    auto details = MDTextArea::create(
+        m_info.m_details.size() ?
+            m_info.m_details :
+            "### No description provided.",
+        { 350.f, 137.5f }
+    );
+    details->setPosition(
+        winSize.width / 2 - details->getScaledContentSize().width / 2,
+        winSize.height / 2 - details->getScaledContentSize().height / 2 - 20.f
+    );
+    m_mainLayer->addChild(details);
+
+    auto detailsBar = Scrollbar::create(details->getScrollLayer());
+    detailsBar->setPosition(
+        winSize.width / 2 + details->getScaledContentSize().width / 2 + 20.f,
+        winSize.height / 2 - 20.f
+    );
+    m_mainLayer->addChild(detailsBar);
+
+
+    auto infoSpr = CCSprite::createWithSpriteFrameName("GJ_infoIcon_001.png");
+    infoSpr->setScale(.85f);
+
+    auto infoBtn = CCMenuItemSpriteExtra::create(
+        infoSpr, this, menu_selector(ModInfoLayer::onInfo)
+    );
+    infoBtn->setPosition(size.width / 2 - 25.f, size.height / 2 - 25.f);
+    m_buttonMenu->addChild(infoBtn);
+
+
+    if (isInstalledMod) {
+        auto settingsSpr = CCSprite::createWithSpriteFrameName(
+            "GJ_optionsBtn_001.png"
+        );
+        settingsSpr->setScale(.65f);
+
+        auto settingsBtn = CCMenuItemSpriteExtra::create(
+            settingsSpr, this, menu_selector(ModInfoLayer::onSettings)
+        );
+        settingsBtn->setPosition(
+            -size.width / 2 + 25.f,
+            -size.height / 2 + 25.f
+        );
+        m_buttonMenu->addChild(settingsBtn);
+
+	    // if (!SettingManager::with(m_mod)->hasSettings()) {
+	    //     settingsSpr->setColor({ 150, 150, 150 });
+	    //     settingsBtn->setTarget(
+        //         this, menu_selector(ModInfoLayer::onNoSettings)
+        //     );
+	    // }
+
+        auto devSpr = ButtonSprite::create(
+            "Dev Options", "bigFont.fnt", "GJ_button_05.png", .6f
+        );
+        devSpr->setScale(.5f);
+
+        auto devBtn = CCMenuItemSpriteExtra::create(
+            devSpr, this, nullptr
+        );
+        devBtn->setPosition(size.width / 2 - 50.f, -size.height / 2 + 25.f);
+        m_buttonMenu->addChild(devBtn);
+
+
+        auto enableBtnSpr = ButtonSprite::create(
+            "Enable", "bigFont.fnt", "GJ_button_01.png", .6f
+        );
+        enableBtnSpr->setScale(.6f);
+
+        auto disableBtnSpr = ButtonSprite::create(
+            "Disable", "bigFont.fnt", "GJ_button_06.png", .6f
+        );
+        disableBtnSpr->setScale(.6f);
+
+        auto enableBtn = CCMenuItemToggler::create(
+            disableBtnSpr, enableBtnSpr,
+            this, menu_selector(ModInfoLayer::onEnableMod)
+        );
+        enableBtn->setPosition(-155.f, 75.f);
+        enableBtn->toggle(!obj->m_mod->isEnabled());
+        m_buttonMenu->addChild(enableBtn);
+
+        if (!m_info.m_supportsDisabling) {
+            enableBtn->setTarget(
+                this, menu_selector(ModInfoLayer::onDisablingNotSupported)
+            );
+            enableBtnSpr->setColor({ 150, 150, 150 });
+            disableBtnSpr->setColor({ 150, 150, 150 });
+        }
+
+        if (
+            m_mod != Loader::get()->getInternalMod() &&
+            m_mod != Mod::get()
+        ) {
+            auto uninstallBtnSpr = ButtonSprite::create(
+                "Uninstall", "bigFont.fnt", "GJ_button_05.png", .6f
+            );
+            uninstallBtnSpr->setScale(.6f);
+
+            auto uninstallBtn = CCMenuItemSpriteExtra::create(
+                uninstallBtnSpr, this,
+                menu_selector(ModInfoLayer::onUninstall)
+            );
+            uninstallBtn->setPosition(-85.f, 75.f);
+            m_buttonMenu->addChild(uninstallBtn);
+
+            // api and loader should be updated through the installer
+            // todo: show update button on them that invokes the installer
+            if (Index::get()->isUpdateAvailableForItem(m_info.m_id)) {
+                m_installBtnSpr = IconButtonSprite::create(
+                    "GE_button_01.png"_spr,
+                    CCSprite::createWithSpriteFrameName("install.png"_spr),
+                    "Update",
+                    "bigFont.fnt"
+                );
+                m_installBtnSpr->setScale(.6f);
+
+                m_installBtn = CCMenuItemSpriteExtra::create(
+                    m_installBtnSpr, this,
+                    menu_selector(ModInfoLayer::onInstallMod)
+                );
+                m_installBtn->setPosition(-8.0f, 75.f);
+                m_buttonMenu->addChild(m_installBtn);
+
+                m_installStatus = DownloadStatusNode::create();
+                m_installStatus->setPosition(
+                    winSize.width / 2 + 105.f,
+                    winSize.height / 2 + 75.f
+                );
+                m_installStatus->setVisible(false);
+                m_mainLayer->addChild(m_installStatus);
+
+                auto incomingVersion = Index::get()->getKnownItem(m_info.m_id).m_info.m_version.toString();
+
+                m_updateVersionLabel = CCLabelBMFont::create(
+                    ("Available: " + incomingVersion).c_str(), "bigFont.fnt"
+                );
+                m_updateVersionLabel->setScale(.35f);
+                m_updateVersionLabel->setAnchorPoint({ .0f, .5f });
+                m_updateVersionLabel->setColor({ 94, 219, 255 });
+                m_updateVersionLabel->setPosition(
+                    winSize.width / 2 + 35.f,
+                    winSize.height / 2 + 75.f
+                );
+                m_mainLayer->addChild(m_updateVersionLabel);
+            }
+        }
+    } else {
+        m_installBtnSpr = IconButtonSprite::create(
+            "GE_button_01.png"_spr,
+            CCSprite::createWithSpriteFrameName("install.png"_spr),
+            "Install",
+            "bigFont.fnt"
+        );
+        m_installBtnSpr->setScale(.6f);
+
+        m_installBtn = CCMenuItemSpriteExtra::create(
+            m_installBtnSpr, this,
+            menu_selector(ModInfoLayer::onInstallMod)
+        );
+        m_installBtn->setPosition(-143.0f, 75.f);
+        m_buttonMenu->addChild(m_installBtn);
+        
+        m_installStatus = DownloadStatusNode::create();
+        m_installStatus->setPosition(
+            winSize.width / 2 - 25.f,
+            winSize.height / 2 + 75.f
+        );
+        m_installStatus->setVisible(false);
+        m_mainLayer->addChild(m_installStatus);
+    }
+
+    auto closeSpr = CCSprite::createWithSpriteFrameName("GJ_closeBtn_001.png");
+    closeSpr->setScale(.8f);
+
+    auto closeBtn = CCMenuItemSpriteExtra::create(
+        closeSpr, this, menu_selector(ModInfoLayer::onClose)
+    );
+    closeBtn->setPosition(-size.width / 2 + 3.f, size.height / 2 - 3.f);
+    m_buttonMenu->addChild(closeBtn);
+
+    this->setKeypadEnabled(true);
+    this->setTouchEnabled(true);
+
+    return true;
+}
+
+void ModInfoLayer::onEnableMod(CCObject* pSender) {
+    // if (!APIInternal::get()->m_shownEnableWarning) {
+    //     APIInternal::get()->m_shownEnableWarning = true;
+    //     FLAlertLayer::create(
+    //         "Notice",
+    //         "<cb>Disabling</c> a <cy>mod</c> removes its hooks & patches and "
+    //         "calls its user-defined disable function if one exists. You may "
+    //         "still see some effects of the mod left however, and you may "
+    //         "need to <cg>restart</c> the game to have it fully unloaded.",
+    //         "OK"
+    //     )->show();
+    //     if (m_list) m_list->updateAllStates(nullptr);
+    //     return;
+    // }
+    if (as<CCMenuItemToggler*>(pSender)->isToggled()) {
+    	auto res = m_mod->load();
+        if (!res) {
+            FLAlertLayer::create(
+                nullptr,
+                "Error Loading Mod",
+                res.error(),
+                "OK", nullptr
+            )->show();
+        }
+        else {
+        	auto res = m_mod->enable();
+	        if (!res) {
+	            FLAlertLayer::create(
+	                nullptr,
+	                "Error Enabling Mod",
+	                res.error(),
+	                "OK", nullptr
+	            )->show();
+	        }
+        }
+        
+    } else {
+        auto res = m_mod->disable();
+        if (!res) {
+            FLAlertLayer::create(
+                nullptr,
+                "Error Disabling Mod",
+                res.error(),
+                "OK", nullptr
+            )->show();
+        }
+    }
+    if (m_list) m_list->updateAllStates(nullptr);
+    as<CCMenuItemToggler*>(pSender)->toggle(m_mod->isEnabled());
+}
+
+void ModInfoLayer::onInstallMod(CCObject*) {
+    auto ticketRes = Index::get()->installItem(
+        Index::get()->getKnownItem(m_info.m_id),
+        [this](InstallTicket* ticket, UpdateStatus status, std::string const& info, uint8_t progress) -> void {
+            this->modInstallProgress(ticket, status, info, progress);
+        }
+    );
+    if (!ticketRes) {
+        return FLAlertLayer::create(
+            "Unable to install",
+            ticketRes.error(),
+            "OK"
+        )->show();
+    }
+    m_ticket = ticketRes.value();
+
+    auto layer = FLAlertLayer::create(
+        this,
+        "Install",
+        "The following <cb>mods</c> will be installed: " +
+        vector_utils::join(m_ticket->getInstallList(), ",") + ".",
+        "Cancel", "OK", 360.f
+    );
+    layer->setTag(TAG_CONFIRM_INSTALL);
+    layer->show();
+}
+
+void ModInfoLayer::onCancelInstall(CCObject*) {
+    m_installBtn->setEnabled(false);
+    m_installBtnSpr->setString("Cancelling");
+
+    if (m_ticket) {
+        m_ticket->cancel();
+    }
+    if (m_updateVersionLabel) {
+        m_updateVersionLabel->setVisible(true);
+    }
+}
+
+void ModInfoLayer::onUninstall(CCObject*) {
+    auto layer = FLAlertLayer::create(
+        this,
+        "Confirm Uninstall",
+        "Are you sure you want to uninstall <cr>" + m_info.m_name + "</c>?",
+        "Cancel", "OK"
+    );
+    layer->setTag(TAG_CONFIRM_UNINSTALL);
+    layer->show();
+}
+
+void ModInfoLayer::FLAlert_Clicked(FLAlertLayer* layer, bool btn2) {
+    switch (layer->getTag()) {
+        case TAG_CONFIRM_INSTALL: {
+            if (btn2) {
+                this->install();
+            } else {
+                this->updateInstallStatus("", 0);
+            }
+        } break;
+
+        case TAG_CONFIRM_UNINSTALL: {
+            if (btn2) {
+                this->uninstall();
+            }
+        } break;
+
+        case TAG_DELETE_SAVEDATA: {
+            if (btn2) {
+                if (ghc::filesystem::remove_all(m_mod->getSaveDir())) {
+                    FLAlertLayer::create(
+                        "Deleted",
+                        "The mod's save data was deleted.",
+                        "OK"
+                    )->show();
+                } else {
+                    FLAlertLayer::create(
+                        "Error",
+                        "Unable to delete mod's save directory!",
+                        "OK"
+                    )->show();
+                }
+            }
+            m_list->refreshList();
+            this->onClose(nullptr);
+        } break;
+    }
+}
+
+void ModInfoLayer::updateInstallStatus(
+    std::string const& status,
+    uint8_t progress
+) {
+    if (status.size()) {
+        m_installStatus->setVisible(true);
+        m_installStatus->setStatus(status);
+        m_installStatus->setProgress(progress);
+    } else {
+        m_installStatus->setVisible(false);
+    }
+}
+
+void ModInfoLayer::modInstallProgress(
+    InstallTicket*,
+    UpdateStatus status,
+    std::string const& info,
+    uint8_t percentage
+) {
+    switch (status) {
+        case UpdateStatus::Failed: {
+            FLAlertLayer::create(
+                "Installation failed :(", info, "OK"
+            )->show();
+            this->updateInstallStatus("", 0);
+
+            m_installBtn->setEnabled(true);
+            m_installBtn->setTarget(
+                this, menu_selector(ModInfoLayer::onInstallMod)
+            );
+            m_installBtnSpr->setString("Install");
+            m_installBtnSpr->setBG("GE_button_01.png"_spr, false);
+
+            m_ticket = nullptr;
+        } break;
+
+        case UpdateStatus::Finished: {
+            this->updateInstallStatus("", 100);
+            
+            FLAlertLayer::create(
+                "Install complete",
+                "Mod succesfully installed! :) "
+                "(You may need to <cy>restart the game</c> "
+                "for the mod to take full effect)",
+                "OK"
+            )->show();
+
+            m_ticket = nullptr;
+
+            m_list->refreshList();
+            this->onClose(nullptr);
+        } break;
+
+        default: {
+            this->updateInstallStatus(info, percentage);
+        } break;
+    }
+}
+
+void ModInfoLayer::install() {
+    if (m_ticket) {
+        if (m_updateVersionLabel) {
+            m_updateVersionLabel->setVisible(false);
+        }
+        this->updateInstallStatus("Starting install", 0);
+
+        m_installBtn->setTarget(
+            this, menu_selector(ModInfoLayer::onCancelInstall)
+        );
+        m_installBtnSpr->setString("Cancel");
+        m_installBtnSpr->setBG("GJ_button_06.png", false);
+
+        m_ticket->start();
+    }
+}
+
+void ModInfoLayer::uninstall() {
+    auto res = m_mod->uninstall();
+    if (!res) {
+        return FLAlertLayer::create(
+            "Uninstall failed :(",
+            res.error(),
+            "OK"
+        )->show();
+    }
+    auto layer = FLAlertLayer::create(
+        this,
+        "Uninstall complete",
+        "Mod was succesfully uninstalled! :) "
+        "(You may need to <cy>restart the game</c> "
+        "for the mod to take full effect). "
+        "<co>Would you also like to delete the mod's "
+        "save data?</c>",
+        "Cancel", "Delete", 350.f
+    );
+    layer->setTag(TAG_DELETE_SAVEDATA);
+    layer->show();
+}
+
+void ModInfoLayer::onDisablingNotSupported(CCObject* pSender) {
+    FLAlertLayer::create(
+        "Unsupported",
+        "<cr>Disabling</c> is not supported for this mod.",
+        "OK"
+    )->show();
+    as<CCMenuItemToggler*>(pSender)->toggle(m_mod->isEnabled());
+}
+
+void ModInfoLayer::onHooks(CCObject*) {
+    auto layer = HookListLayer::create(this->m_mod);
+    this->addChild(layer);
+    layer->showLayer(false);
+}
+
+void ModInfoLayer::onSettings(CCObject*) {
+    //ModSettingsLayer::create(this->m_mod)->show();
+    // FIXME: No settings yet
+}
+
+void ModInfoLayer::onNoSettings(CCObject*) {
+    FLAlertLayer::create(
+        "No Settings Found",
+        "This mod has no customizable settings.",
+        "OK"
+    )->show();
+}
+
+void ModInfoLayer::onInfo(CCObject*) {
+    FLAlertLayer::create(
+        nullptr,
+        ("About " + m_info.m_name).c_str(),
+        "<cr>ID: " + m_info.m_id + "</c>\n"
+        "<cg>Version: " + m_info.m_version.toString() + "</c>\n"
+        "<cp>Developer: " + m_info.m_developer + "</c>\n"
+        "<cb>Path: " + m_info.m_path.string() + "</c>\n",
+        "OK", nullptr, 400.f
+    )->show();
+}
+
+void ModInfoLayer::keyDown(enumKeyCodes key) {
+    if (key == KEY_Escape)
+        return this->onClose(nullptr);
+    if (key == KEY_Space)
+        return;
+    
+    return FLAlertLayer::keyDown(key);
+}
+
+void ModInfoLayer::onClose(CCObject* pSender) {
+    this->setKeyboardEnabled(false);
+    this->removeFromParentAndCleanup(true);
+};
+
+ModInfoLayer* ModInfoLayer::create(Mod* mod, ModListView* list) {
+    auto ret = new ModInfoLayer;
+    if (ret && ret->init(new ModObject(mod), list)) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
+ModInfoLayer* ModInfoLayer::create(ModObject* obj, ModListView* list) {
+    auto ret = new ModInfoLayer;
+    if (ret && ret->init(obj, list)) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
+CCNode* ModInfoLayer::createLogoSpr(ModObject* modObj) {
+    switch (modObj->m_type) {
+        case ModObjectType::Mod: {
+            return ModInfoLayer::createLogoSpr(modObj->m_mod);
+        } break;
+        
+        case ModObjectType::Index: {
+            return ModInfoLayer::createLogoSpr(modObj->m_index);
+        } break;
+        
+        default: {
+            auto spr = CCSprite::createWithSpriteFrameName("no-logo.png"_spr);
+            if (!spr) {
+                return CCLabelBMFont::create("OwO", "goldFont.fnt");
+            }
+            return spr;
+        } break;
+    }
+}
+
+CCNode* ModInfoLayer::createLogoSpr(Mod* mod) {
+    CCNode* spr = nullptr;
+    if (mod == Loader::getInternalMod()) {
+        spr = CCSprite::create("geode.api.png");
+    } else {
+        spr = CCSprite::create(
+            CCString::createWithFormat(
+                "%s.png",
+                mod->getID().c_str()
+            )->getCString()
+        );
+    }
+    if (!spr) spr = CCSprite::createWithSpriteFrameName("no-logo.png"_spr);
+    if (!spr) spr = CCLabelBMFont::create("OwO", "goldFont.fnt");
+    return spr;
+}
+
+CCNode* ModInfoLayer::createLogoSpr(IndexItem const& item) {
+    CCNode* spr = nullptr;
+    auto logoPath = ghc::filesystem::absolute(item.m_path / "logo.png");
+    spr = CCSprite::create(logoPath.string().c_str());
+    if (!spr) spr = CCSprite::createWithSpriteFrameName("no-logo.png"_spr);
+    if (!spr) spr = CCLabelBMFont::create("OwO", "goldFont.fnt");
+    return spr;
+}
diff --git a/loader/src/ui/internal/info/ModInfoLayer.hpp b/loader/src/ui/internal/info/ModInfoLayer.hpp
new file mode 100644
index 00000000..7af442e5
--- /dev/null
+++ b/loader/src/ui/internal/info/ModInfoLayer.hpp
@@ -0,0 +1,73 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+#include <Index.hpp>
+
+USE_GEODE_NAMESPACE();
+
+class ModListView;
+class ModObject;
+
+class DownloadStatusNode : public CCNode {
+protected:
+    Slider* m_bar;
+    CCLabelBMFont* m_label;
+
+    bool init();
+
+public:
+    static DownloadStatusNode* create();
+
+    void setProgress(uint8_t progress);
+    void setStatus(std::string const& text);
+};
+
+class ModInfoLayer :
+    public FLAlertLayer,
+    public FLAlertLayerProtocol
+{
+protected:
+    Mod* m_mod = nullptr;
+    ModInfo m_info;
+    bool m_isIndexMod = false;
+    ModListView* m_list = nullptr;
+    DownloadStatusNode* m_installStatus = nullptr;
+    IconButtonSprite* m_installBtnSpr;
+    CCMenuItemSpriteExtra* m_installBtn;
+    CCLabelBMFont* m_updateVersionLabel = nullptr;
+    InstallTicket* m_ticket = nullptr;
+
+    void onHooks(CCObject*);
+    void onSettings(CCObject*);
+    void onNoSettings(CCObject*);
+    void onInfo(CCObject*);
+    void onEnableMod(CCObject*);
+    void onInstallMod(CCObject*);
+    void onCancelInstall(CCObject*);
+    void onUninstall(CCObject*);
+    void onDisablingNotSupported(CCObject*);
+    void install();
+    void uninstall();
+    void updateInstallStatus(std::string const& status, uint8_t progress);
+
+    void modInstallProgress(
+        InstallTicket*,
+        UpdateStatus status,
+        std::string const& info,
+        uint8_t percentage
+    );
+    void FLAlert_Clicked(FLAlertLayer*, bool) override;
+
+    bool init(ModObject* obj, ModListView* list);
+
+    void keyDown(cocos2d::enumKeyCodes) override;
+    void onClose(cocos2d::CCObject*);
+    
+public:
+    static ModInfoLayer* create(Mod* mod, ModListView* list);
+    static ModInfoLayer* create(ModObject* obj, ModListView* list);
+
+    static CCNode* createLogoSpr(ModObject* modObj);
+    static CCNode* createLogoSpr(Mod* mod);
+    static CCNode* createLogoSpr(IndexItem const& item);
+};
diff --git a/loader/src/ui/internal/list/ModListLayer.cpp b/loader/src/ui/internal/list/ModListLayer.cpp
new file mode 100644
index 00000000..64e33b3f
--- /dev/null
+++ b/loader/src/ui/internal/list/ModListLayer.cpp
@@ -0,0 +1,431 @@
+#include "ModListLayer.hpp"
+#include <Geode/ui/BasedButton.hpp>
+#include "SearchFilterPopup.hpp"
+#include <Geode/ui/Notification.hpp>
+
+static ModListType g_tab = ModListType::Installed;
+static ModListLayer* g_instance = nullptr;
+
+bool ModListLayer::init() {
+	if (!CCLayer::init())
+		return false;
+
+    auto winSize = CCDirector::sharedDirector()->getWinSize();
+	
+	// create background
+	auto bg = CCSprite::create("GJ_gradientBG.png");
+	auto bgSize = bg->getTextureRect().size;
+
+	bg->setAnchorPoint({ 0.0f, 0.0f });
+	bg->setScaleX((winSize.width + 10.0f) / bgSize.width);
+	bg->setScaleY((winSize.height + 10.0f) / bgSize.height);
+	bg->setPosition({ -5.0f, -5.0f });
+	bg->setColor({ 0, 102, 255 });
+
+	this->addChild(bg);
+
+	// create menus
+	m_menu = CCMenu::create();
+	m_topMenu = CCMenu::create();
+
+	// add back button
+    auto backBtn = CCMenuItemSpriteExtra::create(
+		CCSprite::createWithSpriteFrameName("GJ_arrow_01_001.png"),
+		this,
+		menu_selector(ModListLayer::onExit)
+	);
+	backBtn->setPosition(-winSize.width / 2 + 25.0f, winSize.height / 2 - 25.0f);
+	m_menu->addChild(backBtn);
+
+	// add refresh mods button
+	auto reloadSpr = CCSprite::createWithSpriteFrameName("GJ_updateBtn_001.png");
+	reloadSpr->setScale(.8f);
+    auto reloadBtn = CCMenuItemSpriteExtra::create(
+		reloadSpr, this, menu_selector(ModListLayer::onReload)
+	);
+	reloadBtn->setPosition(-winSize.width / 2 + 30.0f, - winSize.height / 2 + 30.0f);
+	m_menu->addChild(reloadBtn);
+
+	// add open folder button
+	auto openSpr = CircleButtonSprite::createWithSpriteFrameName(
+		"gj_folderBtn_001.png", .7f,
+		CircleBaseColor::Green, CircleBaseSize::Small
+	);
+    auto openBtn = CCMenuItemSpriteExtra::create(
+		openSpr, this, menu_selector(ModListLayer::onOpenFolder)
+	);
+	openBtn->setPosition(-winSize.width / 2 + 30.0f, - winSize.height / 2 + 80.0f);
+	this->m_menu->addChild(openBtn);
+
+
+	// add list status label	
+    m_listLabel = CCLabelBMFont::create("", "bigFont.fnt");
+
+    m_listLabel->setPosition(winSize / 2);
+    m_listLabel->setScale(.6f);
+    m_listLabel->setVisible(false);
+    m_listLabel->setZOrder(1001);
+
+    this->addChild(m_listLabel);
+
+	
+	// add index update status label
+    m_indexUpdateLabel = CCLabelBMFont::create("", "goldFont.fnt");
+
+    m_indexUpdateLabel->setPosition(winSize.width / 2, winSize.height / 2 - 80.f);
+    m_indexUpdateLabel->setScale(.5f);
+    m_indexUpdateLabel->setZOrder(1001);
+
+    this->addChild(m_indexUpdateLabel);
+	
+
+	// tabs
+	m_installedTabBtn = TabButton::create(
+		"Installed", this, menu_selector(ModListLayer::onTab)
+	);
+	m_installedTabBtn->setPosition(-95.f, 138.5f);
+	m_installedTabBtn->setTag(static_cast<int>(ModListType::Installed));
+	m_menu->addChild(m_installedTabBtn);
+
+	m_downloadTabBtn = TabButton::create(
+		"Download", this, menu_selector(ModListLayer::onTab)
+	);
+	m_downloadTabBtn->setPosition(0.f, 138.5f);
+	m_downloadTabBtn->setTag(static_cast<int>(ModListType::Download));
+	m_menu->addChild(m_downloadTabBtn);
+
+	m_featuredTabBtn = TabButton::create(
+		"Featured", this, menu_selector(ModListLayer::onTab)
+	);
+	m_featuredTabBtn->setPosition(95.f, 138.5f);
+	m_featuredTabBtn->setTag(static_cast<int>(ModListType::Featured));
+	m_menu->addChild(m_featuredTabBtn);
+
+	// add menus
+	m_menu->setZOrder(0);
+	m_topMenu->setZOrder(10);
+
+	this->addChild(m_menu);
+	this->addChild(m_topMenu);
+
+	// select first tab
+	this->onTab(nullptr);
+
+	// enable keyboard
+    this->setKeyboardEnabled(true);
+    this->setKeypadEnabled(true);
+
+	return true;
+}
+
+std::tuple<CCNode*, CCTextInputNode*> ModListLayer::createSearchControl() {
+	auto layer = CCLayerColor::create({ 194, 114, 62, 255 }, 358.f, 30.f);
+
+	auto menu = CCMenu::create();
+	menu->setPosition(340.f, 15.f);
+
+	auto filterSpr = EditorButtonSprite::createWithSpriteFrameName(
+		"filters.png"_spr, 1.0f, EditorBaseColor::Gray
+	);
+	filterSpr->setScale(.7f);
+
+	auto filterBtn = CCMenuItemSpriteExtra::create(
+		filterSpr, this, menu_selector(ModListLayer::onSearchFilters)
+	);
+	filterBtn->setPosition(-8.f, 0.f);
+	menu->addChild(filterBtn);
+
+	auto searchSpr = CCSprite::createWithSpriteFrameName("gj_findBtn_001.png");
+	searchSpr->setScale(.7f);
+
+	m_searchBtn = CCMenuItemSpriteExtra::create(searchSpr, this, nullptr);
+	m_searchBtn->setPosition(-35.f, 0.f);
+	menu->addChild(m_searchBtn);
+
+	auto searchClearSpr = CCSprite::createWithSpriteFrameName("gj_findBtnOff_001.png");
+	searchClearSpr->setScale(.7f);
+
+	m_searchClearBtn = CCMenuItemSpriteExtra::create(
+		searchClearSpr, this, menu_selector(ModListLayer::onResetSearch)
+	);
+	m_searchClearBtn->setPosition(-35.f, 0.f);
+	m_searchClearBtn->setVisible(false);
+	menu->addChild(m_searchClearBtn);
+
+	auto inputBG = CCScale9Sprite::create(
+        "square02b_001.png", { 0.0f, 0.0f, 80.0f, 80.0f }
+    );
+    inputBG->setColor({ 126, 59, 7 });
+	inputBG->setContentSize({ 530.f, 40.f });
+	inputBG->setPosition(153.f, 15.f);
+	inputBG->setScale(.5f);
+	layer->addChild(inputBG);
+
+	auto input = CCTextInputNode::create(250.f, 20.f, "Search Mods...", "bigFont.fnt");
+	input->setLabelPlaceholderColor({ 150, 150, 150 });
+	input->setLabelPlaceholderScale(.4f);
+	input->setMaxLabelScale(.4f);
+	input->setDelegate(this);
+	input->m_textField->setAnchorPoint({ .0f, .5f });
+	input->m_placeholderLabel->setAnchorPoint({ .0f, .5f });
+
+	layer->addChild(menu);
+	return { layer, input };
+}
+
+void ModListLayer::indexUpdateProgress(
+	UpdateStatus status,
+	std::string const& info,
+	uint8_t percentage
+) {
+	// if we have a check for updates button 
+	// visible, disable it from being clicked 
+	// again
+	if (m_checkForUpdatesBtn) {
+		m_checkForUpdatesBtn->setEnabled(false);
+		as<ButtonSprite*>(
+			m_checkForUpdatesBtn->getNormalImage()
+		)->setString("Updating Index");
+	}
+
+	// if finished, refresh list
+	if (status == UpdateStatus::Finished) {
+		m_indexUpdateLabel->setVisible(false);
+		this->reloadList();
+
+		// make sure to release global instance
+		// and set it back to null
+		CC_SAFE_RELEASE_NULL(g_instance);
+	}
+	else {
+		m_indexUpdateLabel->setVisible(true);
+		m_indexUpdateLabel->setString(info.c_str());
+	}
+
+	if (status == UpdateStatus::Failed) {
+		FLAlertLayer::create(
+			"Error Updating Index",
+			info, "OK"
+		)->show();
+
+		// make sure to release global instance
+		// and set it back to null
+		CC_SAFE_RELEASE(g_instance);
+	}
+}
+
+void ModListLayer::reloadList() {
+    auto winSize = CCDirector::sharedDirector()->getWinSize();
+
+	// remove old list
+    if (m_list) {
+		if (m_searchBG) m_searchBG->retain();
+        m_list->removeFromParent();
+	}
+
+	// create new list
+	const char* filter = m_searchInput ? m_searchInput->getString() : nullptr;
+	auto list = ModListView::create(g_tab, 358.f, 190.f, filter, m_searchFlags);
+	list->setLayer(this);
+
+	// set list status
+	auto status = list->getStatusAsString();
+	if (status.size()) {
+		m_listLabel->setVisible(true);
+		m_listLabel->setString(status.c_str());
+	} else {
+		m_listLabel->setVisible(false);
+	}
+
+	// update index if needed
+	if (g_tab == ModListType::Download && !Index::get()->isIndexUpdated()) {
+		m_listLabel->setString("Updating index...");
+		if (!m_loadingCircle) {
+			m_loadingCircle = LoadingCircle::create();
+
+			m_loadingCircle->setPosition(.0f, -40.f);
+			m_loadingCircle->setScale(.7f);
+			m_loadingCircle->setZOrder(1001);
+
+			m_loadingCircle->show();
+		}
+		this->onCheckForUpdates(nullptr);
+	} else {
+		if (m_loadingCircle) {
+			m_loadingCircle->fadeAndRemove();
+			m_loadingCircle = nullptr;
+		}
+	}
+
+	// set list
+    m_list = GJListLayer::create(
+        list, nullptr, { 0, 0, 0, 180 },
+		358.f, 220.f
+    );
+	m_list->setZOrder(2);
+    m_list->setPosition(
+        winSize / 2 - m_list->getScaledContentSize() / 2
+    );
+    this->addChild(m_list);
+
+	// add search input to list
+	if (!m_searchInput) {
+		auto search = this->createSearchControl();
+
+		m_searchBG = std::get<0>(search);
+		m_searchBG->setPosition(0.f, 190.f);
+		m_list->addChild(m_searchBG);
+
+		m_searchInput = std::get<1>(search);
+		m_searchInput->setPosition(
+			winSize.width / 2 - 155.f,
+			winSize.height / 2 + 95.f
+		);
+		m_searchInput->setZOrder(60);
+		this->addChild(m_searchInput);
+	} else {
+		m_list->addChild(m_searchBG);
+		m_searchBG->release();
+	}
+
+	// check if the user has searched something, 
+	// and show visual indicator if so
+	auto hasQuery = filter && strlen(filter);
+	m_searchBtn->setVisible(!hasQuery);
+	m_searchClearBtn->setVisible(hasQuery);
+
+	// add/remove "Check for Updates" button
+	if (
+		// only show it on the install tab
+		g_tab == ModListType::Installed &&
+		// check if index is updated, and if not 
+		// add button if it doesn't exist yet
+		!Index::get()->isIndexUpdated()
+	) {
+		if (!m_checkForUpdatesBtn) {
+			auto checkSpr = ButtonSprite::create("Check for Updates");
+			checkSpr->setScale(.7f);
+			m_checkForUpdatesBtn = CCMenuItemSpriteExtra::create(
+				checkSpr,
+				this,
+				menu_selector(ModListLayer::onCheckForUpdates)
+			);
+			m_checkForUpdatesBtn->setPosition(0, -winSize.height / 2 + 40.f);
+			m_topMenu->addChild(m_checkForUpdatesBtn);
+		}
+	}
+	// otherwise remove the button if it 
+	// exists
+	else if (m_checkForUpdatesBtn) {
+		m_checkForUpdatesBtn->removeFromParent();
+		m_checkForUpdatesBtn = nullptr;
+	}
+}
+
+void ModListLayer::onCheckForUpdates(CCObject*) {
+	// store instance in a global so the 
+	// layer stays in memory even if the 
+	// user leaves the layer and we don't
+	// end up trying to update the UI of 
+	// a deleted layer
+	g_instance = this;
+	g_instance->retain();
+	
+	// update index
+	Index::get()->updateIndex(
+		[](
+			UpdateStatus status,
+			std::string const& info,
+			uint8_t progress
+		) -> void {
+			g_instance->indexUpdateProgress(status, info, progress);
+		}
+	);
+}
+
+void ModListLayer::textChanged(CCTextInputNode* input) {
+	this->reloadList();
+}
+
+void ModListLayer::onExit(CCObject*) {
+	CCDirector::sharedDirector()->replaceScene(
+		CCTransitionFade::create(.5f, MenuLayer::scene(false))
+	);
+}
+
+void ModListLayer::onReload(CCObject*) {
+	Loader::get()->refreshMods();
+	this->reloadList();
+}
+
+void ModListLayer::onOpenFolder(CCObject*) {
+	dirs::openFolder(
+		ghc::filesystem::canonical(Loader::get()->getGeodeDirectory() / "mods")
+	);
+}
+
+void ModListLayer::onResetSearch(CCObject*) {
+	m_searchInput->setString("");
+}
+
+void ModListLayer::keyDown(enumKeyCodes key) {
+	if (key == KEY_Escape) {
+        this->onExit(nullptr);
+	}
+}
+
+void ModListLayer::onTab(CCObject* pSender) {
+	if (pSender) {
+		g_tab = static_cast<ModListType>(pSender->getTag());
+	}
+	this->reloadList();
+
+	auto toggleTab = [this](CCMenuItemToggler* member) -> void {
+		auto isSelected = member->getTag() == static_cast<int>(g_tab);
+		auto targetMenu = isSelected ? m_topMenu : m_menu;
+		member->toggle(isSelected);
+		if (member->getParent() != targetMenu) {
+			member->retain();
+			member->removeFromParent();
+			targetMenu->addChild(member);
+			member->release();
+		}
+	};
+
+	toggleTab(m_downloadTabBtn);
+	toggleTab(m_installedTabBtn);
+	toggleTab(m_featuredTabBtn);
+}
+
+void ModListLayer::onSearchFilters(CCObject*) {
+	SearchFilterPopup::create(this)->show();
+}
+
+ModListLayer* ModListLayer::create() {
+	// return global instance if one exists
+	if (g_instance) return g_instance;
+
+	// otherwise create new instance like a 
+	// normal person
+	auto ret = new ModListLayer();
+	if (ret && ret->init()) {
+		ret->autorelease();
+		return ret;
+	}
+	CC_SAFE_DELETE(ret);
+	return nullptr;
+}
+
+ModListLayer* ModListLayer::scene() {
+	auto scene = CCScene::create();
+	auto layer = ModListLayer::create();
+	scene->addChild(layer);
+	CCDirector::sharedDirector()->replaceScene(
+		CCTransitionFade::create(.5f, scene)
+	);
+	return layer;
+}
+
+ModListLayer::~ModListLayer() {
+	removeAllChildrenWithCleanup(true);
+}
diff --git a/loader/src/ui/internal/list/ModListLayer.hpp b/loader/src/ui/internal/list/ModListLayer.hpp
new file mode 100644
index 00000000..bf81cfd2
--- /dev/null
+++ b/loader/src/ui/internal/list/ModListLayer.hpp
@@ -0,0 +1,56 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+#include "ModListView.hpp"
+#include <Index.hpp>
+
+USE_GEODE_NAMESPACE();
+
+class SearchFilterPopup;
+
+class ModListLayer : public CCLayer, public TextInputDelegate {
+protected:
+	GJListLayer* m_list = nullptr;
+	CCLabelBMFont* m_listLabel;
+	CCLabelBMFont* m_indexUpdateLabel;
+	CCMenu* m_menu;
+	CCMenu* m_topMenu;
+	CCMenuItemToggler* m_installedTabBtn;
+	CCMenuItemToggler* m_downloadTabBtn;
+	CCMenuItemToggler* m_featuredTabBtn;
+	CCMenuItemSpriteExtra* m_searchBtn;
+	CCMenuItemSpriteExtra* m_searchClearBtn;
+	CCMenuItemSpriteExtra* m_checkForUpdatesBtn = nullptr;
+	CCNode* m_searchBG = nullptr;
+	CCTextInputNode* m_searchInput = nullptr;
+	LoadingCircle* m_loadingCircle = nullptr;
+	int m_searchFlags = ModListView::s_allFlags;
+
+	virtual ~ModListLayer();
+
+	bool init() override;
+
+	void onExit(CCObject*);
+	void onReload(CCObject*);
+	void onCheckForUpdates(CCObject*);
+	void onOpenFolder(CCObject*);
+	void onResetSearch(CCObject*);
+	void keyDown(enumKeyCodes) override;
+	void onTab(CCObject*);
+	void onSearchFilters(CCObject*);
+	void textChanged(CCTextInputNode*) override;
+	void indexUpdateProgress(
+        UpdateStatus status,
+        std::string const& info,
+        uint8_t percentage
+	);
+	std::tuple<CCNode*, CCTextInputNode*> createSearchControl();
+
+	friend class SearchFilterPopup;
+
+public:
+	static ModListLayer* create();
+	static ModListLayer* scene();
+
+	void reloadList();
+};
diff --git a/loader/src/ui/internal/list/ModListView.cpp b/loader/src/ui/internal/list/ModListView.cpp
new file mode 100644
index 00000000..4ab9916e
--- /dev/null
+++ b/loader/src/ui/internal/list/ModListView.cpp
@@ -0,0 +1,539 @@
+#include "ModListView.hpp"
+#include "../info/ModInfoLayer.hpp"
+#include <Geode/utils/WackyGeodeMacros.hpp>
+#include <Index.hpp>
+#include "ModListLayer.hpp"
+
+ModCell::ModCell(const char* name, CCSize size) :
+    TableViewCell(name, size.width, size.height) {}
+
+void ModCell::draw() {
+    reinterpret_cast<StatsCell*>(this)->StatsCell::draw();
+}
+
+void ModCell::onFailedInfo(CCObject*) {
+    FLAlertLayer::create(
+        this,
+        "Error Info",
+        m_obj->m_info.m_reason.size() ?
+            m_obj->m_info.m_reason :
+            m_obj->m_mod->getLoadErrorInfo(),
+        "OK", "Remove file",
+        360.f
+    )->show();
+}
+
+void ModCell::FLAlert_Clicked(FLAlertLayer*, bool btn2) {
+    if (btn2) {
+        try {
+            if (ghc::filesystem::remove(m_obj->m_info.m_file)) {
+                FLAlertLayer::create(
+                    "File removed",
+                    "Removed <cy>" + m_obj->m_info.m_file + "</c>",
+                    "OK"
+                )->show();
+            } else {
+                FLAlertLayer::create(
+                    "Unable to remove file",
+                    "Unable to remove <cy>" + m_obj->m_info.m_file + "</c>",
+                    "OK"
+                )->show();
+            }
+        } catch(std::exception& e) {
+            FLAlertLayer::create(
+                "Unable to remove file",
+                "Unable to remove <cy>" +
+                    m_obj->m_info.m_file + "</c>: <cr>" + 
+                    std::string(e.what()) + "</c>",
+                "OK"
+            )->show();
+        }
+        Loader::get()->refreshMods();
+        m_list->refreshList();
+    }
+}
+
+void ModCell::setupUnloaded() {
+    m_mainLayer->setVisible(true);
+
+    auto menu = CCMenu::create();
+    menu->setPosition(m_width - m_height, m_height / 2);
+    m_mainLayer->addChild(menu);
+
+    auto titleLabel = CCLabelBMFont::create("Failed to Load", "bigFont.fnt");
+    titleLabel->setAnchorPoint({ .0f, .5f });
+    titleLabel->setScale(.5f);
+    titleLabel->setPosition(m_height / 2, m_height / 2 + 7.f);
+    m_mainLayer->addChild(titleLabel);
+    
+    auto pathLabel = CCLabelBMFont::create(
+        m_obj->m_info.m_file.c_str(), "chatFont.fnt"
+    );
+    pathLabel->setAnchorPoint({ .0f, .5f });
+    pathLabel->setScale(.43f);
+    pathLabel->setPosition(m_height / 2, m_height / 2 - 7.f);
+    pathLabel->setColor({ 255, 255, 0 });
+    m_mainLayer->addChild(pathLabel);
+
+    auto whySpr = ButtonSprite::create(
+        "Info", 0, 0, "bigFont.fnt", "GJ_button_01.png", 0, .8f
+    );
+    whySpr->setScale(.65f);
+
+    auto viewBtn = CCMenuItemSpriteExtra::create(
+        whySpr, this, menu_selector(ModCell::onFailedInfo)
+    );
+    menu->addChild(viewBtn);
+}
+
+void ModCell::setupLoadedButtons() {
+    auto viewSpr = m_obj->m_mod->wasSuccesfullyLoaded() ?
+        ButtonSprite::create("View", "bigFont.fnt", "GJ_button_01.png", .8f) :
+        ButtonSprite::create("Why", "bigFont.fnt", "GJ_button_06.png", .8f);
+    viewSpr->setScale(.65f);
+
+    auto viewBtn = CCMenuItemSpriteExtra::create(
+        viewSpr, this, 
+            m_obj->m_mod->wasSuccesfullyLoaded() ?
+                menu_selector(ModCell::onInfo) :
+                menu_selector(ModCell::onFailedInfo)
+    );
+    m_menu->addChild(viewBtn);
+
+    if (
+        m_obj->m_mod->wasSuccesfullyLoaded() &&
+        m_obj->m_mod->supportsDisabling()
+    ) {
+        m_enableToggle = CCMenuItemToggler::createWithStandardSprites(
+            this, menu_selector(ModCell::onEnable), .7f
+        );
+        m_enableToggle->setPosition(-50.f, 0.f);
+        m_menu->addChild(m_enableToggle);
+    }
+
+    auto exMark = CCSprite::createWithSpriteFrameName("exMark_001.png");
+    exMark->setScale(.5f);
+
+    m_unresolvedExMark = CCMenuItemSpriteExtra::create(
+        exMark, this, menu_selector(ModCell::onUnresolvedInfo)
+    );
+    m_unresolvedExMark->setPosition(-80.f, 0.f);
+    m_menu->addChild(m_unresolvedExMark);
+
+    if (!m_obj->m_mod->wasSuccesfullyLoaded()) {
+        m_unresolvedExMark->setVisible(false);
+    } else {
+        if (Index::get()->isUpdateAvailableForItem(m_obj->m_mod->getID())) {
+            viewSpr->updateBGImage("GE_button_01.png"_spr);
+
+            auto updateIcon = CCSprite::createWithSpriteFrameName("updates-available.png"_spr);
+            updateIcon->setPosition(viewSpr->getContentSize() - CCSize { 2.f, 2.f });
+            updateIcon->setZOrder(99);
+            updateIcon->setScale(.5f);
+            viewSpr->addChild(updateIcon);
+        }
+    }
+}
+
+void ModCell::setupIndexButtons() {
+    auto viewSpr = ButtonSprite::create(
+        "View", "bigFont.fnt", "GJ_button_01.png", .8f
+    );
+    viewSpr->setScale(.65f);
+
+    auto viewBtn = CCMenuItemSpriteExtra::create(
+        viewSpr, this, menu_selector(ModCell::onInfo)
+    );
+    m_menu->addChild(viewBtn);
+}
+
+void ModCell::loadFromObject(ModObject* modobj) {
+    m_obj = modobj;
+
+    if (modobj->m_type == ModObjectType::Unloaded) {
+        return this->setupUnloaded();
+    }
+
+    m_mainLayer->setVisible(true);
+    m_backgroundLayer->setOpacity(255);
+    
+    m_menu = CCMenu::create();
+    m_menu->setPosition(m_width - m_height, m_height / 2);
+    m_mainLayer->addChild(m_menu);
+
+    auto logoSize = m_height - 12.f;
+
+    auto logoSpr = ModInfoLayer::createLogoSpr(modobj);
+    logoSpr->setPosition({ logoSize / 2 + 12.f, m_height / 2 });
+    logoSpr->setScale(logoSize / logoSpr->getContentSize().width);
+    m_mainLayer->addChild(logoSpr);
+
+    ModInfo info;
+    switch (modobj->m_type) {
+        case ModObjectType::Mod:
+            info = modobj->m_mod->getModInfo();
+            break;
+
+        case ModObjectType::Index:
+            info = modobj->m_index.m_info;
+            break;
+        
+        default: return;
+    }
+
+    auto titleLabel = CCLabelBMFont::create(info.m_name.c_str(), "bigFont.fnt");
+    titleLabel->setAnchorPoint({ .0f, .5f });
+    titleLabel->setPosition(m_height / 2 + logoSize, m_height / 2 + 7.f);
+    titleLabel->limitLabelWidth(m_width / 2 - 30.f, .5f, .1f);
+    m_mainLayer->addChild(titleLabel);
+
+    auto versionLabel = CCLabelBMFont::create(
+        info.m_version.toString().c_str(), "bigFont.fnt"
+    );
+    versionLabel->setAnchorPoint({ .0f, .5f });
+    versionLabel->setScale(.3f);
+    versionLabel->setPosition(
+        titleLabel->getPositionX() + titleLabel->getScaledContentSize().width + 5.f,
+        m_height / 2 + 7.f
+    );
+    versionLabel->setColor({ 0, 255, 0 });
+    m_mainLayer->addChild(versionLabel);
+    
+    auto creatorStr = "by " + info.m_developer;
+    auto creatorLabel = CCLabelBMFont::create(
+        creatorStr.c_str(), "goldFont.fnt"
+    );
+    creatorLabel->setAnchorPoint({ .0f, .5f });
+    creatorLabel->setScale(.43f);
+    creatorLabel->setPosition(m_height / 2 + logoSize, m_height / 2 - 7.f);
+    m_mainLayer->addChild(creatorLabel);
+
+    switch (modobj->m_type) {
+        case ModObjectType::Mod:
+            this->setupLoadedButtons();
+            break;
+
+        case ModObjectType::Index:
+            this->setupIndexButtons();
+            break;
+
+        default: 
+        	break;
+    }
+    this->updateState();
+}
+
+void ModCell::onInfo(CCObject*) {
+    ModInfoLayer::create(m_obj, m_list)->show();
+}
+
+void ModCell::updateBGColor(int index) {
+	if (index & 1) m_backgroundLayer->setColor(ccc3(0xc2, 0x72, 0x3e));
+    else m_backgroundLayer->setColor(ccc3(0xa1, 0x58, 0x2c));
+    m_backgroundLayer->setOpacity(0xff);
+}
+
+void ModCell::onEnable(CCObject* pSender) {
+    // if (!APIInternal::get()->m_shownEnableWarning) {
+    //     APIInternal::get()->m_shownEnableWarning = true;
+    //     FLAlertLayer::create(
+    //         "Notice",
+    //         "<cb>Disabling</c> a <cy>mod</c> removes its hooks & patches and "
+    //         "calls its user-defined disable function if one exists. You may "
+    //         "still see some effects of the mod left however, and you may "
+    //         "need to <cg>restart</c> the game to have it fully unloaded.",
+    //         "OK"
+    //     )->show();
+    //     m_list->updateAllStates(this);
+    //     return;
+    // }
+    if (!as<CCMenuItemToggler*>(pSender)->isToggled()) {
+        auto res = m_obj->m_mod->load();
+        if (!res) {
+            FLAlertLayer::create(
+                nullptr,
+                "Error Loading Mod",
+                res.error(),
+                "OK", nullptr
+            )->show();
+        }
+        else {
+        	auto res = m_obj->m_mod->enable();
+	        if (!res) {
+	            FLAlertLayer::create(
+	                nullptr,
+	                "Error Enabling Mod",
+	                res.error(),
+	                "OK", nullptr
+	            )->show();
+	        }
+        }
+    } else {
+        auto res = m_obj->m_mod->disable();
+        if (!res) {
+            FLAlertLayer::create(
+                nullptr,
+                "Error Disabling Mod",
+                res.error(),
+                "OK", nullptr
+            )->show();
+        }
+    }
+    m_list->updateAllStates(this);
+}
+
+void ModCell::onUnresolvedInfo(CCObject* pSender) {
+    std::string info =
+        "This mod has the following "
+        "<cr>unresolved dependencies</c>: ";
+    for (auto const& dep : m_obj->m_mod->getUnresolvedDependencies()) {
+        info +=
+            "<cg>" + dep.m_id + "</c> "
+            "(<cy>" + dep.m_version.toString() + "</c>), ";
+    }
+    info.pop_back();
+    info.pop_back();
+    FLAlertLayer::create(
+        nullptr,
+        "Unresolved Dependencies",
+        info,
+        "OK", nullptr,
+        400.f 
+    )->show();
+}
+
+bool ModCell::init(ModListView* list) {
+    m_list = list;
+    return true;
+}
+
+void ModCell::updateState(bool invert) {
+    if (m_obj->m_type == ModObjectType::Mod) {
+        bool unresolved = m_obj->m_mod->hasUnresolvedDependencies();
+        if (m_enableToggle) {
+            m_enableToggle->toggle(m_obj->m_mod->isEnabled() ^ invert);
+            m_enableToggle->setEnabled(!unresolved);
+            m_enableToggle->m_offButton->setOpacity(unresolved ? 100 : 255);
+            m_enableToggle->m_offButton->setColor(unresolved ? cc3x(155) : cc3x(255));
+            m_enableToggle->m_onButton->setOpacity(unresolved ? 100 : 255);
+            m_enableToggle->m_onButton->setColor(unresolved ? cc3x(155) : cc3x(255));
+        }
+        m_unresolvedExMark->setVisible(unresolved);
+    }
+}
+
+ModCell* ModCell::create(ModListView* list, const char* key, CCSize size) {
+    auto pRet = new ModCell(key, size);
+    if (pRet && pRet->init(list)) {
+        return pRet;
+    }
+    CC_SAFE_DELETE(pRet);
+    return nullptr;
+}
+
+
+void ModListView::updateAllStates(ModCell* toggled) {
+    CCARRAY_FOREACH_B_TYPE(m_tableView->m_cellArray, cell, ModCell) {
+        cell->updateState(toggled == cell);
+    }
+}
+
+void ModListView::setupList() {
+    m_itemSeparation = 40.0f;
+
+    if (!m_entries->count()) return;
+
+    m_tableView->reloadData();
+
+    // fix content layer content size so the 
+    // list is properly aligned to the top
+    auto coverage = calculateChildCoverage(m_tableView->m_contentLayer);
+    m_tableView->m_contentLayer->setContentSize({
+        -coverage.origin.x + coverage.size.width,
+        -coverage.origin.y + coverage.size.height
+    });
+
+    if (m_entries->count() == 1) {
+        m_tableView->moveToTopWithOffset(m_itemSeparation * 2);
+    } else if (m_entries->count() == 2) {
+        m_tableView->moveToTopWithOffset(-m_itemSeparation);
+    } else {
+        m_tableView->moveToTop();
+    }
+}
+
+TableViewCell* ModListView::getListCell(const char* key) {
+    return ModCell::create(this, key, { m_width, m_itemSeparation });
+}
+
+void ModListView::loadCell(TableViewCell* cell, unsigned int index) {
+    auto obj = as<ModObject*>(m_entries->objectAtIndex(index));
+    as<ModCell*>(cell)->loadFromObject(obj);
+    if (obj->m_type == ModObjectType::Mod) {
+        if (obj->m_mod->wasSuccesfullyLoaded()) {
+            as<ModCell*>(cell)->updateBGColor(index);
+        } else {
+            cell->m_backgroundLayer->setOpacity(255);
+            cell->m_backgroundLayer->setColor({ 153, 0, 0 });
+        }
+        if (obj->m_mod->isUninstalled()) {
+            cell->m_backgroundLayer->setColor({ 50, 50, 50 });
+        }
+    } else {
+        as<ModCell*>(cell)->updateBGColor(index);
+    }
+}
+
+bool ModListView::filter(ModInfo const& info, const char* searchFilter, int searchFlags) {
+    if (!searchFilter || !strlen(searchFilter)) return true;
+    auto check = [searchFlags, searchFilter](SearchFlags flag, std::string const& name) -> bool {
+        if (!(searchFlags & flag)) return false;
+        return string_utils::contains(
+            string_utils::toLower(name),
+            string_utils::toLower(searchFilter)
+        );
+    };
+    if (check(SearchFlags::Name,        info.m_name)) return true;
+    if (check(SearchFlags::ID,          info.m_id)) return true;
+    if (check(SearchFlags::Developer,   info.m_developer)) return true;
+    if (check(SearchFlags::Description, info.m_description)) return true;
+    if (check(SearchFlags::Details,     info.m_details)) return true;
+    return false;
+}
+
+static void sortInstalledMods(std::vector<Mod*>& mods) {
+    if (!mods.size()) return;
+    // keep track of first object 
+    size_t frontIndex = 0;
+    auto front = mods.front();
+    for (auto mod = mods.begin(); mod != mods.end(); mod++) {
+        // move mods with updates to front
+        if (Index::get()->isUpdateAvailableForItem((*mod)->getID())) {
+            // swap first object and updatable mod
+            // if the updatable mod is the first object, 
+            // nothing changes
+            std::rotate(mods.begin(), mod, mod + 1);
+
+            // get next object at front for next mod 
+            // to sort
+            frontIndex++;
+            front = mods[frontIndex];
+        }
+    }
+}
+
+static std::vector<Mod*> sortedInstalledMods() {
+    auto mods = Loader::get()->getAllMods();
+    sortInstalledMods(mods);
+    return std::move(mods);
+}
+
+bool ModListView::init(
+    CCArray* mods,
+    ModListType type,
+    float width,
+    float height,
+    const char* searchFilter,
+    int searchFlags
+) {
+    if (!mods) {
+        switch (type) {
+            case ModListType::Installed: {
+                mods = CCArray::create();
+                // failed mods first
+                for (auto const& mod : Loader::get()->getFailedMods()) {
+                    mods->addObject(new ModObject(mod));
+                }
+                // internal geode representation always at the top
+                auto imod = Loader::getInternalMod();
+                if (this->filter(imod->getModInfo(), searchFilter, searchFlags)) {
+                    mods->addObject(new ModObject(imod));
+                }
+                // then other mods
+                for (auto const& mod : sortedInstalledMods()) {
+                    // if the mod is no longer installed nor 
+                    // loaded, it's as good as not existing
+                    // (because it doesn't)
+                    if (mod->isUninstalled() && !mod->isLoaded()) continue;
+                    if (this->filter(mod->getModInfo(), searchFilter, searchFlags)) {
+                        mods->addObject(new ModObject(mod));
+                    }
+                }
+                if (!mods->count()) {
+                    m_status = Status::SearchEmpty;
+                }
+            } break;
+
+            case ModListType::Download: {
+                mods = CCArray::create();
+                for (auto const& item : Index::get()->getUninstalledItems()) {
+                    mods->addObject(new ModObject(item));
+                }
+                if (!mods->count()) {
+                    m_status = Status::NoModsFound;
+                }
+            } break;
+
+            case ModListType::Featured: {
+                mods = CCArray::create();
+                m_status = Status::NoModsFound;
+            } break;
+
+            default: return false;
+        }
+    }
+    return CustomListView::init(mods, BoomListType::Default, width, height);
+}
+
+ModListView* ModListView::create(
+    CCArray* mods,
+    ModListType type,
+    float width,
+    float height,
+    const char* searchFilter,
+    int searchFlags
+) {
+    auto pRet = new ModListView;
+    if (pRet) {
+        if (pRet->init(mods, type, width, height, searchFilter, searchFlags)) {
+            pRet->autorelease();
+            return pRet;
+        }
+    }
+    CC_SAFE_DELETE(pRet);
+    return nullptr;
+}
+
+ModListView* ModListView::create(
+    ModListType type,
+    float width,
+    float height,
+    const char* searchFilter,
+    int searchFlags
+) {
+    return ModListView::create(nullptr, type, width, height, searchFilter, searchFlags);
+}
+
+ModListView::Status ModListView::getStatus() const {
+    return m_status;
+}
+
+std::string ModListView::getStatusAsString() const {
+    switch (m_status) {
+        case Status::OK:          return "";
+        case Status::Unknown:     return "Unknown Issue";
+        case Status::NoModsFound: return "No Mods Found";
+        case Status::SearchEmpty: return "No Mods Match Search Query";
+    }
+    return "Unrecorded Status";
+}
+
+void ModListView::setLayer(ModListLayer* layer) {
+    m_layer = layer;
+}
+
+void ModListView::refreshList() {
+    if (m_layer) {
+        m_layer->reloadList();
+    }
+}
diff --git a/loader/src/ui/internal/list/ModListView.hpp b/loader/src/ui/internal/list/ModListView.hpp
new file mode 100644
index 00000000..c7fb6973
--- /dev/null
+++ b/loader/src/ui/internal/list/ModListView.hpp
@@ -0,0 +1,144 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+#include <Index.hpp>
+
+USE_GEODE_NAMESPACE();
+
+enum class ModListType {
+	Installed,
+	Download,
+	Featured,
+};
+
+enum class ModObjectType {
+    Mod,
+    Unloaded,
+    Index,
+};
+
+class ModListLayer;
+
+// Wrapper so you can pass Mods in a CCArray
+struct ModObject : public CCObject {
+    ModObjectType m_type;
+    Mod* m_mod;
+    Loader::FailedModInfo m_info;
+    IndexItem m_index;
+
+    inline ModObject(Mod* mod)
+     : m_mod(mod), m_type(ModObjectType::Mod) {
+        this->autorelease();
+    };
+    inline ModObject(Loader::FailedModInfo const& info)
+     : m_info(info), m_type(ModObjectType::Unloaded) {
+        this->autorelease();
+    };
+    inline ModObject(IndexItem const& index)
+     : m_index(index), m_type(ModObjectType::Index) {
+        this->autorelease();
+    };
+};
+
+class ModListView;
+
+class ModCell : public TableViewCell, public FLAlertLayerProtocol {
+protected:
+    ModListView* m_list;
+    ModObject* m_obj;
+    CCMenu* m_menu;
+    CCMenuItemToggler* m_enableToggle = nullptr;
+    CCMenuItemSpriteExtra* m_unresolvedExMark;
+
+    ModCell(const char* name, CCSize size);
+
+    void draw() override;
+    void onInfo(CCObject*);
+    void onFailedInfo(CCObject*);
+    void onEnable(CCObject*);
+    void onUnresolvedInfo(CCObject*);
+
+    void setupUnloaded();
+    void setupLoadedButtons();
+    void setupIndexButtons();
+
+    void FLAlert_Clicked(FLAlertLayer*, bool btn2) override;
+
+    bool init(ModListView* list);
+
+public:
+    void updateBGColor(int index);
+    void loadFromObject(ModObject*);
+    void updateState(bool invert = false);
+
+    static ModCell* create(ModListView* list, const char* key, CCSize size);
+};
+
+class ModListView : public CustomListView {
+public:
+    // this is not enum class so | works
+    enum SearchFlags {
+        Name        = 0b1,
+        ID          = 0b10,
+        Developer   = 0b100,
+        Credits     = 0b1000,
+        Description = 0b10000,
+        Details     = 0b100000,
+    };
+    static constexpr int s_allFlags =
+        SearchFlags::Name |
+        SearchFlags::ID |
+        SearchFlags::Developer |
+        SearchFlags::Credits |
+        SearchFlags::Description |
+        SearchFlags::Details;
+
+protected:
+    enum class Status {
+        OK,
+        Unknown,
+        NoModsFound,
+        SearchEmpty,
+    };
+
+    Status m_status = Status::OK;
+    ModListLayer* m_layer = nullptr;
+
+    void setupList() override;
+    TableViewCell* getListCell(const char* key) override;
+    void loadCell(TableViewCell* cell, unsigned int index) override;
+
+    bool init(
+        CCArray* mods,
+        ModListType type,
+        float width,
+        float height,
+        const char* searchFilter,
+        int searchFlags
+    );
+    bool filter(ModInfo const& info, const char* searchFilter, int searchFlags);
+
+public:
+    static ModListView* create(
+        CCArray* mods,
+        ModListType type = ModListType::Installed,
+        float width = 358.f,
+        float height = 220.f,
+        const char* searchFilter = nullptr,
+        int searchFlags = 0
+    );
+    static ModListView* create(
+        ModListType type,
+        float width = 358.f,
+        float height = 220.f,
+        const char* searchFilter = nullptr,
+        int searchFlags = 0
+    );
+
+    void updateAllStates(ModCell* toggled = nullptr);
+    void setLayer(ModListLayer* layer);
+    void refreshList();
+
+    Status getStatus() const;
+    std::string getStatusAsString() const;
+};
diff --git a/loader/src/ui/internal/list/SearchFilterPopup.cpp b/loader/src/ui/internal/list/SearchFilterPopup.cpp
new file mode 100644
index 00000000..d60386ab
--- /dev/null
+++ b/loader/src/ui/internal/list/SearchFilterPopup.cpp
@@ -0,0 +1,102 @@
+#include "SearchFilterPopup.hpp"
+#include "ModListLayer.hpp"
+#include "ModListView.hpp"
+
+bool SearchFilterPopup::init(ModListLayer* layer) {
+    this->m_noElasticity = true;
+
+    this->m_modLayer = layer;
+
+    auto winSize = CCDirector::sharedDirector()->getWinSize();
+	CCSize size { 280.f, 250.f };
+
+    if (!this->initWithColor({ 0, 0, 0, 105 })) return false;
+    this->m_mainLayer = CCLayer::create();
+    this->addChild(this->m_mainLayer);
+
+    auto bg = CCScale9Sprite::create("GJ_square05.png", { 0.0f, 0.0f, 80.0f, 80.0f });
+    bg->setContentSize(size);
+    bg->setPosition(winSize.width / 2, winSize.height / 2);
+    this->m_mainLayer->addChild(bg);
+
+    this->m_buttonMenu = CCMenu::create();
+    this->m_mainLayer->addChild(this->m_buttonMenu);
+
+    auto nameLabel = CCLabelBMFont::create("Search Filters", "goldFont.fnt");
+    nameLabel->setPosition(winSize.width / 2, winSize.height / 2 + 100.f);
+    nameLabel->setScale(.7f);
+    this->m_mainLayer->addChild(nameLabel, 2);
+
+    this->m_pos = CCPoint { winSize.width / 2 - 45.f, 86.f };
+
+    this->addToggle("Name",        ModListView::SearchFlags::Name);
+    this->addToggle("ID",          ModListView::SearchFlags::ID);
+    this->addToggle("Credits",     ModListView::SearchFlags::Credits);
+    this->addToggle("Description", ModListView::SearchFlags::Description);
+    this->addToggle("Details",     ModListView::SearchFlags::Details);
+    this->addToggle("Developer",   ModListView::SearchFlags::Developer);
+
+    CCDirector::sharedDirector()->getTouchDispatcher()->incrementForcePrio(2);
+    this->registerWithTouchDispatcher();
+    
+    auto closeSpr = CCSprite::createWithSpriteFrameName("GJ_closeBtn_001.png");
+    closeSpr->setScale(1.0f);
+
+    auto closeBtn = CCMenuItemSpriteExtra::create(
+        closeSpr,
+        this,
+        (SEL_MenuHandler)&SearchFilterPopup::onClose
+    );
+    closeBtn->setUserData(reinterpret_cast<void*>(this));
+
+    this->m_buttonMenu->addChild(closeBtn);
+
+    closeBtn->setPosition( - size.width / 2, size.height / 2 );
+
+    this->setKeypadEnabled(true);
+    this->setTouchEnabled(true);
+
+    return true;
+}
+
+void SearchFilterPopup::addToggle(const char* title, int flag) {
+    GameToolbox::createToggleButton(
+        title, menu_selector(SearchFilterPopup::onToggle),
+        this->m_modLayer->m_searchFlags & flag,
+        this->m_buttonMenu, this->m_pos, this,
+        this->m_buttonMenu, .5f, .5f, 100.f, 
+        { 10.f, .0f }, nullptr, false, flag, nullptr
+    )->setTag(flag);
+    this->m_pos.y += 25.f;
+}
+
+void SearchFilterPopup::keyDown(cocos2d::enumKeyCodes key) {
+    if (key == KEY_Escape)
+        return onClose(nullptr);
+    if (key == KEY_Space)
+        return;
+    return FLAlertLayer::keyDown(key);
+}
+
+void SearchFilterPopup::onClose(cocos2d::CCObject*) {
+    this->setKeyboardEnabled(false);
+    this->removeFromParentAndCleanup(true);
+}
+
+void SearchFilterPopup::onToggle(cocos2d::CCObject* pSender) {
+    if (as<CCMenuItemToggler*>(pSender)->isToggled()) {
+        this->m_modLayer->m_searchFlags &= ~pSender->getTag();
+    } else {
+        this->m_modLayer->m_searchFlags |= pSender->getTag();
+    }
+}
+
+SearchFilterPopup* SearchFilterPopup::create(ModListLayer* layer) {
+    auto ret = new SearchFilterPopup();
+    if (ret && ret->init(layer)) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
diff --git a/loader/src/ui/internal/list/SearchFilterPopup.hpp b/loader/src/ui/internal/list/SearchFilterPopup.hpp
new file mode 100644
index 00000000..a74a58c7
--- /dev/null
+++ b/loader/src/ui/internal/list/SearchFilterPopup.hpp
@@ -0,0 +1,24 @@
+#pragma once
+
+#include <Geode/Geode.hpp>
+
+USE_GEODE_NAMESPACE();
+
+class ModListLayer;
+
+class SearchFilterPopup : public FLAlertLayer {
+    protected:
+        ModListLayer* m_modLayer;
+        CCPoint m_pos;
+
+		bool init(ModListLayer* layer);
+        void addToggle(const char* title, int flag);
+
+		void keyDown(cocos2d::enumKeyCodes) override;
+		void onClose(cocos2d::CCObject*);
+        void onToggle(cocos2d::CCObject*);
+		
+    public:
+        static SearchFilterPopup* create(ModListLayer* layer);
+};
+
diff --git a/loader/src/ui/internal/settings/GeodeSettingNode.cpp b/loader/src/ui/internal/settings/GeodeSettingNode.cpp
new file mode 100644
index 00000000..337b6bb9
--- /dev/null
+++ b/loader/src/ui/internal/settings/GeodeSettingNode.cpp
@@ -0,0 +1,760 @@
+/*#pragma warning(disable: 4067)
+
+#include "GeodeSettingNode.hpp"
+#include <general/TextRenderer.hpp>
+#include <random>
+#include <utils/WackyGeodeMacros.hpp>
+
+USE_GEODE_NAMESPACE();
+
+#define GEODE_GENERATE_SETTING_CREATE(_sett_, _height_)                \
+	_sett_##Node* _sett_##Node::create(_sett_* setting, float width) { \
+		auto ret = new _sett_##Node(width, _height_);                  \
+		if (ret && ret->init(setting)) {                               \
+			ret->autorelease();                                        \
+			return ret;                                                \
+		} CC_SAFE_DELETE(ret); return nullptr; }
+	
+template <typename T>
+std::string toStringWithPrecision(const T a_value, const size_t n = 6, bool cutZeros = true) {
+    std::ostringstream out;
+    out.precision(n);
+    out << std::fixed << a_value;
+    auto str = out.str();
+	while (
+		cutZeros &&
+		string_utils::contains(str, '.') &&
+		(str.back() == '0' || str.back() == '.')
+	) {
+		str = str.substr(0, str.size() - 1);
+	}
+	return str;
+}
+
+// bool
+
+bool BoolSettingNode::init(BoolSetting* setting) {
+	if (!GeodeSettingNode<BoolSetting>::init(setting))
+		return false;
+
+	m_toggle = CCMenuItemToggler::createWithStandardSprites(
+		this, menu_selector(BoolSettingNode::onToggle), .65f
+	);
+	m_toggle->setPosition(-m_toggle->m_onButton->getScaledContentSize().width / 2, 0);
+	m_toggle->toggle(setting->getValue());
+	m_buttonMenu->addChild(m_toggle);
+
+	return true;
+}
+
+void BoolSettingNode::onToggle(CCObject* pSender) {
+	m_value = !m_toggle->isToggled();
+	this->updateSettingsList();
+}
+
+void BoolSettingNode::updateState() {
+	m_toggle->toggle(m_value);
+}
+
+// int
+
+bool IntSettingNode::init(IntSetting* setting) {
+	if (!GeodeSettingNode<IntSetting>::init(setting))
+		return false;
+
+	auto controls = CCArray::create();
+	CCScale9Sprite* bgSprite = nullptr;
+	if (setting->hasInput()) {
+		bgSprite = CCScale9Sprite::create(
+			"square02b_001.png", { 0.0f, 0.0f, 80.0f, 80.0f }
+		);
+		bgSprite->setScale(.25f);
+		bgSprite->setColor({ 0, 0, 0 });
+		bgSprite->setOpacity(75);
+		bgSprite->setContentSize({ 45.f * 4, m_height * 3 });
+		bgSprite->setPosition(-20, 0);
+		m_buttonMenu->addChild(bgSprite);
+		controls->addObject(bgSprite);
+	}
+
+	m_valueInput = MenuInputNode::create(45.f, m_height, "Num", "bigFont.fnt");
+	m_valueInput->setPosition(-20.f, .0f);
+	m_valueInput->getInput()->setAllowedChars("0123456789+-. ");
+	m_valueInput->getInput()->setMaxLabelScale(.5f);
+    m_valueInput->getInput()->setLabelPlaceholderColor({ 150, 150, 150 });
+    m_valueInput->getInput()->setLabelPlaceholderScale(.75f);
+	m_valueInput->getInput()->setDelegate(this);
+	m_valueInput->setEnabled(setting->hasInput());
+	m_buttonMenu->addChild(m_valueInput);
+	controls->addObject(m_valueInput);
+
+	if (setting->hasArrows()) {
+		m_valueInput->setPositionX(-30.f);
+		if (bgSprite) bgSprite->setPositionX(-30.f);
+
+		auto decSpr = CCSprite::createWithSpriteFrameName("navArrowBtn_001.png");
+		decSpr->setScale(.3f);
+		decSpr->setFlipX(true);
+
+		auto decBtn = CCMenuItemSpriteExtra::create(
+			decSpr, this, menu_selector(IntSettingNode::onArrow)
+		);
+		decBtn->setTag(-1);
+		decBtn->setPosition(-60.f, 0);
+		m_buttonMenu->addChild(decBtn);
+		controls->addObject(decBtn);
+
+		auto incSpr = CCSprite::createWithSpriteFrameName("navArrowBtn_001.png");
+		incSpr->setScale(.3f);
+
+		auto incBtn = CCMenuItemSpriteExtra::create(
+			incSpr, this, menu_selector(IntSettingNode::onArrow)
+		);
+		incBtn->setTag(1);
+		incBtn->setPosition(0.f, 0);
+		m_buttonMenu->addChild(incBtn);
+		controls->addObject(incBtn);
+	}
+
+	if (setting->hasSlider()) {
+		m_height += 20.f;
+		this->setContentSize({ m_width, m_height });
+		m_backgroundLayer->setContentSize(this->getContentSize());
+		
+		m_nameLabel->setPositionY(m_nameLabel->getPositionY() + 10.f);
+		if (m_descButton) {
+			m_descButton->setPositionY(m_descButton->getPositionY() - 8.f);
+		}
+		m_buttonMenu->setPositionY(m_buttonMenu->getPositionY() + 18.f);
+
+		CCARRAY_FOREACH_B_TYPE(controls, node, CCNode) {
+			node->setPositionX(node->getPositionX() - (setting->hasArrows() ? 35.f : 45.f));
+		}
+
+		m_slider = Slider::create(this, menu_selector(IntSettingNode::onSlider), .65f);
+		m_slider->setPosition(-65.f, -22.f);
+		m_slider->setValue(
+			// normalized to 0-1
+			(m_value - m_setting->getMin()) /
+			(m_setting->getMax() - m_setting->getMin())
+		);
+		m_buttonMenu->addChild(m_slider);
+	}
+
+	this->updateValue();
+
+	return true;
+}
+
+void IntSettingNode::textChanged(CCTextInputNode* input) {
+	try {
+		m_value = std::stoi(input->getString());
+	} catch(...) {}
+	this->updateValue(false);
+}
+
+void IntSettingNode::textInputClosed(CCTextInputNode* input) {
+	try {
+		m_value = std::stoi(input->getString());
+	} catch(...) {}
+	this->updateValue();
+}
+
+void IntSettingNode::onSlider(CCObject* pSender) {
+	m_value = 
+		as<SliderThumb*>(pSender)->getValue() *
+		(m_setting->getMax() - m_setting->getMin()) + 
+		m_setting->getMin();
+	m_valueInput->getInput()->detachWithIME();
+	this->updateValue();
+}
+
+void IntSettingNode::onArrow(CCObject* pSender) {
+	m_value = m_value + pSender->getTag() * m_setting->getStep();
+	m_valueInput->getInput()->detachWithIME();
+	this->updateValue();
+}
+
+void IntSettingNode::updateValue(bool updateInput) {
+	if (m_value < m_setting->getMin()) {
+		m_value = m_setting->getMin();
+	}
+	if (m_value > m_setting->getMax()) {
+		m_value = m_setting->getMax();
+	}
+	if (updateInput) {
+		m_valueInput->getInput()->setString(
+			std::to_string(m_value).c_str()
+		);
+	}
+	if (m_slider) {
+		m_slider->setValue(
+			static_cast<float>(m_value - m_setting->getMin()) /
+			(m_setting->getMax() - m_setting->getMin())
+		);
+		m_slider->updateBar();
+	}
+	this->updateSettingsList();
+}
+
+void IntSettingNode::updateState() {
+	this->updateValue();
+}
+
+// float
+
+bool FloatSettingNode::init(FloatSetting* setting) {
+	if (!GeodeSettingNode<FloatSetting>::init(setting))
+		return false;
+	
+	auto controls = CCArray::create();
+	CCScale9Sprite* bgSprite = nullptr;
+	if (setting->hasInput()) {
+		bgSprite = CCScale9Sprite::create(
+			"square02b_001.png", { 0.0f, 0.0f, 80.0f, 80.0f }
+		);
+		bgSprite->setScale(.25f);
+		bgSprite->setColor({ 0, 0, 0 });
+		bgSprite->setOpacity(75);
+		bgSprite->setContentSize({ 45.f * 4, m_height * 3 });
+		bgSprite->setPosition(-20, 0);
+		m_buttonMenu->addChild(bgSprite);
+		controls->addObject(bgSprite);
+	}
+
+	m_valueInput = MenuInputNode::create(45.f, m_height, "Num", "bigFont.fnt");
+	m_valueInput->setPosition(-20.f, .0f);
+	m_valueInput->getInput()->setAllowedChars("0123456789+-. ");
+	m_valueInput->getInput()->setMaxLabelScale(.5f);
+    m_valueInput->getInput()->setLabelPlaceholderColor({ 150, 150, 150 });
+    m_valueInput->getInput()->setLabelPlaceholderScale(.5f);
+	m_valueInput->getInput()->setDelegate(this);
+	m_valueInput->setEnabled(setting->hasInput());
+	m_buttonMenu->addChild(m_valueInput);
+	controls->addObject(m_valueInput);
+
+	CCMenuItemSpriteExtra* decBtn = nullptr;
+	CCMenuItemSpriteExtra* incBtn = nullptr;
+	if (setting->hasArrows()) {
+		m_valueInput->setPositionX(-30.f);
+		if (bgSprite) bgSprite->setPositionX(-30.f);
+
+		auto decSpr = CCSprite::createWithSpriteFrameName("navArrowBtn_001.png");
+		decSpr->setScale(.3f);
+		decSpr->setFlipX(true);
+
+		decBtn = CCMenuItemSpriteExtra::create(
+			decSpr, this, menu_selector(FloatSettingNode::onArrow)
+		);
+		decBtn->setTag(-1);
+		decBtn->setPosition(-60.f, 0);
+		m_buttonMenu->addChild(decBtn);
+		controls->addObject(decBtn);
+
+		auto incSpr = CCSprite::createWithSpriteFrameName("navArrowBtn_001.png");
+		incSpr->setScale(.3f);
+
+		incBtn = CCMenuItemSpriteExtra::create(
+			incSpr, this, menu_selector(FloatSettingNode::onArrow)
+		);
+		incBtn->setTag(1);
+		incBtn->setPosition(0.f, 0);
+		m_buttonMenu->addChild(incBtn);
+		controls->addObject(incBtn);
+	}
+
+	if (setting->hasSlider()) {
+		m_height += 20.f;
+		this->setContentSize({ m_width, m_height });
+		m_backgroundLayer->setContentSize(this->getContentSize());
+		
+		m_nameLabel->setPositionY(m_nameLabel->getPositionY() + 10.f);
+		if (m_descButton) {
+			m_descButton->setPositionY(m_descButton->getPositionY() - 8.f);
+		}
+		m_buttonMenu->setPositionY(m_buttonMenu->getPositionY() + 18.f);
+
+		CCARRAY_FOREACH_B_TYPE(controls, node, CCNode) {
+			node->setPositionX(node->getPositionX() - (setting->hasArrows() ? 35.f : 45.f));
+		}
+
+		m_slider = Slider::create(this, menu_selector(FloatSettingNode::onSlider), .65f);
+		m_slider->setPosition(-65.f, -22.f);
+		m_slider->setValue(
+			// normalized to 0-1
+			(m_value - m_setting->getMin()) /
+			(m_setting->getMax() - m_setting->getMin())
+		);
+		m_buttonMenu->addChild(m_slider);
+	}
+
+	this->updateValue();
+
+	return true;
+}
+
+void FloatSettingNode::textChanged(CCTextInputNode* input) {
+	try {
+		m_value = std::stof(input->getString());
+	} catch(...) {}
+	this->updateValue(false);
+}
+
+void FloatSettingNode::textInputClosed(CCTextInputNode* input) {
+	try {
+		m_value = std::stof(input->getString());
+	} catch(...) {}
+	this->updateValue();
+}
+
+void FloatSettingNode::onSlider(CCObject* pSender) {
+	m_value = 
+		as<SliderThumb*>(pSender)->getValue() *
+		(m_setting->getMax() - m_setting->getMin()) + 
+		m_setting->getMin();
+	m_valueInput->getInput()->detachWithIME();
+	this->updateValue();
+}
+
+void FloatSettingNode::onArrow(CCObject* pSender) {
+	m_value = m_value + pSender->getTag() * m_setting->getStep();
+	this->updateValue();
+}
+
+void FloatSettingNode::updateValue(bool updateInput) {
+	if (m_value < m_setting->getMin()) {
+		m_value = m_setting->getMin();
+	}
+	if (m_value > m_setting->getMax()) {
+		m_value = m_setting->getMax();
+	}
+	if (updateInput) {
+		m_valueInput->getInput()->setString(toStringWithPrecision(
+			m_value, m_setting->getPrecision()
+		).c_str());
+	}
+	if (m_slider) {
+		m_slider->setValue(
+			// normalized to 0-1
+			(m_value - m_setting->getMin()) /
+			(m_setting->getMax() - m_setting->getMin())
+		);
+		m_slider->updateBar();
+	}
+	this->updateSettingsList();
+}
+
+void FloatSettingNode::updateState() {
+	this->updateValue();
+}
+
+// string
+
+bool StringSettingNode::init(StringSetting* setting) {
+	if (!GeodeSettingNode<StringSetting>::init(setting))
+		return false;
+	
+	auto bgSprite = CCScale9Sprite::create(
+		"square02b_001.png", { 0.0f, 0.0f, 80.0f, 80.0f }
+	);
+	bgSprite->setScale(.25f);
+	bgSprite->setColor({ 0, 0, 0 });
+	bgSprite->setOpacity(75);
+	bgSprite->setContentSize({ (m_width / 2 - 30.f) * 4, m_height * 3 });
+	bgSprite->setPosition(-m_width / 4 + 18.f, 0);
+	m_buttonMenu->addChild(bgSprite);
+
+	m_input = MenuInputNode::create(
+		m_width / 2 - 30.f, m_height,
+		"...", "bigFont.fnt"
+	);
+	m_input->setPositionX(-m_width / 4 + 18.f);
+	m_input->getInput()->setAllowedChars(setting->getFilter());
+	m_input->getInput()->setMaxLabelScale(.5f);
+    m_input->getInput()->setLabelPlaceholderColor({ 150, 150, 150 });
+    m_input->getInput()->setLabelPlaceholderScale(.5f);
+	m_input->getInput()->setDelegate(this);
+	m_buttonMenu->addChild(m_input);
+
+	m_input->getInput()->setString(m_value.c_str());
+
+	return true;
+}
+
+void StringSettingNode::textChanged(CCTextInputNode* input) {
+	m_value = input->getString();
+	this->updateSettingsList();
+}
+
+void StringSettingNode::updateState() {
+	m_input->getInput()->setString(m_value);
+}
+
+// color
+
+bool ColorSettingNode::init(ColorSetting* setting) {
+	if (!GeodeSettingNode<ColorSetting>::init(setting))
+		return false;
+
+	m_colorSprite = ColorChannelSprite::create();
+	m_colorSprite->setColor(setting->getValue());
+	m_colorSprite->setScale(.65f);
+	
+	auto button = CCMenuItemSpriteExtra::create(
+		m_colorSprite, this, menu_selector(ColorSettingNode::onPickColor)
+	);
+	button->setPosition({ -m_colorSprite->getScaledContentSize().width / 2, 0 });
+	m_buttonMenu->addChild(button);
+
+	return true;
+}
+
+void ColorSettingNode::onPickColor(CCObject*) {
+	ColorPickPopup::create(this)->show();
+}
+
+void ColorSettingNode::updateState() {
+	m_colorSprite->setColor(m_value);
+}
+
+// rgba
+
+bool ColorAlphaSettingNode::init(ColorAlphaSetting* setting) {
+	if (!GeodeSettingNode<ColorAlphaSetting>::init(setting))
+		return false;
+
+	m_colorSprite = ColorChannelSprite::create();
+	m_colorSprite->setColor(to3B(setting->getValue()));
+	m_colorSprite->updateOpacity(setting->getValue().a / 255.f);
+	m_colorSprite->setScale(.65f);
+	
+	auto button = CCMenuItemSpriteExtra::create(
+		m_colorSprite, this, menu_selector(ColorAlphaSettingNode::onPickColor)
+	);
+	button->setPosition({ -m_colorSprite->getScaledContentSize().width / 2, 0 });
+	m_buttonMenu->addChild(button);
+
+	return true;
+}
+
+void ColorAlphaSettingNode::onPickColor(CCObject*) {
+	ColorPickPopup::create(this)->show();
+}
+
+void ColorAlphaSettingNode::updateState() {
+	m_colorSprite->setColor(to3B(m_value));
+}
+
+// path
+
+bool PathSettingNode::init(PathSetting* setting) {
+	if (!GeodeSettingNode<PathSetting>::init(setting))
+		return false;
+
+	auto bgSprite = CCScale9Sprite::create(
+		"square02b_001.png", { 0.0f, 0.0f, 80.0f, 80.0f }
+	);
+	bgSprite->setScale(.25f);
+	bgSprite->setColor({ 0, 0, 0 });
+	bgSprite->setOpacity(75);
+	bgSprite->setContentSize({ (m_width / 2 - 50.f) * 4, m_height * 3 });
+	bgSprite->setPosition(-m_width / 4, 0);
+	m_buttonMenu->addChild(bgSprite);
+
+	m_input = MenuInputNode::create(
+		m_width / 2 - 50.f, m_height,
+		"...", "chatFont.fnt"
+	);
+	m_input->setPositionX(-m_width / 4);
+	m_input->getInput()->setAllowedChars("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ,/\\_-.+%#@!';");
+	m_input->getInput()->setMaxLabelScale(.7f);
+    m_input->getInput()->setLabelPlaceholderColor({ 150, 150, 150 });
+    m_input->getInput()->setLabelPlaceholderScale(.7f);
+	m_input->getInput()->setDelegate(this);
+	m_buttonMenu->addChild(m_input);
+
+	auto folder = CCSprite::createWithSpriteFrameName("gj_folderBtn_001.png");
+	folder->setScale(.65f);
+	auto button = CCMenuItemSpriteExtra::create(
+		folder, this, menu_selector(PathSettingNode::onSelectFile)
+	);
+	button->setPosition({ -folder->getScaledContentSize().width / 2, 0 });
+	m_buttonMenu->addChild(button);
+	
+	m_input->getInput()->setString(m_value.string().c_str());
+
+	return true;
+}
+
+void PathSettingNode::onSelectFile(CCObject*) {
+	FLAlertLayer::create("todo", "implement file picker", "OK")->show();
+	this->updateSettingsList();
+}
+
+void PathSettingNode::textChanged(CCTextInputNode* input) {
+	m_value = input->getString();
+	this->updateSettingsList();
+}
+
+void PathSettingNode::updateState() {
+	m_input->getInput()->setString(m_value.string());
+}
+
+// string[]
+
+bool StringSelectSettingNode::init(StringSelectSetting* setting) {
+	if (!GeodeSettingNode<StringSelectSetting>::init(setting))
+		return false;
+
+	m_selectedLabel = CCLabelBMFont::create(setting->getValue().c_str(), "bigFont.fnt");
+	m_selectedLabel->setPosition(-m_width / 4 + 20.f, .0f);
+	m_selectedLabel->limitLabelWidth(m_width / 2 - 60.f, .5f, .1f);
+	m_buttonMenu->addChild(m_selectedLabel);
+
+	auto decSpr = CCSprite::createWithSpriteFrameName("navArrowBtn_001.png");
+	decSpr->setScale(.3f);
+	decSpr->setFlipX(true);
+
+	auto decBtn = CCMenuItemSpriteExtra::create(
+		decSpr, this, menu_selector(StringSelectSettingNode::onChange)
+	);
+	decBtn->setTag(-1);
+	decBtn->setPosition(-m_width / 2 + 40.f, 0);
+	m_buttonMenu->addChild(decBtn);
+
+	auto incSpr = CCSprite::createWithSpriteFrameName("navArrowBtn_001.png");
+	incSpr->setScale(.3f);
+
+	auto incBtn = CCMenuItemSpriteExtra::create(
+		incSpr, this, menu_selector(StringSelectSettingNode::onChange)
+	);
+	incBtn->setTag(1);
+	incBtn->setPosition(0.f, 0);
+	m_buttonMenu->addChild(incBtn);
+
+	return true;
+}
+
+void StringSelectSettingNode::onChange(CCObject* pSender) {
+	if (pSender) {
+		long newValue = m_value + pSender->getTag();
+		if (newValue < 0) m_value = m_setting->getOptions().size() - 1;
+		else if (newValue > static_cast<long>(m_setting->getOptions().size() - 1)) m_value = 0;
+		else m_value = newValue;
+	}
+
+	m_selectedLabel->setString(m_setting->getValueAt(m_value).c_str());
+	m_selectedLabel->limitLabelWidth(m_width / 2 - 60.f, .5f, .1f);
+	this->updateSettingsList();
+}
+
+void StringSelectSettingNode::updateState() {
+	this->onChange(nullptr);
+}
+
+// custom
+
+bool CustomSettingPlaceHolderNode::init(CustomSettingPlaceHolder* setting, bool isLoaded) {
+	if (!CCNode::init())
+		return false;
+
+	auto pad = m_height;
+	this->setContentSize({ m_width, m_height });
+	m_backgroundLayer->setContentSize(this->getContentSize());
+
+	// i'm using TextRenderer instead of TextArea because 
+	// i couldn't get TextArea to work for some reason 
+	// and TextRenderer should be fast enough for short 
+	// static text
+
+	auto render = TextRenderer::create();
+
+	render->begin(this, CCPointZero, { m_width - pad * 2, m_height });
+
+	render->pushFont(
+		[](int) -> TextRenderer::Label {
+			return CCLabelBMFont::create("", "chatFont.fnt");
+		}
+	);
+	render->pushHorizontalAlign(TextAlignment::Begin);
+	render->pushVerticalAlign(TextAlignment::Begin);
+	render->pushScale(.7f);
+	auto rendered = render->renderString(
+		isLoaded ?
+			"This setting (id: " + setting->getKey() + ") is a "
+			"custom setting which has no registered setting node. "
+			"This is likely a bug in the mod; report it to the "
+			"developer." :
+			"This setting (id: " + setting->getKey() + ") is a " 
+			"custom setting, which means that you need to enable "
+			"& load the mod to change its value."
+	);
+
+	render->end(true, TextAlignment::Center, TextAlignment::Center);
+	render->release();
+
+	m_height = this->getContentSize().height + pad / 4;
+	m_width = this->getContentSize().width;
+	m_width += pad * 2;
+	this->setContentSize({ m_width, m_height });
+	m_backgroundLayer->setContentSize(this->getContentSize());
+
+	for (auto& label : rendered) {
+		label.m_node->setPositionX(pad * 1.5f);
+		label.m_node->setPositionY(label.m_node->getPositionY() + pad / 8);
+	}
+
+    auto bgSprite = CCScale9Sprite::create(
+        "square02b_001.png", { 0.0f, 0.0f, 80.0f, 80.0f }
+    );
+    bgSprite->setScale(.5f);
+    bgSprite->setColor({ 0, 0, 0 });
+    bgSprite->setOpacity(75);
+    bgSprite->setZOrder(-1);
+    bgSprite->setContentSize(m_obContentSize * 2 - CCSize { pad, 0 });
+    bgSprite->setPosition(m_obContentSize / 2);
+    this->addChild(bgSprite);
+
+	auto iconSprite = CCSprite::createWithSpriteFrameName(
+		isLoaded ? "info-warning.png"_spr : "GJ_infoIcon_001.png"
+	);
+	iconSprite->setPosition({ pad * .9f, m_height / 2 });
+	iconSprite->setScale(.8f);
+	this->addChild(iconSprite);
+
+	return true;
+}
+
+CustomSettingPlaceHolderNode* CustomSettingPlaceHolderNode::create(CustomSettingPlaceHolder* setting, bool isLoaded, float width) {
+	auto ret = new CustomSettingPlaceHolderNode(width, 30.f);
+	if (ret && ret->init(setting, isLoaded)) {
+		ret->autorelease();
+		return ret;
+	}
+	CC_SAFE_DELETE(ret);
+	return nullptr;
+}
+
+void ColorPickPopup::setup(
+	ColorSettingNode* colorNode,
+	ColorAlphaSettingNode* alphaNode
+) {
+	m_noElasticity = true;
+	
+	m_colorNode = colorNode;
+	m_colorAlphaNode = alphaNode;
+
+	auto winSize = CCDirector::sharedDirector()->getWinSize();
+
+	auto picker = CCControlColourPicker::colourPicker();
+	picker->setColorValue(
+		colorNode ?
+			colorNode->m_value :
+			to3B(alphaNode->m_value)
+	);
+	picker->setColorTarget(colorNode ? 
+		colorNode->m_colorSprite :
+		alphaNode->m_colorSprite
+	);
+	picker->setPosition(winSize.width / 2, winSize.height / 2 + (colorNode ? 0.f : 20.f));
+	picker->setDelegate(this);
+	m_mainLayer->addChild(picker);
+
+	if (alphaNode) {
+		m_alphaSlider = Slider::create(this, menu_selector(ColorPickPopup::onSlider), .75f);
+		m_alphaSlider->setPosition(winSize.width / 2, winSize.height / 2 - 90.f);
+		m_alphaSlider->setValue(alphaNode->m_value.a / 255.f);
+		m_alphaSlider->updateBar();
+		m_mainLayer->addChild(m_alphaSlider);
+
+		auto bgSprite = CCScale9Sprite::create(
+			"square02b_001.png", { 0.0f, 0.0f, 80.0f, 80.0f }
+		);
+		bgSprite->setScale(.25f);
+		bgSprite->setColor({ 0, 0, 0 });
+		bgSprite->setOpacity(75);
+		bgSprite->setContentSize({ 200.f, 100.f });
+		bgSprite->setPosition(winSize.width / 2 + 115.f, winSize.height / 2 - 90.f);
+		m_mainLayer->addChild(bgSprite);
+
+		m_alphaInput = MenuInputNode::create(50.f, 25.f, "...", "bigFont.fnt");
+		m_alphaInput->setPosition(winSize.width / 2 + 115.f, winSize.height / 2 - 90.f);
+		m_alphaInput->getInput()->setDelegate(this);
+		m_alphaInput->getInput()->setAllowedChars("0123456789. ");
+		m_alphaInput->getInput()->setString(
+			toStringWithPrecision(alphaNode->m_value.a / 255.f, 2, false)
+		);
+		m_alphaInput->setScale(.8f);
+		m_mainLayer->addChild(m_alphaInput);
+	}
+}
+
+void ColorPickPopup::onSlider(CCObject* pSender) {
+	if (m_colorAlphaNode) {
+		m_colorAlphaNode->m_value = to4B(
+			to3B(m_colorAlphaNode->m_value),
+			static_cast<GLubyte>(as<SliderThumb*>(pSender)->getValue() * 255.f)
+		);
+		m_alphaInput->getInput()->setString(
+			toStringWithPrecision(as<SliderThumb*>(pSender)->getValue(), 2, false)
+		);
+		m_colorAlphaNode->m_colorSprite->updateOpacity(
+			m_colorAlphaNode->m_value.a / 255.f
+		);
+		m_colorAlphaNode->updateSettingsList();
+	}
+}
+
+void ColorPickPopup::textChanged(CCTextInputNode* input) {
+	try {
+		m_colorAlphaNode->m_value = to4B(
+			to3B(m_colorAlphaNode->m_value),
+			static_cast<GLubyte>(std::stof(input->getString()) * 255.f)
+		);
+		m_colorAlphaNode->m_colorSprite->updateOpacity(
+			m_colorAlphaNode->m_value.a / 255.f
+		);
+		m_alphaSlider->setValue(std::stof(input->getString()));
+		m_colorAlphaNode->updateSettingsList();
+	} catch(...) {}
+}
+
+void ColorPickPopup::colorValueChanged(ccColor3B color) {
+	if (m_colorNode) {
+		m_colorNode->m_value = color;
+		m_colorNode->updateSettingsList();
+	} else {
+		m_colorAlphaNode->m_value = to4B(color, m_colorAlphaNode->m_value.a);
+		m_colorAlphaNode->updateSettingsList();
+	}
+}
+
+ColorPickPopup* ColorPickPopup::create(
+	ColorSettingNode* colorNode,
+	ColorAlphaSettingNode* colorAlphaNode
+) {
+	auto ret = new ColorPickPopup;
+	if (ret && ret->init(320.f, 250.f, colorNode, colorAlphaNode, "GJ_square02.png")) {
+		ret->autorelease();
+		return ret;
+	}
+	CC_SAFE_DELETE(ret);
+	return nullptr;
+}
+
+ColorPickPopup* ColorPickPopup::create(ColorSettingNode* colorNode) {
+	return ColorPickPopup::create(colorNode, nullptr);
+}
+
+ColorPickPopup* ColorPickPopup::create(ColorAlphaSettingNode* colorNode) {
+	return ColorPickPopup::create(nullptr, colorNode);
+}
+
+GEODE_GENERATE_SETTING_CREATE(BoolSetting, 30.f);
+GEODE_GENERATE_SETTING_CREATE(IntSetting, 30.f);
+GEODE_GENERATE_SETTING_CREATE(FloatSetting, 30.f);
+GEODE_GENERATE_SETTING_CREATE(StringSetting, 30.f);
+GEODE_GENERATE_SETTING_CREATE(ColorSetting, 30.f);
+GEODE_GENERATE_SETTING_CREATE(ColorAlphaSetting, 30.f);
+GEODE_GENERATE_SETTING_CREATE(PathSetting, 30.f);
+GEODE_GENERATE_SETTING_CREATE(StringSelectSetting, 30.f);*/
diff --git a/loader/src/ui/internal/settings/GeodeSettingNode.hpp b/loader/src/ui/internal/settings/GeodeSettingNode.hpp
new file mode 100644
index 00000000..6c000a5e
--- /dev/null
+++ b/loader/src/ui/internal/settings/GeodeSettingNode.hpp
@@ -0,0 +1,282 @@
+/*#pragma once
+
+#pragma warning(disable: 4067)
+
+#include <Geode.hpp>
+#include <nodes/MenuInputNode.hpp>
+#include <nodes/Popup.hpp>
+
+USE_GEODE_NAMESPACE();
+
+namespace geode {
+	class ColorPickPopup;
+
+	template<class SettingClass>
+	class GeodeSettingNode : public SettingNode {
+	protected:
+		SettingClass* m_setting;
+		CCMenu* m_buttonMenu;
+		CCLabelBMFont* m_nameLabel;
+		CCMenuItemSpriteExtra* m_descButton = nullptr;
+		typename SettingClass::value_type_t m_value;
+
+		bool init(SettingClass* setting) {
+			if (!CCNode::init())
+				return false;
+			
+			m_setting = setting;
+			if constexpr (std::is_same_v<typename SettingClass::value_type_t, size_t>) {
+				m_value = m_setting->getIndex();
+			} else {
+				m_value = m_setting->getValue();
+			}
+
+			this->setContentSize({ m_width, m_height });
+
+			auto text = setting->getName().size() ? setting->getName() : setting->getKey();
+			m_nameLabel = CCLabelBMFont::create(text.c_str(), "bigFont.fnt");
+			m_nameLabel->setAnchorPoint({ .0f, .5f });
+			m_nameLabel->setPosition(m_height / 2, m_height / 2);
+			m_nameLabel->limitLabelWidth(m_width / 2 - 40.f, .5f, .1f);
+			this->addChild(m_nameLabel);
+
+			m_buttonMenu = CCMenu::create();
+			m_buttonMenu->setPosition(m_width - m_height / 2, m_height / 2);
+			this->addChild(m_buttonMenu);
+
+			if (m_setting->getDescription().size()) {
+				auto descBtnSpr = CCSprite::createWithSpriteFrameName("GJ_infoIcon_001.png");
+				descBtnSpr->setScale(.7f);
+				m_descButton = CCMenuItemSpriteExtra::create(
+					descBtnSpr, this, menu_selector(GeodeSettingNode::onDescription)
+				);
+				m_descButton->setPosition(
+					-m_buttonMenu->getPositionX() +
+						m_nameLabel->getPositionX() +
+						m_nameLabel->getScaledContentSize().width +
+						m_height / 2,
+					.0f
+				);
+				m_buttonMenu->addChild(m_descButton);
+			}
+
+			CCDirector::sharedDirector()->getTouchDispatcher()->incrementForcePrio(2);
+			this->registerWithTouchDispatcher();
+
+			return true;
+		}
+
+		void onDescription(CCObject*) {
+			FLAlertLayer::create(
+				m_nameLabel->getString(),
+				m_setting->getDescription(),
+				"OK"
+			)->show();
+		}
+
+		bool hasUnsavedChanges() const override {
+			if constexpr (std::is_same_v<typename SettingClass::value_type_t, size_t>) {
+				return m_value != m_setting->getIndex();
+			} else {
+				return m_value != m_setting->getValue();
+			}
+		}
+
+		void commitChanges() override {
+			if constexpr (std::is_same_v<typename SettingClass::value_type_t, size_t>) {
+				m_setting->setIndex(m_value);
+			} else {
+				m_setting->setValue(m_value);
+			}
+		}
+
+		void resetToDefault() override {
+			m_setting->resetToDefault();
+			if constexpr (std::is_same_v<typename SettingClass::value_type_t, size_t>) {
+				m_value = m_setting->getIndex();
+			} else {
+				m_value = m_setting->getValue();
+			}
+			this->updateState();
+		}
+
+		virtual void updateState() = 0;
+
+		~GeodeSettingNode() {
+			CCDirector::sharedDirector()->getTouchDispatcher()->decrementForcePrio(2);
+		}
+
+		GeodeSettingNode(float width, float height) : SettingNode(width, height) {}
+	};
+
+	class BoolSettingNode : public GeodeSettingNode<BoolSetting> {
+	protected:
+		CCMenuItemToggler* m_toggle;
+
+		bool init(BoolSetting* setting);
+
+		void onToggle(CCObject*);
+		void updateState() override;
+
+		BoolSettingNode(float width, float height) : GeodeSettingNode<BoolSetting>(width, height) {}
+
+	public:
+		static BoolSettingNode* create(BoolSetting* setting, float width);
+	};
+
+	class IntSettingNode : public GeodeSettingNode<IntSetting>, public TextInputDelegate {
+	protected:
+		MenuInputNode* m_valueInput;
+		Slider* m_slider = nullptr;
+
+		bool init(IntSetting* setting);
+
+		void updateValue(bool updateInput = true);
+		void onArrow(CCObject*);
+		void onSlider(CCObject*);
+		void textInputClosed(CCTextInputNode* input) override;
+		void textChanged(CCTextInputNode* input) override;
+		void updateState() override;
+
+		IntSettingNode(float width, float height) : GeodeSettingNode<IntSetting>(width, height) {}
+
+	public:
+		static IntSettingNode* create(IntSetting* setting, float width);
+	};
+
+	class FloatSettingNode : public GeodeSettingNode<FloatSetting>, public TextInputDelegate {
+	protected:
+		MenuInputNode* m_valueInput;
+		Slider* m_slider = nullptr;
+
+		bool init(FloatSetting* setting);
+
+		void updateValue(bool updateInput = true);
+		void onArrow(CCObject*);
+		void onSlider(CCObject*);
+		void textInputClosed(CCTextInputNode* input) override;
+		void textChanged(CCTextInputNode* input) override;
+		void updateState() override;
+
+		FloatSettingNode(float width, float height) : GeodeSettingNode<FloatSetting>(width, height) {}
+
+	public:
+		static FloatSettingNode* create(FloatSetting* setting, float width);
+	};
+
+	class StringSettingNode : public GeodeSettingNode<StringSetting>, public TextInputDelegate {
+	protected:
+		MenuInputNode* m_input;
+
+		bool init(StringSetting* setting);
+		void textChanged(CCTextInputNode* input) override;
+		void updateState() override;
+
+		StringSettingNode(float width, float height) : GeodeSettingNode<StringSetting>(width, height) {}
+		
+	public:
+		static StringSettingNode* create(StringSetting* setting, float width);
+	};
+
+	class ColorSettingNode : public GeodeSettingNode<ColorSetting> {
+	protected:
+		ColorChannelSprite* m_colorSprite;
+
+		bool init(ColorSetting* setting);
+		void onPickColor(CCObject*);
+		void updateState() override;
+
+		ColorSettingNode(float width, float height) : GeodeSettingNode<ColorSetting>(width, height) {}
+
+		friend class ColorPickPopup;
+
+	public:
+		static ColorSettingNode* create(ColorSetting* setting, float width);
+	};
+
+	class ColorAlphaSettingNode : public GeodeSettingNode<ColorAlphaSetting> {
+	protected:
+		ColorChannelSprite* m_colorSprite;
+
+		bool init(ColorAlphaSetting* setting);
+		void onPickColor(CCObject*);
+		void updateState() override;
+		
+		ColorAlphaSettingNode(float width, float height) : GeodeSettingNode<ColorAlphaSetting>(width, height) {}
+
+		friend class ColorPickPopup;
+
+
+	public:
+		static ColorAlphaSettingNode* create(ColorAlphaSetting* setting, float width);
+	};
+
+	class PathSettingNode : public GeodeSettingNode<PathSetting>, public TextInputDelegate {
+	protected:
+		MenuInputNode* m_input;
+
+		bool init(PathSetting* setting);
+		void textChanged(CCTextInputNode* input) override;
+		void onSelectFile(CCObject*);
+		void updateState() override;
+
+		PathSettingNode(float width, float height) : GeodeSettingNode<PathSetting>(width, height) {}
+
+	public:
+		static PathSettingNode* create(PathSetting* setting, float width);
+	};
+
+	class StringSelectSettingNode : public GeodeSettingNode<StringSelectSetting> {
+	protected:
+		CCLabelBMFont* m_selectedLabel;
+
+		bool init(StringSelectSetting* setting);
+		void onChange(CCObject* pSender);
+		void updateState() override;
+
+		StringSelectSettingNode(float width, float height) : GeodeSettingNode<StringSelectSetting>(width, height) {}
+
+	public:
+		static StringSelectSettingNode* create(StringSelectSetting* setting, float width);
+	};
+
+	class CustomSettingPlaceHolderNode : public SettingNode {
+	protected:
+		bool init(CustomSettingPlaceHolder* setting, bool isLoaded);
+
+		bool hasUnsavedChanges() const override {
+			return false;
+		}
+		void commitChanges() override {} 
+		void resetToDefault() override {}
+
+		CustomSettingPlaceHolderNode(float width, float height) : SettingNode(width, height) {}
+
+	public:
+		static CustomSettingPlaceHolderNode* create(CustomSettingPlaceHolder* setting, bool isLoaded, float width);
+	};
+
+	class ColorPickPopup :
+		public Popup<ColorPickPopup, ColorSettingNode*, ColorAlphaSettingNode*>,
+		public ColorPickerDelegate,
+		public TextInputDelegate
+	{
+	protected:
+		ColorSettingNode* m_colorNode = nullptr;
+		ColorAlphaSettingNode* m_colorAlphaNode = nullptr;
+		MenuInputNode* m_alphaInput = nullptr;
+		Slider* m_alphaSlider = nullptr;
+	
+		void setup(ColorSettingNode* colorNode, ColorAlphaSettingNode* alphaNode) override;
+		void colorValueChanged(ccColor3B) override;
+		void textChanged(CCTextInputNode* input) override;
+		void onSlider(CCObject*);
+
+	public:
+		static ColorPickPopup* create(ColorSettingNode* colorNode, ColorAlphaSettingNode* colorAlphaNode);
+		static ColorPickPopup* create(ColorSettingNode* colorNode);
+		static ColorPickPopup* create(ColorAlphaSettingNode* colorNode);
+	};
+}
+
+#pragma warning(default: 4067)*/
diff --git a/loader/src/ui/internal/settings/ModSettingsLayer.cpp b/loader/src/ui/internal/settings/ModSettingsLayer.cpp
new file mode 100644
index 00000000..d7a31032
--- /dev/null
+++ b/loader/src/ui/internal/settings/ModSettingsLayer.cpp
@@ -0,0 +1,194 @@
+/*#include "ModSettingsLayer.hpp"
+#include "../settings/ModSettingsList.hpp"
+
+bool ModSettingsLayer::init(Mod* mod) {
+    m_noElasticity = true;
+    m_mod = mod;
+
+    auto winSize = CCDirector::sharedDirector()->getWinSize();
+	const CCSize size { 440.f, 290.f };
+
+    if (!this->initWithColor({ 0, 0, 0, 105 })) return false;
+    m_mainLayer = CCLayer::create();
+    this->addChild(m_mainLayer);
+
+    auto bg = CCScale9Sprite::create("GJ_square01.png", { 0.0f, 0.0f, 80.0f, 80.0f });
+    bg->setContentSize(size);
+    bg->setPosition(winSize.width / 2, winSize.height / 2);
+    m_mainLayer->addChild(bg);
+
+    m_buttonMenu = CCMenu::create();
+    m_buttonMenu->setZOrder(10);
+    m_mainLayer->addChild(m_buttonMenu);
+
+    CCDirector::sharedDirector()->getTouchDispatcher()->incrementForcePrio(2);
+    this->registerWithTouchDispatcher();
+
+	auto nameStr = m_mod->getName() + " Settings";
+    auto nameLabel = CCLabelBMFont::create(
+        nameStr.c_str(), "bigFont.fnt"
+    );
+    nameLabel->setPosition(winSize.width / 2, winSize.height / 2 + 120.f);
+    nameLabel->setScale(.7f);
+    m_mainLayer->addChild(nameLabel, 2); 
+
+	const CCSize listSize { 350.f, 200.f };
+
+    auto bgSprite = CCScale9Sprite::create(
+        "square02b_001.png", { 0.0f, 0.0f, 80.0f, 80.0f }
+    );
+    bgSprite->setScale(.5f);
+    bgSprite->setColor({ 0, 0, 0 });
+    bgSprite->setOpacity(75);
+    bgSprite->setContentSize(listSize * 2);
+    bgSprite->setPosition(winSize.width / 2, winSize.height / 2);
+    m_mainLayer->addChild(bgSprite);
+
+	m_list = ModSettingsList::create(mod, this, listSize.width, listSize.height);
+	m_list->setPosition(winSize.width / 2 - listSize.width / 2, winSize.height / 2 - listSize.height / 2);
+	m_mainLayer->addChild(m_list);
+
+    {
+        auto topSprite = CCSprite::createWithSpriteFrameName("GJ_commentTop_001.png");
+        topSprite->setPosition({ winSize.width / 2, winSize.height / 2 + listSize.height / 2 - 5.f });
+        m_mainLayer->addChild(topSprite);
+
+        auto bottomSprite = CCSprite::createWithSpriteFrameName("GJ_commentTop_001.png");
+        bottomSprite->setFlipY(true);
+        bottomSprite->setPosition({ winSize.width / 2, winSize.height / 2 - listSize.height / 2 + 5.f });
+        m_mainLayer->addChild(bottomSprite);
+
+        auto leftSprite = CCSprite::createWithSpriteFrameName("GJ_commentSide_001.png");
+        leftSprite->setPosition({ winSize.width / 2 - listSize.width / 2 + 1.5f, winSize.height / 2 });
+        leftSprite->setScaleY(5.7f);
+        m_mainLayer->addChild(leftSprite);
+
+        auto rightSprite = CCSprite::createWithSpriteFrameName("GJ_commentSide_001.png");
+        rightSprite->setFlipX(true);
+        rightSprite->setPosition({ winSize.width / 2 + listSize.width / 2 - 1.5f, winSize.height / 2 });
+        rightSprite->setScaleY(5.7f);
+        m_mainLayer->addChild(rightSprite);
+    }
+
+    m_applyBtnSpr = ButtonSprite::create("Apply", "goldFont.fnt", "GJ_button_01.png", .8f);
+    m_applyBtnSpr->setScale(.8f);
+
+    auto applyBtn = CCMenuItemSpriteExtra::create(
+        m_applyBtnSpr,
+        this,
+        menu_selector(ModSettingsLayer::onApply)
+    );
+    applyBtn->setPosition(size.width / 2 - 80.f, -size.height / 2 + 25.f);
+    m_buttonMenu->addChild(applyBtn);
+
+
+    auto resetBtnSpr = ButtonSprite::create("Reset to\nDefault", "bigFont.fnt", "GJ_button_05.png", .8f);
+    resetBtnSpr->setScale(.4f);
+
+    auto resetBtn = CCMenuItemSpriteExtra::create(
+        resetBtnSpr,
+        this,
+        menu_selector(ModSettingsLayer::onResetAllToDefault)
+    );
+    resetBtn->setPosition(size.width / 2 - 150.f, -size.height / 2 + 25.f);
+    m_buttonMenu->addChild(resetBtn);
+
+
+    auto closeSpr = CCSprite::createWithSpriteFrameName("GJ_closeBtn_001.png");
+    closeSpr->setScale(.8f);
+
+    auto closeBtn = CCMenuItemSpriteExtra::create(
+        closeSpr,
+        this,
+        menu_selector(ModSettingsLayer::onClose)
+    );
+    closeBtn->setPosition(-size.width / 2 + 3.f, size.height / 2 - 3.f);
+    m_buttonMenu->addChild(closeBtn);
+
+    this->setKeypadEnabled(true);
+    this->setTouchEnabled(true);
+
+    this->updateState();
+
+    return true;
+}
+
+void ModSettingsLayer::updateState() {
+    if (m_list->hasUnsavedModifiedSettings()) {
+        m_applyBtnSpr->setOpacity(255);
+        m_applyBtnSpr->setColor({ 255, 255, 255 });
+    } else {
+        m_applyBtnSpr->setOpacity(155);
+        m_applyBtnSpr->setColor({ 155, 155, 155 });
+    }
+}
+
+void ModSettingsLayer::FLAlert_Clicked(FLAlertLayer* layer, bool btn2) {
+    if (btn2) {
+        switch (layer->getTag()) {
+            case 0: this->close(); break;
+            case 1: m_list->resetAllToDefault(); break;
+        }
+    }
+}
+
+void ModSettingsLayer::onResetAllToDefault(CCObject*) {
+    auto layer = FLAlertLayer::create(
+        this, "Reset Settings",
+        "Are you sure you want to <co>reset</c> ALL settings?",
+        "Cancel", "Reset", 400.f
+    );
+    layer->setTag(1);
+    layer->show();
+}
+
+void ModSettingsLayer::onApply(CCObject*) {
+    if (!m_list->hasUnsavedModifiedSettings()) {
+        FLAlertLayer::create(
+            "No Changes",
+            "No changes were made.",
+            "OK"
+        )->show();
+    }
+    m_list->applyChanges();
+    this->updateState();
+}
+
+void ModSettingsLayer::keyDown(enumKeyCodes key) {
+    if (key == KEY_Escape)
+        return this->onClose(nullptr);
+    if (key == KEY_Space)
+        return;
+    return FLAlertLayer::keyDown(key);
+}
+
+void ModSettingsLayer::onClose(CCObject*) {
+    if (m_list->hasUnsavedModifiedSettings()) {
+        auto layer = FLAlertLayer::create(
+            this, "Unsaved Changes",
+            "You have <cy>unsaved</c> settings! Are you sure "
+            "you want to <cr>discard</c> your changes?",
+            "Cancel", "Discard", 400.f
+        );
+        layer->setTag(0);
+        layer->show();
+    } else {
+        this->close();
+    }
+};
+
+void ModSettingsLayer::close() {
+    this->setKeyboardEnabled(false);
+    this->removeFromParentAndCleanup(true);
+}
+
+ModSettingsLayer* ModSettingsLayer::create(Mod* mod) {
+    auto ret = new ModSettingsLayer();
+    if (ret && ret->init(mod)) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}*/
+
diff --git a/loader/src/ui/internal/settings/ModSettingsLayer.hpp b/loader/src/ui/internal/settings/ModSettingsLayer.hpp
new file mode 100644
index 00000000..bc8afcb8
--- /dev/null
+++ b/loader/src/ui/internal/settings/ModSettingsLayer.hpp
@@ -0,0 +1,31 @@
+/*#pragma once
+
+#include <Geode.hpp>
+
+USE_GEODE_NAMESPACE();
+
+class ModSettingsList;
+
+class ModSettingsLayer : public FLAlertLayer, public FLAlertLayerProtocol {
+    protected:
+        Mod* m_mod;
+        ModSettingsList* m_list;
+        ButtonSprite* m_applyBtnSpr;
+
+        void FLAlert_Clicked(FLAlertLayer*, bool) override;
+
+		bool init(Mod* mod);
+
+		void onApply(CCObject*);
+		void onResetAllToDefault(CCObject*);
+		void keyDown(enumKeyCodes) override;
+		void onClose(CCObject*);
+        void close();
+		
+    public:
+        static ModSettingsLayer* create(Mod* Mod);
+
+        void updateState();
+};
+
+*/
\ No newline at end of file
diff --git a/loader/src/ui/internal/settings/ModSettingsList.cpp b/loader/src/ui/internal/settings/ModSettingsList.cpp
new file mode 100644
index 00000000..228a19ec
--- /dev/null
+++ b/loader/src/ui/internal/settings/ModSettingsList.cpp
@@ -0,0 +1,109 @@
+/*#include "ModSettingsList.hpp"
+#include <settings/SettingNodeManager.hpp>
+#include <settings/SettingNode.hpp>
+#include <utils/WackyGeodeMacros.hpp>
+#include "ModSettingsLayer.hpp"
+
+bool ModSettingsList::init(Mod* mod, ModSettingsLayer* layer, float width, float height) {
+	m_mod = mod;
+	m_settingsLayer = layer;
+
+	m_scrollLayer = ScrollLayer::create({ width, height });
+	this->addChild(m_scrollLayer);
+
+	if (mod->getSettings().size()) {
+		float offset = 0.f;
+		bool coloredBG = false;
+		std::vector<CCNode*> gen;
+		for (auto const& sett : mod->getSettings()) {
+			auto node = SettingNodeManager::get()->generateNode(mod, sett, width);
+			if (node) {
+				m_settingNodes.push_back(node);
+				node->m_list = this;
+				if (coloredBG) {
+					node->m_backgroundLayer->setColor({ 0, 0, 0 });
+					node->m_backgroundLayer->setOpacity(50);
+				}
+				node->setPosition(
+					0.f, offset - node->getScaledContentSize().height
+				);
+				m_scrollLayer->m_contentLayer->addChild(node);
+				
+				auto separator = CCLayerColor::create({ 0, 0, 0, 50 }, width, 1.f);
+				separator->setPosition(0.f, offset - .5f);
+				m_scrollLayer->m_contentLayer->addChild(separator);
+				gen.push_back(separator);
+
+				offset -= node->m_height;
+				coloredBG = !coloredBG;
+				gen.push_back(node);
+			}
+		}
+		auto separator = CCLayerColor::create({ 0, 0, 0, 50 }, width, 1.f);
+		separator->setPosition(0.f, offset);
+		m_scrollLayer->m_contentLayer->addChild(separator);
+		gen.push_back(separator);
+
+		offset = -offset;
+		// to avoid needing to do moveToTopWithOffset, 
+		// just set the content size to the viewport 
+		// size if its less
+		if (offset < height) offset = height;
+		for (auto& node : gen) {
+			node->setPositionY(node->getPositionY() + offset);
+		}
+		m_scrollLayer->m_contentLayer->setContentSize({ width, offset });
+		m_scrollLayer->moveToTop();
+	} else {
+		auto label = CCLabelBMFont::create("This mod has no settings", "bigFont.fnt");
+		label->setPosition(width / 2, height / 2);
+		label->setScale(.5f);
+		m_scrollLayer->m_contentLayer->addChild(label);
+		m_scrollLayer->setContentSize({ width, height });
+		m_scrollLayer->moveToTop();
+	}
+
+	CCDirector::sharedDirector()->getTouchDispatcher()->incrementForcePrio(2);
+	m_scrollLayer->registerWithTouchDispatcher();
+
+	return true;
+}
+
+bool ModSettingsList::hasUnsavedModifiedSettings() const {
+	for (auto& setting : m_settingNodes) {
+		if (setting->hasUnsavedChanges()) {
+			return true;
+		}
+	}
+	return false;
+}
+
+void ModSettingsList::applyChanges() {
+	for (auto& setting : m_settingNodes) {
+		if (setting->hasUnsavedChanges()) {
+			setting->commitChanges();
+		}
+	}
+}
+
+void ModSettingsList::updateList() {
+	m_settingsLayer->updateState();
+}
+
+void ModSettingsList::resetAllToDefault() {
+	for (auto& setting : m_settingNodes) {
+		setting->resetToDefault();
+	}
+}
+
+ModSettingsList* ModSettingsList::create(
+	Mod* mod, ModSettingsLayer* layer, float width, float height
+) {
+	auto ret = new ModSettingsList();
+	if (ret && ret->init(mod, layer, width, height)) {
+		ret->autorelease();
+		return ret;
+	}
+	CC_SAFE_DELETE(ret);
+	return nullptr;
+}*/
diff --git a/loader/src/ui/internal/settings/ModSettingsList.hpp b/loader/src/ui/internal/settings/ModSettingsList.hpp
new file mode 100644
index 00000000..a7840453
--- /dev/null
+++ b/loader/src/ui/internal/settings/ModSettingsList.hpp
@@ -0,0 +1,29 @@
+/*#pragma once
+
+#include <Geode.hpp>
+#include <nodes/ScrollLayer.hpp>
+#include <settings/SettingNode.hpp>
+
+USE_GEODE_NAMESPACE();
+
+class ModSettingsLayer;
+
+class ModSettingsList : public CCLayer {
+protected:
+    Mod* m_mod;
+    ModSettingsLayer* m_settingsLayer;
+    ScrollLayer* m_scrollLayer;
+    std::vector<SettingNode*> m_settingNodes;
+
+    bool init(Mod* mod, ModSettingsLayer* layer, float width, float height);
+
+public:
+    static ModSettingsList* create(
+        Mod* mod, ModSettingsLayer* layer, float width, float height
+    );
+
+    void updateList();
+    void resetAllToDefault();
+    bool hasUnsavedModifiedSettings() const;
+    void applyChanges();
+};*/
diff --git a/loader/src/ui/internal/settings/Setting.cpp b/loader/src/ui/internal/settings/Setting.cpp
new file mode 100644
index 00000000..f62bb899
--- /dev/null
+++ b/loader/src/ui/internal/settings/Setting.cpp
@@ -0,0 +1,86 @@
+// #include <settings/Setting.hpp>
+// #include <unordered_map>
+
+// USE_GEODE_NAMESPACE();
+
+// Setting* Setting::from(std::string const& id, nlohmann::json const& json) {
+// 	if (json.is_object()) {
+
+// 	} else {
+
+// 	}
+
+// 	auto ctrl = json["control"];
+// 	if (!ctrl.is_string()) {
+// 		FLAlertLayer::create(
+// 			"Failed to load settings",
+// 			"JSON error: 'control' key is not a string (or doesn't exist)!",
+// 			"OK"
+// 		)->show();
+// 		return nullptr;
+// 	}
+// 	std::string control = ctrl;
+
+// 	Setting* out = nullptr;
+// 	/*EventCenter::get()->broadcast(Event(
+// 		events::getSetting(id),
+// 		&out,
+// 		Mod::get()
+// 	));*/
+// 	#pragma message("Event")
+
+// 	if (out == nullptr) {
+// 		FLAlertLayer::create(
+// 			"Failed to load settings",
+// 			std::string("No known setting control '") + control + "'",
+// 			"OK"
+// 		)->show();
+// 	}
+
+// 	return out;
+// }
+
+// bool SettingManager::hasSettings() {
+// 	return !this->m_settings.empty();
+// }
+
+// SettingManager* SettingManager::with(Mod* m) {
+//     static std::unordered_map<Mod*, SettingManager*> managers;
+//     if (!managers.count(m)) {
+//         managers[m] = new SettingManager(m);
+// 	}
+//     return managers[m];
+// }
+
+// SettingManager::SettingManager(Mod* m) {
+// 	m_mod = m;
+// 	auto root = m_mod->getDataStore()["settings"];
+
+// 	if (!root.is_object()) {
+// 		return;
+// 	}
+
+// 	for (auto [id, setting] : root.items()) {
+// 		m_settings[id] = Setting::from(id, setting);
+// 	}
+// }
+
+// Setting* SettingManager::getSetting(std::string id) {
+// 	return m_settings[id];
+// }
+
+// void SettingManager::updateSetting(std::string id) {
+// 	// m_mod->getDataStore()["settings"][id] = this->getSetting(id)->saveJSON();
+// }
+
+// CCNode* Setting::createControl() const {
+// 	return nullptr;
+// }
+
+// std::vector<CCNode*> SettingManager::generateSettingNodes() {
+// 	std::vector<CCNode*> out;
+// 	for (auto [k, v] : this->m_settings) {
+// 		out.push_back(v->createControl());
+// 	}
+// 	return out;
+// }
diff --git a/loader/src/ui/nodes/BasedButton.cpp b/loader/src/ui/nodes/BasedButton.cpp
new file mode 100644
index 00000000..44a159e5
--- /dev/null
+++ b/loader/src/ui/nodes/BasedButton.cpp
@@ -0,0 +1,33 @@
+#include <Geode/ui/BasedButton.hpp>
+
+USE_GEODE_NAMESPACE();
+
+TabButton* TabButton::create(
+    TabBaseColor unselected,
+    TabBaseColor selected,
+    const char* text,
+    cocos2d::CCObject* target,
+    cocos2d::SEL_MenuHandler callback
+) {
+    auto ret = new TabButton();
+    auto sprOff = TabButtonSprite::create(text, unselected);
+    auto sprOn  = TabButtonSprite::create(text, selected);
+    if (ret && ret->init(sprOff, sprOn, target, callback)) {
+        ret->m_offButton->m_colorDip = .3f;
+        ret->m_offButton->m_colorEnabled = true;
+        ret->m_offButton->m_scaleMultiplier = 1.f;
+        ret->m_onButton->setEnabled(false);
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
+TabButton* TabButton::create(const char* text, CCObject* target, SEL_MenuHandler callback) {
+    return TabButton::create(
+        TabBaseColor::Unselected,
+        TabBaseColor::Selected,
+        text, target, callback
+    );
+}
diff --git a/loader/src/ui/nodes/BasedButtonSprite.cpp b/loader/src/ui/nodes/BasedButtonSprite.cpp
new file mode 100644
index 00000000..9612c1b6
--- /dev/null
+++ b/loader/src/ui/nodes/BasedButtonSprite.cpp
@@ -0,0 +1,167 @@
+#include <Geode/ui/BasedButtonSprite.hpp>
+
+USE_GEODE_NAMESPACE();
+
+bool BasedButtonSprite::init(CCNode* ontop, int type, int size, int color) {
+    if (!CCSprite::initWithSpriteFrameName(
+        Mod::get()->expandSpriteName(
+            CCString::createWithFormat("GEODE_blank%02d_%02d_%02d.png", type, size, color)->getCString()
+        )
+    )) return false;
+
+    this->m_type = type;
+    this->m_size = size;
+    this->m_color = color;
+    
+    if (ontop) {
+        this->m_onTop = ontop;
+        this->m_onTop->retain();
+        this->addChild(this->m_onTop);
+        this->m_onTop->setPosition(this->getContentSize() / 2 + this->getTopOffset());
+    }
+
+    return true;
+}
+
+CCPoint BasedButtonSprite::getTopOffset() const {
+    return { 0, 0 };
+}
+
+bool BasedButtonSprite::initWithSprite(const char* sprName, float sprScale, int type, int size, int color) {
+    auto spr = CCSprite::create(sprName);
+    if (!spr) return false;
+    spr->setScale(sprScale);
+    return this->init(spr, type, size, color);
+}
+
+bool BasedButtonSprite::initWithSpriteFrameName(const char* sprName, float sprScale, int type, int size, int color) {
+    auto spr = CCSprite::createWithSpriteFrameName(sprName);
+    if (!spr) return false;
+    spr->setScale(sprScale);
+    return this->init(spr, type, size, color);
+}
+
+BasedButtonSprite::~BasedButtonSprite() {
+    CC_SAFE_RELEASE(this->m_onTop);
+}
+
+BasedButtonSprite* BasedButtonSprite::create(CCNode* ontop, int type, int size, int color) {
+    auto ret = new BasedButtonSprite();
+    if (ret && ret->init(ontop, type, size, color)) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
+EditorButtonSprite* EditorButtonSprite::create(cocos2d::CCNode* top, EditorBaseColor color) {
+    auto ret = new EditorButtonSprite();
+    if (ret && ret->init(top, static_cast<int>(BaseType::Editor), 0, static_cast<int>(color))) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
+EditorButtonSprite* EditorButtonSprite::createWithSprite(
+    const char* sprName,
+    float sprScale,
+    EditorBaseColor color
+) {
+    auto ret = new EditorButtonSprite();
+    if (ret && ret->initWithSprite(
+        sprName, sprScale,
+        static_cast<int>(BaseType::Editor), 0, static_cast<int>(color))
+    ) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
+EditorButtonSprite* EditorButtonSprite::createWithSpriteFrameName(
+    const char* sprName,
+    float sprScale,
+    EditorBaseColor color
+) {
+    auto ret = new EditorButtonSprite();
+    if (ret && ret->initWithSpriteFrameName(
+        sprName, sprScale,
+        static_cast<int>(BaseType::Editor), 0, static_cast<int>(color))
+    ) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
+CircleButtonSprite* CircleButtonSprite::create(
+    cocos2d::CCNode* top,
+    CircleBaseColor color,
+    CircleBaseSize size
+) {
+    auto ret = new CircleButtonSprite();
+    if (ret && ret->init(
+        top,
+        static_cast<int>(BaseType::Circle), static_cast<int>(size), static_cast<int>(color)
+    )) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
+CircleButtonSprite* CircleButtonSprite::createWithSprite(
+    const char* sprName,
+    float sprScale,
+    CircleBaseColor color,
+    CircleBaseSize size
+) {
+    auto ret = new CircleButtonSprite();
+    if (ret && ret->initWithSprite(
+        sprName, sprScale,
+        static_cast<int>(BaseType::Circle), static_cast<int>(size), static_cast<int>(color)
+    )) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
+CircleButtonSprite* CircleButtonSprite::createWithSpriteFrameName(
+    const char* sprName,
+    float sprScale,
+    CircleBaseColor color,
+    CircleBaseSize size
+) {
+    auto ret = new CircleButtonSprite();
+    if (ret && ret->initWithSpriteFrameName(
+        sprName, sprScale,
+        static_cast<int>(BaseType::Circle), static_cast<int>(size), static_cast<int>(color)
+    )) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
+TabButtonSprite* TabButtonSprite::create(const char* text, TabBaseColor color) {
+    auto ret = new TabButtonSprite();
+    auto label = CCLabelBMFont::create(text, "bigFont.fnt");
+    label->limitLabelWidth(75.f, .6f, .1f);
+    if (ret && ret->init(
+        label, static_cast<int>(BaseType::Tab), 0, static_cast<int>(color)
+    )) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
diff --git a/loader/src/ui/nodes/IconButtonSprite.cpp b/loader/src/ui/nodes/IconButtonSprite.cpp
new file mode 100644
index 00000000..70f4407b
--- /dev/null
+++ b/loader/src/ui/nodes/IconButtonSprite.cpp
@@ -0,0 +1,135 @@
+#include <Geode/ui/IconButtonSprite.hpp>
+
+USE_GEODE_NAMESPACE();
+
+bool IconButtonSprite::init(
+    const char* bg,
+    bool bgIsFrame,
+    cocos2d::CCNode* icon,
+    const char* text,
+    const char* font
+) {
+    if (!CCSprite::init())
+        return false;
+    
+    if (bgIsFrame) {
+        m_bg = CCScale9Sprite::createWithSpriteFrameName(bg);
+    } else {
+        m_bg = CCScale9Sprite::create(bg);
+    }
+    this->addChild(m_bg);
+
+    m_label = CCLabelBMFont::create(text, font);
+    m_label->setAnchorPoint({ .0f, .5f });
+    m_label->setZOrder(1);
+    this->addChild(m_label);
+
+    if (icon) {
+        m_icon = icon;
+        icon->setZOrder(1);
+        this->addChild(icon);
+    }
+
+    this->updateLayout();
+    
+    return true;
+}
+
+void IconButtonSprite::updateLayout() {
+    static constexpr const float PAD = 7.5f;
+
+    auto size = CCSize { 20.f, 20.f };
+    if (m_label->getString() && strlen(m_label->getString())) {
+        m_label->limitLabelWidth(100.f, .6f, .1f);
+        size.width += m_label->getScaledContentSize().width;
+    }
+    if (m_icon) {
+        limitNodeSize(m_icon, size, 1.f, .1f);
+    }
+    size.height += 15.f;
+
+    if (m_icon) {
+        size.width += m_icon->getScaledContentSize().width + PAD;
+    }
+
+    this->setContentSize(size);
+    m_bg->setContentSize(size / m_bg->getScale());
+    m_bg->setPosition(m_obContentSize / 2);
+
+    if (m_icon) {
+        m_label->setPosition(
+            size.height / 2 + m_icon->getScaledContentSize().width / 2 + PAD,
+            size.height / 2 + 1.f
+        );
+        m_icon->setPosition(size.height / 2, size.height / 2);
+    } else {
+        m_label->setPosition(size.height / 2, size.height / 2);
+    }
+}
+
+IconButtonSprite* IconButtonSprite::create(
+    const char* bg,
+    cocos2d::CCNode* icon,
+    const char* text,
+    const char* font
+) {
+    auto ret = new IconButtonSprite();
+    if (ret && ret->init(bg, false, icon, text, font)) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
+IconButtonSprite* IconButtonSprite::createWithSpriteFrameName(
+    const char* bg,
+    cocos2d::CCNode* icon,
+    const char* text,
+    const char* font
+) {
+    auto ret = new IconButtonSprite();
+    if (ret && ret->init(bg, true, icon, text, font)) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
+void IconButtonSprite::setBG(const char* bg, bool isFrame) {
+    if (m_bg) {
+        m_bg->removeFromParent();
+    }
+    if (isFrame) {
+        m_bg = CCScale9Sprite::createWithSpriteFrameName(bg);
+    } else {
+        m_bg = CCScale9Sprite::create(bg);
+    }
+    this->addChild(m_bg);
+    this->updateLayout();
+}
+
+void IconButtonSprite::setIcon(cocos2d::CCNode* icon) {
+    if (m_icon) {
+        m_icon->removeFromParent();
+    }
+    m_icon = icon;
+    m_icon->setZOrder(1);
+    this->addChild(icon);
+    this->updateLayout();
+}
+
+cocos2d::CCNode* IconButtonSprite::getIcon() const {
+    return m_icon;
+}
+
+void IconButtonSprite::setString(const char* label) {
+    m_label->setString(label);
+    this->updateLayout();
+}
+
+const char* IconButtonSprite::getString() {
+    return m_label->getString();
+}
+
diff --git a/loader/src/ui/nodes/InputNode.cpp b/loader/src/ui/nodes/InputNode.cpp
new file mode 100644
index 00000000..f3bd38a2
--- /dev/null
+++ b/loader/src/ui/nodes/InputNode.cpp
@@ -0,0 +1,85 @@
+#include <Geode/ui/InputNode.hpp>
+
+USE_GEODE_NAMESPACE();
+
+const char* InputNode::getString() {
+    return m_input->getTextField()->getString();
+}
+
+void InputNode::setString(const char* _str) {
+    m_input->getTextField()->setString(_str);
+    m_input->refreshLabel();
+}
+
+CCTextInputNode* InputNode::getInputNode() const {
+    return m_input;
+}
+
+CCScale9Sprite* InputNode::getBGSprite() const {
+    return m_bgSprite;
+}
+
+void InputNode::setEnabled(bool enabled) {
+    m_input->setMouseEnabled(enabled);
+    m_input->setTouchEnabled(enabled);
+}
+
+bool InputNode::init(float _w, float _h, const char* _phtxt, const char* _fnt, const std::string & _awc, int _cc) {
+    m_bgSprite = cocos2d::extension::CCScale9Sprite::create(
+        "square02b_001.png", { 0.0f, 0.0f, 80.0f, 80.0f }
+    );
+
+    m_bgSprite->setScale(.5f);
+    m_bgSprite->setColor({ 0, 0, 0 });
+    m_bgSprite->setOpacity(75);
+    m_bgSprite->setContentSize({ _w * 2, _h * 2 });
+
+    this->addChild(m_bgSprite);
+
+    m_input = CCTextInputNode::create(
+        _w - 10.0f, 60.0f, _phtxt, _fnt
+    );
+
+    m_input->setLabelPlaceholderColor({ 150, 150, 150 });
+    m_input->setLabelPlaceholderScale(.75f);
+    m_input->setMaxLabelScale(.8f);
+    m_input->setMaxLabelWidth(_cc);
+    if (_awc.length())
+        m_input->setAllowedChars(_awc);
+
+    this->addChild(m_input);
+
+    return true;
+}
+
+bool InputNode::init(float _w, const char* _phtxt, const char* _fnt, const std::string & _awc, int _cc) {
+    return init(_w, 30.0f, _phtxt, _fnt, _awc, _cc);
+}
+
+InputNode* InputNode::create(float _w, const char* _phtxt, const char* _fnt, const std::string & _awc, int _cc) {
+    auto pRet = new InputNode();
+
+    if (pRet && pRet->init(_w, _phtxt, _fnt, _awc, _cc)) {
+        pRet->autorelease();
+        return pRet;
+    }
+
+    CC_SAFE_DELETE(pRet);
+    return nullptr;
+}
+
+InputNode* InputNode::create(float _w, const char* _phtxt, const std::string & _awc) {
+    return create(_w, _phtxt, "bigFont.fnt", _awc, 69);
+}
+
+InputNode* InputNode::create(float _w, const char* _phtxt, const std::string & _awc, int _cc) {
+    return create(_w, _phtxt, "bigFont.fnt", _awc, _cc);
+}
+
+InputNode* InputNode::create(float _w, const char* _phtxt, const char* _fnt) {
+    return create(_w, _phtxt, _fnt, "", 69);
+}
+
+InputNode* InputNode::create(float _w, const char* _phtxt) {
+    return create(_w, _phtxt, "bigFont.fnt");
+}
diff --git a/loader/src/ui/nodes/ListView.cpp b/loader/src/ui/nodes/ListView.cpp
new file mode 100644
index 00000000..7f335c4b
--- /dev/null
+++ b/loader/src/ui/nodes/ListView.cpp
@@ -0,0 +1,78 @@
+#include <Geode/ui/ListView.hpp>
+
+USE_GEODE_NAMESPACE();
+
+GenericListCell::GenericListCell(const char* name, CCSize size) :
+    TableViewCell(name, size.width, size.height) {}
+
+void GenericListCell::draw() {
+    reinterpret_cast<StatsCell*>(this)->StatsCell::draw();
+}
+
+GenericListCell* GenericListCell::create(const char* key, CCSize size) {
+    auto pRet = new GenericListCell(key, size);
+    if (pRet) {
+        return pRet;
+    }
+    CC_SAFE_DELETE(pRet);
+    return nullptr;
+}
+
+void GenericListCell::updateBGColor(int index) {
+	if (index & 1) m_backgroundLayer->setColor(ccc3(0xc2, 0x72, 0x3e));
+    else m_backgroundLayer->setColor(ccc3(0xa1, 0x58, 0x2c));
+    m_backgroundLayer->setOpacity(0xff);
+}
+
+
+void ListView::setupList() {
+    if (!m_entries->count()) return;
+    m_tableView->reloadData();
+
+    // fix content layer content size so the 
+    // list is properly aligned to the top
+    auto coverage = calculateChildCoverage(m_tableView->m_contentLayer);
+    m_tableView->m_contentLayer->setContentSize({
+        -coverage.origin.x + coverage.size.width,
+        -coverage.origin.y + coverage.size.height
+    });
+
+    if (m_entries->count() == 1) {
+        m_tableView->moveToTopWithOffset(m_itemSeparation);
+    } else {
+        m_tableView->moveToTop();
+    }
+}
+
+TableViewCell* ListView::getListCell(const char* key) {
+    return GenericListCell::create(key, { this->m_width, this->m_itemSeparation });
+}
+
+void ListView::loadCell(TableViewCell* cell, unsigned int index) {
+    auto node = dynamic_cast<CCNode*>(m_entries->objectAtIndex(index));
+    if (node) {
+        auto lcell = as<GenericListCell*>(cell);
+        node->setContentSize(lcell->getScaledContentSize());
+        node->setPosition(0, 0);
+        lcell->addChild(node);
+        lcell->updateBGColor(index);
+    }
+}
+
+ListView* ListView::create(
+    CCArray* items,
+    float itemHeight,
+    float width,
+    float height
+) {
+    auto ret = new ListView();
+    if (ret) {
+        ret->m_itemSeparation = itemHeight;
+        if (ret->init(items, BoomListType::Default, width, height)) {
+            ret->autorelease();
+            return ret;
+        }
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
diff --git a/loader/src/ui/nodes/MDTextArea.cpp b/loader/src/ui/nodes/MDTextArea.cpp
new file mode 100644
index 00000000..08b5699b
--- /dev/null
+++ b/loader/src/ui/nodes/MDTextArea.cpp
@@ -0,0 +1,630 @@
+#include <Geode/ui/MDTextArea.hpp>
+#include <Geode/utils/WackyGeodeMacros.hpp>
+#include <md4c.h>
+
+USE_GEODE_NAMESPACE();
+
+static constexpr float g_fontScale = .5f;
+static constexpr float g_paragraphPadding = 7.f;
+static constexpr float g_indent = 7.f;
+static constexpr float g_codeBlockIndent = 8.f;
+static constexpr ccColor3B g_linkColor = cc3x(0x7ff4f4);
+
+TextRenderer::Font g_mdFont = [](int style) -> TextRenderer::Label {
+    if ((style & TextStyleBold) && (style & TextStyleItalic)) {
+        return CCLabelBMFont::create("", "mdFontBI.fnt"_spr);
+    }
+    if ((style & TextStyleBold)) {
+        return CCLabelBMFont::create("", "mdFontB.fnt"_spr);
+    }
+    if ((style & TextStyleItalic)) {
+        return CCLabelBMFont::create("", "mdFontI.fnt"_spr);
+    }
+    return CCLabelBMFont::create("", "mdFont.fnt"_spr);
+};
+
+TextRenderer::Font g_mdMonoFont = [](int style) -> TextRenderer::Label {
+    return CCLabelBMFont::create("", "mdFontMono.fnt"_spr);
+};
+
+
+class MDContentLayer : public CCContentLayer {
+protected:
+    CCMenu* m_content;
+
+public:
+    static MDContentLayer* create(CCMenu* content, float width, float height) {
+        auto ret = new MDContentLayer();
+        if (ret && ret->initWithColor({ 0, 255, 0, 0 }, width, height)) {
+            ret->m_content = content;
+            ret->autorelease();
+            return ret;
+        }
+        CC_SAFE_DELETE(ret);
+        return nullptr;
+    }
+
+    void setPosition(CCPoint const& pos) override {
+        // cringe CCContentLayer expect its children to 
+        // all be TableViewCells
+        CCLayerColor::setPosition(pos);
+
+        // so that's why based MDContentLayer expects itself 
+        // to have a CCMenu :-)
+        if (m_content) {
+            CCARRAY_FOREACH_B_TYPE(m_content->getChildren(), child, CCNode) {
+               auto y = this->getPositionY() + child->getPositionY();
+               child->setVisible(!(
+                    (m_content->getContentSize().height < y) ||
+                    (y < -child->getContentSize().height)
+                ));
+            }
+        }
+    }
+};
+
+
+Result<ccColor3B> colorForIdentifier(std::string const& tag) {
+    if (string_utils::contains(tag, ' ')) {
+        auto hexStr = string_utils::split(
+            string_utils::normalize(tag), " "
+        ).at(1);
+        try {
+            auto hex = std::stoi(hexStr, nullptr, 16);
+            return Ok(cc3x(hex));
+        } catch(...) {
+            return Err("Invalid hex");
+        }
+    } else {
+        auto colorText = tag.substr(1);
+        if (!colorText.size()) {
+            return Err("No color specified");
+        } else if (colorText.size() > 1) {
+            return Err("Color tag " + tag + " unexpectedly long, either do <cx> or <c hex>");
+        } else {
+            switch (colorText.front()) {
+                case 'b': return Ok(cc3x(0x4a52e1)); break;
+                case 'g': return Ok(cc3x(0x40e348)); break;
+                case 'l': return Ok(cc3x(0x60abef)); break;
+                case 'j': return Ok(cc3x(0x32c8ff)); break;
+                case 'y': return Ok(cc3x(0xffff00)); break;
+                case 'o': return Ok(cc3x(0xffa54b)); break;
+                case 'r': return Ok(cc3x(0xff5a5a)); break;
+                case 'p': return Ok(cc3x(0xff00ff)); break;
+                default:
+                    return Err("Unknown color " + colorText);
+            }
+        }
+    }
+    return Err("Unknown error");
+}
+
+
+bool MDTextArea::init(
+    std::string const& str,
+    CCSize const& size
+) {
+    if (!CCLayer::init())
+        return false;
+
+    m_text = str;
+    m_size = size;
+    this->setContentSize(size);
+    m_renderer = TextRenderer::create();
+    CC_SAFE_RETAIN(m_renderer);
+
+    m_bgSprite = CCScale9Sprite::create(
+        "square02b_001.png", { 0.0f, 0.0f, 80.0f, 80.0f }
+    );
+    m_bgSprite->setScale(.5f);
+    m_bgSprite->setColor({ 0, 0, 0 });
+    m_bgSprite->setOpacity(75);
+    m_bgSprite->setContentSize(size * 2 + CCSize { 25.f, 25.f });
+    m_bgSprite->setPosition(size / 2);
+    this->addChild(m_bgSprite);
+
+    m_scrollLayer = ScrollLayer::create({ 0, 0, m_size.width, m_size.height }, true);
+
+    m_content = CCMenu::create();
+    m_content->setZOrder(2);
+    m_scrollLayer->m_contentLayer->addChild(m_content);
+
+    CCDirector::sharedDirector()->getTouchDispatcher()->incrementForcePrio(2);
+    m_scrollLayer->registerWithTouchDispatcher();
+
+    this->addChild(m_scrollLayer);
+
+    this->updateLabel();
+
+    return true;
+}
+
+MDTextArea::~MDTextArea() {
+    CC_SAFE_RELEASE(m_renderer);
+}
+
+class BreakLine : public CCNode {
+protected:
+    void draw() override {
+        // some nodes sometimes set the blend func to
+        // something else without resetting it back
+        ccGLBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
+        ccDrawSolidRect({ 0, 0 }, this->getContentSize(), { 1.f, 1.f, 1.f, .2f });
+        CCNode::draw();
+    }
+
+public:
+    static BreakLine* create(float width) {
+        auto ret = new BreakLine;
+        if (ret && ret->init()) {
+            ret->autorelease();
+            ret->setContentSize({ width, 1.f });
+            return ret;
+        }
+        CC_SAFE_DELETE(ret);
+        return nullptr;
+    }
+};
+
+void MDTextArea::onLink(CCObject* pSender) {
+    auto href = as<CCString*>(as<CCNode*>(pSender)->getUserObject());
+    auto layer = FLAlertLayer::create(
+        this,
+        "Hold Up!",
+        "Links are spooky! Are you sure you want to go to <cy>" +
+        std::string(href->getCString()) + "</c>?",
+        "Cancel", "Yes",
+        360.f
+    );
+    layer->setUserObject(href);
+    layer->show();
+}
+
+void MDTextArea::onGDProfile(CCObject* pSender) {
+    auto href = as<CCString*>(as<CCNode*>(pSender)->getUserObject());
+    auto profile = std::string(href->getCString());
+    profile = profile.substr(profile.find(":") + 1);
+    try {
+        ProfilePage::create(std::stoi(profile), false)->show();
+    } catch(...) {
+        FLAlertLayer::create(
+            "Error",
+            "Invalid profile ID: <cr>" + profile + "</c>. This is "
+            "probably the modder's fault, report the bug to them.",
+            "OK"
+        )->show();
+    }
+}
+
+void MDTextArea::FLAlert_Clicked(FLAlertLayer* layer, bool btn) {
+    if (btn) {
+        web::openLinkInBrowser(as<CCString*>(layer->getUserObject())->getCString());
+    }
+}
+
+struct MDParser {
+    static std::string s_lastLink;
+    static std::string s_lastImage;
+    static bool s_isOrderedList;
+    static bool s_isCodeBlock;
+    static float s_codeStart;
+    static size_t s_orderedListNum;
+    static std::vector<TextRenderer::Label> s_codeSpans;
+
+    static int parseText(MD_TEXTTYPE type, MD_CHAR const* rawText, MD_SIZE size, void* mdtextarea) {
+        auto textarea = reinterpret_cast<MDTextArea*>(mdtextarea);
+        auto renderer = textarea->m_renderer;
+        auto text = std::string(rawText, size);
+        switch (type) {
+            case MD_TEXTTYPE::MD_TEXT_CODE: {
+                auto rendered = renderer->renderString(text);
+                if (!s_isCodeBlock) {
+                    // code span BGs need to be rendered after all  
+                    // rendering is done since the position of the 
+                    // rendered labels may change after alignments 
+                    // are adjusted
+                    vector_utils::push(s_codeSpans, rendered);
+                }
+            } break;
+
+            case MD_TEXTTYPE::MD_TEXT_BR: {
+                renderer->breakLine();
+            } break;
+
+            case MD_TEXTTYPE::MD_TEXT_SOFTBR: {
+                renderer->breakLine();
+            } break;
+            
+            case MD_TEXTTYPE::MD_TEXT_NORMAL: {
+                if (s_lastLink.size()) {
+                    renderer->pushColor(g_linkColor);
+                    renderer->pushDecoFlags(TextDecorationUnderline);
+                    auto rendered = renderer->renderStringInteractive(
+                        text,
+                        textarea,
+                        string_utils::startsWith(s_lastLink, "user:") ?
+                            menu_selector(MDTextArea::onGDProfile) : 
+                            menu_selector(MDTextArea::onLink)
+                    );
+                    for (auto const& label : rendered) {
+                        label.m_node->setUserObject(CCString::create(s_lastLink));
+                    }
+                    renderer->popDecoFlags();
+                    renderer->popColor();
+                } else if (s_lastImage.size()) {
+                    bool isFrame = false;
+                    if (string_utils::startsWith(s_lastImage, "frame:")) {
+                        s_lastImage = s_lastImage.substr(s_lastImage.find(":") + 1);
+                        isFrame = true;
+                    }
+                    CCSprite* spr = nullptr;
+                    if (isFrame) {
+                        spr = CCSprite::createWithSpriteFrameName(s_lastImage.c_str());
+                    } else {
+                        spr = CCSprite::create(s_lastImage.c_str());
+                    }
+                    if (spr) {
+                        renderer->renderNode(spr);
+                    } else {
+                        renderer->renderString(text);
+                    }
+                    s_lastImage = "";
+                } else {
+                    renderer->renderString(text);
+                }
+            } break;
+            
+            case MD_TEXTTYPE::MD_TEXT_HTML: {
+                if (text.size() > 2) {
+                    auto tag = string_utils::trim(text.substr(1, text.size() - 2));
+                    auto isClosing = tag.front() == '/';
+                    if (isClosing) tag = tag.substr(1);
+                    if (tag.front() != 'c') {
+                        Log::get() << Severity::Warning << "Unknown tag " << text;
+                        renderer->renderString(text);
+                    } else {
+                        if (isClosing) {
+                            renderer->popColor();
+                        } else {
+                            auto color = colorForIdentifier(tag);
+                            if (color) {
+                                renderer->pushColor(color.value());
+                            } else {
+                                Log::get() << Severity::Warning
+                                    << "Error parsing color: " << color.error();
+                            }
+                        }
+                    }
+                } else {
+                    Log::get() << Severity::Warning
+                        << "Too short tag " << text;
+                    renderer->renderString(text);
+                }
+            } break;
+
+            default: {
+                Log::get() << Severity::Warning << "Unhandled text type " << type;
+            } break;
+        }
+        return 0;
+    }
+
+    static int enterBlock(MD_BLOCKTYPE type, void* detail, void* mdtextarea) {
+        auto textarea = reinterpret_cast<MDTextArea*>(mdtextarea);
+        auto renderer = textarea->m_renderer;
+        switch (type) {
+            case MD_BLOCKTYPE::MD_BLOCK_DOC: {} break;
+
+            case MD_BLOCKTYPE::MD_BLOCK_H: {
+                auto hdetail = reinterpret_cast<MD_BLOCK_H_DETAIL*>(detail);
+                renderer->pushStyleFlags(TextStyleBold);
+                switch (hdetail->level) {
+                    case 1: renderer->pushScale(g_fontScale * 2.f); break;
+                    case 2: renderer->pushScale(g_fontScale * 1.5f); break;
+                    case 3: renderer->pushScale(g_fontScale * 1.17f); break;
+                    case 4: renderer->pushScale(g_fontScale); break;
+                    case 5: renderer->pushScale(g_fontScale * .83f); break;
+                    default:
+                    case 6: renderer->pushScale(g_fontScale * .67f); break;
+                }
+                // switch (hdetail->level) {
+                //     case 3: renderer->pushCaps(TextCapitalization::AllUpper); break;
+                // }
+            } break;
+            
+            case MD_BLOCKTYPE::MD_BLOCK_P: {} break;
+            
+            case MD_BLOCKTYPE::MD_BLOCK_UL:
+            case MD_BLOCKTYPE::MD_BLOCK_OL: {
+                renderer->pushIndent(g_indent);
+                s_isOrderedList = type == MD_BLOCKTYPE::MD_BLOCK_OL;
+                s_orderedListNum = 0;
+            } break;
+
+            case MD_BLOCKTYPE::MD_BLOCK_HR: {
+                renderer->breakLine(g_paragraphPadding / 2);
+                renderer->renderNode(BreakLine::create(textarea->m_size.width));
+                renderer->breakLine(g_paragraphPadding);
+            } break;
+
+            case MD_BLOCKTYPE::MD_BLOCK_LI: {
+                renderer->pushOpacity(renderer->getCurrentOpacity() / 2);
+                auto lidetail = reinterpret_cast<MD_BLOCK_LI_DETAIL*>(detail);
+                if (s_isOrderedList) {
+                    s_orderedListNum++;
+                    renderer->renderString(std::to_string(s_orderedListNum) + ". ");
+                } else {
+                    renderer->renderString("• ");
+                }
+                renderer->popOpacity();
+            } break;
+
+            case MD_BLOCKTYPE::MD_BLOCK_CODE: {
+                s_isCodeBlock = true;
+                s_codeStart = renderer->getCursorPos().y;
+                renderer->pushFont(g_mdMonoFont);
+                renderer->pushIndent(g_codeBlockIndent);
+                renderer->pushWrapOffset(g_codeBlockIndent);
+            } break;
+
+            default: {
+                Log::get() << Severity::Warning << "Unhandled block enter type " << type;
+            } break;
+        }
+        return 0;
+    }
+    
+    static int leaveBlock(MD_BLOCKTYPE type, void* detail, void* mdtextarea) {
+        auto textarea = reinterpret_cast<MDTextArea*>(mdtextarea);
+        auto renderer = textarea->m_renderer;
+        switch (type) {
+            case MD_BLOCKTYPE::MD_BLOCK_DOC: {} break;
+
+            case MD_BLOCKTYPE::MD_BLOCK_H: {
+                auto hdetail = reinterpret_cast<MD_BLOCK_H_DETAIL*>(detail);
+                renderer->breakLine();
+                if (hdetail->level == 1) {
+                    renderer->breakLine(g_paragraphPadding / 2);
+                    renderer->renderNode(BreakLine::create(textarea->m_size.width));
+                }
+                renderer->breakLine(g_paragraphPadding);
+                renderer->popScale();
+                renderer->popStyleFlags();
+                // switch (hdetail->level) {
+                //     case 3: renderer->popCaps(); break;
+                // }
+            } break;
+            
+            case MD_BLOCKTYPE::MD_BLOCK_P: {
+                renderer->breakLine();
+                renderer->breakLine(g_paragraphPadding);
+            } break;
+            
+            case MD_BLOCKTYPE::MD_BLOCK_OL:
+            case MD_BLOCKTYPE::MD_BLOCK_UL: {
+                renderer->popIndent();
+            } break;
+
+            case MD_BLOCKTYPE::MD_BLOCK_CODE: {
+                auto codeEnd = renderer->getCursorPos().y;
+
+                auto pad = g_codeBlockIndent / 1.5f;
+
+                CCSize size {
+                    textarea->m_size.width
+                     - renderer->getCurrentIndent()
+                     - renderer->getCurrentWrapOffset() +
+                     pad * 2,
+                    s_codeStart - codeEnd + pad * 2
+                };
+
+                auto bg = CCScale9Sprite::create(
+                    "square02b_001.png", { 0.0f, 0.0f, 80.0f, 80.0f }
+                );
+                bg->setScale(.25f);
+                bg->setColor({ 0, 0, 0 });
+                bg->setOpacity(75);
+                bg->setContentSize(size * 4);
+                bg->setPosition(
+                    size.width / 2 + renderer->getCurrentIndent() - pad,
+                    // mmm i love magic numbers
+                    // the -2.f is to offset the the box 
+                    // to fit the Ubuntu font very neatly.
+                    // idk if it works the same for other 
+                    // fonts
+                    s_codeStart - 2.f + pad - size.height / 2
+                );
+                bg->setAnchorPoint({ .5f, .5f });
+                bg->setZOrder(-1);
+                textarea->m_content->addChild(bg);
+                
+                renderer->popWrapOffset();
+                renderer->popIndent();
+                renderer->popFont();
+
+                renderer->breakLine();
+            } break;
+
+            case MD_BLOCKTYPE::MD_BLOCK_LI: {} break;
+            
+            case MD_BLOCKTYPE::MD_BLOCK_HR: {} break;
+
+            default: {
+                Log::get() << Severity::Warning << "Unhandled block leave type " << type;
+            } break;
+        }
+        return 0;
+    }
+    
+    static int enterSpan(MD_SPANTYPE type, void* detail, void* mdtextarea) {
+        auto renderer = reinterpret_cast<MDTextArea*>(mdtextarea)->m_renderer;
+        switch (type) {
+            case MD_SPANTYPE::MD_SPAN_STRONG: {
+                renderer->pushStyleFlags(TextStyleBold);
+            } break;
+
+            case MD_SPANTYPE::MD_SPAN_EM: {
+                renderer->pushStyleFlags(TextStyleItalic);
+            } break;
+            
+            case MD_SPANTYPE::MD_SPAN_DEL: {
+                renderer->pushDecoFlags(TextDecorationStrikethrough);
+            } break;
+            
+            case MD_SPANTYPE::MD_SPAN_U: {
+                renderer->pushDecoFlags(TextDecorationUnderline);
+            } break;
+
+            case MD_SPANTYPE::MD_SPAN_IMG: {
+                auto adetail = reinterpret_cast<MD_SPAN_IMG_DETAIL*>(detail);
+                s_lastImage = std::string(adetail->src.text, adetail->src.size);
+            } break;
+            
+            case MD_SPANTYPE::MD_SPAN_A: {
+                auto adetail = reinterpret_cast<MD_SPAN_A_DETAIL*>(detail);
+                s_lastLink = std::string(adetail->href.text, adetail->href.size);
+            } break;
+
+            case MD_SPANTYPE::MD_SPAN_CODE: {
+                s_isCodeBlock = false;
+                renderer->pushFont(g_mdMonoFont);
+            } break;
+
+            default: {
+                Log::get() << Severity::Warning << "Unhandled span enter type " << type;
+            } break;
+        }
+        return 0;
+    }
+    
+    static int leaveSpan(MD_SPANTYPE type, void* detail, void* mdtextarea) {
+        auto renderer = reinterpret_cast<MDTextArea*>(mdtextarea)->m_renderer;
+        switch (type) {
+            case MD_SPANTYPE::MD_SPAN_STRONG: {
+                renderer->popStyleFlags();
+            } break;
+
+            case MD_SPANTYPE::MD_SPAN_EM: {
+                renderer->popStyleFlags();
+            } break;
+            
+            case MD_SPANTYPE::MD_SPAN_DEL: {
+                renderer->popDecoFlags();
+            } break;
+            
+            case MD_SPANTYPE::MD_SPAN_U: {
+                renderer->popDecoFlags();
+            } break;
+            
+            case MD_SPANTYPE::MD_SPAN_A: {
+                s_lastLink = "";
+            } break;
+            
+            case MD_SPANTYPE::MD_SPAN_IMG: {
+                s_lastImage = "";
+            } break;
+
+            case MD_SPANTYPE::MD_SPAN_CODE: {
+                renderer->popFont();
+            } break;
+
+            default: {
+                Log::get() << Severity::Warning << "Unhandled span leave type " << type;
+            } break;
+        }
+        return 0;
+    }
+};
+std::string MDParser::s_lastLink = "";
+std::string MDParser::s_lastImage = "";
+bool MDParser::s_isOrderedList = false;
+size_t MDParser::s_orderedListNum = 0;
+bool MDParser::s_isCodeBlock = false;
+float MDParser::s_codeStart = 0;
+decltype(MDParser::s_codeSpans) MDParser::s_codeSpans = {};
+
+void MDTextArea::updateLabel() {
+    m_renderer->begin(m_content, CCPointZero, m_size);
+
+    m_renderer->pushFont(g_mdFont);
+    m_renderer->pushScale(.5f);
+    m_renderer->pushVerticalAlign(TextAlignment::End);
+    m_renderer->pushHorizontalAlign(TextAlignment::Begin);
+
+    MD_PARSER parser;
+
+    parser.abi_version = 0;
+    parser.flags = MD_FLAG_UNDERLINE |
+                   MD_FLAG_STRIKETHROUGH |
+                   MD_FLAG_PERMISSIVEURLAUTOLINKS |
+                   MD_FLAG_PERMISSIVEWWWAUTOLINKS;
+
+    parser.text = &MDParser::parseText;
+    parser.enter_block = &MDParser::enterBlock;
+    parser.leave_block = &MDParser::leaveBlock;
+    parser.enter_span = &MDParser::enterSpan;
+    parser.leave_span = &MDParser::leaveSpan;
+    parser.debug_log = nullptr;
+    parser.syntax = nullptr;
+    
+    MDParser::s_codeSpans = {};
+
+    if (md_parse(m_text.c_str(), m_text.size(), &parser, this)) {
+        m_renderer->renderString("Error parsing Markdown");
+    }
+
+    for (auto& render : MDParser::s_codeSpans) {
+        auto bg = CCScale9Sprite::create(
+            "square02b_001.png", { 0.0f, 0.0f, 80.0f, 80.0f }
+        );
+        bg->setScale(.125f);
+        bg->setColor({ 0, 0, 0 });
+        bg->setOpacity(75);
+        bg->setContentSize(render.m_node->getScaledContentSize() * 8 + CCSize { 20.f, .0f });
+        bg->setPosition(
+            render.m_node->getPositionX() - 2.5f * (.5f - render.m_node->getAnchorPoint().x),
+            render.m_node->getPositionY() - .5f
+        );
+        bg->setAnchorPoint(render.m_node->getAnchorPoint());
+        bg->setZOrder(-1);
+        m_content->addChild(bg);
+        // i know what you're thinking.
+        // my brother in christ, what the hell is this?
+        // where did this magical + 1.5f come from?
+        // the reason is that if you remove them, code 
+        // spans are slightly offset and it triggers my 
+        // OCD.
+        render.m_node->setPositionY(render.m_node->getPositionY() + 1.5f);
+    }
+    
+    m_renderer->end();
+
+    m_scrollLayer->m_contentLayer->setContentSize(m_content->getContentSize());
+    m_scrollLayer->moveToTop();
+}
+
+CCScrollLayerExt* MDTextArea::getScrollLayer() const {
+    return m_scrollLayer;
+}
+
+void MDTextArea::setString(const char* text) {
+    this->m_text = text;
+    this->updateLabel();
+}
+
+const char* MDTextArea::getString() {
+    return this->m_text.c_str();
+}
+
+MDTextArea* MDTextArea::create(
+    std::string const& str,
+    CCSize const& size
+) {
+    auto ret = new MDTextArea;
+    if (ret && ret->init(str, size)) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
diff --git a/loader/src/ui/nodes/MenuInputNode.cpp b/loader/src/ui/nodes/MenuInputNode.cpp
new file mode 100644
index 00000000..5ef260e1
--- /dev/null
+++ b/loader/src/ui/nodes/MenuInputNode.cpp
@@ -0,0 +1,51 @@
+#include <Geode/ui/MenuInputNode.hpp>
+
+USE_GEODE_NAMESPACE();
+
+bool MenuInputNode::init(
+    float width, float height, const char* placeholder, const char* fontPath, bool bg
+) {
+    if (!CCMenuItem::init())
+        return false;
+    
+    if (bg) {
+        m_bgSprite = cocos2d::extension::CCScale9Sprite::create(
+            "square02b_001.png", { 0.0f, 0.0f, 80.0f, 80.0f }
+        );
+        m_bgSprite->setScale(.5f);
+        m_bgSprite->setColor({ 0, 0, 0 });
+        m_bgSprite->setOpacity(75);
+        m_bgSprite->setContentSize({ width * 2, height * 2 });
+        this->addChild(m_bgSprite);
+    }
+
+    this->setContentSize({ width, height });
+    this->setAnchorPoint({ .5f, .5f });
+    m_input = CCTextInputNode::create(width, height, placeholder, fontPath);
+    m_input->setPosition(width / 2, height / 2);
+    this->addChild(m_input);
+    
+    this->setEnabled(true);
+
+    return true;
+}
+
+MenuInputNode* MenuInputNode::create(
+    float width, float height, const char* placeholder, const char* fontPath, bool bg
+) {
+    auto ret = new MenuInputNode;
+    if (ret && ret->init(width, height, placeholder, fontPath, bg)) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
+void MenuInputNode::selected() {
+    m_input->onClickTrackNode(true);
+}
+
+CCTextInputNode* MenuInputNode::getInput() const {
+    return m_input;
+}
diff --git a/loader/src/ui/nodes/Notification.cpp b/loader/src/ui/nodes/Notification.cpp
new file mode 100644
index 00000000..55c38bab
--- /dev/null
+++ b/loader/src/ui/nodes/Notification.cpp
@@ -0,0 +1,474 @@
+#include <Geode/ui/Notification.hpp>
+#include <Geode/ui/TextRenderer.hpp>
+#include <Geode/utils/WackyGeodeMacros.hpp>
+
+USE_GEODE_NAMESPACE();
+
+// todo: make sure notifications dont disappear 
+// off the screen if the user happens to switch 
+// scenes or smth that causes actions from being 
+// run / completed
+
+Notification::Notification() {}
+
+Notification::~Notification() {
+    CCDirector::sharedDirector()->getTouchDispatcher()->decrementForcePrio(2);
+}
+
+void Notification::registerWithTouchDispatcher() {
+    CCDirector::sharedDirector()->getTouchDispatcher()->addTargetedDelegate(
+        this,
+        0,
+        true
+    );
+}
+
+static bool isHovered(CCNode* node, CCTouch* touch) {
+    auto csize = node->getScaledContentSize();
+    if (
+        CCRect {
+            node->getPositionX() - csize.width / 2,
+            node->getPositionY() - csize.height / 2,
+            csize.width,
+            csize.height
+        }.containsPoint(touch->getLocation())
+    ) {
+        return true;
+    }
+    return false;
+}
+
+static bool shouldHideNotification(
+    CCTouch* touch, NotificationLocation const& location
+) {
+    static constexpr const float HIDE_THRESHOLD = 20.f;
+    auto dist = touch->getLocation() - touch->getStartLocation();
+    switch (location) {
+        case NotificationLocation::BottomLeft:
+        case NotificationLocation::TopLeft:
+            return dist.x < -HIDE_THRESHOLD;
+
+        case NotificationLocation::BottomRight:
+        case NotificationLocation::TopRight:
+            return dist.x > HIDE_THRESHOLD;
+
+        case NotificationLocation::BottomCenter:
+            return dist.y < -HIDE_THRESHOLD;
+
+        case NotificationLocation::TopCenter:
+            return dist.y > HIDE_THRESHOLD;
+    }
+    return false;
+}
+
+void Notification::ccTouchMoved(CCTouch* touch, CCEvent* event) {
+    auto dist = touch->getLocation() - touch->getStartLocation();
+    switch (m_location) {
+        case NotificationLocation::BottomLeft:
+        case NotificationLocation::TopLeft:
+            this->setPositionX(m_posAtTouchStart.x + dist.x);
+            if (this->getPositionX() > m_showDest.x) {
+                this->setPositionX(m_showDest.x);
+            }
+            break;
+            
+        case NotificationLocation::BottomRight:
+        case NotificationLocation::TopRight:
+            this->setPositionX(m_posAtTouchStart.x + dist.x);
+            if (this->getPositionX() < m_showDest.x) {
+                this->setPositionX(m_showDest.x);
+            }
+            break;
+
+        case NotificationLocation::BottomCenter:
+            this->setPositionY(m_posAtTouchStart.y + dist.y);
+            if (this->getPositionY() > m_showDest.y) {
+                this->setPositionY(m_showDest.y);
+            }
+            break;
+
+        case NotificationLocation::TopCenter:
+            this->setPositionY(m_posAtTouchStart.y + dist.y);
+            if (this->getPositionY() < m_showDest.y) {
+                this->setPositionY(m_showDest.y);
+            }
+            break;
+    }
+    auto clicking = !shouldHideNotification(touch, m_location);
+    if (m_clicking != clicking) {
+        m_clicking = clicking;
+        this->animateClicking();
+    }
+    auto hovered = isHovered(this, touch);
+    if (m_hovered != hovered) {
+        m_hovered = hovered;
+        if (hovered) {
+            m_bg->setColor({ 150, 150, 150 });
+        } else {
+            m_bg->setColor({ 255, 255, 255 });
+        }
+        this->animateClicking();
+    }
+}
+
+bool Notification::ccTouchBegan(CCTouch* touch, CCEvent* event) {
+    if (!isHovered(this, touch)) {
+        return false;
+    }
+    m_bg->setColor({ 150, 150, 150 });
+    this->stopAllActions();
+    m_posAtTouchStart = this->getPosition();
+    m_clicking = true;
+    m_hovered = true;
+    this->animateClicking();
+    return true;
+}
+
+void Notification::ccTouchEnded(CCTouch* touch, CCEvent* event) {
+    m_clicking = false;
+    m_hovered = false;
+    this->animateClicking();
+    m_bg->setColor({ 255, 255, 255 });
+    if (shouldHideNotification(touch, m_location)) {
+        return this->hide();
+    }
+    if (isHovered(this, touch)) {
+        this->animateIn();
+        this->clicked();
+    }
+}
+
+void Notification::clicked() {
+    if (m_callback) {
+        m_callback(this);
+        if (m_hideOnClicked) {
+            this->animateOutClicked();
+        }
+    }
+}
+
+bool Notification::init(
+    Mod* owner,
+    std::string const& title,
+    std::string const& text,
+    CCNode* icon,
+    const char* bg,
+    std::function<void(Notification*)> callback,
+    bool hideOnClick
+) {
+    if (!CCLayer::init())
+        return false;
+    
+    m_owner = owner;
+    m_callback = callback;
+    m_hideOnClicked = hideOnClick;
+
+    // m_labels is Ref so no need to call 
+    // retain manually
+    m_labels = CCArray::create();
+
+    m_bg = CCScale9Sprite::create(bg);
+    m_bg->setScale(.6f);
+
+    // using TextRenderer to create the text 
+    // so it automatically wraps the lines
+    auto renderer = TextRenderer::create();
+    renderer->begin(this, CCPointZero, { 120.f, 20.f });
+
+    renderer->pushBMFont("chatFont.fnt");
+    renderer->pushScale(.4f);
+    for (auto& label : renderer->renderString(
+        text + "\n(from " + owner->getName() + ")"
+    )) {
+        m_labels->addObject(label.m_node);
+    }
+
+    renderer->end();
+    renderer->release();
+
+    // add icon
+    float iconSpace = .0f;
+    if (icon) {
+        m_icon = icon;
+        iconSpace = 20.f;
+        m_icon->setPosition({
+            -m_obContentSize.width / 2 + iconSpace / 2,
+            .0f
+        });
+        limitNodeSize(m_icon,
+            { iconSpace - 8.f, m_obContentSize.height - 8.f },
+            1.f, .1f
+        );
+        this->addChild(m_icon);
+    }
+
+    // add title
+    if (title.size()) {
+        m_title = CCLabelBMFont::create(title.c_str(), "goldFont.fnt");
+        m_title->limitLabelWidth(m_obContentSize.width - iconSpace, .4f, .01f);
+        m_obContentSize.height += 14;
+        m_title->setPosition(
+            -m_obContentSize.width / 2 + iconSpace,
+            m_obContentSize.height / 2 - 6.f
+        );
+        m_title->setAnchorPoint({ .0f, .5f });
+        this->addChild(m_title);
+    }
+
+    // move text content if an icon is present
+    m_obContentSize.width += iconSpace;
+    m_icon->setPositionX(m_icon->getPositionX() - iconSpace / 2);
+    m_title->setPositionX(m_title->getPositionX() - iconSpace / 2);
+    CCARRAY_FOREACH_B_TYPE(m_labels, label, CCNode) {
+        label->setPosition(
+            label->getPositionX() + iconSpace - m_obContentSize.width / 2,
+            label->getPositionY() - m_obContentSize.height / 2 + 2.f
+        );
+    }
+
+    // fit bg to content
+    m_bg->setContentSize(
+        m_obContentSize / m_bg->getScale() + CCSize { 6.f, 6.f }
+    );
+    m_bg->setPosition(0, 0);
+    m_bg->setZOrder(-1);
+    this->addChild(m_bg);
+
+    // set anchor point to middle so the 
+    // notification properly scales
+    this->setAnchorPoint({ .0f, .0f });
+    this->setVisible(false);
+
+    // make sure ~CCLayer properly removes 
+    // the notification from touch dispatcher
+    this->setTouchEnabled(true);
+
+    // make this notification the most important 
+    // touch fella on the screen
+    CCDirector::sharedDirector()->getTouchDispatcher()->incrementForcePrio(2);
+    this->registerWithTouchDispatcher();
+
+    return true;
+}
+
+Notification* Notification::create(
+    Mod* owner,
+    std::string const& title,
+    std::string const& text,
+    CCNode* icon,
+    const char* bg,
+    std::function<void(Notification*)> callback,
+    bool hideOnClick
+) {
+    auto ret = new Notification();
+    if (ret && ret->init(
+        owner, title, text, icon, bg, callback, hideOnClick
+    )) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
+void Notification::showForReal() {
+    if (!m_pParent) {
+        CCDirector::sharedDirector()->getRunningScene()->addChild(this);
+    }
+    SceneManager::get()->keepAcrossScenes(this);
+    // haha i am incredibly mature
+    this->setZOrder(0xB00B1E5);
+    this->setVisible(true);
+
+    static constexpr const float pad = 15.f;
+
+    float xMovement = .0f, yMovement = .0f;
+    float xStart = .0f, yStart = .0f;
+    switch (m_location) {
+        case NotificationLocation::TopLeft: {
+            xMovement = this->getScaledContentSize().width + pad * 2;
+            xStart = -this->getScaledContentSize().width / 2 - pad;
+            yStart = m_pParent->getContentSize().height
+                - pad - this->getScaledContentSize().height / 2;
+        } break;
+
+        case NotificationLocation::BottomLeft: {
+            xMovement = this->getScaledContentSize().width + pad * 2;
+            xStart = -this->getScaledContentSize().width / 2 - pad;
+            yStart = pad + this->getScaledContentSize().height / 2;
+        } break;
+
+        case NotificationLocation::TopRight: {
+            xMovement = -this->getScaledContentSize().width - pad * 2;
+            xStart = m_pParent->getContentSize().width +
+                this->getScaledContentSize().width / 2 + pad;
+            yStart = m_pParent->getContentSize().height
+                - pad - this->getScaledContentSize().height / 2;
+        } break;
+
+        case NotificationLocation::BottomRight: {
+            xMovement = -this->getScaledContentSize().width - pad * 2;
+            xStart = m_pParent->getContentSize().width +
+                this->getScaledContentSize().width / 2 + pad;
+            yStart = pad + this->getScaledContentSize().height / 2;
+        } break;
+
+        case NotificationLocation::BottomCenter: {
+            yMovement = pad * 2 + this->getScaledContentSize().height;
+            xStart = m_pParent->getContentSize().width / 2;
+            yStart = -pad - this->getScaledContentSize().height / 2;
+        } break;
+
+        case NotificationLocation::TopCenter: {
+            yMovement = -pad * 2 - this->getScaledContentSize().height;
+            xStart = m_pParent->getContentSize().width / 2;
+            yStart = m_pParent->getContentSize().height + pad +
+                this->getScaledContentSize().height / 2;
+        } break;
+    }
+
+    m_hideDest = CCPoint { xStart, yStart };
+    m_showDest = CCPoint { xStart + xMovement, yStart + yMovement };
+
+    GameSoundManager::sharedManager()->playEffect(
+        "newNotif03.ogg"_spr, 1.f, 1.f, 1.f
+    );
+
+    this->setPosition(xStart, yStart);
+    this->animateIn();
+}
+
+void Notification::hide() {
+    // if this notification has already been hidden, 
+    // don't do anything
+    if (m_hiding || !NotificationManager::get()->isInQueue(this)) {
+        return;
+    }
+    GameSoundManager::sharedManager()->playEffect(
+        "byeNotif00.ogg"_spr, 1.f, 1.f, 1.f
+    );
+    m_hiding = true;
+    this->animateOut();
+}
+
+void Notification::animateIn() {
+    this->runAction(CCEaseInOut::create(
+        CCMoveTo::create(.3f, m_showDest),
+        6.f
+    ));
+    if (m_time) {
+        this->runAction(CCSequence::create(
+            CCDelayTime::create(m_time),
+            CCCallFunc::create(this, callfunc_selector(Notification::hide)),
+            nullptr
+        ));
+    }
+}
+
+void Notification::animateOut() {
+    this->runAction(CCSequence::create(
+        CCEaseInOut::create(
+            CCMoveTo::create(.3f, { m_hideDest }),
+            6.f
+        ),
+        CCCallFunc::create(this, callfunc_selector(Notification::hidden)),
+        nullptr
+    ));
+}
+
+void Notification::animateOutClicked() {
+    this->runAction(CCSequence::create(
+        CCEaseBackIn::create(
+            CCScaleTo::create(.2f, .0f)
+        ),
+        CCCallFunc::create(this, callfunc_selector(Notification::hidden)),
+        nullptr
+    ));
+}
+
+void Notification::animateClicking() {
+    this->runAction(CCEaseInOut::create(
+        CCScaleTo::create(.1f, (
+            (m_clicking && m_hovered) ? m_targetScale * .9f : m_targetScale
+        )), 2.f
+    ));
+}
+
+void Notification::show(NotificationLocation location, float time) {
+    if (location == NotificationLocation::TopCenter) {
+        // the notification is larger at top center to 
+        // be more easily readable on mobile
+        this->setScale(1.5f);
+    } else {
+        this->setScale(1.2f);
+    }
+    m_targetScale = m_fScaleX;
+
+    m_time = time;
+    m_location = location;
+    NotificationManager::get()->push(this);
+}
+
+void Notification::hidden() {
+    NotificationManager::get()->pop(this);
+    this->removeFromParent();
+    SceneManager::get()->forget(this);
+}
+
+NotificationBuilder Notification::build() {
+    return std::move(NotificationBuilder());
+}
+
+Notification* NotificationBuilder::show() {
+    auto icon = m_iconNode;
+    if (!icon && m_icon.size()) {
+        icon = CCSprite::create(m_icon.c_str());
+        if (!icon) icon = CCSprite::createWithSpriteFrameName(m_icon.c_str());
+    }
+    auto notif = Notification::create(
+        m_owner, m_title, m_text, icon,
+        m_bg.c_str(), m_callback, m_hideOnClick
+    );
+    notif->show(m_location, m_time);
+    return notif;
+}
+
+
+bool NotificationManager::isInQueue(Notification* notification) {
+    auto location = notification->m_location;
+    if (m_notifications.count(location)) {
+        return vector_utils::contains(
+            m_notifications.at(location), Ref(notification)
+        );
+    }
+    return false;
+}
+
+void NotificationManager::push(Notification* notification) {
+    auto location = notification->m_location;
+    if (!m_notifications.count(location)) {
+        m_notifications[location] = { notification };
+        notification->showForReal();
+    } else {
+        m_notifications[location].push_back(notification);
+    }
+}
+
+void NotificationManager::pop(Notification* notification) {
+    auto location = notification->m_location;
+    if (m_notifications.count(location)) {
+        vector_utils::erase(
+            m_notifications.at(location), Ref(notification)
+        );
+        if (!m_notifications.at(location).size()) {
+            m_notifications.erase(location);
+        } else {
+            m_notifications.at(location).front()->showForReal();
+        }
+    }
+}
+
+NotificationManager* NotificationManager::get() {
+    static auto inst = new NotificationManager;
+    return inst;
+}
diff --git a/loader/src/ui/nodes/Popup.cpp b/loader/src/ui/nodes/Popup.cpp
new file mode 100644
index 00000000..236f23a8
--- /dev/null
+++ b/loader/src/ui/nodes/Popup.cpp
@@ -0,0 +1,50 @@
+#include <Geode/ui/Popup.hpp>
+
+USE_GEODE_NAMESPACE();
+
+class QuickPopup : public FLAlertLayer, public FLAlertLayerProtocol {
+protected:
+    std::function<void(FLAlertLayer*, bool)> m_selected;
+
+    void FLAlert_Clicked(FLAlertLayer* layer, bool btn2) override {
+        if (m_selected) {
+            m_selected(layer, btn2);
+        }
+    }
+
+public:
+    static QuickPopup* create(
+        const char* title,
+        std::string const& content,
+        const char* btn1,
+        const char* btn2,
+        std::function<void(FLAlertLayer*, bool)> selected
+    ) {
+        auto inst = new QuickPopup;
+        inst->m_selected = selected;
+        if (inst && inst->init(
+            inst, title, content,
+            btn1, btn2, 350.f, false, .0f
+        )) {
+            inst->autorelease();
+            return inst;
+        }
+        CC_SAFE_DELETE(inst);
+        return nullptr;
+    }
+};
+
+void geode::createQuickPopup(
+    const char* title,
+    std::string const& content,
+    const char* btn1,
+    const char* btn2,
+    std::function<void(FLAlertLayer*, bool)> selected
+) {
+    QuickPopup::create(
+        title,
+        content,
+        btn1, btn2,
+        selected
+    )->show();
+}
diff --git a/loader/src/ui/nodes/SceneManager.cpp b/loader/src/ui/nodes/SceneManager.cpp
new file mode 100644
index 00000000..91b746e0
--- /dev/null
+++ b/loader/src/ui/nodes/SceneManager.cpp
@@ -0,0 +1,39 @@
+#include <Geode/ui/SceneManager.hpp>
+#include <Geode/utils/WackyGeodeMacros.hpp>
+
+USE_GEODE_NAMESPACE();
+
+bool SceneManager::setup() {
+    m_persistedNodes = CCArray::create();
+    m_persistedNodes->retain();
+    return true;
+}
+
+SceneManager* SceneManager::get() {
+    static SceneManager* inst = nullptr;
+	if (!inst) {
+		inst = new SceneManager();
+		inst->setup();
+	}
+    return inst;
+}
+
+SceneManager::~SceneManager() {
+    m_persistedNodes->release();
+}
+
+void SceneManager::keepAcrossScenes(CCNode* node) {
+    m_persistedNodes->addObject(node);
+}
+
+void SceneManager::forget(CCNode* node) {
+    m_persistedNodes->removeObject(node);
+}
+
+void SceneManager::willSwitchToScene(CCScene* scene) {
+    CCARRAY_FOREACH_B_TYPE(m_persistedNodes, node, CCNode) {
+        // no cleanup in order to keep actions running
+        node->removeFromParentAndCleanup(false);
+        scene->addChild(node);
+    }
+}
diff --git a/loader/src/ui/nodes/ScrollLayer.cpp b/loader/src/ui/nodes/ScrollLayer.cpp
new file mode 100644
index 00000000..4fa549b3
--- /dev/null
+++ b/loader/src/ui/nodes/ScrollLayer.cpp
@@ -0,0 +1,75 @@
+#include <Geode/ui/ScrollLayer.hpp>
+#include <Geode/utils/WackyGeodeMacros.hpp>
+
+USE_GEODE_NAMESPACE();
+
+GenericContentLayer* GenericContentLayer::create(float width, float height) {
+    auto ret = new GenericContentLayer();
+    if (ret && ret->initWithColor({ 0, 0, 0, 0 }, width, height)) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
+void GenericContentLayer::setPosition(CCPoint const& pos) {
+    // CCContentLayer expect its children to 
+    // all be TableViewCells
+    CCLayerColor::setPosition(pos);
+
+    CCARRAY_FOREACH_B_TYPE(m_pChildren, child, CCNode) {
+        auto y = this->getPositionY() + child->getPositionY();
+        child->setVisible(!(
+            (m_obContentSize.height < y) ||
+            (y < -child->getContentSize().height)
+        ));
+    }
+}
+
+
+void ScrollLayer::scrollWheel(float y, float) {
+    if (m_scrollWheelEnabled) {
+        this->scrollLayer(y);
+    }
+}
+
+void ScrollLayer::enableScrollWheel(bool enable) {
+    m_scrollWheelEnabled = enable;
+}
+
+ScrollLayer::ScrollLayer(
+    CCRect const& rect,
+    bool scrollWheelEnabled,
+    bool vertical
+) : CCScrollLayerExt(rect) {
+    m_scrollWheelEnabled = scrollWheelEnabled;
+
+    m_disableVertical = !vertical;
+    m_disableHorizontal = vertical;
+    m_cutContent = true;
+
+    m_contentLayer->removeFromParent();
+    m_contentLayer = GenericContentLayer::create(
+        rect.size.width, rect.size.height
+    );
+    m_contentLayer->setAnchorPoint({ 0, 0 });
+    this->addChild(m_contentLayer);
+
+    this->setMouseEnabled(true);
+    this->setTouchEnabled(true);
+}
+
+ScrollLayer* ScrollLayer::create(CCRect const& rect, bool scroll, bool vertical) {
+    auto ret = new ScrollLayer(rect, scroll, vertical);
+    if (ret) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
+ScrollLayer* ScrollLayer::create(CCSize const& size, bool scroll, bool vertical) {
+    return ScrollLayer::create({ 0, 0, size.width, size.height }, scroll, vertical);
+}
diff --git a/loader/src/ui/nodes/Scrollbar.cpp b/loader/src/ui/nodes/Scrollbar.cpp
new file mode 100644
index 00000000..c7e3dfd1
--- /dev/null
+++ b/loader/src/ui/nodes/Scrollbar.cpp
@@ -0,0 +1,201 @@
+#include <Geode/ui/Scrollbar.hpp>
+
+// TODO: die
+#undef min
+#undef max
+
+USE_GEODE_NAMESPACE();
+
+// bool Scrollbar::mouseDownExt(MouseEvent, cocos2d::CCPoint const& mpos) {
+//     if (!m_target) return false;
+
+//     ExtMouseDispatcher::get()->attainCapture(this);
+
+//     auto pos = this->convertToNodeSpace(mpos);
+
+//     auto contentHeight = m_target->m_contentLayer->getScaledContentSize().height;
+//     auto targetHeight = m_target->getScaledContentSize().height;
+
+//     auto h = contentHeight - targetHeight + m_target->m_scrollLimitTop;
+//     auto p = targetHeight / contentHeight;
+
+//     auto thumbHeight = m_resizeThumb ? std::min(p, 1.f) * targetHeight / .4f : 0;
+
+//     auto posY = h * (
+//         (-pos.y - targetHeight / 2 + thumbHeight / 4 - 5) /
+//         (targetHeight - thumbHeight / 2 + 10)
+//     );
+
+//     if (posY > 0.0f) posY = 0.0f;
+//     if (posY < -h) posY = -h;
+    
+//     auto offsetY = m_target->m_contentLayer->getPositionY() - posY;
+
+//     return true;
+// }
+
+// bool Scrollbar::mouseUpExt(MouseEvent, cocos2d::CCPoint const&) {
+//     ExtMouseDispatcher::get()->releaseCapture(this);
+//     return true;
+// }
+
+// void Scrollbar::mouseMoveExt(cocos2d::CCPoint const& mpos) {
+//     if (!m_target || !ExtMouseDispatcher::get()->isCapturing(this)) return;
+
+//     if (this->m_extMouseDown.size()) {
+//         auto pos = this->convertToNodeSpace(mpos);
+
+//         auto contentHeight = m_target->m_contentLayer->getScaledContentSize().height;
+//         auto targetHeight = m_target->getScaledContentSize().height;
+        
+//         auto h = contentHeight - targetHeight + m_target->m_scrollLimitTop;
+//         auto p = targetHeight / contentHeight;
+
+//         auto thumbHeight = m_resizeThumb ? std::min(p, 1.f) * targetHeight / .4f : 0;
+
+//         auto posY = h * (
+//             (-pos.y - targetHeight / 2 + thumbHeight / 4 - 5) /
+//             (targetHeight - thumbHeight / 2 + 10)
+//         );
+
+//         if (posY > 0.0f) posY = 0.0f;
+//         if (posY < -h) posY = -h;
+
+//         m_target->m_contentLayer->setPositionY(posY);
+//     }
+// }
+
+void Scrollbar::scrollWheel(float x, float y) {
+    if (!m_target) return;
+    m_target->scrollWheel(x, y);
+}
+
+void Scrollbar::draw() {
+    CCLayer::draw();
+
+    if (!m_target) return;
+
+    auto contentHeight = m_target->m_contentLayer->getScaledContentSize().height;
+    auto targetHeight = m_target->getScaledContentSize().height;
+    
+    if (m_trackIsRotated) {
+        m_track->setContentSize({
+            targetHeight / m_track->getScale(),
+            m_width / m_track->getScale()
+        });
+    } else {
+        m_track->setContentSize({
+            m_width / m_track->getScale(),
+            targetHeight / m_track->getScale()
+        });
+    }
+    m_track->setPosition(.0f, .0f);
+
+    this->setContentSize({ m_width, targetHeight });
+
+    auto h = contentHeight - targetHeight + m_target->m_scrollLimitTop;
+    auto p = targetHeight / contentHeight;
+
+    GLubyte o;
+    if (m_hoverHighlight) {
+        o = 100;
+        // if (m_extMouseHovered) {
+            // o = 160;
+        // }
+        // if (m_extMouseDown.size()) {
+            // o = 255;
+        // }
+    } else {
+        o = 255;
+        // if (m_extMouseDown.size()) {
+        //     o = 125;
+        // }
+    }
+    m_thumb->setColor({ o, o, o });
+
+    auto y = m_target->m_contentLayer->getPositionY();
+
+    auto thumbHeight = m_resizeThumb ? std::min(p, 1.f) * targetHeight / .4f : 0;
+    auto thumbPosY = - targetHeight / 2 + thumbHeight / 4 - 5.0f + 
+        (h ? (-y) / h : 1.f) * (targetHeight - thumbHeight / 2 + 10.0f);
+
+    auto fHeightTop = [&]() -> float {
+        return thumbPosY - targetHeight / 2 + thumbHeight * .4f / 2 + 3.0f;
+    };
+    auto fHeightBottom = [&]() -> float {
+        return thumbPosY + targetHeight / 2 - thumbHeight * .4f / 2 - 3.0f;
+    };
+    
+    if (fHeightTop() > 0.0f) {
+        thumbHeight -= fHeightTop();
+        thumbPosY -= fHeightTop();
+    }
+    
+    if (fHeightBottom() < 0.f) {
+        thumbHeight += fHeightBottom();
+        thumbPosY -= fHeightBottom();
+    }
+
+    m_thumb->setPosition(0.f, thumbPosY);
+    if (m_resizeThumb) {
+        m_thumb->setContentSize({ m_width, thumbHeight });
+    }
+}
+
+void Scrollbar::setTarget(CCScrollLayerExt* target) {
+    m_target = target;
+}
+
+bool Scrollbar::init(CCScrollLayerExt* target) {
+    if (!this->CCLayer::init())
+        return false;
+    
+    m_target = target;
+
+    if (cocos::fileExistsInSearchPaths("scrollbar.png"_spr)) {
+        m_track = CCScale9Sprite::create("scrollbar.png"_spr);
+        m_track->setColor({ 0, 0, 0 });
+        m_track->setOpacity(150);
+        m_track->setScale(.8f);
+
+        m_thumb = CCScale9Sprite::create("scrollbar.png"_spr);
+        m_thumb->setScale(.4f);
+
+        m_width = 8.f;
+        m_resizeThumb = true;
+        m_trackIsRotated = false;
+        m_hoverHighlight = true;
+    } else {
+        m_track = CCScale9Sprite::create("slidergroove.png");
+        m_track->setRotation(90);
+        m_track->setScale(.8f);
+        
+        m_thumb = CCScale9Sprite::create("sliderthumb.png");
+        m_thumb->setScale(.6f);
+
+        m_width = 12.f;
+        m_resizeThumb = false;
+        m_trackIsRotated = true;
+        m_hoverHighlight = false;
+    }
+
+    this->addChild(m_track);
+    this->addChild(m_thumb);
+
+    // this->registerWithMouseDispatcher();
+
+    return true;
+}
+
+Scrollbar* Scrollbar::create(CCScrollLayerExt* target) {
+    auto ret = new Scrollbar;
+
+    if (ret && ret->init(target)) {
+        ret->autorelease();
+        return ret;
+    }
+
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
diff --git a/loader/src/ui/nodes/TextRenderer.cpp b/loader/src/ui/nodes/TextRenderer.cpp
new file mode 100644
index 00000000..ab9a7f07
--- /dev/null
+++ b/loader/src/ui/nodes/TextRenderer.cpp
@@ -0,0 +1,779 @@
+#include <Geode/ui/TextRenderer.hpp>
+#include <Geode/utils/WackyGeodeMacros.hpp>
+#undef max
+#undef min
+
+USE_GEODE_NAMESPACE();
+using namespace std::string_literals;
+
+bool TextDecorationWrapper::init(
+    TextRenderer::Label const& label,
+    int deco,
+    ccColor3B const& color,
+    GLubyte opacity
+) {
+    if (!CCNodeRGBA::init())
+        return false;
+    
+    label.m_node->removeFromParent();
+    this->addChild(label.m_node);
+
+    m_label = label;
+    m_deco = deco;
+    this->setColor(color);
+    this->setOpacity(opacity);
+
+    return true;
+}
+
+void TextDecorationWrapper::draw() {
+    // some nodes sometimes set the blend func to
+    // something else without resetting it back
+    ccGLBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
+    if (m_deco & TextDecorationUnderline) {
+        ccDrawSolidRect(
+            { 0, 0 },
+            { m_obContentSize.width, 1.f },
+            {
+                _realColor.r / 255.f,
+                _realColor.g / 255.f,
+                _realColor.b / 255.f,
+                _realOpacity / 255.f
+            }
+        );
+    }
+    if (m_deco & TextDecorationStrikethrough) {
+        ccDrawSolidRect(
+            { 0, m_obContentSize.height * .4f - .75f },
+            { m_obContentSize.width, m_obContentSize.height * .4f + .75f },
+            {
+                _realColor.r / 255.f,
+                _realColor.g / 255.f,
+                _realColor.b / 255.f,
+                _realOpacity / 255.f
+            }
+        );
+    }
+    CCNode::draw();
+}
+
+void TextDecorationWrapper::setString(const char* text) {
+    m_label.m_labelProtocol->setString(text);
+    this->setContentSize(m_label.m_node->getScaledContentSize());
+    m_label.m_node->setPosition(m_obContentSize / 2);
+}
+
+const char* TextDecorationWrapper::getString() {
+    return m_label.m_labelProtocol->getString();
+}
+
+void TextDecorationWrapper::setColor(cocos2d::ccColor3B const& color) {
+    m_label.m_rgbaProtocol->setColor(color);
+    return CCNodeRGBA::setColor(color);
+}
+
+void TextDecorationWrapper::setOpacity(GLubyte opacity) {
+    m_label.m_rgbaProtocol->setOpacity(opacity);
+    return CCNodeRGBA::setOpacity(opacity);
+}
+
+void TextDecorationWrapper::updateDisplayedColor(ccColor3B const& color) {
+    m_label.m_rgbaProtocol->updateDisplayedColor(color);
+    return CCNodeRGBA::updateDisplayedColor(color);
+}
+
+void TextDecorationWrapper::updateDisplayedOpacity(GLubyte opacity) {
+    m_label.m_rgbaProtocol->updateDisplayedOpacity(opacity);
+    return CCNodeRGBA::updateDisplayedOpacity(opacity);
+}
+
+TextDecorationWrapper* TextDecorationWrapper::create(
+    TextRenderer::Label const& label,
+    int deco,
+    ccColor3B const& color,
+    GLubyte opacity
+) {
+    auto ret = new TextDecorationWrapper;
+    if (ret && ret->init(label, deco, color, opacity)) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
+TextDecorationWrapper* TextDecorationWrapper::wrap(
+    TextRenderer::Label const& label,
+    int deco,
+    ccColor3B const& color,
+    GLubyte opacity
+) {
+    auto pos = label.m_node->getPosition();
+    auto wrapper = TextDecorationWrapper::create(label, deco, color, opacity);
+    wrapper->setPosition(pos);
+    return wrapper;
+}
+
+
+bool TextLinkedButtonWrapper::init(
+    TextRenderer::Label const& label,
+    cocos2d::CCObject* target,
+    cocos2d::SEL_MenuHandler handler
+) {
+    if (!CCMenuItemSprite::initWithNormalSprite(label.m_node, nullptr, nullptr, target, handler))
+        return false;
+
+    m_label = label;
+    
+    label.m_node->removeFromParent();
+    this->addChild(label.m_node);
+
+    this->setCascadeColorEnabled(true);
+    this->setCascadeOpacityEnabled(true);
+
+    return true;
+}
+
+void TextLinkedButtonWrapper::link(TextLinkedButtonWrapper* other) {
+    if (this != other) {
+        m_linked.push_back(other);
+    }
+}
+
+void TextLinkedButtonWrapper::setString(const char* text) {
+    m_label.m_labelProtocol->setString(text);
+    this->setContentSize(m_label.m_node->getScaledContentSize());
+    m_label.m_node->setPosition(m_obContentSize * m_label.m_node->getAnchorPoint());
+}
+
+const char* TextLinkedButtonWrapper::getString() {
+    return m_label.m_labelProtocol->getString();
+}
+
+void TextLinkedButtonWrapper::setColor(cocos2d::ccColor3B const& color) {
+    m_label.m_rgbaProtocol->setColor(color);
+    return CCMenuItemSprite::setColor(color);
+}
+
+void TextLinkedButtonWrapper::setOpacity(GLubyte opacity) {
+    m_label.m_rgbaProtocol->setOpacity(opacity);
+    return CCMenuItemSprite::setOpacity(opacity);
+}
+
+void TextLinkedButtonWrapper::updateDisplayedColor(ccColor3B const& color) {
+    m_label.m_rgbaProtocol->updateDisplayedColor(color);
+    return CCMenuItemSprite::updateDisplayedColor(color);
+}
+
+void TextLinkedButtonWrapper::updateDisplayedOpacity(GLubyte opacity) {
+    m_label.m_rgbaProtocol->updateDisplayedOpacity(opacity);
+    return CCMenuItemSprite::updateDisplayedOpacity(opacity);
+}
+
+void TextLinkedButtonWrapper::selectedWithoutPropagation(bool selected) {
+    if (selected) {
+        m_opacity = this->getOpacity();
+        m_color = this->getColor();
+        this->setOpacity(150);
+        this->setColor({ 255, 255, 255 });
+    } else {
+        this->setOpacity(m_opacity);
+        this->setColor(m_color);
+    }
+}
+
+void TextLinkedButtonWrapper::selected() {
+    this->selectedWithoutPropagation(true);
+    for (auto& node : m_linked) {
+        node->selectedWithoutPropagation(true);
+    }
+}
+
+void TextLinkedButtonWrapper::unselected() {
+    this->selectedWithoutPropagation(false);
+    for (auto& node : m_linked) {
+        node->selectedWithoutPropagation(false);
+    }
+}
+
+TextLinkedButtonWrapper* TextLinkedButtonWrapper::create(
+    TextRenderer::Label const& label,
+    cocos2d::CCObject* target,
+    cocos2d::SEL_MenuHandler handler
+) {
+    auto ret = new TextLinkedButtonWrapper;
+    if (ret && ret->init(label, target, handler)) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+
+TextLinkedButtonWrapper* TextLinkedButtonWrapper::wrap(
+    TextRenderer::Label const& label,
+    cocos2d::CCObject* target,
+    cocos2d::SEL_MenuHandler handler
+) {
+    auto pos = label.m_node->getPosition();
+    auto anchor = label.m_node->getAnchorPoint();
+    auto wrapper = TextLinkedButtonWrapper::create(label, target, handler);
+    wrapper->setPosition(pos - wrapper->getScaledContentSize() * anchor);
+    return wrapper;
+}
+
+
+bool TextRenderer::init() {
+    return true;
+}
+
+void TextRenderer::begin(CCNode* target, CCPoint const& pos, CCSize const& size) {
+    m_target = target ? target : CCNode::create();
+    m_target->setContentSize(size);
+    m_target->setPosition(pos);
+    m_target->removeAllChildren();
+    m_cursor = CCPointZero;
+    m_origin = pos;
+    m_size = size;
+}
+
+CCNode* TextRenderer::end(
+    bool fitToContent,
+    TextAlignment horizontalAlign,
+    TextAlignment verticalAlign
+) {
+    // adjust vertical alignments of last line
+    this->adjustLineAlignment();
+    // resize target
+    if (fitToContent && m_target) {
+        // figure out area covered by target children
+        auto coverage = calculateChildCoverage(m_target);
+        // convert area from rect to size
+        auto renderedWidth = -coverage.origin.x + coverage.size.width;
+        auto renderedHeight = -coverage.origin.y + coverage.size.height;
+        // target size is always at least requested size
+        m_target->setContentSize({
+            std::max(renderedWidth, m_size.width),
+            std::max(renderedHeight, m_size.height)
+        });
+        // calculate paddings
+        float padX;
+        float padY;
+        switch (horizontalAlign) {
+            default:
+            case TextAlignment::Begin:
+                padX = .0f; break;
+            case TextAlignment::Center:
+                padX = std::max(m_size.width - renderedWidth, 0.f) / 2; break;
+            case TextAlignment::End:
+                padX = std::max(m_size.width - renderedWidth, 0.f); break;
+        }
+        switch (verticalAlign) {
+            default:
+            case TextAlignment::Begin:
+                padY = .0f; break;
+            case TextAlignment::Center:
+                padY = std::max(m_size.height - renderedHeight, 0.f) / 2; break;
+            case TextAlignment::End:
+                padY = std::max(m_size.height - renderedHeight, 0.f); break;
+        }
+        // adjust child positions
+        CCARRAY_FOREACH_B_TYPE(m_target->getChildren(), child, CCNode) {
+            child->setPosition(
+                child->getPositionX() + padX,
+                child->getPositionY() +
+                    m_target->getContentSize().height - 
+                    coverage.size.height - padY
+            );
+        }
+    }
+
+    // clear stacks
+    m_fontStack.clear();
+    m_scaleStack.clear();
+    m_styleStack.clear();
+    m_colorStack.clear();
+    m_opacityStack.clear();
+    m_decorationStack.clear();
+    m_capsStack.clear();
+    m_lastRendered.clear();
+    m_indentationStack.clear();
+    m_wrapOffsetStack.clear();
+    m_hAlignmentStack.clear();
+    m_vAlignmentStack.clear();
+
+    // reset
+    m_cursor = CCPointZero;
+    m_size = CCSizeZero;
+    auto ret = m_target;
+    m_target = nullptr;
+    m_lastRendered = {};
+    CC_SAFE_RELEASE_NULL(m_lastRenderedNode);
+    m_renderedLine.clear();
+
+    return ret;
+}
+
+void TextRenderer::moveCursor(CCPoint const& pos) {
+    m_cursor = pos;
+}
+
+CCPoint const& TextRenderer::getCursorPos() {
+    return m_cursor;
+}
+
+bool TextRenderer::render(std::string const& word, CCNode* to, CCLabelProtocol* label) {
+    auto origLabelStr = label->getString();
+    auto str = ((origLabelStr && strlen(origLabelStr)) ?
+        origLabelStr : ""
+    ) + word;
+    if (m_size.width) {
+        std::string orig = origLabelStr;
+        label->setString(str.c_str());
+        if (
+            m_cursor.x + to->getScaledContentSize().width >
+            m_size.width - this->getCurrentWrapOffset()
+        ) {
+            label->setString(orig.c_str());
+            return false;
+        }
+        return true;
+    } else {
+        label->setString(str.c_str());
+        return true;
+    }
+}
+
+TextRenderer::Label TextRenderer::addWrappers(
+    Label const& label,
+    bool isButton,
+    CCObject* target,
+    SEL_MenuHandler callback
+) {
+    Label ret = label;
+    if (this->getCurrentDeco() != TextDecorationNone) {
+        auto wrapper = TextDecorationWrapper::wrap(
+            ret, this->getCurrentDeco(), this->getCurrentColor(), 
+            this->getCurrentOpacity()
+        );
+        ret = Label(wrapper);
+    }
+    if (isButton) {
+        auto wrapper = TextLinkedButtonWrapper::wrap(
+            ret, target, callback
+        );
+        ret = Label(wrapper);
+    }
+    ret.m_lineHeight = label.m_lineHeight;
+    return ret;
+}
+
+std::vector<TextRenderer::Label> TextRenderer::renderStringEx(
+    std::string const& str,
+    Font font,
+    float scale,
+    ccColor3B color,
+    GLubyte opacity,
+    int style,
+    int deco,
+    TextCapitalization caps,
+    bool addToTarget,
+    bool isButton,
+    CCObject* target,
+    SEL_MenuHandler callback
+) {
+    std::vector<Label> res;
+
+    if (!target) target = m_target;
+
+    Label label;
+    bool newLine = true;
+
+    auto lastIndent = m_indentationStack.size() > 1 ?
+        m_indentationStack.at(m_indentationStack.size() - 1) : .0f;
+
+    if (m_cursor.x == m_origin.x + lastIndent && this->getCurrentIndent() > .0f) {
+        m_cursor.x += this->getCurrentIndent();
+    }
+
+    auto createLabel = [&]() -> bool {
+        // create label through font and add 
+        // decorations (underline, strikethrough) + 
+        // buttonize (new word just dropped)
+        label = this->addWrappers(
+            font(style), isButton, target, callback
+        );
+
+        label.m_node->setScale(scale);
+        label.m_node->setPosition(m_cursor);
+        label.m_node->setAnchorPoint({ .0f, label.m_node->getAnchorPoint().y });
+        label.m_rgbaProtocol->setColor(color);
+        label.m_rgbaProtocol->setOpacity(opacity);
+
+        res.push_back(label);
+        m_renderedLine.push_back(label.m_node);
+        if (addToTarget) {
+            m_target->addChild(label.m_node);
+        }
+
+        return true;
+    };
+
+    auto nextLine = [&]() -> bool {
+        this->breakLine(label.m_lineHeight * scale);
+        if (!createLabel()) return false;
+        newLine = true;
+        return true;
+    };
+
+    // create initial label
+    if (!createLabel()) return {};
+
+    bool firstLine = true;
+    for (auto line : string_utils::split(str, "\n")) {
+        if (!firstLine && !nextLine()) {
+            return {};
+        }
+        firstLine = false;
+        for (auto word : string_utils::split(line, " ")) {
+            // add extra space in front of word if not on 
+            // new line
+            if (!newLine) word = " " + word;
+            newLine = false;
+
+            // update capitalization
+            switch (caps) {
+                case TextCapitalization::AllUpper: string_utils::toUpperIP(word); break;
+                case TextCapitalization::AllLower: string_utils::toLowerIP(word); break;
+                default: break;
+            }
+
+            // try to render at the end of current line
+            if (this->render(word, label.m_node, label.m_labelProtocol)) continue;
+
+            // try to create a new line
+            if (!nextLine()) return {};
+
+            if (string_utils::startsWith(word, " ")) word = word.substr(1);
+            newLine = false;
+
+            // try to render on new line
+            if (this->render(word, label.m_node, label.m_labelProtocol)) continue;
+
+            // no need to create a new line as we know 
+            // the current one has no content and is 
+            // supposed to receive this one
+
+            // render character by character
+            for (auto& c : word) {
+                if (
+                    !this->render(std::string(1, c), label.m_node, label.m_labelProtocol)
+                ) {
+                    if (!nextLine()) return {};
+                    
+                    if (string_utils::startsWith(word, " ")) word = word.substr(1);
+                    newLine = false;
+                }
+            }
+        }
+        // increment cursor position
+        m_cursor.x += label.m_node->getScaledContentSize().width;
+    }
+
+    if (isButton) {
+        for (auto& btn : res) for (auto& b : res) {
+            if (btn.m_node != b.m_node) {
+                as<TextLinkedButtonWrapper*>(btn.m_node)->link(
+                    as<TextLinkedButtonWrapper*>(b.m_node)
+                );
+            }
+        }
+    }
+
+    CC_SAFE_RELEASE_NULL(m_lastRenderedNode);
+    m_lastRendered = res;
+
+    return res;
+}
+
+std::vector<TextRenderer::Label> TextRenderer::renderString(std::string const& str) {
+    return this->renderStringEx(
+        str,
+        this->getCurrentFont(),
+        this->getCurrentScale(),
+        this->getCurrentColor(),
+        this->getCurrentOpacity(),
+        this->getCurrentStyle(),
+        this->getCurrentDeco(),
+        this->getCurrentCaps(),
+        true, false,
+        nullptr, nullptr
+    );
+}
+
+std::vector<TextRenderer::Label> TextRenderer::renderStringInteractive(
+    std::string const& str,
+    CCObject* target,
+    SEL_MenuHandler callback
+) {
+    return this->renderStringEx(
+        str,
+        this->getCurrentFont(),
+        this->getCurrentScale(),
+        this->getCurrentColor(),
+        this->getCurrentOpacity(),
+        this->getCurrentStyle(),
+        this->getCurrentDeco(),
+        this->getCurrentCaps(),
+        true, true,
+        target, callback
+    );
+}
+
+CCNode* TextRenderer::renderNode(CCNode* node) {
+    m_cursor.x += node->getScaledContentSize().width * node->getAnchorPoint().x;
+    node->setPosition(m_cursor);
+    m_target->addChild(node);
+    m_cursor.x += node->getScaledContentSize().width * (1.f - node->getAnchorPoint().x);
+    m_lastRendered.clear();
+    CC_SAFE_RELEASE(m_lastRenderedNode);
+    m_lastRenderedNode = node;
+    CC_SAFE_RETAIN(m_lastRenderedNode);
+    m_renderedLine.push_back(node);
+    return node;
+}
+
+void TextRenderer::breakLine(float incY) {
+    auto h = this->adjustLineAlignment();
+    m_renderedLine.clear();
+    float y = incY;
+    if (!y && m_fontStack.size()) {
+        y = m_fontStack.back()(this->getCurrentStyle()).m_lineHeight * this->getCurrentScale();
+        if (!y && m_lastRendered.size()) {
+            y = as<CCNode*>(m_lastRendered.back().m_node)->getScaledContentSize().height;
+        }
+        if (!y && m_lastRenderedNode) {
+            y = m_lastRenderedNode->getScaledContentSize().height;
+        }
+    }
+    if (h > y) y = h;
+    m_cursor.y -= y;
+    m_cursor.x = m_origin.x + getCurrentIndent();
+}
+
+float TextRenderer::adjustLineAlignment() {
+    auto coverage = calculateNodeCoverage(m_renderedLine);
+    auto maxWidth = -coverage.origin.x + coverage.size.width;
+    auto maxHeight = .0f;
+    for (auto& node : m_renderedLine) {
+        if (node->getScaledContentSize().height > maxHeight) {
+            maxHeight = node->getScaledContentSize().height;
+        }
+    }
+    for (auto& node : m_renderedLine) {
+        auto height = node->getScaledContentSize().height;
+        auto anchor = node->getAnchorPoint().y;
+        switch (this->getCurrentVerticalAlign()) {
+            case TextAlignment::Begin: default: {
+                node->setPositionY(m_cursor.y - height * (1.f - anchor));
+            } break;
+
+            case TextAlignment::Center: {
+                node->setPositionY(m_cursor.y - maxHeight / 2 + height * (.5f - anchor));
+            } break;
+
+            case TextAlignment::End: {
+                node->setPositionY(m_cursor.y - maxHeight + height * anchor);
+            } break;
+        }
+        switch (this->getCurrentHorizontalAlign()) {
+            case TextAlignment::Begin: default: {
+                // already correct
+            } break;
+
+            case TextAlignment::Center: {
+                node->setPositionX(
+                    node->getPositionX() + (m_size.width - maxWidth) / 2
+                );
+            } break;
+
+            case TextAlignment::End: {
+                node->setPositionX(
+                    node->getPositionX() + m_size.width - maxWidth - this->getCurrentIndent()
+                );
+            } break;
+        }
+    }
+    return maxHeight;
+}
+
+void TextRenderer::pushBMFont(const char* bmFont) {
+    m_fontStack.push_back([bmFont](int) -> Label {
+        return CCLabelBMFont::create("", bmFont);
+    });
+}
+
+void TextRenderer::pushFont(Font const& font) {
+    m_fontStack.push_back(font);
+}
+
+void TextRenderer::popFont() {
+    if (m_fontStack.size()) m_fontStack.pop_back();
+}
+
+TextRenderer::Font TextRenderer::getCurrentFont() const {
+    if (!m_fontStack.size()) {
+        return [](int) -> Label {
+            return CCLabelBMFont::create("", "bigFont.fnt");
+        };
+    }
+    return m_fontStack.back();
+}
+
+void TextRenderer::pushScale(float scale) {
+    m_scaleStack.push_back(scale);
+}
+
+void TextRenderer::popScale() {
+    if (m_scaleStack.size()) m_scaleStack.pop_back();
+}
+
+float TextRenderer::getCurrentScale() const {
+    return m_scaleStack.size() ? m_scaleStack.back() : 1.f;
+}
+
+void TextRenderer::pushStyleFlags(int style) {
+    int oldStyle = TextStyleRegular;
+    if (m_styleStack.size()) oldStyle = m_styleStack.back();
+    m_styleStack.push_back(oldStyle | style);
+}
+
+void TextRenderer::popStyleFlags() {
+    if (m_styleStack.size()) m_styleStack.pop_back();
+}
+
+int TextRenderer::getCurrentStyle() const {
+    return m_styleStack.size() ? m_styleStack.back() : TextStyleRegular;
+}
+
+void TextRenderer::pushColor(ccColor3B const& color) {
+    m_colorStack.push_back(color);
+}
+
+void TextRenderer::popColor() {
+    if (m_colorStack.size()) m_colorStack.pop_back();
+}
+
+ccColor3B TextRenderer::getCurrentColor() const {
+    return m_colorStack.size() ? m_colorStack.back() : ccColor3B { 255, 255, 255 };
+}
+
+void TextRenderer::pushOpacity(GLubyte opacity) {
+    m_opacityStack.push_back(opacity);
+}
+
+void TextRenderer::popOpacity() {
+    if (m_opacityStack.size()) m_opacityStack.pop_back();
+}
+
+GLubyte TextRenderer::getCurrentOpacity() const {
+    return m_opacityStack.size() ? m_opacityStack.back() : 255;
+}
+
+void TextRenderer::pushDecoFlags(int deco) {
+    int oldDeco = TextDecorationNone;
+    if (m_decorationStack.size()) oldDeco = m_decorationStack.back();
+    m_decorationStack.push_back(oldDeco | deco);
+}
+
+void TextRenderer::popDecoFlags() {
+    if (m_decorationStack.size()) m_decorationStack.pop_back();
+}
+
+int TextRenderer::getCurrentDeco() const {
+    return m_decorationStack.size() ? m_decorationStack.back() : TextDecorationNone;
+}
+
+void TextRenderer::pushCaps(TextCapitalization caps) {
+    m_capsStack.push_back(caps);
+}
+
+void TextRenderer::popCaps() {
+    if (m_capsStack.size()) m_capsStack.pop_back();
+}
+
+TextCapitalization TextRenderer::getCurrentCaps() const {
+    return m_capsStack.size() ? m_capsStack.back() : TextCapitalization::Normal;
+}
+
+void TextRenderer::pushIndent(float indent) {
+    m_indentationStack.push_back(indent);
+}
+
+void TextRenderer::popIndent() {
+    if (m_indentationStack.size()) m_indentationStack.pop_back();
+}
+
+float TextRenderer::getCurrentIndent() const {
+    float res = .0f;
+    for (auto& indent : m_indentationStack) {
+        res += indent;
+    }
+    return res;
+}
+
+void TextRenderer::pushWrapOffset(float wrapOffset) {
+    m_wrapOffsetStack.push_back(wrapOffset);
+}
+
+void TextRenderer::popWrapOffset() {
+    if (m_wrapOffsetStack.size()) m_wrapOffsetStack.pop_back();
+}
+
+float TextRenderer::getCurrentWrapOffset() const {
+    float res = .0f;
+    for (auto& offset : m_wrapOffsetStack) {
+        res += offset;
+    }
+    return res;
+}
+
+void TextRenderer::pushVerticalAlign(TextAlignment align) {
+    m_vAlignmentStack.push_back(align);
+}
+
+void TextRenderer::popVerticalAlign() {
+    if (m_vAlignmentStack.size()) m_vAlignmentStack.pop_back();
+}
+
+TextAlignment TextRenderer::getCurrentVerticalAlign() const {
+    return m_vAlignmentStack.size() ? m_vAlignmentStack.back() : TextAlignment::Center;
+}
+
+void TextRenderer::pushHorizontalAlign(TextAlignment align) {
+    m_hAlignmentStack.push_back(align);
+}
+
+void TextRenderer::popHorizontalAlign() {
+    if (m_hAlignmentStack.size()) m_hAlignmentStack.pop_back();
+}
+
+TextAlignment TextRenderer::getCurrentHorizontalAlign() const {
+    return m_hAlignmentStack.size() ? m_hAlignmentStack.back() : TextAlignment::Begin;
+}
+
+
+TextRenderer::~TextRenderer() {
+    this->end();
+}
+
+TextRenderer* TextRenderer::create() {
+    auto ret = new TextRenderer();
+    if (ret && ret->init()) {
+        ret->autorelease();
+        return ret;
+    }
+    CC_SAFE_DELETE(ret);
+    return nullptr;
+}
+