From f52c715364b861d76512ed6e5167751f30d974bb Mon Sep 17 00:00:00 2001 From: TakaRikka Date: Tue, 28 Apr 2026 02:53:36 -0700 Subject: [PATCH 001/150] convert d_name literals to hex + enable version --- src/d/actor/d_flower.inc | 20 +++++++------- src/d/actor/d_grass.inc | 16 +++++------ src/d/d_name.cpp | 57 +++++++++++++++++++++++++++++++++++++++ src/dusk/iso_validate.cpp | 1 + src/dusk/version.cpp | 2 ++ 5 files changed, 78 insertions(+), 18 deletions(-) diff --git a/src/d/actor/d_flower.inc b/src/d/actor/d_flower.inc index 4024d5ad6a..4b78e2950d 100644 --- a/src/d/actor/d_flower.inc +++ b/src/d/actor/d_flower.inc @@ -13,7 +13,7 @@ const u16 l_J_Ohana00_64TEX__height = 63; #if TARGET_PC #include "dusk/dvd_asset.hpp" using GameVersion = dusk::version::GameVersion; -static u8* l_J_Ohana00_64TEX_get() { static u8 buf[0x800]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x9060}, {GameVersion::GcnPal, 0x9060}}, 0x800), true); return buf; } +static u8* l_J_Ohana00_64TEX_get() { static u8 buf[0x800]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x9060}, {GameVersion::GcnPal, 0x9060}, {GameVersion::GcnJpn, 0x9060}}, 0x800), true); return buf; } #define l_J_Ohana00_64TEX (l_J_Ohana00_64TEX_get()) #else #include "assets/l_J_Ohana00_64TEX.h" @@ -111,10 +111,10 @@ static u8 l_flowerTexCoord[] = { #if TARGET_PC using GameVersion = dusk::version::GameVersion; -static u8* l_J_hana00DL_get() { static u8 buf[0x150]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x9D20}, {GameVersion::GcnPal, 0x9D20}}, 0x150), true); return buf; } -static u8* l_J_hana00_cDL_get() { static u8 buf[0xDE]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x9E80}, {GameVersion::GcnPal, 0x9E80}}, 0xDE), true); return buf; } -static u8* l_matDL_get() { static u8 buf[0x99]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x9F60}, {GameVersion::GcnPal, 0x9F60}}, 0x99), true); return buf; } -static u8* l_matLight4DL_get() { static u8 buf[0x99]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0xA000}, {GameVersion::GcnPal, 0xA000}}, 0x99), true); return buf; } +static u8* l_J_hana00DL_get() { static u8 buf[0x150]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x9D20}, {GameVersion::GcnPal, 0x9D20}, {GameVersion::GcnJpn, 0x9D20}}, 0x150), true); return buf; } +static u8* l_J_hana00_cDL_get() { static u8 buf[0xDE]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x9E80}, {GameVersion::GcnPal, 0x9E80}, {GameVersion::GcnJpn, 0x9E80}}, 0xDE), true); return buf; } +static u8* l_matDL_get() { static u8 buf[0x99]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x9F60}, {GameVersion::GcnPal, 0x9F60}, {GameVersion::GcnJpn, 0x9F60}}, 0x99), true); return buf; } +static u8* l_matLight4DL_get() { static u8 buf[0x99]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0xA000}, {GameVersion::GcnPal, 0xA000}, {GameVersion::GcnJpn, 0xA000}}, 0x99), true); return buf; } #define l_J_hana00DL (l_J_hana00DL_get()) #define l_J_hana00_cDL (l_J_hana00_cDL_get()) #define l_matDL (l_matDL_get()) @@ -270,11 +270,11 @@ static u8 l_flowerTexCoord2[] = { #if TARGET_PC using GameVersion = dusk::version::GameVersion; -static u8* l_J_hana01DL_get() { static u8 buf[0x138]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0xB7C0}, {GameVersion::GcnPal, 0xB7C0}}, 0x138), true); return buf; } -static u8* l_J_hana01_c_00DL_get() { static u8 buf[0xDE]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0xB900}, {GameVersion::GcnPal, 0xB900}}, 0xDE), true); return buf; } -static u8* l_J_hana01_c_01DL_get() { static u8 buf[0x128]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0xB9E0}, {GameVersion::GcnPal, 0xB9E0}}, 0x128), true); return buf; } -static u8* l_mat2DL_get() { static u8 buf[0x99]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0xBB20}, {GameVersion::GcnPal, 0xBB20}}, 0x99), true); return buf; } -static u8* l_mat2Light4DL_get() { static u8 buf[0x99]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0xBBC0}, {GameVersion::GcnPal, 0xBBC0}}, 0x99), true); return buf; } +static u8* l_J_hana01DL_get() { static u8 buf[0x138]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0xB7C0}, {GameVersion::GcnPal, 0xB7C0}, {GameVersion::GcnJpn, 0xB7C0}}, 0x138), true); return buf; } +static u8* l_J_hana01_c_00DL_get() { static u8 buf[0xDE]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0xB900}, {GameVersion::GcnPal, 0xB900}, {GameVersion::GcnJpn, 0xB900}}, 0xDE), true); return buf; } +static u8* l_J_hana01_c_01DL_get() { static u8 buf[0x128]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0xB9E0}, {GameVersion::GcnPal, 0xB9E0}, {GameVersion::GcnJpn, 0xB9E0}}, 0x128), true); return buf; } +static u8* l_mat2DL_get() { static u8 buf[0x99]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0xBB20}, {GameVersion::GcnPal, 0xBB20}, {GameVersion::GcnJpn, 0xBB20}}, 0x99), true); return buf; } +static u8* l_mat2Light4DL_get() { static u8 buf[0x99]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0xBBC0}, {GameVersion::GcnPal, 0xBBC0}, {GameVersion::GcnJpn, 0xBBC0}}, 0x99), true); return buf; } #define l_J_hana01DL (l_J_hana01DL_get()) #define l_J_hana01_c_00DL (l_J_hana01_c_00DL_get()) #define l_J_hana01_c_01DL (l_J_hana01_c_01DL_get()) diff --git a/src/d/actor/d_grass.inc b/src/d/actor/d_grass.inc index 3d67a57007..785b05c281 100644 --- a/src/d/actor/d_grass.inc +++ b/src/d/actor/d_grass.inc @@ -21,8 +21,8 @@ const u16 l_M_kusa05_RGBATEX__height = 31; #if TARGET_PC #include "dusk/dvd_asset.hpp" using GameVersion = dusk::version::GameVersion; -static u8* l_M_kusa05_RGBATEX_get() { static u8 buf[0x800]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x7680}, {GameVersion::GcnPal, 0x7680}}, 0x800), true); return buf; } -static u8* l_M_Hijiki00TEX_get() { static u8 buf[0x800]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x7E80}, {GameVersion::GcnPal, 0x7E80}}, 0x800), true); return buf; } +static u8* l_M_kusa05_RGBATEX_get() { static u8 buf[0x800]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x7680}, {GameVersion::GcnPal, 0x7680}, {GameVersion::GcnJpn, 0x7680}}, 0x800), true); return buf; } +static u8* l_M_Hijiki00TEX_get() { static u8 buf[0x800]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x7E80}, {GameVersion::GcnPal, 0x7E80}, {GameVersion::GcnJpn, 0x7E80}}, 0x800), true); return buf; } #define l_M_kusa05_RGBATEX (l_M_kusa05_RGBATEX_get()) #define l_M_Hijiki00TEX (l_M_Hijiki00TEX_get()) #else @@ -116,12 +116,12 @@ static u8 l_texCoord[160] = { #if TARGET_PC using GameVersion = dusk::version::GameVersion; -static u8* l_M_Kusa_9qDL_get() { static u8 buf[0xCB]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x8B00}, {GameVersion::GcnPal, 0x8B00}}, 0xCB), true); return buf; } -static u8* l_M_Kusa_9q_cDL_get() { static u8 buf[0xCB]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x8BE0}, {GameVersion::GcnPal, 0x8BE0}}, 0xCB), true); return buf; } -static u8* l_M_TenGusaDL_get() { static u8 buf[0xD4]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x8CC0}, {GameVersion::GcnPal, 0x8CC0}}, 0xD4), true); return buf; } -static u8* l_Tengusa_matDL_get() { static u8 buf[0xA8]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x8DA0}, {GameVersion::GcnPal, 0x8DA0}}, 0xA8), true); return buf; } -static u8* l_kusa9q_matDL_get() { static u8 buf[0xA8]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x8E60}, {GameVersion::GcnPal, 0x8E60}}, 0xA8), true); return buf; } -static u8* l_kusa9q_l4_matDL_get() { static u8 buf[0xA8]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x8F20}, {GameVersion::GcnPal, 0x8F20}}, 0xA8), true); return buf; } +static u8* l_M_Kusa_9qDL_get() { static u8 buf[0xCB]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x8B00}, {GameVersion::GcnPal, 0x8B00}, {GameVersion::GcnJpn, 0x8B00}}, 0xCB), true); return buf; } +static u8* l_M_Kusa_9q_cDL_get() { static u8 buf[0xCB]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x8BE0}, {GameVersion::GcnPal, 0x8BE0}, {GameVersion::GcnJpn, 0x8BE0}}, 0xCB), true); return buf; } +static u8* l_M_TenGusaDL_get() { static u8 buf[0xD4]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x8CC0}, {GameVersion::GcnPal, 0x8CC0}, {GameVersion::GcnJpn, 0x8CC0}}, 0xD4), true); return buf; } +static u8* l_Tengusa_matDL_get() { static u8 buf[0xA8]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x8DA0}, {GameVersion::GcnPal, 0x8DA0}, {GameVersion::GcnJpn, 0x8DA0}}, 0xA8), true); return buf; } +static u8* l_kusa9q_matDL_get() { static u8 buf[0xA8]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x8E60}, {GameVersion::GcnPal, 0x8E60}, {GameVersion::GcnJpn, 0x8E60}}, 0xA8), true); return buf; } +static u8* l_kusa9q_l4_matDL_get() { static u8 buf[0xA8]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x8F20}, {GameVersion::GcnPal, 0x8F20}, {GameVersion::GcnJpn, 0x8F20}}, 0xA8), true); return buf; } #define l_M_Kusa_9qDL (l_M_Kusa_9qDL_get()) #define l_M_Kusa_9q_cDL (l_M_Kusa_9q_cDL_get()) #define l_M_TenGusaDL (l_M_TenGusaDL_get()) diff --git a/src/d/d_name.cpp b/src/d/d_name.cpp index 1f3ccbb1d7..56fea3309d 100644 --- a/src/d/d_name.cpp +++ b/src/d/d_name.cpp @@ -17,6 +17,62 @@ static bool isPalOrJpn() { return dusk::version::isRegionPal() || dusk::version::isRegionJpn(); } +#if TARGET_PC +static const char* l_mojiHira[65] = { + "\x82\xA0", "\x82\xA2", "\x82\xA4", "\x82\xA6", "\x82\xA8", "\x82\xA9", "\x82\xAB", "\x82\xAD", "\x82\xAF", "\x82\xB1", "\x82\xB3", "\x82\xB5", "\x82\xB7", + "\x82\xB9", "\x82\xBB", "\x82\xBD", "\x82\xBF", "\x82\xC2", "\x82\xC4", "\x82\xC6", "\x82\xC8", "\x82\xC9", "\x82\xCA", "\x82\xCB", "\x82\xCC", "\x82\xCD", + "\x82\xD0", "\x82\xD3", "\x82\xD6", "\x82\xD9", "\x82\xDC", "\x82\xDD", "\x82\xDE", "\x82\xDF", "\x82\xE0", "\x82\xE2", "\x81\x40", "\x82\xE4", "\x81\x40", + "\x82\xE6", "\x82\xE7", "\x82\xE8", "\x82\xE9", "\x82\xEA", "\x82\xEB", "\x82\xED", "\x81\x40", "\x82\xF0", "\x81\x40", "\x82\xF1", "\x82\x9F", "\x82\xA1", + "\x82\xA3", "\x82\xA5", "\x82\xA7", "\x82\xE1", "\x81\x40", "\x82\xE3", "\x81\x40", "\x82\xE5", "\x82\xC1", "\x81\x40", "\x81\x5B", "\x81\x4A", "\x81\x4B", +}; + +static const char* l_mojiHira2[65] = { + "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x82\xAA", "\x82\xAC", "\x82\xAE", "\x82\xB0", "\x82\xB2", "\x82\xB4", "\x82\xB6", "\x82\xB8", + "\x82\xBA", "\x82\xBC", "\x82\xBE", "\x82\xC0", "\x82\xC3", "\x82\xC5", "\x82\xC7", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x82\xCE", + "\x82\xD1", "\x82\xD4", "\x82\xD7", "\x82\xDA", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", + "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", + "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", +}; + +static const char* l_mojiHira3[65] = { + "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", + "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x82\xCF", + "\x82\xD2", "\x82\xD5", "\x82\xD8", "\x82\xDB", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", + "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", + "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", +}; + +static const char* l_mojikata[65] = { + "\x83\x41", "\x83\x43", "\x83\x45", "\x83\x47", "\x83\x49", "\x83\x4A", "\x83\x4C", "\x83\x4E", "\x83\x50", "\x83\x52", "\x83\x54", "\x83\x56", "\x83\x58", + "\x83\x5A", "\x83\x5C", "\x83\x5E", "\x83\x60", "\x83\x63", "\x83\x65", "\x83\x67", "\x83\x69", "\x83\x6A", "\x83\x6B", "\x83\x6C", "\x83\x6D", "\x83\x6E", + "\x83\x71", "\x83\x74", "\x83\x77", "\x83\x7A", "\x83\x7D", "\x83\x7E", "\x83\x80", "\x83\x81", "\x83\x82", "\x83\x84", "\x81\x40", "\x83\x86", "\x81\x40", + "\x83\x88", "\x83\x89", "\x83\x8A", "\x83\x8B", "\x83\x8C", "\x83\x8D", "\x83\x8F", "\x81\x40", "\x83\x93", "\x81\x40", "\x83\x93", "\x83\x40", "\x83\x42" + "\x83\x44", "\x83\x46", "\x83\x48", "\x83\x83", "\x81\x40", "\x83\x85", "\x81\x40", "\x83\x87", "\x83\x62", "\x81\x40", "\x81\x5B", "\x81\x4A", "\x81\x4B", +}; + +static const char* l_mojikata2[65] = { + "\x81\x8F", "\x81\x8F", "\x83\x94", "\x81\x8F", "\x81\x8F", "\x83\x4B", "\x83\x4D", "\x83\x4F", "\x83\x51", "\x83\x53", "\x83\x55", "\x83\x57", "\x83\x59", + "\x83\x5B", "\x83\x5D", "\x83\x5F", "\x83\x61", "\x83\x64", "\x83\x66", "\x83\x68", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x83\x6F", + "\x83\x72", "\x83\x75", "\x83\x78", "\x83\x7B", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", + "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", + "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", +}; + +static const char* l_mojikata3[65] = { + "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", + "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x83\x70", + "\x83\x73", "\x83\x76", "\x83\x79", "\x83\x7C", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", + "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", + "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", "\x81\x8F", +}; + +static const char* l_mojiEisu[65] = { + "A", "N", "a", "n", "1", "B", "O", "b", "o", "2", "C", "P", "c", "p", "3", "D", "Q", + "d", "q", "4", "E", "R", "e", "r", "5", "F", "S", "f", "s", "6", "G", "T", "g", "t", + "7", "H", "U", "h", "u", "8", "I", "V", "i", "v", "9", "J", "W", "j", "w", "0", "K", + "X", "k", "x", ",", "L", "Y", "l", "y", ".", "M", "Z", "m", "z", " ", +}; +#else static const char* l_mojiHira[65] = { "あ", "い", "う", "え", "お", "か", "き", "く", "け", "こ", "さ", "し", "す", "せ", "そ", "た", "ち", "つ", "て", "と", "な", "に", "ぬ", "ね", "の", "は", @@ -71,6 +127,7 @@ static const char* l_mojiEisu[65] = { "7", "H", "U", "h", "u", "8", "I", "V", "i", "v", "9", "J", "W", "j", "w", "0", "K", "X", "k", "x", ",", "L", "Y", "l", "y", ".", "M", "Z", "m", "z", " ", }; +#endif #if TARGET_PC // The game normally mutates this string list to fill in the real character codes. diff --git a/src/dusk/iso_validate.cpp b/src/dusk/iso_validate.cpp index e83b3fd34e..0eac34711b 100644 --- a/src/dusk/iso_validate.cpp +++ b/src/dusk/iso_validate.cpp @@ -25,6 +25,7 @@ constexpr const char* PAL_GAME_IDS[] = { constexpr const char* SUPPORTED_TP_GAME_IDS[] = { "GZ2E01", // GCN USA "GZ2P01", // GCN PAL + "GZ2J01", }; template diff --git a/src/dusk/version.cpp b/src/dusk/version.cpp index e925dabaf8..1655031cdb 100644 --- a/src/dusk/version.cpp +++ b/src/dusk/version.cpp @@ -28,6 +28,8 @@ void init() { gameVersion = GameVersion::GcnUsa; } else if (game == "GZ2P") { gameVersion = GameVersion::GcnPal; + } else if (game == "GZ2J") { + gameVersion = GameVersion::GcnJpn; } else { // TODO: Handle remaining valid versions. DuskLog.fatal("Unknown/unsupported game version in disc: {}", game); From 00704ad838667e3c47b1fdd58adcc24c20ed5974 Mon Sep 17 00:00:00 2001 From: TakaRikka Date: Tue, 28 Apr 2026 03:43:52 -0700 Subject: [PATCH 002/150] mDoExt font region handling --- src/m_Do/m_Do_ext.cpp | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/m_Do/m_Do_ext.cpp b/src/m_Do/m_Do_ext.cpp index fc8952e91d..48aa1cc2cd 100644 --- a/src/m_Do/m_Do_ext.cpp +++ b/src/m_Do/m_Do_ext.cpp @@ -3701,7 +3701,15 @@ static ResFONT* mDoExt_resfont0; static void mDoExt_initFont0() { static char const fontdata[] = "rodan_b_24_22.bfn"; -#if REGION_JPN +#if TARGET_PC + if (getGameVersion() == GameVersion::GcnJpn) { + mDoExt_initFontCommon(&mDoExt_font0, &mDoExt_resfont0, mDoExt_getZeldaHeap(), + fontdata, dComIfGp_getFontArchive(), 0, 200, 512); + } else { + mDoExt_initFontCommon(&mDoExt_font0, &mDoExt_resfont0, mDoExt_getZeldaHeap(), + fontdata, dComIfGp_getFontArchive(), 1, 0, 0); + } +#elif REGION_JPN mDoExt_initFontCommon(&mDoExt_font0, &mDoExt_resfont0, mDoExt_getZeldaHeap(), fontdata, dComIfGp_getFontArchive(), 0, 200, 512); #else @@ -3728,7 +3736,13 @@ void mDoExt_removeMesgFont() { JKR_DELETE(mDoExt_font0); mDoExt_font0 = NULL; if (mDoExt_resfont0 != NULL) { -#if REGION_JPN +#if TARGET_PC + if (getGameVersion() == GameVersion::GcnJpn) { + JKRFileLoader::removeResource(mDoExt_resfont0, NULL); + } else { + JKRFree(mDoExt_resfont0); + } +#elif REGION_JPN JKRFileLoader::removeResource(mDoExt_resfont0, NULL); #else JKRFree(mDoExt_resfont0); From 9a3428983bc7bb4df50511c08cdfc7a45dbb2dd4 Mon Sep 17 00:00:00 2001 From: TakaRikka Date: Sat, 16 May 2026 19:37:46 -0700 Subject: [PATCH 003/150] build fix / verif --- src/dusk/iso_validate.cpp | 2 +- src/m_Do/m_Do_ext.cpp | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/dusk/iso_validate.cpp b/src/dusk/iso_validate.cpp index 00cd365ca9..fbb05bab13 100644 --- a/src/dusk/iso_validate.cpp +++ b/src/dusk/iso_validate.cpp @@ -88,7 +88,7 @@ struct KnownDisc { constexpr auto KNOWN_DISCS = std::to_array({ {"GZ2E01", Platform::GameCube, Region::NorthAmerica, "14e886f08e548a000afde98a3195e788"}, - {"GZ2J01", Platform::GameCube, Region::Japan}, + {"GZ2J01", Platform::GameCube, Region::Japan, "5967dc7a6a553652f4d2050aeef6f368"}, {"GZ2P01", Platform::GameCube, Region::Europe, "9ef597588b0035ca9e91b333fa9a8a7e"}, {"RZDE01", Platform::Wii, Region::NorthAmerica}, {"RZDJ01", Platform::Wii, Region::Japan}, diff --git a/src/m_Do/m_Do_ext.cpp b/src/m_Do/m_Do_ext.cpp index 48aa1cc2cd..0ea258c527 100644 --- a/src/m_Do/m_Do_ext.cpp +++ b/src/m_Do/m_Do_ext.cpp @@ -26,6 +26,7 @@ #include #include "dusk/logging.h" #include "dusk/frame_interpolation.h" +#include "dusk/version.hpp" u8 mDoExt::CurrentHeapAdjustVerbose; u8 mDoExt::HeapAdjustVerbose; @@ -3702,7 +3703,7 @@ static ResFONT* mDoExt_resfont0; static void mDoExt_initFont0() { static char const fontdata[] = "rodan_b_24_22.bfn"; #if TARGET_PC - if (getGameVersion() == GameVersion::GcnJpn) { + if (dusk::version::getGameVersion() == dusk::version::GameVersion::GcnJpn) { mDoExt_initFontCommon(&mDoExt_font0, &mDoExt_resfont0, mDoExt_getZeldaHeap(), fontdata, dComIfGp_getFontArchive(), 0, 200, 512); } else { @@ -3737,7 +3738,7 @@ void mDoExt_removeMesgFont() { mDoExt_font0 = NULL; if (mDoExt_resfont0 != NULL) { #if TARGET_PC - if (getGameVersion() == GameVersion::GcnJpn) { + if (dusk::version::getGameVersion() == dusk::version::GameVersion::GcnJpn) { JKRFileLoader::removeResource(mDoExt_resfont0, NULL); } else { JKRFree(mDoExt_resfont0); From 1a7c853d9be2a38b2347850fd79af386fe0d0d8d Mon Sep 17 00:00:00 2001 From: TakaRikka Date: Sat, 16 May 2026 21:06:55 -0700 Subject: [PATCH 004/150] better disc info display --- src/d/d_s_logo.cpp | 9 ++++++++- src/dusk/iso_validate.cpp | 22 +++++++--------------- src/dusk/iso_validate.hpp | 15 ++++++++++++++- src/dusk/ui/prelaunch.cpp | 32 ++++++++++++++++++++++++++++++-- src/dusk/ui/settings.cpp | 4 ++-- 5 files changed, 61 insertions(+), 21 deletions(-) diff --git a/src/d/d_s_logo.cpp b/src/d/d_s_logo.cpp index db0cfa1387..7a58a4cf77 100644 --- a/src/d/d_s_logo.cpp +++ b/src/d/d_s_logo.cpp @@ -907,7 +907,14 @@ dScnLogo_c::~dScnLogo_c() { mDoExt_getRubyFont(); mDoExt_setAraCacheSize(free_size - aram_heap->getTotalFreeSize()); -#if VERSION == VERSION_GCN_JPN +#if TARGET_PC + if (getGameVersion() == GameVersion::GcnJpn) { + if (dComIfGp_getFontArchive() != NULL) { + dComIfGp_getFontArchive()->unmount(); + dComIfGp_setFontArchive(NULL); + } + } +#elif VERSION == VERSION_GCN_JPN if (dComIfGp_getFontArchive() != NULL) { dComIfGp_getFontArchive()->unmount(); dComIfGp_setFontArchive(NULL); diff --git a/src/dusk/iso_validate.cpp b/src/dusk/iso_validate.cpp index fbb05bab13..9dc2c894b8 100644 --- a/src/dusk/iso_validate.cpp +++ b/src/dusk/iso_validate.cpp @@ -60,18 +60,6 @@ const char* verification_state_name(dusk::DiscVerificationState state) noexcept namespace dusk::iso { -enum class Platform : u8 { - GameCube, - Wii, -}; - -enum class Region : u8 { - NorthAmerica, - Europe, - Japan, - Korea, -}; - struct KnownDisc { std::string_view id; Platform platform; @@ -216,7 +204,9 @@ ValidationError validate(const char* path, VerificationStatus& status, DiscInfo& return ValidationError::WrongGame; } status.knownDisc = knownDisc; - info.isPal = knownDisc->region == Region::Europe; + + info.platform = knownDisc->platform; + info.region = knownDisc->region; if (!knownDisc->supported) { return ValidationError::WrongVersion; } @@ -251,7 +241,9 @@ ValidationError inspect(const char* path, DiscInfo& info) { if (!knownDisc) { return ValidationError::WrongGame; } - info.isPal = knownDisc->region == Region::Europe; + + info.platform = knownDisc->platform; + info.region = knownDisc->region; if (!knownDisc->supported) { return ValidationError::WrongVersion; } @@ -260,7 +252,7 @@ ValidationError inspect(const char* path, DiscInfo& info) { bool isPal(const char* path) { DiscInfo info{}; - return inspect(path, info) == ValidationError::Success && info.isPal; + return inspect(path, info) == ValidationError::Success && info.region == Region::Europe; } void log_verification_state(std::string_view path, DiscVerificationState state) { diff --git a/src/dusk/iso_validate.hpp b/src/dusk/iso_validate.hpp index 176284fd02..c064aa81cd 100644 --- a/src/dusk/iso_validate.hpp +++ b/src/dusk/iso_validate.hpp @@ -17,6 +17,18 @@ enum class ValidationError : u8 { Success }; +enum class Platform : u8 { + GameCube, + Wii, +}; + +enum class Region : u8 { + NorthAmerica, + Europe, + Japan, + Korea, +}; + struct VerificationStatus { std::atomic_size_t bytesRead = 0; std::atomic_size_t bytesTotal = 0; @@ -25,7 +37,8 @@ struct VerificationStatus { }; struct DiscInfo { - bool isPal = false; + Platform platform = Platform::GameCube; + Region region = Region::NorthAmerica; }; ValidationError inspect(const char* path, DiscInfo& info); diff --git a/src/dusk/ui/prelaunch.cpp b/src/dusk/ui/prelaunch.cpp index ef6b6e5709..44f7ea47d6 100644 --- a/src/dusk/ui/prelaunch.cpp +++ b/src/dusk/ui/prelaunch.cpp @@ -877,8 +877,36 @@ void Prelaunch::update() { if (mDiscDetail != nullptr) { if (activeDiscLoaded) { mDiscDetail->SetProperty(Rml::PropertyId::Display, Rml::Style::Display::Block); - Rml::String innerRML = "GameCube • "; - innerRML += state.activeDiscInfo.isPal ? "EUR" : "USA"; + Rml::String innerRML = ""; + + switch (state.activeDiscInfo.platform) { + case iso::Platform::GameCube: + innerRML += "GameCube"; + break; + case iso::Platform::Wii: + innerRML += "Wii"; + break; + } + + innerRML += " • "; + + switch (state.activeDiscInfo.region) { + case iso::Region::Japan: + innerRML += "JPN"; + break; + case iso::Region::Europe: + innerRML += "EUR"; + break; + case iso::Region::NorthAmerica: + innerRML += "USA"; + break; + case iso::Region::Korea: + innerRML += "KOR"; + break; + default: + innerRML += "Unknown"; + break; + } mDiscDetail->SetInnerRML(innerRML); } else { mDiscDetail->SetProperty(Rml::PropertyId::Display, Rml::Style::Display::None); diff --git a/src/dusk/ui/settings.cpp b/src/dusk/ui/settings.cpp index f12cb11ade..0300b84747 100644 --- a/src/dusk/ui/settings.cpp +++ b/src/dusk/ui/settings.cpp @@ -562,7 +562,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .getValue = [] { const auto& state = prelaunch_state(); - if (!state.configuredDiscCanLaunch || !state.configuredDiscInfo.isPal) { + if (!state.configuredDiscCanLaunch || state.configuredDiscInfo.region != iso::Region::Europe) { return kLanguageNames[0]; } const u8 idx = static_cast(getSettings().game.language.getValue()); @@ -572,7 +572,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { [] { const auto& state = prelaunch_state(); return !state.configuredDiscCanLaunch || - !state.configuredDiscInfo.isPal; + state.configuredDiscInfo.region != iso::Region::Europe; }, .isModified = [] { From e26fab71d6ce1400d3d99c39961e78b41059b980 Mon Sep 17 00:00:00 2001 From: Reilly Brogan Date: Mon, 8 Jun 2026 00:33:49 -0500 Subject: [PATCH 005/150] Add DUSK_PACKAGE_INSTALL (#1966) Signed-off-by: Reilly Brogan --- CMakeLists.txt | 21 +++++++++++++++++---- src/dusk/imgui/ImGuiEngine.cpp | 4 ++++ src/m_Do/m_Do_main.cpp | 3 +++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index f36987ad96..e52e841ec5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -148,6 +148,7 @@ option(DUSK_SELECTED_OPT "If on, selected parts of the project will be compiled option(DUSK_MOVIE_SUPPORT "If on, compile against libjpeg-turbo to enable THP file decoding" ON) option(DUSK_ENABLE_UPDATE_CHECKER "Enable update checking support" ON) option(DUSK_ENABLE_SENTRY_NATIVE "Enable sentry-native crash reporting support" OFF) +option(DUSK_PACKAGE_INSTALL "Install Dusklight with a Linux-native file structure" OFF) option(DUSK_GFX_DEBUG_GROUPS "Report debug groups to the native graphics API" ${DUSK_GFX_DEBUG_GROUPS_DEFAULT}) set(DUSK_SENTRY_DSN "" CACHE STRING "Sentry DSN") set(DUSK_SENTRY_ENVIRONMENT "development" CACHE STRING "Sentry environment") @@ -256,7 +257,6 @@ elseif (MSVC) add_compile_options($<$:/utf-8>) endif () - include(FetchContent) # Declare all dependencies first so CMake can download them in parallel @@ -425,6 +425,11 @@ if(ANDROID) list(APPEND GAME_COMPILE_DEFS TARGET_ANDROID=1) endif () +if (DUSK_PACKAGE_INSTALL) + include(GNUInstallDirs) + list(APPEND GAME_COMPILE_DEFS DUSK_ASSET_DIR="${CMAKE_INSTALL_FULL_DATADIR}/dusklight/") +endif () + if (DUSK_GFX_DEBUG_GROUPS) list(APPEND GAME_COMPILE_DEFS DUSK_GFX_DEBUG_GROUPS=1) target_compile_definitions(aurora_gx PRIVATE AURORA_GFX_DEBUG_GROUPS) @@ -648,12 +653,20 @@ set(EXTRA_TARGETS "") if (TARGET crashpad_handler) list(APPEND EXTRA_TARGETS crashpad_handler) endif () -install(TARGETS ${BINARY_TARGETS} ${EXTRA_TARGETS} DESTINATION ${CMAKE_INSTALL_PREFIX}) +if (DUSK_PACKAGE_INSTALL) + install(TARGETS ${BINARY_TARGETS} ${EXTRA_TARGETS} DESTINATION ${CMAKE_INSTALL_BINDIR}) +else() + install(TARGETS ${BINARY_TARGETS} ${EXTRA_TARGETS} DESTINATION ${CMAKE_INSTALL_PREFIX}) +endif() aurora_install_runtime_dlls(dusklight ${CMAKE_INSTALL_PREFIX}) if (NOT APPLE) - install(DIRECTORY ${CMAKE_SOURCE_DIR}/res DESTINATION ${CMAKE_INSTALL_PREFIX}) + if (DUSK_PACKAGE_INSTALL) + install(DIRECTORY ${CMAKE_SOURCE_DIR}/res DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/dusklight) + else() + install(DIRECTORY ${CMAKE_SOURCE_DIR}/res DESTINATION ${CMAKE_INSTALL_PREFIX}) + endif() endif () -if (CMAKE_BUILD_TYPE STREQUAL Debug OR CMAKE_BUILD_TYPE STREQUAL RelWithDebInfo) +if (CMAKE_BUILD_TYPE STREQUAL Debug OR CMAKE_BUILD_TYPE STREQUAL RelWithDebInfo AND NOT DUSK_PACKAGE_INSTALL) set(DEBUG_FILES_LIST "") foreach (target IN LISTS BINARY_TARGETS EXTRA_TARGETS) get_target_output_name(${target} output_name) diff --git a/src/dusk/imgui/ImGuiEngine.cpp b/src/dusk/imgui/ImGuiEngine.cpp index 4b6a7fb531..b2ea1f8e1a 100644 --- a/src/dusk/imgui/ImGuiEngine.cpp +++ b/src/dusk/imgui/ImGuiEngine.cpp @@ -20,7 +20,11 @@ namespace dusk { namespace { std::string GetAssetPath(const char* assetName) { +#ifdef DUSK_ASSET_DIR + const char* basePath = DUSK_ASSET_DIR; +#else const char* basePath = SDL_GetBasePath(); +#endif if (basePath != nullptr && basePath[0] != '\0') { return std::string(basePath) + "res/" + assetName; } diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index 7cb9830cd5..12d2202e36 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -577,6 +577,9 @@ int game_main(int argc, char* argv[]) { config.appName = dusk::AppName; config.userPath = reinterpret_cast(userPathString.c_str()); config.cachePath = reinterpret_cast(cachePathString.c_str()); +#ifdef DUSK_ASSET_DIR + config.resourcesPath = DUSK_ASSET_DIR; +#endif config.vsync = dusk::getSettings().video.enableVsync; config.startFullscreen = dusk::getSettings().video.enableFullscreen; config.windowPosX = -1; From 93e33ecf1adf52eb44ef62b07886bace54b994b6 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Mon, 8 Jun 2026 00:07:53 -0600 Subject: [PATCH 006/150] Cap map resolution multiplier Resolves #1058 Resolves #1422 Resolves #1668 --- extern/aurora | 2 +- src/d/d_map_path.cpp | 40 +++++++++++++++++++++++++++++----------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/extern/aurora b/extern/aurora index 19479a53e4..1ebb33f112 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 19479a53e4e82c58fb1b4fe07498383b89688713 +Subproject commit 1ebb33f112c893c352faabc96356d63648cef711 diff --git a/src/d/d_map_path.cpp b/src/d/d_map_path.cpp index f5e447a2aa..a7579ee92d 100644 --- a/src/d/d_map_path.cpp +++ b/src/d/d_map_path.cpp @@ -19,12 +19,30 @@ #include #include -constexpr u16 kMapResolutionMultiplier = 4; -constexpr u16 kMapImageSide = 16 * kMapResolutionMultiplier; +constexpr u16 kPreferredMapResolutionMultiplier = 4; +constexpr u32 kMaxMapRenderPixels = 4096 * 4096; +constexpr u16 kMapImageSide = 16 * kPreferredMapResolutionMultiplier; constexpr u32 kMapImageTotalPixels = kMapImageSide * kMapImageSide; typedef std::function PaintI8Fn; +u16 map_resolution_multiplier(u16 width, u16 height) { + const u32 basePixels = static_cast(width) * height; + if (basePixels == 0) { + return 1; + } + + u16 scale = kPreferredMapResolutionMultiplier; + while (scale > 1) { + const u32 scalePixels = static_cast(scale) * scale; + if (basePixels <= kMaxMapRenderPixels / scalePixels) { + break; + } + scale--; + } + return scale; +} + void paint_i8(std::span dst, size_t width, PaintI8Fn paint) { const auto blocksAcross = width >> 3; @@ -478,9 +496,9 @@ void dRenderingMap_c::makeResTIMG(ResTIMG* p_image, u16 width, u16 height, u8* p p_image->format = GX_TF_C8; p_image->alphaEnabled = 2; #ifdef TARGET_PC - // Increase map render resolution - p_image->width = width * kMapResolutionMultiplier; - p_image->height = height * kMapResolutionMultiplier; + const u16 scale = map_resolution_multiplier(width, height); + p_image->width = width * scale; + p_image->height = height * scale; #else p_image->width = width; p_image->height = height; @@ -563,9 +581,9 @@ void dRenderingFDAmap_c::drawBack() const { void dRenderingFDAmap_c::preRenderingMap() { #ifdef TARGET_PC - // Increase map render resolution - const u16 w = mTexWidth * kMapResolutionMultiplier; - const u16 h = mTexHeight * kMapResolutionMultiplier; + const u16 scale = map_resolution_multiplier(mTexWidth, mTexHeight); + const u16 w = mTexWidth * scale; + const u16 h = mTexHeight * scale; GXCreateFrameBuffer(w, h); // Set logical viewport dimensions GXSetViewport(0.0f, 0.0f, mTexWidth, mTexHeight, 0.0f, 1.0f); @@ -610,9 +628,9 @@ void dRenderingFDAmap_c::preRenderingMap() { void dRenderingFDAmap_c::postRenderingMap() { GXSetCopyFilter(GX_FALSE, NULL, GX_FALSE, NULL); #ifdef TARGET_PC - // Increase map render resolution - const u16 w = mTexWidth * kMapResolutionMultiplier; - const u16 h = mTexHeight * kMapResolutionMultiplier; + const u16 scale = map_resolution_multiplier(mTexWidth, mTexHeight); + const u16 w = mTexWidth * scale; + const u16 h = mTexHeight * scale; GXSetTexCopySrc(0, 0, w, h); GXSetTexCopyDst(w, h, GX_CTF_R8, GX_FALSE); GXCopyTex(field_0x4, GX_TRUE); From 285691cd192544018185e5b27873482662794b79 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Mon, 8 Jun 2026 22:14:10 -0600 Subject: [PATCH 007/150] Add -debug-asan presets with AddressSanitizer --- CMakeLists.txt | 30 ++++++++++++++++++ CMakePresets.json | 70 +++++++++++++++++++++++++++++++++++++++++ docs/building.md | 4 +++ src/dusk/asan_options.c | 3 ++ 4 files changed, 107 insertions(+) create mode 100644 src/dusk/asan_options.c diff --git a/CMakeLists.txt b/CMakeLists.txt index e52e841ec5..8954e9357a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -126,6 +126,33 @@ set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON) set_property(GLOBAL PROPERTY USE_FOLDERS ON) set_property(GLOBAL PROPERTY PREDEFINED_TARGETS_FOLDER "_cmake") +option(ENABLE_ASAN "Enable AddressSanitizer" OFF) +if (ENABLE_ASAN) + if (CMAKE_C_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC" AND + CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") + add_compile_options($<$:/fsanitize=address>) + add_link_options(/fsanitize=address /INCREMENTAL:NO) + set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "ProgramDatabase") + foreach (_lang C CXX) + foreach (_rtc_flag /RTC1 /RTCc /RTCs /RTCu) + string(REPLACE "${_rtc_flag}" "" CMAKE_${_lang}_FLAGS_DEBUG "${CMAKE_${_lang}_FLAGS_DEBUG}") + endforeach () + endforeach () + elseif (CMAKE_C_COMPILER_FRONTEND_VARIANT STREQUAL "GNU" AND + CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "GNU") + add_compile_options( + $<$:-fsanitize=address> + $<$:-fno-omit-frame-pointer> + ) + add_link_options(-fsanitize=address) + else () + message(FATAL_ERROR "ENABLE_ASAN requires GNU-like or MSVC-like C/C++ compiler frontends") + endif () + + add_compile_definitions(NDEBUG_SANITIZER) # Avoids absl issue with SwissTable debug code + message(STATUS "dusklight: Enabled AddressSanitizer") +endif () + if (CMAKE_SYSTEM_NAME STREQUAL Linux) set(DAWN_USE_WAYLAND ON CACHE BOOL "Enable support for Wayland surface" FORCE) endif () @@ -491,6 +518,9 @@ if(ANDROID) else () add_executable(dusklight ${DUSK_FILES}) endif () +if (ENABLE_ASAN) + target_sources(dusklight PRIVATE src/dusk/asan_options.c) +endif () target_compile_definitions(dusklight PRIVATE ${GAME_COMPILE_DEFS}) target_include_directories(dusklight PRIVATE ${GAME_INCLUDE_DIRS}) diff --git a/CMakePresets.json b/CMakePresets.json index 6c3a2c46ef..461e751296 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -52,6 +52,16 @@ } } }, + { + "name": "asan", + "hidden": true, + "cacheVariables": { + "ENABLE_ASAN": { + "type": "BOOL", + "value": true + } + } + }, { "name": "linux-default", "displayName": "Linux (default)", @@ -83,6 +93,15 @@ "linux-default" ] }, + { + "name": "linux-default-debug-asan", + "displayName": "Linux (default) Debug ASan", + "inherits": [ + "debug", + "linux-default", + "asan" + ] + }, { "name": "linux-default-relwithdebinfo", "displayName": "Linux (default) RelWithDebInfo", @@ -110,6 +129,15 @@ "linux-clang" ] }, + { + "name": "linux-clang-debug-asan", + "displayName": "Linux (Clang) Debug ASan", + "inherits": [ + "debug", + "linux-clang", + "asan" + ] + }, { "name": "linux-clang-relwithdebinfo", "displayName": "Linux (Clang) RelWithDebInfo", @@ -148,6 +176,15 @@ "windows-msvc" ] }, + { + "name": "windows-msvc-debug-asan", + "displayName": "Windows (MSVC) Debug ASan", + "inherits": [ + "debug", + "windows-msvc", + "asan" + ] + }, { "name": "windows-msvc-relwithdebinfo", "displayName": "Windows (MSVC) RelWithDebInfo", @@ -239,6 +276,15 @@ "macos-default" ] }, + { + "name": "macos-default-debug-asan", + "displayName": "macOS (default) Debug ASan", + "inherits": [ + "debug", + "macos-default", + "asan" + ] + }, { "name": "macos-default-relwithdebinfo", "displayName": "macOS (default) RelWithDebInfo", @@ -529,6 +575,12 @@ "description": "Linux (default) debug build", "displayName": "Linux (default) Debug" }, + { + "name": "linux-default-debug-asan", + "configurePreset": "linux-default-debug-asan", + "description": "Linux (default) debug build with AddressSanitizer", + "displayName": "Linux (default) Debug ASan" + }, { "name": "linux-default-relwithdebinfo", "configurePreset": "linux-default-relwithdebinfo", @@ -541,6 +593,12 @@ "description": "Linux (Clang) debug build", "displayName": "Linux (Clang) Debug" }, + { + "name": "linux-clang-debug-asan", + "configurePreset": "linux-clang-debug-asan", + "description": "Linux (Clang) debug build with AddressSanitizer", + "displayName": "Linux (Clang) Debug ASan" + }, { "name": "linux-clang-relwithdebinfo", "configurePreset": "linux-clang-relwithdebinfo", @@ -553,6 +611,12 @@ "description": "macOS debug build", "displayName": "macOS Debug" }, + { + "name": "macos-default-debug-asan", + "configurePreset": "macos-default-debug-asan", + "description": "macOS debug build with AddressSanitizer", + "displayName": "macOS Debug ASan" + }, { "name": "macos-default-relwithdebinfo", "configurePreset": "macos-default-relwithdebinfo", @@ -610,6 +674,12 @@ "description": "Windows (MSVC) debug build", "displayName": "Windows (MSVC) Debug" }, + { + "name": "windows-msvc-debug-asan", + "configurePreset": "windows-msvc-debug-asan", + "description": "Windows (MSVC) debug build with AddressSanitizer", + "displayName": "Windows (MSVC) Debug ASan" + }, { "name": "windows-msvc-relwithdebinfo", "configurePreset": "windows-msvc-relwithdebinfo", diff --git a/docs/building.md b/docs/building.md index 9f7879ab48..31222df414 100644 --- a/docs/building.md +++ b/docs/building.md @@ -180,6 +180,7 @@ cmake --build --preset macos-default-relwithdebinfo Alternate presets available: * `macos-default-debug`: Clang, Debug +* `macos-default-debug-asan`: Clang, Debug, AddressSanitizer **ninja (Linux)** @@ -191,8 +192,10 @@ cmake --build --preset linux-default-relwithdebinfo Alternate presets available: * `linux-default-debug`: GCC, Debug +* `linux-default-debug-asan`: GCC, Debug, AddressSanitizer * `linux-clang-relwithdebinfo`: Clang, RelWithDebInfo * `linux-clang-debug`: Clang, Debug +* `linux-clang-debug-asan`: Clang, Debug, AddressSanitizer **ninja (Windows)** @@ -204,6 +207,7 @@ cmake --build --preset windows-msvc-relwithdebinfo Alternate presets available: * `windows-msvc-debug`: MSVC, Debug +* `windows-msvc-debug-asan`: MSVC, Debug, AddressSanitizer * `windows-clang-relwithdebinfo`: Clang-cl, RelWithDebInfo * `windows-clang-debug`: Clang-cl, Debug diff --git a/src/dusk/asan_options.c b/src/dusk/asan_options.c new file mode 100644 index 0000000000..6bfb55f28d --- /dev/null +++ b/src/dusk/asan_options.c @@ -0,0 +1,3 @@ +const char* __asan_default_options(void) { + return "abort_on_error=1:symbolize=1:intercept_memcmp=0:detect_leaks=0"; +} From 28a37f6b4fe807c26db38316df8f78be1e9a2e7e Mon Sep 17 00:00:00 2001 From: Luke Street Date: Mon, 8 Jun 2026 22:14:34 -0600 Subject: [PATCH 008/150] Check for sNoUseDrawMtxPtr in J3DModel::entry --- libs/JSystem/src/J3DGraphAnimator/J3DModel.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/libs/JSystem/src/J3DGraphAnimator/J3DModel.cpp b/libs/JSystem/src/J3DGraphAnimator/J3DModel.cpp index 8c895d5077..8e111428cc 100644 --- a/libs/JSystem/src/J3DGraphAnimator/J3DModel.cpp +++ b/libs/JSystem/src/J3DGraphAnimator/J3DModel.cpp @@ -542,8 +542,11 @@ void J3DModel::viewCalc() { } #ifdef TARGET_PC - for (u16 i = 0; i < mModelData->getDrawMtxNum(); ++i) { - dusk::frame_interp::record_final_mtx(getDrawMtxPtr()[i]); + Mtx* drawMtx = getDrawMtxPtr(); + if (drawMtx != J3DMtxBuffer::sNoUseDrawMtxPtr) { + for (u16 i = 0; i < mModelData->getDrawMtxNum(); ++i) { + dusk::frame_interp::record_final_mtx(drawMtx[i]); + } } #endif From a58f64ed80b4a1db073a09b968f470347458261f Mon Sep 17 00:00:00 2001 From: Luke Street Date: Mon, 8 Jun 2026 22:49:28 -0600 Subject: [PATCH 009/150] Update aurora --- CMakeLists.txt | 4 +--- extern/aurora | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8954e9357a..80be4f3fe4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -378,9 +378,7 @@ set(GAME_INCLUDE_DIRS find_package(Threads REQUIRED) set(GAME_LIBS aurora::core aurora::gx aurora::gd aurora::si aurora::vi aurora::pad aurora::mtx aurora::os aurora::dvd aurora::card freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt - Threads::Threads) - -list(APPEND GAME_LIBS zstd::libzstd) + Threads::Threads zstd::libzstd) if (DUSK_ENABLE_SENTRY_NATIVE) list(APPEND GAME_LIBS sentry) diff --git a/extern/aurora b/extern/aurora index 1ebb33f112..cc9aa81ce7 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 1ebb33f112c893c352faabc96356d63648cef711 +Subproject commit cc9aa81ce756a2c9826a747ca9940c1f5d1e55db From 34e1e740abda473b4c594c8217a9d28c51f79296 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Mon, 8 Jun 2026 23:14:51 -0600 Subject: [PATCH 010/150] Update aurora --- extern/aurora | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extern/aurora b/extern/aurora index cc9aa81ce7..6fa2cbb961 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit cc9aa81ce756a2c9826a747ca9940c1f5d1e55db +Subproject commit 6fa2cbb961626c1ef1b13eb12a5b3b43d3bde5f0 From 7290649bb863834244ab78dfef3732939040a206 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 9 Jun 2026 00:58:17 -0600 Subject: [PATCH 011/150] Prune old logs & dawn_cache.db entries --- extern/aurora | 2 +- src/dusk/logging.cpp | 123 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/extern/aurora b/extern/aurora index 6fa2cbb961..9bc79d649c 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 6fa2cbb961626c1ef1b13eb12a5b3b43d3bde5f0 +Subproject commit 9bc79d649cd54939961f349d36acf8dd6b143be2 diff --git a/src/dusk/logging.cpp b/src/dusk/logging.cpp index b24f54878a..1303515e5a 100644 --- a/src/dusk/logging.cpp +++ b/src/dusk/logging.cpp @@ -1,12 +1,16 @@ #include "dusk/logging.h" +#include #include #include #include +#include +#include #include #include #include #include #include +#include #include "dusk/io.hpp" #include "tracy/Tracy.hpp" @@ -44,6 +48,9 @@ namespace { // We use this to check if the LogState is destroyed before attempting to acquire it. std::atomic g_logStateAlive(true); std::atomic g_logFd(-1); +constexpr size_t MaxRetainedLogCount = 10; +constexpr size_t MaxRetainedOldLogCount = MaxRetainedLogCount - 1; +constexpr uintmax_t MaxRetainedOldLogBytes = 100ull * 1024ull * 1024ull; struct LogState { std::mutex mutex; @@ -91,6 +98,121 @@ FILE* LogStreamForLevel(AuroraLogLevel level) { return level >= LOG_ERROR ? stderr : stdout; } +struct LogFileCandidate { + std::filesystem::path path; + std::string filename; + uintmax_t size; +}; + +void warn_log_cleanup_failure( + const char* action, const std::filesystem::path& path, const std::error_code& ec) { + std::fprintf(stderr, "[WARNING | dusk] Failed to %s '%s': %s\n", action, + dusk::io::fs_path_to_string(path).c_str(), ec.message().c_str()); +} + +bool is_digit_at(const std::string_view value, size_t index) { + return std::isdigit(static_cast(value[index])) != 0; +} + +bool is_generated_log_file_name(const std::filesystem::path& path) { + const std::string filename = path.filename().string(); + constexpr std::string_view currentPrefix = "dusklight-"sv; + constexpr std::string_view legacyPrefix = "dusk-"sv; + constexpr std::string_view suffix = ".log"sv; + size_t timestampOffset = 0; + + if (filename.starts_with(currentPrefix)) { + timestampOffset = currentPrefix.size(); + } else if (filename.starts_with(legacyPrefix)) { + timestampOffset = legacyPrefix.size(); + } else { + return false; + } + + if (filename.size() != timestampOffset + 19 || !filename.ends_with(suffix) || + filename[timestampOffset + 8] != '-') { + return false; + } + + for (size_t i = timestampOffset; i < timestampOffset + 8; ++i) { + if (!is_digit_at(filename, i)) { + return false; + } + } + for (size_t i = timestampOffset + 9; i < timestampOffset + 15; ++i) { + if (!is_digit_at(filename, i)) { + return false; + } + } + + return true; +} + +void delete_log_file(const std::filesystem::path& path) { + std::error_code ec; + std::filesystem::remove(path, ec); + if (ec) { + warn_log_cleanup_failure("remove old log file", path, ec); + } +} + +void prune_old_log_files(const std::filesystem::path& logsDir) { + std::error_code ec; + std::filesystem::directory_iterator entries{logsDir, ec}; + if (ec) { + warn_log_cleanup_failure("inspect log directory", logsDir, ec); + return; + } + + std::vector candidates; + for (const auto& entry : entries) { + const std::filesystem::path path = entry.path(); + if (!is_generated_log_file_name(path)) { + continue; + } + + ec.clear(); + const auto status = entry.symlink_status(ec); + if (ec) { + warn_log_cleanup_failure("inspect log file", path, ec); + continue; + } + if (!std::filesystem::is_regular_file(status)) { + continue; + } + + ec.clear(); + const uintmax_t size = entry.file_size(ec); + if (ec) { + warn_log_cleanup_failure("inspect size of log file", path, ec); + continue; + } + + candidates.push_back({path, path.filename().string(), size}); + } + + std::sort(candidates.begin(), candidates.end(), + [](const LogFileCandidate& a, const LogFileCandidate& b) { + return a.filename > b.filename; + }); + + const size_t retainedCount = std::min(candidates.size(), MaxRetainedOldLogCount); + uintmax_t retainedBytes = 0; + for (size_t i = 0; i < retainedCount; ++i) { + retainedBytes += candidates[i].size; + } + + size_t retainedAfterSizeLimit = retainedCount; + while (retainedAfterSizeLimit > 0 && retainedBytes > MaxRetainedOldLogBytes) { + --retainedAfterSizeLimit; + retainedBytes -= candidates[retainedAfterSizeLimit].size; + } + + for (size_t i = retainedAfterSizeLimit; i < candidates.size(); ++i) { + delete_log_file(candidates[i].path); + } +} + std::string MakeTimestampedLogName() { const auto now = std::chrono::system_clock::now(); const std::time_t nowTime = std::chrono::system_clock::to_time_t(now); @@ -230,6 +352,7 @@ void dusk::InitializeFileLogging(const std::filesystem::path& configDir, AuroraL io::fs_path_to_string(logsDir).c_str(), ec.message().c_str()); return; } + prune_old_log_files(logsDir); const std::filesystem::path logPath = logsDir / MakeTimestampedLogName(); g_logState.file = io::FileStream::Create(logPath).ToInner(); From a58f9c7b43e133e3a2ae312971542122e1d2b1d8 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 9 Jun 2026 22:51:23 -0600 Subject: [PATCH 012/150] Update Android shell to SDL 3.4.10 --- .../app/HIDDeviceBLESteamController.java | 244 +++++++++++++++--- .../java/org/libsdl/app/HIDDeviceManager.java | 13 +- .../java/org/libsdl/app/HIDDeviceUSB.java | 49 +++- .../main/java/org/libsdl/app/SDLActivity.java | 23 +- .../org/libsdl/app/SDLControllerManager.java | 85 +++++- .../java/org/libsdl/app/SDLSensorManager.java | 32 +++ .../main/java/org/libsdl/app/SDLSurface.java | 21 +- 7 files changed, 411 insertions(+), 56 deletions(-) create mode 100644 platforms/android/app/src/main/java/org/libsdl/app/SDLSensorManager.java diff --git a/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java b/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java index bf1ca2149d..bcd8806c41 100644 --- a/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java +++ b/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java @@ -19,9 +19,13 @@ import android.os.*; import java.lang.Runnable; import java.util.Arrays; +import java.util.HashMap; import java.util.LinkedList; import java.util.UUID; +import java.util.regex.Pattern; +import java.util.regex.Matcher; + class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice { private static final String TAG = "hidapi"; @@ -33,10 +37,19 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe private boolean mIsConnected = false; private boolean mIsChromebook = false; private boolean mIsReconnecting = false; + private boolean mHasEnabledNotifications = false; + private boolean mHasSeenInputUpdate = false; private boolean mFrozen = false; private LinkedList mOperations; GattOperation mCurrentOperation = null; private Handler mHandler; + private int mProductId = -1; + private int mReportId = 0; + private UUID mInputCharacteristic; + + private static final int D0G_BLE2_PID = 0x1106; + private static final int TRITON_BLE_PID = 0x1303; + private static final int TRANSPORT_AUTO = 0; private static final int TRANSPORT_BREDR = 1; @@ -45,10 +58,14 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000; static final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3"); - static final UUID inputCharacteristic = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3"); + static final UUID inputCharacteristicD0G = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3"); + static final UUID inputCharacteristicTriton_0x45 = UUID.fromString("100F6C7A-1735-4313-B402-38567131E5F3"); + static final UUID inputCharacteristicTriton_0x47 = UUID.fromString("100F6C7C-1735-4313-B402-38567131E5F3"); static final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3"); static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 }; + private HashMap mOutputReportChars = new HashMap(); + static class GattOperation { private enum Operation { CHR_READ, @@ -61,6 +78,7 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe byte[] mValue; BluetoothGatt mGatt; boolean mResult = true; + int mDelayMs = 0; private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid) { mGatt = gatt; @@ -68,6 +86,13 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe mUuid = uuid; } + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, int delayMs) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + mDelayMs = delayMs; + } + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value) { mGatt = gatt; mOp = operation; @@ -75,6 +100,14 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe mValue = value; } + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value, int delayMs) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + mValue = value; + mDelayMs = delayMs; + } + public void run() { // This is executed in main thread BluetoothGattCharacteristic chr; @@ -136,6 +169,8 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe return mResult; } + public int getDelayMs() { return mDelayMs; } + private BluetoothGattCharacteristic getCharacteristic(UUID uuid) { BluetoothGattService valveService = mGatt.getService(steamControllerService); if (valveService == null) @@ -154,6 +189,10 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid) { return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid); } + + static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid, int delayMs) { + return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid, delayMs); + } } HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) { @@ -166,6 +205,8 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe mHandler = new Handler(Looper.getMainLooper()); mGatt = connectGatt(); + mHasEnabledNotifications = false; + mHasSeenInputUpdate = false; // final HIDDeviceBLESteamController finalThis = this; // mHandler.postDelayed(new Runnable() { // @Override @@ -314,8 +355,45 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe Log.v(TAG, "Found Valve steam controller service " + service.getUuid()); for (BluetoothGattCharacteristic chr : service.getCharacteristics()) { - if (chr.getUuid().equals(inputCharacteristic)) { - Log.v(TAG, "Found input characteristic"); + if (chr.getUuid().equals(inputCharacteristicTriton_0x45)) { + Log.v(TAG, "Found Triton input characteristic 0x45"); + mProductId = TRITON_BLE_PID; + mReportId = 0x45; + mInputCharacteristic = chr.getUuid(); + } else if (chr.getUuid().equals(inputCharacteristicTriton_0x47)) { + Log.v(TAG, "Found Triton input characteristic 0x47"); + mProductId = TRITON_BLE_PID; + mReportId = 0x47; + mInputCharacteristic = chr.getUuid(); + } else if (chr.getUuid().equals(inputCharacteristicD0G)) { + Log.v(TAG, "Found D0G input characteristic"); + mProductId = D0G_BLE2_PID; + mReportId = 0x03; + mInputCharacteristic = chr.getUuid(); + } else { + Pattern reportPattern = Pattern.compile("100F6C([0-9A-Z]{2})", Pattern.CASE_INSENSITIVE); + Matcher matcher = reportPattern.matcher(chr.getUuid().toString()); + + if (matcher.find()) { + try { + int reportId = Integer.parseInt(matcher.group(1), 16); + + reportId -= 0x35; + if (reportId >= 0x80) { + // This is a Triton output report characteristic that we need to care about. + Log.v(TAG, "Found Triton output report 0x" + Integer.toString(reportId, 16)); + mOutputReportChars.put(reportId, chr); + } + } + catch (NumberFormatException nfe) { + Log.w(TAG, "Could not parse report characteristic " + chr.getUuid().toString() + ": " + nfe.toString()); + } + } + } + } + + for (BluetoothGattCharacteristic chr : service.getCharacteristics()) { + if (chr.getUuid().equals(mInputCharacteristic)) { // Start notifications BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); if (cccd != null) { @@ -372,21 +450,30 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe mCurrentOperation = mOperations.removeFirst(); } - // Run in main thread - mHandler.post(new Runnable() { - @Override - public void run() { - synchronized (mOperations) { - if (mCurrentOperation == null) { - Log.e(TAG, "Current operation null in executor?"); - return; - } + Runnable gattOperationRunnable = new Runnable() { + @Override + public void run() { + synchronized (mOperations) { + if (mCurrentOperation == null) { + Log.e(TAG, "Current operation null in executor?"); + return; + } - mCurrentOperation.run(); - // now wait for the GATT callback and when it comes, finish this operation + mCurrentOperation.run(); + // now wait for the GATT callback and when it comes, finish this operation + } } - } - }); + }; + + if (mCurrentOperation.getDelayMs() == 0) { + // Run in main thread + mHandler.post(gattOperationRunnable); + } + else { + // If we have a delay on this operation, wait before we post it. + mHandler.postDelayed(gattOperationRunnable, mCurrentOperation.getDelayMs()); + } + } private void queueGattOperation(GattOperation op) { @@ -397,8 +484,39 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe } private void enableNotification(UUID chrUuid) { - GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid); + // Add a 500ms delay to notification write for Amazon Fire TV devices, as otherwise if we do this too quickly after connecting + // it will return success and then silently drop the operation on the floor. + GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid, 500); queueGattOperation(op); + + // Amazon Fire devices can also silently timeout on writeDescriptor, so + // set up a little delayed check that will attempt to write a second time. + // + // While this only seems to be needed on Amazon Fire TV devices at present, it + // doesn't hurt to have a retry on other devices as well. + // + final HIDDeviceBLESteamController finalThis = this; + final UUID finalUuid = chrUuid; + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + if (!finalThis.mHasEnabledNotifications) { + + if (finalThis.mHasSeenInputUpdate) { + // Amazon Five devices may have enabled notifications on the input characteristic and not given us a callback. If we've seen + // input reports, though, somewhat by definition notifications are enabled. + Log.w(TAG, "WriteDescriptor has never returned, but we've seen input reports. Moving on with controller initialization."); + finalThis.mHasEnabledNotifications = true; + finalThis.enableValveMode(); + return; + } + + // Give one more try. + GattOperation retry = HIDDeviceBLESteamController.GattOperation.enableNotification(finalThis.mGatt, finalUuid, 500); + finalThis.queueGattOperation(retry); + } + } + }, 1000); } void writeCharacteristic(UUID uuid, byte[] value) { @@ -448,8 +566,16 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe mIsConnected = false; gatt.disconnect(); mGatt = connectGatt(false); - } - else { + } else { + if (getProductId() == TRITON_BLE_PID) { + // Android will not properly play well with Data Length Extensions without manually requesting a large MTU, + // and Triton controllers require DLE support. + // + // 517 is basically a "magic number" as far as Android's bluetooth code is concerned, so do not change + // this value. It is functionally "please enable data length extensions" on some Android builds. + mGatt.requestMtu(517); + } + probeService(this); } } @@ -474,7 +600,7 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe // Only register controller with the native side once it has been fully configured if (!isRegistered()) { Log.v(TAG, "Registering Steam Controller with ID: " + getId()); - mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true); + mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true, mReportId); setRegistered(); } } @@ -487,7 +613,8 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe // Enable this for verbose logging of controller input reports //Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + HexDump.dumpHexString(characteristic.getValue())); - if (characteristic.getUuid().equals(inputCharacteristic) && !mFrozen) { + if (characteristic.getUuid().equals(mInputCharacteristic) && !mFrozen) { + mHasSeenInputUpdate = true; mManager.HIDDeviceInputReport(getId(), characteristic.getValue()); } } @@ -497,19 +624,36 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe //Log.v(TAG, "onDescriptorRead status=" + status); } + private void enableValveMode() + { + BluetoothGattService valveService = mGatt.getService(steamControllerService); + if (valveService == null) + return; + + BluetoothGattCharacteristic reportChr = valveService.getCharacteristic(reportCharacteristic); + if (reportChr != null) { + if (getProductId() == TRITON_BLE_PID) { + // For Triton we just mark things registered. + Log.v(TAG, "Registering Triton Steam Controller with ID: " + getId()); + mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true, mReportId); + setRegistered(); + } else { + // For the original controller, we need to manually enter Valve mode. + Log.v(TAG, "Writing report characteristic to enter valve mode"); + reportChr.setValue(enterValveMode); + mGatt.writeCharacteristic(reportChr); + } + } + } + @Override public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { BluetoothGattCharacteristic chr = descriptor.getCharacteristic(); //Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid()); - if (chr.getUuid().equals(inputCharacteristic)) { - boolean hasWrittenInputDescriptor = true; - BluetoothGattCharacteristic reportChr = chr.getService().getCharacteristic(reportCharacteristic); - if (reportChr != null) { - Log.v(TAG, "Writing report characteristic to enter valve mode"); - reportChr.setValue(enterValveMode); - gatt.writeCharacteristic(reportChr); - } + if (chr.getUuid().equals(mInputCharacteristic)) { + mHasEnabledNotifications = true; + enableValveMode(); } finishCurrentGattOperation(); @@ -548,9 +692,20 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe @Override public int getProductId() { - // We don't have an easy way to query from the Bluetooth device, but we know what it is - final int D0G_BLE2_PID = 0x1106; - return D0G_BLE2_PID; + if (mProductId > 0) { + // We've already set a product ID. + return mProductId; + } + + if (mDevice.getName().startsWith("Steam Ctrl")) { + // We're a newer Triton device + mProductId = TRITON_BLE_PID; + } else { + // We're an OG Steam Controller + mProductId = D0G_BLE2_PID; + } + + return mProductId; } @Override @@ -601,10 +756,29 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe writeCharacteristic(reportCharacteristic, actual_report); return report.length; } else { - //Log.v(TAG, "writeOutputReport " + HexDump.dumpHexString(report)); - writeCharacteristic(reportCharacteristic, report); - return report.length; + // If we're an original-recipe Steam Controller we just write to the characteristic directly. + if (getProductId() == D0G_BLE2_PID) { + //Log.v(TAG, "writeOutputReport " + HexDump.dumpHexString(report)); + writeCharacteristic(reportCharacteristic, report); + return report.length; + } + + // If we're a Triton, we need to find the correct report characteristic. + if (report.length > 0) { + int reportId = report[0] & 0xFF; + BluetoothGattCharacteristic targetedReportCharacteristic = mOutputReportChars.get(reportId); + if (targetedReportCharacteristic != null) { + byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1); + //Log.v(TAG, "writeOutputReport 0x" + Integer.toString(reportId, 16) + " " + HexDump.dumpHexString(report)); + writeCharacteristic(targetedReportCharacteristic.getUuid(), actual_report); + return report.length; + } else { + Log.w(TAG, "Got report write request for unknown report type 0x" + Integer.toString(reportId, 16)); + } + } } + + return -1; } @Override diff --git a/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java b/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java index 1fb2bfb4a7..691416c1c9 100644 --- a/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java +++ b/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java @@ -256,6 +256,7 @@ public class HIDDeviceManager { 0x24c6, // PowerA 0x2c22, // Qanba 0x2dc8, // 8BitDo + 0x3537, // GameSir 0x37d7, // Flydigi 0x9886, // ASTRO Gaming }; @@ -360,7 +361,7 @@ public class HIDDeviceManager { HIDDeviceUSB device = new HIDDeviceUSB(this, usbDevice, interface_index); int id = device.getId(); mDevicesById.put(id, device); - HIDDeviceConnected(id, device.getIdentifier(), device.getVendorId(), device.getProductId(), device.getSerialNumber(), device.getVersion(), device.getManufacturerName(), device.getProductName(), usbInterface.getId(), usbInterface.getInterfaceClass(), usbInterface.getInterfaceSubclass(), usbInterface.getInterfaceProtocol(), false); + HIDDeviceConnected(id, device.getIdentifier(), device.getVendorId(), device.getProductId(), device.getSerialNumber(), device.getVersion(), device.getManufacturerName(), device.getProductName(), usbInterface.getId(), usbInterface.getInterfaceClass(), usbInterface.getInterfaceSubclass(), usbInterface.getInterfaceProtocol(), false, 0); } } } @@ -529,7 +530,13 @@ public class HIDDeviceManager { return false; } - return bluetoothDevice.getName().equals("SteamController") && ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) != 0); + // Steam Controllers will always support Bluetooth Low Energy + if ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) == 0) { + return false; + } + + // Match on the name either the original Steam Controller or the new second-generation one advertise with. + return bluetoothDevice.getName().equals("SteamController") || bluetoothDevice.getName().startsWith("Steam Ctrl"); } private void close() { @@ -681,7 +688,7 @@ public class HIDDeviceManager { private native void HIDDeviceRegisterCallback(); private native void HIDDeviceReleaseCallback(); - native void HIDDeviceConnected(int deviceID, String identifier, int vendorId, int productId, String serial_number, int release_number, String manufacturer_string, String product_string, int interface_number, int interface_class, int interface_subclass, int interface_protocol, boolean bBluetooth); + native void HIDDeviceConnected(int deviceID, String identifier, int vendorId, int productId, String serial_number, int release_number, String manufacturer_string, String product_string, int interface_number, int interface_class, int interface_subclass, int interface_protocol, boolean bBluetooth, int reportID); native void HIDDeviceOpenPending(int deviceID); native void HIDDeviceOpenResult(int deviceID, boolean opened); native void HIDDeviceDisconnected(int deviceID); diff --git a/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java b/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java index f9e9389802..8954639733 100644 --- a/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java +++ b/platforms/android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java @@ -21,6 +21,7 @@ class HIDDeviceUSB implements HIDDevice { protected InputThread mInputThread; protected boolean mRunning; protected boolean mFrozen; + protected boolean mClaimed; public HIDDeviceUSB(HIDDeviceManager manager, UsbDevice usbDevice, int interface_index) { mManager = manager; @@ -29,6 +30,7 @@ class HIDDeviceUSB implements HIDDevice { mInterface = mDevice.getInterface(mInterfaceIndex).getId(); mDeviceId = manager.getDeviceIDForIdentifier(getIdentifier()); mRunning = false; + mClaimed = false; } String getIdentifier() { @@ -114,6 +116,7 @@ class HIDDeviceUSB implements HIDDevice { close(); return false; } + mClaimed = true; // Find the endpoints for (int j = 0; j < iface.getEndpointCount(); j++) { @@ -132,9 +135,12 @@ class HIDDeviceUSB implements HIDDevice { } } - // Make sure the required endpoints were present - if (mInputEndpoint == null || mOutputEndpoint == null) { + // Make sure the required endpoints were present. The original Steam Controller and the wireless dongle for it do NOT + // actually have -- or require -- output endpoints, so we need to accept only an input one for them or else we'll fall + // back to the Android system gamepad functionality (and lose our paddles et al). + if (mInputEndpoint == null) { Log.w(TAG, "Missing required endpoint on USB device " + getDeviceName()); + mConnection.releaseInterface(iface); close(); return false; } @@ -154,6 +160,11 @@ class HIDDeviceUSB implements HIDDevice { return -1; } + if (!mClaimed) { + Log.w(TAG, "writeReport() called but some other process currently owns the USB device"); + return -1; + } + if (feature) { int res = -1; int offset = 0; @@ -185,6 +196,11 @@ class HIDDeviceUSB implements HIDDevice { } return length; } else { + if (mOutputEndpoint == null) + { + Log.e(TAG, "Tried to write an output report to an interface with no output endpoint!"); + return -1; + } int res = mConnection.bulkTransfer(mOutputEndpoint, report, report.length, 1000); if (res != report.length) { Log.w(TAG, "writeOutputReport() returned " + res + " on device " + getDeviceName()); @@ -205,6 +221,12 @@ class HIDDeviceUSB implements HIDDevice { Log.w(TAG, "readReport() called with no device connection"); return false; } + if (!mClaimed) { + if (feature) { + return false; + } + return true; + } if (report_number == 0x0) { /* Offset the return buffer by 1, so that the report ID @@ -258,10 +280,13 @@ class HIDDeviceUSB implements HIDDevice { mInputThread = null; } if (mConnection != null) { - UsbInterface iface = mDevice.getInterface(mInterfaceIndex); - mConnection.releaseInterface(iface); + if (mClaimed) { + UsbInterface iface = mDevice.getInterface(mInterfaceIndex); + mConnection.releaseInterface(iface); + } mConnection.close(); mConnection = null; + mClaimed = false; } } @@ -274,6 +299,22 @@ class HIDDeviceUSB implements HIDDevice { @Override public void setFrozen(boolean frozen) { mFrozen = frozen; + + /* If we have a valid device connection and the claim state doesn't match what we want, try to correct that. */ + if (mConnection != null && mClaimed == mFrozen) { + UsbInterface iface = mDevice.getInterface(mInterfaceIndex); + if (frozen) { + mClaimed = !mConnection.releaseInterface(iface); + if (mClaimed) { + Log.e(TAG, "Tried to release claim on USB device, but failed!"); + } + } else { + mClaimed = mConnection.claimInterface(iface, true); + if (!mClaimed) { + Log.e(TAG, "Tried to regain claim on USB device, but failed!"); + } + } + } } protected class InputThread extends Thread { diff --git a/platforms/android/app/src/main/java/org/libsdl/app/SDLActivity.java b/platforms/android/app/src/main/java/org/libsdl/app/SDLActivity.java index 5c54cde863..dcc49852ac 100644 --- a/platforms/android/app/src/main/java/org/libsdl/app/SDLActivity.java +++ b/platforms/android/app/src/main/java/org/libsdl/app/SDLActivity.java @@ -61,7 +61,7 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh private static final String TAG = "SDL"; private static final int SDL_MAJOR_VERSION = 3; private static final int SDL_MINOR_VERSION = 4; - private static final int SDL_MICRO_VERSION = 8; + private static final int SDL_MICRO_VERSION = 10; /* // Display InputType.SOURCE/CLASS of events and devices // @@ -530,7 +530,8 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh if (mHIDDeviceManager != null) { mHIDDeviceManager.setFrozen(true); - } + } + if (!mHasMultiWindow) { pauseNativeThread(); } @@ -543,7 +544,8 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh if (mHIDDeviceManager != null) { mHIDDeviceManager.setFrozen(false); - } + } + if (!mHasMultiWindow) { resumeNativeThread(); } @@ -616,6 +618,14 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh super.onWindowFocusChanged(hasFocus); Log.v(TAG, "onWindowFocusChanged(): " + hasFocus); + // If we are gaining focus, we can always try to restore our USB devices. If we are losing focus, + // only try to relinquish them if we don't have background events allowed (for multi-window Android setups). + if (hasFocus || !SDLActivity.nativeGetHintBoolean("SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS", false)) { + if (mHIDDeviceManager != null) { + mHIDDeviceManager.setFrozen(!hasFocus); + } + } + if (SDLActivity.mBrokenLibraries) { return; } @@ -1481,11 +1491,11 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh if (SDLControllerManager.isDeviceSDLJoystick(deviceId)) { // Note that we process events with specific key codes here if (event.getAction() == KeyEvent.ACTION_DOWN) { - if (SDLControllerManager.onNativePadDown(deviceId, keyCode)) { + if (SDLControllerManager.onNativePadDown(deviceId, keyCode, event.getScanCode())) { return true; } } else if (event.getAction() == KeyEvent.ACTION_UP) { - if (SDLControllerManager.onNativePadUp(deviceId, keyCode)) { + if (SDLControllerManager.onNativePadUp(deviceId, keyCode, event.getScanCode())) { return true; } } @@ -1963,7 +1973,7 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh Intent i = new Intent(Intent.ACTION_VIEW); i.setData(Uri.parse(url)); - int flags = Intent.FLAG_ACTIVITY_NO_HISTORY + int flags = Intent.FLAG_ACTIVITY_NO_HISTORY | Intent.FLAG_ACTIVITY_MULTIPLE_TASK | Intent.FLAG_ACTIVITY_NEW_DOCUMENT; i.addFlags(flags); @@ -2227,3 +2237,4 @@ class SDLClipboardHandler implements SDLActivity.onNativeClipboardChanged(); } } + diff --git a/platforms/android/app/src/main/java/org/libsdl/app/SDLControllerManager.java b/platforms/android/app/src/main/java/org/libsdl/app/SDLControllerManager.java index 7655ecfd6f..8681d050b7 100644 --- a/platforms/android/app/src/main/java/org/libsdl/app/SDLControllerManager.java +++ b/platforms/android/app/src/main/java/org/libsdl/app/SDLControllerManager.java @@ -10,6 +10,10 @@ import android.hardware.lights.Light; import android.hardware.lights.LightsRequest; import android.hardware.lights.LightsManager; import android.hardware.lights.LightState; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; import android.graphics.Color; import android.os.Build; import android.os.VibrationEffect; @@ -30,16 +34,18 @@ public class SDLControllerManager static native void nativeAddJoystick(int device_id, String name, String desc, int vendor_id, int product_id, int button_mask, - int naxes, int axis_mask, int nhats, boolean can_rumble, boolean has_rgb_led); + int naxes, int axis_mask, int nhats, boolean can_rumble, boolean has_rgb_led, + boolean has_accelerometer, boolean has_gyroscope); static native void nativeRemoveJoystick(int device_id); static native void nativeAddHaptic(int device_id, String name); static native void nativeRemoveHaptic(int device_id); - static public native boolean onNativePadDown(int device_id, int keycode); - static public native boolean onNativePadUp(int device_id, int keycode); + static public native boolean onNativePadDown(int device_id, int keycode, int scancode); + static public native boolean onNativePadUp(int device_id, int keycode, int scancode); static native void onNativeJoy(int device_id, int axis, float value); static native void onNativeHat(int device_id, int hat_id, int x, int y); + static native void onNativeJoySensor(int device_id, int sensor_type, long sensor_timestamp, float x, float y, float z); protected static SDLJoystickHandler mJoystickHandler; protected static SDLHapticHandler mHapticHandler; @@ -81,6 +87,13 @@ public class SDLControllerManager mJoystickHandler.setLED(device_id, red, green, blue); } + /** + * This method is called by SDL using JNI. + */ + static void joystickSetSensorsEnabled(int device_id, boolean enabled) { + mJoystickHandler.setSensorsEnabled(device_id, enabled); + } + /** * This method is called by SDL using JNI. */ @@ -153,6 +166,10 @@ class SDLJoystickHandler { ArrayList hats; ArrayList lights; LightsManager.LightsSession lightsSession; + SensorManager sensorManager; + SDLJoySensorListener sensorListener; + Sensor accelerometerSensor; + Sensor gyroscopeSensor; } static class RangeComparator implements Comparator { @Override @@ -225,12 +242,13 @@ class SDLJoystickHandler { joystick.desc = getJoystickDescriptor(joystickDevice); joystick.axes = new ArrayList(); joystick.hats = new ArrayList(); + java.util.Set axisStrsSet = new java.util.HashSet(); joystick.lights = new ArrayList(); List ranges = joystickDevice.getMotionRanges(); Collections.sort(ranges, new RangeComparator()); for (InputDevice.MotionRange range : ranges) { - if ((range.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) { + if (((range.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) && axisStrsSet.add(range.getAxis())) { if (range.getAxis() == MotionEvent.AXIS_HAT_X || range.getAxis() == MotionEvent.AXIS_HAT_Y) { joystick.hats.add(range); } else { @@ -241,6 +259,8 @@ class SDLJoystickHandler { boolean can_rumble = false; boolean has_rgb_led = false; + boolean has_accelerometer = false; + boolean has_gyroscope = false; if (Build.VERSION.SDK_INT >= 31 /* Android 12.0 (S) */) { VibratorManager vibratorManager = joystickDevice.getVibratorManager(); int[] vibrators = vibratorManager.getVibratorIds(); @@ -258,12 +278,26 @@ class SDLJoystickHandler { joystick.lightsSession = lightsManager.openSession(); has_rgb_led = true; } + SensorManager sensorManager = joystickDevice.getSensorManager(); + if (sensorManager != null) { + joystick.sensorManager = sensorManager; + joystick.sensorListener = new SDLJoySensorListener(joystick.device_id); + joystick.accelerometerSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); + if (joystick.accelerometerSensor != null) { + has_accelerometer = true; + } + joystick.gyroscopeSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE); + if (joystick.gyroscopeSensor != null) { + has_gyroscope = true; + } + } } mJoysticks.add(joystick); SDLControllerManager.nativeAddJoystick(joystick.device_id, joystick.name, joystick.desc, getVendorId(joystickDevice), getProductId(joystickDevice), - getButtonMask(joystickDevice), joystick.axes.size(), getAxisMask(joystick.axes), joystick.hats.size()/2, can_rumble, has_rgb_led); + getButtonMask(joystickDevice), joystick.axes.size(), getAxisMask(joystick.axes), joystick.hats.size()/2, can_rumble, has_rgb_led, + has_accelerometer, has_gyroscope); } } } @@ -508,6 +542,31 @@ class SDLJoystickHandler { } joystick.lightsSession.requestLights(lightsRequest.build()); } + + void setSensorsEnabled(int device_id, boolean enabled) { + if (Build.VERSION.SDK_INT < 31 /* Android 12.0 (S) */) { + return; + } + SDLJoystick joystick = getJoystick(device_id); + if (joystick == null || joystick.sensorManager == null) { + return; + } + if (enabled) { + if (joystick.accelerometerSensor != null) { + SDLSensorManager.registerListener(joystick.sensorManager, joystick.sensorListener, joystick.accelerometerSensor, SensorManager.SENSOR_DELAY_GAME); + } + if (joystick.gyroscopeSensor != null) { + SDLSensorManager.registerListener(joystick.sensorManager, joystick.sensorListener, joystick.gyroscopeSensor, SensorManager.SENSOR_DELAY_GAME); + } + } else { + if (joystick.accelerometerSensor != null) { + SDLSensorManager.unregisterListener(joystick.sensorManager, joystick.sensorListener, joystick.accelerometerSensor); + } + if (joystick.gyroscopeSensor != null) { + SDLSensorManager.unregisterListener(joystick.sensorManager, joystick.sensorListener, joystick.gyroscopeSensor); + } + } + } } class SDLHapticHandler_API31 extends SDLHapticHandler { @@ -933,3 +992,19 @@ class SDLGenericMotionListener_API29 extends SDLGenericMotionListener_API26 { return penDevice.isExternal() ? SDL_PEN_DEVICE_TYPE_INDIRECT : SDL_PEN_DEVICE_TYPE_DIRECT; } } + +class SDLJoySensorListener implements SensorEventListener { + int device_id; + + public SDLJoySensorListener(int device_id) { + this.device_id = device_id; + } + + @Override + public void onAccuracyChanged(Sensor sensor, int accuracy) {} + + @Override + public void onSensorChanged(SensorEvent event) { + SDLControllerManager.onNativeJoySensor(device_id, event.sensor.getType(), event.timestamp, event.values[0], event.values[1], event.values[2]); + } +} diff --git a/platforms/android/app/src/main/java/org/libsdl/app/SDLSensorManager.java b/platforms/android/app/src/main/java/org/libsdl/app/SDLSensorManager.java new file mode 100644 index 0000000000..586e3fab6e --- /dev/null +++ b/platforms/android/app/src/main/java/org/libsdl/app/SDLSensorManager.java @@ -0,0 +1,32 @@ +package org.libsdl.app; + +import android.hardware.Sensor; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; + +// This class coordinates synchronized access to sensor manager registration +// +// This prevents a java.util.ConcurrentModificationException exception on +// Android 16, specifically on the Samsung Tab S9 Ultra. + +class SDLSensorManager +{ + static private SDLSensorManager mManager = new SDLSensorManager(); + + public static void registerListener(SensorManager manager, SensorEventListener listener, Sensor sensor, int samplingPeriodUs) { + mManager.RegisterListener(manager, listener, sensor, samplingPeriodUs); + } + + public static void unregisterListener(SensorManager manager, SensorEventListener listener, Sensor sensor) { + mManager.UnregisterListener(manager, listener, sensor); + } + + private synchronized void RegisterListener(SensorManager manager, SensorEventListener listener, Sensor sensor, int samplingPeriodUs) { + manager.registerListener(listener, sensor, samplingPeriodUs, null); + } + + private synchronized void UnregisterListener(SensorManager manager, SensorEventListener listener, Sensor sensor) { + manager.unregisterListener(listener, sensor); + } +} + diff --git a/platforms/android/app/src/main/java/org/libsdl/app/SDLSurface.java b/platforms/android/app/src/main/java/org/libsdl/app/SDLSurface.java index 5ed335ac39..8d56658958 100644 --- a/platforms/android/app/src/main/java/org/libsdl/app/SDLSurface.java +++ b/platforms/android/app/src/main/java/org/libsdl/app/SDLSurface.java @@ -47,6 +47,9 @@ public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback, // Is SurfaceView ready for rendering protected boolean mIsSurfaceReady; + // Is on-screen keyboard visible + protected boolean mKeyboardVisible; + // Pinch events private final ScaleGestureDetector scaleGestureDetector; @@ -213,6 +216,18 @@ public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback, WindowInsets.Type.displayCutout()); SDLActivity.onNativeInsetsChanged(combined.left, combined.right, combined.top, combined.bottom); + + if (insets.isVisible(WindowInsets.Type.ime())) { + if (!mKeyboardVisible) { + mKeyboardVisible = true; + SDLActivity.onNativeScreenKeyboardShown(); + } + } else { + if (mKeyboardVisible) { + mKeyboardVisible = false; + SDLActivity.onNativeScreenKeyboardHidden(); + } + } } // Pass these to any child views in case they need them @@ -318,11 +333,11 @@ public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback, protected void enableSensor(int sensortype, boolean enabled) { // TODO: This uses getDefaultSensor - what if we have >1 accels? if (enabled) { - mSensorManager.registerListener(this, + SDLSensorManager.registerListener(mSensorManager, this, mSensorManager.getDefaultSensor(sensortype), - SensorManager.SENSOR_DELAY_GAME, null); + SensorManager.SENSOR_DELAY_GAME); } else { - mSensorManager.unregisterListener(this, + SDLSensorManager.unregisterListener(mSensorManager, this, mSensorManager.getDefaultSensor(sensortype)); } } From 131a09f317b479cef9375a84c7aafd9ceced860f Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 9 Jun 2026 23:47:45 -0600 Subject: [PATCH 013/150] Implement JPA particle batching --- extern/aurora | 2 +- .../include/JSystem/JParticle/JPABaseShape.h | 65 ++-- .../include/JSystem/JParticle/JPAResource.h | 28 +- libs/JSystem/src/JParticle/JPABaseShape.cpp | 299 ++++++++++++++++-- libs/JSystem/src/JParticle/JPAResource.cpp | 281 +++++++++++++++- 5 files changed, 616 insertions(+), 59 deletions(-) diff --git a/extern/aurora b/extern/aurora index 9bc79d649c..5143394381 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 9bc79d649cd54939961f349d36acf8dd6b143be2 +Subproject commit 514339438178ef2bed1b14e5149d90ece0c6e0cc diff --git a/libs/JSystem/include/JSystem/JParticle/JPABaseShape.h b/libs/JSystem/include/JSystem/JParticle/JPABaseShape.h index 795a5a2628..ec15c0430a 100644 --- a/libs/JSystem/include/JSystem/JParticle/JPABaseShape.h +++ b/libs/JSystem/include/JSystem/JParticle/JPABaseShape.h @@ -3,6 +3,20 @@ #include +#if TARGET_PC +#include + +struct ParticleDrawCtx { + bool batch; // off = immediate mode + bool useTexMtx; // UVs transformed by texMtx + bool useClr0; // prm color in GX_VA_CLR0 + bool useClr1; // env color in GX_VA_CLR1 + Mtx texMtx; + GXColor clr0; + GXColor clr1; +}; +#endif + struct JPAEmitterWorkData; class JPABaseParticle; class JKRHeap; @@ -75,6 +89,9 @@ public: const GXTevColorArg* getTevColorArg() const { return st_ca[(pBsd->mFlags >> 0x0F) & 0x07]; } const GXTevAlphaArg* getTevAlphaArg() const { return st_aa[(pBsd->mFlags >> 0x12) & 0x01]; } +#if TARGET_PC + u32 getTevColorArgSel() const { return (pBsd->mFlags >> 0x0F) & 0x07; } +#endif u32 getType() const { return (pBsd->mFlags >> 0) & 0x0F; } u32 getDirType() const { return (pBsd->mFlags >> 4) & 0x07; } @@ -186,26 +203,34 @@ void JPARegistPrm(JPAEmitterWorkData*); void JPARegistEnv(JPAEmitterWorkData*); void JPARegistPrmEnv(JPAEmitterWorkData*); -void JPADrawPoint(JPAEmitterWorkData*, JPABaseParticle*); -void JPADrawLine(JPAEmitterWorkData*, JPABaseParticle*); -void JPADrawRotBillboard(JPAEmitterWorkData*, JPABaseParticle*); -void JPADrawBillboard(JPAEmitterWorkData*, JPABaseParticle*); -void JPADrawRotDirection(JPAEmitterWorkData*, JPABaseParticle*); -void JPADrawDirection(JPAEmitterWorkData*, JPABaseParticle*); -void JPADrawRotation(JPAEmitterWorkData*, JPABaseParticle*); -void JPADrawDBillboard(JPAEmitterWorkData*, JPABaseParticle*); -void JPADrawRotYBillboard(JPAEmitterWorkData*, JPABaseParticle*); -void JPADrawYBillboard(JPAEmitterWorkData*, JPABaseParticle*); -void JPADrawParticleCallBack(JPAEmitterWorkData*, JPABaseParticle*); -void JPALoadTexAnm(JPAEmitterWorkData*, JPABaseParticle*); -void JPASetPointSize(JPAEmitterWorkData*, JPABaseParticle*); -void JPASetLineWidth(JPAEmitterWorkData*, JPABaseParticle*); -void JPALoadCalcTexCrdMtxAnm(JPAEmitterWorkData*, JPABaseParticle*); -void JPARegistAlpha(JPAEmitterWorkData*, JPABaseParticle*); -void JPARegistEnv(JPAEmitterWorkData*, JPABaseParticle*); -void JPARegistAlphaEnv(JPAEmitterWorkData*, JPABaseParticle*); -void JPARegistPrmAlpha(JPAEmitterWorkData*, JPABaseParticle*); -void JPARegistPrmAlphaEnv(JPAEmitterWorkData*, JPABaseParticle*); +#if TARGET_PC +#define JPA_DRAW_PARTICLE_ARGS JPAEmitterWorkData*, JPABaseParticle*, ParticleDrawCtx* +#else +#define JPA_DRAW_PARTICLE_ARGS JPAEmitterWorkData*, JPABaseParticle* +#endif + +void JPADrawPoint(JPA_DRAW_PARTICLE_ARGS); +void JPADrawLine(JPA_DRAW_PARTICLE_ARGS); +void JPADrawRotBillboard(JPA_DRAW_PARTICLE_ARGS); +void JPADrawBillboard(JPA_DRAW_PARTICLE_ARGS); +void JPADrawRotDirection(JPA_DRAW_PARTICLE_ARGS); +void JPADrawDirection(JPA_DRAW_PARTICLE_ARGS); +void JPADrawRotation(JPA_DRAW_PARTICLE_ARGS); +void JPADrawDBillboard(JPA_DRAW_PARTICLE_ARGS); +void JPADrawRotYBillboard(JPA_DRAW_PARTICLE_ARGS); +void JPADrawYBillboard(JPA_DRAW_PARTICLE_ARGS); +void JPADrawParticleCallBack(JPA_DRAW_PARTICLE_ARGS); +void JPALoadTexAnm(JPA_DRAW_PARTICLE_ARGS); +void JPASetPointSize(JPA_DRAW_PARTICLE_ARGS); +void JPASetLineWidth(JPA_DRAW_PARTICLE_ARGS); +void JPALoadCalcTexCrdMtxAnm(JPA_DRAW_PARTICLE_ARGS); +void JPARegistAlpha(JPA_DRAW_PARTICLE_ARGS); +void JPARegistEnv(JPA_DRAW_PARTICLE_ARGS); +void JPARegistAlphaEnv(JPA_DRAW_PARTICLE_ARGS); +void JPARegistPrmAlpha(JPA_DRAW_PARTICLE_ARGS); +void JPARegistPrmAlphaEnv(JPA_DRAW_PARTICLE_ARGS); + +#undef JPA_DRAW_PARTICLE_ARGS #if TARGET_PC void JPAInterpBillboard(JPAEmitterWorkData*, JPABaseParticle*); diff --git a/libs/JSystem/include/JSystem/JParticle/JPAResource.h b/libs/JSystem/include/JSystem/JParticle/JPAResource.h index ebf2127033..07563fa73d 100644 --- a/libs/JSystem/include/JSystem/JParticle/JPAResource.h +++ b/libs/JSystem/include/JSystem/JParticle/JPAResource.h @@ -17,6 +17,10 @@ class JPADynamicsBlock; class JPAFieldBlock; class JPAKeyBlock; +#if TARGET_PC +struct ParticleDrawCtx; +#endif + /** * @ingroup jsystem-jparticle * @@ -50,13 +54,19 @@ public: public: typedef void (*EmitterFunc)(JPAEmitterWorkData*); typedef void (*ParticleFunc)(JPAEmitterWorkData*, JPABaseParticle*); +#if TARGET_PC + typedef void (*DrawParticleFunc)(JPAEmitterWorkData*, JPABaseParticle*, + ParticleDrawCtx*); +#else + typedef ParticleFunc DrawParticleFunc; +#endif /* 0x00 */ EmitterFunc* mpCalcEmitterFuncList; /* 0x04 */ EmitterFunc* mpDrawEmitterFuncList; /* 0x08 */ EmitterFunc* mpDrawEmitterChildFuncList; /* 0x0C */ ParticleFunc* mpCalcParticleFuncList; - /* 0x10 */ ParticleFunc* mpDrawParticleFuncList; + /* 0x10 */ DrawParticleFunc* mpDrawParticleFuncList; /* 0x14 */ ParticleFunc* mpCalcParticleChildFuncList; - /* 0x18 */ ParticleFunc* mpDrawParticleChildFuncList; + /* 0x18 */ DrawParticleFunc* mpDrawParticleChildFuncList; /* 0x1C */ JPABaseShape* pBsp; /* 0x20 */ JPAExtraShape* pEsp; @@ -77,6 +87,20 @@ public: /* 0x45 */ u8 mpDrawParticleFuncListNum; /* 0x46 */ u8 mpCalcParticleChildFuncListNum; /* 0x47 */ u8 mpDrawParticleChildFuncListNum; + +#if TARGET_PC + struct BatchInfo { + f32 vtxPos[8][3]; + f32 vtxUv[8][2]; + u8 vtxCount; // 4 (quad) or 8 (cross) + bool supported; // draw func list contains only batchable funcs + bool hasPtclColor; // per-particle JPARegist* func is present + bool hasPtclTexMtx; // JPALoadCalcTexCrdMtxAnm is present + }; + BatchInfo mBatchInfo; + + void initBatchInfo(); +#endif }; #endif /* JPARESOURCE_H */ diff --git a/libs/JSystem/src/JParticle/JPABaseShape.cpp b/libs/JSystem/src/JParticle/JPABaseShape.cpp index add61135fe..a569d96842 100644 --- a/libs/JSystem/src/JParticle/JPABaseShape.cpp +++ b/libs/JSystem/src/JParticle/JPABaseShape.cpp @@ -14,6 +14,33 @@ #endif #include "tracy/Tracy.hpp" +#if TARGET_PC +#define JPA_DRAW_CTX_PARAM , ParticleDrawCtx* ctx + +namespace { +GXColor emitter_prm_color(JPAEmitterWorkData* work) { + JPABaseEmitter* emtr = work->mpEmtr; + GXColor prm = emtr->mPrmClr; + prm.r = COLOR_MULTI(prm.r, emtr->mGlobalPrmClr.r); + prm.g = COLOR_MULTI(prm.g, emtr->mGlobalPrmClr.g); + prm.b = COLOR_MULTI(prm.b, emtr->mGlobalPrmClr.b); + prm.a = COLOR_MULTI(prm.a, emtr->mGlobalPrmClr.a); + return prm; +} + +GXColor emitter_env_color(JPAEmitterWorkData* work) { + JPABaseEmitter* emtr = work->mpEmtr; + GXColor env = emtr->mEnvClr; + env.r = COLOR_MULTI(env.r, emtr->mGlobalEnvClr.r); + env.g = COLOR_MULTI(env.g, emtr->mGlobalEnvClr.g); + env.b = COLOR_MULTI(env.b, emtr->mGlobalEnvClr.b); + return env; +} +} // namespace +#else +#define JPA_DRAW_CTX_PARAM +#endif + void JPASetPointSize(JPAEmitterWorkData* work) { GXSetPointSize((u8)(25.0f * work->mGlobalPtclScl.x), GX_TO_ONE); } @@ -22,15 +49,16 @@ void JPASetLineWidth(JPAEmitterWorkData* work) { GXSetLineWidth((u8)(25.0f * work->mGlobalPtclScl.x), GX_TO_ONE); } -void JPASetPointSize(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { +void JPASetPointSize(JPAEmitterWorkData* work, JPABaseParticle* ptcl JPA_DRAW_CTX_PARAM) { GXSetPointSize((u8)(ptcl->mParticleScaleX * (25.0f * work->mGlobalPtclScl.x)), GX_TO_ONE); } -void JPASetLineWidth(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { +void JPASetLineWidth(JPAEmitterWorkData* work, JPABaseParticle* ptcl JPA_DRAW_CTX_PARAM) { GXSetLineWidth((u8)(ptcl->mParticleScaleX * (25.0f * work->mGlobalPtclScl.x)), GX_TO_ONE); } void JPARegistPrm(JPAEmitterWorkData* work) { + ZoneScoped; JPABaseEmitter* emtr = work->mpEmtr; GXColor prm = emtr->mPrmClr; prm.r = COLOR_MULTI(prm.r, emtr->mGlobalPrmClr.r); @@ -41,6 +69,7 @@ void JPARegistPrm(JPAEmitterWorkData* work) { } void JPARegistEnv(JPAEmitterWorkData* work) { + ZoneScoped; JPABaseEmitter* emtr = work->mpEmtr; GXColor env = emtr->mEnvClr; env.r = COLOR_MULTI(env.r, emtr->mGlobalEnvClr.r); @@ -50,6 +79,7 @@ void JPARegistEnv(JPAEmitterWorkData* work) { } void JPARegistPrmEnv(JPAEmitterWorkData* work) { + ZoneScoped; JPABaseEmitter* emtr = work->mpEmtr; GXColor prm = emtr->mPrmClr; GXColor env = emtr->mEnvClr; @@ -64,7 +94,8 @@ void JPARegistPrmEnv(JPAEmitterWorkData* work) { GXSetTevColor(GX_TEVREG1, env); } -void JPARegistAlpha(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { +void JPARegistAlpha(JPAEmitterWorkData* work, JPABaseParticle* ptcl JPA_DRAW_CTX_PARAM) { + ZoneScoped; JPABaseEmitter* emtr = work->mpEmtr; GXColor prm = emtr->mPrmClr; prm.r = COLOR_MULTI(prm.r, emtr->mGlobalPrmClr.r); @@ -72,10 +103,19 @@ void JPARegistAlpha(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { prm.b = COLOR_MULTI(prm.b, emtr->mGlobalPrmClr.b); prm.a = COLOR_MULTI(prm.a, emtr->mGlobalPrmClr.a); prm.a = COLOR_MULTI(prm.a, ptcl->mPrmColorAlphaAnm); +#if TARGET_PC + if (ctx->batch) { + ctx->clr0 = prm; + if (ctx->useClr1) { + ctx->clr1 = emitter_env_color(work); + } + return; + } +#endif GXSetTevColor(GX_TEVREG0, prm); } -void JPARegistPrmAlpha(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { +void JPARegistPrmAlpha(JPAEmitterWorkData* work, JPABaseParticle* ptcl JPA_DRAW_CTX_PARAM) { ZoneScoped; JPABaseEmitter* emtr = work->mpEmtr; GXColor prm = ptcl->mPrmClr; @@ -84,10 +124,19 @@ void JPARegistPrmAlpha(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { prm.b = COLOR_MULTI(prm.b, emtr->mGlobalPrmClr.b); prm.a = COLOR_MULTI(prm.a, emtr->mGlobalPrmClr.a); prm.a = COLOR_MULTI(prm.a, ptcl->mPrmColorAlphaAnm); +#if TARGET_PC + if (ctx->batch) { + ctx->clr0 = prm; + if (ctx->useClr1) { + ctx->clr1 = emitter_env_color(work); + } + return; + } +#endif GXSetTevColor(GX_TEVREG0, prm); } -void JPARegistPrmAlphaEnv(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { +void JPARegistPrmAlphaEnv(JPAEmitterWorkData* work, JPABaseParticle* ptcl JPA_DRAW_CTX_PARAM) { ZoneScoped; JPABaseEmitter* emtr = work->mpEmtr; GXColor prm = ptcl->mPrmClr; @@ -100,11 +149,19 @@ void JPARegistPrmAlphaEnv(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { env.r = COLOR_MULTI(env.r, emtr->mGlobalEnvClr.r); env.g = COLOR_MULTI(env.g, emtr->mGlobalEnvClr.g); env.b = COLOR_MULTI(env.b, emtr->mGlobalEnvClr.b); +#if TARGET_PC + if (ctx->batch) { + ctx->clr0 = prm; + ctx->clr1 = env; + return; + } +#endif GXSetTevColor(GX_TEVREG0, prm); GXSetTevColor(GX_TEVREG1, env); } -void JPARegistAlphaEnv(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { +void JPARegistAlphaEnv(JPAEmitterWorkData* work, JPABaseParticle* ptcl JPA_DRAW_CTX_PARAM) { + ZoneScoped; JPABaseEmitter* emtr = work->mpEmtr; GXColor prm = emtr->mPrmClr; GXColor env = ptcl->mEnvClr; @@ -116,16 +173,31 @@ void JPARegistAlphaEnv(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { env.r = COLOR_MULTI(env.r, emtr->mGlobalEnvClr.r); env.g = COLOR_MULTI(env.g, emtr->mGlobalEnvClr.g); env.b = COLOR_MULTI(env.b, emtr->mGlobalEnvClr.b); +#if TARGET_PC + if (ctx->batch) { + ctx->clr0 = prm; + ctx->clr1 = env; + return; + } +#endif GXSetTevColor(GX_TEVREG0, prm); GXSetTevColor(GX_TEVREG1, env); } -void JPARegistEnv(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { +void JPARegistEnv(JPAEmitterWorkData* work, JPABaseParticle* ptcl JPA_DRAW_CTX_PARAM) { + ZoneScoped; JPABaseEmitter* emtr = work->mpEmtr; GXColor env = ptcl->mEnvClr; env.r = COLOR_MULTI(env.r, emtr->mGlobalEnvClr.r); env.g = COLOR_MULTI(env.g, emtr->mGlobalEnvClr.g); env.b = COLOR_MULTI(env.b, emtr->mGlobalEnvClr.b); +#if TARGET_PC + if (ctx->batch) { + ctx->clr0 = emitter_prm_color(work); + ctx->clr1 = env; + return; + } +#endif GXSetTevColor(GX_TEVREG1, env); } @@ -258,7 +330,7 @@ void JPAGenCalcTexCrdMtxAnm(JPAEmitterWorkData* work) { GXSetTexCoordGen(GX_TEXCOORD0, GX_TG_MTX2x4, GX_TG_TEX0, GX_TEXMTX0); } -void JPALoadCalcTexCrdMtxAnm(JPAEmitterWorkData* work, JPABaseParticle* param_1) { +void JPALoadCalcTexCrdMtxAnm(JPAEmitterWorkData* work, JPABaseParticle* param_1 JPA_DRAW_CTX_PARAM) { ZoneScoped; JPABaseShape* shape = work->mpRes->getBsp(); f32 dVar16 = param_1->mAge; @@ -286,6 +358,12 @@ void JPALoadCalcTexCrdMtxAnm(JPAEmitterWorkData* work, JPABaseParticle* param_1) local_108[2][1] = 0.0f; local_108[2][2] = 1.0f; local_108[2][3] = 0.0f; +#if TARGET_PC + if (ctx->batch) { + MTXCopy(local_108, ctx->texMtx); + return; + } +#endif GXLoadTexMtxImm(local_108, 0x1e, GX_MTX2x4); } @@ -299,7 +377,7 @@ void JPALoadTexAnm(JPAEmitterWorkData* work) { work->mpResMgr->load(work->mpRes->getTexIdx(work->mpEmtr->mTexAnmIdx), GX_TEXMAP0); } -void JPALoadTexAnm(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { +void JPALoadTexAnm(JPAEmitterWorkData* work, JPABaseParticle* ptcl JPA_DRAW_CTX_PARAM) { ZoneScoped; work->mpResMgr->load(work->mpRes->getTexIdx(ptcl->mTexAnmIdx), GX_TEXMAP0); } @@ -429,6 +507,47 @@ static projectionFunc p_prj[3] = { }; #if TARGET_PC +static void emit_batch_quad(JPAEmitterWorkData* work, const ParticleDrawCtx* ctx, + const Mtx posMtx) { + const JPAResource::BatchInfo& info = work->mpRes->mBatchInfo; + + for (int i = 0; i < info.vtxCount; i++) { + Vec localPos = {info.vtxPos[i][0], info.vtxPos[i][1], info.vtxPos[i][2]}; + Vec drawPos; + MTXMultVec(posMtx, &localPos, &drawPos); + + f32 texS = info.vtxUv[i][0]; + f32 texT = info.vtxUv[i][1]; + if (ctx->useTexMtx) { + f32 srcS = texS; + f32 srcT = texT; + texS = ctx->texMtx[0][0] * srcS + ctx->texMtx[0][1] * srcT + ctx->texMtx[0][3]; + texT = ctx->texMtx[1][0] * srcS + ctx->texMtx[1][1] * srcT + ctx->texMtx[1][3]; + } + + GXPosition3f32(drawPos.x, drawPos.y, drawPos.z); + if (ctx->useClr0) { + GXColor4u8(ctx->clr0.r, ctx->clr0.g, ctx->clr0.b, ctx->clr0.a); + } + if (ctx->useClr1) { + GXColor4u8(ctx->clr1.r, ctx->clr1.g, ctx->clr1.b, ctx->clr1.a); + } + GXTexCoord2f32(texS, texT); + } +} + +static void submit_particle_quad( + JPAEmitterWorkData* work, ParticleDrawCtx* ctx, const Mtx posMtx, const u8* dl, u32 dlSize) { + if (ctx->batch) { + emit_batch_quad(work, ctx, posMtx); + return; + } + + GXLoadPosMtxImm(posMtx, GX_PNMTX0); + p_prj[work->mPrjType](work, posMtx); + GXCallDisplayList(dl, dlSize); +} + void JPAInterpBillboard(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { Mtx ptclPosMtx; MTXTrans(ptclPosMtx, ptcl->mPosition.x, ptcl->mPosition.y, ptcl->mPosition.z); @@ -448,7 +567,7 @@ void JPAInterpRotBillboard(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { } #endif -void JPADrawBillboard(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { +void JPADrawBillboard(JPAEmitterWorkData* work, JPABaseParticle* ptcl JPA_DRAW_CTX_PARAM) { if (ptcl->checkStatus(JPAPtclStts_Invisible)) { return; } @@ -473,12 +592,16 @@ void JPADrawBillboard(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { posMtx[2][2] = 1.0f; posMtx[2][3] = pos.z; posMtx[0][1] = posMtx[0][2] = posMtx[1][0] = posMtx[1][2] = posMtx[2][0] = posMtx[2][1] = 0.0f; +#if TARGET_PC + submit_particle_quad(work, ctx, posMtx, jpa_dl, sizeof(jpa_dl)); +#else GXLoadPosMtxImm(posMtx, GX_PNMTX0); p_prj[work->mPrjType](work, posMtx); GXCallDisplayList(jpa_dl, sizeof(jpa_dl)); +#endif } -void JPADrawRotBillboard(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { +void JPADrawRotBillboard(JPAEmitterWorkData* work, JPABaseParticle* ptcl JPA_DRAW_CTX_PARAM) { if (ptcl->checkStatus(JPAPtclStts_Invisible)) { return; } @@ -517,12 +640,16 @@ void JPADrawRotBillboard(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { posMtx[2][2] = 1.0f; posMtx[2][3] = pos.z; posMtx[0][2] = posMtx[1][2] = posMtx[2][0] = posMtx[2][1] = 0.0f; +#if TARGET_PC + submit_particle_quad(work, ctx, posMtx, jpa_dl, sizeof(jpa_dl)); +#else GXLoadPosMtxImm(posMtx, GX_PNMTX0); p_prj[work->mPrjType](work, posMtx); GXCallDisplayList(jpa_dl, sizeof(jpa_dl)); +#endif } -void JPADrawYBillboard(JPAEmitterWorkData* work, JPABaseParticle* param_1) { +void JPADrawYBillboard(JPAEmitterWorkData* work, JPABaseParticle* param_1 JPA_DRAW_CTX_PARAM) { if (param_1->checkStatus(JPAPtclStts_Invisible)) { return; } @@ -542,12 +669,16 @@ void JPADrawYBillboard(JPAEmitterWorkData* work, JPABaseParticle* param_1) { local_38[2][2] = work->mYBBCamMtx[2][2]; local_38[2][3] = local_48.z; local_38[0][1] = local_38[0][2] = local_38[1][0] = local_38[2][0] = 0.0f; +#if TARGET_PC + submit_particle_quad(work, ctx, local_38, jpa_dl, sizeof(jpa_dl)); +#else GXLoadPosMtxImm(local_38, GX_PNMTX0); p_prj[work->mPrjType](work, local_38); GXCallDisplayList(jpa_dl, sizeof(jpa_dl)); +#endif } -void JPADrawRotYBillboard(JPAEmitterWorkData* work, JPABaseParticle* param_1) { +void JPADrawRotYBillboard(JPAEmitterWorkData* work, JPABaseParticle* param_1 JPA_DRAW_CTX_PARAM) { if (param_1->checkStatus(JPAPtclStts_Invisible)) { return; } @@ -576,9 +707,13 @@ void JPADrawRotYBillboard(JPAEmitterWorkData* work, JPABaseParticle* param_1) { local_38[2][1] = local_94 * fVar1; local_38[2][2] = local_90; local_38[2][3] = local_48.z; +#if TARGET_PC + submit_particle_quad(work, ctx, local_38, jpa_dl, sizeof(jpa_dl)); +#else GXLoadPosMtxImm(local_38, GX_PNMTX0); p_prj[work->mPrjType](work, local_38); GXCallDisplayList(jpa_dl, sizeof(jpa_dl)); +#endif } void dirTypeVel(JPAEmitterWorkData const* work, JPABaseParticle const* param_1, @@ -741,6 +876,88 @@ static u8* p_dl[2] = { }; #if TARGET_PC +static bool make_direction_mtx(JPAEmitterWorkData* work, JPABaseParticle* ptcl, Mtx posMtx) { + JGeometry::TVec3 axisY; + JGeometry::TVec3 axisZ; + JGeometry::TVec3 baseAxis(ptcl->mBaseAxis); + p_direction[work->mDirType](work, ptcl, &axisY); + if (axisY.isZero()) { + return false; + } + + axisY.normalize(); + axisZ.cross(baseAxis, axisY); + if (axisZ.isZero()) { + return false; + } + + axisZ.normalize(); + baseAxis.cross(axisY, axisZ); + baseAxis.normalize(); + ptcl->mBaseAxis.set(baseAxis); + + f32 scaleX = work->mGlobalPtclScl.x * ptcl->mParticleScaleX; + f32 scaleY = work->mGlobalPtclScl.y * ptcl->mParticleScaleY; + posMtx[0][0] = baseAxis.x; + posMtx[0][1] = axisY.x; + posMtx[0][2] = axisZ.x; + posMtx[0][3] = ptcl->mPosition.x; + posMtx[1][0] = baseAxis.y; + posMtx[1][1] = axisY.y; + posMtx[1][2] = axisZ.y; + posMtx[1][3] = ptcl->mPosition.y; + posMtx[2][0] = baseAxis.z; + posMtx[2][1] = axisY.z; + posMtx[2][2] = axisZ.z; + posMtx[2][3] = ptcl->mPosition.z; + p_plane[work->mPlaneType](posMtx, scaleX, scaleY); + return true; +} + +static bool make_rot_direction_mtx(JPAEmitterWorkData* work, JPABaseParticle* ptcl, Mtx posMtx) { + f32 sinRot = JMASSin(ptcl->mRotateAngle); + f32 cosRot = JMASCos(ptcl->mRotateAngle); + JGeometry::TVec3 axisY; + JGeometry::TVec3 axisZ; + JGeometry::TVec3 baseAxis(ptcl->mBaseAxis); + p_direction[work->mDirType](work, ptcl, &axisY); + if (axisY.isZero()) { + return false; + } + + axisY.normalize(); + axisZ.cross(baseAxis, axisY); + if (axisZ.isZero()) { + return false; + } + + axisZ.normalize(); + baseAxis.cross(axisY, axisZ); + baseAxis.normalize(); + ptcl->mBaseAxis.set(baseAxis); + + f32 scaleX = work->mGlobalPtclScl.x * ptcl->mParticleScaleX; + f32 scaleY = work->mGlobalPtclScl.y * ptcl->mParticleScaleY; + Mtx rotMtx; + Mtx dirMtx; + p_rot[work->mRotType](sinRot, cosRot, rotMtx); + p_plane[work->mPlaneType](rotMtx, scaleX, scaleY); + dirMtx[0][0] = baseAxis.x; + dirMtx[0][1] = axisY.x; + dirMtx[0][2] = axisZ.x; + dirMtx[0][3] = ptcl->mPosition.x; + dirMtx[1][0] = baseAxis.y; + dirMtx[1][1] = axisY.y; + dirMtx[1][2] = axisZ.y; + dirMtx[1][3] = ptcl->mPosition.y; + dirMtx[2][0] = baseAxis.z; + dirMtx[2][1] = axisY.z; + dirMtx[2][2] = axisZ.z; + dirMtx[2][3] = ptcl->mPosition.z; + MTXConcat(dirMtx, rotMtx, posMtx); + return true; +} + void JPAInterpDirection(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { JGeometry::TVec3 axisY; JGeometry::TVec3 axisZ; @@ -823,7 +1040,7 @@ void JPAInterpRotDirection(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { } #endif -void JPADrawDirection(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { +void JPADrawDirection(JPAEmitterWorkData* work, JPABaseParticle* ptcl JPA_DRAW_CTX_PARAM) { if (ptcl->checkStatus(JPAPtclStts_Invisible)) { return; } @@ -832,8 +1049,12 @@ void JPADrawDirection(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { Mtx posMtx; #if TARGET_PC - if (!dusk::frame_interp::lookup_replacement(ptcl, posMtx)) -#endif + if (!dusk::frame_interp::lookup_replacement(ptcl, posMtx) && + !make_direction_mtx(work, ptcl, posMtx)) + { + return; + } +#else { JGeometry::TVec3 axisY; JGeometry::TVec3 axisZ; @@ -869,14 +1090,19 @@ void JPADrawDirection(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { posMtx[2][3] = ptcl->mPosition.z; p_plane[work->mPlaneType](posMtx, scaleX, scaleY); } +#endif MTXConcat(work->mPosCamMtx, posMtx, posMtx); +#if TARGET_PC + submit_particle_quad(work, ctx, posMtx, p_dl[work->mDLType], sizeof(jpa_dl)); +#else GXLoadPosMtxImm(posMtx, GX_PNMTX0); p_prj[work->mPrjType](work, posMtx); GXCallDisplayList(p_dl[work->mDLType], sizeof(jpa_dl)); +#endif } -void JPADrawRotDirection(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { +void JPADrawRotDirection(JPAEmitterWorkData* work, JPABaseParticle* ptcl JPA_DRAW_CTX_PARAM) { if (ptcl->checkStatus(JPAPtclStts_Invisible)) { return; } @@ -886,8 +1112,12 @@ void JPADrawRotDirection(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { Mtx mtx1; Mtx mtx2; #if TARGET_PC - if (!dusk::frame_interp::lookup_replacement(ptcl, mtx1)) -#endif + if (!dusk::frame_interp::lookup_replacement(ptcl, mtx1) && + !make_rot_direction_mtx(work, ptcl, mtx1)) + { + return; + } +#else { f32 sinRot = JMASSin(ptcl->mRotateAngle); f32 cosRot = JMASCos(ptcl->mRotateAngle); @@ -927,13 +1157,18 @@ void JPADrawRotDirection(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { mtx2[2][3] = ptcl->mPosition.z; MTXConcat(mtx2, mtx1, mtx1); } +#endif MTXConcat(work->mPosCamMtx, mtx1, mtx2); +#if TARGET_PC + submit_particle_quad(work, ctx, mtx2, p_dl[work->mDLType], sizeof(jpa_dl)); +#else GXLoadPosMtxImm(mtx2, GX_PNMTX0); p_prj[work->mPrjType](work, mtx2); GXCallDisplayList(p_dl[work->mDLType], sizeof(jpa_dl)); +#endif } -void JPADrawDBillboard(JPAEmitterWorkData* param_0, JPABaseParticle* param_1) { +void JPADrawDBillboard(JPAEmitterWorkData* param_0, JPABaseParticle* param_1 JPA_DRAW_CTX_PARAM) { if (param_1->checkStatus(JPAPtclStts_Invisible)) { return; } @@ -970,7 +1205,7 @@ void JPADrawDBillboard(JPAEmitterWorkData* param_0, JPABaseParticle* param_1) { GXCallDisplayList(jpa_dl, sizeof(jpa_dl)); } -void JPADrawRotation(JPAEmitterWorkData* param_0, JPABaseParticle* param_1) { +void JPADrawRotation(JPAEmitterWorkData* param_0, JPABaseParticle* param_1 JPA_DRAW_CTX_PARAM) { if (param_1->checkStatus(JPAPtclStts_Invisible)) { return; } @@ -988,12 +1223,16 @@ void JPADrawRotation(JPAEmitterWorkData* param_0, JPABaseParticle* param_1) { auStack_88[1][3] = param_1->mPosition.y; auStack_88[2][3] = param_1->mPosition.z; MTXConcat(param_0->mPosCamMtx, auStack_88, auStack_88); +#if TARGET_PC + submit_particle_quad(param_0, ctx, auStack_88, p_dl[param_0->mDLType], sizeof(jpa_dl)); +#else GXLoadPosMtxImm(auStack_88, 0); p_prj[param_0->mPrjType](param_0, auStack_88); GXCallDisplayList(p_dl[param_0->mDLType], sizeof(jpa_dl)); +#endif } -void JPADrawPoint(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { +void JPADrawPoint(JPAEmitterWorkData* work, JPABaseParticle* ptcl JPA_DRAW_CTX_PARAM) { if (ptcl->checkStatus(JPAPtclStts_Invisible)) { return; } @@ -1010,7 +1249,7 @@ void JPADrawPoint(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { GXSetVtxDesc(GX_VA_TEX0, GX_INDEX8); } -void JPADrawLine(JPAEmitterWorkData* param_0, JPABaseParticle* param_1) { +void JPADrawLine(JPAEmitterWorkData* param_0, JPABaseParticle* param_1 JPA_DRAW_CTX_PARAM) { if (param_1->checkStatus(JPAPtclStts_Invisible)) { return; } @@ -1086,7 +1325,7 @@ void JPADrawStripe(JPAEmitterWorkData* param_0) { GXSetVtxDesc(GX_VA_POS, GX_DIRECT); GXSetVtxDesc(GX_VA_TEX0, GX_DIRECT); GXBegin(GX_TRIANGLESTRIP, GX_VTXFMT1, ptcl_num << 1); - for (JPANode* node = startNode; node != param_0->mpAlivePtcl->getEnd(); + for (JPANode* node = startNode; node != param_0->mpAlivePtcl->getEnd(); node = node_func(node), coord += step) { param_0->mpCurNode = node; JPABaseParticle* particle = node->getObject(); @@ -1111,7 +1350,7 @@ void JPADrawStripe(JPAEmitterWorkData* param_0) { } particle->mBaseAxis.cross(local_f8, local_104); particle->mBaseAxis.normalize(); - + local_c8[0][0] = local_104.x; local_c8[0][1] = local_f8.x; local_c8[0][2] = particle->mBaseAxis.x; @@ -1177,7 +1416,7 @@ void JPADrawStripeX(JPAEmitterWorkData* param_0) { GXSetVtxDesc(GX_VA_POS, GX_DIRECT); GXSetVtxDesc(GX_VA_TEX0, GX_DIRECT); GXBegin(GX_TRIANGLESTRIP, GX_VTXFMT1, ptcl_num << 1); - for (JPANode* node = startNode; node != param_0->mpAlivePtcl->getEnd(); + for (JPANode* node = startNode; node != param_0->mpAlivePtcl->getEnd(); node = node_func(node), coord += step) { param_0->mpCurNode = node; JPABaseParticle* particle = node->getObject(); @@ -1202,7 +1441,7 @@ void JPADrawStripeX(JPAEmitterWorkData* param_0) { } particle->mBaseAxis.cross(local_c0, local_cc); particle->mBaseAxis.normalize(); - + local_90[0][0] = local_cc.x; local_90[0][1] = local_c0.x; local_90[0][2] = particle->mBaseAxis.x; @@ -1227,7 +1466,7 @@ void JPADrawStripeX(JPAEmitterWorkData* param_0) { coord = start_coord; GXBegin(GX_TRIANGLESTRIP, GX_VTXFMT1, ptcl_num << 1); - for (JPANode* node = startNode; node != param_0->mpAlivePtcl->getEnd(); + for (JPANode* node = startNode; node != param_0->mpAlivePtcl->getEnd(); node = node_func(node), coord += step) { param_0->mpCurNode = node; JPABaseParticle* particle = node->getObject(); @@ -1252,7 +1491,7 @@ void JPADrawStripeX(JPAEmitterWorkData* param_0) { } particle->mBaseAxis.cross(local_c0, local_cc); particle->mBaseAxis.normalize(); - + local_90[0][0] = local_cc.x; local_90[0][1] = local_c0.x; local_90[0][2] = particle->mBaseAxis.x; @@ -1289,7 +1528,7 @@ void JPADrawEmitterCallBackB(JPAEmitterWorkData* work) { emtr->mpEmtrCallBack->draw(emtr); } -void JPADrawParticleCallBack(JPAEmitterWorkData* work, JPABaseParticle* ptcl) { +void JPADrawParticleCallBack(JPAEmitterWorkData* work, JPABaseParticle* ptcl JPA_DRAW_CTX_PARAM) { JPABaseEmitter* emtr = work->mpEmtr; if (emtr->mpPtclCallBack == NULL) { return; diff --git a/libs/JSystem/src/JParticle/JPAResource.cpp b/libs/JSystem/src/JParticle/JPAResource.cpp index 59e679d449..2d5d872bac 100644 --- a/libs/JSystem/src/JParticle/JPAResource.cpp +++ b/libs/JSystem/src/JParticle/JPAResource.cpp @@ -18,9 +18,21 @@ #include "global.h" #include "tracy/Tracy.hpp" +#if TARGET_PC +#define JPA_DRAW_CTX_ARG , &ctx +#else +#define JPA_DRAW_CTX_ARG +#endif + JPAResource::JPAResource() { mpCalcEmitterFuncList = mpDrawEmitterFuncList = mpDrawEmitterChildFuncList = NULL; +#if TARGET_PC + mpCalcParticleFuncList = mpCalcParticleChildFuncList = NULL; + mpDrawParticleFuncList = mpDrawParticleChildFuncList = NULL; + mBatchInfo = {}; +#else mpCalcParticleFuncList = mpDrawParticleFuncList = mpCalcParticleChildFuncList = mpDrawParticleChildFuncList = NULL; +#endif pBsp = NULL; pEsp = NULL; pCsp = NULL; @@ -61,6 +73,60 @@ static u8 jpa_crd[32] ATTRIBUTE_ALIGN(32) = { 0x00, 0x00, 0x01, 0x00, 0x01, 0x02, 0x00, 0x02, 0x00, 0x00, 0x02, 0x00, 0x02, 0x02, 0x00, 0x02, }; +#if TARGET_PC +void JPAResource::initBatchInfo() { + mBatchInfo = {}; + + bool hasDrawFunc = false; + for (int i = 0; i < mpDrawParticleFuncListNum; i++) { + DrawParticleFunc func = mpDrawParticleFuncList[i]; + if (func == JPADrawBillboard || func == JPADrawRotBillboard || + func == JPADrawYBillboard || func == JPADrawRotYBillboard || + func == JPADrawDirection || func == JPADrawRotDirection || func == JPADrawRotation) + { + hasDrawFunc = true; + } else if (func == JPADrawParticleCallBack) { + // Batchable only for emitters without a particle callback; checked per draw + } else if (func == JPALoadCalcTexCrdMtxAnm) { + mBatchInfo.hasPtclTexMtx = true; + } else if (func == JPARegistAlpha || func == JPARegistPrmAlpha || + func == JPARegistPrmAlphaEnv || func == JPARegistAlphaEnv || + func == static_cast(JPARegistEnv)) // overloaded + { + mBatchInfo.hasPtclColor = true; + } else { + // JPADrawPoint, JPADrawLine, JPADrawDBillboard, JPALoadTexAnm, + // JPASetPointSize, JPASetLineWidth + return; + } + } + if (!hasDrawFunc) { + return; + } + + // Template array offsets, same math as setPTev + int base_plane_type = (pBsp->getType() == 3 || pBsp->getType() == 7) ? + pBsp->getBasePlaneType() : 0; + int center_offset = pEsp != nullptr ? (pEsp->getScaleCenterX() + 3 * pEsp->getScaleCenterY()) * 0xC : 0x30; + const s8* pos = reinterpret_cast(jpa_pos) + center_offset + base_plane_type * 0x6C; + const s8* crd = reinterpret_cast(jpa_crd) + (pBsp->getTilingS() + 2 * pBsp->getTilingT()) * 8; + + bool cross = pBsp->getType() == 4 || pBsp->getType() == 8; + mBatchInfo.vtxCount = cross ? 8 : 4; + for (int i = 0; i < mBatchInfo.vtxCount; i++) { + int posIdx = i < 4 ? i : 72 + (i - 4); + int crdIdx = i & 3; + mBatchInfo.vtxPos[i][0] = pos[posIdx * 3 + 0]; + mBatchInfo.vtxPos[i][1] = pos[posIdx * 3 + 1]; + mBatchInfo.vtxPos[i][2] = pos[posIdx * 3 + 2]; + mBatchInfo.vtxUv[i][0] = crd[crdIdx * 2 + 0]; + mBatchInfo.vtxUv[i][1] = crd[crdIdx * 2 + 1]; + } + + mBatchInfo.supported = true; +} +#endif + void JPAResource::init(JKRHeap* heap) { BOOL is_glbl_clr_anm = pBsp->isGlblClrAnm(); BOOL is_glbl_tex_anm = pBsp->isGlblTexAnm(); @@ -525,7 +591,10 @@ void JPAResource::init(JKRHeap* heap) { if (mpDrawParticleFuncListNum != 0) { mpDrawParticleFuncList = - (ParticleFunc*)JKRAllocFromHeap(heap, mpDrawParticleFuncListNum * sizeof(ParticleFunc), alignof(ParticleFunc)); + (DrawParticleFunc*)JKRAllocFromHeap( + heap, + mpDrawParticleFuncListNum * sizeof(DrawParticleFunc), + alignof(DrawParticleFunc)); } func_no = 0; @@ -635,7 +704,10 @@ void JPAResource::init(JKRHeap* heap) { if (mpDrawParticleChildFuncListNum != 0) { mpDrawParticleChildFuncList = - (ParticleFunc*)JKRAllocFromHeap(heap, mpDrawParticleChildFuncListNum * sizeof(ParticleFunc), sizeof(EmitterFunc)); + (DrawParticleFunc*)JKRAllocFromHeap( + heap, + mpDrawParticleChildFuncListNum * sizeof(DrawParticleFunc), + alignof(DrawParticleFunc)); } func_no = 0; @@ -699,6 +771,10 @@ void JPAResource::init(JKRHeap* heap) { mpDrawParticleChildFuncList[func_no] = &JPARegistPrmAlphaEnv; func_no++; } + +#if TARGET_PC + initBatchInfo(); +#endif } bool JPAResource::calc(JPAEmitterWorkData* work, JPABaseEmitter* emtr) { @@ -808,6 +884,183 @@ void JPAResource::draw(JPAEmitterWorkData* work, JPABaseEmitter* emtr) { } } +#if TARGET_PC +static GXTevAlphaArg to_vtx_alpha_arg(GXTevAlphaArg arg) { + return arg == GX_CA_A0 ? GX_CA_RASA : arg; +} + +static void batch_set_tev_op(GXTevStageID stage) { + GXSetTevColorOp(stage, GX_TEV_ADD, GX_TB_ZERO, GX_CS_SCALE_1, GX_TRUE, GX_TEVPREV); + GXSetTevAlphaOp(stage, GX_TEV_ADD, GX_TB_ZERO, GX_CS_SCALE_1, GX_TRUE, GX_TEVPREV); +} + +static void batch_setup_tev(JPAEmitterWorkData* work, bool useClr1) { + JPABaseShape* shape = work->mpRes->getBsp(); + JPAExTexShape* ets = work->mpRes->getEts(); + bool useIndirect = ets != nullptr && ets->isUseIndirect(); + + // JPAEmitterManager::draw configures both channels to pass vertex color through + GXSetNumChans(useClr1 ? 2 : 1); + + const GXTevAlphaArg* alphaArg = shape->getTevAlphaArg(); + GXSetTevOrder(GX_TEVSTAGE0, GX_TEXCOORD0, GX_TEXMAP0, GX_COLOR0A0); + GXSetTevAlphaIn(GX_TEVSTAGE0, to_vtx_alpha_arg(alphaArg[0]), to_vtx_alpha_arg(alphaArg[1]), + to_vtx_alpha_arg(alphaArg[2]), to_vtx_alpha_arg(alphaArg[3])); + batch_set_tev_op(GX_TEVSTAGE0); + if (!useIndirect) { + GXSetTevDirect(GX_TEVSTAGE0); + } + GXTevStageID nextStage = GX_TEVSTAGE1; + + switch (shape->getTevColorArgSel()) { + case 0: // TEXC + GXSetTevColorIn(GX_TEVSTAGE0, GX_CC_ZERO, GX_CC_TEXC, GX_CC_ONE, GX_CC_ZERO); + break; + case 1: // C0 * TEXC + GXSetTevColorIn(GX_TEVSTAGE0, GX_CC_ZERO, GX_CC_RASC, GX_CC_TEXC, GX_CC_ZERO); + break; + case 2: // lerp(C0, 1, TEXC) + GXSetTevColorIn(GX_TEVSTAGE0, GX_CC_RASC, GX_CC_ONE, GX_CC_TEXC, GX_CC_ZERO); + break; + case 3: // lerp(C1, C0, TEXC) = C0 * TEXC (stage 0) + C1 * (1 - TEXC) (stage 1) + GXSetTevColorIn(GX_TEVSTAGE0, GX_CC_ZERO, GX_CC_RASC, GX_CC_TEXC, GX_CC_ZERO); + GXSetTevOrder(nextStage, GX_TEXCOORD0, GX_TEXMAP0, GX_COLOR1A1); + GXSetTevColorIn(nextStage, GX_CC_RASC, GX_CC_ZERO, GX_CC_TEXC, GX_CC_CPREV); + GXSetTevAlphaIn(nextStage, GX_CA_ZERO, GX_CA_ZERO, GX_CA_ZERO, GX_CA_APREV); + batch_set_tev_op(nextStage); + GXSetTevDirect(nextStage); + nextStage = static_cast(nextStage + 1); + break; + case 4: // TEXC * C0 + C1: C0 * TEXC (stage 0), + C1 (stage 1) + GXSetTevColorIn(GX_TEVSTAGE0, GX_CC_ZERO, GX_CC_RASC, GX_CC_TEXC, GX_CC_ZERO); + GXSetTevOrder(nextStage, GX_TEXCOORD0, GX_TEXMAP0, GX_COLOR1A1); + GXSetTevColorIn(nextStage, GX_CC_CPREV, GX_CC_ZERO, GX_CC_ZERO, GX_CC_RASC); + GXSetTevAlphaIn(nextStage, GX_CA_ZERO, GX_CA_ZERO, GX_CA_ZERO, GX_CA_APREV); + batch_set_tev_op(nextStage); + GXSetTevDirect(nextStage); + nextStage = static_cast(nextStage + 1); + break; + case 5: // C0 + GXSetTevColorIn(GX_TEVSTAGE0, GX_CC_ZERO, GX_CC_ZERO, GX_CC_ZERO, GX_CC_RASC); + break; + } + + if (ets != nullptr && ets->isUseSecTex()) { + // Mirrors setPTev's secondary texture stage, at the next free stage + GXTexCoordID texCoord = useIndirect ? GX_TEXCOORD2 : GX_TEXCOORD1; + GXSetTevOrder(nextStage, texCoord, GX_TEXMAP3, GX_COLOR_NULL); + GXSetTevColorIn(nextStage, GX_CC_ZERO, GX_CC_TEXC, GX_CC_CPREV, GX_CC_ZERO); + GXSetTevAlphaIn(nextStage, GX_CA_ZERO, GX_CA_TEXA, GX_CA_APREV, GX_CA_ZERO); + batch_set_tev_op(nextStage); + GXSetTevDirect(nextStage); + nextStage = static_cast(nextStage + 1); + } + + GXSetNumTevStages(nextStage); +} + +static void batch_setup_vtx_desc(bool useClr0, bool useClr1) { + static Mtx identityMtx = { + {1.0f, 0.0f, 0.0f, 0.0f}, + {0.0f, 1.0f, 0.0f, 0.0f}, + {0.0f, 0.0f, 1.0f, 0.0f}, + }; + + GXLoadPosMtxImm(identityMtx, GX_PNMTX0); + GXSetCurrentMtx(GX_PNMTX0); + GXClearVtxDesc(); + GXSetVtxDesc(GX_VA_POS, GX_DIRECT); + if (useClr0) { + GXSetVtxDesc(GX_VA_CLR0, GX_DIRECT); + } + if (useClr1) { + GXSetVtxDesc(GX_VA_CLR1, GX_DIRECT); + } + GXSetVtxDesc(GX_VA_TEX0, GX_DIRECT); + GXSetVtxAttrFmt(GX_VTXFMT1, GX_VA_POS, GX_POS_XYZ, GX_F32, 0); + if (useClr0) { + GXSetVtxAttrFmt(GX_VTXFMT1, GX_VA_CLR0, GX_CLR_RGBA, GX_RGBA8, 0); + } + if (useClr1) { + GXSetVtxAttrFmt(GX_VTXFMT1, GX_VA_CLR1, GX_CLR_RGBA, GX_RGBA8, 0); + } + GXSetVtxAttrFmt(GX_VTXFMT1, GX_VA_TEX0, GX_TEX_ST, GX_F32, 0); +} + +static void batch_restore_gx(JPAEmitterWorkData* work, bool changedTev, bool changedTexMtx) { + GXClearVtxDesc(); + GXSetVtxDesc(GX_VA_POS, GX_INDEX8); + GXSetVtxDesc(GX_VA_TEX0, GX_INDEX8); + GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_POS, GX_POS_XYZ, GX_S8, 0); + GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_TEX0, GX_TEX_ST, GX_S8, 0); + GXSetVtxAttrFmt(GX_VTXFMT1, GX_VA_POS, GX_POS_XYZ, GX_F32, 0); + GXSetVtxAttrFmt(GX_VTXFMT1, GX_VA_TEX0, GX_TEX_ST, GX_F32, 0); + GXSetCurrentMtx(GX_PNMTX0); + + if (changedTexMtx) { + GXSetTexCoordGen(GX_TEXCOORD0, GX_TG_MTX2x4, GX_TG_TEX0, GX_TEXMTX0); + } + + if (changedTev) { + GXSetNumChans(0); + work->mpRes->getBsp()->setGX(work); + work->mpRes->setPTev(); + } +} + +static bool draw_particle_batch(JPAEmitterWorkData* work) { + ZoneScoped; + + JPAResource* res = work->mpRes; + const JPAResource::BatchInfo& info = res->mBatchInfo; + if (!info.supported || work->mPrjType != 0 || work->mpEmtr->mpPtclCallBack != nullptr) { + return false; + } + + bool useClr0 = false; + bool useClr1 = false; + if (info.hasPtclColor) { + u32 colorSel = res->getBsp()->getTevColorArgSel(); + if (colorSel >= 6) { + return false; + } + useClr0 = true; + useClr1 = colorSel == 3 || colorSel == 4; + batch_setup_tev(work, useClr1); + } + + if (info.hasPtclTexMtx) { + // UVs are CPU-transformed; drop the texgen + GXSetTexCoordGen(GX_TEXCOORD0, GX_TG_MTX2x4, GX_TG_TEX0, GX_IDENTITY); + } + + batch_setup_vtx_desc(useClr0, useClr1); + + ParticleDrawCtx ctx{}; + ctx.batch = true; + ctx.useTexMtx = info.hasPtclTexMtx; + ctx.useClr0 = useClr0; + ctx.useClr1 = useClr1; + + bool fwdAhead = res->getBsp()->isDrawFwdAhead(); + JPANode* node = fwdAhead ? work->mpEmtr->mAlivePtclBase.getLast() : + work->mpEmtr->mAlivePtclBase.getFirst(); + + GXBegin(GX_QUADS, GX_VTXFMT1, GX_AUTO); + while (node != work->mpEmtr->mAlivePtclBase.getEnd()) { + work->mpCurNode = node; + for (int i = res->mpDrawParticleFuncListNum - 1; i >= 0; i--) { + (*res->mpDrawParticleFuncList[i])(work, node->getObject(), &ctx); + } + node = fwdAhead ? node->getPrev() : node->getNext(); + } + GXEnd(); + + batch_restore_gx(work, useClr0, info.hasPtclTexMtx); + return true; +} +#endif + void JPAResource::drawP(JPAEmitterWorkData* work) { ZoneScoped; work->mpEmtr->clearStatus(0x80); @@ -842,13 +1095,25 @@ void JPAResource::drawP(JPAEmitterWorkData* work) { (*mpDrawEmitterFuncList[i])(work); } +#if TARGET_PC + if (draw_particle_batch(work)) { + GXSetMisc(GX_MT_XF_FLUSH, 0); + if (work->mpEmtr->mpEmtrCallBack != nullptr) { + work->mpEmtr->mpEmtrCallBack->drawAfter(work->mpEmtr); + } + return; + } + + ParticleDrawCtx ctx{}; // immediate mode +#endif + if (pBsp->isDrawFwdAhead()) { JPANode* node = work->mpEmtr->mAlivePtclBase.getLast(); for (; node != work->mpEmtr->mAlivePtclBase.getEnd(); node = node->getPrev()) { work->mpCurNode = node; if (mpDrawParticleFuncList != NULL) { for (int i = mpDrawParticleFuncListNum - 1; i >= 0; i--) { - (*mpDrawParticleFuncList[i])(work, node->getObject()); + (*mpDrawParticleFuncList[i])(work, node->getObject() JPA_DRAW_CTX_ARG); } } } @@ -858,7 +1123,7 @@ void JPAResource::drawP(JPAEmitterWorkData* work) { work->mpCurNode = node; if (mpDrawParticleFuncList != NULL) { for (int i = mpDrawParticleFuncListNum - 1; i >= 0; i--) { - (*mpDrawParticleFuncList[i])(work, node->getObject()); + (*mpDrawParticleFuncList[i])(work, node->getObject() JPA_DRAW_CTX_ARG); } } } @@ -905,13 +1170,17 @@ void JPAResource::drawC(JPAEmitterWorkData* work) { (*mpDrawEmitterChildFuncList[i])(work); } +#if TARGET_PC + ParticleDrawCtx ctx{}; // immediate mode +#endif + if (pBsp->isDrawFwdAhead()) { JPANode* node = work->mpEmtr->mAlivePtclChld.getLast(); for (; node != work->mpEmtr->mAlivePtclChld.getEnd(); node = node->getPrev()) { work->mpCurNode = node; if (mpDrawParticleChildFuncList != NULL) { for (int i = mpDrawParticleChildFuncListNum - 1; i >= 0; i--) { - (*mpDrawParticleChildFuncList[i])(work, node->getObject()); + (*mpDrawParticleChildFuncList[i])(work, node->getObject() JPA_DRAW_CTX_ARG); } } } @@ -921,7 +1190,7 @@ void JPAResource::drawC(JPAEmitterWorkData* work) { work->mpCurNode = node; if (mpDrawParticleChildFuncList != NULL) { for (int i = mpDrawParticleChildFuncListNum - 1; i >= 0; i--) { - (*mpDrawParticleChildFuncList[i])(work, node->getObject()); + (*mpDrawParticleChildFuncList[i])(work, node->getObject() JPA_DRAW_CTX_ARG); } } } From 7c5ed6a0e1bdbac4a64d5f8b1e15eb3f7fff62d9 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 10 Jun 2026 00:18:14 -0600 Subject: [PATCH 014/150] Fix OSWaitCond implementation --- src/dusk/OSMutex.cpp | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/dusk/OSMutex.cpp b/src/dusk/OSMutex.cpp index ef1a528f1b..b3127eb6fc 100644 --- a/src/dusk/OSMutex.cpp +++ b/src/dusk/OSMutex.cpp @@ -181,20 +181,22 @@ void OSWaitCond(OSCond* cond, OSMutex* mutex) { mutex->count = 0; mutex->thread = nullptr; - // Unlock the recursive mutex the same number of times it was locked - for (s32 i = 0; i < savedCount; i++) { - mutexData.nativeMutex.unlock(); - } - - // Wait on the condition variable - { - std::unique_lock lock(mutexData.nativeMutex); + // Keep one recursion level held so cv.wait() is what releases the mutex; + // fully unlocking before the wait opens a window where a signal is lost. + if (savedCount >= 1) { + for (s32 i = 1; i < savedCount; i++) { + mutexData.nativeMutex.unlock(); + } + std::unique_lock lock(mutexData.nativeMutex, std::adopt_lock); + condData.cv.wait(lock); + lock.release(); + for (s32 i = 1; i < savedCount; i++) { + mutexData.nativeMutex.lock(); + } + } else { + // Mutex wasn't held on entry (contract violation); wait anyway. + std::unique_lock lock(mutexData.nativeMutex); condData.cv.wait(lock); - } - - // Re-lock the recursive mutex the same number of times - for (s32 i = 0; i < savedCount; i++) { - mutexData.nativeMutex.lock(); } // Restore GC mutex state From 00707024bbc95e97f6a9a5506ed5bdc655d3071d Mon Sep 17 00:00:00 2001 From: Luke Street Date: Sat, 13 Jun 2026 10:40:57 -0600 Subject: [PATCH 015/150] Update aurora & flower/grass draw batching --- extern/aurora | 2 +- files.cmake | 2 + include/d/actor/d_flower.h | 10 + include/d/actor/d_grass.h | 8 + libs/JSystem/src/J3DGraphBase/J3DShape.cpp | 4 +- .../JSystem/src/J3DGraphBase/J3DShapeDraw.cpp | 348 +++------------- src/d/actor/d_flower.inc | 378 ++++++++++++++++++ src/d/actor/d_grass.inc | 356 +++++++++++++++++ src/dusk/batch.cpp | 72 ++++ src/dusk/batch.hpp | 25 ++ 10 files changed, 909 insertions(+), 296 deletions(-) create mode 100644 src/dusk/batch.cpp create mode 100644 src/dusk/batch.hpp diff --git a/extern/aurora b/extern/aurora index 5143394381..ad55eedb31 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 514339438178ef2bed1b14e5149d90ece0c6e0cc +Subproject commit ad55eedb3121e540b44fbabf8e9f68bfa55ba182 diff --git a/files.cmake b/files.cmake index 99ccfc23c9..a920db2e4f 100644 --- a/files.cmake +++ b/files.cmake @@ -1419,6 +1419,8 @@ set(DUSK_FILES src/dusk/dvd_asset.cpp src/d/actor/d_a_alink_dusk.cpp src/dusk/asserts.cpp + src/dusk/batch.cpp + src/dusk/batch.hpp src/dusk/config.cpp src/dusk/crash_handler.cpp src/dusk/crash_reporting.cpp diff --git a/include/d/actor/d_flower.h b/include/d/actor/d_flower.h index cd505335a8..f63847956a 100644 --- a/include/d/actor/d_flower.h +++ b/include/d/actor/d_flower.h @@ -4,6 +4,10 @@ #include "JSystem/J3DGraphBase/J3DPacket.h" #include "SSystem/SComponent/c_xyz.h" +#if TARGET_PC +#include "dusk/batch.hpp" +#endif + class cCcD_Obj; class dCcMassS_HitInf; class fopAc_ac_c; @@ -107,6 +111,12 @@ public: #if TARGET_PC TGXTexObj mTexObj_l_J_Ohana00_64TEX; TGXTexObj mTexObj_l_J_Ohana01_64128_0419TEX; + + dusk::batch::LeafTemplate mTplHana00; // l_J_hana00DL + dusk::batch::LeafTemplate mTplHana00Cut; // l_J_hana00_cDL + dusk::batch::LeafTemplate mTplHana01; // l_J_hana01DL + dusk::batch::LeafTemplate mTplHana01Cut00; // l_J_hana01_c_00DL + dusk::batch::LeafTemplate mTplHana01Cut; // l_J_hana01_c_01DL #endif }; // Size: 0x12A54 diff --git a/include/d/actor/d_grass.h b/include/d/actor/d_grass.h index 47b948679d..b7cc3d7d95 100644 --- a/include/d/actor/d_grass.h +++ b/include/d/actor/d_grass.h @@ -4,6 +4,10 @@ #include "JSystem/J3DGraphBase/J3DPacket.h" #include "SSystem/SComponent/c_xyz.h" +#if TARGET_PC +#include "../../../src/dusk/batch.hpp" +#endif + class cCcD_Obj; class csXyz; class dCcMassS_HitInf; @@ -110,6 +114,10 @@ public: #if TARGET_PC TGXTexObj mTexObj_l_M_Hijiki00TEX; TGXTexObj mTexObj_l_M_kusa05_RGBATEX; + + dusk::batch::LeafTemplate mTplKusa9q; // l_M_Kusa_9qDL + dusk::batch::LeafTemplate mTplKusa9qCut; // l_M_Kusa_9q_cDL + dusk::batch::LeafTemplate mTplTengusa; // l_M_TenGusaDL #endif }; // Size: 0x1D718 diff --git a/libs/JSystem/src/J3DGraphBase/J3DShape.cpp b/libs/JSystem/src/J3DGraphBase/J3DShape.cpp index fc7ac5b727..4b407d1e05 100644 --- a/libs/JSystem/src/J3DGraphBase/J3DShape.cpp +++ b/libs/JSystem/src/J3DGraphBase/J3DShape.cpp @@ -136,8 +136,8 @@ void J3DLoadCPCmd(u8 addr, u32 val) { #if TARGET_PC static void J3DLoadArrayBasePtr(GXAttr attr, void* data, u32 size, bool le) { u32 idx = (attr == GX_VA_NBT) ? 1 : (attr - GX_VA_POS); - GXCmd1u8(GX_LOAD_AURORA); - GXCmd1u16(GX_LOAD_AURORA_ARRAYBASE | idx); + GXCmd1u8(GX_AURORA); + GXCmd1u16(GX_AURORA_LOAD_ARRAYBASE | idx); GXCmd1u64((u64)data); GXCmd1u32(size); GXCmd1u8(le ? 1 : 0); diff --git a/libs/JSystem/src/J3DGraphBase/J3DShapeDraw.cpp b/libs/JSystem/src/J3DGraphBase/J3DShapeDraw.cpp index b40f577d79..e9abdc85f1 100644 --- a/libs/JSystem/src/J3DGraphBase/J3DShapeDraw.cpp +++ b/libs/JSystem/src/J3DGraphBase/J3DShapeDraw.cpp @@ -7,265 +7,11 @@ #include "JSystem/JKernel/JKRHeap.h" #if TARGET_PC -#include +#include #include -#include -#include "dusk/logging.h" namespace { -u16 read_be16(const u8* data) { - return (u16(data[0]) << 8) | data[1]; -} - -void append_be16(std::vector& out, u16 value) { - out.push_back(value >> 8); - out.push_back(value & 0xFF); -} - -void append_bytes(std::vector& out, const u8* data, u32 size) { - out.insert(out.end(), data, data + size); -} - -bool is_matrix_idx_attr(GXAttr attr) { - return attr >= GX_VA_PNMTXIDX && attr <= GX_VA_TEX7MTXIDX; -} - -bool is_draw_opcode(u8 opcode) { - return opcode == GX_QUADS || opcode == GX_TRIANGLES || opcode == GX_TRIANGLESTRIP || - opcode == GX_TRIANGLEFAN || opcode == GX_LINES || opcode == GX_LINESTRIP || - opcode == GX_POINTS; -} - -bool is_mergeable_draw_opcode(u8 opcode) { - return opcode == GX_QUADS || opcode == GX_TRIANGLES || opcode == GX_TRIANGLESTRIP || - opcode == GX_TRIANGLEFAN; -} - -bool calc_vtx_stride(const GXVtxDescList* vtxDesc, u32& stride) { - stride = 0; - for (; vtxDesc->attr != GX_VA_NULL; vtxDesc++) { - switch (vtxDesc->type) { - case GX_NONE: - break; - case GX_DIRECT: - if (!is_matrix_idx_attr(vtxDesc->attr)) { - return false; - } - stride += 1; - break; - case GX_INDEX8: - stride += 1; - break; - case GX_INDEX16: - stride += 2; - break; - default: - return false; - } - } - return stride != 0; -} - -bool get_command_size(const u8* dlStart, u32 dlSize, u32 offset, u32 stride, u32& cmdSize) { - if (offset >= dlSize) { - return false; - } - - const u8 cmd = dlStart[offset]; - const u8 opcode = cmd & GX_OPCODE_MASK; - switch (opcode) { - case GX_NOP: - case GX_CMD_INVL_VC: - cmdSize = 1; - return true; - case (GX_LOAD_BP_REG & GX_OPCODE_MASK): - cmdSize = 5; - return offset + cmdSize <= dlSize; - case GX_LOAD_CP_REG: - cmdSize = 6; - return offset + cmdSize <= dlSize; - case GX_LOAD_XF_REG: { - if (offset + 5 > dlSize) { - return false; - } - const u16 count = read_be16(dlStart + offset + 1) + 1; - cmdSize = 5 + count * 4; - return offset + cmdSize <= dlSize; - } - case GX_LOAD_INDX_A: - case GX_LOAD_INDX_B: - case GX_LOAD_INDX_C: - case GX_LOAD_INDX_D: - cmdSize = 5; - return offset + cmdSize <= dlSize; - case GX_CMD_CALL_DL: - cmdSize = 9; - return offset + cmdSize <= dlSize; - default: - if (is_draw_opcode(opcode)) { - if (offset + 3 > dlSize) { - return false; - } - const u16 vtxCount = read_be16(dlStart + offset + 1); - cmdSize = 3 + vtxCount * stride; - return offset + cmdSize <= dlSize; - } - return false; - } -} - -struct MergeRun { - u8 cmd = 0; - u16 vtxCount = 0; - std::vector vertices; -}; - -void flush_merge_run(std::vector& out, MergeRun& run) { - if (run.vtxCount == 0) { - return; - } - - out.push_back(run.cmd); - append_be16(out, run.vtxCount); - append_bytes(out, run.vertices.data(), run.vertices.size()); - run.vertices.clear(); - run.vtxCount = 0; -} - -void append_vertex(std::vector& out, const u8* vertices, u32 stride, u16 idx) { - append_bytes(out, vertices + idx * stride, stride); -} - -bool triangulate_draw( - std::vector& out, u8 opcode, const u8* vertices, u32 stride, u16 vtxCount) { - switch (opcode) { - case GX_TRIANGLES: - append_bytes(out, vertices, vtxCount * stride); - return true; - case GX_TRIANGLEFAN: - if (vtxCount < 3) { - return false; - } - for (u16 v = 2; v < vtxCount; v++) { - append_vertex(out, vertices, stride, 0); - append_vertex(out, vertices, stride, v - 1); - append_vertex(out, vertices, stride, v); - } - return true; - case GX_TRIANGLESTRIP: - if (vtxCount < 3) { - return false; - } - for (u16 v = 2; v < vtxCount; v++) { - if ((v & 1) == 0) { - append_vertex(out, vertices, stride, v - 2); - append_vertex(out, vertices, stride, v - 1); - } else { - append_vertex(out, vertices, stride, v - 1); - append_vertex(out, vertices, stride, v - 2); - } - append_vertex(out, vertices, stride, v); - } - return true; - case GX_QUADS: - if ((vtxCount & 3) != 0) { - return false; - } - for (u16 v = 0; v < vtxCount; v += 4) { - append_vertex(out, vertices, stride, v); - append_vertex(out, vertices, stride, v + 1); - append_vertex(out, vertices, stride, v + 2); - append_vertex(out, vertices, stride, v + 2); - append_vertex(out, vertices, stride, v + 3); - append_vertex(out, vertices, stride, v); - } - return true; - default: - return false; - } -} - -void append_triangles_to_run( - std::vector& out, MergeRun& run, u8 cmd, const std::vector& vertices, u32 stride) { - u32 offset = 0; - u32 remaining = vertices.size() / stride; - while (remaining != 0) { - if (run.vtxCount != 0 && run.cmd != cmd) { - flush_merge_run(out, run); - } - - if (run.vtxCount == 0) { - run.cmd = cmd; - } - - u32 available = 0xFFFF - run.vtxCount; - if (available == 0) { - flush_merge_run(out, run); - continue; - } - - u32 toCopy = std::min(remaining, available); - append_bytes(run.vertices, vertices.data() + offset * stride, toCopy * stride); - run.vtxCount += toCopy; - offset += toCopy; - remaining -= toCopy; - - if (run.vtxCount == 0xFFFF) { - flush_merge_run(out, run); - } - } -} - -bool optimize_display_list(const u8* dlStart, u32 dlSize, u32 stride, std::vector& out) { - MergeRun run; - out.reserve(dlSize); - - for (u32 offset = 0; offset < dlSize;) { - u32 cmdSize = 0; - if (!get_command_size(dlStart, dlSize, offset, stride, cmdSize)) { - return false; - } - - const u8 cmd = dlStart[offset]; - const u8 opcode = cmd & GX_OPCODE_MASK; - if (opcode == GX_NOP) { - offset += cmdSize; - continue; - } - - if (!is_draw_opcode(opcode)) { - flush_merge_run(out, run); - append_bytes(out, dlStart + offset, cmdSize); - offset += cmdSize; - continue; - } - - if (!is_mergeable_draw_opcode(opcode)) { - flush_merge_run(out, run); - append_bytes(out, dlStart + offset, cmdSize); - offset += cmdSize; - continue; - } - - const u16 vtxCount = read_be16(dlStart + offset + 1); - const u8* vertices = dlStart + offset + 3; - std::vector triangles; - if (!triangulate_draw(triangles, opcode, vertices, stride, vtxCount)) { - flush_merge_run(out, run); - append_bytes(out, dlStart + offset, cmdSize); - offset += cmdSize; - continue; - } - - append_triangles_to_run(out, run, (GX_TRIANGLES | (cmd & GX_VAT_MASK)), triangles, stride); - offset += cmdSize; - } - - flush_merge_run(out, run); - return true; -} - void set_display_list_copy(void*& displayList, u32& displayListSize, const u8* data, u32 size) { const u32 alignedSize = ALIGN_NEXT(size, 0x20); u8* newDL = JKR_NEW_ARRAY_ARGS(u8, alignedSize, 0x20); @@ -289,20 +35,11 @@ u32 J3DShapeDraw::countVertex(u32 stride) { u8* dlStart = (u8*)getDisplayList(); #if TARGET_PC - for (u32 offset = 0; offset < getDisplayListSize();) { - u8 cmd = dlStart[offset]; - u8 opcode = cmd & GX_OPCODE_MASK; - u32 cmdSize = 0; - if (!get_command_size(dlStart, getDisplayListSize(), offset, stride, cmdSize)) { - break; + aurora::gx::dl::Reader reader{dlStart, getDisplayListSize(), static_cast(stride)}; + while (const auto cmd = reader.next()) { + if (cmd->kind != aurora::gx::dl::Command::Kind::Passthrough) { + count += cmd->draw.vtxCount; } - if (!is_draw_opcode(opcode)) { - offset += cmdSize; - continue; - } - int vtxNum = be16(*reinterpret_cast(dlStart + offset + 1)); - count += vtxNum; - offset += 3 + stride * vtxNum; } #else for (u8* dl = dlStart; (dl - dlStart) < getDisplayListSize();) { @@ -320,6 +57,53 @@ u32 J3DShapeDraw::countVertex(u32 stride) { return count; } +#if TARGET_PC +void J3DShapeDraw::addTexMtxIndexInDL(u32 stride, u32 attrOffs, u32 valueBase) { + u32 byteNum = countVertex(stride); + u32 oldSize = mDisplayListSize; + u32 newSize = ALIGN_NEXT(oldSize + byteNum, 0x20); + u8* newDLStart = JKR_NEW_ARRAY_ARGS(u8, newSize, 0x20); + u8* oldDLStart = (u8*)mDisplayList; + u8* newDL = newDLStart; + + aurora::gx::dl::Reader reader{oldDLStart, mDisplayListSize, static_cast(stride)}; + while (const auto cmd = reader.next()) { + if (cmd->kind == aurora::gx::dl::Command::Kind::Passthrough) { + std::memcpy(newDL, cmd->data, cmd->size); + newDL += cmd->size; + continue; + } + + const auto& draw = cmd->draw; + const u32 headerSize = draw.vertices - cmd->data; + std::memcpy(newDL, cmd->data, headerSize); + newDL += headerSize; + + for (u32 i = 0; i < draw.vtxCount; i++) { + const u8* oldVtx = draw.vertices + stride * i; + u8 pnmtxidx = oldVtx[0]; + std::memcpy(newDL, oldVtx, attrOffs); + newDL += attrOffs; + *newDL++ = valueBase + pnmtxidx; + std::memcpy(newDL, oldVtx + attrOffs, stride - attrOffs); + newDL += stride - attrOffs; + } + } + if (reader.failed()) { + // preserve the remainder untouched + std::memcpy(newDL, oldDLStart + reader.pos(), mDisplayListSize - reader.pos()); + newDL += mDisplayListSize - reader.pos(); + } + + u32 realSize = ALIGN_NEXT((uintptr_t)newDL - (uintptr_t)newDLStart, 0x20); + for (; (newDL - newDLStart) < newSize; newDL++) + *newDL = 0; + + mDisplayListSize = realSize; + mDisplayList = newDLStart; + DCStoreRange(newDLStart, mDisplayListSize); +} +#else void J3DShapeDraw::addTexMtxIndexInDL(u32 stride, u32 attrOffs, u32 valueBase) { u32 byteNum = countVertex(stride); u32 oldSize = mDisplayListSize; @@ -330,32 +114,13 @@ void J3DShapeDraw::addTexMtxIndexInDL(u32 stride, u32 attrOffs, u32 valueBase) { u8* newDL = newDLStart; for (; (oldDL - oldDLStart) < mDisplayListSize;) { -#if TARGET_PC - u32 oldOffset = oldDL - oldDLStart; - u32 cmdSize = 0; - if (!get_command_size(oldDLStart, mDisplayListSize, oldOffset, stride, cmdSize)) { - memcpy(newDL, oldDL, mDisplayListSize - oldOffset); - newDL += mDisplayListSize - oldOffset; - break; - } -#endif // Copy command u8 cmd = *(u8*)oldDL; oldDL++; *newDL++ = cmd; -#if TARGET_PC - u8 opcode = cmd & GX_OPCODE_MASK; - if (!is_draw_opcode(opcode)) { - memcpy(newDL, oldDL, cmdSize - 1); - oldDL += cmdSize - 1; - newDL += cmdSize - 1; - continue; - } -#else if (cmd != GX_TRIANGLEFAN && cmd != GX_TRIANGLESTRIP) break; -#endif // Copy count int vtxNum = *(u16*)oldDL; @@ -384,6 +149,7 @@ void J3DShapeDraw::addTexMtxIndexInDL(u32 stride, u32 attrOffs, u32 valueBase) { mDisplayList = newDLStart; DCStoreRange(newDLStart, mDisplayListSize); } +#endif J3DShapeDraw::J3DShapeDraw(const u8* displayList, u32 displayListSize) { #if TARGET_PC @@ -397,12 +163,8 @@ J3DShapeDraw::J3DShapeDraw(const u8* displayList, u32 displayListSize) { #if TARGET_PC J3DShapeDraw::J3DShapeDraw( const u8* displayList, u32 displayListSize, const GXVtxDescList* vtxDesc) { - u32 stride = 0; - std::vector optimized; - if (calc_vtx_stride(vtxDesc, stride) && - optimize_display_list(displayList, displayListSize, stride, optimized)) - { - set_display_list_copy(mDisplayList, mDisplayListSize, optimized.data(), optimized.size()); + if (const auto optimized = aurora::gx::dl::optimize(displayList, displayListSize, vtxDesc)) { + set_display_list_copy(mDisplayList, mDisplayListSize, optimized->data(), optimized->size()); } else { set_display_list_copy(mDisplayList, mDisplayListSize, displayList, displayListSize); } diff --git a/src/d/actor/d_flower.inc b/src/d/actor/d_flower.inc index e5f055fb16..54b1e022cc 100644 --- a/src/d/actor/d_flower.inc +++ b/src/d/actor/d_flower.inc @@ -15,6 +15,12 @@ const u16 l_J_Ohana00_64TEX__height = 63; using GameVersion = dusk::version::GameVersion; static u8* l_J_Ohana00_64TEX_get() { static u8 buf[0x800]; static bool _ = (dusk::LoadArchivedRelAsset(buf, 'AMEM', "d_a_grass.rel", {{GameVersion::GcnUsa, 0x9060}, {GameVersion::GcnPal, 0x9060}}, 0x800), true); return buf; } #define l_J_Ohana00_64TEX (l_J_Ohana00_64TEX_get()) + +// from d_grass.inc +static MtxP get_model_mtx(Mtx modelMtx, Mtx storage); +static void transform_positions( + const dusk::batch::LeafTemplate& tpl, const Vec* posArray, const Mtx mtx, Vec* xfPos); +static void split_batch(u32& emitted, u32 vtxCount); #else #include "assets/l_J_Ohana00_64TEX.h" #endif @@ -588,6 +594,12 @@ dFlower_packet_c::dFlower_packet_c() { GXInitTexObj(&mTexObj_l_J_Ohana01_64128_0419TEX, l_J_Ohana01_64128_0419TEX, l_J_Ohana01_64128_0419TEX__width + 1, l_J_Ohana01_64128_0419TEX__height + 1, GX_TF_CMPR, GX_MIRROR, GX_MIRROR, GX_FALSE ); + + dusk::batch::decode_leaf_template(l_J_hana00DL, 0x140, mTplHana00); + dusk::batch::decode_leaf_template(l_J_hana00_cDL, 0xC0, mTplHana00Cut); + dusk::batch::decode_leaf_template(l_J_hana01DL, 0x120, mTplHana01); + dusk::batch::decode_leaf_template(l_J_hana01_c_00DL, 0xC0, mTplHana01Cut00); + dusk::batch::decode_leaf_template(l_J_hana01_c_01DL, 0x120, mTplHana01Cut); #endif m_deleteRoom = &dFlower_packet_c::deleteRoom; @@ -597,6 +609,371 @@ dFlower_packet_c::dFlower_packet_c() { #endif } +#if TARGET_PC +static void batch_setup_tev(u32 lightMask) { + GXSetCullMode(GX_CULL_NONE); + + GXSetNumChans(2); + GXSetChanCtrl(GX_COLOR0, GX_FALSE, GX_SRC_REG, GX_SRC_VTX, 0, GX_DF_NONE, GX_AF_NONE); + GXSetChanCtrl(GX_COLOR1, GX_TRUE, GX_SRC_VTX, GX_SRC_REG, lightMask, GX_DF_CLAMP, GX_AF_SPOT); + + GXSetNumTevStages(3); + + GXSetTevOrder(GX_TEVSTAGE0, GX_TEXCOORD_NULL, GX_TEXMAP_NULL, GX_COLOR1A1); + GXSetTevColorIn(GX_TEVSTAGE0, GX_CC_ZERO, GX_CC_ZERO, GX_CC_ZERO, GX_CC_RASC); + GXSetTevColorOp(GX_TEVSTAGE0, GX_TEV_ADD, GX_TB_ZERO, GX_CS_SCALE_1, GX_TRUE, GX_TEVPREV); + GXSetTevAlphaIn(GX_TEVSTAGE0, GX_CA_ZERO, GX_CA_ZERO, GX_CA_ZERO, GX_CA_ZERO); + GXSetTevAlphaOp(GX_TEVSTAGE0, GX_TEV_ADD, GX_TB_ZERO, GX_CS_SCALE_1, GX_TRUE, GX_TEVPREV); + + GXSetTevOrder(GX_TEVSTAGE1, GX_TEXCOORD_NULL, GX_TEXMAP_NULL, GX_COLOR0A0); + GXSetTevColorIn(GX_TEVSTAGE1, GX_CC_ZERO, GX_CC_CPREV, GX_CC_RASC, GX_CC_ZERO); + GXSetTevColorOp(GX_TEVSTAGE1, GX_TEV_ADD, GX_TB_ZERO, GX_CS_SCALE_1, GX_TRUE, GX_TEVPREV); + GXSetTevAlphaIn(GX_TEVSTAGE1, GX_CA_ZERO, GX_CA_ZERO, GX_CA_ZERO, GX_CA_ZERO); + GXSetTevAlphaOp(GX_TEVSTAGE1, GX_TEV_ADD, GX_TB_ZERO, GX_CS_SCALE_1, GX_TRUE, GX_TEVPREV); + + GXSetTevOrder(GX_TEVSTAGE2, GX_TEXCOORD0, GX_TEXMAP0, GX_COLOR_NULL); + GXSetTevColorIn(GX_TEVSTAGE2, GX_CC_ZERO, GX_CC_TEXC, GX_CC_CPREV, GX_CC_C0); + GXSetTevColorOp(GX_TEVSTAGE2, GX_TEV_ADD, GX_TB_ZERO, GX_CS_SCALE_4, GX_TRUE, GX_TEVPREV); + GXSetTevAlphaIn(GX_TEVSTAGE2, GX_CA_ZERO, GX_CA_ZERO, GX_CA_ZERO, GX_CA_TEXA); + GXSetTevAlphaOp(GX_TEVSTAGE2, GX_TEV_ADD, GX_TB_ZERO, GX_CS_SCALE_1, GX_TRUE, GX_TEVPREV); +} + +static GXColor hana00_amb_color(const dFlower_data_c* flower, const dKy_tevstr_c* tevstr) { + GXColor amb = {0, 0, 0, 0xFF}; + if (DEBUG && g_kankyoHIO.navy.grass_adjust_ON != 0) { + amb.r = g_kankyoHIO.navy.grass_ambcol.r * 2; + amb.g = g_kankyoHIO.navy.grass_ambcol.g * 2; + amb.b = g_kankyoHIO.navy.grass_ambcol.b * 2; + } else { + amb.r = (flower->field_0x04 & 0x1F) * 2; + amb.g = ((flower->field_0x04 >> 5) & 0x1F) * 2; + amb.b = ((flower->field_0x04 >> 0xA) & 0x1F) * 2; + } + + if (daPy_py_c::checkNowWolfPowerUp()) { + f32 ambRate = g_env_light.bg_amb_col[0].r / 255.0f; + f32 col = (((flower->field_0x04 & 0x1F) * 2 + 0x10)); + amb.r = col * (ambRate * 4.0f); + + ambRate = g_env_light.bg_amb_col[0].g / 255.0f; + f32 col2 = (((flower->field_0x04 >> 5) & 0x1F) * 2 + 0x10); + amb.g = col2 * (4.0f * ambRate); + + ambRate = g_env_light.bg_amb_col[0].b / 255.0f; + f32 col3 = (((flower->field_0x04 >> 10) & 0x1F) * 2 + 0x10); + amb.b = col3 * (4.0f * ambRate); + } + + if (amb.r == 0x3E) { + amb.r = tevstr->AmbCol.r; + } + + if (amb.g == 0x3E) { + amb.g = tevstr->AmbCol.g; + } + + if (amb.b == 0x3E) { + amb.b = tevstr->AmbCol.b; + } + + return amb; +} + +static GXColor hana01_amb_color(int idx, const dKy_tevstr_c* tevstr) { + f32 rRate = tevstr->AmbCol.r * 0.03125f; + if (rRate > 1.0f) { + rRate = 1.0f; + } + + f32 gRate = tevstr->AmbCol.g * 0.03125f; + if (gRate > 1.0f) { + gRate = 1.0f; + } + + f32 bRate = tevstr->AmbCol.b * 0.03125f; + if (bRate > 1.0f) { + bRate = 1.0f; + } + + GXColor amb = {1, 1, 1, 1}; + + GXColor sub; + sub.r = -0.4f * tevstr->AmbCol.r * rRate; + sub.g = -0.4f * tevstr->AmbCol.g * gRate; + sub.b = -0.4f * tevstr->AmbCol.b * bRate; + + switch (idx & 7) { + case 0: + amb.r = tevstr->AmbCol.r + sub.r; + amb.g = tevstr->AmbCol.g; + amb.b = tevstr->AmbCol.b; + break; + case 1: + amb.r = tevstr->AmbCol.r; + amb.g = tevstr->AmbCol.g + sub.g; + amb.b = tevstr->AmbCol.b; + break; + case 2: + amb.r = tevstr->AmbCol.r; + amb.g = tevstr->AmbCol.g; + amb.b = tevstr->AmbCol.b + sub.b; + break; + case 3: + amb.r = tevstr->AmbCol.r + sub.r; + amb.g = tevstr->AmbCol.g + sub.g; + amb.b = tevstr->AmbCol.b; + break; + case 4: + amb.r = tevstr->AmbCol.r; + amb.g = tevstr->AmbCol.g + sub.g; + amb.b = tevstr->AmbCol.b + sub.b; + break; + case 5: + amb.r = tevstr->AmbCol.r + sub.r; + amb.g = tevstr->AmbCol.g; + amb.b = tevstr->AmbCol.b + sub.b; + break; + case 6: + amb.r = tevstr->AmbCol.r + sub.r; + amb.g = tevstr->AmbCol.g + sub.g; + amb.b = tevstr->AmbCol.b + sub.b; + break; + case 7: + break; + } + + if (daPy_py_c::checkNowWolfPowerUp()) { + f32 ambRate = g_env_light.bg_amb_col[0].r / 255.0f; + amb.r = (amb.r + 8) * (6.0f * ambRate); + + ambRate = g_env_light.bg_amb_col[0].g / 255.0f; + amb.g = (amb.g + 8) * (6.0f * ambRate); + + ambRate = g_env_light.bg_amb_col[0].b / 255.0f; + amb.b = (amb.b + 8) * (6.0f * ambRate); + } + + amb.a = 0xFF; + return amb; +} + +static void flower_emit(const dusk::batch::LeafTemplate& tpl, const Vec* xformedPos, GXColor amb) { + for (u32 i = 0; i < tpl.vtxCount; i++) { + const dusk::batch::LeafTemplate::Vtx& v = tpl.vtx[i]; + const Vec& p = xformedPos[v.pos]; + GXPosition3f32(p.x, p.y, p.z); + GXNormal1x8(v.nrm); + GXColor1x8(v.clr); + GXColor4u8(amb.r, amb.g, amb.b, amb.a); + GXTexCoord1x8(v.tex); + } +} + +void dFlower_packet_c::draw() { + ZoneScoped; + dScnKy_env_light_c* kankyo = dKy_getEnvlight(); + j3dSys.reinitGX(); + + GXSetNumIndStages(0); + dKy_setLight_again(); + GXClearVtxDesc(); + GXSetVtxDesc(GX_VA_POS, GX_INDEX8); + GXSetVtxDesc(GX_VA_NRM, GX_INDEX8); + GXSetVtxDesc(GX_VA_CLR0, GX_INDEX8); + GXSetVtxDesc(GX_VA_TEX0, GX_INDEX8); + GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_POS, GX_POS_XYZ, GX_F32, 0); + GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_NRM, GX_NRM_XYZ, GX_F32, 0); + GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_CLR0, GX_CLR_RGBA, GX_RGBA8, 0); + GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_TEX0, GX_TEX_ST, GX_F32, 0); + GXSetVtxAttrFmt(GX_VTXFMT1, GX_VA_POS, GX_POS_XYZ, GX_F32, 0); + GXSetVtxAttrFmt(GX_VTXFMT1, GX_VA_NRM, GX_NRM_XYZ, GX_F32, 0); + GXSetVtxAttrFmt(GX_VTXFMT1, GX_VA_CLR0, GX_CLR_RGBA, GX_RGBA8, 0); + GXSetVtxAttrFmt(GX_VTXFMT1, GX_VA_CLR1, GX_CLR_RGBA, GX_RGBA8, 0); + GXSetVtxAttrFmt(GX_VTXFMT1, GX_VA_TEX0, GX_TEX_ST, GX_F32, 0); + GXSETARRAY(GX_VA_POS, &l_flowerPos, sizeof(l_flowerPos), sizeof(Vec), true); + GXSETARRAY(GX_VA_NRM, &l_flowerNormal, sizeof(l_flowerNormal), sizeof(Vec), true); + GXSETARRAY(GX_VA_CLR0, &l_flowerColor, sizeof(l_flowerColor), sizeof(GXColor), true); + GXSETARRAY(GX_VA_TEX0, &l_flowerTexCoord, sizeof(l_flowerTexCoord), 8, true); + + static GXVtxDescList vtxDescList[] = { + {GX_VA_POS, GX_DIRECT}, + {GX_VA_NRM, GX_INDEX8}, + {GX_VA_CLR0, GX_INDEX8}, + {GX_VA_CLR1, GX_DIRECT}, + {GX_VA_TEX0, GX_INDEX8}, + {GX_VA_NULL, GX_NONE}, + }; + static Vec xfPos[256]; + Mtx identity; + MTXIdentity(identity); + + // --- hana00 --- + for (int i = 0; i < 64; i++) { + dFlower_data_c* first = m_room[i].getData(); + if (first == nullptr || !dComIfGp_roomControl_checkStatusFlag(i, 0x10)) { + continue; + } + + dKy_tevstr_c* tevstr = dComIfGp_roomControl_getTevStr(i); + int lightCount = 6; + + if (dComIfGp_roomControl_getStatusRoomDt(i) != nullptr) { + lightCount = dComIfGp_roomControl_getStatusRoomDt(i)->getLightVecInfoNum(); + } + + if (dKy_SunMoon_Light_Check() && lightCount < 2) { + lightCount = 2; + } + + for (int j = 0; j < 6; j++) { + if (kankyo->field_0x0c18[j].field_0x26 == 1) { + lightCount++; + } + } + + if (lightCount <= 2) { + GXCallDisplayList(l_matLight4DL, 0x80); + } else { + GXCallDisplayList(l_matDL, 0x80); + } + + GXSetTevColorS10(GX_TEVREG0, {0, 0, 0, 0}); + dKy_Global_amb_set(tevstr); + dKy_GxFog_tevstr_set(tevstr); + dKy_setLight_nowroom_grass(tevstr->room_no, 1.0f); + + GXLoadTexObj(&mTexObj_l_J_Ohana00_64TEX, GX_TEXMAP0); + batch_setup_tev(lightCount <= 2 ? (GX_LIGHT1 | GX_LIGHT2 | GX_LIGHT3 | GX_LIGHT4) : + (GX_LIGHT1 | GX_LIGHT2 | GX_LIGHT3 | GX_LIGHT4 | + GX_LIGHT5 | GX_LIGHT6 | GX_LIGHT7)); + GXSetVtxDescv(vtxDescList); + GXLoadPosMtxImm(identity, GX_PNMTX0); + GXLoadNrmMtxImm(j3dSys.getViewMtx(), 0); + + for (int bucket = 0; bucket < 2; bucket++) { + const bool cut = bucket != 0; + const dusk::batch::LeafTemplate& tpl = cut ? mTplHana00Cut : mTplHana00; + + bool open = false; + u32 emitted = 0; + for (dFlower_data_c* flower = first; flower != nullptr; flower = flower->mp_next) { + if (cLib_checkBit(flower->m_state, 4) || + cLib_checkBit(flower->m_state, 0x40)) + { + continue; + } + if ((cLib_checkBit(flower->m_state, 8) != 0) != cut) { + continue; + } + + if (!open) { + GXBegin(GX_TRIANGLES, GX_VTXFMT1, GX_AUTO); + open = true; + } + split_batch(emitted, tpl.vtxCount); + + Mtx interpMtx; + MtxP mtx = get_model_mtx(flower->m_modelMtx, interpMtx); + transform_positions(tpl, reinterpret_cast(l_flowerPos), mtx, xfPos); + flower_emit(tpl, xfPos, hana00_amb_color(flower, tevstr)); + } + if (open) { + GXEnd(); + } + } + } + + // --- hana01 --- + GXSETARRAY(GX_VA_POS, mp_pos, sizeof(l_flowerPos2), sizeof(Vec), true); + GXSETARRAY(GX_VA_NRM, &l_flowerNormal2, sizeof(l_flowerNormal2), sizeof(Vec), true); + GXSETARRAY(GX_VA_CLR0, mp_colors, sizeof(l_flowerColor2), sizeof(GXColor), true); + GXSETARRAY(GX_VA_TEX0, mp_texCoords, sizeof(l_flowerTexCoord2), 8, true); + + for (int i = 0; i < 64; i++) { + dFlower_data_c* first = m_room[i].getData(); + if (first == NULL) { + continue; + } + + dKy_tevstr_c* tevstr = dComIfGp_roomControl_getTevStr(i); + int lightCount = 6; + + if (dComIfGp_roomControl_getStatusRoomDt(i) != NULL) { + lightCount = dComIfGp_roomControl_getStatusRoomDt(i)->getLightVecInfoNum(); + } + +#if DEBUG + if (g_kankyoHIO.light.m_HOSTIO_setting != 0) { + lightCount = g_kankyoHIO.dungeonLight.usedLights; + } +#endif + + if (dKy_SunMoon_Light_Check() == TRUE && lightCount < 2) { + lightCount = 2; + } + + if (lightCount <= 2) { + GXCallDisplayList(mp_mat2Light4DL, m_mat2Light4DL_size); + } else { + GXCallDisplayList(mp_mat2DL, m_mat2DL_size); + } + + GXSetTevColorS10(GX_TEVREG0, {0, 0, 0, 0}); + dKy_Global_amb_set(tevstr); + dKy_GxFog_tevstr_set(tevstr); + dKy_setLight_nowroom_grass(tevstr->room_no, 1.0f); + + GXLoadTexObj(&mTexObj_l_J_Ohana01_64128_0419TEX, GX_TEXMAP0); + batch_setup_tev(lightCount <= 2 ? (GX_LIGHT1 | GX_LIGHT2 | GX_LIGHT3 | GX_LIGHT4) : + (GX_LIGHT1 | GX_LIGHT2 | GX_LIGHT3 | GX_LIGHT4 | + GX_LIGHT5 | GX_LIGHT6 | GX_LIGHT7)); + GXSetVtxDescv(vtxDescList); + GXLoadPosMtxImm(identity, GX_PNMTX0); + GXLoadNrmMtxImm(j3dSys.getViewMtx(), 0); + + const dusk::batch::LeafTemplate* const buckets[3] = { + &mTplHana01, &mTplHana01Cut00, &mTplHana01Cut}; + for (int bucket = 0; bucket < 3; bucket++) { + const dusk::batch::LeafTemplate& tpl = *buckets[bucket]; + + bool open = false; + u32 emitted = 0; + int idx = 0; + for (dFlower_data_c* flower = first; flower != NULL; flower = flower->mp_next, idx++) { + if (cLib_checkBit(flower->m_state, 4) || + !cLib_checkBit(flower->m_state, 0x40)) + { + continue; + } + const int flowerBucket = cLib_checkBit(flower->m_state, 8) ? 2 : + cLib_checkBit(flower->m_state, 0x10) ? 1 : + 0; + if (flowerBucket != bucket) { + continue; + } + + if (!open) { + GXBegin(GX_TRIANGLES, GX_VTXFMT1, GX_AUTO); + open = true; + } + split_batch(emitted, tpl.vtxCount); + + Mtx interpMtx; + MtxP mtx = get_model_mtx(flower->m_modelMtx, interpMtx); + transform_positions(tpl, mp_pos, mtx, xfPos); + flower_emit(tpl, xfPos, hana01_amb_color(idx, tevstr)); + } + if (open) { + GXEnd(); + } + } + } + + GXSetNumTevStages(1); + GXSetNumChans(1); + J3DShape::resetVcdVatCache(); +} +#else void dFlower_packet_c::draw() { ZoneScoped; dScnKy_env_light_c* kankyo = dKy_getEnvlight(); @@ -886,6 +1263,7 @@ void dFlower_packet_c::draw() { J3DShape::resetVcdVatCache(); } +#endif void dFlower_packet_c::calc() { dFlower_anm_c* anm_p = getAnm(); diff --git a/src/d/actor/d_grass.inc b/src/d/actor/d_grass.inc index 8b94368a34..9e8c360518 100644 --- a/src/d/actor/d_grass.inc +++ b/src/d/actor/d_grass.inc @@ -512,11 +512,366 @@ dGrass_packet_c::dGrass_packet_c() { m_Mkusa_9q_cDL_size = 0xC0; field_0x1d714 = 0; +#if TARGET_PC + dusk::batch::decode_leaf_template(mp_Mkusa_9q_DL, m_Mkusa_9q_DL_size, mTplKusa9q); + dusk::batch::decode_leaf_template(mp_Mkusa_9q_cDL, m_Mkusa_9q_cDL_size, mTplKusa9qCut); + dusk::batch::decode_leaf_template(l_M_TenGusaDL, 0xC0, mTplTengusa); +#endif + OS_REPORT("草群メモリ=%fK\n", 117.7734375f); m_deleteRoom = &dGrass_packet_c::deleteRoom; } +#if TARGET_PC +static MtxP get_model_mtx(Mtx modelMtx, Mtx storage) { + if (dusk::frame_interp::lookup_replacement(modelMtx, storage)) { + cMtx_concat(j3dSys.getViewMtx(), storage, storage); + return storage; + } + return modelMtx; +} + +static void transform_positions( + const dusk::batch::LeafTemplate& tpl, const Vec* posArray, const Mtx mtx, Vec* xfPos) { + for (u32 i = 0; i < tpl.posRefCount; i++) { + const u8 idx = tpl.posRefs[i]; + MTXMultVec(mtx, &posArray[idx], &xfPos[idx]); + } +} + +static void split_batch(u32& emitted, u32 vtxCount) { + if (emitted + vtxCount > 0xFFFF) { + GXEnd(); + GXBegin(GX_TRIANGLES, GX_VTXFMT1, GX_AUTO); + emitted = 0; + } + emitted += vtxCount; +} + +static GXColor blade_amb_color(const dGrass_data_c* blade, const dKy_tevstr_c* tevstr) { + GXColor amb; + amb.a = 0; + +#if DEBUG + if (g_kankyoHIO.navy.grass_adjust_ON) { + amb.r = g_kankyoHIO.navy.grass_ambcol.r * 2; + amb.g = g_kankyoHIO.navy.grass_ambcol.g * 2; + amb.b = g_kankyoHIO.navy.grass_ambcol.b * 2; + return amb; + } +#endif + + amb.r = (blade->m_addCol & 0x1F) * 2; + amb.g = ((blade->m_addCol >> 5) & 0x1F) * 2; + amb.b = ((blade->m_addCol >> 0xA) & 0x1F) * 2; + + if (daPy_py_c::checkNowWolfPowerUp()) { + f32 ambRate = g_env_light.bg_amb_col[0].r / 255.0f; + f32 col = (((blade->m_addCol & 0x1F) * 2 + 0x10)); + amb.r = col * (ambRate * 4.0f); + + ambRate = g_env_light.bg_amb_col[0].g / 255.0f; + f32 col2 = (((blade->m_addCol >> 5) & 0x1F) * 2 + 0x10); + amb.g = col2 * (4.0f * ambRate); + + ambRate = g_env_light.bg_amb_col[0].b / 255.0f; + f32 col3 = (((blade->m_addCol >> 10) & 0x1F) * 2 + 0x10); + amb.b = col3 * (4.0f * ambRate); + } + + f32 roomAmbScale = 1.0f - (static_cast(blade->m_pos.x) & 0xFF) * 0.001953125f; + f32 colScale = 1.1f - (static_cast(static_cast(blade->m_pos.x)) & 0xFF) / 2000.0f; + colScale -= (static_cast(blade->m_pos.z) & 0xFF) / 2000.0f; + + if (colScale > 1.0f) { + colScale = 1.0f; + } + + if (amb.r == 0x3E) { + amb.r = tevstr->AmbCol.r * roomAmbScale; + } else { + amb.r = amb.r * colScale; + } + + if (amb.g == 0x3E) { + amb.g = tevstr->AmbCol.g * roomAmbScale; + } else { + amb.g = amb.g * colScale; + } + + if (amb.b == 0x3E) { + amb.b = tevstr->AmbCol.b * roomAmbScale; + } else { + amb.b = amb.b * colScale; + } + + return amb; +} + +static void blade_emit(const dusk::batch::LeafTemplate& tpl, const Vec* xformedPos, + const GXColor* colors, GXColor amb) { + for (u32 i = 0; i < tpl.vtxCount; i++) { + const dusk::batch::LeafTemplate::Vtx& v = tpl.vtx[i]; + const Vec& p = xformedPos[v.pos]; + GXPosition3f32(p.x, p.y, p.z); + GXNormal1x8(v.nrm); + GXColor4u8(amb.r, amb.g, amb.b, colors[v.clr].a); + GXTexCoord1x8(v.tex); + } +} + +void dGrass_packet_c::draw() { + ZoneScoped; + dScnKy_env_light_c* kankyo = dKy_getEnvlight(); + + j3dSys.reinitGX(); + GXSetNumIndStages(0); + dKy_setLight_again(); + GXClearVtxDesc(); + + static GXVtxDescList l_vtxDescList[] = { + {GX_VA_POS, GX_INDEX8}, + {GX_VA_NRM, GX_INDEX8}, + {GX_VA_CLR0, GX_INDEX8}, + {GX_VA_TEX0, GX_INDEX8}, + {GX_VA_NULL, GX_NONE}, + }; + + static GXVtxDescList l_batchVtxDescList[] = { + {GX_VA_POS, GX_DIRECT}, + {GX_VA_NRM, GX_INDEX8}, + {GX_VA_CLR0, GX_DIRECT}, + {GX_VA_TEX0, GX_INDEX8}, + {GX_VA_NULL, GX_NONE}, + }; + + GXSetVtxDescv(l_vtxDescList); + GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_POS, GX_POS_XYZ, GX_F32, 0); + GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_NRM, GX_NRM_XYZ, GX_F32, 0); + GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_CLR0, GX_CLR_RGBA, GX_RGBA8, 0); + GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_TEX0, GX_TEX_ST, GX_F32, 0); + GXSetVtxAttrFmt(GX_VTXFMT1, GX_VA_POS, GX_POS_XYZ, GX_F32, 0); + GXSetVtxAttrFmt(GX_VTXFMT1, GX_VA_NRM, GX_NRM_XYZ, GX_F32, 0); + GXSetVtxAttrFmt(GX_VTXFMT1, GX_VA_CLR0, GX_CLR_RGBA, GX_RGBA8, 0); + GXSetVtxAttrFmt(GX_VTXFMT1, GX_VA_TEX0, GX_TEX_ST, GX_F32, 0); + GXSETARRAY(GX_VA_POS, mp_pos, sizeof(l_pos), sizeof(Vec), true); + GXSETARRAY(GX_VA_NRM, mp_normal, sizeof(l_normal), sizeof(Vec), true); + GXSETARRAY(GX_VA_CLR0, mp_colors, sizeof(l_color), sizeof(GXColor), true); + GXSETARRAY(GX_VA_TEX0, mp_texCoords, sizeof(l_texCoord), 8, true); + + GXColorS10 reg1 = {0, 0, 0, 0}; + + // daytime "shine" alpha curve (TEVREG1 alpha) + f32 daytime = g_env_light.getDaytime(); + f32 ratio; + f32 shine; + if (daytime >= 90.0f && daytime < 135.0f) { + ratio = 1.0f - (0.022222223f * (135.0f - daytime)); + shine = 100.0f - (18.0f * ratio); + } else if (daytime >= 135.0f && daytime < 225.0f) { + ratio = 1.0f - (0.011111111f * (225.0f - daytime)); + shine = 82.0f - (25.0f * ratio); + } else if (daytime >= 225.0f && daytime < 270.0f) { + ratio = 1.0f - (0.022222223f * (270.0f - daytime)); + shine = 57.0f - (-25.0f * ratio); + } else if (daytime >= 270.0f && daytime < 315.0f) { + ratio = (1.0f - (0.022222223f * (315.0f - daytime))); + shine = 82.0f - (-18.0f * ratio); + } else { + shine = 100.0f; + } + +#if DEBUG + if (g_kankyoHIO.navy.grass_shine_value != 0.0f) { + shine = g_kankyoHIO.navy.grass_shine_value; + } +#endif + + static Vec xfPos[256]; + Mtx identity; + PSMTXIdentity(identity); + + for (int i = 0; i < 64; i++) { + dGrass_data_c* first = m_room[i].getData(); + if (first == NULL || !dComIfGp_roomControl_checkStatusFlag(i, 0x10)) { + continue; + } + + int lightCount = 6; + dKy_tevstr_c* tevstr = dComIfGp_roomControl_getTevStr(i); + + f32 lightInf = g_env_light.grass_light_inf_rate * g_env_light.bg_light_influence; + lightInf += 0.5f * (1.0f - lightInf); + + J3DLightInfo* lightInfo = tevstr->mLights[0].getLightInfo(); + reg1.r = lightInfo->mColor.r * lightInf; + reg1.g = lightInfo->mColor.g * lightInf; + reg1.b = lightInfo->mColor.b * lightInf; + reg1.a = shine; + if (memcmp(dComIfGp_getStartStageName(), "D_MN01", 6) == 0) { + reg1.r = 0; + reg1.g = 0x1E; + reg1.b = 5; + reg1.a = 0x50; + } + GFSetTevColorS10(GX_TEVREG1, reg1); + + if (dComIfGp_roomControl_getStatusRoomDt(i) != nullptr) { + lightCount = dComIfGp_roomControl_getStatusRoomDt(i)->getLightVecInfoNum(); + } + +#if DEBUG + if (g_kankyoHIO.light.m_HOSTIO_setting != 0) { + lightCount = g_kankyoHIO.dungeonLight.usedLights; + } +#endif + + if (dKy_SunMoon_Light_Check() == TRUE && lightCount < 2) { + lightCount = 2; + } + + for (int j = 0; j < 6; j++) { + if (kankyo->field_0x0c18[j].field_0x26 == 1) { + lightCount++; + } + } + + // room-level setup + if (first->field_0x05 <= 3 || first->field_0x05 >= 10) { + GXLoadTexObj(&mTexObj_l_M_kusa05_RGBATEX, GX_TEXMAP0); + if (lightCount <= 3) { + GXCallDisplayList(mp_kusa9q_14_DL, m_kusa9q_DL_14_size); + } else { + GXCallDisplayList(mp_kusa9q_DL, m_kusa9q_DL_size); + } + } else { + GXLoadTexObj(&mTexObj_l_M_Hijiki00TEX, GX_TEXMAP0); + GXCallDisplayList(l_Tengusa_matDL, 0xA0); + } + + GFSetTevColorS10(GX_TEVREG2, {0, 0, 0, 0}); + + dKy_Global_amb_set(tevstr); + dKy_GfFog_tevstr_set(tevstr); + dKy_setLight_nowroom_grass(tevstr->room_no, 0.0f); + + GXSetVtxDescv(l_batchVtxDescList); + GXLoadPosMtxImm(identity, GX_PNMTX0); + GXLoadNrmMtxImm(j3dSys.getViewMtx(), 0); + + // buckets: (kusa05 vs tengusa) x (standing vs cut) + bool hasRegrowing = false; + for (int bucket = 0; bucket < 4; bucket++) { + const bool kusaTex = bucket < 2; + const bool cut = (bucket & 1) != 0; + const dusk::batch::LeafTemplate& tpl = + cut ? mTplKusa9qCut : (kusaTex ? mTplKusa9q : mTplTengusa); + + bool open = false; + u32 emitted = 0; + for (dGrass_data_c* blade = first; blade != NULL; blade = blade->mp_next) { + if (cLib_checkBit(blade->field_0x01, 2)) { + continue; // clipped + } + if (blade->field_0x02 < -1) { + hasRegrowing = true; + continue; + } + const bool bladeKusaTex = blade->field_0x05 <= 3 || blade->field_0x05 >= 10; + if (bladeKusaTex != kusaTex || (blade->field_0x02 < 0) != cut) { + continue; + } + + if (!open) { + if (kusaTex) { + GXLoadTexObj(&mTexObj_l_M_kusa05_RGBATEX, GX_TEXMAP0); + if (lightCount <= 2) { + GXCallDisplayList(mp_kusa9q_14_DL, m_kusa9q_DL_14_size); + } else { + GXCallDisplayList(mp_kusa9q_DL, m_kusa9q_DL_size); + } + } else { + GXLoadTexObj(&mTexObj_l_M_Hijiki00TEX, GX_TEXMAP0); + GXCallDisplayList(l_Tengusa_matDL, 0xA0); + } + // change amb_src to GX_SRC_VTX + const u32 lightMask = + (kusaTex && lightCount <= 2) + ? (GX_LIGHT1 | GX_LIGHT2 | GX_LIGHT3 | GX_LIGHT4) + : (GX_LIGHT1 | GX_LIGHT2 | GX_LIGHT3 | GX_LIGHT4 | GX_LIGHT5 | + GX_LIGHT6 | GX_LIGHT7); + GXSetChanCtrl(GX_COLOR0, GX_TRUE, GX_SRC_VTX, GX_SRC_REG, lightMask, + GX_DF_CLAMP, GX_AF_SPOT); + reg1.a = cut ? 0 : shine; + GFSetTevColorS10(GX_TEVREG1, reg1); + GXBegin(GX_TRIANGLES, GX_VTXFMT1, GX_AUTO); + open = true; + } + + split_batch(emitted, tpl.vtxCount); + + Mtx interpMtx; + MtxP mtx = get_model_mtx(blade->m_modelMtx, interpMtx); + transform_positions(tpl, mp_pos, mtx, xfPos); + blade_emit(tpl, xfPos, mp_colors, blade_amb_color(blade, tevstr)); + } + if (open) { + GXEnd(); + } + } + + // regrowing blades have per-blade TEVREG2 alpha + // draw them with the original immediate path + if (hasRegrowing) { + GXSetVtxDescv(l_vtxDescList); + for (dGrass_data_c* blade = first; blade != NULL; blade = blade->mp_next) { + if (blade->field_0x02 >= -1 || cLib_checkBit(blade->field_0x01, 2)) { + continue; + } + + const bool kusaTex = blade->field_0x05 <= 3 || blade->field_0x05 >= 10; + if (kusaTex) { + GXLoadTexObj(&mTexObj_l_M_kusa05_RGBATEX, GX_TEXMAP0); + if (lightCount <= 2) { + GXCallDisplayList(mp_kusa9q_14_DL, m_kusa9q_DL_14_size); + } else { + GXCallDisplayList(mp_kusa9q_DL, m_kusa9q_DL_size); + } + } else { + GXLoadTexObj(&mTexObj_l_M_Hijiki00TEX, GX_TEXMAP0); + GXCallDisplayList(l_Tengusa_matDL, 0xA0); + } + + reg1.a = 0; + GFSetTevColorS10(GX_TEVREG1, reg1); + GXSetChanAmbColor(GX_COLOR0A0, blade_amb_color(blade, tevstr)); + + Mtx modelMtx; + GXLoadPosMtxImm(get_model_mtx(blade->m_modelMtx, modelMtx), GX_PNMTX0); + GXLoadNrmMtxImm(j3dSys.getViewMtx(), 0); + + GFSetTevColorS10(GX_TEVREG2, + {0, 0, 0, static_cast(-0x100 - (blade->field_0x02 << 8) / 40)}); + + if (blade->field_0x02 != -2) { + if (kusaTex) { + GXCallDisplayList(mp_Mkusa_9q_DL, m_Mkusa_9q_DL_size); + } else { + GXCallDisplayList(l_M_TenGusaDL, 0xC0); + } + } else { + GXCallDisplayList(mp_Mkusa_9q_cDL, m_Mkusa_9q_cDL_size); + } + + GFSetTevColorS10(GX_TEVREG2, {0, 0, 0, 0}); + } + } + } + + J3DShape::resetVcdVatCache(); +} +#else void dGrass_packet_c::draw() { ZoneScoped; dScnKy_env_light_c* kankyo = dKy_getEnvlight(); @@ -811,6 +1166,7 @@ void dGrass_packet_c::draw() { J3DShape::resetVcdVatCache(); } +#endif void dGrass_packet_c::calc() { cXyz* temp_r29 = dKyw_get_wind_vec(); diff --git a/src/dusk/batch.cpp b/src/dusk/batch.cpp new file mode 100644 index 0000000000..04cba52b0d --- /dev/null +++ b/src/dusk/batch.cpp @@ -0,0 +1,72 @@ +#include "dusk/batch.hpp" +#include "dusk/logging.h" + +#include +#include + +namespace dusk::batch { + +void decode_leaf_template(const u8* dl, u32 size, LeafTemplate& out) { + out.vtxCount = 0; + out.posRefCount = 0; + bool posSeen[256] = {}; + + static constexpr GXVtxDescList kLeafDesc[] = { + {GX_VA_POS, GX_INDEX8}, + {GX_VA_NRM, GX_INDEX8}, + {GX_VA_CLR0, GX_INDEX8}, + {GX_VA_TEX0, GX_INDEX8}, + {GX_VA_NULL, GX_NONE}, + }; + + aurora::gx::dl::Reader reader{dl, size, kLeafDesc}; + while (const auto cmd = reader.next()) { + if (cmd->kind == aurora::gx::dl::Command::Kind::Passthrough) { + if (cmd->data[0] != GX_NOP) { + DuskLog.fatal("decode_leaf_template: unexpected opcode {:#x}", cmd->data[0]); + } + continue; + } + if (cmd->kind != aurora::gx::dl::Command::Kind::Draw) { + DuskLog.fatal("decode_leaf_template: unexpected pre-optimized draw"); + } + + const auto& draw = cmd->draw; + bool overflow = false; + const bool expanded = + aurora::gx::dl::expand_triangles(draw.prim, draw.vtxCount, [&](u16 i0, u16 i1, u16 i2) { + if (overflow || out.vtxCount + 3 > LeafTemplate::kMaxVtx) { + overflow = true; + return; + } + for (const u16 elem : {i0, i1, i2}) { + LeafTemplate::Vtx& v = out.vtx[out.vtxCount++]; + v.pos = draw.attr_idx(elem, GX_VA_POS); + v.nrm = draw.attr_idx(elem, GX_VA_NRM); + v.clr = draw.attr_idx(elem, GX_VA_CLR0); + v.tex = draw.attr_idx(elem, GX_VA_TEX0); + if (!posSeen[v.pos]) { + posSeen[v.pos] = true; + if (out.posRefCount >= LeafTemplate::kMaxPosRefs) { + overflow = true; + return; + } + out.posRefs[out.posRefCount++] = v.pos; + } + } + }); + if (!expanded) { + DuskLog.fatal("decode_leaf_template: untriangulable draw (prim {:#x}, {} verts)", + static_cast(draw.prim), draw.vtxCount); + } + if (overflow) { + DuskLog.fatal("decode_leaf_template: template overflow ({} verts, {} positions)", + out.vtxCount, out.posRefCount); + } + } + if (reader.failed()) { + DuskLog.fatal("decode_leaf_template: failed to walk display list"); + } +} + +} // namespace dusk::batch diff --git a/src/dusk/batch.hpp b/src/dusk/batch.hpp new file mode 100644 index 0000000000..2569f27761 --- /dev/null +++ b/src/dusk/batch.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include + +namespace dusk::batch { + +struct LeafTemplate { + static constexpr u32 kMaxVtx = 192; + static constexpr u32 kMaxPosRefs = 64; + + struct Vtx { + u8 pos; + u8 nrm; + u8 clr; + u8 tex; + }; + Vtx vtx[kMaxVtx]; + u16 vtxCount = 0; + u8 posRefs[kMaxPosRefs]; + u8 posRefCount = 0; +}; + +void decode_leaf_template(const u8* dl, u32 size, LeafTemplate& out); + +} // namespace dusk From cacb768725e1b1a0822e7839b7386c92c17130db Mon Sep 17 00:00:00 2001 From: Luke Street Date: Sat, 13 Jun 2026 10:44:51 -0600 Subject: [PATCH 016/150] Updatea aurora & set DOWNLOAD_EXTRACT_TIMESTAMP FALSE --- CMakeLists.txt | 4 ++-- extern/aurora | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 80be4f3fe4..1abe420b67 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -291,13 +291,13 @@ message(STATUS "dusklight: Fetching cxxopts") FetchContent_Declare(cxxopts URL https://github.com/jarro2783/cxxopts/archive/refs/tags/v3.3.1.tar.gz URL_HASH SHA256=3bfc70542c521d4b55a46429d808178916a579b28d048bd8c727ee76c39e2072 - DOWNLOAD_EXTRACT_TIMESTAMP TRUE + DOWNLOAD_EXTRACT_TIMESTAMP FALSE ) message(STATUS "dusklight: Fetching nlohmann/json") FetchContent_Declare(json URL https://github.com/nlohmann/json/releases/download/v3.12.0/json.tar.xz URL_HASH SHA256=42f6e95cad6ec532fd372391373363b62a14af6d771056dbfc86160e6dfff7aa - DOWNLOAD_EXTRACT_TIMESTAMP TRUE + DOWNLOAD_EXTRACT_TIMESTAMP FALSE ) FetchContent_MakeAvailable(cxxopts json) diff --git a/extern/aurora b/extern/aurora index ad55eedb31..0c68e0b4e8 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit ad55eedb3121e540b44fbabf8e9f68bfa55ba182 +Subproject commit 0c68e0b4e804fe835d59bf6f8c81f1dac4a617b3 From d0894853d79cf4a020ce7e3a07ee217c7848877b Mon Sep 17 00:00:00 2001 From: Kyrio Date: Sun, 14 Jun 2026 04:47:18 +0200 Subject: [PATCH 017/150] Add setting to memorize window size (#2025) * Add "Memorize Window Size" setting * Code review #2025 * memorize -> remember, helpText revision --------- Co-authored-by: Irastris --- include/dusk/settings.h | 3 +++ src/dusk/settings.cpp | 6 ++++++ src/dusk/ui/settings.cpp | 15 +++++++++++++++ src/m_Do/m_Do_main.cpp | 21 +++++++++++++++++++-- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/include/dusk/settings.h b/include/dusk/settings.h index 7bdb97370e..da8055b225 100644 --- a/include/dusk/settings.h +++ b/include/dusk/settings.h @@ -134,6 +134,9 @@ struct UserSettings { ConfigVar enableFpsOverlay; ConfigVar fpsOverlayCorner; ConfigVar maxFrameRate; + ConfigVar rememberWindowSize; + ConfigVar lastWindowWidth; + ConfigVar lastWindowHeight; } video; struct { diff --git a/src/dusk/settings.cpp b/src/dusk/settings.cpp index 0a58dfd1ec..74145e32ea 100644 --- a/src/dusk/settings.cpp +++ b/src/dusk/settings.cpp @@ -11,6 +11,9 @@ UserSettings g_userSettings = { .enableFpsOverlay {"game.enableFpsOverlay", false}, .fpsOverlayCorner {"game.fpsOverlayCorner", 0}, .maxFrameRate {"video.maxFrameRate", 240}, + .rememberWindowSize {"video.rememberWindowSize", false}, + .lastWindowWidth {"video.lastWindowWidth", 0}, + .lastWindowHeight {"video.lastWindowHeight", 0}, }, .audio = { @@ -200,6 +203,9 @@ void registerSettings() { Register(g_userSettings.video.enableFpsOverlay); Register(g_userSettings.video.fpsOverlayCorner); Register(g_userSettings.video.maxFrameRate); + Register(g_userSettings.video.rememberWindowSize); + Register(g_userSettings.video.lastWindowWidth); + Register(g_userSettings.video.lastWindowHeight); // Audio Register(g_userSettings.audio.masterVolume); diff --git a/src/dusk/ui/settings.cpp b/src/dusk/ui/settings.cpp index c0f1dc235a..49757aab20 100644 --- a/src/dusk/ui/settings.cpp +++ b/src/dusk/ui/settings.cpp @@ -802,6 +802,21 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { pane.add_rml( "
Display the current framerate in a corner of the screen while playing."); }); + config_bool_select(leftPane, rightPane, getSettings().video.rememberWindowSize, + { + .key = "Remember Window Size", + .helpText = "Save and restore the previous session's window size when opening Dusklight.", + .onChange = + [](bool value) { + if (value && !dusk::getSettings().video.enableFullscreen) { + const auto windowSize = aurora::window::get_window_size(); + dusk::getSettings().video.lastWindowWidth.setValue(windowSize.width); + dusk::getSettings().video.lastWindowHeight.setValue(windowSize.height); + dusk::config::Save(); + } + }, + .isDisabled = [] { return IsMobile; }, + }); leftPane.add_section("Resolution"); graphics_tuner_control(*this, leftPane, rightPane, getSettings().game.internalResolutionScale, diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index 12d2202e36..8fedae1f6a 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -259,6 +259,13 @@ void main01(void) { dusk::ui::handle_event(event->sdl); dusk::g_imguiConsole.HandleSDLEvent(event->sdl); break; + case AURORA_WINDOW_RESIZED: + if (dusk::getSettings().video.rememberWindowSize && !dusk::getSettings().video.enableFullscreen) { + dusk::getSettings().video.lastWindowWidth.setValue(event->windowSize.width); + dusk::getSettings().video.lastWindowHeight.setValue(event->windowSize.height); + dusk::config::Save(); + } + break; case AURORA_DISPLAY_SCALE_CHANGED: dusk::ImGuiEngine_Initialize(event->windowSize.scale); break; @@ -584,8 +591,18 @@ int game_main(int argc, char* argv[]) { config.startFullscreen = dusk::getSettings().video.enableFullscreen; config.windowPosX = -1; config.windowPosY = -1; - config.windowWidth = defaultWindowWidth * 2; - config.windowHeight = defaultWindowHeight * 2; + + const int lastWindowWidth = dusk::getSettings().video.lastWindowWidth.getValue(); + const int lastWindowHeight = dusk::getSettings().video.lastWindowHeight.getValue(); + + if (dusk::getSettings().video.rememberWindowSize && lastWindowWidth > 0 && lastWindowHeight > 0) { + config.windowWidth = lastWindowWidth; + config.windowHeight = lastWindowHeight; + } else { + config.windowWidth = defaultWindowWidth * 2; + config.windowHeight = defaultWindowHeight * 2; + } + config.desiredBackend = ResolveDesiredBackend(parsed_arg_options); config.logCallback = &aurora_log_callback; config.logLevel = startupLogLevel; From b07fb501285b9e3f7141dcdea1e9d7857c3ace21 Mon Sep 17 00:00:00 2001 From: matthewdavidrichardanderson Date: Sun, 14 Jun 2026 06:39:30 +0200 Subject: [PATCH 018/150] monkey crash fix (#2034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * monkey fix 🐒 * wrap saru_p clear in target_pc * Clear monkey leader pointer on delete --------- Co-authored-by: matthewdavidrichardanderson Co-authored-by: Luke Street --- src/d/actor/d_a_npc_ks.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/d/actor/d_a_npc_ks.cpp b/src/d/actor/d_a_npc_ks.cpp index 71eeb16335..222827df63 100644 --- a/src/d/actor/d_a_npc_ks.cpp +++ b/src/d/actor/d_a_npc_ks.cpp @@ -6882,6 +6882,16 @@ static int daNpc_Ks_Delete(npc_ks_class* i_this) { i_this->model->stopZelAnime(); } +#if TARGET_PC + if (leader == i_this) { + leader = NULL; + } + + if (saru_p[i_this->set_id] == i_this) { + saru_p[i_this->set_id] = NULL; + } +#endif + return 1; } From afc3376aec8f33f2d77bd1776b591cc939bc55a2 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Sun, 14 Jun 2026 01:11:14 -0600 Subject: [PATCH 019/150] Update aurora --- extern/aurora | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extern/aurora b/extern/aurora index 0c68e0b4e8..b59a3a5b3c 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 0c68e0b4e804fe835d59bf6f8c81f1dac4a617b3 +Subproject commit b59a3a5b3caa53174365109b1abc0ced0ce0c9b7 From 33e13d508b1439a1c24920830d37f6d1a7917ebf Mon Sep 17 00:00:00 2001 From: Luke Street Date: Sun, 14 Jun 2026 11:28:19 -0600 Subject: [PATCH 020/150] Use Aurora-provided FPS for overlay --- extern/aurora | 2 +- src/dusk/ui/overlay.cpp | 44 ++--------------------------------------- src/dusk/ui/overlay.hpp | 13 ------------ 3 files changed, 3 insertions(+), 56 deletions(-) diff --git a/extern/aurora b/extern/aurora index b59a3a5b3c..e3fd6b1900 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit b59a3a5b3caa53174365109b1abc0ced0ce0c9b7 +Subproject commit e3fd6b19008e40ba3888099e3c4555c9958e1c21 diff --git a/src/dusk/ui/overlay.cpp b/src/dusk/ui/overlay.cpp index 69c262720e..d6f87b9262 100644 --- a/src/dusk/ui/overlay.cpp +++ b/src/dusk/ui/overlay.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -187,43 +188,6 @@ void remove_element(Rml::Element*& elem) noexcept { } // namespace -// https://vplesko.com/posts/how_to_implement_an_fps_counter.html -void Overlay::advance_fps_counter(float& outFps, Uint64 perfFreq) { - if (perfFreq == 0) { - outFps = 0.f; - return; - } - - const Uint64 curr = SDL_GetPerformanceCounter(); - if (!mFpsHavePrevCounter) { - mFpsPrevCounter = curr; - mFpsHavePrevCounter = true; - outFps = 0.f; - return; - } - - const Uint64 processingTicks = curr - mFpsPrevCounter; - mFpsPrevCounter = curr; - - mFpsFrameEvents.push_back({curr, processingTicks}); - mFpsSumTicks += processingTicks; - - while (!mFpsFrameEvents.empty() && mFpsFrameEvents.front().endCounter + perfFreq < curr) { - mFpsSumTicks -= mFpsFrameEvents.front().processingTicks; - mFpsFrameEvents.pop_front(); - } - - const auto n = mFpsFrameEvents.size(); - if (n == 0 || mFpsSumTicks == 0) { - outFps = 0.f; - return; - } - - const double avgSeconds = - static_cast(mFpsSumTicks) / static_cast(n) / static_cast(perfFreq); - outFps = static_cast(1.0 / avgSeconds); -} - static std::string FormatTime(OSTime ticks) { OSCalendarTime t; OSTicksToCalendarTime(ticks, &t); @@ -276,8 +240,7 @@ void Overlay::update() { mFpsCounter->SetAttribute("corner", kFpsCorners[idx]); const Uint64 perfFreq = SDL_GetPerformanceFrequency(); - float fps = 0.f; - advance_fps_counter(fps, perfFreq); + float fps = aurora_get_fps(); const Uint64 now = SDL_GetPerformanceCounter(); // Limit updates to twice per second @@ -290,9 +253,6 @@ void Overlay::update() { } } else { mFpsCounter->RemoveAttribute("open"); - mFpsFrameEvents.clear(); - mFpsSumTicks = 0; - mFpsHavePrevCounter = false; mFpsLastUpdate = 0; } } diff --git a/src/dusk/ui/overlay.hpp b/src/dusk/ui/overlay.hpp index 8a2edd4a26..a8ccde5b9d 100644 --- a/src/dusk/ui/overlay.hpp +++ b/src/dusk/ui/overlay.hpp @@ -3,7 +3,6 @@ #include "document.hpp" #include -#include namespace dusk::ui { @@ -26,19 +25,7 @@ protected: Rml::Element* mSpeedrunIgt = nullptr; clock::time_point mCurrentToastStartTime; clock::time_point mMenuNotificationStartTime; - - struct FpsFrameEvent { - Uint64 endCounter; - Uint64 processingTicks; - }; - - std::deque mFpsFrameEvents; - Uint64 mFpsSumTicks = 0; - bool mFpsHavePrevCounter = false; - Uint64 mFpsPrevCounter = 0; Uint64 mFpsLastUpdate = 0; - - void advance_fps_counter(float& outFps, Uint64 perfFreq); }; } // namespace dusk::ui From 02fdde376870639b2f492f4f481dacde9f466437 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Sun, 14 Jun 2026 22:41:33 -0600 Subject: [PATCH 021/150] Scale map rendering with IR (#2056) * Scale map rendering with IR + scale config * Remove config option --- include/d/d_map.h | 3 ++ src/d/d_map.cpp | 22 ++++++++++++ src/d/d_map_path.cpp | 79 +++++++++++++++++++++++++------------------ src/d/d_meter_map.cpp | 6 ++++ 4 files changed, 77 insertions(+), 33 deletions(-) diff --git a/include/d/d_map.h b/include/d/d_map.h index 1acc7e4519..1cddbf3944 100644 --- a/include/d/d_map.h +++ b/include/d/d_map.h @@ -157,6 +157,9 @@ public: int getDispType() const; void _move(f32, f32, int, f32); void _draw(); +#if TARGET_PC + bool refreshTextureSize(); +#endif virtual ~dMap_c() { #if DEBUG diff --git a/src/d/d_map.cpp b/src/d/d_map.cpp index 8cecead15a..765facc335 100644 --- a/src/d/d_map.cpp +++ b/src/d/d_map.cpp @@ -1213,6 +1213,10 @@ void dMap_c::changeTextureSize(int param_1, int param_2, int param_3) { JUT_ASSERT(2672, mImage_p != NULL); JUT_ASSERT(2673, mResTIMG != NULL); +#if TARGET_PC + GXDestroyCopyTex(mImage_p); +#endif + mTexSizeX = param_1 >> param_3; mTexSizeY = param_2 >> param_3; @@ -1226,6 +1230,24 @@ void dMap_c::changeTextureSize(int param_1, int param_2, int param_3) { } #endif +#if TARGET_PC +bool dMap_c::refreshTextureSize() { + JUT_ASSERT(2688, mImage_p != NULL); + JUT_ASSERT(2689, mResTIMG != NULL); + + const u16 oldWidth = mResTIMG->width; + const u16 oldHeight = mResTIMG->height; + makeResTIMG(mResTIMG, mTexSizeX, mTexSizeY, mImage_p, (u8*)m_res, 0x33); + + if (mResTIMG->width == oldWidth && mResTIMG->height == oldHeight) { + return false; + } + + GXDestroyCopyTex(mImage_p); + return true; +} +#endif + void dMap_c::_remove() { if (mImage_p != NULL) { #if TARGET_PC diff --git a/src/d/d_map_path.cpp b/src/d/d_map_path.cpp index a7579ee92d..cafbb93e38 100644 --- a/src/d/d_map_path.cpp +++ b/src/d/d_map_path.cpp @@ -15,32 +15,49 @@ #include #ifdef TARGET_PC -#include -#include -#include +#include "dusk/settings.h" +#include "m_Do/m_Do_graphic.h" +#include +#include -constexpr u16 kPreferredMapResolutionMultiplier = 4; -constexpr u32 kMaxMapRenderPixels = 4096 * 4096; -constexpr u16 kMapImageSide = 16 * kPreferredMapResolutionMultiplier; +#include +#include +#include +#include +#include +#include +#include + +constexpr u16 kMapIconResolutionMultiplier = 4; +constexpr u16 kMapImageSide = 16 * kMapIconResolutionMultiplier; constexpr u32 kMapImageTotalPixels = kMapImageSide * kMapImageSide; typedef std::function PaintI8Fn; -u16 map_resolution_multiplier(u16 width, u16 height) { - const u32 basePixels = static_cast(width) * height; - if (basePixels == 0) { - return 1; +u16 scaled_map_axis(u16 value, f32 scale) { + const auto scaledValue = + static_cast(std::max(1.0f, std::round(static_cast(value) * scale))); + return static_cast(std::min(scaledValue, std::numeric_limits::max())); +} + +aurora::Vec2 map_render_size_for(u16 width, u16 height) { + if (width == 0 || height == 0) { + return {width, height}; } - u16 scale = kPreferredMapResolutionMultiplier; - while (scale > 1) { - const u32 scalePixels = static_cast(scale) * scale; - if (basePixels <= kMaxMapRenderPixels / scalePixels) { - break; - } - scale--; - } - return scale; + u32 renderWidth = 0; + u32 renderHeight = 0; + AuroraGetRenderSize(&renderWidth, &renderHeight); + + const f32 logicalWidth = std::max(mDoGph_gInf_c::getWidthF(), 1.0f); + const f32 logicalHeight = std::max(mDoGph_gInf_c::getHeightF(), 1.0f); + const f32 irScaleX = renderWidth > 0 ? static_cast(renderWidth) / logicalWidth : 1.0f; + const f32 irScaleY = renderHeight > 0 ? static_cast(renderHeight) / logicalHeight : 1.0f; + const f32 hudScale = std::clamp(dusk::getSettings().game.hudScale.getValue(), 0.5f, 2.0f); + return { + scaled_map_axis(width, irScaleX * hudScale), + scaled_map_axis(height, irScaleY * hudScale), + }; } void paint_i8(std::span dst, size_t width, PaintI8Fn paint) { @@ -496,9 +513,9 @@ void dRenderingMap_c::makeResTIMG(ResTIMG* p_image, u16 width, u16 height, u8* p p_image->format = GX_TF_C8; p_image->alphaEnabled = 2; #ifdef TARGET_PC - const u16 scale = map_resolution_multiplier(width, height); - p_image->width = width * scale; - p_image->height = height * scale; + const auto [rw, rh] = map_render_size_for(width, height); + p_image->width = rw; + p_image->height = rh; #else p_image->width = width; p_image->height = height; @@ -581,16 +598,14 @@ void dRenderingFDAmap_c::drawBack() const { void dRenderingFDAmap_c::preRenderingMap() { #ifdef TARGET_PC - const u16 scale = map_resolution_multiplier(mTexWidth, mTexHeight); - const u16 w = mTexWidth * scale; - const u16 h = mTexHeight * scale; - GXCreateFrameBuffer(w, h); + const auto [rw, rh] = map_render_size_for(mTexWidth, mTexHeight); + GXCreateFrameBuffer(rw, rh); // Set logical viewport dimensions GXSetViewport(0.0f, 0.0f, mTexWidth, mTexHeight, 0.0f, 1.0f); GXSetScissor(0, 0, mTexWidth, mTexHeight); // Set render viewport dimensions - GXSetViewportRender(0.0f, 0.0f, w, h, 0.0f, 1.0f); - GXSetScissorRender(0, 0, w, h); + GXSetViewportRender(0.0f, 0.0f, rw, rh, 0.0f, 1.0f); + GXSetScissorRender(0, 0, rw, rh); #else GXSetViewport(0.0f, 0.0f, mTexWidth, mTexHeight, 0.0f, 1.0f); GXSetScissor(0, 0, mTexWidth, mTexHeight); @@ -628,11 +643,9 @@ void dRenderingFDAmap_c::preRenderingMap() { void dRenderingFDAmap_c::postRenderingMap() { GXSetCopyFilter(GX_FALSE, NULL, GX_FALSE, NULL); #ifdef TARGET_PC - const u16 scale = map_resolution_multiplier(mTexWidth, mTexHeight); - const u16 w = mTexWidth * scale; - const u16 h = mTexHeight * scale; - GXSetTexCopySrc(0, 0, w, h); - GXSetTexCopyDst(w, h, GX_CTF_R8, GX_FALSE); + const auto [rw, rh] = map_render_size_for(mTexWidth, mTexHeight); + GXSetTexCopySrc(0, 0, rw, rh); + GXSetTexCopyDst(rw, rh, GX_CTF_R8, GX_FALSE); GXCopyTex(field_0x4, GX_TRUE); GXRestoreFrameBuffer(); #else diff --git a/src/d/d_meter_map.cpp b/src/d/d_meter_map.cpp index c03214b963..95cd82cd53 100644 --- a/src/d/d_meter_map.cpp +++ b/src/d/d_meter_map.cpp @@ -539,6 +539,12 @@ void dMeterMap_c::_move(u32 param_0) { } #endif +#if TARGET_PC + if (mMap->refreshTextureSize()) { + mMapJ2DPicture->changeTexture(mMap->getResTIMGPointer(), 0); + } +#endif + int stayNo = dComIfGp_roomControl_getStayNo(); field_0x14 = param_0; From 38a0a7be0bb9aee462b204af359e2a7f99bc3856 Mon Sep 17 00:00:00 2001 From: doop <56421834+dooplecks@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:47:23 -0400 Subject: [PATCH 022/150] Always create a save file if one doesn't exist (#2028) * Always create a save file if one doesn't exist * Fix MSVC build * Re-add accidentally deleted line * Integrate into d_file_select state machine & put behind instantSaves --------- Co-authored-by: Luke Street --- include/d/d_file_select.h | 9 +++++++ src/d/d_file_select.cpp | 54 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/include/d/d_file_select.h b/include/d/d_file_select.h index d478126b38..75fbb63fd9 100644 --- a/include/d/d_file_select.h +++ b/include/d/d_file_select.h @@ -287,6 +287,11 @@ public: MEMCARDCHECKPROC_ERR_YESNO_CURSOR_MOVE_ANM, MEMCARDCHECKPROC_SAVEDATA_CLEAR, +#if TARGET_PC + MEMCARDCHECKPROC_AUTO_MAKE_GAMEFILE, + MEMCARDCHECKPROC_AUTO_MAKE_GAMEFILE_ERR_WAIT, +#endif + #if PLATFORM_WII || PLATFORM_SHIELD MEMCARDCHECKPROC_NAND_STAT_CHECK, MEMCARDCHECKPROC_GAMEFILE_INIT_SEL, @@ -445,6 +450,10 @@ public: void MemCardMakeGameFile(); void MemCardMakeGameFileWait(); void MemCardMakeGameFileCheck(); +#if TARGET_PC + void MemCardAutoMakeGameFile(); + void MemCardAutoMakeGameFileErrWait(); +#endif void MemCardMsgWindowInitOpen(); void MemCardMsgWindowOpen(); void MemCardMsgWindowClose(); diff --git a/src/d/d_file_select.cpp b/src/d/d_file_select.cpp index 7ba3fac836..27b18c202a 100644 --- a/src/d/d_file_select.cpp +++ b/src/d/d_file_select.cpp @@ -4238,6 +4238,11 @@ static MemCardCheckFuncT MemCardCheckProc[] = { &dFile_select_c::MemCardErrYesNoCursorMoveAnm, &dFile_select_c::MemCardSaveDataClear, +#if TARGET_PC + &dFile_select_c::MemCardAutoMakeGameFile, + &dFile_select_c::MemCardAutoMakeGameFileErrWait, +#endif + #if PLATFORM_WII || PLATFORM_SHIELD &dFile_select_c::nandStatCheck, &dFile_select_c::gameFileInitSel, @@ -4321,11 +4326,33 @@ void dFile_select_c::MemCardStatCheck() { mDoMemCd_Load(); mCardCheckProc = MEMCARDCHECKPROC_LOAD_WAIT; break; +#if TARGET_PC + case 1: { // no save file + if (dusk::getSettings().game.instantSaves) { + field_0x03b1 = 1; + setInitSaveData(); + dataSave(); + mCardCheckProc = MEMCARDCHECKPROC_AUTO_MAKE_GAMEFILE; + } else { + errDispInitSet(22, 0); + field_0x0280 = true; + mNextCardCheckProc = MEMCARDCHECKPROC_MAKE_GAMEFILE_SEL; + } + break; + } + case 4: // card is writing + if (dusk::getSettings().game.instantSaves) { + field_0x03b1 = 1; + mCardCheckProc = MEMCARDCHECKPROC_AUTO_MAKE_GAMEFILE; + } + break; +#else case 1: errDispInitSet(22, 0); field_0x0280 = true; mNextCardCheckProc = MEMCARDCHECKPROC_MAKE_GAMEFILE_SEL; break; +#endif } #else switch (status) { @@ -5031,6 +5058,33 @@ void dFile_select_c::MemCardMakeGameFileCheck() { } } +#if TARGET_PC +void dFile_select_c::MemCardAutoMakeGameFile() { + field_0x03b4 = mDoMemCd_SaveSync(); + if (field_0x03b4 == 0) { + return; + } + + field_0x03b1 = 0; + if (field_0x03b4 == 1) { + mDoMemCd_Load(); + mCardCheckProc = MEMCARDCHECKPROC_LOAD_WAIT; + } else { + errDispInitSet(0x1A, 0); + field_0x0280 = false; + mWindowCloseMsgDispCb = NULL; + mKeyWaitMsgDispCb = NULL; + mNextCardCheckProc = MEMCARDCHECKPROC_AUTO_MAKE_GAMEFILE_ERR_WAIT; + } +} + +void dFile_select_c::MemCardAutoMakeGameFileErrWait() { + mNextCardCheckProc = MEMCARDCHECKPROC_STAT_CHECK; + mKeyWaitCardCheckProc = MEMCARDCHECKPROC_MSG_WINDOW_CLOSE; + mCardCheckProc = MEMCARDCHECKPROC_ERRMSG_WAIT_KEY; +} +#endif + #if PLATFORM_WII || PLATFORM_SHIELD void dFile_select_c::gameFileInitSel() { if (errYesNoSelect() != 0) { From a6e5160c7162b43601860234c01ee531df2976f3 Mon Sep 17 00:00:00 2001 From: qubitnano <146656568+qubitnano@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:47:40 -0400 Subject: [PATCH 023/150] flake.nix: xxHash -> xxhash (#2058) warning: 'xxHash' has been renamed to 'xxhash' --- flake.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 99fcd49f94..ec87c5526a 100644 --- a/flake.nix +++ b/flake.nix @@ -138,7 +138,7 @@ NOD_PREBUILT = nod; CXXOPTS = pkgs.cxxopts.src; JSON = pkgs.nlohmann_json.src; - XXHASH = pkgs.xxHash.src; + XXHASH = pkgs.xxhash.src; ZSTD = pkgs.zstd.src; FMT = pkgs.fetchzip { url = "https://github.com/fmtlib/fmt/archive/refs/tags/11.1.4.tar.gz"; @@ -194,7 +194,7 @@ pkgs.zstd pkgs.cxxopts pkgs.nlohmann_json - pkgs.xxHash + pkgs.xxhash pkgs.abseil-cpp pkgs.zlib pkgs.libpng From ad53af5c78dd0dba1e878d4582f03d276503b044 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Mon, 15 Jun 2026 12:48:04 -0600 Subject: [PATCH 024/150] Touch controls (#2053) * WIP touch controls * Action icons * Updates * Don't mutate freeCamera config; allow switching between touch and controller cam * Wow * Fix build & add Skip button * Fix build & add settings * RCSS cleanup * Dpad and fishing, might redo * Add menu mouse controls * More pointer & fix icons * Optimizations & introduce layout system * Update aurora * Implement touch controls layout editor * Cleanup & fixes * Allow disabling mouse/touch in menus * More fixes --- extern/aurora | 2 +- files.cmake | 12 + include/d/d_camera.h | 2 +- include/d/d_file_select.h | 4 + include/d/d_menu_collect.h | 2 + include/d/d_menu_insect.h | 4 + include/d/d_menu_letter.h | 4 + include/d/d_menu_option.h | 3 + include/d/d_menu_ring.h | 3 + include/d/d_menu_save.h | 2 + include/d/d_menu_skill.h | 4 + include/d/d_msg_scrn_3select.h | 4 + include/dusk/action_bindings.h | 8 + include/dusk/config_var.hpp | 11 +- include/dusk/menu_pointer.h | 60 + include/dusk/settings.h | 31 +- include/dusk/touch_camera.h | 12 + .../include/JSystem/J2DGraph/J2DPicture.h | 3 + res/rml/overlay.rcss | 2 +- res/rml/touch_controls.rcss | 340 ++++ res/rml/touch_controls_editor.rcss | 138 ++ src/d/actor/d_a_alink_link.inc | 27 + src/d/d_camera.cpp | 49 +- src/d/d_file_select.cpp | 260 ++++ src/d/d_menu_collect.cpp | 91 ++ src/d/d_menu_fmap2D.cpp | 13 + src/d/d_menu_insect.cpp | 43 + src/d/d_menu_letter.cpp | 67 + src/d/d_menu_option.cpp | 132 ++ src/d/d_menu_ring.cpp | 81 + src/d/d_menu_save.cpp | 155 +- src/d/d_menu_skill.cpp | 38 + src/d/d_meter2.cpp | 8 + src/d/d_meter2_draw.cpp | 35 + src/d/d_meter_map.cpp | 53 +- src/d/d_msg_class.cpp | 15 + src/d/d_msg_object.cpp | 20 +- src/d/d_msg_scrn_3select.cpp | 55 + src/d/d_msg_scrn_explain.cpp | 4 + src/dusk/action_bindings.cpp | 81 +- src/dusk/config.cpp | 274 +++- src/dusk/gyro.cpp | 11 +- src/dusk/menu_pointer.cpp | 386 +++++ src/dusk/mouse.cpp | 14 +- src/dusk/settings.cpp | 30 + src/dusk/touch_camera.cpp | 26 + src/dusk/ui/button.hpp | 2 - src/dusk/ui/controller_config.cpp | 14 + src/dusk/ui/controls.hpp | 192 +++ src/dusk/ui/document.cpp | 36 +- src/dusk/ui/document.hpp | 11 +- src/dusk/ui/icon_provider.cpp | 899 +++++++++++ src/dusk/ui/icon_provider.hpp | 23 + src/dusk/ui/overlay.cpp | 4 +- src/dusk/ui/preset.cpp | 2 + src/dusk/ui/settings.cpp | 39 + src/dusk/ui/touch_controls.cpp | 1372 +++++++++++++++++ src/dusk/ui/touch_controls.hpp | 133 ++ src/dusk/ui/touch_controls_common.cpp | 359 +++++ src/dusk/ui/touch_controls_common.hpp | 45 + src/dusk/ui/touch_controls_editor.cpp | 630 ++++++++ src/dusk/ui/touch_controls_editor.hpp | 98 ++ src/dusk/ui/ui.cpp | 11 +- src/f_ap/f_ap_game.cpp | 3 + src/m_Do/m_Do_controller_pad.cpp | 14 + src/m_Do/m_Do_main.cpp | 2 + 66 files changed, 6372 insertions(+), 136 deletions(-) create mode 100644 include/dusk/menu_pointer.h create mode 100644 include/dusk/touch_camera.h create mode 100644 res/rml/touch_controls.rcss create mode 100644 res/rml/touch_controls_editor.rcss create mode 100644 src/dusk/menu_pointer.cpp create mode 100644 src/dusk/touch_camera.cpp create mode 100644 src/dusk/ui/controls.hpp create mode 100644 src/dusk/ui/icon_provider.cpp create mode 100644 src/dusk/ui/icon_provider.hpp create mode 100644 src/dusk/ui/touch_controls.cpp create mode 100644 src/dusk/ui/touch_controls.hpp create mode 100644 src/dusk/ui/touch_controls_common.cpp create mode 100644 src/dusk/ui/touch_controls_common.hpp create mode 100644 src/dusk/ui/touch_controls_editor.cpp create mode 100644 src/dusk/ui/touch_controls_editor.hpp diff --git a/extern/aurora b/extern/aurora index e3fd6b1900..f49d3c5f58 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit e3fd6b19008e40ba3888099e3c4555c9958e1c21 +Subproject commit f49d3c5f58aa2fd3e21f73a129e07c1df68aaa98 diff --git a/files.cmake b/files.cmake index a920db2e4f..715622a495 100644 --- a/files.cmake +++ b/files.cmake @@ -1434,6 +1434,8 @@ set(DUSK_FILES src/dusk/game_clock.cpp src/dusk/globals.cpp src/dusk/gyro.cpp + include/dusk/menu_pointer.h + src/dusk/menu_pointer.cpp src/dusk/mouse.cpp src/dusk/gamepad_color.cpp src/dusk/autosave.cpp @@ -1447,6 +1449,7 @@ set(DUSK_FILES src/dusk/stubs.cpp include/dusk/texture_replacements.hpp src/dusk/texture_replacements.cpp + src/dusk/touch_camera.cpp src/dusk/update_check.cpp src/dusk/update_check.hpp #src/dusk/m_Do_ext_dusk.cpp @@ -1476,6 +1479,7 @@ set(DUSK_FILES src/dusk/ui/button.hpp src/dusk/ui/component.cpp src/dusk/ui/component.hpp + src/dusk/ui/controls.hpp src/dusk/ui/controller_config.cpp src/dusk/ui/controller_config.hpp src/dusk/ui/document.cpp @@ -1488,6 +1492,8 @@ set(DUSK_FILES src/dusk/ui/graphics_tuner.hpp src/dusk/ui/input.cpp src/dusk/ui/input.hpp + src/dusk/ui/icon_provider.cpp + src/dusk/ui/icon_provider.hpp src/dusk/ui/modal.cpp src/dusk/ui/modal.hpp src/dusk/ui/nav_types.hpp @@ -1513,6 +1519,12 @@ set(DUSK_FILES src/dusk/ui/string_button.hpp src/dusk/ui/tab_bar.cpp src/dusk/ui/tab_bar.hpp + src/dusk/ui/touch_controls_common.cpp + src/dusk/ui/touch_controls_common.hpp + src/dusk/ui/touch_controls.cpp + src/dusk/ui/touch_controls.hpp + src/dusk/ui/touch_controls_editor.cpp + src/dusk/ui/touch_controls_editor.hpp src/dusk/ui/ui.cpp src/dusk/ui/ui.hpp src/dusk/ui/warp.cpp diff --git a/include/d/d_camera.h b/include/d/d_camera.h index 0698bbbe5a..f98667d988 100644 --- a/include/d/d_camera.h +++ b/include/d/d_camera.h @@ -1037,7 +1037,7 @@ public: bool test1Camera(s32); bool test2Camera(s32); #if TARGET_PC - static bool canUseFreeCam(); + static bool isAimActive(); bool freeCamera(); bool executeDebugFlyCam(); void deactivateDebugFlyCam(); diff --git a/include/d/d_file_select.h b/include/d/d_file_select.h index 75fbb63fd9..634c0db352 100644 --- a/include/d/d_file_select.h +++ b/include/d/d_file_select.h @@ -416,6 +416,10 @@ public: bool yesnoWakuAlpahAnm(u8); #if TARGET_PC void fileSelectWide(); + bool pointerDataSelect(); + bool pointerMenuSelect(); + bool pointerCopyDataToSelect(); + bool pointerYesNoSelect(bool errorSelect); #endif void _draw(); void errorMoveAnmInitSet(int, int); diff --git a/include/d/d_menu_collect.h b/include/d/d_menu_collect.h index 28a095896e..d99b8f17cb 100644 --- a/include/d/d_menu_collect.h +++ b/include/d/d_menu_collect.h @@ -74,6 +74,8 @@ public: #if TARGET_PC void menuCollectWide(); + bool pointerWait(); + void pointerActivateCurrent(); #endif void _create(); diff --git a/include/d/d_menu_insect.h b/include/d/d_menu_insect.h index 6b23a3845d..90cfca7d5a 100644 --- a/include/d/d_menu_insect.h +++ b/include/d/d_menu_insect.h @@ -51,6 +51,10 @@ public: void setBButtonString(u16); void setHIO(bool); +#if TARGET_PC + bool pointerWait(); +#endif + virtual void draw() { _draw(); } virtual ~dMenu_Insect_c(); diff --git a/include/d/d_menu_letter.h b/include/d/d_menu_letter.h index 163c381699..208f8b65a9 100644 --- a/include/d/d_menu_letter.h +++ b/include/d/d_menu_letter.h @@ -55,6 +55,10 @@ public: u8 getLetterNum(); void setHIO(bool); +#if TARGET_PC + bool pointerWait(); +#endif + virtual void draw() { _draw(); } virtual ~dMenu_Letter_c(); diff --git a/include/d/d_menu_option.h b/include/d/d_menu_option.h index 7204d62974..03aca42808 100644 --- a/include/d/d_menu_option.h +++ b/include/d/d_menu_option.h @@ -80,6 +80,9 @@ public: void setBButtonString(u16); bool isRumbleSupported(); bool dpdMenuMove(); +#if TARGET_PC + bool pointerConfirmSelect(); +#endif void paneResize(u64); void initialize(); void yesnoMenuMoveAnmInitSet(int, int); diff --git a/include/d/d_menu_ring.h b/include/d/d_menu_ring.h index 74624eac80..f2137a9748 100644 --- a/include/d/d_menu_ring.h +++ b/include/d/d_menu_ring.h @@ -74,6 +74,9 @@ public: void clacEllipsePlotAverage(int, f32, f32); bool dpdMove(); u8 openExplain(u8); +#if TARGET_PC + bool pointerMove(); +#endif virtual void draw() { _draw(); } virtual ~dMenu_Ring_c(); diff --git a/include/d/d_menu_save.h b/include/d/d_menu_save.h index 116795f7be..d9d052aa33 100644 --- a/include/d/d_menu_save.h +++ b/include/d/d_menu_save.h @@ -266,6 +266,8 @@ public: #if TARGET_PC void menuSaveWide(); + bool pointerSaveSelect(); + bool pointerYesNoSelect(bool errorSelect, u8 errParam = 0, u8 soundParam = 0); #endif void _draw2(); diff --git a/include/d/d_menu_skill.h b/include/d/d_menu_skill.h index 9ea305163e..2f9097b165 100644 --- a/include/d/d_menu_skill.h +++ b/include/d/d_menu_skill.h @@ -49,6 +49,10 @@ public: u8 getSkillNum(); void setHIO(bool); +#if TARGET_PC + bool pointerWait(); +#endif + virtual void draw() { _draw(); } virtual ~dMenu_Skill_c(); diff --git a/include/d/d_msg_scrn_3select.h b/include/d/d_msg_scrn_3select.h index 949db3f130..1cb92e5be6 100644 --- a/include/d/d_msg_scrn_3select.h +++ b/include/d/d_msg_scrn_3select.h @@ -49,6 +49,10 @@ public: void selectScale(); void selectTrans(); void selectAnimeTransform(int); +#if TARGET_PC + bool pointerMove(); + bool consumePointerClick(); +#endif void setOffsetX(f32 i_offsetX) { mOffsetX = i_offsetX; } bool isAnimeUpdate(int param_0) { return (field_0x114 & (u8)(1 << param_0)) ? TRUE : FALSE; } diff --git a/include/dusk/action_bindings.h b/include/dusk/action_bindings.h index 7eba412fe8..a71dac5dfe 100644 --- a/include/dusk/action_bindings.h +++ b/include/dusk/action_bindings.h @@ -9,6 +9,8 @@ namespace dusk { enum class ActionBinds { FIRST_PERSON_CAMERA, CALL_MIDNA, + OPEN_MAP_SCREEN, + TOGGLE_MINIMAP, OPEN_DUSKLIGHT_MENU, TURBO_SPEED_BUTTON, COUNT, @@ -32,6 +34,12 @@ bool isActionBound(ActionBinds action, u32 port); void updateActionBindings(); +void setVirtualActionBind(ActionBinds action, u32 port, bool pressed, bool available = true); + +void clearVirtualActionBind(ActionBinds action, u32 port); + +void clearAllVirtualActionBinds(); + bool getActionBindTrig(ActionBinds action, u32 port); bool getActionBindHold(ActionBinds action, u32 port); diff --git a/include/dusk/config_var.hpp b/include/dusk/config_var.hpp index 0bae27bfd3..258bf4143c 100644 --- a/include/dusk/config_var.hpp +++ b/include/dusk/config_var.hpp @@ -4,6 +4,7 @@ #include "dolphin/types.h" #include #include +#include #include /** @@ -139,11 +140,16 @@ concept ConfigValueInteger = || std::is_same_v || std::is_same_v; +template +struct ConfigValueTraits { + static constexpr bool enabled = false; +}; + /** * \brief Concept that defines the legal set of types that can be used for CVar values. * * Valid types cannot be cv-qualified and must be basic primitive types (int, float, bool), - * strings, or enums of the basic primitives. + * strings, enums of the basic primitives, or explicitly-enabled structured settings. */ template concept ConfigValue = @@ -154,7 +160,8 @@ concept ConfigValue = || std::is_same_v || std::is_same_v || std::is_same_v - || (std::is_enum_v && ConfigValueInteger>)); + || (std::is_enum_v && ConfigValueInteger>) + || ConfigValueTraits::enabled); template const ConfigImplBase* GetConfigImpl(); diff --git a/include/dusk/menu_pointer.h b/include/dusk/menu_pointer.h new file mode 100644 index 0000000000..17546877d6 --- /dev/null +++ b/include/dusk/menu_pointer.h @@ -0,0 +1,60 @@ +#pragma once + +#include "dolphin/types.h" + +class CPaneMgr; + +namespace dusk::menu_pointer { + +enum class Context { + None, + FileSelect, + Save, + ItemWheel, + Collection, + Options, + Dialog, +}; + +enum class Phase { + Move, + Press, + Release, + Cancel, +}; + +struct State { + f32 x = 0.0f; + f32 y = 0.0f; + bool valid = false; + bool down = false; + bool pressed = false; + bool released = false; + bool clicked = false; + bool touch = false; +}; + +void begin_game_frame() noexcept; +void end_game_frame() noexcept; +void begin_context(Context context) noexcept; +bool handle_fallthrough_pointer(f32 x, f32 y, Phase phase, bool touch, s32 mouseButton = -1) noexcept; + +bool active() noexcept; +bool enabled() noexcept; +bool mouse_capture_active() noexcept; +const State& state() noexcept; +bool consume_click() noexcept; +void set_dialog_choice(u8 choice, bool clicked) noexcept; +bool get_dialog_choice(u8& choice) noexcept; +bool consume_dialog_click(u8& choice) noexcept; +void defer_activation(Context context, u8 target) noexcept; +bool consume_deferred_activation(Context context, u8 target) noexcept; +void clear_deferred_activation(Context context) noexcept; +u32 suppressed_pad_buttons(u32 port) noexcept; +void finish_pad_suppression_read(u32 port) noexcept; + +bool hit_rect(f32 left, f32 top, f32 right, f32 bottom, f32 padding = 0.0f) noexcept; +bool hit_pane(CPaneMgr* pane, f32 padding = 0.0f) noexcept; +bool hit_pane(J2DPane* pane, f32 padding = 0.0f) noexcept; + +} // namespace dusk::menu_pointer diff --git a/include/dusk/settings.h b/include/dusk/settings.h index da8055b225..e4b28686ff 100644 --- a/include/dusk/settings.h +++ b/include/dusk/settings.h @@ -1,9 +1,9 @@ -#ifndef DUSK_CONFIG_H -#define DUSK_CONFIG_H +#pragma once #include #include "dusk/config_var.hpp" +#include "dusk/ui/controls.hpp" namespace dusk { @@ -40,11 +40,6 @@ enum class DiscVerificationState : u8 { HashMismatch, }; -enum class GyroMode : u8 { - Sensor = 0, - Mouse = 1, -}; - enum class FrameInterpMode : u8 { Off = 0, Capped = 1, @@ -96,12 +91,6 @@ struct ConfigEnumRange { static constexpr auto max = DiscVerificationState::HashMismatch; }; -template <> -struct ConfigEnumRange { - static constexpr auto min = GyroMode::Sensor; - static constexpr auto max = GyroMode::Mouse; -}; - template <> struct ConfigEnumRange { static constexpr auto min = FrameInterpMode::Off; @@ -119,6 +108,11 @@ struct ConfigEnumRange { static constexpr auto min = MagicArmorMode::NORMAL; static constexpr auto max = MagicArmorMode::COSMETIC; }; + +template <> +struct ConfigValueTraits { + static constexpr bool enabled = true; +}; } // namespace config // Persistent user settings @@ -221,6 +215,9 @@ struct UserSettings { ConfigVar mouseCameraSensitivity; ConfigVar invertMouseY; ConfigVar freeCamera; + ConfigVar enableTouchControls; + ConfigVar enableMenuPointer; + ConfigVar touchControlsLayout; ConfigVar invertCameraXAxis; ConfigVar invertCameraYAxis; ConfigVar invertFirstPersonXAxis; @@ -229,6 +226,8 @@ struct UserSettings { ConfigVar invertAirSwimY; ConfigVar freeCameraXSensitivity; ConfigVar freeCameraYSensitivity; + ConfigVar touchCameraXSensitivity; + ConfigVar touchCameraYSensitivity; ConfigVar debugFlyCam; ConfigVar debugFlyCamLockEvents; ConfigVar allowBackgroundInput; @@ -287,6 +286,8 @@ struct UserSettings { struct { std::array firstPersonCamera; std::array callMidna; + std::array openMapScreen; + std::array toggleMinimap; std::array openDusklightMenu; std::array turboSpeedButton; } actionBindings; @@ -318,6 +319,4 @@ struct TransientSettings { TransientSettings& getTransientSettings(); -} - -#endif // DUSK_CONFIG_H +} // namespace dusk diff --git a/include/dusk/touch_camera.h b/include/dusk/touch_camera.h new file mode 100644 index 0000000000..d1680afed1 --- /dev/null +++ b/include/dusk/touch_camera.h @@ -0,0 +1,12 @@ +#pragma once + +namespace dusk::touch_camera { + +constexpr float YAW_DEGREES_PER_DP = 0.34f; +constexpr float PITCH_DEGREES_PER_DP = 0.22f; + +void add_delta(float yaw_dp, float pitch_dp) noexcept; +bool consume_delta(float& yaw_dp, float& pitch_dp) noexcept; +void clear() noexcept; + +} // namespace dusk::touch_camera diff --git a/libs/JSystem/include/JSystem/J2DGraph/J2DPicture.h b/libs/JSystem/include/JSystem/J2DGraph/J2DPicture.h index b054c97fae..f738a96dd4 100644 --- a/libs/JSystem/include/JSystem/J2DGraph/J2DPicture.h +++ b/libs/JSystem/include/JSystem/J2DGraph/J2DPicture.h @@ -212,6 +212,9 @@ public: void setCornerColor(JUtility::TColor c0) { setCornerColor(c0, c0, c0, c0); } +#if TARGET_PC + JUtility::TColor corner(size_t index) const { return mCornerColor[index]; } +#endif protected: /* 0x100 */ JUTTexture* mTexture[2]; diff --git a/res/rml/overlay.rcss b/res/rml/overlay.rcss index 8927e1a01c..9a86843295 100644 --- a/res/rml/overlay.rcss +++ b/res/rml/overlay.rcss @@ -16,7 +16,7 @@ body { flex-direction: column; justify-content: flex-end; align-items: stretch; - z-index: 1; + z-index: 2; pointer-events: none; } diff --git a/res/rml/touch_controls.rcss b/res/rml/touch_controls.rcss new file mode 100644 index 0000000000..5bf42136ad --- /dev/null +++ b/res/rml/touch_controls.rcss @@ -0,0 +1,340 @@ +*, *:before, *:after { + box-sizing: border-box; +} + +body { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; + font-family: "Fira Sans Condensed"; + font-weight: bold; + color: rgba(248, 244, 232, 90%); + z-index: 1; + filter: opacity(0); + transition: filter 0.2s linear-in-out; +} + +body[open] { + filter: opacity(1); +} + +body:not([open]) { + pointer-events: none; +} + +button { + display: flex; + align-items: center; + justify-content: center; + decorator: none; + padding: 0; + border: 1dp rgba(255, 255, 255, 22%); + background-color: rgba(22, 24, 28, 48%); + color: rgba(248, 244, 232, 90%); + text-align: center; + /* backdrop-filter: blur(7dp); */ + /* box-shadow: 0 6dp 18dp rgba(0, 0, 0, 28%); */ + transform-origin: center; + transition: background-color border-color filter transform 0.08s linear-in-out, + opacity 0.2s linear-in-out; +} + +button.pressed, +button.active { + background-color: rgba(63, 78, 90, 68%); + border-color: rgba(255, 255, 255, 48%); + filter: brightness(1.18); +} + +button:hidden { + opacity: 0; + pointer-events: none; +} + +button span { + display: block; + line-height: 1; +} + +button icon { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} + +button icon glyph { + display: block; + font-family: "Material Symbols Rounded"; + font-weight: normal; + font-size: 24dp; + line-height: 1; +} + +.midna-icon, +.item-icon, +.item-count, +.oil-meter { + display: none; +} + +.midna-icon.visible, +.item-icon.visible, +.item-count.visible, +.oil-meter.visible { + display: block; +} + +.control { + position: absolute; +} + +.trigger-l.active { + background-color: rgba(57, 116, 133, 74%); + border-color: rgba(128, 222, 234, 72%); +} + +.trigger, +.skip { + border-radius: 23dp; +} + +.trigger { + font-size: 22dp; +} + +.button-z { + background-color: rgba(118, 79, 158, 58%); + border-color: rgba(203, 170, 255, 36%); +} + +.midna-icon { + position: absolute; + left: 9dp; + top: -1dp; + height: 48dp; +} + +.button-z.has-icon span, +.face.has-item span { + position: absolute; + font-size: 13dp; + line-height: 1; +} + +.button-z.has-icon span { + right: 9dp; + bottom: 7dp; +} + +.button-z.pressed { + background-color: rgba(139, 91, 187, 82%); + border-color: rgba(220, 194, 255, 70%); +} + +action-bar { + position: absolute; + display: flex; + align-items: center; + justify-content: stretch; + border: 1dp rgba(255, 255, 255, 22%); + border-radius: 23dp; + background-color: rgba(22, 24, 28, 48%); + /* backdrop-filter: blur(7dp); */ + /* box-shadow: 0 -6dp 18dp rgba(0, 0, 0, 28%); */ + overflow: hidden; + opacity: 1; + transform-origin: center; + transition: opacity 0.2s linear-in-out; +} + +action-bar:hidden, +action-bar:hidden button, +action-bar:hidden separator { + opacity: 0; + pointer-events: none; +} + +.utility { + position: relative; + flex: 1 1 auto; + width: 56dp; + height: 44dp; + margin: 0; + border-width: 0dp; + border-radius: 0; + background-color: transparent; + box-shadow: none; +} + +.utility, +.skip { + opacity: 0.55; +} + +.utility.pressed { + background-color: rgba(63, 78, 90, 68%); +} + +.utility.pressed, +.skip.pressed { + opacity: 1; +} + +.skip { + z-index: 1; + border-color: rgba(255, 255, 255, 36%); +} + +separator { + display: block; + flex: 0 0 1dp; + width: 1dp; + height: 24dp; + background-color: rgba(255, 255, 255, 18%); + opacity: 1; + transition: opacity 0.2s linear-in-out; +} + +.face { + position: absolute; + border-radius: 29dp; + font-size: 24dp; + overflow: visible; +} + +.item-icon { + width: auto; + height: auto; + max-width: 76%; + max-height: 76%; +} + +.item-count { + position: absolute; + left: 6dp; + bottom: 5dp; + min-width: 17dp; + height: 15dp; + padding: 1dp 3dp; + border-radius: 7dp; + background-color: rgba(0, 0, 0, 52%); + color: rgba(255, 255, 255, 92%); + font-size: 12dp; + line-height: 13dp; + text-align: center; +} + +.oil-meter { + position: absolute; + left: 12dp; + bottom: -5dp; + width: 34dp; + height: 8dp; + padding: 2dp; + border: 1dp rgba(42, 32, 18, 82%); + border-radius: 4dp; + background-color: rgba(18, 14, 10, 70%); + /* box-shadow: 0 2dp 6dp rgba(0, 0, 0, 35%); */ +} + +oil-fill { + display: block; + width: 0%; + height: 100%; + border-radius: 2dp; + background-color: rgb(255, 232, 74); +} + +.face.has-item span { + right: 6dp; + bottom: 6dp; + color: rgba(255, 255, 255, 88%); +} + +.face.a { + border-radius: 37dp; + font-size: 31dp; + background-color: rgba(34, 112, 123, 62%); +} + +.face.b { + background-color: rgba(161, 61, 66, 58%); +} + +.face.x { + background-color: rgba(83, 115, 151, 56%); +} + +.face.y { + background-color: rgba(113, 91, 150, 54%); +} + +button.control.docked-top, +action-bar.docked-top { + border-top-width: 0dp; + border-top-left-radius: 0dp; + border-top-right-radius: 0dp; +} + +button.control.docked-bottom, +action-bar.docked-bottom { + border-bottom-width: 0dp; + border-bottom-left-radius: 0dp; + border-bottom-right-radius: 0dp; +} + +button.control.docked-left, +action-bar.docked-left { + border-left-width: 0dp; + border-top-left-radius: 0dp; + border-bottom-left-radius: 0dp; +} + +button.control.docked-right, +action-bar.docked-right { + border-right-width: 0dp; + border-top-right-radius: 0dp; + border-bottom-right-radius: 0dp; +} + +touch-stick { + display: block; + position: absolute; + width: 124dp; + height: 124dp; + border-radius: 62dp; + background-color: rgba(18, 20, 24, 35%); + border: 1dp rgba(255, 255, 255, 20%); + /* backdrop-filter: blur(7dp); */ + /* box-shadow: 0 8dp 24dp rgba(0, 0, 0, 24%); */ + opacity: 0; + pointer-events: none; + transition: opacity 0.18s linear-in-out; +} + +touch-stick.active { + opacity: 1; +} + +stick-ring { + position: absolute; + left: 18dp; + top: 18dp; + width: 88dp; + height: 88dp; + border-radius: 44dp; + border: 1dp rgba(255, 255, 255, 18%); +} + +stick-knob { + position: absolute; + width: 48dp; + height: 48dp; + border-radius: 24dp; + background-color: rgba(238, 236, 226, 55%); + border: 1dp rgba(255, 255, 255, 45%); +} diff --git a/res/rml/touch_controls_editor.rcss b/res/rml/touch_controls_editor.rcss new file mode 100644 index 0000000000..2ca99d935a --- /dev/null +++ b/res/rml/touch_controls_editor.rcss @@ -0,0 +1,138 @@ +body.touch-editor { + background-color: rgba(4, 6, 8, 34%); + z-index: 8; +} + +body.touch-editor .control, +body.touch-editor action-bar { + opacity: 0.88; + cursor: move; + pointer-events: auto; +} + +body.touch-editor .control:hover, +body.touch-editor action-bar:hover, +body.touch-editor .control.editor-selected, +body.touch-editor action-bar.editor-selected { + border-color: rgba(255, 232, 128, 80%); + filter: brightness(1.15); +} + +body.touch-editor action-bar button, +body.touch-editor action-bar separator { + pointer-events: none; +} + +selection-frame { + display: none; + position: absolute; + z-index: 20; + border: 2dp rgba(255, 232, 128, 88%); + background-color: rgba(255, 232, 128, 7%); + pointer-events: none; +} + +selection-frame.visible { + display: block; +} + +resize-handle { + display: block; + position: absolute; + width: 22dp; + height: 22dp; + border: 2dp rgba(255, 244, 190, 96%); + border-radius: 11dp; + background-color: rgba(34, 37, 42, 86%); + pointer-events: auto; +} + +resize-handle.left { + left: -12dp; +} + +resize-handle.right { + right: -12dp; +} + +resize-handle.top { + top: -12dp; +} + +resize-handle.bottom { + bottom: -12dp; +} + +resize-handle.horizontal { + top: 50%; + margin-top: -11dp; +} + +resize-handle.vertical { + left: 50%; + margin-left: -11dp; +} + +resize-handle.corner.left { + left: -12dp; +} + +resize-handle.corner.right { + right: -12dp; +} + +resize-handle.corner.top { + top: -12dp; +} + +resize-handle.corner.bottom { + bottom: -12dp; +} + +editor-toolbar { + display: flex; + position: absolute; + left: 24dp; + right: 24dp; + top: 50%; + z-index: 30; + height: 48dp; + margin-top: -24dp; + gap: 8dp; + justify-content: center; + pointer-events: auto; +} + +editor-toolbar button.editor-command { + flex: 0 1 150dp; + min-width: 96dp; + height: 48dp; + padding: 0 14dp; + border-radius: 8dp; + border: 1dp rgba(255, 255, 255, 26%); + background-color: rgba(17, 19, 24, 88%); + color: rgba(255, 250, 232, 94%); + font-family: "Fira Sans"; + font-size: 18dp; + line-height: 48dp; + opacity: 1; + cursor: pointer; +} + +editor-toolbar button.editor-command span { + display: block; + width: 100%; + line-height: 48dp; + text-align: center; +} + +editor-toolbar button.editor-command.primary { + border-color: rgba(255, 232, 128, 70%); + background-color: rgba(96, 82, 38, 90%); +} + +editor-toolbar button.editor-command:hover, +editor-toolbar button.editor-command:focus-visible { + border-color: rgba(255, 244, 190, 92%); + background-color: rgba(78, 85, 96, 92%); +} diff --git a/src/d/actor/d_a_alink_link.inc b/src/d/actor/d_a_alink_link.inc index 4d93f39457..0cf53edf3e 100644 --- a/src/d/actor/d_a_alink_link.inc +++ b/src/d/actor/d_a_alink_link.inc @@ -14,6 +14,7 @@ #include "dusk/action_bindings.h" #include "dusk/gyro.h" #include "dusk/mouse.h" +#include "dusk/touch_camera.h" #endif bool daAlink_c::checkNoSubjectModeCamera() { @@ -172,6 +173,32 @@ BOOL daAlink_c::setBodyAngleToCamera() { sp8 = mBodyAngle.x; } } + + if (dusk::getSettings().game.enableTouchControls && checkAimContext()) { + f32 touchYawDp = 0.0f; + f32 touchPitchDp = 0.0f; + if (dusk::touch_camera::consume_delta(touchYawDp, touchPitchDp)) { + f32 scale = 1.0f; + if (checkWolfEyeUp()) { + scale *= 0.6f; + } + if (dComIfGp_checkPlayerStatus0(0, 0x200000)) { + scale /= dComIfGp_getCameraZoomScale(field_0x317c); + } + + const f32 yawDeg = -touchYawDp * dusk::touch_camera::YAW_DEGREES_PER_DP * scale * + dusk::getSettings().game.touchCameraXSensitivity; + const f32 pitchDeg = touchPitchDp * dusk::touch_camera::PITCH_DEGREES_PER_DP * + scale * dusk::getSettings().game.touchCameraYSensitivity; + + shape_angle.y = shape_angle.y + cM_deg2s(yawDeg); + sp8 = sp8 + cM_deg2s(pitchDeg); + + if (checkNotItemSinkLimit() && sp8 > 0 && sp8 > mBodyAngle.x) { + sp8 = mBodyAngle.x; + } + } + } #endif if (checkNotItemSinkLimit() && sp8 > 0) { diff --git a/src/d/d_camera.cpp b/src/d/d_camera.cpp index 50e9f073b6..483e052312 100644 --- a/src/d/d_camera.cpp +++ b/src/d/d_camera.cpp @@ -34,6 +34,7 @@ #include "dusk/action_bindings.h" #include "dusk/mouse.h" #include "dusk/settings.h" +#include "dusk/touch_camera.h" #include "imgui.h" #endif @@ -7499,6 +7500,15 @@ static constexpr s16 FLYCAM_ROLL_SPEED = 256; static ImVec2 sFlyCamLastMousePos = {-1.f, -1.f}; #if TARGET_PC +static constexpr f32 TOUCH_CAMERA_CSTICK_EXIT_THRESHOLD = 0.05f; +static bool sTouchFreeCameraActive = false; + +bool dCamera_c::isAimActive() { + auto* link = daAlink_getAlinkActorClass(); + return link != nullptr && link->checkAimContext() && + dComIfGp_checkCameraAttentionStatus(link->field_0x317c, 0x10); +} + bool dCamera_c::executeDebugFlyCam() { if (!dusk::getSettings().game.debugFlyCam) { if (mDebugFlyCam.initialized) { @@ -7640,16 +7650,30 @@ void dCamera_c::deactivateDebugFlyCam() { mDebugFlyCam.initialized = false; } -bool dCamera_c::canUseFreeCam() { - return dusk::getSettings().game.freeCamera || dusk::getSettings().game.enableMouseCamera; -} - bool dCamera_c::freeCamera() { - if (canUseFreeCam() && mGear == 1) { + f32 touchYawDp = 0.0f; + f32 touchPitchDp = 0.0f; + bool touchCameraMoved = false; + const bool touchControlsEnabled = dusk::getSettings().game.enableTouchControls; + if (touchControlsEnabled && !isAimActive()) { + touchCameraMoved = dusk::touch_camera::consume_delta(touchYawDp, touchPitchDp); + } + if (!touchControlsEnabled || + mPadInfo.mCStick.mLastValue > TOUCH_CAMERA_CSTICK_EXIT_THRESHOLD) + { + sTouchFreeCameraActive = false; + } + if (touchCameraMoved) { + sTouchFreeCameraActive = true; + } + + const bool useFreeCamera = dusk::getSettings().game.freeCamera || + dusk::getSettings().game.enableMouseCamera || sTouchFreeCameraActive; + if (useFreeCamera && mGear == 1) { mGear = 0; } - if (!canUseFreeCam() || mCamStyle == 70) + if (!useFreeCamera || mCamStyle == 70) { mCamParam.mManualMode = 0; return false; @@ -7660,6 +7684,17 @@ bool dCamera_c::freeCamera() { mCamParam.freeYAngle = mViewCache.mDirection.mInclination.Degree(); } + if (touchCameraMoved) { + mCamParam.mManualMode = 1; + const f32 yawInput = dusk::getSettings().game.invertCameraXAxis ? -touchYawDp : touchYawDp; + const f32 pitchInput = + touchPitchDp * (dusk::getSettings().game.invertCameraYAxis ? -1.0f : 1.0f); + mCamParam.freeXAngle += yawInput * dusk::getSettings().game.touchCameraXSensitivity * + dusk::touch_camera::YAW_DEGREES_PER_DP; + mCamParam.freeYAngle += pitchInput * dusk::getSettings().game.touchCameraYSensitivity * + dusk::touch_camera::PITCH_DEGREES_PER_DP; + } + cXyz camMovement = {mPadInfo.mCStick.mLastPosX, mPadInfo.mCStick.mLastPosY, 0.0f}; f32 magnitude = sqrt(mPadInfo.mCStick.mLastPosX * mPadInfo.mCStick.mLastPosX + mPadInfo.mCStick.mLastPosY * mPadInfo.mCStick.mLastPosY); @@ -11359,7 +11394,7 @@ static int camera_execute(camera_process_class* i_this) { const auto target = get_target_trim_height(i_this); const auto step = dusk::frame_interp::get_interpolation_step(); const auto cur = camera->TrimHeight(); - const auto prev = (4.0f * cur - target) / 3.0f; + const auto prev = (4.0f * cur - target) / 3.0f; const auto trim_height = prev + (cur - prev) * step; widezoom_correction(i_this, trim_height); diff --git a/src/d/d_file_select.cpp b/src/d/d_file_select.cpp index 27b18c202a..6e5e2e4954 100644 --- a/src/d/d_file_select.cpp +++ b/src/d/d_file_select.cpp @@ -23,8 +23,22 @@ #include "m_Do/m_Do_graphic.h" #include +#if TARGET_PC +#include "dusk/menu_pointer.h" #include "dusk/string.hpp" +namespace { +constexpr u8 pointer_target(u8 group, u8 index) noexcept { + return static_cast((group << 4) | (index & 0x0F)); +} + +constexpr u8 s_pointerDataSelectTarget = 0; +constexpr u8 s_pointerMenuSelectTarget = 1; +constexpr u8 s_pointerCopySelectTarget = 2; +constexpr u8 s_pointerYesNoSelectTarget = 3; +} // namespace +#endif + static s32 SelStartFrameTbl[3] = { 59, 99, @@ -756,8 +770,143 @@ void dFile_select_c::dataSelectInit() { } } +#if TARGET_PC +bool dFile_select_c::pointerDataSelect() { + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::FileSelect); + for (u8 i = 0; i < 3; ++i) { + if (!dusk::menu_pointer::hit_pane(mSelFilePanes[i], 8.0f)) { + continue; + } + const bool clicked = dusk::menu_pointer::consume_click(); + if (mSelectNum != i) { + mDoAud_seStart(Z2SE_FILE_SELECT_CURSOR, NULL, 0, 0); + mLastSelectNum = mSelectNum; + mSelectNum = i; + if (clicked) { + dusk::menu_pointer::defer_activation( + dusk::menu_pointer::Context::FileSelect, + pointer_target(s_pointerDataSelectTarget, i)); + } + dataSelectAnmSet(); + mDataSelProc = DATASELPROC_DATA_SELECT_MOVE_ANIME; + return true; + } + if (clicked) { + dataSelectStart(); + return true; + } + } + return false; +} + +bool dFile_select_c::pointerMenuSelect() { + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::FileSelect); + for (u8 i = 0; i < 3; ++i) { + if (!dusk::menu_pointer::hit_pane(m3mSelPane[i], 8.0f)) { + continue; + } + const bool clicked = dusk::menu_pointer::consume_click(); + if (!mIsDataNew[mSelectNum] && mSelectMenuNum != i) { + mDoAud_seStart(Z2SE_SY_MENU_CURSOR_COMMON, NULL, 0, 0); + mLastSelectMenuNum = mSelectMenuNum; + mSelectMenuNum = i; + if (clicked) { + dusk::menu_pointer::defer_activation( + dusk::menu_pointer::Context::FileSelect, + pointer_target(s_pointerMenuSelectTarget, i)); + } + menuSelectAnmSet(); + mDataSelProc = DATASELPROC_MENU_SELECT_MOVE_ANM; + return true; + } + if (clicked) { + menuSelectStart(); + return true; + } + } + return false; +} + +bool dFile_select_c::pointerCopyDataToSelect() { + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::FileSelect); + for (u8 i = 0; i < 2; ++i) { + if (!dusk::menu_pointer::hit_pane(mCpSelPane[i], 8.0f)) { + continue; + } + const bool clicked = dusk::menu_pointer::consume_click(); + if (field_0x026b != i) { + mDoAud_seStart(Z2SE_FILE_SELECT_CURSOR, NULL, 0, 0); + field_0x026c = field_0x026b; + field_0x026b = i; + if (clicked) { + dusk::menu_pointer::defer_activation( + dusk::menu_pointer::Context::FileSelect, + pointer_target(s_pointerCopySelectTarget, i)); + } + copyDataToSelectMoveAnmSet(); + mDataSelProc = DATASELPROC_COPY_DATA_TO_SELECT_MOVE_ANM; + return true; + } + if (clicked) { + copyDataToSelectStart(); + return true; + } + } + return false; +} + +bool dFile_select_c::pointerYesNoSelect(bool errorSelect) { + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::FileSelect); + for (u8 i = 0; i < 2; ++i) { + if (!dusk::menu_pointer::hit_pane(mYnSelPane[i], 8.0f)) { + continue; + } + const bool clicked = + (!errorSelect || field_0x0268 == i) && dusk::menu_pointer::consume_click(); + if (field_0x0268 != i) { + field_0x0269 = field_0x0268; + field_0x0268 = i; + if (errorSelect) { + errCurMove(0); + return false; + } else { + mDoAud_seStart(Z2SE_SY_MENU_CURSOR_COMMON, NULL, 0, 0); + if (clicked) { + dusk::menu_pointer::defer_activation( + dusk::menu_pointer::Context::FileSelect, + pointer_target(s_pointerYesNoSelectTarget, i)); + } + yesnoSelectAnmSet(); + mDataSelProc = DATASELPROC_YES_NO_CURSOR_MOVE_ANM; + return true; + } + } + if (clicked) { + if (errorSelect) { + if (field_0x0268 != 0) { + mDoAud_seStart(Z2SE_SY_CURSOR_OK, 0, 0, 0); + } else { + mDoAud_seStart(Z2SE_SY_CURSOR_CANCEL, 0, 0, 0); + } + mSelIcon->setAlphaRate(0.0f); + } else { + yesNoSelectStart(); + } + return true; + } + } + return false; +} +#endif + // handles switching between quest logs void dFile_select_c::dataSelect() { +#if TARGET_PC + if (pointerDataSelect()) { + return; + } +#endif + stick->checkTrigger(); // If A or Start was pressed @@ -801,6 +950,9 @@ static u16 msgTbl[3] = { }; void dFile_select_c::dataSelectStart() { +#if TARGET_PC + dusk::menu_pointer::clear_deferred_activation(dusk::menu_pointer::Context::FileSelect); +#endif mSelIcon->setAlphaRate(0.0f); if (mIsNoData[mSelectNum]) { @@ -949,6 +1101,16 @@ void dFile_select_c::dataSelectAnmSet() { } void dFile_select_c::dataSelectMoveAnime() { +#if TARGET_PC + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::FileSelect); + if (mSelectNum != 0xFF && dusk::menu_pointer::hit_pane(mSelFilePanes[mSelectNum], 8.0f) && + dusk::menu_pointer::consume_click()) + { + dusk::menu_pointer::defer_activation( + dusk::menu_pointer::Context::FileSelect, + pointer_target(s_pointerDataSelectTarget, mSelectNum)); + } +#endif bool iVar7 = true; bool iVar6 = true; bool bVar1 = true; @@ -997,6 +1159,14 @@ void dFile_select_c::dataSelectMoveAnime() { mSelFilePanes[mLastSelectNum]->getPanePtr()->setAnimation((J2DAnmTransform*)NULL); } +#if TARGET_PC + if (dusk::menu_pointer::consume_deferred_activation( + dusk::menu_pointer::Context::FileSelect, + pointer_target(s_pointerDataSelectTarget, mSelectNum))) { + dataSelectStart(); + return; + } +#endif mDataSelProc = DATASELPROC_DATA_SELECT; } } @@ -1161,6 +1331,12 @@ void dFile_select_c::selectDataOpenEraseMove() { // Handles selecting between copy / start / delete menus in quest log void dFile_select_c::menuSelect() { +#if TARGET_PC + if (pointerMenuSelect()) { + return; + } +#endif + stick->checkTrigger(); // if a was pressed, do the menu selection process @@ -1191,6 +1367,9 @@ void dFile_select_c::menuSelect() { // Handles copy / start / delete actions depending on which menu is selected from menuSelect void dFile_select_c::menuSelectStart() { +#if TARGET_PC + dusk::menu_pointer::clear_deferred_activation(dusk::menu_pointer::Context::FileSelect); +#endif #if TARGET_PC if (!dusk::getSettings().game.hideTvSettingsScreen || mSelectMenuNum != 1) { mDoAud_seStart(Z2SE_SY_CURSOR_OK, NULL, 0, 0); @@ -1312,6 +1491,17 @@ void dFile_select_c::menuSelectAnmSet() { } void dFile_select_c::menuSelectMoveAnm() { +#if TARGET_PC + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::FileSelect); + if (mSelectMenuNum != 0xFF && + dusk::menu_pointer::hit_pane(m3mSelPane[mSelectMenuNum], 8.0f) && + dusk::menu_pointer::consume_click()) + { + dusk::menu_pointer::defer_activation( + dusk::menu_pointer::Context::FileSelect, + pointer_target(s_pointerMenuSelectTarget, mSelectMenuNum)); + } +#endif bool tmp1 = true; if (mSelectMenuNum != 0xFF && @@ -1369,6 +1559,14 @@ void dFile_select_c::menuSelectMoveAnm() { m3mSelPane[mLastSelectMenuNum]->getPanePtr()->setAnimation((J2DAnmTransform*)NULL); } +#if TARGET_PC + if (dusk::menu_pointer::consume_deferred_activation( + dusk::menu_pointer::Context::FileSelect, + pointer_target(s_pointerMenuSelectTarget, mSelectMenuNum))) { + menuSelectStart(); + return; + } +#endif mDataSelProc = DATASELPROC_MENU_SELECT; } } @@ -1698,6 +1896,12 @@ void dFile_select_c::setSaveDataForCopySel() { } void dFile_select_c::copyDataToSelect() { +#if TARGET_PC + if (pointerCopyDataToSelect()) { + return; + } +#endif + stick->checkTrigger(); if (mDoCPd_c::getTrigA(PAD_1)) { @@ -1722,6 +1926,9 @@ void dFile_select_c::copyDataToSelect() { } void dFile_select_c::copyDataToSelectStart() { +#if TARGET_PC + dusk::menu_pointer::clear_deferred_activation(dusk::menu_pointer::Context::FileSelect); +#endif mDoAud_seStart(Z2SE_SY_CURSOR_OK, NULL, 0, 0); mCpDataToNum = getCptoNum(field_0x026b); @@ -1787,6 +1994,17 @@ void dFile_select_c::copyDataToSelectMoveAnmSet() { } void dFile_select_c::copyDataToSelectMoveAnm() { +#if TARGET_PC + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::FileSelect); + if (field_0x026b != 0xFF && + dusk::menu_pointer::hit_pane(mCpSelPane[field_0x026b], 8.0f) && + dusk::menu_pointer::consume_click()) + { + dusk::menu_pointer::defer_activation( + dusk::menu_pointer::Context::FileSelect, + pointer_target(s_pointerCopySelectTarget, field_0x026b)); + } +#endif bool iVar7 = true; bool iVar6 = true; bool bVar1 = true; @@ -1836,6 +2054,14 @@ void dFile_select_c::copyDataToSelectMoveAnm() { mSelIcon2->setAlphaRate(1.0f); } +#if TARGET_PC + if (dusk::menu_pointer::consume_deferred_activation( + dusk::menu_pointer::Context::FileSelect, + pointer_target(s_pointerCopySelectTarget, field_0x026b))) { + copyDataToSelectStart(); + return; + } +#endif mDataSelProc = DATASELPROC_COPY_DATA_TO_SELECT; } } @@ -2105,6 +2331,12 @@ void dFile_select_c::yesnoCursorShow() { } void dFile_select_c::YesNoSelect() { +#if TARGET_PC + if (pointerYesNoSelect(false)) { + return; + } +#endif + stick->checkTrigger(); if (mDoCPd_c::getTrigA(PAD_1)) { @@ -2129,6 +2361,9 @@ void dFile_select_c::YesNoSelect() { } void dFile_select_c::yesNoSelectStart() { +#if TARGET_PC + dusk::menu_pointer::clear_deferred_activation(dusk::menu_pointer::Context::FileSelect); +#endif if (field_0x0268 != 0) { mDoAud_seStart(Z2SE_SY_CURSOR_OK, NULL, 0, 0); field_0x03b1 = 1; @@ -2284,10 +2519,29 @@ void dFile_select_c::YesNoCancelMove() { } void dFile_select_c::yesNoCursorMoveAnm() { +#if TARGET_PC + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::FileSelect); + if (field_0x0268 != 0xFF && + dusk::menu_pointer::hit_pane(mYnSelPane[field_0x0268], 8.0f) && + dusk::menu_pointer::consume_click()) + { + dusk::menu_pointer::defer_activation( + dusk::menu_pointer::Context::FileSelect, + pointer_target(s_pointerYesNoSelectTarget, field_0x0268)); + } +#endif bool isYnSelMove = yesnoSelectMoveAnm(); bool isYnWakuAlpha = yesnoWakuAlpahAnm(field_0x0269); if (isYnSelMove == true && isYnWakuAlpha == true) { yesnoCursorShow(); +#if TARGET_PC + if (dusk::menu_pointer::consume_deferred_activation( + dusk::menu_pointer::Context::FileSelect, + pointer_target(s_pointerYesNoSelectTarget, field_0x0268))) { + yesNoSelectStart(); + return; + } +#endif mDataSelProc = DATASELPROC_YES_NO_SELECT; } } @@ -5238,6 +5492,12 @@ void dFile_select_c::MemCardMsgWindowClose() { bool dFile_select_c::errYesNoSelect() { bool rv = false; +#if TARGET_PC + if (pointerYesNoSelect(true)) { + return true; + } +#endif + stick->checkTrigger(); if (mDoCPd_c::getTrigA(PAD_1)) { diff --git a/src/d/d_menu_collect.cpp b/src/d/d_menu_collect.cpp index 7ac70e4a7b..3aea57e1d3 100644 --- a/src/d/d_menu_collect.cpp +++ b/src/d/d_menu_collect.cpp @@ -36,6 +36,10 @@ #include "d/d_menu_window.h" #include "JSystem/J3DGraphBase/J3DMaterial.h" +#if TARGET_PC +#include "dusk/menu_pointer.h" +#endif + typedef void (dMenu_Collect2D_c::*initFunc)(); static DUSK_CONSTEXPR initFunc init[] = { &dMenu_Collect2D_c::wait_init, &dMenu_Collect2D_c::save_open_init, @@ -1786,6 +1790,12 @@ void dMenu_Collect2D_c::wait_init() { } void dMenu_Collect2D_c::wait_proc() { +#if TARGET_PC + if (pointerWait()) { + return; + } +#endif + if (dMw_A_TRIGGER()) { if (mCursorX == 0 && mCursorY == 5) { if (mDoGph_gInf_c::getFader()->mStatus == 1) { @@ -1887,6 +1897,87 @@ void dMenu_Collect2D_c::wait_proc() { } } +#if TARGET_PC +void dMenu_Collect2D_c::pointerActivateCurrent() { + if (mCursorX == 0 && mCursorY == 5) { + if (mDoGph_gInf_c::getFader()->mStatus == 1) { + mSubWindowOpenCheck = 1; + Z2GetAudioMgr()->seStart(Z2SE_SY_MENU_CHANGE_WINDOW, NULL, 0, 0, 1.0f, 1.0f, -1.0f, + -1.0f, 0); + dMeter2Info_set2DVibrationM(); + } + } else if (mCursorX == 1 && mCursorY == 5) { + if (mDoGph_gInf_c::getFader()->mStatus == 1) { + mSubWindowOpenCheck = 2; + Z2GetAudioMgr()->seStart(Z2SE_SY_MENU_CHANGE_WINDOW, NULL, 0, 0, 1.0f, 1.0f, -1.0f, + -1.0f, 0); + dMeter2Info_set2DVibrationM(); + } + } else if (mCursorX == 3 && mCursorY == 4) { + if (field_0x22d[3][4] != 0 && mDoGph_gInf_c::getFader()->mStatus == 1) { + mSubWindowOpenCheck = 3; + dMeter2Info_set2DVibration(); + } + } else if (mCursorX == 2 && mCursorY == 4) { + if (isFishIconVisible() && mDoGph_gInf_c::getFader()->mStatus == 1) { + mSubWindowOpenCheck = 4; + dMeter2Info_set2DVibration(); + } + } else if (mCursorX == 3 && mCursorY == 3) { + if (isSkillIconVisible() && mDoGph_gInf_c::getFader()->mStatus == 1) { + mSubWindowOpenCheck = 5; + dMeter2Info_set2DVibration(); + } + } else if (mCursorX == 2 && mCursorY == 3) { + if (isInsectIconVisible() && mDoGph_gInf_c::getFader()->mStatus == 1) { + mSubWindowOpenCheck = 6; + dMeter2Info_set2DVibration(); + } + } else if (field_0x22d[mCursorX][mCursorY] != 0 && !mIsWolf) { + if ((mCursorX >= 3 && mCursorX <= 4) || (mCursorX == 5 && mCursorY == 2)) { + u8 cursorY = mCursorY; + if (cursorY == 0) { + if (daPy_getPlayerActorClass()->getSwordChangeWaitTimer() == 0) { + changeSword(); + } + } else if (cursorY == 1) { + if (daPy_getPlayerActorClass()->getShieldChangeWaitTimer() == 0) { + changeShield(); + } + } else if (cursorY == 2 && + daPy_getPlayerActorClass()->getClothesChangeWaitTimer() == 0) + { + changeClothe(); + } + } + } +} + +bool dMenu_Collect2D_c::pointerWait() { + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::Collection); + for (u8 y = 0; y < 6; ++y) { + for (u8 x = 0; x < 7; ++x) { + if (getItemTag(x, y, true) == 0 || !dusk::menu_pointer::hit_pane(mpSelPm[x][y], 8.0f)) { + continue; + } + if (mCursorX != x || mCursorY != y) { + mDoAud_seStart(Z2SE_SY_MENU_CURSOR_COMMON, NULL, 0, 0); + mCursorX = x; + mCursorY = y; + cursorPosSet(); + setItemNameString(mCursorX, mCursorY); + } + if (dusk::menu_pointer::consume_click()) { + pointerActivateCurrent(); + return true; + } + return false; + } + } + return false; +} +#endif + void dMenu_Collect2D_c::save_open_init() { JKRHeap* heap = mDoExt_setCurrentHeap(mpSubHeap); diff --git a/src/d/d_menu_fmap2D.cpp b/src/d/d_menu_fmap2D.cpp index 960131a7a2..795e3a5f64 100644 --- a/src/d/d_menu_fmap2D.cpp +++ b/src/d/d_menu_fmap2D.cpp @@ -17,7 +17,10 @@ #include "d/d_msg_scrn_explain.h" #include "m_Do/m_Do_graphic.h" #include "d/actor/d_a_midna.h" +#if TARGET_PC #include "dusk/frame_interpolation.h" +#include "dusk/ui/touch_controls.hpp" +#endif #include #if TARGET_PC @@ -2509,6 +2512,10 @@ dMenu_Fmap2DTop_c::dMenu_Fmap2DTop_c(JKRExpHeap* i_heap, STControl* i_stick) { } dMenu_Fmap2DTop_c::~dMenu_Fmap2DTop_c() { +#if TARGET_PC + dusk::ui::set_control_override(dusk::ui::Control::Z, dusk::ui::ControlOverride::Default); +#endif + deleteExplain(); JKR_DELETE(mpTitleScreen); mpTitleScreen = NULL; @@ -2782,6 +2789,12 @@ void dMenu_Fmap2DTop_c::setZButtonString(u32 param_0, u8 i_alpha) { param_0 = 0x533; } +#if TARGET_PC + dusk::ui::set_control_override(dusk::ui::Control::Z, + param_0 != 0 && isWarpAccept() ? dusk::ui::ControlOverride::Action : + dusk::ui::ControlOverride::Default); +#endif + #if VERSION == VERSION_GCN_JPN static const u64 cont_zt[5] = {MULTI_CHAR('cont_zt'), MULTI_CHAR('cont_zt1'), MULTI_CHAR('cont_zt2'), MULTI_CHAR('cont_zt3'), MULTI_CHAR('cont_zt4')}; #define setZButtonString_font_zt cont_zt diff --git a/src/d/d_menu_insect.cpp b/src/d/d_menu_insect.cpp index 8e6ad49b5b..7967ca6cdf 100644 --- a/src/d/d_menu_insect.cpp +++ b/src/d/d_menu_insect.cpp @@ -22,6 +22,10 @@ #include #include +#if TARGET_PC +#include "dusk/menu_pointer.h" +#endif + typedef void (dMenu_Insect_c::*initFunc)(); static initFunc map_init_process[] = { &dMenu_Insect_c::wait_init, &dMenu_Insect_c::explain_open_init, @@ -280,6 +284,12 @@ void dMenu_Insect_c::wait_init() { void dMenu_Insect_c::wait_move() { if (mDoGph_gInf_c::getFader()->getStatus() == 1) { +#if TARGET_PC + if (pointerWait()) { + return; + } +#endif + if (mDoCPd_c::getTrigB(PAD_1) || field_0xf7 == 0) { if (mDoCPd_c::getTrigB(PAD_1) && field_0xf6 == 1) { dMeter2Info_setInsectSelectType(0); @@ -301,6 +311,39 @@ void dMenu_Insect_c::wait_move() { } } +#if TARGET_PC +bool dMenu_Insect_c::pointerWait() { + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::Collection); + for (u8 y = 0; y < 4; ++y) { + for (u8 x = 0; x < 6; ++x) { + const int index = x + y * 6; + if (!isGetInsect(x, y) || !dusk::menu_pointer::hit_pane(mpINSParent[index], 8.0f)) { + continue; + } + + if (field_0xf4 != x || field_0xf5 != y) { + field_0xf4 = x; + field_0xf5 = y; + setCursorPos(); + setAButtonString(0x368); + Z2GetAudioMgr()->seStart(Z2SE_SY_CURSOR_ITEM, NULL, 0, 0, 1.0f, 1.0f, -1.0f, + -1.0f, 0); + } + if (dusk::menu_pointer::consume_click()) { + field_0xf3 = 1; + Z2GetAudioMgr()->seStart(Z2SE_SY_EXP_WIN_OPEN, NULL, 0, 0, 1.0f, 1.0f, -1.0f, + -1.0f, 0); + dMeter2Info_set2DVibration(); + return true; + } + return false; + } + } + + return false; +} +#endif + void dMenu_Insect_c::explain_open_init() { char local_78[32]; char local_98[32]; diff --git a/src/d/d_menu_letter.cpp b/src/d/d_menu_letter.cpp index cd7da6a6f4..0d7efd3adb 100644 --- a/src/d/d_menu_letter.cpp +++ b/src/d/d_menu_letter.cpp @@ -19,6 +19,15 @@ #ifdef TARGET_PC #include "dusk/achievements.h" +#include "dusk/menu_pointer.h" +#include "dusk/ui/touch_controls.hpp" + +static void enable_turn_page_controls(bool enabled) { + const auto controlOverride = + enabled ? dusk::ui::ControlOverride::Action : dusk::ui::ControlOverride::Default; + dusk::ui::set_control_override(dusk::ui::Control::L, controlOverride); + dusk::ui::set_control_override(dusk::ui::Control::R, controlOverride); +} #endif #if VERSION == VERSION_GCN_JPN @@ -82,6 +91,10 @@ dMenu_Letter_c::dMenu_Letter_c(JKRExpHeap* i_heap, STControl* i_stick, CSTContro dMenu_Letter_c::~dMenu_Letter_c() { +#if TARGET_PC + enable_turn_page_controls(false); +#endif + JKR_DELETE(mpDrawCursor); mpDrawCursor = NULL; @@ -357,6 +370,10 @@ int dMenu_Letter_c::_open() { } int dMenu_Letter_c::_close() { +#if TARGET_PC + enable_turn_page_controls(false); +#endif + s16 closeWindowFrame = g_drawHIO.mLetterSelectScreen.mCloseFrame[dMeter_drawLetterHIO_c::WINDOW_FRAME]; field_0x368 = 0; @@ -386,6 +403,10 @@ int dMenu_Letter_c::_close() { } void dMenu_Letter_c::wait_init() { +#if TARGET_PC + enable_turn_page_controls(field_0x374 > 1); +#endif + setAButtonString(0x40c); setBButtonString(0x3f9); } @@ -393,6 +414,12 @@ void dMenu_Letter_c::wait_init() { void dMenu_Letter_c::wait_move() { u8 oldIndex = mIndex; if (mDoGph_gInf_c::getFader()->getStatus() == 1) { +#if TARGET_PC + if (pointerWait()) { + return; + } +#endif + if (mDoCPd_c::getTrigB(PAD_1) != 0) { mpDrawCursor->offPlayAnime(0); mStatus = 3; @@ -448,8 +475,40 @@ void dMenu_Letter_c::wait_move() { } } +#if TARGET_PC +bool dMenu_Letter_c::pointerWait() { + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::Collection); + for (u8 i = 0; i < field_0x373; ++i) { + if (!dusk::menu_pointer::hit_pane(mpLetterParent[i], 8.0f)) { + continue; + } + + if (mIndex != i) { + mIndex = i; + changeActiveColor(); + Z2GetAudioMgr()->seStart(Z2SE_SY_CURSOR_ITEM, NULL, 0, 0, 1.0f, 1.0f, -1.0f, + -1.0f, 0); + } + if (dusk::menu_pointer::consume_click()) { + mProcess = 3; + Z2GetAudioMgr()->seStart(Z2SE_SY_LETTER_OPEN, NULL, 0, 0, 1.0f, 1.0f, -1.0f, + -1.0f, 0); + dMeter2Info_set2DVibration(); + return true; + } + return false; + } + + return false; +} +#endif + void dMenu_Letter_c::slide_right_init() { +#if TARGET_PC + enable_turn_page_controls(false); +#endif + field_0x358 = -field_0x1ec->getWidth() * mDoGph_gInf_c::getInvScale(); field_0x35c = field_0x1ec->getWidth() IF_NOT_DUSK(* mDoGph_gInf_c::getInvScale()); changePageLight(); @@ -467,6 +526,10 @@ void dMenu_Letter_c::slide_right_move() { } void dMenu_Letter_c::slide_left_init() { +#if TARGET_PC + enable_turn_page_controls(false); +#endif + field_0x358 = field_0x1ec->getWidth() * mDoGph_gInf_c::getInvScale(); field_0x35c = -field_0x1ec->getWidth() IF_NOT_DUSK(* mDoGph_gInf_c::getInvScale()); changePageLight(); @@ -484,6 +547,10 @@ void dMenu_Letter_c::slide_left_move() { } void dMenu_Letter_c::read_open_init() { +#if TARGET_PC + enable_turn_page_controls(false); +#endif + field_0x36a = 0; u8 idx = field_0x3ac[field_0x36f * 6 + mIndex] - 1; field_0x3e3 = 1; diff --git a/src/d/d_menu_option.cpp b/src/d/d_menu_option.cpp index 62f30d2d99..cc4bb01f2a 100644 --- a/src/d/d_menu_option.cpp +++ b/src/d/d_menu_option.cpp @@ -26,6 +26,11 @@ #include "JSystem/JAudio2/JASDriverIF.h" +#if TARGET_PC +#include "dusk/menu_pointer.h" +#include "dusk/ui/touch_controls.hpp" +#endif + typedef void (dMenu_Option_c::*initFunc)(); static initFunc init[] = { &dMenu_Option_c::atten_init, @@ -293,6 +298,10 @@ void dMenu_Option_c::_create() { } void dMenu_Option_c::_delete() { +#if TARGET_PC + dusk::ui::set_control_override(dusk::ui::Control::Z, dusk::ui::ControlOverride::Default); +#endif + JKR_DELETE(mpString); mpString = NULL; @@ -518,6 +527,15 @@ void dMenu_Option_c::_move() { (this->*init[field_0x3ef])(); } } + +#if TARGET_PC + if (field_0x3f4 == 5 && field_0x3ef != SelectType3 && field_0x3f3 == 5 && + field_0x3ef != SelectType4 && field_0x3ef != SelectType5 && field_0x3ef != SelectType6 && + field_0x3ef != SelectType7 && pointerConfirmSelect()) + { + goto skip; + } +#endif } skip: u8 oldValue = field_0x3ef; @@ -1074,6 +1092,34 @@ void dMenu_Option_c::confirm_move_move() { bool leftTrigger = checkLeftTrigger(); bool rightTrigger = checkRightTrigger(); +#if TARGET_PC + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::Options); + for (u8 i = 0; i < 2; ++i) { + if (!dusk::menu_pointer::hit_pane(mpYesNoSelBase_c[i], 8.0f)) { + continue; + } + if (field_0x3f9 != i) { + Z2GetAudioMgr()->seStart(Z2SE_SY_MENU_CURSOR_COMMON, NULL, 0, 0, 1.0f, 1.0f, -1.0f, + -1.0f, 0); + field_0x3fa = field_0x3f9; + field_0x3f9 = i; + yesnoSelectAnmSet(); + field_0x3ef = SelectType6; + mpWarning->_move(); + setAnimation(); + return; + } + if (dusk::menu_pointer::consume_click()) { + yesNoSelectStart(); + field_0x3ef = SelectType7; + dMeter2Info_set2DVibrationM(); + mpWarning->_move(); + setAnimation(); + return; + } + } +#endif + if (mDoCPd_c::getTrigA(PAD_1) != 0) { yesNoSelectStart(); field_0x3ef = SelectType7; @@ -2063,6 +2109,11 @@ void dMenu_Option_c::cursorAnime(f32 i_cursorValue) { } void dMenu_Option_c::setZButtonString(u16 i_stringID) { +#if TARGET_PC + dusk::ui::set_control_override(dusk::ui::Control::Z, + i_stringID != 0 ? dusk::ui::ControlOverride::Action : dusk::ui::ControlOverride::Default); +#endif + if (i_stringID == 0) { for (int i = 0; i < 3; i++) { if (mpZButtonText[i] != NULL) { @@ -2142,7 +2193,88 @@ bool dMenu_Option_c::isRumbleSupported() { return JUTGamePad::sRumbleSupported >> 0x1f; } +#if TARGET_PC +bool dMenu_Option_c::pointerConfirmSelect() { + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::Options); + if (!dusk::menu_pointer::state().clicked) { + return false; + } + + for (u8 i = 0; i < SelectType3; ++i) { + if (dusk::menu_pointer::hit_pane(mpMenuPane[i], 8.0f)) { + return false; + } + } + + if (!dusk::menu_pointer::consume_click()) { + return false; + } + + field_0x3f7 = 1; + field_0x3f5 = field_0x3ef; + field_0x3ef = SelectType4; + dMeter2Info_set2DVibration(); + (this->*init[field_0x3ef])(); + return true; +} +#endif + bool dMenu_Option_c::dpdMenuMove() { +#if TARGET_PC + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::Options); + for (u8 i = 0; i < SelectType3; ++i) { + if (!dusk::menu_pointer::hit_pane(mpMenuPane[i], 8.0f)) { + continue; + } + if (getSelectType() != i) { + field_0x3ef = i; + setCursorPos(i); + Z2GetAudioMgr()->seStart(Z2SE_SY_CURSOR_OPTION, NULL, 0, 0, 1.0f, 1.0f, -1.0f, + -1.0f, 0); + } + if (!dusk::menu_pointer::consume_click()) { + return true; + } + + switch (i) { + case SelectType0: + field_0x3e4 ^= 1; + field_0x3da = 5; + field_0x3ef = SelectType3; + field_0x3f5 = SelectType0; + Z2GetAudioMgr()->seStart(Z2SE_SY_OPTION_SWITCH, NULL, 0, 0, 1.0f, 1.0f, -1.0f, + -1.0f, 0); + return true; + case SelectType1: + if (isRumbleSupported()) { + field_0x3ea ^= 1; + if (field_0x3ea != 0) { + mDoCPd_c::startMotorWave(0, &field_0x3e0, JUTGamePad::CRumble::VAL_0, 0x3c); + } + field_0x3da = 5; + field_0x3ef = SelectType3; + field_0x3f5 = SelectType1; + Z2GetAudioMgr()->seStart(Z2SE_SY_OPTION_SWITCH, NULL, 0, 0, 1.0f, 1.0f, -1.0f, + -1.0f, 0); + } + return true; + case SelectType2: + if (field_0x3e9 == 0) { + field_0x3e9 = 2; + } else { + field_0x3e9--; + } + field_0x3da = 5; + mDoAud_setOutputMode(dMo_soundMode[field_0x3e9]); + setSoundMode(dMo_soundMode[field_0x3e9]); + field_0x3ef = SelectType3; + field_0x3f5 = SelectType2; + Z2GetAudioMgr()->seStart(Z2SE_SY_OPTION_SWITCH, NULL, 0, 0, 1.0f, 1.0f, -1.0f, + -1.0f, 0); + return true; + } + } +#endif return false; } diff --git a/src/d/d_menu_ring.cpp b/src/d/d_menu_ring.cpp index a468ba889e..fde1f35af8 100644 --- a/src/d/d_menu_ring.cpp +++ b/src/d/d_menu_ring.cpp @@ -31,7 +31,9 @@ #if TARGET_PC #include "dusk/game_clock.h" +#include "dusk/menu_pointer.h" #include "dusk/settings.h" +#include "dusk/ui/touch_controls.hpp" #endif typedef void (dMenu_Ring_c::*initFunc)(); @@ -614,6 +616,9 @@ void dMenu_Ring_c::_delete() { * initializes a new process if mStatus changes */ void dMenu_Ring_c::_move() { +#if TARGET_PC + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::ItemWheel); +#endif mRingRadiusH = g_ringHIO.mRingRadiusH; mRingRadiusV = g_ringHIO.mRingRadiusV; mOldStatus = mStatus; // Save current status for check @@ -1517,6 +1522,11 @@ void dMenu_Ring_c::stick_wait_proc() { setDoStatus(0); return; } +#if TARGET_PC + if (pointerMove()) { + return; + } +#endif if (dMw_A_TRIGGER() && !dMeter2Info_isTouchKeyCheck(0xe)) { Z2GetAudioMgr()->seStart(Z2SE_SYS_ERROR, NULL, 0, 0, 1.0f, 1.0f, -1.0f, -1.0f, 0); } @@ -1528,6 +1538,49 @@ void dMenu_Ring_c::stick_wait_proc() { } } +#if TARGET_PC +bool dMenu_Ring_c::pointerMove() { + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::ItemWheel); + const auto& pointer = dusk::menu_pointer::state(); + if (!pointer.valid || mItemsTotal == 0) { + return false; + } + + int hoveredSlot = -1; + f32 bestDistance = 42.0f; + for (u8 i = 0; i < mItemsTotal; ++i) { + const f32 x = mItemSlotPosX[i] + mCenterPosX; + const f32 y = mItemSlotPosY[i] + mCenterPosY; + const f32 distance = calcDistance(pointer.x, pointer.y, x, y); + if (distance < bestDistance) { + bestDistance = distance; + hoveredSlot = i; + } + } + + if (hoveredSlot < 0) { + return false; + } + + if (mCurrentSlot != hoveredSlot) { + mDirectSelectCursorPos.x = mItemSlotPosX[mCurrentSlot]; + mDirectSelectCursorPos.z = mItemSlotPosY[mCurrentSlot]; + mCurrentSlot = hoveredSlot; + mDirectSelectActive = true; + field_0x670 = field_0x63e[mCurrentSlot]; + setStatus(STATUS_MOVE); + field_0x6b2 = 0; + return true; + } + + if (dusk::menu_pointer::consume_click()) { + return true; + } + + return false; +} +#endif + void dMenu_Ring_c::stick_move_init() { if (mCursorSpeed == 0) { mCursorSpeed = g_ringHIO.mCursorInitSpeed; @@ -1672,12 +1725,40 @@ void dMenu_Ring_c::drawSelectItem() { #else if (field_0x674[i] < 10) { #endif +#if TARGET_PC + f32 initSizeX; + f32 initSizeY; + f32 initScaleX; + f32 initScaleY; + Vec pos; + dusk::ui::EquipTarget touchTarget; + if (dusk::ui::get_equip_target(i, touchTarget)) { + initSizeX = touchTarget.width; + initSizeY = touchTarget.height; + initScaleX = 1.0f; + initScaleY = 1.0f; + pos.x = touchTarget.left; + pos.y = touchTarget.top; + pos.z = 0.0f; + } else { + CPaneMgr* meterItemPane = dMeter2Info_getMeterItemPanePtr(i); + if (meterItemPane == NULL) { + continue; + } + initSizeX = meterItemPane->getInitSizeX() * 1.7f; + initSizeY = meterItemPane->getInitSizeY() * 1.7f; + initScaleX = meterItemPane->getInitScaleX(); + initScaleY = meterItemPane->getInitScaleY(); + pos = meterItemPane->getGlobalVtxCenter(meterItemPane->mPane, true, 0); + } +#else f32 initSizeX = dMeter2Info_getMeterItemPanePtr(i)->getInitSizeX() * 1.7f; f32 initSizeY = dMeter2Info_getMeterItemPanePtr(i)->getInitSizeY() * 1.7f; f32 initScaleX = dMeter2Info_getMeterItemPanePtr(i)->getInitScaleX(); f32 initScaleY = dMeter2Info_getMeterItemPanePtr(i)->getInitScaleY(); Vec pos = dMeter2Info_getMeterItemPanePtr(i)->getGlobalVtxCenter( dMeter2Info_getMeterItemPanePtr(i)->mPane, true, 0); +#endif #if TARGET_PC f32 fVar14 = 0.1f + 0.8f * u; diff --git a/src/d/d_menu_save.cpp b/src/d/d_menu_save.cpp index d73e4ff5e8..d272c57500 100644 --- a/src/d/d_menu_save.cpp +++ b/src/d/d_menu_save.cpp @@ -18,11 +18,15 @@ #include "m_Do/m_Do_controller_pad.h" #include "m_Do/m_Do_graphic.h" #include "d/d_msg_scrn_explain.h" -#include "dusk/frame_interpolation.h" -#include "dusk/settings.h" #include "JSystem/J2DGraph/J2DAnmLoader.h" #include "f_op/f_op_msg_mng.h" +#if TARGET_PC +#include "dusk/frame_interpolation.h" +#include "dusk/menu_pointer.h" +#include "dusk/settings.h" +#endif + static int SelStartFrameTbl[3] = { 59, 99, @@ -54,6 +58,17 @@ static int YnSelStartFrameTbl[2][2] = { static int YnSelEndFrameTbl[2][2] = {{2138, 3171}, {2150, 3181}}; +#if TARGET_PC +namespace { +constexpr u8 pointer_target(u8 group, u8 index) noexcept { + return static_cast((group << 4) | (index & 0x0F)); +} + +constexpr u8 s_pointerSaveSelectTarget = 0; +constexpr u8 s_pointerYesNoSelectTarget = 1; +} // namespace +#endif + static dMs_HIO_c g_msHIO; dMs_HIO_c::dMs_HIO_c() { @@ -1766,6 +1781,12 @@ void dMenu_save_c::openSaveSelect3() { void dMenu_save_c::saveSelect() { if (!mDoRst::isReset()) { +#if TARGET_PC + if (pointerSaveSelect()) { + return; + } +#endif + stick->checkTrigger(); if (mDoCPd_c::getTrigA(PAD_1)) { @@ -1792,7 +1813,84 @@ void dMenu_save_c::saveSelect() { } } +#if TARGET_PC +bool dMenu_save_c::pointerSaveSelect() { + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::Save); + for (u8 i = 0; i < 3; ++i) { + if (!dusk::menu_pointer::hit_pane(mpSelData[i], 8.0f)) { + continue; + } + const bool clicked = dusk::menu_pointer::consume_click(); + if (mSelectedFile != i) { + mDoAud_seStart(Z2SE_FILE_SELECT_CURSOR, NULL, 0, 0); + mLastSelFile = mSelectedFile; + mSelectedFile = i; + if (clicked) { + dusk::menu_pointer::defer_activation( + dusk::menu_pointer::Context::Save, + pointer_target(s_pointerSaveSelectTarget, i)); + } + dataSelectAnmSet(); + mMenuProc = PROC_SAVE_SELECT_MOVE_ANM; + return true; + } + if (clicked) { + saveSelectStart(); + return true; + } + } + return false; +} + +bool dMenu_save_c::pointerYesNoSelect(bool errorSelect, u8 errParam, u8 soundParam) { + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::Save); + for (u8 i = 0; i < 2; ++i) { + if (!dusk::menu_pointer::hit_pane(mpNoYes[i], 8.0f)) { + continue; + } + const bool clicked = + (!errorSelect || mYesNoCursor == i) && dusk::menu_pointer::consume_click(); + if (mYesNoCursor != i) { + if (errorSelect) { + errCurMove(errParam, soundParam); + return false; + } + mDoAud_seStart(Z2SE_SY_MENU_CURSOR_COMMON, NULL, 0, 0); + mYesNoPrevCursor = mYesNoCursor; + mYesNoCursor = i; + if (clicked) { + dusk::menu_pointer::defer_activation( + dusk::menu_pointer::Context::Save, + pointer_target(s_pointerYesNoSelectTarget, i)); + } + yesnoSelectAnmSet(0); + mMenuProc = PROC_YES_NO_CURSOR_MOVE_ANM; + return true; + } + if (clicked) { + if (errorSelect) { + if (mYesNoCursor != CURSOR_NO) { + if (soundParam == 0) { + mDoAud_seStart(Z2SE_SY_CURSOR_OK, NULL, 0, 0); + } + } else if (soundParam == 0) { + mDoAud_seStart(Z2SE_SY_CURSOR_CANCEL, NULL, 0, 0); + } + mSelIcon->setAlphaRate(0.0f); + } else { + yesnoSelectStart(); + } + return true; + } + } + return false; +} +#endif + void dMenu_save_c::saveSelectStart() { +#if TARGET_PC + dusk::menu_pointer::clear_deferred_activation(dusk::menu_pointer::Context::Save); +#endif mDoAud_seStart(Z2SE_SY_CURSOR_OK, NULL, 0, 0); selectDataMoveAnmInitSet(SelOpenStartFrameTbl[mSelectedFile], SelOpenEndFrameTbl[mSelectedFile]); @@ -1851,6 +1949,17 @@ void dMenu_save_c::dataSelectAnmSet() { } void dMenu_save_c::saveSelectMoveAnime() { +#if TARGET_PC + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::Save); + if (mSelectedFile != 0xFF && + dusk::menu_pointer::hit_pane(mpSelData[mSelectedFile], 8.0f) && + dusk::menu_pointer::consume_click()) + { + dusk::menu_pointer::defer_activation( + dusk::menu_pointer::Context::Save, + pointer_target(s_pointerSaveSelectTarget, mSelectedFile)); + } +#endif bool bookWakuAnmComplete = true; bool selWakuAnmComplete = true; bool var_r29 = true; @@ -1900,12 +2009,26 @@ void dMenu_save_c::saveSelectMoveAnime() { if (mLastSelFile != 0xFF) { mpSelData[mLastSelFile]->getPanePtr()->setAnimation((J2DAnmTransformKey*)NULL); } +#if TARGET_PC + if (dusk::menu_pointer::consume_deferred_activation( + dusk::menu_pointer::Context::Save, + pointer_target(s_pointerSaveSelectTarget, mSelectedFile))) { + saveSelectStart(); + return; + } +#endif mMenuProc = PROC_SAVE_SELECT; } } void dMenu_save_c::saveYesNoSelect() { if (!mDoRst::isReset()) { +#if TARGET_PC + if (pointerYesNoSelect(false)) { + return; + } +#endif + stick->checkTrigger(); if (mDoCPd_c::getTrigA(PAD_1)) { @@ -1933,6 +2056,9 @@ void dMenu_save_c::saveYesNoSelect() { } void dMenu_save_c::yesnoSelectStart() { +#if TARGET_PC + dusk::menu_pointer::clear_deferred_activation(dusk::menu_pointer::Context::Save); +#endif if (mYesNoCursor != CURSOR_NO) { mDoAud_seStart(Z2SE_SY_CURSOR_OK, NULL, 0, 0); mSelIcon->setAlphaRate(0.0f); @@ -2001,11 +2127,30 @@ void dMenu_save_c::yesnoSelectAnmSet(u8 param_0) { } void dMenu_save_c::yesNoCursorMoveAnm() { +#if TARGET_PC + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::Save); + if (mYesNoCursor != 0xFF && + dusk::menu_pointer::hit_pane(mpNoYes[mYesNoCursor], 8.0f) && + dusk::menu_pointer::consume_click()) + { + dusk::menu_pointer::defer_activation( + dusk::menu_pointer::Context::Save, + pointer_target(s_pointerYesNoSelectTarget, mYesNoCursor)); + } +#endif bool selAnmComplete = yesnoSelectMoveAnm(0); bool wakuAnmComplete = yesnoWakuAlpahAnm(mYesNoPrevCursor); if (selAnmComplete == true && wakuAnmComplete == true) { yesnoCursorShow(); +#if TARGET_PC + if (dusk::menu_pointer::consume_deferred_activation( + dusk::menu_pointer::Context::Save, + pointer_target(s_pointerYesNoSelectTarget, mYesNoCursor))) { + yesnoSelectStart(); + return; + } +#endif mMenuProc = PROC_SAVE_YES_NO_SELECT; } } @@ -2181,6 +2326,12 @@ bool dMenu_save_c::errYesNoSelect(u8 param_0, u8 param_1) { return false; } +#if TARGET_PC + if (pointerYesNoSelect(true, param_0, param_1)) { + return true; + } +#endif + stick->checkTrigger(); if (mDoCPd_c::getTrigA(PAD_1)) { diff --git a/src/d/d_menu_skill.cpp b/src/d/d_menu_skill.cpp index 2692d33c3f..601bae1eb6 100644 --- a/src/d/d_menu_skill.cpp +++ b/src/d/d_menu_skill.cpp @@ -18,6 +18,10 @@ #include "m_Do/m_Do_graphic.h" #include +#if TARGET_PC +#include "dusk/menu_pointer.h" +#endif + typedef void (dMenu_Skill_c::*initFunc)(); static initFunc map_init_process[] = { &dMenu_Skill_c::wait_init, @@ -275,6 +279,12 @@ void dMenu_Skill_c::wait_init() { void dMenu_Skill_c::wait_move() { u8 oldIndex = mIndex; if (mDoGph_gInf_c::getFader()->getStatus() == 1) { +#if TARGET_PC + if (pointerWait()) { + return; + } +#endif + if (mDoCPd_c::getTrigB(PAD_1) != 0) { mpDrawCursor->offPlayAnime(0); mStatus = 3; @@ -299,6 +309,34 @@ void dMenu_Skill_c::wait_move() { } } +#if TARGET_PC +bool dMenu_Skill_c::pointerWait() { + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::Collection); + for (u8 i = 0; i < mSkillNum; ++i) { + if (!dusk::menu_pointer::hit_pane(mpLetterParent[i], 8.0f)) { + continue; + } + + if (mIndex != i) { + mIndex = i; + changeActiveColor(); + Z2GetAudioMgr()->seStart(Z2SE_SY_CURSOR_ITEM, NULL, 0, 0, 1.0f, 1.0f, -1.0f, + -1.0f, 0); + } + if (dusk::menu_pointer::consume_click()) { + mProcess = PROC_WAIT_MOVE; + Z2GetAudioMgr()->seStart(Z2SE_SY_EXP_WIN_OPEN, NULL, 0, 0, 1.0f, 1.0f, -1.0f, + -1.0f, 0); + dMeter2Info_set2DVibration(); + return true; + } + return false; + } + + return false; +} +#endif + void dMenu_Skill_c::read_open_init() { static const u32 i_id[7] = { 1716, 1715, 1717, 1718, 1719, 1720, 1721, diff --git a/src/d/d_meter2.cpp b/src/d/d_meter2.cpp index 7675bbfb0a..4afaf450a8 100644 --- a/src/d/d_meter2.cpp +++ b/src/d/d_meter2.cpp @@ -2966,7 +2966,15 @@ void dMeter2_c::alphaAnimeButtonCross() { field_0x190++; } } else { +#if TARGET_PC + if (dusk::getSettings().game.enableTouchControls) { + mpMeterDraw->setAlphaButtonCrossAnimeMin(); + } else { + mpMeterDraw->setAlphaButtonCrossAnimeMax(); + } +#else mpMeterDraw->setAlphaButtonCrossAnimeMax(); +#endif if (field_0x190 < 5) { field_0x190++; diff --git a/src/d/d_meter2_draw.cpp b/src/d/d_meter2_draw.cpp index 900367d7f3..eeebe65924 100644 --- a/src/d/d_meter2_draw.cpp +++ b/src/d/d_meter2_draw.cpp @@ -25,6 +25,7 @@ #if TARGET_PC #include "dusk/settings.h" +#include "dusk/ui/icon_provider.hpp" #include namespace { @@ -653,10 +654,22 @@ void dMeter2Draw_c::draw() { J2DGrafContext* graf_ctx = dComIfGp_getCurrentGrafPort(); graf_ctx->setup2D(); +#if TARGET_PC + const bool touchControlsEnabled = dusk::getSettings().game.enableTouchControls; + if (touchControlsEnabled) { + mpButtonParent->hide(); + } else { + mpButtonParent->show(); + } +#endif + mpScreen->draw(0.0f, 0.0f, graf_ctx); drawKanteraScreen(1); drawKanteraScreen(2); +#if TARGET_PC + if (!touchControlsEnabled) { +#endif for (int i = 0; i < 2; i++) { if (mpItemXY[i] != NULL) { for (int j = 0; j < 3; j++) { @@ -705,6 +718,9 @@ void dMeter2Draw_c::draw() { } } } +#if TARGET_PC + } +#endif if (mpLightDropParent->getAlphaRate() != 0.0f) { f32 var_f28 = g_drawHIO.mLightDrop.mPikariScaleNormal; @@ -787,7 +803,11 @@ void dMeter2Draw_c::draw() { } } +#if TARGET_PC + if (!touchControlsEnabled && field_0x738 > 0.0f) { +#else if (field_0x738 > 0.0f) { +#endif drawPikari(mpButtonMidona, &field_0x738, g_drawHIO.mMidnaIconPikariScale, g_drawHIO.mMidnaIconPikariFrontOuter, g_drawHIO.mMidnaIconPikariFrontInner, g_drawHIO.mMidnaIconPikariBackOuter, g_drawHIO.mMidnaIconPikariBackInner, @@ -2452,6 +2472,11 @@ void dMeter2Draw_c::drawButtonB(u8 i_action, bool param_1, f32 i_posX, f32 i_pos SAFE_STRCPY(static_cast(mpBText[i]->getPanePtr())->getStringPtr(), mp_string); } +#if TARGET_PC + if (dusk::getSettings().game.enableTouchControls) { + mpScreen->search(MULTI_CHAR('item_b_n'))->hide(); + } else +#endif if (i_action == 0x26 || i_action == 0x2E) { mpScreen->search(MULTI_CHAR('item_b_n'))->show(); var_r31 = 1; @@ -2729,6 +2754,12 @@ void dMeter2Draw_c::drawButtonXY(int i_no, u8 i_itemNo, u8 i_action, bool param_ mpTextXY[i_no]->scale(g_drawHIO.mButtonXYTextScale, g_drawHIO.mButtonXYTextScale); mpTextXY[i_no]->paneTrans(g_drawHIO.mButtonXYTextPosX, g_drawHIO.mButtonXYTextPosY); } + +#if TARGET_PC + if (dusk::getSettings().game.enableTouchControls) { + mpScreen->search(tag[i_no])->hide(); + } +#endif } } @@ -3294,6 +3325,10 @@ void dMeter2Draw_c::setButtonIconMidonaAlpha(u32 param_0) { } mpButtonXY[2]->setAlpha(255.0f * field_0x724 * temp_f30_2); + +#if TARGET_PC + dusk::ui::update_midna_icon_texture(mpButtonMidona != NULL ? mpButtonMidona->getPanePtr() : NULL); +#endif } void dMeter2Draw_c::setButtonIconAlpha(int i_no, u8 unused0, u32 unused1, bool unused2) { diff --git a/src/d/d_meter_map.cpp b/src/d/d_meter_map.cpp index 95cd82cd53..bffdbb8931 100644 --- a/src/d/d_meter_map.cpp +++ b/src/d/d_meter_map.cpp @@ -22,6 +22,10 @@ #endif #include +#if TARGET_PC +#include "dusk/action_bindings.h" +#endif + #if (PLATFORM_WII || PLATFORM_SHIELD) dMeter_map_HIO_c g_meter_mapHIO; #endif @@ -738,7 +742,38 @@ void dMeterMap_c::ctrlShowMap() { } } - } else if (!mDoCPd_c::getTrigUp(PAD_1) && !mDoCPd_c::getTrigDown(PAD_1)) { + } +#if TARGET_PC + else if (!isEventRunCheck() && + (dMeter2Info_getMapStatus() == 0 || dMeter2Info_getMapStatus() == 1) && + !dMeter2Info_isSub2DStatus(1) && (isFmapScreen() || isDmapScreen()) && + dusk::getActionBindTrig(dusk::ActionBinds::OPEN_MAP_SCREEN, PAD_1)) + { + dMeter2Info_setMapStatus(2); + dMeter2Info_setMapKeyDirection(0x400); + Z2GetAudioMgr()->seStart(Z2SE_SY_MAP_OPEN_S, NULL, 0, 0, 1.0f, 1.0f, -1.0f, + -1.0f, 0); + dMeter2Info_set2DVibration(); + } else if (!isEventRunCheck() && + (dMeter2Info_getMapStatus() == 0 || dMeter2Info_getMapStatus() == 1) && + isEnableDispMapAndMapDispSizeTypeNo() && + dusk::getActionBindTrig(dusk::ActionBinds::TOGGLE_MINIMAP, PAD_1)) + { + if (isDispPosInsideFlg()) { + setDispPosOutsideFlg_SE_On(); + Z2GetAudioMgr()->seStart(Z2SE_SY_MAP_CLOSE_S, NULL, 0, 0, 1.0f, 1.0f, -1.0f, + -1.0f, 0); + dMeter2Info_setMapStatus(0); + } else { + setDispPosInsideFlg_SE_On(); + Z2GetAudioMgr()->seStart(Z2SE_SY_MAP_OPEN_S, NULL, 0, 0, 1.0f, 1.0f, -1.0f, + -1.0f, 0); + dMeter2Info_set2DVibration(); + dMeter2Info_setMapStatus(1); + } + } +#endif + else if (!mDoCPd_c::getTrigUp(PAD_1) && !mDoCPd_c::getTrigDown(PAD_1)) { keyCheck(); } @@ -833,7 +868,21 @@ void dMeterMap_c::meter_map_move(u32 param_0) { dMeter2Info_set2DVibration(); } dMeter2Info_resetPauseStatus(); - } else if ( + } +#if TARGET_PC + else if (!dComIfGp_event_runCheck() && !dMsgObject_isTalkNowCheck() && + (dMeter2Info_getMapStatus() == 0 || dMeter2Info_getMapStatus() == 1) && + !dMeter2Info_isSub2DStatus(1) && (isFmapScreen() || isDmapScreen()) && + dusk::getActionBindTrig(dusk::ActionBinds::OPEN_MAP_SCREEN, PAD_1)) + { + dMeter2Info_setMapStatus(2); + dMeter2Info_setMapKeyDirection(0x400); + Z2GetAudioMgr()->seStart(Z2SE_SY_MAP_OPEN_S, NULL, 0, 0, 1.0f, 1.0f, -1.0f, + -1.0f, 0); + dMeter2Info_set2DVibration(); + } +#endif + else if ( #if DEBUG dMw_RIGHT_TRIGGER() && #else diff --git a/src/d/d_msg_class.cpp b/src/d/d_msg_class.cpp index 01d2ab48e6..8dc2a82c01 100644 --- a/src/d/d_msg_class.cpp +++ b/src/d/d_msg_class.cpp @@ -13,6 +13,7 @@ #include "JSystem/JUtility/JUTFont.h" #if TARGET_PC +#include "dusk/menu_pointer.h" #include "dusk/scope_guard.hpp" #endif @@ -575,6 +576,20 @@ void jmessage_tReference::pageSend() { void jmessage_tReference::selectMessage() { if (mSelectNum != 0) { +#if TARGET_PC + u8 pointerChoice = 0xFF; + if (dusk::menu_pointer::get_dialog_choice(pointerChoice) && pointerChoice < mSelectNum && + pointerChoice != mSelectPos) + { + mSelectPos = pointerChoice; + if (mSelectType != 0) { + getObjectPtr()->getSequenceProcessor()->calcStringLength(); + } + Z2GetAudioMgr()->seStart(Z2SE_SY_TALK_CURSOR, NULL, 0, 0, 1.0f, 1.0f, -1.0f, + -1.0f, 0); + } +#endif + mpStick->checkTrigger(); if (mSelectType == 0) { diff --git a/src/d/d_msg_object.cpp b/src/d/d_msg_object.cpp index a2744eecff..4caec360a9 100644 --- a/src/d/d_msg_object.cpp +++ b/src/d/d_msg_object.cpp @@ -26,12 +26,13 @@ #include #include "JSystem/JKernel/JKRExpHeap.h" -#include "dusk/version.hpp" #include "m_Do/m_Do_controller_pad.h" #include "m_Do/m_Do_lib.h" #if TARGET_PC +#include "dusk/menu_pointer.h" #include "dusk/settings.h" +#include "dusk/version.hpp" #include #include #include @@ -1123,7 +1124,20 @@ void dMsgObject_c::selectProc() { dComIfGp_setAStatusForce(0x2a, 0); } } - if (mDoCPd_c::getTrigA(0)) { +#if TARGET_PC + jmessage_tReference* pRef = (jmessage_tReference*)mpRenProc->getReference(); + u8 pointerChoice = 0xFF; + bool pointerConfirm = dusk::menu_pointer::consume_dialog_click(pointerChoice) && + pointerChoice < pRef->getSelectNum(); + if (pointerConfirm) { + pRef->setSelectPos(pointerChoice); + } +#endif + if (mDoCPd_c::getTrigA(0) +#if TARGET_PC + || pointerConfirm +#endif + ) { if (getSelectCursorPosLocal() != 0xff) { field_0x1a3 = 1; } @@ -1145,7 +1159,9 @@ void dMsgObject_c::selectProc() { } field_0x1a3 = 2; } +#ifndef TARGET_PC jmessage_tReference* pRef = (jmessage_tReference*)mpRenProc->getReference(); +#endif if (getStatusLocal() == 8) { if (isMidonaMessage() && field_0x1a3 != 0) { if (field_0x1a3 == 2 && getSelectCancelPos() == 3) { diff --git a/src/d/d_msg_scrn_3select.cpp b/src/d/d_msg_scrn_3select.cpp index 68d0732481..b021b52dec 100644 --- a/src/d/d_msg_scrn_3select.cpp +++ b/src/d/d_msg_scrn_3select.cpp @@ -16,6 +16,17 @@ #include "d/d_msg_object.h" #include "d/d_pane_class.h" +#if TARGET_PC +#include "dusk/menu_pointer.h" + +namespace { +bool hit_choice_pane(CPaneMgr* pane, f32 padding) { + return pane != NULL && pane->getPanePtr() != NULL && pane->getPanePtr()->isVisible() && + dusk::menu_pointer::hit_pane(pane, padding); +} +} // namespace +#endif + typedef void (dMsgScrn3Select_c::*processFn)(); processFn process[] = { &dMsgScrn3Select_c::open1Proc, &dMsgScrn3Select_c::open2Proc, &dMsgScrn3Select_c::waitProc, @@ -470,6 +481,9 @@ bool dMsgScrn3Select_c::selAnimeMove(u8 i_selNum, u8 param_1, bool param_2) { mSelNum = i_selNum; field_0x114 = 0; field_0x108 = param_2; +#if TARGET_PC + pointerMove(); +#endif (this->*process[mProcess])(); @@ -518,6 +532,47 @@ bool dMsgScrn3Select_c::selAnimeMove(u8 i_selNum, u8 param_1, bool param_2) { return mProcess == PROC_SELECT_e ? TRUE : FALSE; } +#if TARGET_PC +bool dMsgScrn3Select_c::pointerMove() { + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::Dialog); + mDPDPoint = 0xFF; + + const u8 firstPane = mSelNum == 2 ? 1 : 0; + for (u8 choice = 0; choice < mSelNum; ++choice) { + const u8 paneIndex = firstPane + choice; + if (paneIndex >= 3) { + continue; + } + + // TODO: this sucks and should be replaced with Wii mpTouchArea + bool hit = hit_choice_pane(mpSel_c[paneIndex], 8.0f) || + hit_choice_pane(mpTmSel_c[paneIndex], 24.0f) || + hit_choice_pane(mpTmrSel_c[paneIndex], 24.0f) || + hit_choice_pane(mpKahen_c[paneIndex], 8.0f) || + hit_choice_pane(mpCursor_c[paneIndex], 8.0f); + for (int i = 0; i < 5 && !hit; ++i) { + hit = hit_choice_pane(mpSelCldw_c[i][paneIndex], 8.0f); + } + + if (!hit) { + continue; + } + + mDPDPoint = choice; + field_0x110 = paneIndex; + dusk::menu_pointer::set_dialog_choice(choice, dusk::menu_pointer::state().clicked); + return true; + } + + return false; +} + +bool dMsgScrn3Select_c::consumePointerClick() { + u8 choice = 0xFF; + return dusk::menu_pointer::consume_dialog_click(choice); +} +#endif + bool dMsgScrn3Select_c::selAnimeEnd() { if (mProcess == PROC_MAX_e) { return true; diff --git a/src/d/d_msg_scrn_explain.cpp b/src/d/d_msg_scrn_explain.cpp index bc6275e10c..29a88590ee 100644 --- a/src/d/d_msg_scrn_explain.cpp +++ b/src/d/d_msg_scrn_explain.cpp @@ -643,6 +643,10 @@ f32 dMsgScrnExplain_c::getAlphaRatio() { bool dMsgScrnExplain_c::checkTriggerA() { if (mDoCPd_c::getTrigA(PAD_1)) { return true; +#if TARGET_PC + } else if (mpSelect_c != NULL && mpSelect_c->consumePointerClick()) { + return true; +#endif } else { return false; } diff --git a/src/dusk/action_bindings.cpp b/src/dusk/action_bindings.cpp index 204f219558..68b07d6c0a 100644 --- a/src/dusk/action_bindings.cpp +++ b/src/dusk/action_bindings.cpp @@ -8,10 +8,19 @@ namespace dusk { static std::array(ActionBinds::COUNT)>, PAD_CHANMAX> actionPressData{}; +struct VirtualActionBindData { + bool pressed = false; + bool available = false; +}; + +static std::array(ActionBinds::COUNT)>, PAD_CHANMAX> virtualActionData{}; + ActionBindsMap& getActionBinds() { static ActionBindsMap actionBinds = { {ActionBinds::FIRST_PERSON_CAMERA, {&getSettings().actionBindings.firstPersonCamera, "First Person Camera"}}, {ActionBinds::CALL_MIDNA, {&getSettings().actionBindings.callMidna, "Call Midna"}}, + {ActionBinds::OPEN_MAP_SCREEN, {&getSettings().actionBindings.openMapScreen, "Open Map Screen"}}, + {ActionBinds::TOGGLE_MINIMAP, {&getSettings().actionBindings.toggleMinimap, "Toggle Minimap"}}, {ActionBinds::OPEN_DUSKLIGHT_MENU, {&getSettings().actionBindings.openDusklightMenu, "Open Dusklight Menu"}}, {ActionBinds::TURBO_SPEED_BUTTON, {&getSettings().actionBindings.turboSpeedButton, "Turbo Speed Button"}}, }; @@ -25,6 +34,10 @@ bool isActionBound(ActionBinds action, u32 port) { return false; } + if (port < PAD_CHANMAX && virtualActionData[port][static_cast(action)].available) { + return true; + } + return getActionBindButton(action, port) != PAD_NATIVE_BUTTON_INVALID; } @@ -41,43 +54,71 @@ void updateActionBindings() { // If the action isn't bound, or if documents are visible and the action isn't // opening the dusklight menu, don't update. Otherwise, we may accidentally // perform actions while the dusklight menu is open. - if (!isActionBound(action, port) || + const int button = boundAction.configVars->at(port); + const bool virtualAvailable = virtualActionData[port][static_cast(action)].available; + if ((button == PAD_NATIVE_BUTTON_INVALID && !virtualAvailable) || (ui::any_document_visible() && action != ActionBinds::OPEN_DUSKLIGHT_MENU)) { continue; } - int button = boundAction.configVars->at(port); - - // If keyboard is active for this port - u32 count = 0; - if (PADGetKeyButtonBindings(port, &count) != nullptr) { - int numKeys = 0; - const bool* kbState = SDL_GetKeyboardState(&numKeys); - if (kbState[button]) { - actionPressData[port][static_cast(action)].pressedCurFrame = true; - } - } else { - // If controller is active - auto controller = aurora::input::get_controller_for_player(port); - if (controller) { - if (SDL_GetGamepadButton(controller->m_controller, static_cast(button))) { + if (button != PAD_NATIVE_BUTTON_INVALID) { + // If keyboard is active for this port + u32 count = 0; + if (PADGetKeyButtonBindings(port, &count) != nullptr) { + int numKeys = 0; + const bool* kbState = SDL_GetKeyboardState(&numKeys); + if (kbState[button]) { actionPressData[port][static_cast(action)].pressedCurFrame = true; } + } else { + // If controller is active + auto controller = aurora::input::get_controller_for_player(port); + if (controller) { + if (SDL_GetGamepadButton(controller->m_controller, static_cast(button))) { + actionPressData[port][static_cast(action)].pressedCurFrame = true; + } + } } } } + + for (auto& [action, _] : getActionBinds()) { + const auto& virtualAction = virtualActionData[port][static_cast(action)]; + if (virtualAction.available && virtualAction.pressed && !ui::any_document_visible()) { + actionPressData[port][static_cast(action)].pressedCurFrame = true; + } + } } } +void setVirtualActionBind(ActionBinds action, u32 port, bool pressed, bool available) { + if (port >= PAD_CHANMAX) { + return; + } + virtualActionData[port][static_cast(action)] = { + .pressed = pressed, + .available = available, + }; +} + +void clearVirtualActionBind(ActionBinds action, u32 port) { + if (port >= PAD_CHANMAX) { + return; + } + virtualActionData[port][static_cast(action)] = {}; +} + +void clearAllVirtualActionBinds() { + virtualActionData = {}; +} + bool getActionBindTrig(ActionBinds action, u32 port) { - return isActionBound(action, port) && - actionPressData[port][static_cast(action)].pressedCurFrame && + return actionPressData[port][static_cast(action)].pressedCurFrame && !actionPressData[port][static_cast(action)].pressedPrevFrame; } bool getActionBindHold(ActionBinds action, u32 port) { - return isActionBound(action, port) && - actionPressData[port][static_cast(action)].pressedCurFrame && + return actionPressData[port][static_cast(action)].pressedCurFrame && actionPressData[port][static_cast(action)].pressedPrevFrame; } diff --git a/src/dusk/config.cpp b/src/dusk/config.cpp index 31e4a80b05..fa331eb518 100644 --- a/src/dusk/config.cpp +++ b/src/dusk/config.cpp @@ -1,19 +1,23 @@ #include "dusk/config.hpp" +#include "absl/container/flat_hash_map.h" #include "fmt/format.h" #include "nlohmann/json.hpp" -#include "absl/container/flat_hash_map.h" #include "aurora/lib/logging.hpp" #include "dusk/io.hpp" #include "dusk/settings.h" -#include +#include #include -#include +#include +#include #include +#include +#include +#include -#include "dusk/main.h" #include "dusk/action_bindings.h" +#include "dusk/main.h" using namespace dusk::config; @@ -26,6 +30,104 @@ aurora::Module DuskConfigLog("dusk::config"); static absl::flat_hash_map RegisteredConfigVars; static bool RegistrationDone = false; +static std::optional parse_control_anchor(std::string_view value) { + if (value == "none") { + return dusk::ui::ControlAnchor::None; + } + if (value == "top") { + return dusk::ui::ControlAnchor::Top; + } + if (value == "left") { + return dusk::ui::ControlAnchor::Left; + } + if (value == "bottom") { + return dusk::ui::ControlAnchor::Bottom; + } + if (value == "right") { + return dusk::ui::ControlAnchor::Right; + } + if (value == "topLeft") { + return dusk::ui::ControlAnchor::TopLeft; + } + if (value == "topRight") { + return dusk::ui::ControlAnchor::TopRight; + } + if (value == "bottomLeft") { + return dusk::ui::ControlAnchor::BottomLeft; + } + if (value == "bottomRight") { + return dusk::ui::ControlAnchor::BottomRight; + } + return std::nullopt; +} + +static const char* control_anchor_value(dusk::ui::ControlAnchor anchor) { + switch (anchor) { + case dusk::ui::ControlAnchor::None: + return "none"; + case dusk::ui::ControlAnchor::Top: + return "top"; + case dusk::ui::ControlAnchor::Left: + return "left"; + case dusk::ui::ControlAnchor::Bottom: + return "bottom"; + case dusk::ui::ControlAnchor::Right: + return "right"; + case dusk::ui::ControlAnchor::TopLeft: + return "topLeft"; + case dusk::ui::ControlAnchor::TopRight: + return "topRight"; + case dusk::ui::ControlAnchor::BottomLeft: + return "bottomLeft"; + case dusk::ui::ControlAnchor::BottomRight: + return "bottomRight"; + } + return "none"; +} + +static std::optional json_finite_float(const json& object, const char* key) { + const auto iter = object.find(key); + if (iter == object.end() || !iter->is_number()) { + return std::nullopt; + } + + const float value = iter->get(); + if (!std::isfinite(value)) { + return std::nullopt; + } + + return value; +} + +static std::optional parse_control_props(const json& value) { + if (!value.is_object()) { + return std::nullopt; + } + + const auto x = json_finite_float(value, "x"); + const auto y = json_finite_float(value, "y"); + const auto w = json_finite_float(value, "w"); + const auto h = json_finite_float(value, "h"); + const auto scale = json_finite_float(value, "scale"); + const auto anchorIter = value.find("anchor"); + if (!x || !y || !w || !h || !scale || anchorIter == value.end() || !anchorIter->is_string()) { + return std::nullopt; + } + + const auto anchor = parse_control_anchor(anchorIter->get()); + if (!anchor || *w <= 0.0f || *h <= 0.0f || *scale <= 0.0f) { + return std::nullopt; + } + return dusk::ui::ControlProps{ + .x = *x, + .y = *y, + .w = *w, + .h = *h, + .scale = *scale, + .anchor = *anchor, + }; +} + static std::filesystem::path GetConfigJsonPath() { return dusk::ConfigPath / ConfigFileName; } @@ -46,8 +148,8 @@ static void ReplaceFile(const std::filesystem::path& source, const std::filesyst } } -ConfigVarBase::ConfigVarBase(const char* name, const ConfigImplBase* impl) : name(name), registered(false), layer(ConfigVarLayer::Default), impl(impl) { -} +ConfigVarBase::ConfigVarBase(const char* name, const ConfigImplBase* impl) + : name(name), registered(false), layer(ConfigVarLayer::Default), impl(impl) {} const char* ConfigVarBase::getName() const noexcept { return name; @@ -72,11 +174,13 @@ static T sanitizeEnumValue(const ConfigVar& cVar, T value) { return value; } -template +template void ConfigImpl::loadFromJson(ConfigVar& cVar, const json& jsonValue) { if constexpr (std::is_enum_v) { if (jsonValue.is_boolean()) { - DuskConfigLog.error("Doing default migration of CVar {} from bool, enum values may not be what is expected!", cVar.getName()); + DuskConfigLog.error("Doing default migration of CVar {} from bool, enum values may not " + "be what is expected!", + cVar.getName()); using Underlying = std::underlying_type_t; const bool b = jsonValue.get(); @@ -91,13 +195,14 @@ void ConfigImpl::loadFromJson(ConfigVar& cVar, const json& jsonValue) { cVar.setValue(sanitizeEnumValue(cVar, jsonValue.get()), false); } -template +template nlohmann::json ConfigImpl::dumpToJson(const ConfigVar& cVar) { return cVar.getValueForSave(); } -template requires std::is_integral_v && std::is_signed_v -static void loadFromArgImpl(ConfigVar& cVar, const std::string_view stringValue) { +template +requires std::is_integral_v&& std::is_signed_v static void loadFromArgImpl( + ConfigVar& cVar, const std::string_view stringValue) { const std::string str(stringValue); const auto result = std::stoll(str); if (result >= std::numeric_limits::min() && result <= std::numeric_limits::max()) { @@ -107,8 +212,9 @@ static void loadFromArgImpl(ConfigVar& cVar, const std::string_view stringVal } } -template requires std::is_integral_v && std::is_unsigned_v -static void loadFromArgImpl(ConfigVar& cVar, const std::string_view stringValue) { +template +requires std::is_integral_v&& std::is_unsigned_v static void loadFromArgImpl( + ConfigVar& cVar, const std::string_view stringValue) { const std::string str(stringValue); const auto result = std::stoull(str); if (result <= std::numeric_limits::max()) { @@ -134,14 +240,17 @@ static void loadFromArgImpl(ConfigVar& cVar, const std::string_view cVar.setOverrideValue(std::string(stringValue)); } -template requires std::is_enum_v -static void loadFromArgImpl(ConfigVar& cVar, const std::string_view stringValue) { +template +requires std::is_enum_v static void loadFromArgImpl( + ConfigVar& cVar, const std::string_view stringValue) { using Underlying = std::underlying_type_t; const std::string str(stringValue); if constexpr (std::is_signed_v) { const auto result = std::stoll(str); - if (result >= std::numeric_limits::min() && result <= std::numeric_limits::max()) { + if (result >= std::numeric_limits::min() && + result <= std::numeric_limits::max()) + { cVar.setOverrideValue(sanitizeEnumValue(cVar, static_cast(result))); } else { throw std::out_of_range("Value is too large"); @@ -156,16 +265,20 @@ static void loadFromArgImpl(ConfigVar& cVar, const std::string_view stringVal } } -template +template void ConfigImpl::loadFromArg(ConfigVar& cVar, const std::string_view stringValue) { loadFromArgImpl(cVar, stringValue); } -template<> +template <> void ConfigImpl::loadFromArg(ConfigVar& cVar, const std::string_view stringValue) { - if (stringValue == "1" || stringValue == "TRUE" || stringValue == "true" || stringValue == "True") { + if (stringValue == "1" || stringValue == "TRUE" || stringValue == "true" || + stringValue == "True") + { cVar.setOverrideValue(true); - } else if (stringValue == "0" || stringValue == "FALSE" || stringValue == "false" || stringValue == "False") { + } else if (stringValue == "0" || stringValue == "FALSE" || stringValue == "false" || + stringValue == "False") + { cVar.setOverrideValue(false); } else { throw InvalidConfigError("Value cannot be parsed as boolean"); @@ -174,42 +287,103 @@ void ConfigImpl::loadFromArg(ConfigVar& cVar, const std::string_view // My IDE is convinced this namespace is necessary. It shouldn't be AFAICT? namespace dusk::config { - template class ConfigImpl; - template class ConfigImpl; - template class ConfigImpl; - template class ConfigImpl; - template class ConfigImpl; - template class ConfigImpl; - template class ConfigImpl; - template class ConfigImpl; - template class ConfigImpl; - template class ConfigImpl; - template class ConfigImpl; - template class ConfigImpl; - template class ConfigImpl; - template class ConfigImpl; - template class ConfigImpl; - template class ConfigImpl; - template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; - template<> void ConfigImpl::loadFromJson(ConfigVar& cVar, const json& jsonValue) { - if (jsonValue.is_boolean()) { - const bool b = jsonValue.get(); +template <> +void ConfigImpl::loadFromJson( + ConfigVar& cVar, const json& jsonValue) { + if (jsonValue.is_boolean()) { + const bool b = jsonValue.get(); - const FrameInterpMode mode = b ? FrameInterpMode::Unlimited : FrameInterpMode::Off; + const FrameInterpMode mode = b ? FrameInterpMode::Unlimited : FrameInterpMode::Off; - cVar.setValue(sanitizeEnumValue(cVar, mode), false); - return; + cVar.setValue(sanitizeEnumValue(cVar, mode), false); + return; + } + + cVar.setValue(sanitizeEnumValue(cVar, jsonValue.get()), false); +} + +template <> +void ConfigImpl::loadFromJson( + ConfigVar& cVar, const json& jsonValue) { + if (!jsonValue.is_object()) { + return; + } + + const int version = jsonValue.value("version", 0); + if (version != ui::ControlLayout::Version) { + return; + } + + const auto controlsIter = jsonValue.find("controls"); + if (controlsIter == jsonValue.end() || !controlsIter->is_object()) { + return; + } + + ui::ControlLayout layout{.version = version}; + for (const auto& control : controlsIter->items()) { + if (!ui::is_control_layout_id(control.key())) { + continue; } - cVar.setValue(sanitizeEnumValue(cVar, jsonValue.get()), false); + if (const auto props = parse_control_props(control.value())) { + layout.controls[control.key()] = *props; + } } - template class ConfigImpl; - template class ConfigImpl; - template class ConfigImpl; - template class ConfigImpl; + + cVar.setValue(std::move(layout), false); } +template <> +void ConfigImpl::loadFromArg( + ConfigVar&, const std::string_view) { + throw InvalidConfigError("Touch control layout cannot be parsed from launch arguments"); +} + +template <> +nlohmann::json ConfigImpl::dumpToJson(const ConfigVar& cVar) { + const auto& layout = cVar.getValueForSave(); + json controls = json::object(); + for (const auto& [id, props] : layout.controls) { + controls[id] = { + {"x", props.x}, + {"y", props.y}, + {"w", props.w}, + {"h", props.h}, + {"scale", props.scale}, + {"anchor", control_anchor_value(props.anchor)}, + }; + } + + return { + {"version", ui::ControlLayout::Version}, + {"controls", std::move(controls)}, + }; +} + +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +} // namespace dusk::config + void dusk::config::Register(ConfigVarBase& configVar) { const auto& name = configVar.getName(); if (RegistrationDone) { @@ -298,9 +472,7 @@ void dusk::config::Save() { } const auto configPathString = io::fs_path_to_string(configJsonPath); - DuskConfigLog.info( - "Saving config to '{}'", - configPathString); + DuskConfigLog.info("Saving config to '{}'", configPathString); json j; diff --git a/src/dusk/gyro.cpp b/src/dusk/gyro.cpp index 1e2e379960..31b32bd412 100644 --- a/src/dusk/gyro.cpp +++ b/src/dusk/gyro.cpp @@ -70,16 +70,7 @@ bool rollgoal_gyro_enabled() { } bool queryGyroAimContext() { - if (!static_cast(getSettings().game.enableGyroAim)) { - return false; - } - - daAlink_c* link = daAlink_getAlinkActorClass(); - if (link == nullptr) { - return false; - } - - return link->checkAimContext() && dComIfGp_checkCameraAttentionStatus(link->field_0x317c, 0x10); + return getSettings().game.enableGyroAim.getValue() && dCamera_c::isAimActive(); } void read(float dt) { diff --git a/src/dusk/menu_pointer.cpp b/src/dusk/menu_pointer.cpp new file mode 100644 index 0000000000..91b66cce55 --- /dev/null +++ b/src/dusk/menu_pointer.cpp @@ -0,0 +1,386 @@ +#include "dusk/menu_pointer.h" + +#include "m_Do/m_Do_graphic.h" +#include "d/d_pane_class.h" +#include "dusk/settings.h" + +#include +#include + +#include + +namespace dusk::menu_pointer { +namespace { +State s_state; +bool s_clickConsumed = false; +Context s_lastContext = Context::None; +Context s_currentContext = Context::None; +u8 s_lastDialogChoice = 0xFF; +u8 s_currentDialogChoice = 0xFF; +bool s_lastDialogChoiceValid = false; +bool s_currentDialogChoiceValid = false; +bool s_lastDialogClicked = false; +bool s_currentDialogClicked = false; +bool s_mouseActive = false; +bool s_mouseButtonCaptured = false; +s32 s_mouseButton = -1; +u32 s_suppressedPadHoldMask = 0; +u32 s_suppressedPadNextReadMask = 0; +Context s_deferredActivationContext = Context::None; +u8 s_deferredActivationTarget = 0xFF; + +s32 scancode_from_rml_button(s32 button) noexcept { + switch (button) { + case 0: + return PAD_KEY_MOUSE_LEFT; + case 1: + return PAD_KEY_MOUSE_RIGHT; + case 2: + return PAD_KEY_MOUSE_MIDDLE; + default: + return PAD_KEY_INVALID; + } +} + +bool is_mouse_scancode(s32 scancode) noexcept { + return scancode >= PAD_KEY_MOUSE_X2 && scancode <= PAD_KEY_MOUSE_LEFT; +} + +PADButton pad_button_for_scancode(u32 port, s32 scancode) noexcept { + u32 count = 0; + PADKeyButtonBinding* bindings = PADGetKeyButtonBindings(port, &count); + if (bindings == nullptr) { + return 0; + } + + for (u32 i = 0; i < count; ++i) { + if (bindings[i].scancode == scancode) { + return bindings[i].padButton; + } + } + + return 0; +} + +s32 menu_confirm_mouse_scancode() noexcept { + constexpr u32 port = PAD_CHAN0; + u32 count = 0; + PADKeyButtonBinding* bindings = PADGetKeyButtonBindings(port, &count); + if (bindings == nullptr) { + return PAD_KEY_MOUSE_LEFT; + } + + for (u32 i = 0; i < count; ++i) { + if (bindings[i].padButton == PAD_BUTTON_A && is_mouse_scancode(bindings[i].scancode)) { + return bindings[i].scancode; + } + } + + return pad_button_for_scancode(port, PAD_KEY_MOUSE_LEFT) != 0 ? PAD_KEY_INVALID : + PAD_KEY_MOUSE_LEFT; +} + +bool mouse_button_is_menu_confirm(s32 button) noexcept { + const s32 scancode = scancode_from_rml_button(button); + return scancode != PAD_KEY_INVALID && scancode == menu_confirm_mouse_scancode(); +} + +void suppress_pad_for_mouse_button(s32 button, bool held) noexcept { + const s32 scancode = scancode_from_rml_button(button); + if (scancode == PAD_KEY_INVALID) { + return; + } + + const PADButton padButton = pad_button_for_scancode(PAD_CHAN0, scancode); + if (padButton == 0) { + return; + } + + s_suppressedPadNextReadMask |= padButton; + if (held) { + s_suppressedPadHoldMask |= padButton; + } else { + s_suppressedPadHoldMask &= ~padButton; + } +} + +void set_position_from_rml(f32 x, f32 y) noexcept { + auto* context = aurora::rmlui::get_context(); + if (context == nullptr) { + return; + } + + const auto dimensions = context->GetDimensions(); + const f32 width = std::max(static_cast(dimensions.x), 1.0f); + const f32 height = std::max(static_cast(dimensions.y), 1.0f); + + s_state.x = mDoGph_gInf_c::getMinXF() + x / width * mDoGph_gInf_c::getWidthF(); + s_state.y = mDoGph_gInf_c::getMinYF() + y / height * mDoGph_gInf_c::getHeightF(); + s_state.valid = true; +} + +void clear_input_state() noexcept { + s_state = {}; + s_clickConsumed = false; + s_lastDialogChoice = 0xFF; + s_currentDialogChoice = 0xFF; + s_lastDialogChoiceValid = false; + s_currentDialogChoiceValid = false; + s_lastDialogClicked = false; + s_currentDialogClicked = false; + s_mouseActive = false; + s_mouseButtonCaptured = false; + s_mouseButton = -1; + s_suppressedPadHoldMask = 0; + s_suppressedPadNextReadMask = 0; + s_deferredActivationContext = Context::None; + s_deferredActivationTarget = 0xFF; +} + +} // namespace + +bool handle_fallthrough_pointer(f32 x, f32 y, Phase phase, bool touch, s32 mouseButton) noexcept { + if (!enabled()) { + return false; + } + + s_clickConsumed = false; + + if (!touch) { + if (phase == Phase::Press) { + if (!mouse_button_is_menu_confirm(mouseButton)) { + return false; + } + s_mouseButtonCaptured = true; + s_mouseButton = mouseButton; + suppress_pad_for_mouse_button(mouseButton, true); + } else if (phase == Phase::Release) { + if (!s_mouseButtonCaptured || s_mouseButton != mouseButton) { + return false; + } + suppress_pad_for_mouse_button(mouseButton, false); + s_mouseButtonCaptured = false; + s_mouseButton = -1; + } else if (phase == Phase::Cancel) { + if (s_mouseButtonCaptured) { + suppress_pad_for_mouse_button(s_mouseButton, false); + s_mouseButtonCaptured = false; + s_mouseButton = -1; + } else if (!s_mouseActive) { + return false; + } + } + s_mouseActive = true; + } + + if (phase != Phase::Cancel) { + set_position_from_rml(x, y); + } + s_state.touch = touch; + + switch (phase) { + case Phase::Press: + s_state.down = true; + s_state.pressed = true; + break; + case Phase::Release: + s_state.down = false; + s_state.released = true; + s_state.clicked = true; + break; + case Phase::Cancel: + s_state.down = false; + break; + case Phase::Move: + default: + break; + } + + return true; +} + +void begin_game_frame() noexcept { + s_currentContext = Context::None; + s_currentDialogChoice = 0xFF; + s_currentDialogChoiceValid = false; + s_currentDialogClicked = false; + s_clickConsumed = false; + if (!enabled()) { + clear_input_state(); + } +} + +void end_game_frame() noexcept { + s_lastContext = s_currentContext; + s_lastDialogChoice = s_currentDialogChoice; + s_lastDialogChoiceValid = s_currentDialogChoiceValid; + s_lastDialogClicked = s_currentDialogClicked; + s_state.pressed = false; + s_state.released = false; + s_state.clicked = false; + if (!s_state.down) { + s_state.valid = false; + } + s_clickConsumed = false; +} + +void begin_context(Context context) noexcept { + if (context == Context::None) { + return; + } + + if (s_lastContext == Context::None && s_currentContext == Context::None) { + s_state = {}; + s_mouseActive = false; + s_mouseButtonCaptured = false; + s_mouseButton = -1; + s_suppressedPadHoldMask = 0; + s_suppressedPadNextReadMask = 0; + s_deferredActivationContext = Context::None; + s_deferredActivationTarget = 0xFF; + } + + s_currentContext = context; +} + +bool active() noexcept { + return s_currentContext != Context::None || s_lastContext != Context::None; +} + +bool enabled() noexcept { + return getSettings().game.enableMenuPointer.getValue(); +} + +bool mouse_capture_active() noexcept { + return enabled() && s_mouseButtonCaptured; +} + +const State& state() noexcept { + return s_state; +} + +bool consume_click() noexcept { + if (!s_state.clicked || s_clickConsumed) { + return false; + } + + s_clickConsumed = true; + return true; +} + +void set_dialog_choice(u8 choice, bool clicked) noexcept { + s_currentDialogChoice = choice; + s_currentDialogChoiceValid = true; + s_currentDialogClicked = clicked; +} + +bool get_dialog_choice(u8& choice) noexcept { + if (s_currentDialogChoiceValid) { + choice = s_currentDialogChoice; + return true; + } + if (s_lastDialogChoiceValid) { + choice = s_lastDialogChoice; + return true; + } + return false; +} + +bool consume_dialog_click(u8& choice) noexcept { + if (s_currentDialogChoiceValid && s_currentDialogClicked) { + choice = s_currentDialogChoice; + s_currentDialogClicked = false; + return true; + } + if (s_lastDialogChoiceValid && s_lastDialogClicked) { + choice = s_lastDialogChoice; + s_lastDialogClicked = false; + return true; + } + return false; +} + +void defer_activation(Context context, u8 target) noexcept { + s_deferredActivationContext = context; + s_deferredActivationTarget = target; +} + +bool consume_deferred_activation(Context context, u8 target) noexcept { + if (s_deferredActivationContext != context || s_deferredActivationTarget != target) { + return false; + } + + s_deferredActivationContext = Context::None; + s_deferredActivationTarget = 0xFF; + return true; +} + +void clear_deferred_activation(Context context) noexcept { + if (s_deferredActivationContext != context) { + return; + } + + s_deferredActivationContext = Context::None; + s_deferredActivationTarget = 0xFF; +} + +u32 suppressed_pad_buttons(u32 port) noexcept { + if (port != PAD_CHAN0) { + return 0; + } + + return s_suppressedPadHoldMask | s_suppressedPadNextReadMask; +} + +void finish_pad_suppression_read(u32 port) noexcept { + if (port != PAD_CHAN0) { + return; + } + + s_suppressedPadNextReadMask = 0; +} + +bool hit_rect(f32 left, f32 top, f32 right, f32 bottom, f32 padding) noexcept { + const auto& state = menu_pointer::state(); + if (!state.valid) { + return false; + } + + if (left > right) { + std::swap(left, right); + } + if (top > bottom) { + std::swap(top, bottom); + } + + return state.x >= left - padding && state.x <= right + padding && state.y >= top - padding && + state.y <= bottom + padding; +} + +bool hit_pane(CPaneMgr* pane, f32 padding) noexcept { + if (pane == nullptr || pane->getPanePtr() == nullptr) { + return false; + } + + Mtx mtx; + Vec v0 = pane->getGlobalVtx(&mtx, 0, false, 0); + Vec v1 = pane->getGlobalVtx(&mtx, 1, false, 0); + Vec v2 = pane->getGlobalVtx(&mtx, 2, false, 0); + Vec v3 = pane->getGlobalVtx(&mtx, 3, false, 0); + const f32 left = std::min({v0.x, v1.x, v2.x, v3.x}); + const f32 right = std::max({v0.x, v1.x, v2.x, v3.x}); + const f32 top = std::min({v0.y, v1.y, v2.y, v3.y}); + const f32 bottom = std::max({v0.y, v1.y, v2.y, v3.y}); + return hit_rect(left, top, right, bottom, padding); +} + +bool hit_pane(J2DPane* pane, f32 padding) noexcept { + if (pane == nullptr || !pane->isVisible()) { + return false; + } + + const JGeometry::TBox2& bounds = pane->getBounds(); + return hit_rect(bounds.i.x, bounds.i.y, bounds.f.x, bounds.f.y, padding); +} + +} // namespace dusk::menu_pointer diff --git a/src/dusk/mouse.cpp b/src/dusk/mouse.cpp index 8ebfaf60cb..f1aa5672fd 100644 --- a/src/dusk/mouse.cpp +++ b/src/dusk/mouse.cpp @@ -1,4 +1,5 @@ #include "dusk/mouse.h" +#include "dusk/menu_pointer.h" #include "dusk/settings.h" #include "dusk/ui/ui.hpp" #include "d/actor/d_a_alink.h" @@ -26,16 +27,7 @@ void reset_deltas() { } bool queryMouseAimContext() { - if (!getSettings().game.enableMouseAim) { - return false; - } - - daAlink_c* link = daAlink_getAlinkActorClass(); - if (link == nullptr) { - return false; - } - - return link->checkAimContext() && dComIfGp_checkCameraAttentionStatus(link->field_0x317c, 0x10); + return getSettings().game.enableMouseAim.getValue() && dCamera_c::isAimActive(); } bool wantMouseCapture() { @@ -50,7 +42,7 @@ bool isWindowFocused(SDL_Window* window) { } bool shouldCaptureMouse(SDL_Window* window) { - if (window == nullptr || ui::any_document_visible()) { + if (window == nullptr || ui::any_document_visible() || menu_pointer::active()) { return false; } return wantMouseCapture() && isWindowFocused(window); diff --git a/src/dusk/settings.cpp b/src/dusk/settings.cpp index 74145e32ea..37585c5190 100644 --- a/src/dusk/settings.cpp +++ b/src/dusk/settings.cpp @@ -95,6 +95,9 @@ UserSettings g_userSettings = { .mouseCameraSensitivity {"game.mouseCameraSensitivity", 1.0f}, .invertMouseY {"game.invertMouseY", false}, .freeCamera {"game.freeCamera", false}, + .enableTouchControls {"game.enableTouchControls", false}, + .enableMenuPointer {"game.enableMenuPointer", true}, + .touchControlsLayout {"game.touchControlsLayout", ui::ControlLayout{}}, .invertCameraXAxis {"game.invertCameraXAxis", false}, .invertCameraYAxis {"game.invertCameraYAxis", false}, .invertFirstPersonXAxis {"game.invertFirstPersonXAxis", false}, @@ -103,6 +106,8 @@ UserSettings g_userSettings = { .invertAirSwimY {"game.invertAirSwimY", false}, .freeCameraXSensitivity {"game.freeCameraXSensitivity", 1.0f}, .freeCameraYSensitivity {"game.freeCameraYSensitivity", 1.0f}, + .touchCameraXSensitivity {"game.touchCameraXSensitivity", 1.0f}, + .touchCameraYSensitivity {"game.touchCameraYSensitivity", 1.0f}, .debugFlyCam {"game.debugFlyCam", false}, .debugFlyCamLockEvents {"game.debugFlyCamLockEvents", true}, .allowBackgroundInput {"game.allowBackgroundInput", true}, @@ -176,6 +181,18 @@ UserSettings g_userSettings = { ActionBindConfigVar{"actionBindings.callMidna_port2", PAD_NATIVE_BUTTON_INVALID}, ActionBindConfigVar{"actionBindings.callMidna_port3", PAD_NATIVE_BUTTON_INVALID}, }, + .openMapScreen { + ActionBindConfigVar{"actionBindings.openMapScreen_port0", PAD_NATIVE_BUTTON_INVALID}, + ActionBindConfigVar{"actionBindings.openMapScreen_port1", PAD_NATIVE_BUTTON_INVALID}, + ActionBindConfigVar{"actionBindings.openMapScreen_port2", PAD_NATIVE_BUTTON_INVALID}, + ActionBindConfigVar{"actionBindings.openMapScreen_port3", PAD_NATIVE_BUTTON_INVALID}, + }, + .toggleMinimap { + ActionBindConfigVar{"actionBindings.toggleMinimap_port0", PAD_NATIVE_BUTTON_INVALID}, + ActionBindConfigVar{"actionBindings.toggleMinimap_port1", PAD_NATIVE_BUTTON_INVALID}, + ActionBindConfigVar{"actionBindings.toggleMinimap_port2", PAD_NATIVE_BUTTON_INVALID}, + ActionBindConfigVar{"actionBindings.toggleMinimap_port3", PAD_NATIVE_BUTTON_INVALID}, + }, .openDusklightMenu { ActionBindConfigVar{"actionBindings.openDusklightMenu_port0", PAD_NATIVE_BUTTON_INVALID}, ActionBindConfigVar{"actionBindings.openDusklightMenu_port1", PAD_NATIVE_BUTTON_INVALID}, @@ -246,6 +263,8 @@ void registerSettings() { Register(g_userSettings.game.invertAirSwimY); Register(g_userSettings.game.freeCameraXSensitivity); Register(g_userSettings.game.freeCameraYSensitivity); + Register(g_userSettings.game.touchCameraXSensitivity); + Register(g_userSettings.game.touchCameraYSensitivity); Register(g_userSettings.game.minimalHUD); Register(g_userSettings.game.hudScale); Register(g_userSettings.game.pauseOnFocusLost); @@ -310,6 +329,9 @@ void registerSettings() { Register(g_userSettings.game.mouseCameraSensitivity); Register(g_userSettings.game.invertMouseY); Register(g_userSettings.game.freeCamera); + Register(g_userSettings.game.enableTouchControls); + Register(g_userSettings.game.enableMenuPointer); + Register(g_userSettings.game.touchControlsLayout); Register(g_userSettings.game.debugFlyCam); Register(g_userSettings.game.debugFlyCamLockEvents); Register(g_userSettings.game.allowBackgroundInput); @@ -337,6 +359,14 @@ void registerSettings() { Register(g_userSettings.actionBindings.callMidna[1]); Register(g_userSettings.actionBindings.callMidna[2]); Register(g_userSettings.actionBindings.callMidna[3]); + Register(g_userSettings.actionBindings.openMapScreen[0]); + Register(g_userSettings.actionBindings.openMapScreen[1]); + Register(g_userSettings.actionBindings.openMapScreen[2]); + Register(g_userSettings.actionBindings.openMapScreen[3]); + Register(g_userSettings.actionBindings.toggleMinimap[0]); + Register(g_userSettings.actionBindings.toggleMinimap[1]); + Register(g_userSettings.actionBindings.toggleMinimap[2]); + Register(g_userSettings.actionBindings.toggleMinimap[3]); Register(g_userSettings.actionBindings.openDusklightMenu[0]); Register(g_userSettings.actionBindings.openDusklightMenu[1]); Register(g_userSettings.actionBindings.openDusklightMenu[2]); diff --git a/src/dusk/touch_camera.cpp b/src/dusk/touch_camera.cpp new file mode 100644 index 0000000000..6f48d22c38 --- /dev/null +++ b/src/dusk/touch_camera.cpp @@ -0,0 +1,26 @@ +#include "dusk/touch_camera.h" + +namespace dusk::touch_camera { +namespace { +float s_yaw_dp = 0.0f; +float s_pitch_dp = 0.0f; +} // namespace + +void add_delta(float yaw_dp, float pitch_dp) noexcept { + s_yaw_dp += yaw_dp; + s_pitch_dp += pitch_dp; +} + +bool consume_delta(float& yaw_dp, float& pitch_dp) noexcept { + yaw_dp = s_yaw_dp; + pitch_dp = s_pitch_dp; + clear(); + return yaw_dp != 0.0f || pitch_dp != 0.0f; +} + +void clear() noexcept { + s_yaw_dp = 0.0f; + s_pitch_dp = 0.0f; +} + +} // namespace dusk::touch_camera diff --git a/src/dusk/ui/button.hpp b/src/dusk/ui/button.hpp index bf97a66e6e..f2c9756c98 100644 --- a/src/dusk/ui/button.hpp +++ b/src/dusk/ui/button.hpp @@ -19,8 +19,6 @@ public: void set_text(const Rml::String& text); Button& on_pressed(ButtonCallback callback); - const Rml::String& get_text() const { return mProps.text; } - private: void update_props(Props props); diff --git a/src/dusk/ui/controller_config.cpp b/src/dusk/ui/controller_config.cpp index 20e9ef62d5..a506a593a6 100644 --- a/src/dusk/ui/controller_config.cpp +++ b/src/dusk/ui/controller_config.cpp @@ -861,6 +861,20 @@ void ControllerConfigWindow::render_page(Pane& pane, int port, Page page) { break; } case Page::Rumble: { + if (PADCanForceDeviceRumble(static_cast(port))) { + pane.add_child(BoolButton::Props{ + .key = "Use Device Haptics", + .getValue = [port] { return PADGetForceDeviceRumble(static_cast(port)); }, + .setValue = + [port](bool value) { + PADSetForceDeviceRumble(static_cast(port), value ? TRUE : FALSE); + PADSerializeMappings(); + }, + .isDisabled = [this] { return mRumbleTestActive; }, + }); + pane.add_text("Use native device haptics instead of controller rumble. " + "Useful for devices with built-in gamepads."); + } auto& rumbleTest = pane.add_select_button({ .key = "Test Rumble", .getValue = diff --git a/src/dusk/ui/controls.hpp b/src/dusk/ui/controls.hpp new file mode 100644 index 0000000000..2a83d6127f --- /dev/null +++ b/src/dusk/ui/controls.hpp @@ -0,0 +1,192 @@ +#pragma once + +#include + +namespace dusk::ui { + +struct EquipTarget { + float left = 0.0f; + float top = 0.0f; + float width = 0.0f; + float height = 0.0f; + bool valid = false; +}; + +enum class Control { + A, + B, + X, + Y, + Z, + L, + R, + FIRST_PERSON, + ITEMS, + COLLECTIONS, + MAP, + SKIP, + DPAD_UP, + DPAD_DOWN, + DPAD_LEFT, + DPAD_RIGHT, + COUNT, +}; + +enum class ControlAnchor : u8 { + None, + Top, + Left, + Bottom, + Right, + TopLeft, + TopRight, + BottomLeft, + BottomRight, +}; + +struct ControlProps { + float x = 0.0f; + float y = 0.0f; + float w = 0.0f; + float h = 0.0f; + float scale = 1.0f; + ControlAnchor anchor = ControlAnchor::None; +}; + +struct ControlRect { + float l = 0.0f; + float t = 0.0f; + float w = 0.0f; + float h = 0.0f; +}; + +struct ResolvedControlLayout { + ControlRect visual; + ControlRect box; + float scale = 1.0f; +}; + +struct ControlLayoutSize { + float w = 0.0f; + float h = 0.0f; +}; + +struct ControlLayout { + static constexpr int Version = 1; + + int version = Version; + std::map > controls; +}; + +constexpr std::array kControlLayoutIds = { + "actionBar", + "buttonA", + "buttonB", + "buttonX", + "buttonY", + "buttonZ", + "skip", + "triggerL", + "triggerR", +}; + +constexpr bool is_control_layout_id(std::string_view id) noexcept { + for (const auto knownId : kControlLayoutIds) { + if (id == knownId) { + return true; + } + } + return false; +} + +constexpr ControlRect resolve_anchored_rect( + ControlAnchor anchor, float x, float y, float w, float h, ControlLayoutSize docSize) noexcept { + switch (anchor) { + case ControlAnchor::None: + return {x * docSize.w - w * 0.5f, y * docSize.h - h * 0.5f, w, h}; + case ControlAnchor::Top: + return {x * docSize.w - w * 0.5f, y, w, h}; + case ControlAnchor::Bottom: + return {x * docSize.w - w * 0.5f, docSize.h - y - h, w, h}; + case ControlAnchor::Left: + return {x, y * docSize.h - h * 0.5f, w, h}; + case ControlAnchor::Right: + return {docSize.w - x - w, y * docSize.h - h * 0.5f, w, h}; + case ControlAnchor::TopLeft: + return {x, y, w, h}; + case ControlAnchor::TopRight: + return {docSize.w - x - w, y, w, h}; + case ControlAnchor::BottomLeft: + return {x, docSize.h - y - h, w, h}; + case ControlAnchor::BottomRight: + return {docSize.w - x - w, docSize.h - y - h, w, h}; + } + return {}; +} + +constexpr ResolvedControlLayout resolve_control_layout( + ControlProps props, ControlLayoutSize docSize) noexcept { + const float visualW = props.w * props.scale; + const float visualH = props.h * props.scale; + const ControlRect visual = + resolve_anchored_rect(props.anchor, props.x, props.y, visualW, visualH, docSize); + const ControlRect box = { + visual.l + (visual.w - props.w) * 0.5f, + visual.t + (visual.h - props.h) * 0.5f, + props.w, + props.h, + }; + return { + .visual = visual, + .box = box, + .scale = props.scale, + }; +} + +constexpr ControlProps encode_control_props(ControlRect visual, ControlLayoutSize docSize, + ControlProps props, ControlAnchor anchor) noexcept { + props.anchor = anchor; + + switch (anchor) { + case ControlAnchor::None: + props.x = (visual.l + visual.w * 0.5f) / docSize.w; + props.y = (visual.t + visual.h * 0.5f) / docSize.h; + break; + case ControlAnchor::Top: + props.x = (visual.l + visual.w * 0.5f) / docSize.w; + props.y = visual.t; + break; + case ControlAnchor::Bottom: + props.x = (visual.l + visual.w * 0.5f) / docSize.w; + props.y = docSize.h - visual.t - visual.h; + break; + case ControlAnchor::Left: + props.x = visual.l; + props.y = (visual.t + visual.h * 0.5f) / docSize.h; + break; + case ControlAnchor::Right: + props.x = docSize.w - visual.l - visual.w; + props.y = (visual.t + visual.h * 0.5f) / docSize.h; + break; + case ControlAnchor::TopLeft: + props.x = visual.l; + props.y = visual.t; + break; + case ControlAnchor::TopRight: + props.x = docSize.w - visual.l - visual.w; + props.y = visual.t; + break; + case ControlAnchor::BottomLeft: + props.x = visual.l; + props.y = docSize.h - visual.t - visual.h; + break; + case ControlAnchor::BottomRight: + props.x = docSize.w - visual.l - visual.w; + props.y = docSize.h - visual.t - visual.h; + break; + } + + return props; +} + +} // namespace dusk::ui diff --git a/src/dusk/ui/document.cpp b/src/dusk/ui/document.cpp index a7bcc3f9ed..fc8be92a83 100644 --- a/src/dusk/ui/document.cpp +++ b/src/dusk/ui/document.cpp @@ -5,6 +5,7 @@ #include "Z2AudioLib/Z2SeMgr.h" #include "m_Do/m_Do_audio.h" +#include namespace dusk::ui { namespace { @@ -19,11 +20,15 @@ Rml::ElementDocument* load_document(const Rml::String& source) { } // namespace -Document::Document(const Rml::String& source) : mDocument(load_document(source)) { +Document::Document(const Rml::String& source, bool passive) + : mDocument(load_document(source)), mPassive(passive) { // Block events while hidden (except for Menu command); play nav sounds when visible listen( Rml::EventId::Keydown, [this](Rml::Event& event) { + if (mPassive) { + return; + } const auto cmd = map_nav_event(event); if (cmd != NavCommand::Menu && !visible()) { event.StopImmediatePropagation(); @@ -40,11 +45,14 @@ Document::Document(const Rml::String& source) : mDocument(load_document(source)) listen(Rml::EventId::Scroll, blockUnlessVisible, true); listen(Rml::EventId::Keydown, [this](Rml::Event& event) { - const auto cmd = map_nav_event(event); - if (cmd == NavCommand::None) { + if (mPassive) { + auto* doc = top_document(); + if (doc != nullptr && doc->handle_nav_event(event)) { + event.StopPropagation(); + } return; } - if (handle_nav_command(event, cmd)) { + if (handle_nav_event(event)) { event.StopPropagation(); } }); @@ -97,6 +105,18 @@ void Document::listen(Rml::Element* element, Rml::EventId event, std::make_unique(element, event, std::move(callback), capture)); } +void Document::listen(Rml::Element* element, const Rml::String& event, + ScopedEventListener::Callback callback, bool capture) { + if (element == nullptr) { + element = mDocument; + } + if (element == nullptr || event.empty() || !callback) { + return; + } + mListeners.emplace_back( + std::make_unique(element, event, std::move(callback), capture)); +} + bool Document::visible() const { if (mDocument == nullptr) { return false; @@ -104,6 +124,14 @@ bool Document::visible() const { return *mDocument->GetProperty(Rml::PropertyId::Visibility) == Rml::Style::Visibility::Visible; } +bool Document::handle_nav_event(Rml::Event& event) { + const auto cmd = map_nav_event(event); + if (cmd == NavCommand::None) { + return false; + } + return handle_nav_command(event, cmd); +} + bool Document::handle_nav_command(Rml::Event& event, NavCommand cmd) { if (cmd == NavCommand::Menu) { mDoAud_seStartMenu(visible() ? kSoundMenuClose : kSoundMenuOpen); diff --git a/src/dusk/ui/document.hpp b/src/dusk/ui/document.hpp index d0f4cae841..cf61267ee9 100644 --- a/src/dusk/ui/document.hpp +++ b/src/dusk/ui/document.hpp @@ -7,7 +7,7 @@ namespace dusk::ui { class Document { public: - Document(const Rml::String& source); + explicit Document(const Rml::String& source, bool passive = false); virtual ~Document(); Document(const Document&) = delete; @@ -21,9 +21,15 @@ public: void listen(Rml::Element* element, Rml::EventId event, ScopedEventListener::Callback callback, bool capture = false); + void listen(Rml::Element* element, const Rml::String& event, + ScopedEventListener::Callback callback, bool capture = false); void listen(Rml::EventId event, ScopedEventListener::Callback callback, bool capture = false) { listen(mDocument, event, std::move(callback), capture); } + void listen( + const Rml::String& event, ScopedEventListener::Callback callback, bool capture = false) { + listen(mDocument, event, std::move(callback), capture); + } void toggle() { if (visible()) { hide(false); @@ -43,6 +49,8 @@ public: bool pending_close() const { return mPendingClose; } bool closed() const { return mClosed; } + bool handle_nav_event(Rml::Event& event); + protected: virtual bool handle_nav_command(Rml::Event& event, NavCommand cmd); @@ -50,6 +58,7 @@ protected: std::vector > mListeners; bool mPendingClose = false; bool mClosed = false; + bool mPassive = false; }; } // namespace dusk::ui diff --git a/src/dusk/ui/icon_provider.cpp b/src/dusk/ui/icon_provider.cpp new file mode 100644 index 0000000000..b8307b92f7 --- /dev/null +++ b/src/dusk/ui/icon_provider.cpp @@ -0,0 +1,899 @@ +#include "icon_provider.hpp" + +#include "d/dolzel.h" // IWYU pragma: keep + +#ifdef AURORA_ENABLE_RMLUI + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "JSystem/J2DGraph/J2DPicture.h" +#include "JSystem/JUtility/JUTTexture.h" +#include "d/actor/d_a_alink.h" +#include "d/d_com_inf_game.h" +#include "d/d_item_data.h" +#include "d/d_meter2_info.h" +#include "d/d_pane_class.h" + +namespace dusk::ui { +namespace { + +constexpr std::string_view kScheme = "item"; +constexpr std::string_view kSourcePrefix = "item://"; +constexpr std::string_view kMeterScheme = "meter"; +constexpr std::string_view kMeterSourcePrefix = "meter://"; +constexpr size_t kItemTextureBufferSize = 0xC00; +constexpr size_t kMaxCachedIcons = 128; +constexpr uint64_t kMeterTextureSourceSlots = 8; +constexpr uint32_t kMinRenderedPaneIconSize = 128; +constexpr uint32_t kMaxRenderedPaneIconSize = 1024; + +struct alignas(32) ItemTextureBuffer { + std::array bytes{}; + + std::byte* data() noexcept { return bytes.data(); } + const std::byte* data() const noexcept { return bytes.data(); } +}; + +struct CachedIcon { + std::vector pixels; + uint32_t width = 0; + uint32_t height = 0; +}; + +struct RuntimeIconState { + CachedIcon icon; + uint64_t revision = 0; + bool valid = false; +}; + +struct LayerColors { + JUtility::TColor black; + JUtility::TColor white; + std::array corner; +}; + +struct RectF { + float left = std::numeric_limits::max(); + float top = std::numeric_limits::max(); + float right = std::numeric_limits::lowest(); + float bottom = std::numeric_limits::lowest(); + + bool valid() const noexcept { return left < right && top < bottom; } + float width() const noexcept { return right - left; } + float height() const noexcept { return bottom - top; } + + void include(float x, float y) noexcept { + if (!std::isfinite(x) || !std::isfinite(y)) { + return; + } + left = std::min(left, x); + top = std::min(top, y); + right = std::max(right, x); + bottom = std::max(bottom, y); + } + + void include(const RectF& rect) noexcept { + if (!rect.valid()) { + return; + } + include(rect.left, rect.top); + include(rect.right, rect.bottom); + } +}; + +struct PictureLayer { + J2DPicture* picture = nullptr; + RectF rect; + uint8_t alpha = 0; +}; + +struct SurfaceDeleter { + void operator()(SDL_Surface* surface) const noexcept { SDL_DestroySurface(surface); } +}; + +using SurfacePtr = std::unique_ptr; + +std::unordered_map& icon_cache() { + static auto* cache = new std::unordered_map(); + return *cache; +} + +RuntimeIconState& midna_icon_state() { + static auto* state = new RuntimeIconState(); + return *state; +} + +std::string_view strip_query(std::string_view path) noexcept { + const auto queryPos = path.find_first_of("?#"); + if (queryPos != std::string_view::npos) { + path = path.substr(0, queryPos); + } + return path; +} + +std::optional parse_item_no(std::string_view text) noexcept { + if (text.starts_with("0x") || text.starts_with("0X")) { + text.remove_prefix(2); + } + unsigned value = 0; + const auto* first = text.data(); + const auto* last = text.data() + text.size(); + const auto [ptr, ec] = std::from_chars(first, last, value, 16); + if (ec != std::errc() || ptr != last || value > 0xFF) { + return std::nullopt; + } + return static_cast(value); +} + +bool is_valid_icon_item(u8 itemNo) noexcept { + return itemNo != 0 && itemNo != dItemNo_NONE_e; +} + +u8 item_icon_texture_item(u8 itemNo) noexcept { + if (itemNo == dItemNo_LIGHT_ARROW_e) { + return dItemNo_BOW_e; + } + return itemNo; +} + +std::optional selected_slot_item(int slot) noexcept { + const u8 itemNo = dComIfGp_getSelectItem(slot); + if (!is_valid_icon_item(itemNo)) { + return std::nullopt; + } + return item_icon_texture_item(itemNo); +} + +bool is_sword_item(u8 itemNo) noexcept { + switch (itemNo) { + case dItemNo_WOOD_STICK_e: + case dItemNo_SWORD_e: + case dItemNo_MASTER_SWORD_e: + case dItemNo_LIGHT_SWORD_e: + return true; + default: + return false; + } +} + +std::optional b_button_item() noexcept { + const u8 action = dComIfGp_getAStatus(); + if (action == 0x26 || action == 0x2E) { + const u8 sword = dComIfGs_getSelectEquipSword(); + if (is_sword_item(sword)) { + return sword; + } + return std::nullopt; + } + if (action == 0x4F) { + return dItemNo_LURE_ROD_e; + } + return std::nullopt; +} + +std::optional item_for_source(std::string_view source) noexcept { + if (!source.starts_with(kSourcePrefix)) { + return std::nullopt; + } + + std::string_view path = strip_query(source.substr(kSourcePrefix.size())); + if (path.starts_with("item/")) { + path.remove_prefix(5); + const auto itemNo = parse_item_no(path); + if (itemNo && is_valid_icon_item(*itemNo)) { + return item_icon_texture_item(*itemNo); + } + return std::nullopt; + } + if (path == "slot/x") { + return selected_slot_item(0); + } + if (path == "slot/y") { + return selected_slot_item(1); + } + if (path == "button/b") { + return b_button_item(); + } + return std::nullopt; +} + +uint32_t item_revision(u8 itemNo) noexcept { + uint32_t revision = itemNo; + revision = revision * 131u + g_meter2_info.getItemType(itemNo); + + if (itemNo == dItemNo_KANTERA_e || itemNo == dItemNo_KANTERA2_e) { + revision = revision * 131u + (dComIfGs_getOil() == 0 ? 0u : 1u); + } + if (itemNo == dItemNo_COPY_ROD_e) { + auto* player = daPy_getPlayerActorClass(); + revision = revision * 131u + (player != nullptr && player->checkCopyRodTopUse() ? 1u : 0u); + } + return revision; +} + +std::string item_source_for_item(u8 itemNo) { + itemNo = item_icon_texture_item(itemNo); + return fmt::format("{}://item/{:02x}?rev={:08x}", kScheme, itemNo, item_revision(itemNo)); +} + +std::optional selected_slot_count(int slot) noexcept { + const u8 itemNo = dComIfGp_getSelectItem(slot); + if (!is_valid_icon_item(itemNo)) { + return std::nullopt; + } + if (item_icon_texture_item(itemNo) == dItemNo_KANTERA_e || + item_icon_texture_item(itemNo) == dItemNo_KANTERA2_e) + { + return std::nullopt; + } + + int count = 0; + int max = 0; + switch (itemNo) { + case dItemNo_BOW_e: + case dItemNo_LIGHT_ARROW_e: + case dItemNo_ARROW_LV1_e: + case dItemNo_ARROW_LV2_e: + case dItemNo_ARROW_LV3_e: + case dItemNo_HAWK_ARROW_e: + count = dComIfGs_getArrowNum(); + max = dComIfGs_getArrowMax(); + break; + case dItemNo_BOMB_ARROW_e: + count = std::min(dComIfGp_getSelectItemNum(slot), dComIfGs_getArrowNum()); + max = std::max(dComIfGp_getSelectItemMaxNum(slot), dComIfGs_getArrowMax()); + break; + default: + count = dComIfGp_getSelectItemNum(slot); + max = dComIfGp_getSelectItemMaxNum(slot); + break; + } + if (max <= 0) { + return std::nullopt; + } + return std::clamp(count, 0, max); +} + +aurora::gfx::ConvertedTexture decode_timg(const ResTIMG* image) { + if (image == nullptr || image->width.host() == 0 || image->height.host() == 0) { + return {}; + } + + const auto* base = reinterpret_cast(image); + const auto width = image->width.host(); + const auto height = image->height.host(); + const uint32_t textureSize = GXGetTexBufferSize(width, height, image->format, GX_FALSE, 0); + const auto* textureData = base + static_cast(image->imageOffset); + + if (image->indexTexture != 0) { + const auto* paletteData = base + static_cast(image->paletteOffset); + return aurora::gfx::convert_texture_palette(image->format, width, height, 1, + aurora::ArrayRef{textureData, textureSize}, static_cast(image->colorFormat), + image->numColors, + aurora::ArrayRef{paletteData, static_cast(image->numColors) * 2}); + } + + return aurora::gfx::convert_texture( + image->format, width, height, 1, aurora::ArrayRef{textureData, textureSize}); +} + +uint8_t lerp_u8(uint8_t a, uint8_t b, uint32_t t) noexcept { + return static_cast( + (static_cast(a) * (255u - t) + static_cast(b) * t) / 255u); +} + +JUtility::TColor lerp_color( + const JUtility::TColor& a, const JUtility::TColor& b, uint32_t t) noexcept { + return { + lerp_u8(a.r, b.r, t), + lerp_u8(a.g, b.g, t), + lerp_u8(a.b, b.b, t), + lerp_u8(a.a, b.a, t), + }; +} + +JUtility::TColor bilerp_corner( + const LayerColors& colors, uint32_t x, uint32_t y, uint32_t width, uint32_t height) noexcept { + const uint32_t u = width > 1 ? (x * 255u) / (width - 1u) : 0u; + const uint32_t v = height > 1 ? (y * 255u) / (height - 1u) : 0u; + const JUtility::TColor top = lerp_color(colors.corner[0], colors.corner[1], u); + const JUtility::TColor bottom = lerp_color(colors.corner[2], colors.corner[3], u); + return lerp_color(top, bottom, v); +} + +std::array apply_layer_colors(std::span src, + const LayerColors& colors, uint32_t x, uint32_t y, uint32_t width, uint32_t height) noexcept { + std::array out{ + lerp_u8(colors.black.r, colors.white.r, src[0]), + lerp_u8(colors.black.g, colors.white.g, src[1]), + lerp_u8(colors.black.b, colors.white.b, src[2]), + src[3], + }; + + const auto corner = bilerp_corner(colors, x, y, width, height); + out[0] = static_cast((static_cast(out[0]) * corner.r) / 255u); + out[1] = static_cast((static_cast(out[1]) * corner.g) / 255u); + out[2] = static_cast((static_cast(out[2]) * corner.b) / 255u); + out[3] = static_cast((static_cast(out[3]) * corner.a) / 255u); + return out; +} + +void blend_premultiplied(uint8_t* dst, const std::array& src) noexcept { + const uint32_t srcAlpha = src[3]; + const uint32_t invAlpha = 255u - srcAlpha; + const uint32_t srcR = (static_cast(src[0]) * srcAlpha) / 255u; + const uint32_t srcG = (static_cast(src[1]) * srcAlpha) / 255u; + const uint32_t srcB = (static_cast(src[2]) * srcAlpha) / 255u; + + dst[0] = static_cast( + std::min(255u, srcR + (static_cast(dst[0]) * invAlpha) / 255u)); + dst[1] = static_cast( + std::min(255u, srcG + (static_cast(dst[1]) * invAlpha) / 255u)); + dst[2] = static_cast( + std::min(255u, srcB + (static_cast(dst[2]) * invAlpha) / 255u)); + dst[3] = static_cast( + std::min(255u, srcAlpha + (static_cast(dst[3]) * invAlpha) / 255u)); +} + +LayerColors layer_colors(const J2DPicture& picture) noexcept { + return { + .black = picture.getBlack(), + .white = picture.getWhite(), + .corner = {picture.corner(0), picture.corner(1), picture.corner(2), picture.corner(3)}, + }; +} + +LayerColors layer_colors(J2DPicture& picture, uint8_t alpha) noexcept { + std::array corners{}; + picture.getNewColor(corners.data()); + for (auto& corner : corners) { + corner.a = static_cast((static_cast(corner.a) * alpha) / 255u); + } + return { + .black = picture.getBlack(), + .white = picture.getWhite(), + .corner = corners, + }; +} + +std::optional render_item_icon(u8 itemNo) { + std::array buffers{}; + std::array pictures{}; + + const int textureCount = + dMeter2Info_readItemTexture(itemNo, buffers[0].data(), &pictures[0], buffers[1].data(), + &pictures[1], buffers[2].data(), &pictures[2], buffers[3].data(), &pictures[3], -1); + if (textureCount <= 0) { + return std::nullopt; + } + + std::array decodedLayers{}; + std::array colors{}; + int decodedCount = 0; + for (int i = 0; i < textureCount && i < static_cast(decodedLayers.size()); ++i) { + auto decoded = decode_timg(reinterpret_cast(buffers[i].data())); + if (decoded.data.empty()) { + continue; + } + colors[decodedCount] = layer_colors(pictures[i]); + decodedLayers[decodedCount] = std::move(decoded); + ++decodedCount; + } + if (decodedCount == 0) { + return std::nullopt; + } + + CachedIcon icon{ + .width = decodedLayers[0].width, + .height = decodedLayers[0].height, + }; + icon.pixels.assign(static_cast(icon.width) * static_cast(icon.height) * 4, 0); + + for (int layer = 0; layer < decodedCount; ++layer) { + const auto& decoded = decodedLayers[layer]; + for (uint32_t y = 0; y < icon.height; ++y) { + const uint32_t sourceY = decoded.height > 0 ? (y * decoded.height) / icon.height : 0; + for (uint32_t x = 0; x < icon.width; ++x) { + const uint32_t sourceX = decoded.width > 0 ? (x * decoded.width) / icon.width : 0; + const size_t sourceOffset = + (static_cast(sourceY) * decoded.width + static_cast(sourceX)) * + 4; + if (sourceOffset + 3 >= decoded.data.size()) { + continue; + } + + const std::span sourcePixel( + decoded.data.data() + sourceOffset, 4); + const auto pixel = + apply_layer_colors(sourcePixel, colors[layer], x, y, icon.width, icon.height); + uint8_t* destination = + icon.pixels.data() + + (static_cast(y) * icon.width + static_cast(x)) * 4; + blend_premultiplied(destination, pixel); + } + } + } + + return icon; +} + +SurfacePtr create_rgba_surface(uint32_t width, uint32_t height) { + if (width == 0 || height == 0 || + width > static_cast(std::numeric_limits::max()) || + height > static_cast(std::numeric_limits::max())) + { + return {}; + } + + return SurfacePtr{SDL_CreateSurface( + static_cast(width), static_cast(height), SDL_PIXELFORMAT_RGBA32)}; +} + +bool lock_surface(SDL_Surface* surface) noexcept { + return surface != nullptr && (!SDL_MUSTLOCK(surface) || SDL_LockSurface(surface)); +} + +void unlock_surface(SDL_Surface* surface) noexcept { + if (surface != nullptr && SDL_MUSTLOCK(surface)) { + SDL_UnlockSurface(surface); + } +} + +SurfacePtr create_layer_surface( + const aurora::gfx::ConvertedTexture& decoded, const LayerColors& colors) { + if (decoded.width == 0 || decoded.height == 0 || decoded.data.empty()) { + return {}; + } + + auto surface = create_rgba_surface(decoded.width, decoded.height); + if (!surface || !lock_surface(surface.get())) { + return {}; + } + + for (uint32_t y = 0; y < decoded.height; ++y) { + auto* destination = static_cast(surface->pixels) + + static_cast(y) * static_cast(surface->pitch); + for (uint32_t x = 0; x < decoded.width; ++x) { + const size_t sourceOffset = + (static_cast(y) * decoded.width + static_cast(x)) * 4; + if (sourceOffset + 3 >= decoded.data.size()) { + continue; + } + + const std::span sourcePixel(decoded.data.data() + sourceOffset, 4); + const auto pixel = + apply_layer_colors(sourcePixel, colors, x, y, decoded.width, decoded.height); + std::memcpy(destination + static_cast(x) * 4, pixel.data(), pixel.size()); + } + } + + unlock_surface(surface.get()); + SDL_SetSurfaceBlendMode(surface.get(), SDL_BLENDMODE_BLEND); + return surface; +} + +std::optional icon_from_surface(SDL_Surface* surface) { + if (surface == nullptr || surface->w <= 0 || surface->h <= 0) { + return std::nullopt; + } + + CachedIcon icon{ + .width = static_cast(surface->w), + .height = static_cast(surface->h), + }; + const size_t rowSize = static_cast(icon.width) * 4u; + icon.pixels.resize(rowSize * static_cast(icon.height)); + + if (!lock_surface(surface)) { + return std::nullopt; + } + + for (uint32_t y = 0; y < icon.height; ++y) { + const auto* source = static_cast(surface->pixels) + + static_cast(y) * static_cast(surface->pitch); + auto* destination = icon.pixels.data() + static_cast(y) * rowSize; + std::memcpy(destination, source, rowSize); + } + + unlock_surface(surface); + return icon; +} + +RectF pane_global_rect(J2DPane* pane) noexcept { + RectF rect; + CPaneMgr paneMgr; + Mtx m; + for (u8 i = 0; i < 4; ++i) { + const Vec vertex = paneMgr.getGlobalVtx(pane, &m, i, false, 0); + rect.include(vertex.x, vertex.y); + } + return rect; +} + +uint8_t effective_pane_alpha(J2DPane& pane, uint8_t parentAlpha) noexcept { + uint32_t alpha = pane.getAlpha(); + if (pane.isInfluencedAlpha()) { + alpha = alpha * parentAlpha / 255u; + } + return static_cast(alpha); +} + +void collect_picture_layers( + J2DPane* pane, std::vector& layers, uint8_t parentAlpha = 255) noexcept { + if (pane == nullptr || !pane->isVisible()) { + return; + } + + const uint8_t paneAlpha = effective_pane_alpha(*pane, parentAlpha); + if (paneAlpha == 0) { + return; + } + + if (pane->getKind() == MULTI_CHAR('PIC1') || pane->getKind() == MULTI_CHAR('PIC2')) { + auto* picture = static_cast(pane); + if (picture->getTexture(0) != nullptr) { + RectF rect = pane_global_rect(pane); + if (rect.valid()) { + layers.push_back({ + .picture = picture, + .rect = rect, + .alpha = paneAlpha, + }); + } + } + } + + for (J2DPane* child = pane->getFirstChildPane(); child != nullptr; + child = child->getNextChildPane()) + { + collect_picture_layers(child, layers, paneAlpha); + } +} + +std::optional icon_dimension(float value) noexcept { + if (!std::isfinite(value) || value <= 0.0f) { + return std::nullopt; + } + + const auto dimension = static_cast(std::ceil(value)); + if (dimension == 0 || dimension > kMaxRenderedPaneIconSize) { + return std::nullopt; + } + return dimension; +} + +float pane_icon_render_scale(const std::vector& layers, const RectF& canvas) { + float scale = 1.0f; + for (const auto& layer : layers) { + if (layer.picture == nullptr || !layer.rect.valid() || layer.rect.width() <= 0.0f || + layer.rect.height() <= 0.0f) + { + continue; + } + + auto* texture = layer.picture->getTexture(0); + const ResTIMG* image = texture != nullptr ? texture->getTexInfo() : nullptr; + if (image == nullptr || image->width.host() == 0 || image->height.host() == 0) { + continue; + } + + scale = std::max(scale, static_cast(image->width) / layer.rect.width()); + scale = std::max(scale, static_cast(image->height) / layer.rect.height()); + } + + const float canvasMax = std::max(canvas.width(), canvas.height()); + if (canvasMax <= 0.0f) { + return scale; + } + + const float minScale = static_cast(kMinRenderedPaneIconSize) / canvasMax; + const float maxScale = static_cast(kMaxRenderedPaneIconSize) / canvasMax; + return std::clamp(std::max(scale, minScale), 1.0f, maxScale); +} + +void composite_picture_layer( + SDL_Surface& icon, const RectF& canvas, const PictureLayer& layer, float renderScale) { + if (layer.picture == nullptr || !layer.rect.valid()) { + return; + } + + auto* texture = layer.picture->getTexture(0); + if (texture == nullptr) { + return; + } + + auto decoded = decode_timg(texture->getTexInfo()); + if (decoded.data.empty() || decoded.width == 0 || decoded.height == 0) { + return; + } + + const auto colors = layer_colors(*layer.picture, layer.alpha); + auto layerSurface = create_layer_surface(decoded, colors); + if (!layerSurface) { + return; + } + + const float dstLeft = (layer.rect.left - canvas.left) * renderScale; + const float dstTop = (layer.rect.top - canvas.top) * renderScale; + const float dstRight = (layer.rect.right - canvas.left) * renderScale; + const float dstBottom = (layer.rect.bottom - canvas.top) * renderScale; + const float dstWidth = dstRight - dstLeft; + const float dstHeight = dstBottom - dstTop; + if (dstWidth <= 0.0f || dstHeight <= 0.0f) { + return; + } + + const int x0 = std::clamp(static_cast(std::floor(dstLeft)), 0, icon.w); + const int y0 = std::clamp(static_cast(std::floor(dstTop)), 0, icon.h); + const int x1 = std::clamp(static_cast(std::ceil(dstRight)), 0, icon.w); + const int y1 = std::clamp(static_cast(std::ceil(dstBottom)), 0, icon.h); + if (x0 >= x1 || y0 >= y1) { + return; + } + + SDL_Rect destinationRect{ + .x = x0, + .y = y0, + .w = x1 - x0, + .h = y1 - y0, + }; + SDL_BlitSurfaceScaled( + layerSurface.get(), nullptr, &icon, &destinationRect, SDL_SCALEMODE_LINEAR); +} + +std::optional render_j2d_pane_icon(J2DPane* pane) { + std::vector layers; + collect_picture_layers(pane, layers); + if (layers.empty()) { + return std::nullopt; + } + + RectF canvas; + for (const auto& layer : layers) { + canvas.include(layer.rect); + } + if (!canvas.valid()) { + return std::nullopt; + } + + const float renderScale = pane_icon_render_scale(layers, canvas); + auto width = icon_dimension(canvas.width() * renderScale); + auto height = icon_dimension(canvas.height() * renderScale); + if (!width || !height) { + return std::nullopt; + } + + auto surface = create_rgba_surface(*width, *height); + if (!surface) { + return std::nullopt; + } + + for (const auto& layer : layers) { + composite_picture_layer(*surface, canvas, layer, renderScale); + } + + return icon_from_surface(surface.get()); +} + +std::optional icon_provider(std::string_view source) { + const auto itemNo = item_for_source(source); + if (!itemNo) { + return std::nullopt; + } + + auto& cache = icon_cache(); + const std::string key(source); + auto it = cache.find(key); + if (it == cache.end()) { + auto icon = render_item_icon(*itemNo); + if (!icon) { + return std::nullopt; + } + if (cache.size() >= kMaxCachedIcons) { + cache.erase(cache.begin()); + } + it = cache.emplace(key, std::move(*icon)).first; + } + + const auto& icon = it->second; + return aurora::rmlui::RuntimeTexture{ + .width = icon.width, + .height = icon.height, + .rgba8 = + std::span(reinterpret_cast(icon.pixels.data()), icon.pixels.size()), + .premultipliedAlpha = true, + }; +} + +std::optional meter_texture_provider(std::string_view source) { + if (!source.starts_with(kMeterSourcePrefix)) { + return std::nullopt; + } + + const std::string name(strip_query(source.substr(kMeterSourcePrefix.size()))); + if (name != "midna") { + return std::nullopt; + } + + const auto& state = midna_icon_state(); + if (!state.valid) { + return std::nullopt; + } + + return aurora::rmlui::RuntimeTexture{ + .width = state.icon.width, + .height = state.icon.height, + .rgba8 = std::span( + reinterpret_cast(state.icon.pixels.data()), state.icon.pixels.size()), + .premultipliedAlpha = true, + }; +} + +} // namespace + +void register_icon_texture_provider() noexcept { + aurora::rmlui::register_texture_provider(std::string(kScheme), icon_provider); + aurora::rmlui::register_texture_provider(std::string(kMeterScheme), meter_texture_provider); +} + +void unregister_icon_texture_provider() noexcept { + aurora::rmlui::unregister_texture_provider(kScheme); + aurora::rmlui::unregister_texture_provider(kMeterScheme); + icon_cache().clear(); + midna_icon_state() = {}; +} + +void update_midna_icon_texture(J2DPane* pane) noexcept { + auto& state = midna_icon_state(); + if (pane == nullptr || !pane->isVisible()) { + if (state.valid) { + state.valid = false; + state.icon = {}; + state.revision++; + } + return; + } + + auto icon = render_j2d_pane_icon(pane); + if (!icon) { + if (state.valid) { + state.valid = false; + state.icon = {}; + state.revision++; + } + return; + } + + if (!state.valid || state.icon.width != icon->width || state.icon.height != icon->height || + state.icon.pixels != icon->pixels) + { + state.icon = std::move(*icon); + state.valid = true; + state.revision++; + } +} + +std::string midna_icon_source() { + const auto& state = midna_icon_state(); + if (!state.valid) { + return ""; + } + return fmt::format( + "{}://midna?slot={}", kMeterScheme, state.revision % kMeterTextureSourceSlots); +} + +uint64_t midna_icon_revision() noexcept { + const auto& state = midna_icon_state(); + return state.valid ? state.revision : 0; +} + +std::string item_icon_source_for_button(Control control) { + std::optional itemNo; + switch (control) { + case Control::X: + itemNo = selected_slot_item(0); + break; + case Control::Y: + itemNo = selected_slot_item(1); + break; + case Control::B: + itemNo = b_button_item(); + break; + default: + break; + } + if (!itemNo) { + return {}; + } + return item_source_for_item(*itemNo); +} + +std::string item_count_label_for_button(Control control) { + std::optional count; + switch (control) { + case Control::X: + count = selected_slot_count(0); + break; + case Control::Y: + count = selected_slot_count(1); + break; + default: + break; + } + if (!count) { + return {}; + } + return fmt::format("{}", *count); +} + +std::optional item_oil_fill_for_button(Control control) noexcept { + std::optional itemNo; + switch (control) { + case Control::X: + itemNo = selected_slot_item(0); + break; + case Control::Y: + itemNo = selected_slot_item(1); + break; + default: + break; + } + if (!itemNo || (*itemNo != dItemNo_KANTERA_e && *itemNo != dItemNo_KANTERA2_e)) { + return std::nullopt; + } + + const int maxOil = dComIfGs_getMaxOil(); + if (maxOil <= 0) { + return std::nullopt; + } + return std::clamp( + static_cast(dComIfGs_getOil()) / static_cast(maxOil), 0.0f, 1.0f); +} + +} // namespace dusk::ui + +#else + +namespace dusk::ui { + +void register_icon_texture_provider() noexcept {} +void unregister_icon_texture_provider() noexcept {} +void update_midna_icon_texture(J2DPane*) noexcept {} +std::string midna_icon_source() { + return {}; +} +uint64_t midna_icon_revision() noexcept { + return 0; +} +std::string item_icon_source_for_button(Control) { + return {}; +} +std::string item_count_label_for_button(Control) { + return {}; +} +std::optional item_oil_fill_for_button(Control) noexcept { + return std::nullopt; +} + +} // namespace dusk::ui + +#endif diff --git a/src/dusk/ui/icon_provider.hpp b/src/dusk/ui/icon_provider.hpp new file mode 100644 index 0000000000..a77c518992 --- /dev/null +++ b/src/dusk/ui/icon_provider.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include "controls.hpp" + +#include +#include +#include + +class J2DPane; + +namespace dusk::ui { + +void register_icon_texture_provider() noexcept; +void unregister_icon_texture_provider() noexcept; + +void update_midna_icon_texture(J2DPane* pane) noexcept; +std::string midna_icon_source(); +uint64_t midna_icon_revision() noexcept; +std::string item_icon_source_for_button(Control control); +std::string item_count_label_for_button(Control control); +std::optional item_oil_fill_for_button(Control control) noexcept; + +} // namespace dusk::ui diff --git a/src/dusk/ui/overlay.cpp b/src/dusk/ui/overlay.cpp index d6f87b9262..192b0ebf6a 100644 --- a/src/dusk/ui/overlay.cpp +++ b/src/dusk/ui/overlay.cpp @@ -5,6 +5,7 @@ #include "dusk/action_bindings.h" #include "controller_config.hpp" #include "dusk/livesplit.h" +#include "dusk/settings.h" #include "dusk/speedrun.h" #include "fmt/format.h" #include "magic_enum.hpp" @@ -194,7 +195,7 @@ static std::string FormatTime(OSTime ticks) { return fmt::format("{0:02}:{1:02}:{2:02}.{3:03}", t.hour, t.min, t.sec, t.msec); } -Overlay::Overlay() : Document(kDocumentSource) { +Overlay::Overlay() : Document(kDocumentSource, true) { mFpsCounter = mDocument->GetElementById("fps"); mSpeedrunTimer = mDocument->GetElementById("speedrun-timer"); mSpeedrunRta = mDocument->GetElementById("speedrun-rta"); @@ -317,6 +318,7 @@ void Overlay::update() { u32 count = 0; const bool showControllerWarning = PADGetIndexForPort(PAD_CHAN0) < 0 && PADGetKeyButtonBindings(PAD_CHAN0, &count) == nullptr && + !getSettings().game.enableTouchControls && dynamic_cast(top_document()) == nullptr && dynamic_cast(top_document()) == nullptr; if (showControllerWarning && mControllerWarning == nullptr) { diff --git a/src/dusk/ui/preset.cpp b/src/dusk/ui/preset.cpp index 5d1bbcdd28..1a75a27e6b 100644 --- a/src/dusk/ui/preset.cpp +++ b/src/dusk/ui/preset.cpp @@ -21,6 +21,7 @@ void applyPresetClassic() { s.game.shadowResolutionMultiplier.setValue(1); s.game.hideTvSettingsScreen.setValue(false); s.game.menuScalingMode.setValue(MenuScaling::GameCube); + s.game.enableMenuPointer.setValue(false); AuroraSetViewportPolicy(AURORA_VIEWPORT_FIT); } @@ -53,6 +54,7 @@ void applyPresetDusk() { s.game.autoSave.setValue(true); s.game.menuScalingMode.setValue(MenuScaling::Dusklight); s.game.enhancedMapMenus.setValue(true); + s.game.enableMenuPointer.setValue(true); } } // namespace diff --git a/src/dusk/ui/settings.cpp b/src/dusk/ui/settings.cpp index 49757aab20..194ddd78a7 100644 --- a/src/dusk/ui/settings.cpp +++ b/src/dusk/ui/settings.cpp @@ -22,6 +22,7 @@ #include "menu_bar.hpp" #include "pane.hpp" #include "prelaunch.hpp" +#include "touch_controls_editor.hpp" #include "ui.hpp" #include @@ -35,6 +36,17 @@ #include #include +#if defined(__APPLE__) +#include +#endif + +#if defined(TARGET_ANDROID) || defined(__ANDROID__) || \ + (defined(__APPLE__) && TARGET_OS_IOS && !TARGET_OS_MACCATALYST) +#define TOUCH_CONTROLS_AVAILABLE true +#else +#define TOUCH_CONTROLS_AVAILABLE false +#endif + namespace dusk::ui { namespace { @@ -968,6 +980,31 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .onChange = [](bool value) { aurora_set_background_input(value); }, }); +#if TOUCH_CONTROLS_AVAILABLE + leftPane.add_section("Touch"); + addOption("Touch Controls", getSettings().game.enableTouchControls, + "Enables controls overlay for touch screens.

Press and drag on the left side " + "of the screen to move, and on the right side of the screen to control the camera."); + auto& customizeTouchLayout = leftPane.add_button(ControlledButton::Props{ + .text = "Customize Layout", + .isDisabled = [] { return !getSettings().game.enableTouchControls; }, + }); + leftPane.register_control(customizeTouchLayout.on_pressed( + [this] { push(std::make_unique()); }), + rightPane, [](Pane& pane) { + pane.clear(); + pane.add_text("Open the touch controls layout editor."); + }); + config_percent_select(leftPane, rightPane, getSettings().game.touchCameraXSensitivity, + "Touch Camera X Sensitivity", + "Adjusts touch camera horizontal sensitivity.

Applies to touch input only.", + 25, 400, 5, [] { return !getSettings().game.enableTouchControls; }); + config_percent_select(leftPane, rightPane, getSettings().game.touchCameraYSensitivity, + "Touch Camera Y Sensitivity", + "Adjusts touch camera vertical sensitivity.

Applies to touch input only.", 25, + 400, 5, [] { return !getSettings().game.enableTouchControls; }); +#endif + leftPane.add_section("Camera"); addOption("Free Camera", getSettings().game.freeCamera, "Enables free camera control, letting you control the camera fully with the C-Stick."); @@ -1035,6 +1072,8 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { [] { return !getSettings().game.enableMouseAim || !getSettings().game.enableMouseCamera; }); leftPane.add_section("Gameplay"); + addOption("Mouse/Touch in Menus", getSettings().game.enableMenuPointer, + "Enables mouse and touch input for supported in-game menus."); addOption("Invert Air/Swim X Axis", getSettings().game.invertAirSwimX, "Invert horizontal movement while flying or swimming."); addOption("Invert Air/Swim Y Axis", getSettings().game.invertAirSwimY, diff --git a/src/dusk/ui/touch_controls.cpp b/src/dusk/ui/touch_controls.cpp new file mode 100644 index 0000000000..a85a90930f --- /dev/null +++ b/src/dusk/ui/touch_controls.cpp @@ -0,0 +1,1372 @@ +#include "touch_controls.hpp" +#include "touch_controls_common.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "d/actor/d_a_alink.h" +#include "d/actor/d_a_player.h" +#include "d/d_com_inf_game.h" +#include "d/d_meter2_info.h" +#include "d/d_msg_object.h" +#include "dusk/action_bindings.h" +#include "dusk/menu_pointer.h" +#include "dusk/settings.h" +#include "dusk/touch_camera.h" +#include "f_op/f_op_overlap_mng.h" +#include "icon_provider.hpp" +#include "m_Do/m_Do_graphic.h" +#include "ui.hpp" + +namespace dusk::ui { +namespace { + +constexpr u32 kPort = PAD_CHAN0; +constexpr float kStickRadiusDp = 62.f; +constexpr float kStickKnobRadiusDp = 24.f; +constexpr float kAnalogZoneTopDp = 92.f; +constexpr float kAnalogZoneBottomDp = 30.f; +constexpr float kLeftZoneWidth = 0.46f; +constexpr float kRightZoneStart = 0.52f; +constexpr u8 kTriggerAnalog = 180; +constexpr auto kLDoubleTapWindow = std::chrono::milliseconds(300); +constexpr auto kHoldActionDuration = std::chrono::milliseconds(450); +constexpr float kFaceIconTargetRatio = 0.76f; +constexpr float kPressedScale = 0.94f; +constexpr size_t kEquipTargetCount = 4; + +std::array sEquipTargets{}; +std::array(Control::COUNT)> sControlOverrides{}; +TouchControls* sTouchControls = nullptr; + +struct ControlInfo { + const char* id = nullptr; + const char* iconId = nullptr; + const char* oilId = nullptr; + const char* oilFillId = nullptr; + const char* countId = nullptr; + u16 padButton = 0; + std::optional tapAction; + std::optional holdAction; +}; + +constexpr std::array(Control::COUNT)> kControls = {{ + { + .id = "button-a", + .padButton = PAD_BUTTON_A, + }, + { + .id = "button-b", + .iconId = "button-b-icon", + .padButton = PAD_BUTTON_B, + }, + { + .id = "button-x", + .iconId = "button-x-icon", + .oilId = "button-x-oil", + .oilFillId = "button-x-oil-fill", + .countId = "button-x-count", + .padButton = PAD_BUTTON_X, + }, + { + .id = "button-y", + .iconId = "button-y-icon", + .oilId = "button-y-oil", + .oilFillId = "button-y-oil-fill", + .countId = "button-y-count", + .padButton = PAD_BUTTON_Y, + }, + { + .id = "button-z", + .iconId = "z-midna-icon", + .padButton = PAD_TRIGGER_Z, + }, + { + .id = "trigger-l", + }, + { + .id = "trigger-r", + }, + { + .id = "first-person", + .tapAction = ActionBinds::FIRST_PERSON_CAMERA, + }, + { + .id = "items", + .padButton = PAD_BUTTON_UP, + }, + { + .id = "collections", + .padButton = PAD_BUTTON_START, + }, + { + .id = "map", + .tapAction = ActionBinds::OPEN_MAP_SCREEN, + .holdAction = ActionBinds::TOGGLE_MINIMAP, + }, + { + .id = "skip", + .padButton = PAD_BUTTON_START, + }, +}}; + +constexpr const ControlInfo* control_info(Control control) noexcept { + const auto index = static_cast(control); + return index < kControls.size() ? &kControls[index] : nullptr; +} + +bool control_override_active(Control control) noexcept { + const auto index = static_cast(control); + return index < sControlOverrides.size() && sControlOverrides[index] != ControlOverride::Default; +} + +Rml::String touch_controls_document_source() { + const auto fragment = touch_controls_rml_fragment(); + return Rml::String{R"RML( + + + + + + + + + +)RML"} + Rml::String{fragment.data(), fragment.size()} + + Rml::String{R"RML( + + +)RML"}; +} + +s8 stick_value(float value) noexcept { + return static_cast(std::clamp(std::lround(value * 127.f), -127l, 127l)); +} + +bool player_attention_locked() noexcept { + const auto* player = daPy_getPlayerActorClass(); + return player != nullptr && (player->checkAttentionLock() || player->checkEnemyAttentionLock()); +} + +bool item_wheel_active() noexcept { + return dMeter2Info_getWindowStatus() == 2; +} + +bool fishing_controls_active() noexcept { + const auto* player = daAlink_getAlinkActorClass(); + if (player == nullptr) { + return false; + } + return player->checkCanoeFishingWaitAnime(); +} + +enum class StickOutput { + MainStick, + CStick, +}; + +StickOutput stick_output_mode() noexcept { + if (fishing_controls_active()) { + return StickOutput::CStick; + } + return StickOutput::MainStick; +} + +bool controls_available(bool allowItemWheel) noexcept { + if (dComIfGp_getLinkPlayer() == nullptr) { + return false; + } + + const auto* fader = mDoGph_gInf_c::getFader(); + if (fader == nullptr || fader->getStatus() != JUTFader::Wait || mDoGph_gInf_c::isFade()) { + return false; + } + + const bool itemWheelActive = allowItemWheel && item_wheel_active(); + const auto heapLock = dComIfGp_isHeapLockFlag(); + if ((heapLock != 0 && heapLock != 5 && !(itemWheelActive && heapLock == 1)) || + (dComIfGp_isPauseFlag() && !itemWheelActive) || dComIfGp_getMesgStatus() != 0 || + dComIfGp_isEnableNextStage() || fopOvlpM_IsDoingReq()) + { + return false; + } + + return true; +} + +Rml::Vector2f clamped_stick_delta( + Rml::Vector2f start, Rml::Vector2f current, float stickRadius) noexcept { + Rml::Vector2f delta = current - start; + const float length = std::sqrt(delta.x * delta.x + delta.y * delta.y); + delta *= length > stickRadius ? stickRadius / length : 1.f; + return delta; +} + +struct FaceButtonState { + std::string iconSource; + uint64_t iconRevision = 0; + bool visible = false; + bool showIcon = false; +}; + +void release_rml_texture(const std::string& source) noexcept { + if (!source.empty()) { + Rml::ReleaseTexture(source); + } +} + +FaceButtonState override_button_state(Control control) { + if (control_override_active(control)) { + return { + .iconSource = "", + .visible = true, + .showIcon = false, + }; + } + + return {}; +} + +bool game_controls_suppressed() noexcept { + return !controls_available(true) || dComIfGp_event_runCheck() || + (dComIfGp_getMsgObjectClass() != nullptr && dMsgObject_isTalkNowCheck()); +} + +FaceButtonState xy_button_state(Control control) { + if (sControlOverrides[static_cast(control)] != ControlOverride::Default) { + return override_button_state(control); + } + if (game_controls_suppressed()) { + return {}; + } + + const bool itemMode = dComIfGp_getLinkPlayer() != nullptr && daPy_py_c::checkNowWolf() == 0; + const auto source = itemMode ? item_icon_source_for_button(control) : std::string(); + return { + .iconSource = source, + .visible = true, + .showIcon = itemMode && !source.empty(), + }; +} + +FaceButtonState z_button_state() { + if (sControlOverrides[static_cast(Control::Z)] != ControlOverride::Default) { + return override_button_state(Control::Z); + } + if (game_controls_suppressed()) { + return {}; + } + + const auto source = midna_icon_source(); + return { + .iconSource = source, + .iconRevision = midna_icon_revision(), + .visible = true, + .showIcon = !source.empty(), + }; +} + +FaceButtonState b_button_state() { + if (game_controls_suppressed()) { + return { + .iconSource = "", + .visible = true, + .showIcon = false, + }; + } + + const auto source = item_icon_source_for_button(Control::B); + return { + .iconSource = source, + .visible = true, + .showIcon = dMeter2Info_isUseButton(METER2_USEBUTTON_B) && !source.empty(), + }; +} + +void clear_equip_targets() noexcept { + for (auto& target : sEquipTargets) { + target.valid = false; + } +} + +void sync_equip_target(int slot, ControlRect rectDp, float widthRatio, float heightRatio, + bool square = false) noexcept { + if (slot < 0 || static_cast(slot) >= sEquipTargets.size()) { + return; + } + + auto& target = sEquipTargets[slot]; + target.valid = false; + + auto* context = aurora::rmlui::get_context(); + if (context == nullptr) { + return; + } + + const auto dimensions = context->GetDimensions(); + if (dimensions.x <= 0 || dimensions.y <= 0) { + return; + } + + const float scale = std::max(context->GetDensityIndependentPixelRatio(), 1.f); + const Rml::Vector2f buttonPosition{rectDp.l * scale, rectDp.t * scale}; + const Rml::Vector2f buttonSize{rectDp.w * scale, rectDp.h * scale}; + if (buttonSize.x <= 0.f || buttonSize.y <= 0.f) { + return; + } + + Rml::Vector2f targetSize{buttonSize.x * widthRatio, buttonSize.y * heightRatio}; + if (square) { + const float side = std::min(buttonSize.x, buttonSize.y) * widthRatio; + targetSize = Rml::Vector2f{side, side}; + } + const auto targetPosition = buttonPosition + (buttonSize - targetSize) * 0.5f; + const float scaleX = mDoGph_gInf_c::getWidthF() / static_cast(dimensions.x); + const float scaleY = mDoGph_gInf_c::getHeightF() / static_cast(dimensions.y); + + target.left = mDoGph_gInf_c::getMinXF() + targetPosition.x * scaleX; + target.top = mDoGph_gInf_c::getMinYF() + targetPosition.y * scaleY; + target.width = targetSize.x * scaleX; + target.height = targetSize.y * scaleY; + target.valid = true; +} + +} // namespace + +bool get_equip_target(int slot, EquipTarget& target) noexcept { + if (slot < 0 || static_cast(slot) >= sEquipTargets.size()) { + return false; + } + + const auto& stored = sEquipTargets[slot]; + if (!stored.valid) { + return false; + } + + target = stored; + return true; +} + +void set_control_override(Control control, ControlOverride override) noexcept { + const auto index = static_cast(control); + if (index >= sControlOverrides.size()) { + return; + } + sControlOverrides[index] = override; +} + +void sync_virtual_input() noexcept { + if (sTouchControls != nullptr) { + sTouchControls->sync_virtual_input(); + } +} + +TouchControls::TouchControls() + : Document(touch_controls_document_source(), true), + mRoot(mDocument != nullptr ? mDocument->GetElementById("root") : nullptr), + mControlStick(mDocument != nullptr ? mDocument->GetElementById("control-stick") : nullptr), + mControlKnob(mDocument != nullptr ? mDocument->GetElementById("control-knob") : nullptr), + mActionBar(mDocument != nullptr ? mDocument->GetElementById("action-bar") : nullptr) { + sTouchControls = this; + if (mDocument != nullptr) { + for (std::size_t i = 0; i < kControls.size(); ++i) { + const auto& info = kControls[i]; + auto& elements = mControlElements[i]; + elements.root = info.id != nullptr ? mDocument->GetElementById(info.id) : nullptr; + elements.icon = + info.iconId != nullptr ? mDocument->GetElementById(info.iconId) : nullptr; + elements.oil = info.oilId != nullptr ? mDocument->GetElementById(info.oilId) : nullptr; + elements.oilFill = + info.oilFillId != nullptr ? mDocument->GetElementById(info.oilFillId) : nullptr; + elements.count = + info.countId != nullptr ? mDocument->GetElementById(info.countId) : nullptr; + } + } + + listen(mRoot, Rml::EventId::Transitionend, [this](Rml::Event& event) { + if (event.GetTargetElement() == mRoot && !mRoot->HasAttribute("open") && + Document::visible()) + { + Document::hide(mPendingClose); + } + }); + + auto listenControl = [this](Control control) { + const auto index = static_cast(control); + auto* element = index < mControlElements.size() ? mControlElements[index].root : nullptr; + if (element == nullptr) { + return; + } + listen(element, aurora::rmlui::TouchStartEvent, [this, control](Rml::Event& event) { + if (!visible() || mWasSuppressed || !getSettings().game.enableTouchControls) { + return; + } + if (start_control_touch(touch_event_id(event), control)) { + event.StopPropagation(); + } + }); + listen(element, aurora::rmlui::TouchEndEvent, [this](Rml::Event& event) { + if (release_control_touch(touch_event_id(event), false)) { + event.StopPropagation(); + } + }); + listen(element, aurora::rmlui::TouchCancelEvent, [this](Rml::Event& event) { + if (release_control_touch(touch_event_id(event), true)) { + event.StopPropagation(); + } + }); + }; + for (std::size_t i = 0; i < kControls.size(); ++i) { + listenControl(static_cast(i)); + } + + listen(mRoot, aurora::rmlui::TouchStartEvent, + [this](Rml::Event& event) { handle_touch_down(event); }); + listen(mRoot, aurora::rmlui::TouchMoveEvent, + [this](Rml::Event& event) { handle_touch_motion(event); }); + listen( + mRoot, aurora::rmlui::TouchEndEvent, [this](Rml::Event& event) { handle_touch_up(event); }); + listen(mRoot, aurora::rmlui::TouchCancelEvent, + [this](Rml::Event& event) { handle_touch_cancel(event); }); + listen(mRoot, Rml::EventId::Mousemove, [this](Rml::Event& event) { handle_mouse_move(event); }); + listen(mRoot, Rml::EventId::Mousedown, [this](Rml::Event& event) { handle_mouse_down(event); }); + listen(mRoot, Rml::EventId::Mouseup, [this](Rml::Event& event) { handle_mouse_up(event); }); +} + +TouchControls::~TouchControls() { + clear_virtual_input(); + clearAllVirtualActionBinds(); + if (sTouchControls == this) { + sTouchControls = nullptr; + } +} + +void TouchControls::show() { + Document::show(); + if (mRoot != nullptr) { + mRoot->SetAttribute("open", ""); + } +} + +void TouchControls::hide(bool close) { + clear_virtual_input(); + if (mRoot != nullptr) { + mRoot->RemoveAttribute("open"); + mPendingClose = close; + } else { + Document::hide(close); + } +} + +void TouchControls::set_control_pressed(Control control, bool pressed) { + set_control_visual(control, pressed); + sync_control_button_mask(); + + switch (control) { + case Control::L: + if (control_override_active(control)) { + mLPressed = pressed; + mLLatched = false; + mManualLLatched = false; + mLReleasePending = false; + mLPressStartTime = {}; + mLastLTapTime = {}; + break; + } + if (pressed && (mLLatched || mManualLLatched)) { + mLLatched = false; + mManualLLatched = false; + mLPressed = false; + mLReleasePending = true; + mLPressStartTime = {}; + mLastLTapTime = {}; + set_control_visual(control, false); + } else if (pressed) { + const auto now = clock::now(); + if (!player_attention_locked() && mLastLTapTime != clock::time_point{} && + now - mLastLTapTime <= kLDoubleTapWindow) + { + mManualLLatched = true; + mLPressed = false; + mLReleasePending = true; + mLPressStartTime = {}; + mLastLTapTime = {}; + } else if (!mLReleasePending) { + mLPressed = true; + mLPressStartTime = now; + } + } else if (!mLReleasePending) { + mLPressed = false; + } + if (!pressed) { + const auto now = clock::now(); + if (!mLReleasePending) { + const bool wasQuickTap = mLPressStartTime != clock::time_point{} && + now - mLPressStartTime <= kLDoubleTapWindow; + mLastLTapTime = wasQuickTap ? now : clock::time_point{}; + } + mLPressStartTime = {}; + mLReleasePending = false; + } + if (!pressed && !player_attention_locked()) { + mLLatched = false; + } + break; + case Control::R: + mRTriggerHeld = pressed; + break; + default: + break; + } +} + +bool TouchControls::fire_control_action(Control control, ControlAction action) noexcept { + const auto* info = control_info(control); + if (info == nullptr) { + return false; + } + + const auto actionBind = action == ControlAction::Tap ? info->tapAction : info->holdAction; + if (!actionBind) { + return false; + } + + const auto actionIndex = static_cast(*actionBind); + if (actionIndex >= mQueuedActions.size()) { + return false; + } + + mQueuedActions.set(actionIndex); + return true; +} + +bool TouchControls::start_control_touch(SDL_FingerID id, Control control) noexcept { + const auto index = static_cast(control); + if (index >= mControlTouches.size()) { + return false; + } + + auto& touch = mControlTouches[index]; + if (touch.active) { + return false; + } + + touch = { + .id = id, + .startTime = clock::now(), + .active = true, + .longPressFired = false, + }; + set_control_pressed(control, true); + return true; +} + +void TouchControls::release_control(Control control) noexcept { + const auto index = static_cast(control); + if (index < mControlTouches.size()) { + mControlTouches[index] = {}; + } + + sync_control_button_mask(); + switch (control) { + case Control::L: + mLPressed = false; + mLLatched = false; + mManualLLatched = false; + mLReleasePending = false; + mLPressStartTime = {}; + mLastLTapTime = {}; + break; + case Control::R: + mRTriggerHeld = false; + break; + default: + break; + } + set_control_visual(control, false); +} + +void TouchControls::sync_control_button_mask() noexcept { + u16 buttonMask = 0; + for (std::size_t i = 0; i < mControlTouches.size() && i < kControls.size(); ++i) { + if (mControlTouches[i].active) { + buttonMask |= kControls[i].padButton; + } + } + mButtonMask = buttonMask; +} + +void TouchControls::set_control_visual(Control control, bool pressed) noexcept { + const auto index = static_cast(control); + auto* element = index < mControlElements.size() ? mControlElements[index].root : nullptr; + if (index >= mControlVisualPressed.size()) { + return; + } + mControlVisualPressed[index] = pressed; + if (element != nullptr) { + element->SetClass("pressed", pressed); + } + apply_control_transform(control); +} + +void TouchControls::apply_control_transform(Control control) noexcept { + const auto index = static_cast(control); + if (index >= mControlElements.size()) { + return; + } + auto& elements = mControlElements[index]; + auto& layout = elements.layout; + const float pressedScale = + index < mControlVisualPressed.size() && mControlVisualPressed[index] ? kPressedScale : 1.f; + apply_control_transform_if_changed( + elements.root, layout.appliedTransform, layout.layoutScale * pressedScale); +} + +void TouchControls::sync_l_lock_state() noexcept { + if (player_attention_locked()) { + if (mLPressed) { + mLLatched = true; + } + } else { + mLLatched = false; + } +} + +void TouchControls::clear_motion_touch_input() noexcept { + mMoveTouch = {}; + mCameraTouch = {}; + touch_camera::clear(); + if (mControlStick != nullptr) { + mControlStick->SetClass("active", false); + } +} + +void TouchControls::clear_control_input() noexcept { + for (std::size_t i = 0; i < mControlTouches.size(); ++i) { + release_control(static_cast(i)); + } + mQueuedActions.reset(); + mButtonMask = 0; + mWantsVirtualPad = false; + PADClearVirtualStatus(kPort); + for (const auto& control : kControls) { + if (control.tapAction) { + setVirtualActionBind(*control.tapAction, kPort, false, false); + } + if (control.holdAction) { + setVirtualActionBind(*control.holdAction, kPort, false, false); + } + } +} + +void TouchControls::clear_virtual_input() noexcept { + clear_motion_touch_input(); + mMenuPointerTouch = 0; + mMenuPointerMouseSuppressions = 0; + mMenuPointerTouchActive = false; + clear_control_input(); +} + +void TouchControls::sync_touch_state() noexcept { + const bool controlsEnabled = getSettings().game.enableTouchControls; + const bool pointerMenuActive = menu_pointer::active(); + if (mWasSuppressed || (!controlsEnabled && !pointerMenuActive)) { + clear_virtual_input(); + return; + } + + if (pointerMenuActive) { + clear_motion_touch_input(); + if (!controlsEnabled) { + clear_control_input(); + return; + } + } + + sync_l_lock_state(); + const bool aimActive = dCamera_c::isAimActive(); + if (aimActive && mMoveTouch.active) { + if (!mCameraTouch.active) { + mCameraTouch = mMoveTouch; + mCameraTouch.start = mMoveTouch.current; + } + mMoveTouch = {}; + } + + const float stickRadius = kStickRadiusDp * touch_dp_scale(); + if (mControlStick != nullptr) { + if (mMoveTouch.active && stickRadius > 0.f) { + const auto delta = + clamped_stick_delta(mMoveTouch.start, mMoveTouch.current, stickRadius); + const float knobRadius = kStickKnobRadiusDp * touch_dp_scale(); + mControlStick->SetClass("active", true); + mControlStick->SetProperty(Rml::PropertyId::Left, + Rml::Property(mMoveTouch.start.x - stickRadius, Rml::Unit::PX)); + mControlStick->SetProperty(Rml::PropertyId::Top, + Rml::Property(mMoveTouch.start.y - stickRadius, Rml::Unit::PX)); + mControlKnob->SetProperty(Rml::PropertyId::Left, + Rml::Property(stickRadius + delta.x - knobRadius, Rml::Unit::PX)); + mControlKnob->SetProperty(Rml::PropertyId::Top, + Rml::Property(stickRadius + delta.y - knobRadius, Rml::Unit::PX)); + } else { + mControlStick->SetClass("active", false); + } + } + + sync_visual_state(); +} + +void TouchControls::sync_virtual_input() noexcept { + sync_touch_state(); + if (mWasSuppressed || !getSettings().game.enableTouchControls) { + return; + } + + PADStatus status{}; + status.err = PAD_ERR_NONE; + status.button = mButtonMask; + + if (mLPressed || mLLatched || mManualLLatched) { + status.button |= PAD_TRIGGER_L; + status.triggerLeft = kTriggerAnalog; + } + if (mRTriggerHeld) { + status.button |= PAD_TRIGGER_R; + status.triggerRight = kTriggerAnalog; + } + + const float stickRadius = kStickRadiusDp * touch_dp_scale(); + if (mMoveTouch.active && stickRadius > 0.f) { + const auto delta = clamped_stick_delta(mMoveTouch.start, mMoveTouch.current, stickRadius); + const float stickX = stick_value(delta.x / stickRadius); + const float stickY = stick_value(-delta.y / stickRadius); + switch (stick_output_mode()) { + case StickOutput::CStick: + status.substickX = stickX; + status.substickY = stickY; + break; + case StickOutput::MainStick: + default: + status.stickX = stickX; + status.stickY = stickY; + break; + } + } + + mWantsVirtualPad = status.button != 0 || status.stickX != 0 || status.stickY != 0 || + status.substickX != 0 || status.substickY != 0 || status.triggerLeft != 0 || + status.triggerRight != 0; + if (mWantsVirtualPad) { + PADSetVirtualStatus(kPort, &status); + } else { + PADClearVirtualStatus(kPort); + } + + for (const auto& control : kControls) { + if (control.tapAction) { + const bool queued = mQueuedActions.test(static_cast(*control.tapAction)); + setVirtualActionBind( + *control.tapAction, kPort, queued, queued && visible() && !mWasSuppressed); + } + if (control.holdAction) { + const bool queued = mQueuedActions.test(static_cast(*control.holdAction)); + setVirtualActionBind( + *control.holdAction, kPort, queued, queued && visible() && !mWasSuppressed); + } + } + mQueuedActions.reset(); +} + +void TouchControls::sync_visibility() noexcept { + mWasSuppressed = any_document_visible(); + if ((getSettings().game.enableTouchControls || + (menu_pointer::enabled() && menu_pointer::active())) && + !mWasSuppressed) + { + show(); + } else if (visible()) { + hide(false); + } else { + clear_virtual_input(); + } +} + +void TouchControls::sync_safe_area() noexcept { + if (mDocument == nullptr) { + return; + } + const auto insets = safe_area_insets(mDocument->GetContext()); + if (insets == mSafeInsets) { + return; + } + mSafeInsets = insets; +} + +void TouchControls::sync_control_layouts() noexcept { + auto* context = mDocument != nullptr ? mDocument->GetContext() : aurora::rmlui::get_context(); + const auto docSize = touch_document_size_dp(context); + if (docSize.w <= 0.f || docSize.h <= 0.f || context == nullptr) { + return; + } + + const auto& customControls = getSettings().game.touchControlsLayout.getValue().controls; + for (const auto& info : touch_layout_controls()) { + auto props = info.props; + if (const auto iter = customControls.find(info.layoutId); iter != customControls.end()) { + props = iter->second; + } + + const auto layout = resolve_control_layout(props, docSize); + if (info.hasControl) { + const auto index = static_cast(info.control); + if (index >= mControlElements.size()) { + continue; + } + + auto& elements = mControlElements[index]; + auto& state = elements.layout; + state.visualRect = layout.visual; + state.layoutScale = layout.scale; + apply_control_box_if_changed(elements.root, state.appliedBox, layout.box); + apply_control_dock_classes( + elements.root, touch_control_dock_anchor(layout.visual, docSize)); + apply_control_transform(info.control); + continue; + } + + mActionBarLayout.visualRect = layout.visual; + mActionBarLayout.layoutScale = layout.scale; + apply_control_box_if_changed(mActionBar, mActionBarLayout.appliedBox, layout.box); + apply_control_dock_classes(mActionBar, touch_control_dock_anchor(layout.visual, docSize)); + apply_control_transform_if_changed( + mActionBar, mActionBarLayout.appliedTransform, mActionBarLayout.layoutScale); + } +} + +void TouchControls::sync_visual_state() noexcept { + if (mWasSuppressed || !getSettings().game.enableTouchControls) { + clear_motion_touch_input(); + for (const auto control : {Control::L, Control::R}) { + const auto& elements = mControlElements[static_cast(control)]; + if (elements.root != nullptr) { + elements.root->SetPseudoClass("hidden", true); + } + release_control(control); + } + return; + } + + const bool hideGameplayControls = game_controls_suppressed(); + const auto& lTrigger = mControlElements[static_cast(Control::L)]; + const auto& rTrigger = mControlElements[static_cast(Control::R)]; + const bool lHidden = hideGameplayControls && !control_override_active(Control::L); + const bool rHidden = hideGameplayControls && !control_override_active(Control::R); + + if (lTrigger.root != nullptr) { + lTrigger.root->SetPseudoClass("hidden", lHidden); + lTrigger.root->SetClass("active", + !lHidden && (mLPressed || mLLatched || mManualLLatched || + (!control_override_active(Control::L) && player_attention_locked()))); + } + if (rTrigger.root != nullptr) { + rTrigger.root->SetPseudoClass("hidden", rHidden); + } + + if (lHidden) { + release_control(Control::L); + } + if (rHidden) { + release_control(Control::R); + } +} + +void TouchControls::sync_action_bar_state() noexcept { + if (mWasSuppressed || !getSettings().game.enableTouchControls) { + if (mActionBar != nullptr) { + mActionBar->SetPseudoClass("hidden", true); + } + const auto& skip = mControlElements[static_cast(Control::SKIP)]; + if (skip.root != nullptr) { + skip.root->SetPseudoClass("hidden", true); + } + for (const auto control : {Control::FIRST_PERSON, Control::ITEMS, Control::COLLECTIONS, + Control::MAP, Control::SKIP}) + { + release_control(control); + } + return; + } + + auto* event = dComIfGp_getEvent(); + const bool skipVisible = event != nullptr && event->mEventStatus == 1 && + event->mSkipFunc != nullptr && !event->chkFlag2(2); + const bool hidden = + !skipVisible && + (!controls_available(false) || dComIfGp_event_runCheck() || + (dComIfGp_getMsgObjectClass() != nullptr && dMsgObject_isTalkNowCheck())); + const auto& skip = mControlElements[static_cast(Control::SKIP)]; + if (mActionBar != nullptr) { + mActionBar->SetPseudoClass("hidden", hidden || skipVisible); + } + if (skip.root != nullptr) { + skip.root->SetPseudoClass("hidden", !skipVisible); + } + if (skipVisible) { + for (const auto control : + {Control::FIRST_PERSON, Control::ITEMS, Control::COLLECTIONS, Control::MAP}) + { + release_control(control); + } + return; + } + + release_control(Control::SKIP); + if (!hidden) { + return; + } + + for (const auto control : + {Control::FIRST_PERSON, Control::ITEMS, Control::COLLECTIONS, Control::MAP}) + { + release_control(control); + } +} + +void TouchControls::sync_control_displays() noexcept { + if (mWasSuppressed || !getSettings().game.enableTouchControls) { + for (const auto control : {Control::A, Control::B, Control::X, Control::Y, Control::Z}) { + const auto& elements = mControlElements[static_cast(control)]; + if (elements.root != nullptr) { + elements.root->SetPseudoClass("hidden", true); + } + release_control(control); + } + clear_equip_targets(); + return; + } + + const auto bState = b_button_state(); + const auto xState = xy_button_state(Control::X); + const auto yState = xy_button_state(Control::Y); + const auto zState = z_button_state(); + + const auto& a = mControlElements[static_cast(Control::A)]; + const auto& b = mControlElements[static_cast(Control::B)]; + const auto& x = mControlElements[static_cast(Control::X)]; + const auto& y = mControlElements[static_cast(Control::Y)]; + const auto& z = mControlElements[static_cast(Control::Z)]; + + if (a.root != nullptr) { + a.root->SetPseudoClass("hidden", false); + } + if (z.root != nullptr) { + z.root->SetPseudoClass("hidden", !zState.visible); + z.root->SetClass("has-icon", zState.showIcon); + } + if (!zState.visible) { + release_control(Control::Z); + } + if (z.icon != nullptr) { + z.icon->SetClass("visible", zState.showIcon); + } + + const bool zSourceChanged = zState.iconSource != mZTriggerIconSource; + const bool zRevisionChanged = zState.iconRevision != mZTriggerIconRevision; + if (zSourceChanged || zRevisionChanged) { + const std::string previousSource = mZTriggerIconSource; + mZTriggerIconSource = zState.iconSource; + mZTriggerIconRevision = zState.iconRevision; + if (z.icon == nullptr) { + release_rml_texture(previousSource); + } else if (zState.iconSource.empty()) { + z.icon->RemoveAttribute("src"); + } else { + release_rml_texture(zState.iconSource); + if (!zSourceChanged) { + z.icon->RemoveAttribute("src"); + } + z.icon->SetAttribute("src", zState.iconSource); + } + if (zSourceChanged) { + release_rml_texture(previousSource); + } + } + + const auto syncIcon = [this](Rml::Element* button, Rml::Element* icon, std::string& lastSource, + Control control, const FaceButtonState& state) { + if (button != nullptr) { + button->SetPseudoClass("hidden", !state.visible); + button->SetClass("has-item", state.showIcon); + } + if (!state.visible) { + release_control(control); + } + + if (icon != nullptr) { + icon->SetClass("visible", state.showIcon); + } + + if (state.iconSource == lastSource) { + return; + } + + const std::string previousSource = lastSource; + lastSource = state.iconSource; + if (icon == nullptr) { + release_rml_texture(previousSource); + return; + } + if (state.iconSource.empty()) { + icon->RemoveAttribute("src"); + } else { + icon->SetAttribute("src", state.iconSource); + } + release_rml_texture(previousSource); + }; + + syncIcon(b.root, b.icon, mButtonBIconSource, Control::B, bState); + syncIcon(x.root, x.icon, mButtonXIconSource, Control::X, xState); + syncIcon(y.root, y.icon, mButtonYIconSource, Control::Y, yState); + + const auto syncCount = [](Rml::Element* countElement, std::string& lastLabel, Control control, + const FaceButtonState& state) { + const std::string label = state.showIcon ? item_count_label_for_button(control) : ""; + if (label == lastLabel) { + return; + } + + lastLabel = label; + if (countElement == nullptr) { + return; + } + countElement->SetClass("visible", !label.empty()); + countElement->SetInnerRML(label); + }; + + syncCount(x.count, mButtonXCountLabel, Control::X, xState); + syncCount(y.count, mButtonYCountLabel, Control::Y, yState); + + const auto syncOil = [](Rml::Element* meter, Rml::Element* fill, Control control, + const FaceButtonState& state) { + const auto oilFill = state.showIcon ? item_oil_fill_for_button(control) : std::nullopt; + if (meter != nullptr) { + meter->SetClass("visible", oilFill.has_value()); + } + if (fill != nullptr) { + const float percent = oilFill ? *oilFill * 100.f : 0.f; + fill->SetProperty(Rml::PropertyId::Width, Rml::Property(percent, Rml::Unit::PERCENT)); + } + }; + + syncOil(b.oil, b.oilFill, Control::B, bState); + syncOil(x.oil, x.oilFill, Control::X, xState); + syncOil(y.oil, y.oilFill, Control::Y, yState); + + clear_equip_targets(); + if (!visible() || mWasSuppressed || !getSettings().game.enableTouchControls) { + return; + } + + if (xState.showIcon && x.layout.visualRect) { + sync_equip_target(0, *x.layout.visualRect, kFaceIconTargetRatio, kFaceIconTargetRatio); + } + if (yState.showIcon && y.layout.visualRect) { + sync_equip_target(1, *y.layout.visualRect, kFaceIconTargetRatio, kFaceIconTargetRatio); + } + if (zState.showIcon && z.layout.visualRect) { + sync_equip_target(2, *z.layout.visualRect, 1.f, 1.f, true); + } + if (bState.showIcon && b.layout.visualRect) { + sync_equip_target(3, *b.layout.visualRect, kFaceIconTargetRatio, kFaceIconTargetRatio); + } +} + +void TouchControls::update() { + sync_visibility(); + sync_control_long_presses(); + sync_safe_area(); + sync_control_layouts(); + sync_visual_state(); + sync_action_bar_state(); + sync_control_displays(); + sync_touch_state(); +} + +bool TouchControls::release_control_touch(SDL_FingerID id, bool cancelled) noexcept { + for (std::size_t i = 0; i < mControlTouches.size(); ++i) { + auto& touch = mControlTouches[i]; + if (!touch.active || touch.id != id) { + continue; + } + + const auto control = static_cast(i); + const bool shouldFireTapAction = !cancelled && !touch.longPressFired; + touch = {}; + set_control_pressed(control, false); + if (shouldFireTapAction) { + fire_control_action(control, ControlAction::Tap); + } + return true; + } + + return false; +} + +void TouchControls::sync_control_long_presses() noexcept { + const auto now = clock::now(); + for (std::size_t i = 0; i < mControlTouches.size(); ++i) { + auto& touch = mControlTouches[i]; + if (!touch.active || touch.longPressFired || now - touch.startTime < kHoldActionDuration) { + continue; + } + + if (!fire_control_action(static_cast(i), ControlAction::Hold)) { + continue; + } + + touch.longPressFired = true; + } +} + +bool TouchControls::handle_menu_event(Rml::Event& event, menu_pointer::Phase phase) noexcept { + if (!menu_pointer::active() || event.GetTargetElement() != mRoot) { + return false; + } + if (!menu_pointer::enabled()) { + mMenuPointerTouch = 0; + mMenuPointerMouseSuppressions = 0; + mMenuPointerTouchActive = false; + event.StopPropagation(); + return true; + } + + const auto id = touch_event_id(event); + switch (phase) { + case menu_pointer::Phase::Press: + if (mMenuPointerTouchActive) { + event.StopPropagation(); + return true; + } + mMenuPointerTouch = id; + mMenuPointerTouchActive = true; + break; + case menu_pointer::Phase::Move: + if (!mMenuPointerTouchActive || mMenuPointerTouch != id) { + event.StopPropagation(); + return true; + } + break; + case menu_pointer::Phase::Release: + case menu_pointer::Phase::Cancel: + if (!mMenuPointerTouchActive || mMenuPointerTouch != id) { + event.StopPropagation(); + return true; + } + mMenuPointerTouchActive = false; + break; + } + + const auto position = touch_event_position(event); + menu_pointer::handle_fallthrough_pointer(position.x, position.y, phase, true); + switch (phase) { + case menu_pointer::Phase::Press: + case menu_pointer::Phase::Release: + mMenuPointerMouseSuppressions = 2; + break; + case menu_pointer::Phase::Move: + case menu_pointer::Phase::Cancel: + mMenuPointerMouseSuppressions = 1; + break; + } + event.StopPropagation(); + return true; +} + +void TouchControls::handle_touch_down(Rml::Event& event) noexcept { + if (!visible() || mWasSuppressed) { + return; + } + if (handle_menu_event(event, menu_pointer::Phase::Press)) { + return; + } + if (!getSettings().game.enableTouchControls) { + return; + } + + const auto position = touch_event_position(event); + auto* context = aurora::rmlui::get_context(); + if (context == nullptr) { + return; + } + + const auto id = touch_event_id(event); + if (dCamera_c::isAimActive()) { + if (!mCameraTouch.active) { + mCameraTouch = { + .id = id, + .start = position, + .current = position, + .active = true, + }; + } + return; + } + + const auto dimensions = context->GetDimensions(); + const float top = mSafeInsets.top + kAnalogZoneTopDp * touch_dp_scale(); + const float bottom = static_cast(dimensions.y) - mSafeInsets.bottom - + kAnalogZoneBottomDp * touch_dp_scale(); + if (position.y < top || position.y > bottom) { + return; + } + + const auto width = static_cast(dimensions.x); + if (!mMoveTouch.active && position.x < width * kLeftZoneWidth) { + mMoveTouch = { + .id = id, + .start = position, + .current = position, + .active = true, + }; + } else if (!mCameraTouch.active && position.x > width * kRightZoneStart) { + mCameraTouch = { + .id = id, + .start = position, + .current = position, + .active = true, + }; + } +} + +void TouchControls::handle_touch_motion(Rml::Event& event) noexcept { + if (!visible() || mWasSuppressed) { + return; + } + if (handle_menu_event(event, menu_pointer::Phase::Move)) { + return; + } + if (!getSettings().game.enableTouchControls) { + return; + } + + const auto id = touch_event_id(event); + const auto position = touch_event_position(event); + if (mMoveTouch.active && mMoveTouch.id == id) { + mMoveTouch.current = position; + } + if (mCameraTouch.active && mCameraTouch.id == id) { + const auto delta = position - mCameraTouch.current; + mCameraTouch.current = position; + const float scale = touch_dp_scale(); + touch_camera::add_delta(delta.x / scale, delta.y / scale); + } +} + +void TouchControls::handle_touch_up(Rml::Event& event) noexcept { + if (!visible() || mWasSuppressed) { + return; + } + const auto id = touch_event_id(event); + if (release_control_touch(id, false)) { + return; + } + if (handle_menu_event(event, menu_pointer::Phase::Release)) { + return; + } + if (mMoveTouch.active && mMoveTouch.id == id) { + mMoveTouch = {}; + } + if (mCameraTouch.active && mCameraTouch.id == id) { + mCameraTouch = {}; + } +} + +void TouchControls::handle_touch_cancel(Rml::Event& event) noexcept { + if (!visible() || mWasSuppressed) { + return; + } + const auto id = touch_event_id(event); + if (release_control_touch(id, true)) { + return; + } + if (handle_menu_event(event, menu_pointer::Phase::Cancel)) { + return; + } + if (mMoveTouch.active && mMoveTouch.id == id) { + mMoveTouch = {}; + } + if (mCameraTouch.active && mCameraTouch.id == id) { + mCameraTouch = {}; + } +} + +void TouchControls::handle_mouse_move(Rml::Event& event) noexcept { + if (mMenuPointerMouseSuppressions > 0) { + --mMenuPointerMouseSuppressions; + return; + } + if (!visible() || mWasSuppressed || !menu_pointer::active() || + !menu_pointer::enabled() || event.GetTargetElement() != mRoot) + { + return; + } + + const auto position = mouse_event_position(event); + menu_pointer::handle_fallthrough_pointer( + position.x, position.y, menu_pointer::Phase::Move, false); + event.StopPropagation(); +} + +void TouchControls::handle_mouse_down(Rml::Event& event) noexcept { + if (mMenuPointerMouseSuppressions > 0) { + --mMenuPointerMouseSuppressions; + return; + } + if (!visible() || mWasSuppressed || !menu_pointer::active() || + !menu_pointer::enabled() || event.GetTargetElement() != mRoot) + { + return; + } + + const auto position = mouse_event_position(event); + const s32 button = event.GetParameter("button", -1); + if (!menu_pointer::handle_fallthrough_pointer( + position.x, position.y, menu_pointer::Phase::Press, false, button)) + { + return; + } + event.StopPropagation(); +} + +void TouchControls::handle_mouse_up(Rml::Event& event) noexcept { + if (mMenuPointerMouseSuppressions > 0) { + --mMenuPointerMouseSuppressions; + return; + } + if (!visible() || mWasSuppressed || + !menu_pointer::enabled() || + (!menu_pointer::active() && !menu_pointer::mouse_capture_active()) || + event.GetTargetElement() != mRoot) + { + return; + } + + const auto position = mouse_event_position(event); + const s32 button = event.GetParameter("button", -1); + if (!menu_pointer::handle_fallthrough_pointer( + position.x, position.y, menu_pointer::Phase::Release, false, button)) + { + return; + } + event.StopPropagation(); +} + +} // namespace dusk::ui diff --git a/src/dusk/ui/touch_controls.hpp b/src/dusk/ui/touch_controls.hpp new file mode 100644 index 0000000000..752afd58b2 --- /dev/null +++ b/src/dusk/ui/touch_controls.hpp @@ -0,0 +1,133 @@ +#pragma once + +#include "controls.hpp" +#include "document.hpp" + +#include "dusk/action_bindings.h" +#include "dusk/menu_pointer.h" +#include "dusk/ui/controls.hpp" + +#include +#include +#include +#include +#include +#include + +namespace dusk::ui { + +enum class ControlOverride { + Default, + Action, +}; + +bool get_equip_target(int slot, EquipTarget& target) noexcept; +void set_control_override(Control control, ControlOverride override) noexcept; +void sync_virtual_input() noexcept; + +class TouchControls final : public Document { +public: + TouchControls(); + ~TouchControls() override; + + void show() override; + void hide(bool close) override; + void update() override; + void sync_virtual_input() noexcept; + +private: + struct StickTouch { + SDL_FingerID id = 0; + Rml::Vector2f start; + Rml::Vector2f current; + bool active = false; + }; + struct ControlTouch { + SDL_FingerID id = 0; + clock::time_point startTime{}; + bool active = false; + bool longPressFired = false; + }; + struct LayoutState { + std::optional visualRect; + std::optional appliedBox; + float layoutScale = 1.0f; + std::optional appliedTransform; + }; + struct ControlElements { + Rml::Element* root = nullptr; + Rml::Element* icon = nullptr; + Rml::Element* oil = nullptr; + Rml::Element* oilFill = nullptr; + Rml::Element* count = nullptr; + LayoutState layout; + }; + enum class ControlAction { + Tap, + Hold, + }; + + void set_control_pressed(Control control, bool pressed); + void release_control(Control control) noexcept; + void sync_control_button_mask() noexcept; + bool fire_control_action(Control control, ControlAction action) noexcept; + bool start_control_touch(SDL_FingerID id, Control control) noexcept; + void set_control_visual(Control control, bool pressed) noexcept; + void sync_l_lock_state() noexcept; + void clear_motion_touch_input() noexcept; + void clear_control_input() noexcept; + void clear_virtual_input() noexcept; + void sync_touch_state() noexcept; + void sync_visibility() noexcept; + void sync_safe_area() noexcept; + void sync_control_layouts() noexcept; + void sync_visual_state() noexcept; + void sync_action_bar_state() noexcept; + void sync_control_displays() noexcept; + void apply_control_transform(Control control) noexcept; + void handle_touch_down(Rml::Event& event) noexcept; + void handle_touch_motion(Rml::Event& event) noexcept; + void handle_touch_up(Rml::Event& event) noexcept; + void handle_touch_cancel(Rml::Event& event) noexcept; + void handle_mouse_move(Rml::Event& event) noexcept; + void handle_mouse_down(Rml::Event& event) noexcept; + void handle_mouse_up(Rml::Event& event) noexcept; + void sync_control_long_presses() noexcept; + bool release_control_touch(SDL_FingerID id, bool cancelled) noexcept; + bool handle_menu_event(Rml::Event& event, menu_pointer::Phase phase) noexcept; + + Rml::Element* mRoot = nullptr; + Rml::Element* mControlStick = nullptr; + Rml::Element* mControlKnob = nullptr; + Rml::Element* mActionBar = nullptr; + std::array(Control::COUNT)> mControlElements{}; + std::string mButtonBIconSource; + std::string mButtonXIconSource; + std::string mButtonYIconSource; + std::string mZTriggerIconSource; + uint64_t mZTriggerIconRevision = 0; + std::string mButtonXCountLabel; + std::string mButtonYCountLabel; + StickTouch mMoveTouch; + StickTouch mCameraTouch; + SDL_FingerID mMenuPointerTouch = 0; + int mMenuPointerMouseSuppressions = 0; + std::array(Control::COUNT)> mControlTouches{}; + std::array(Control::COUNT)> mControlVisualPressed{}; + std::bitset(ActionBinds::COUNT)> mQueuedActions; + LayoutState mActionBarLayout; + Insets mSafeInsets; + u16 mButtonMask = 0; + bool mLPressed = false; + bool mLLatched = false; + bool mManualLLatched = false; + bool mLReleasePending = false; + bool mRTriggerHeld = false; + bool mWantsVirtualPad = false; + bool mWasSuppressed = true; + bool mMenuPointerTouchActive = false; + clock::time_point mLPressStartTime{}; + clock::time_point mLastLTapTime{}; +}; + +} // namespace dusk::ui diff --git a/src/dusk/ui/touch_controls_common.cpp b/src/dusk/ui/touch_controls_common.cpp new file mode 100644 index 0000000000..ef46d49335 --- /dev/null +++ b/src/dusk/ui/touch_controls_common.cpp @@ -0,0 +1,359 @@ +#include "touch_controls_common.hpp" + +#include + +#include +#include +#include + +namespace dusk::ui { +namespace { + +constexpr std::array kLayoutControls = {{ + { + .layoutId = "triggerL", + .elementId = "trigger-l", + .props = + { + .x = 24.f, + .y = 18.f, + .w = 78.f, + .h = 46.f, + .scale = 1.f, + .anchor = ControlAnchor::TopLeft, + }, + .control = Control::L, + .hasControl = true, + }, + { + .layoutId = "triggerR", + .elementId = "trigger-r", + .props = + { + .x = 24.f, + .y = 18.f, + .w = 78.f, + .h = 46.f, + .scale = 1.f, + .anchor = ControlAnchor::TopRight, + }, + .control = Control::R, + .hasControl = true, + }, + { + .layoutId = "buttonZ", + .elementId = "button-z", + .props = + { + .x = 24.f, + .y = 72.f, + .w = 78.f, + .h = 46.f, + .scale = 1.f, + .anchor = ControlAnchor::TopRight, + }, + .control = Control::Z, + .hasControl = true, + }, + { + .layoutId = "actionBar", + .elementId = "action-bar", + .props = + { + .x = 56.f, + .y = 0.f, + .w = 230.f, + .h = 46.f, + .scale = 1.f, + .anchor = ControlAnchor::BottomLeft, + }, + }, + { + .layoutId = "skip", + .elementId = "skip", + .props = + { + .x = 24.f, + .y = 18.f, + .w = 64.f, + .h = 46.f, + .scale = 1.f, + .anchor = ControlAnchor::TopRight, + }, + .control = Control::SKIP, + .hasControl = true, + }, + { + .layoutId = "buttonY", + .elementId = "button-y", + .props = + { + .x = 124.f, + .y = 138.f, + .w = 58.f, + .h = 58.f, + .scale = 1.f, + .anchor = ControlAnchor::BottomRight, + }, + .control = Control::Y, + .hasControl = true, + }, + { + .layoutId = "buttonX", + .elementId = "button-x", + .props = + { + .x = 28.f, + .y = 144.f, + .w = 58.f, + .h = 58.f, + .scale = 1.f, + .anchor = ControlAnchor::BottomRight, + }, + .control = Control::X, + .hasControl = true, + }, + { + .layoutId = "buttonB", + .elementId = "button-b", + .props = + { + .x = 158.f, + .y = 48.f, + .w = 58.f, + .h = 58.f, + .scale = 1.f, + .anchor = ControlAnchor::BottomRight, + }, + .control = Control::B, + .hasControl = true, + }, + { + .layoutId = "buttonA", + .elementId = "button-a", + .props = + { + .x = 62.f, + .y = 64.f, + .w = 74.f, + .h = 74.f, + .scale = 1.f, + .anchor = ControlAnchor::BottomRight, + }, + .control = Control::A, + .hasControl = true, + }, +}}; + +constexpr std::string_view kTouchControlsRmlFragment = R"RML( + + + + + + + + + + + + + + + + + + + +)RML"; + +} // namespace + +std::string_view touch_controls_rml_fragment() noexcept { + return kTouchControlsRmlFragment; +} + +std::span touch_layout_controls() noexcept { + return kLayoutControls; +} + +const TouchLayoutControlInfo* find_touch_layout_control(std::string_view layoutId) noexcept { + for (const auto& info : kLayoutControls) { + if (info.layoutId == layoutId) { + return &info; + } + } + return nullptr; +} + +const TouchLayoutControlInfo* find_touch_layout_control(Control control) noexcept { + for (const auto& info : kLayoutControls) { + if (info.hasControl && info.control == control) { + return &info; + } + } + return nullptr; +} + +SDL_FingerID touch_event_id(const Rml::Event& event) noexcept { + return event.GetParameter("finger_id", 0); +} + +Rml::Vector2f touch_event_position(const Rml::Event& event) noexcept { + return { + event.GetParameter("x", 0.f), + event.GetParameter("y", 0.f), + }; +} + +Rml::Vector2f mouse_event_position(const Rml::Event& event) noexcept { + return { + event.GetParameter("mouse_x", 0.f), + event.GetParameter("mouse_y", 0.f), + }; +} + +float touch_dp_scale(Rml::Context* context) noexcept { + if (context == nullptr) { + context = aurora::rmlui::get_context(); + } + if (context == nullptr) { + return 1.f; + } + return std::max(context->GetDensityIndependentPixelRatio(), 1.f); +} + +ControlLayoutSize touch_document_size_dp(Rml::Context* context) noexcept { + if (context == nullptr) { + return {}; + } + + const auto dimensions = context->GetDimensions(); + const float scale = touch_dp_scale(context); + return { + .w = static_cast(dimensions.x) / scale, + .h = static_cast(dimensions.y) / scale, + }; +} + +ControlAnchor touch_control_dock_anchor(ControlRect visual, ControlLayoutSize docSize) noexcept { + if (docSize.w <= 0.f || docSize.h <= 0.f || visual.w <= 0.f || visual.h <= 0.f) { + return ControlAnchor::None; + } + + const bool top = control_float_near(visual.t, 0.f); + const bool bottom = control_float_near(visual.t + visual.h, docSize.h); + const bool left = control_float_near(visual.l, 0.f); + const bool right = control_float_near(visual.l + visual.w, docSize.w); + + if (top && left && !right) { + return ControlAnchor::TopLeft; + } + if (top && right && !left) { + return ControlAnchor::TopRight; + } + if (bottom && left && !right) { + return ControlAnchor::BottomLeft; + } + if (bottom && right && !left) { + return ControlAnchor::BottomRight; + } + if (top) { + return ControlAnchor::Top; + } + if (bottom) { + return ControlAnchor::Bottom; + } + if (left) { + return ControlAnchor::Left; + } + if (right) { + return ControlAnchor::Right; + } + return ControlAnchor::None; +} + +bool control_float_near(float a, float b) noexcept { + return std::abs(a - b) <= 0.01f; +} + +bool control_rect_near(ControlRect a, ControlRect b) noexcept { + return control_float_near(a.l, b.l) && control_float_near(a.t, b.t) && + control_float_near(a.w, b.w) && control_float_near(a.h, b.h); +} + +void apply_control_box_if_changed( + Rml::Element* element, std::optional& appliedBox, ControlRect box) noexcept { + if (element == nullptr || (appliedBox && control_rect_near(*appliedBox, box))) { + return; + } + + element->SetProperty(Rml::PropertyId::Left, Rml::Property(box.l, Rml::Unit::DP)); + element->SetProperty(Rml::PropertyId::Top, Rml::Property(box.t, Rml::Unit::DP)); + element->SetProperty(Rml::PropertyId::Width, Rml::Property(box.w, Rml::Unit::DP)); + element->SetProperty(Rml::PropertyId::Height, Rml::Property(box.h, Rml::Unit::DP)); + appliedBox = box; +} + +void apply_control_transform_if_changed( + Rml::Element* element, std::optional& appliedTransform, float scale) noexcept { + if (element == nullptr || (appliedTransform && control_float_near(*appliedTransform, scale))) { + return; + } + + element->SetProperty(Rml::PropertyId::Transform, + Rml::Transform::MakeProperty({Rml::Transforms::Scale2D{scale}})); + appliedTransform = scale; +} + +void apply_control_dock_classes(Rml::Element* element, ControlAnchor anchor) noexcept { + if (element == nullptr) { + return; + } + + bool top = false; + bool bottom = false; + bool left = false; + bool right = false; + + switch (anchor) { + case ControlAnchor::Top: + top = true; + break; + case ControlAnchor::Bottom: + bottom = true; + break; + case ControlAnchor::Left: + left = true; + break; + case ControlAnchor::Right: + right = true; + break; + case ControlAnchor::TopLeft: + top = true; + left = true; + break; + case ControlAnchor::TopRight: + top = true; + right = true; + break; + case ControlAnchor::BottomLeft: + bottom = true; + left = true; + break; + case ControlAnchor::BottomRight: + bottom = true; + right = true; + break; + case ControlAnchor::None: + break; + } + + element->SetClass("docked", top || bottom || left || right); + element->SetClass("docked-top", top); + element->SetClass("docked-bottom", bottom); + element->SetClass("docked-left", left); + element->SetClass("docked-right", right); +} + +} // namespace dusk::ui diff --git a/src/dusk/ui/touch_controls_common.hpp b/src/dusk/ui/touch_controls_common.hpp new file mode 100644 index 0000000000..30445479ff --- /dev/null +++ b/src/dusk/ui/touch_controls_common.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include "controls.hpp" + +#include +#include + +#include +#include +#include +#include + +namespace dusk::ui { + +constexpr std::size_t kTouchLayoutControlCount = 9; + +struct TouchLayoutControlInfo { + std::string_view layoutId; + const char* elementId = nullptr; + ControlProps props; + Control control = Control::COUNT; + bool hasControl = false; +}; + +std::string_view touch_controls_rml_fragment() noexcept; +std::span touch_layout_controls() noexcept; +const TouchLayoutControlInfo* find_touch_layout_control(std::string_view layoutId) noexcept; +const TouchLayoutControlInfo* find_touch_layout_control(Control control) noexcept; + +SDL_FingerID touch_event_id(const Rml::Event& event) noexcept; +Rml::Vector2f touch_event_position(const Rml::Event& event) noexcept; +Rml::Vector2f mouse_event_position(const Rml::Event& event) noexcept; +float touch_dp_scale(Rml::Context* context = nullptr) noexcept; +ControlLayoutSize touch_document_size_dp(Rml::Context* context) noexcept; +ControlAnchor touch_control_dock_anchor(ControlRect visual, ControlLayoutSize docSize) noexcept; + +bool control_float_near(float a, float b) noexcept; +bool control_rect_near(ControlRect a, ControlRect b) noexcept; +void apply_control_box_if_changed( + Rml::Element* element, std::optional& appliedBox, ControlRect box) noexcept; +void apply_control_transform_if_changed( + Rml::Element* element, std::optional& appliedTransform, float scale) noexcept; +void apply_control_dock_classes(Rml::Element* element, ControlAnchor anchor) noexcept; + +} // namespace dusk::ui diff --git a/src/dusk/ui/touch_controls_editor.cpp b/src/dusk/ui/touch_controls_editor.cpp new file mode 100644 index 0000000000..539494d42f --- /dev/null +++ b/src/dusk/ui/touch_controls_editor.cpp @@ -0,0 +1,630 @@ +#include "touch_controls_editor.hpp" + +#include "modal.hpp" + +#include "Z2AudioLib/Z2SeMgr.h" +#include "dusk/config.hpp" +#include "dusk/settings.h" +#include "m_Do/m_Do_audio.h" + +#include + +#include +#include +#include +#include + +namespace dusk::ui { +namespace { + +constexpr float kDragThresholdDp = 6.f; +constexpr float kMinControlDp = 36.f; +constexpr float kMinTriggerWidthDp = 44.f; +constexpr float kMinTriggerHeightDp = 32.f; +constexpr float kMinActionBarWidthDp = 112.f; +constexpr float kMinActionBarHeightDp = 36.f; +constexpr float kMinScale = 0.25f; + +struct HandleBinding { + const char* id = nullptr; + TouchControlsEditor::EditHandle handle = TouchControlsEditor::EditHandle::Move; +}; + +constexpr std::array kHandleBindings = { + HandleBinding{"editor-handle-left", TouchControlsEditor::EditHandle::Left}, + HandleBinding{"editor-handle-right", TouchControlsEditor::EditHandle::Right}, + HandleBinding{"editor-handle-top", TouchControlsEditor::EditHandle::Top}, + HandleBinding{"editor-handle-bottom", TouchControlsEditor::EditHandle::Bottom}, + HandleBinding{"editor-handle-top-left", TouchControlsEditor::EditHandle::TopLeft}, + HandleBinding{"editor-handle-top-right", TouchControlsEditor::EditHandle::TopRight}, + HandleBinding{"editor-handle-bottom-left", TouchControlsEditor::EditHandle::BottomLeft}, + HandleBinding{"editor-handle-bottom-right", TouchControlsEditor::EditHandle::BottomRight}, +}; + +Rml::String touch_controls_editor_document_source() { + const auto fragment = touch_controls_rml_fragment(); + return Rml::String{R"RML( + + + + + + +)RML"} + Rml::String{fragment.data(), fragment.size()} + Rml::String{R"RML( + + + + + + + + + + + + + + + + + +)RML"}; +} + +bool is_corner(TouchControlsEditor::EditHandle handle) noexcept { + using EditHandle = TouchControlsEditor::EditHandle; + return handle == EditHandle::TopLeft || handle == EditHandle::TopRight || + handle == EditHandle::BottomLeft || handle == EditHandle::BottomRight; +} + +bool is_horizontal_edge(TouchControlsEditor::EditHandle handle) noexcept { + using EditHandle = TouchControlsEditor::EditHandle; + return handle == EditHandle::Left || handle == EditHandle::Right; +} + +bool is_vertical_edge(TouchControlsEditor::EditHandle handle) noexcept { + using EditHandle = TouchControlsEditor::EditHandle; + return handle == EditHandle::Top || handle == EditHandle::Bottom; +} + +bool control_valid(std::size_t index) noexcept { + return index < touch_layout_controls().size(); +} + +float squared_distance(Rml::Vector2f a, Rml::Vector2f b) noexcept { + const auto delta = a - b; + return delta.x * delta.x + delta.y * delta.y; +} + +} // namespace + +TouchControlsEditor::TouchControlsEditor() + : Document(touch_controls_editor_document_source()), + mRoot(mDocument != nullptr ? mDocument->GetElementById("root") : nullptr), + mSelectionFrame( + mDocument != nullptr ? mDocument->GetElementById("editor-selection-frame") : nullptr), + mSaveButton(mDocument != nullptr ? mDocument->GetElementById("editor-save") : nullptr), + mResetButton(mDocument != nullptr ? mDocument->GetElementById("editor-reset") : nullptr), + mCancelButton(mDocument != nullptr ? mDocument->GetElementById("editor-cancel") : nullptr), + mWorkingLayout(getSettings().game.touchControlsLayout.getValue()) { + mWorkingLayout.version = ControlLayout::Version; + + const auto controls = touch_layout_controls(); + for (std::size_t i = 0; i < controls.size() && i < mElements.size(); ++i) { + mElements[i].root = + mDocument != nullptr ? mDocument->GetElementById(controls[i].elementId) : nullptr; + } + + bind_control_events(); + bind_handle_events(); + bind_toolbar_events(); + + listen(mRoot, aurora::rmlui::TouchStartEvent, [this](Rml::Event& event) { + if (event.GetTargetElement() != mRoot) { + return; + } + clear_selected_control(); + event.StopPropagation(); + }); + listen(mRoot, Rml::EventId::Mousedown, [this](Rml::Event& event) { + const s32 button = event.GetParameter("button", -1); + if (button != 0 || event.GetTargetElement() != mRoot) { + return; + } + clear_selected_control(); + event.StopPropagation(); + }); + listen(mRoot, aurora::rmlui::TouchMoveEvent, [this](Rml::Event& event) { + if (continue_edit(touch_event_position(event))) { + event.StopPropagation(); + } + }); + listen(mRoot, aurora::rmlui::TouchEndEvent, [this](Rml::Event& event) { + if (end_edit(true, touch_event_id(event), false)) { + event.StopPropagation(); + } + }); + listen(mRoot, aurora::rmlui::TouchCancelEvent, [this](Rml::Event& event) { + if (end_edit(true, touch_event_id(event), true)) { + event.StopPropagation(); + } + }); + listen(mRoot, Rml::EventId::Mousemove, [this](Rml::Event& event) { + if (continue_edit(mouse_event_position(event))) { + event.StopPropagation(); + } + }); + listen(mRoot, Rml::EventId::Mouseup, [this](Rml::Event& event) { + if (end_edit(false, 0, false)) { + event.StopPropagation(); + } + }); + listen(mRoot, Rml::EventId::Transitionend, [this](Rml::Event& event) { + if (event.GetTargetElement() == mRoot && !mRoot->HasAttribute("open") && + Document::visible()) + { + Document::hide(mPendingClose); + } + }); +} + +void TouchControlsEditor::show() { + Document::show(); + if (mRoot != nullptr) { + mRoot->SetAttribute("open", ""); + } +} + +void TouchControlsEditor::hide(bool close) { + if (mRoot != nullptr) { + mRoot->RemoveAttribute("open"); + mPendingClose = close; + } else { + Document::hide(close); + } +} + +void TouchControlsEditor::update() { + sync_control_layouts(); + sync_selection_frame(); + Document::update(); +} + +bool TouchControlsEditor::focus() { + return mSaveButton != nullptr && mSaveButton->Focus(true); +} + +void TouchControlsEditor::bind_control_events() noexcept { + const auto controls = touch_layout_controls(); + for (std::size_t i = 0; i < controls.size() && i < mElements.size(); ++i) { + auto* element = mElements[i].root; + if (element == nullptr) { + continue; + } + + listen(element, aurora::rmlui::TouchStartEvent, [this, i](Rml::Event& event) { + if (begin_edit(i, EditHandle::Move, touch_event_position(event), true, + touch_event_id(event))) + { + event.StopPropagation(); + } + }); + listen(element, Rml::EventId::Mousedown, [this, i](Rml::Event& event) { + const s32 button = event.GetParameter("button", -1); + if (button != 0) { + return; + } + if (begin_edit(i, EditHandle::Move, mouse_event_position(event), false)) { + event.StopPropagation(); + } + }); + } +} + +void TouchControlsEditor::bind_handle_events() noexcept { + for (const auto& binding : kHandleBindings) { + auto* element = mDocument != nullptr ? mDocument->GetElementById(binding.id) : nullptr; + if (element == nullptr) { + continue; + } + + listen(element, aurora::rmlui::TouchStartEvent, [this, handle = binding.handle]( + Rml::Event& event) { + if (!control_valid(mSelectedIndex)) { + return; + } + if (begin_edit(mSelectedIndex, handle, touch_event_position(event), true, + touch_event_id(event))) + { + event.StopPropagation(); + } + }); + listen(element, Rml::EventId::Mousedown, [this, handle = binding.handle](Rml::Event& event) { + const s32 button = event.GetParameter("button", -1); + if (button != 0 || !control_valid(mSelectedIndex)) { + return; + } + if (begin_edit(mSelectedIndex, handle, mouse_event_position(event), false)) { + event.StopPropagation(); + } + }); + } +} + +void TouchControlsEditor::bind_toolbar_events() noexcept { + bind_button_command(mSaveButton, &TouchControlsEditor::save_layout); + bind_button_command(mResetButton, &TouchControlsEditor::request_reset); + bind_button_command(mCancelButton, &TouchControlsEditor::cancel_edit); +} + +void TouchControlsEditor::bind_button_command( + Rml::Element* element, void (TouchControlsEditor::*callback)()) noexcept { + if (element == nullptr) { + return; + } + + listen(element, Rml::EventId::Click, [this, callback](Rml::Event& event) { + (this->*callback)(); + event.StopPropagation(); + }); + listen(element, Rml::EventId::Keydown, [this, callback](Rml::Event& event) { + if (map_nav_event(event) != NavCommand::Confirm) { + return; + } + (this->*callback)(); + event.StopPropagation(); + }); +} + +void TouchControlsEditor::sync_control_layouts() noexcept { + auto* context = mDocument != nullptr ? mDocument->GetContext() : nullptr; + const auto docSize = touch_document_size_dp(context); + if (docSize.w <= 0.f || docSize.h <= 0.f || context == nullptr) { + return; + } + + const auto controls = touch_layout_controls(); + for (std::size_t i = 0; i < controls.size() && i < mElements.size(); ++i) { + const auto layout = resolve_control_layout(props_for(i), docSize); + auto& element = mElements[i]; + element.layout.visualRect = layout.visual; + element.layout.layoutScale = layout.scale; + if (element.root != nullptr) { + element.root->SetPseudoClass("hidden", false); + } + apply_control_box_if_changed(element.root, element.layout.appliedBox, layout.box); + apply_control_dock_classes( + element.root, touch_control_dock_anchor(layout.visual, docSize)); + apply_control_transform_if_changed( + element.root, element.layout.appliedTransform, element.layout.layoutScale); + } +} + +void TouchControlsEditor::sync_selection_frame() noexcept { + const bool hasSelection = + control_valid(mSelectedIndex) && mElements[mSelectedIndex].layout.visualRect; + if (mSelectionFrame == nullptr) { + return; + } + + mSelectionFrame->SetClass("visible", hasSelection); + for (std::size_t i = 0; i < mElements.size(); ++i) { + if (mElements[i].root != nullptr) { + mElements[i].root->SetClass("editor-selected", hasSelection && i == mSelectedIndex); + } + } + if (!hasSelection) { + mAppliedSelectionFrame = std::nullopt; + return; + } + + apply_control_box_if_changed( + mSelectionFrame, mAppliedSelectionFrame, *mElements[mSelectedIndex].layout.visualRect); +} + +void TouchControlsEditor::set_selected_control(std::size_t index) noexcept { + if (!control_valid(index)) { + clear_selected_control(); + return; + } + mSelectedIndex = index; + sync_selection_frame(); +} + +void TouchControlsEditor::clear_selected_control() noexcept { + mSelectedIndex = kTouchLayoutControlCount; + sync_selection_frame(); +} + +ControlProps TouchControlsEditor::props_for(std::size_t index) const { + const auto controls = touch_layout_controls(); + if (!control_valid(index)) { + return {}; + } + + const auto& info = controls[index]; + if (const auto iter = mWorkingLayout.controls.find(info.layoutId); + iter != mWorkingLayout.controls.end()) + { + return iter->second; + } + return info.props; +} + +void TouchControlsEditor::store_props( + std::size_t index, ControlRect visual, ControlProps props) noexcept { + if (!control_valid(index)) { + return; + } + + auto* context = mDocument != nullptr ? mDocument->GetContext() : nullptr; + const auto docSize = touch_document_size_dp(context); + if (docSize.w <= 0.f || docSize.h <= 0.f) { + return; + } + + props.w = std::max(props.w, 1.f); + props.h = std::max(props.h, 1.f); + props.scale = std::max(props.scale, kMinScale); + props = encode_control_props(visual, docSize, props, touch_control_dock_anchor(visual, docSize)); + mWorkingLayout.version = ControlLayout::Version; + mWorkingLayout.controls[std::string{touch_layout_controls()[index].layoutId}] = props; + sync_control_layouts(); + sync_selection_frame(); +} + +void TouchControlsEditor::restore_active_control() noexcept { + if (!control_valid(mPointerEdit.index)) { + return; + } + + auto& controls = mWorkingLayout.controls; + const auto key = std::string{touch_layout_controls()[mPointerEdit.index].layoutId}; + if (mPointerEdit.storedProps) { + controls[key] = *mPointerEdit.storedProps; + } else { + controls.erase(key); + } + sync_control_layouts(); + sync_selection_frame(); +} + +bool TouchControlsEditor::begin_edit( + std::size_t index, EditHandle handle, Rml::Vector2f positionPx, bool touch, + SDL_FingerID touchId) noexcept { + if (!control_valid(index) || mPointerEdit.active) { + return false; + } + + auto* context = mDocument != nullptr ? mDocument->GetContext() : nullptr; + const auto docSize = touch_document_size_dp(context); + if (docSize.w <= 0.f || docSize.h <= 0.f) { + return false; + } + + const auto props = props_for(index); + const auto layout = resolve_control_layout(props, docSize); + std::optional storedProps; + if (const auto iter = mWorkingLayout.controls.find(touch_layout_controls()[index].layoutId); + iter != mWorkingLayout.controls.end()) + { + storedProps = iter->second; + } + + mPointerEdit = { + .index = index, + .touchId = touchId, + .startPointerDp = pointer_position_dp(positionPx), + .startVisual = layout.visual, + .startProps = props, + .storedProps = storedProps, + .handle = handle, + .active = true, + .touch = touch, + }; + set_selected_control(index); + return true; +} + +bool TouchControlsEditor::continue_edit(Rml::Vector2f positionPx) noexcept { + if (!mPointerEdit.active) { + return false; + } + + const auto pointerDp = pointer_position_dp(positionPx); + if (!mPointerEdit.dragging) { + if (squared_distance(pointerDp, mPointerEdit.startPointerDp) < + kDragThresholdDp * kDragThresholdDp) + { + return true; + } + mPointerEdit.dragging = true; + } + + auto props = mPointerEdit.startProps; + auto rect = rect_for_edit(pointerDp, props); + rect = clamp_visual_rect(mPointerEdit.index, rect); + if (is_corner(mPointerEdit.handle)) { + props.scale = std::max(rect.w / std::max(mPointerEdit.startProps.w, 1.f), kMinScale); + } else if (is_horizontal_edge(mPointerEdit.handle)) { + props.w = rect.w / std::max(props.scale, kMinScale); + } else if (is_vertical_edge(mPointerEdit.handle)) { + props.h = rect.h / std::max(props.scale, kMinScale); + } + store_props(mPointerEdit.index, rect, props); + return true; +} + +bool TouchControlsEditor::end_edit(bool touch, SDL_FingerID touchId, bool cancelled) noexcept { + if (!mPointerEdit.active || mPointerEdit.touch != touch || + (touch && mPointerEdit.touchId != touchId)) + { + return false; + } + + if (cancelled && mPointerEdit.dragging) { + restore_active_control(); + } + mPointerEdit = {}; + return true; +} + +Rml::Vector2f TouchControlsEditor::pointer_position_dp(Rml::Vector2f positionPx) const noexcept { + auto* context = mDocument != nullptr ? mDocument->GetContext() : nullptr; + return positionPx / touch_dp_scale(context); +} + +ControlRect TouchControlsEditor::rect_for_edit( + Rml::Vector2f pointerDp, ControlProps& props) const noexcept { + const auto& edit = mPointerEdit; + auto rect = edit.startVisual; + const auto delta = pointerDp - edit.startPointerDp; + + switch (edit.handle) { + case EditHandle::Move: + rect.l += delta.x; + rect.t += delta.y; + return rect; + case EditHandle::Left: { + const float right = edit.startVisual.l + edit.startVisual.w; + rect.l = pointerDp.x; + rect.w = right - rect.l; + return rect; + } + case EditHandle::Right: + rect.w = pointerDp.x - edit.startVisual.l; + return rect; + case EditHandle::Top: { + const float bottom = edit.startVisual.t + edit.startVisual.h; + rect.t = pointerDp.y; + rect.h = bottom - rect.t; + return rect; + } + case EditHandle::Bottom: + rect.h = pointerDp.y - edit.startVisual.t; + return rect; + case EditHandle::TopLeft: + case EditHandle::TopRight: + case EditHandle::BottomLeft: + case EditHandle::BottomRight: + break; + } + + auto* context = mDocument != nullptr ? mDocument->GetContext() : nullptr; + const auto docSize = touch_document_size_dp(context); + const bool left = edit.handle == EditHandle::TopLeft || edit.handle == EditHandle::BottomLeft; + const bool top = edit.handle == EditHandle::TopLeft || edit.handle == EditHandle::TopRight; + const Rml::Vector2f fixed{ + left ? edit.startVisual.l + edit.startVisual.w : edit.startVisual.l, + top ? edit.startVisual.t + edit.startVisual.h : edit.startVisual.t, + }; + const float desiredW = left ? fixed.x - pointerDp.x : pointerDp.x - fixed.x; + const float desiredH = top ? fixed.y - pointerDp.y : pointerDp.y - fixed.y; + const auto minSize = min_visual_size(edit.index); + const float minRatio = + std::max(minSize.x / std::max(edit.startVisual.w, 1.f), + minSize.y / std::max(edit.startVisual.h, 1.f)); + const float maxW = left ? fixed.x : docSize.w - fixed.x; + const float maxH = top ? fixed.y : docSize.h - fixed.y; + const float maxRatio = + std::max(minRatio, std::min(maxW / std::max(edit.startVisual.w, 1.f), + maxH / std::max(edit.startVisual.h, 1.f))); + const float ratio = + std::clamp(std::max(desiredW / std::max(edit.startVisual.w, 1.f), + desiredH / std::max(edit.startVisual.h, 1.f)), + minRatio, maxRatio); + + rect.w = edit.startVisual.w * ratio; + rect.h = edit.startVisual.h * ratio; + rect.l = left ? fixed.x - rect.w : fixed.x; + rect.t = top ? fixed.y - rect.h : fixed.y; + props.scale = std::max(edit.startProps.scale * ratio, kMinScale); + return rect; +} + +ControlRect TouchControlsEditor::clamp_visual_rect(std::size_t index, ControlRect rect) const noexcept { + auto* context = mDocument != nullptr ? mDocument->GetContext() : nullptr; + const auto docSize = touch_document_size_dp(context); + if (docSize.w <= 0.f || docSize.h <= 0.f || !control_valid(index)) { + return rect; + } + + const auto minSize = min_visual_size(index); + const float minW = std::min(minSize.x, docSize.w); + const float minH = std::min(minSize.y, docSize.h); + rect.w = std::clamp(rect.w, minW, docSize.w); + rect.h = std::clamp(rect.h, minH, docSize.h); + rect.l = std::clamp(rect.l, 0.f, std::max(0.f, docSize.w - rect.w)); + rect.t = std::clamp(rect.t, 0.f, std::max(0.f, docSize.h - rect.h)); + return rect; +} + +Rml::Vector2f TouchControlsEditor::min_visual_size(std::size_t index) const noexcept { + if (!control_valid(index)) { + return {kMinControlDp, kMinControlDp}; + } + + const auto id = touch_layout_controls()[index].layoutId; + if (id == "actionBar") { + return {kMinActionBarWidthDp, kMinActionBarHeightDp}; + } + if (id == "triggerL" || id == "triggerR" || id == "buttonZ" || id == "skip") { + return {kMinTriggerWidthDp, kMinTriggerHeightDp}; + } + return {kMinControlDp, kMinControlDp}; +} + +bool TouchControlsEditor::handle_nav_command(Rml::Event& event, NavCommand cmd) { + if (cmd == NavCommand::Cancel || cmd == NavCommand::Menu) { + cancel_edit(); + return true; + } + return Document::handle_nav_command(event, cmd); +} + +void TouchControlsEditor::save_layout() { + mWorkingLayout.version = ControlLayout::Version; + getSettings().game.touchControlsLayout.setValue(mWorkingLayout); + config::Save(); + mDoAud_seStartMenu(kSoundItemChange); + pop(); +} + +void TouchControlsEditor::request_reset() { + auto dismiss = [](Modal& modal) { modal.pop(); }; + push(std::make_unique(Modal::Props{ + .title = "Reset Touch Layout?", + .bodyRml = "Reset controls to their default layout. This will not be saved until you press Save.", + .actions = + { + ModalAction{ + .label = "Reset", + .onPressed = + [this, dismiss](Modal& modal) { + reset_working_layout(); + mDoAud_seStartMenu(kSoundItemChange); + dismiss(modal); + }, + }, + ModalAction{ + .label = "Cancel", + .onPressed = dismiss, + }, + }, + })); +} + +void TouchControlsEditor::reset_working_layout() noexcept { + mWorkingLayout = ControlLayout{}; + mWorkingLayout.version = ControlLayout::Version; + mPointerEdit = {}; + sync_control_layouts(); + sync_selection_frame(); +} + +void TouchControlsEditor::cancel_edit() { + mDoAud_seStartMenu(kSoundWindowClose); + pop(); +} + +} // namespace dusk::ui diff --git a/src/dusk/ui/touch_controls_editor.hpp b/src/dusk/ui/touch_controls_editor.hpp new file mode 100644 index 0000000000..4af33d8fbc --- /dev/null +++ b/src/dusk/ui/touch_controls_editor.hpp @@ -0,0 +1,98 @@ +#pragma once + +#include "controls.hpp" +#include "document.hpp" +#include "touch_controls_common.hpp" + +#include +#include +#include + +namespace dusk::ui { + +class TouchControlsEditor final : public Document { +public: + TouchControlsEditor(); + + void show() override; + void hide(bool close) override; + void update() override; + bool focus() override; + + enum class EditHandle { + Move, + Left, + Right, + Top, + Bottom, + TopLeft, + TopRight, + BottomLeft, + BottomRight, + }; + +private: + struct LayoutState { + std::optional visualRect; + std::optional appliedBox; + float layoutScale = 1.0f; + std::optional appliedTransform; + }; + + struct EditElement { + Rml::Element* root = nullptr; + LayoutState layout; + }; + + struct PointerEdit { + std::size_t index = kTouchLayoutControlCount; + SDL_FingerID touchId = 0; + Rml::Vector2f startPointerDp; + ControlRect startVisual; + ControlProps startProps; + std::optional storedProps; + EditHandle handle = EditHandle::Move; + bool active = false; + bool touch = false; + bool dragging = false; + }; + + void bind_control_events() noexcept; + void bind_handle_events() noexcept; + void bind_toolbar_events() noexcept; + void bind_button_command( + Rml::Element* element, void (TouchControlsEditor::*callback)()) noexcept; + void sync_control_layouts() noexcept; + void sync_selection_frame() noexcept; + void set_selected_control(std::size_t index) noexcept; + void clear_selected_control() noexcept; + ControlProps props_for(std::size_t index) const; + void store_props(std::size_t index, ControlRect visual, ControlProps props) noexcept; + void restore_active_control() noexcept; + bool begin_edit(std::size_t index, EditHandle handle, Rml::Vector2f positionPx, bool touch, + SDL_FingerID touchId = 0) noexcept; + bool continue_edit(Rml::Vector2f positionPx) noexcept; + bool end_edit(bool touch, SDL_FingerID touchId, bool cancelled) noexcept; + Rml::Vector2f pointer_position_dp(Rml::Vector2f positionPx) const noexcept; + ControlRect rect_for_edit(Rml::Vector2f pointerDp, ControlProps& props) const noexcept; + ControlRect clamp_visual_rect(std::size_t index, ControlRect rect) const noexcept; + Rml::Vector2f min_visual_size(std::size_t index) const noexcept; + bool handle_nav_command(Rml::Event& event, NavCommand cmd) override; + void save_layout(); + void request_reset(); + void reset_working_layout() noexcept; + void cancel_edit(); + + Rml::Element* mRoot = nullptr; + Rml::Element* mSelectionFrame = nullptr; + Rml::Element* mSaveButton = nullptr; + Rml::Element* mResetButton = nullptr; + Rml::Element* mCancelButton = nullptr; + std::array mElements{}; + ControlLayout mWorkingLayout; + PointerEdit mPointerEdit; + std::optional mAppliedSelectionFrame; + std::size_t mSelectedIndex = kTouchLayoutControlCount; +}; + +} // namespace dusk::ui diff --git a/src/dusk/ui/ui.cpp b/src/dusk/ui/ui.cpp index af05d0f476..0d9d0b7e1e 100644 --- a/src/dusk/ui/ui.cpp +++ b/src/dusk/ui/ui.cpp @@ -1,7 +1,11 @@ #include "ui.hpp" #include -#include +#include +#include +#include +#include +#include #include #include #include @@ -11,11 +15,12 @@ #include #include "aurora/lib/window.hpp" +#include "dusk/config.hpp" #include "dusk/io.hpp" #include "input.hpp" +#include "icon_provider.hpp" #include "prelaunch.hpp" #include "window.hpp" -#include "dusk/config.hpp" namespace dusk::ui { namespace { @@ -56,11 +61,13 @@ bool initialize() noexcept { load_font("MaterialSymbolsRounded-Regular.ttf"); load_font("NotoMono-Regular.ttf"); + register_icon_texture_provider(); sInitialized = true; return true; } void shutdown() noexcept { + unregister_icon_texture_provider(); sDocumentStack.clear(); sPassiveDocuments.clear(); sConnectedGamepads.clear(); diff --git a/src/f_ap/f_ap_game.cpp b/src/f_ap/f_ap_game.cpp index e2e8a61168..ca11ccbe64 100644 --- a/src/f_ap/f_ap_game.cpp +++ b/src/f_ap/f_ap_game.cpp @@ -29,6 +29,7 @@ #include "tracy/Tracy.hpp" #include #include +#include "dusk/menu_pointer.h" #endif fapGm_HIO_c::fapGm_HIO_c() { @@ -743,6 +744,7 @@ static void fapGm_AfterRecord() { BOOL isRecording = false; static void duskExecute() { + dusk::menu_pointer::begin_game_frame(); dusk::input::handleGamepadColor(); updateAutoSave(); @@ -842,6 +844,7 @@ void fapGm_Execute() { #ifdef TARGET_PC dusk::speedrun::onGameFrame(); dusk::AchievementSystem::get().tick(); + dusk::menu_pointer::end_game_frame(); #endif } diff --git a/src/m_Do/m_Do_controller_pad.cpp b/src/m_Do/m_Do_controller_pad.cpp index 8ba03fc63b..f7eaa92ca2 100644 --- a/src/m_Do/m_Do_controller_pad.cpp +++ b/src/m_Do/m_Do_controller_pad.cpp @@ -12,6 +12,11 @@ #include "m_Do/m_Do_main.h" #include "tracy/Tracy.hpp" +#if TARGET_PC +#include "dusk/menu_pointer.h" +#include "dusk/ui/touch_controls.hpp" +#endif + JUTGamePad* mDoCPd_c::m_gamePad[4]; interface_of_controller_pad mDoCPd_c::m_cpadInfo[4]; @@ -58,6 +63,9 @@ void mDoCPd_c::create() { void mDoCPd_c::read() { ZoneScoped; +#if TARGET_PC + dusk::ui::sync_virtual_input(); +#endif JUTGamePad::read(); if (!mDoRst::isReset() && mDoRst::is3ButtonReset()) { @@ -88,6 +96,12 @@ void mDoCPd_c::read() { cLib_memSet(interface, 0, sizeof(interface_of_controller_pad)); } else { convert(interface, *pad); +#if TARGET_PC + const u32 suppressedButtons = dusk::menu_pointer::suppressed_pad_buttons(i); + interface->mButtonFlags &= ~suppressedButtons; + interface->mPressedButtonFlags &= ~suppressedButtons; + dusk::menu_pointer::finish_pad_suppression_read(i); +#endif LRlockCheck(interface); } #if DEBUG diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index 8fedae1f6a..4694146714 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -65,6 +65,7 @@ #include "dusk/ui/overlay.hpp" #include "dusk/ui/prelaunch.hpp" #include "dusk/ui/preset.hpp" +#include "dusk/ui/touch_controls.hpp" #include "dusk/ui/ui.hpp" #include "version.h" @@ -663,6 +664,7 @@ int game_main(int argc, char* argv[]) { dusk::texture_replacements::reload(); dusk::ui::initialize(); dusk::ui::push_document(std::make_unique(), true, true); + dusk::ui::push_document(std::make_unique(), false, true); dusk::ui::push_document(std::make_unique(), false); // Invalidate a bad saved isoPath so that Dusklight can't get blocked from starting up. From db87b9195469e066df5bf5d0f1ec3ee3faf17e79 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Mon, 15 Jun 2026 22:46:50 -0600 Subject: [PATCH 025/150] Update aurora & redraw hearts guage on HUD scale change --- extern/aurora | 2 +- src/d/d_meter2.cpp | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/extern/aurora b/extern/aurora index f49d3c5f58..319f9bb546 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit f49d3c5f58aa2fd3e21f73a129e07c1df68aaa98 +Subproject commit 319f9bb546f35f017104bcc0780a20e307bf93b5 diff --git a/src/d/d_meter2.cpp b/src/d/d_meter2.cpp index 4afaf450a8..f628466919 100644 --- a/src/d/d_meter2.cpp +++ b/src/d/d_meter2.cpp @@ -663,8 +663,15 @@ void dMeter2_c::moveLife() { draw_life = true; } - if (mLifeGaugeScale != g_drawHIO.mLifeParentScale) { - mLifeGaugeScale = g_drawHIO.mLifeParentScale; +#if TARGET_PC + const f32 lifeGaugeScale = + g_drawHIO.mLifeParentScale * + std::clamp(dusk::getSettings().game.hudScale.getValue(), 0.5f, 2.0f); +#else + const f32 lifeGaugeScale = g_drawHIO.mLifeParentScale; +#endif + if (mLifeGaugeScale != lifeGaugeScale) { + mLifeGaugeScale = lifeGaugeScale; draw_life = true; } From 8e9d4d624abc491ac21bfcefdd3f233cd1c16efb Mon Sep 17 00:00:00 2001 From: Luke Street Date: Mon, 15 Jun 2026 22:55:30 -0600 Subject: [PATCH 026/150] Fix hookshot hanging w/ touch controls --- include/d/actor/d_a_alink.h | 1 + src/d/actor/d_a_alink_dusk.cpp | 10 ++++++++++ src/dusk/ui/touch_controls.cpp | 9 +++++++-- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/include/d/actor/d_a_alink.h b/include/d/actor/d_a_alink.h index fa75c085fb..a3cd2cbfed 100644 --- a/include/d/actor/d_a_alink.h +++ b/include/d/actor/d_a_alink.h @@ -4556,6 +4556,7 @@ public: void handleWolfHowl(); void handleQuickTransform(); bool checkAimContext(); + bool checkTouchAimCaptureContext(); void onIronBallChainInterpCallback(); diff --git a/src/d/actor/d_a_alink_dusk.cpp b/src/d/actor/d_a_alink_dusk.cpp index b787eebcaa..b1d06c089f 100644 --- a/src/d/actor/d_a_alink_dusk.cpp +++ b/src/d/actor/d_a_alink_dusk.cpp @@ -175,3 +175,13 @@ bool daAlink_c::checkAimContext() { return false; } } + +bool daAlink_c::checkTouchAimCaptureContext() { + switch (mProcID) { + case PROC_HOOKSHOT_ROOF_WAIT: + case PROC_HOOKSHOT_WALL_WAIT: + return false; + default: + return checkAimContext(); + } +} diff --git a/src/dusk/ui/touch_controls.cpp b/src/dusk/ui/touch_controls.cpp index a85a90930f..e10ca399c6 100644 --- a/src/dusk/ui/touch_controls.cpp +++ b/src/dusk/ui/touch_controls.cpp @@ -156,6 +156,11 @@ bool player_attention_locked() noexcept { return player != nullptr && (player->checkAttentionLock() || player->checkEnemyAttentionLock()); } +bool touch_aim_capture_active() noexcept { + auto* player = daAlink_getAlinkActorClass(); + return player != nullptr && player->checkTouchAimCaptureContext() && dCamera_c::isAimActive(); +} + bool item_wheel_active() noexcept { return dMeter2Info_getWindowStatus() == 2; } @@ -692,7 +697,7 @@ void TouchControls::sync_touch_state() noexcept { } sync_l_lock_state(); - const bool aimActive = dCamera_c::isAimActive(); + const bool aimActive = touch_aim_capture_active(); if (aimActive && mMoveTouch.active) { if (!mCameraTouch.active) { mCameraTouch = mMoveTouch; @@ -1208,7 +1213,7 @@ void TouchControls::handle_touch_down(Rml::Event& event) noexcept { } const auto id = touch_event_id(event); - if (dCamera_c::isAimActive()) { + if (touch_aim_capture_active()) { if (!mCameraTouch.active) { mCameraTouch = { .id = id, From 44cb2c84ba5830552b2d18759d778d0d42f2bf95 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Mon, 15 Jun 2026 23:07:00 -0600 Subject: [PATCH 027/150] Update aurora --- extern/aurora | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extern/aurora b/extern/aurora index 319f9bb546..b78cca8e58 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 319f9bb546f35f017104bcc0780a20e307bf93b5 +Subproject commit b78cca8e5851768ccd52eef0d965b903abba49d1 From 16cc37ca10ebb64db1e714c6a53d7f21b13766b4 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Mon, 15 Jun 2026 23:39:36 -0600 Subject: [PATCH 028/150] Android: Call Surface.setFrameRate & update it --- files.cmake | 2 + .../com/twilitrealm/dusk/DuskActivity.java | 82 +++++++++++++++++++ src/dusk/android_frame_rate.cpp | 74 +++++++++++++++++ src/dusk/android_frame_rate.hpp | 7 ++ src/dusk/ui/settings.cpp | 16 +++- src/m_Do/m_Do_main.cpp | 2 + 6 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 src/dusk/android_frame_rate.cpp create mode 100644 src/dusk/android_frame_rate.hpp diff --git a/files.cmake b/files.cmake index 715622a495..f891024e8a 100644 --- a/files.cmake +++ b/files.cmake @@ -1418,6 +1418,8 @@ set(DUSK_FILES include/dusk/scope_guard.hpp src/dusk/dvd_asset.cpp src/d/actor/d_a_alink_dusk.cpp + src/dusk/android_frame_rate.hpp + src/dusk/android_frame_rate.cpp src/dusk/asserts.cpp src/dusk/batch.cpp src/dusk/batch.hpp diff --git a/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskActivity.java b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskActivity.java index cc1c985193..96fc302d9e 100644 --- a/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskActivity.java +++ b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskActivity.java @@ -4,6 +4,7 @@ import android.app.ActionBar; import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.ClipData; +import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.net.Uri; @@ -14,12 +15,16 @@ import android.provider.DocumentsContract; import android.provider.OpenableColumns; import android.provider.Settings; import android.util.Log; +import android.view.Display; +import android.view.Surface; +import android.view.SurfaceHolder; import android.view.View; import android.view.Window; import android.view.WindowInsets; import android.view.WindowInsetsController; import org.libsdl.app.SDLActivity; +import org.libsdl.app.SDLSurface; import java.io.File; import java.util.ArrayList; @@ -27,6 +32,7 @@ import java.util.List; public class DuskActivity extends SDLActivity { private static final String TAG = "DuskActivity"; + private static final float DEFAULT_SURFACE_FRAME_RATE = 60.0f; private static final int FOLDER_DIALOG_REQUEST_CODE = 0x4455; private static final int MANAGE_STORAGE_REQUEST_CODE = 0x4456; private static final String EXTERNAL_STORAGE_AUTHORITY = @@ -88,6 +94,11 @@ public class DuskActivity extends SDLActivity { hideSystemBars(); } + @Override + protected SDLSurface createSDLSurface(Context context) { + return new DuskSurface(context); + } + @Override protected void onResume() { super.onResume(); @@ -139,6 +150,77 @@ public class DuskActivity extends SDLActivity { }; } + public void setPreferredSurfaceFrameRate(float frameRate) { + runOnUiThread(() -> { + if (mSurface instanceof DuskSurface) { + ((DuskSurface)mSurface).setPreferredFrameRate(frameRate); + } + }); + } + + private static final class DuskSurface extends SDLSurface { + private float preferredFrameRate = DEFAULT_SURFACE_FRAME_RATE; + + DuskSurface(Context context) { + super(context); + } + + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { + super.surfaceChanged(holder, format, width, height); + setTargetFrameRate(holder); + } + + void setPreferredFrameRate(float frameRate) { + preferredFrameRate = frameRate; + setTargetFrameRate(getHolder()); + } + + private void setTargetFrameRate(SurfaceHolder holder) { + if (!mIsSurfaceReady || Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return; + } + + Surface surface = holder != null ? holder.getSurface() : getHolder().getSurface(); + if (surface == null || !surface.isValid()) { + return; + } + + float targetFrameRate = getMaxSupportedFrameRate(); + if (preferredFrameRate > 0.0f) { + targetFrameRate = preferredFrameRate; + } + if (targetFrameRate <= 0.0f) { + return; + } + + try { + surface.setFrameRate( + targetFrameRate, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT); + Log.v(TAG, "Requested surface frame rate " + targetFrameRate + " fps"); + } catch (RuntimeException e) { + Log.w(TAG, "Failed to request surface frame rate", e); + } + } + + private float getMaxSupportedFrameRate() { + if (mDisplay == null) { + return 0.0f; + } + + float maxFrameRate = mDisplay.getRefreshRate(); + Display.Mode[] modes = mDisplay.getSupportedModes(); + if (modes == null) { + return maxFrameRate; + } + + for (Display.Mode mode : modes) { + maxFrameRate = Math.max(maxFrameRate, mode.getRefreshRate()); + } + return maxFrameRate; + } + } + @Override protected String[] getArguments() { Intent intent = getIntent(); diff --git a/src/dusk/android_frame_rate.cpp b/src/dusk/android_frame_rate.cpp new file mode 100644 index 0000000000..bf889a9482 --- /dev/null +++ b/src/dusk/android_frame_rate.cpp @@ -0,0 +1,74 @@ +#include "dusk/android_frame_rate.hpp" + +#if defined(TARGET_ANDROID) || defined(__ANDROID__) || defined(ANDROID) +#include "dusk/settings.h" + +#include +#include + +namespace dusk::android { +namespace { + +float preferred_surface_frame_rate() { + switch (getSettings().game.enableFrameInterpolation.getValue()) { + case FrameInterpMode::Off: + return 30.0f; + case FrameInterpMode::Unlimited: + default: + return 0.0f; + case FrameInterpMode::Capped: + return static_cast(getSettings().video.maxFrameRate.getValue()); + } +} + +bool clear_pending_exception(JNIEnv* env) { + if (env == nullptr || !env->ExceptionCheck()) { + return false; + } + env->ExceptionClear(); + return true; +} + +} // namespace + +void update_surface_frame_rate() { + auto* env = static_cast(SDL_GetAndroidJNIEnv()); + if (env == nullptr) { + return; + } + + jobject activity = static_cast(SDL_GetAndroidActivity()); + if (activity == nullptr || clear_pending_exception(env)) { + if (activity != nullptr) { + env->DeleteLocalRef(activity); + } + return; + } + + jclass activityClass = env->GetObjectClass(activity); + if (activityClass == nullptr || clear_pending_exception(env)) { + env->DeleteLocalRef(activity); + return; + } + + jmethodID setPreferredFrameRate = + env->GetMethodID(activityClass, "setPreferredSurfaceFrameRate", "(F)V"); + env->DeleteLocalRef(activityClass); + if (setPreferredFrameRate == nullptr || clear_pending_exception(env)) { + env->DeleteLocalRef(activity); + return; + } + + jvalue args[1]{}; + args[0].f = preferred_surface_frame_rate(); + env->CallVoidMethodA(activity, setPreferredFrameRate, args); + env->DeleteLocalRef(activity); + clear_pending_exception(env); +} + +} // namespace dusk::android +#else +namespace dusk::android { +void update_surface_frame_rate() {} +} // namespace dusk::android +#endif diff --git a/src/dusk/android_frame_rate.hpp b/src/dusk/android_frame_rate.hpp new file mode 100644 index 0000000000..03a7876633 --- /dev/null +++ b/src/dusk/android_frame_rate.hpp @@ -0,0 +1,7 @@ +#pragma once + +namespace dusk::android { + +void update_surface_frame_rate(); + +} // namespace dusk::android diff --git a/src/dusk/ui/settings.cpp b/src/dusk/ui/settings.cpp index 194ddd78a7..1a21d4ae34 100644 --- a/src/dusk/ui/settings.cpp +++ b/src/dusk/ui/settings.cpp @@ -6,6 +6,7 @@ #include "dusk/app_info.hpp" #include "dusk/audio/DuskAudioSystem.h" #include "dusk/audio/DuskDsp.hpp" +#include "dusk/android_frame_rate.hpp" #include "dusk/config.hpp" #include "dusk/hotkeys.h" #include "dusk/data.hpp" @@ -478,14 +479,19 @@ SelectButton& config_percent_select(Pane& leftPane, Pane& rightPane, ConfigVar& var, Rml::String key, Rml::String helpText, int min, int max, int step = 5, - std::function isDisabled = {}, std::string suffix = "") { + std::function isDisabled = {}, std::function onChange = {}, + std::string suffix = "") { auto& button = leftPane.add_child(NumberButton::Props{ .key = std::move(key), .getValue = [&var] { return var; }, .setValue = - [&var, min, max](int value) { - var.setValue(std::clamp(value, min, max)); + [&var, min, max, callback = std::move(onChange)](int value) { + const int clampedValue = std::clamp(value, min, max); + var.setValue(clampedValue); config::Save(); + if (callback) { + callback(clampedValue); + } }, .isDisabled = std::move(isDisabled), .isModified = [&var] { return var.getValue() != var.getDefaultValue(); }, @@ -929,6 +935,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .on_pressed([i] { mDoAud_seStartMenu(kSoundItemChange); getSettings().game.enableFrameInterpolation.setValue(static_cast(i)); + android::update_surface_frame_rate(); config::Save(); }); } @@ -936,7 +943,8 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { }); config_int_select(leftPane, rightPane, getSettings().video.maxFrameRate, "Framerate Cap", "Limit the framerate to the specified value.", 30, 540, 1, - [] { return getSettings().game.enableFrameInterpolation.getValue() != FrameInterpMode::Capped; }); + [] { return getSettings().game.enableFrameInterpolation.getValue() != FrameInterpMode::Capped; }, + [](int) { android::update_surface_frame_rate(); }); config_bool_select(leftPane, rightPane, getSettings().game.enableMapBackground, { .key = "Enable Mini-Map Shadows", diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index 4694146714..2cddcd0981 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -47,6 +47,7 @@ #include #include #include "SSystem/SComponent/c_API.h" +#include "dusk/android_frame_rate.hpp" #include "dusk/app_info.hpp" #include "dusk/crash_handler.h" #include "dusk/crash_reporting.h" @@ -555,6 +556,7 @@ int game_main(int argc, char* argv[]) { dusk::resetForSpeedrunMode(); } ApplyCVarOverrides(parsed_arg_options["cvar"]); + dusk::android::update_surface_frame_rate(); dusk::crash_reporting::initialize(); dusk::crash_handler::install(); // TODO: How to handle this? From 42e12eb5abc61067bea3030cd0447a915e53f679 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 16 Jun 2026 00:14:19 -0600 Subject: [PATCH 029/150] Update aurora & fix RCSS warning --- extern/aurora | 2 +- res/rml/touch_controls.rcss | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/extern/aurora b/extern/aurora index b78cca8e58..0c1b0d6f55 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit b78cca8e5851768ccd52eef0d965b903abba49d1 +Subproject commit 0c1b0d6f5536a2401b17c48d61fc97c5886711ad diff --git a/res/rml/touch_controls.rcss b/res/rml/touch_controls.rcss index 5bf42136ad..4c8057d6c0 100644 --- a/res/rml/touch_controls.rcss +++ b/res/rml/touch_controls.rcss @@ -139,7 +139,6 @@ action-bar { position: absolute; display: flex; align-items: center; - justify-content: stretch; border: 1dp rgba(255, 255, 255, 22%); border-radius: 23dp; background-color: rgba(22, 24, 28, 48%); From e4557efb23daefbe7d56508ec9cb827c6cfab703 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 16 Jun 2026 01:39:54 -0600 Subject: [PATCH 030/150] Disable PkgConfig on Windows --- CMakePresets.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CMakePresets.json b/CMakePresets.json index 461e751296..b7cb18c2d1 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -158,7 +158,11 @@ "cacheVariables": { "CMAKE_C_COMPILER": "cl", "CMAKE_CXX_COMPILER": "cl", - "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install" + "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install", + "CMAKE_DISABLE_FIND_PACKAGE_PkgConfig": { + "type": "BOOL", + "value": true + } }, "vendor": { "microsoft.com/VisualStudioSettings/CMake/1.0": { From 7a34830dc7cc809d6d261fa4d8ce8931f54d1309 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 16 Jun 2026 01:40:00 -0600 Subject: [PATCH 031/150] Update aurora --- extern/aurora | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extern/aurora b/extern/aurora index 0c1b0d6f55..153231b398 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 0c1b0d6f5536a2401b17c48d61fc97c5886711ad +Subproject commit 153231b398ebf731a3d838e061acfcea50905089 From facbf35343e1760245f56f5b7adfcb9c0d17e32d Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 16 Jun 2026 01:53:04 -0600 Subject: [PATCH 032/150] Update aurora --- extern/aurora | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extern/aurora b/extern/aurora index 153231b398..3f1c26f2a6 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 153231b398ebf731a3d838e061acfcea50905089 +Subproject commit 3f1c26f2a6a6b6a9f58232ba6e8d7394e573cfe0 From 9eb9acfa112cbf078147482bcbfebbbe2aa11120 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 16 Jun 2026 12:56:02 -0600 Subject: [PATCH 033/150] Move frame limiter after aurora_end_frame --- src/m_Do/m_Do_main.cpp | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index 2cddcd0981..0d1e695524 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -334,11 +334,21 @@ void main01(void) { mDoAud_Execute(); } + aurora_end_frame(); + + FrameMark; + +#ifdef DUSK_DISCORD + dusk::discord::run_callbacks(); + dusk::discord::update_presence(); +#endif + static Limiter main_loop_limiter; static double last_fps_setting = 0.0; static Limiter::duration_t target_ns = 0; if (dusk::getSettings().game.enableFrameInterpolation.getValue() == dusk::FrameInterpMode::Capped && !dusk::getTransientSettings().skipFrameRateLimit) { + ZoneScopedN("Frame limiter"); double current_fps = dusk::getSettings().video.maxFrameRate.getValue(); if (current_fps != last_fps_setting) { last_fps_setting = current_fps; @@ -350,16 +360,6 @@ void main01(void) { } else { main_loop_limiter.Reset(); } - - aurora_end_frame(); - - - FrameMark; - -#ifdef DUSK_DISCORD - dusk::discord::run_callbacks(); - dusk::discord::update_presence(); -#endif } while (dusk::IsRunning); exit:; From 0c9c8795cea50ce65e7cb8923d5f146666a774b2 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 16 Jun 2026 13:12:37 -0600 Subject: [PATCH 034/150] Update aurora --- extern/aurora | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extern/aurora b/extern/aurora index 3f1c26f2a6..39933ea02a 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 3f1c26f2a6a6b6a9f58232ba6e8d7394e573cfe0 +Subproject commit 39933ea02a603e841e4635d42ac1d07bd636d870 From 1fd8a2ca3c41234abcf19274f356e48395d229c0 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 16 Jun 2026 13:37:04 -0600 Subject: [PATCH 035/150] More fixes for clawshot touch controls --- extern/aurora | 2 +- include/d/actor/d_a_alink.h | 2 +- src/d/actor/d_a_alink_dusk.cpp | 2 +- src/d/actor/d_a_alink_link.inc | 6 +++--- src/d/d_camera.cpp | 2 +- src/dusk/ui/touch_controls.cpp | 9 ++------- 6 files changed, 9 insertions(+), 14 deletions(-) diff --git a/extern/aurora b/extern/aurora index 39933ea02a..0acac1320c 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 39933ea02a603e841e4635d42ac1d07bd636d870 +Subproject commit 0acac1320c58d9083f9bbd8bd8b40647c0d52ecf diff --git a/include/d/actor/d_a_alink.h b/include/d/actor/d_a_alink.h index a3cd2cbfed..a1d78c6116 100644 --- a/include/d/actor/d_a_alink.h +++ b/include/d/actor/d_a_alink.h @@ -4556,7 +4556,7 @@ public: void handleWolfHowl(); void handleQuickTransform(); bool checkAimContext(); - bool checkTouchAimCaptureContext(); + bool checkAimInputContext(); void onIronBallChainInterpCallback(); diff --git a/src/d/actor/d_a_alink_dusk.cpp b/src/d/actor/d_a_alink_dusk.cpp index b1d06c089f..8a2bc0025b 100644 --- a/src/d/actor/d_a_alink_dusk.cpp +++ b/src/d/actor/d_a_alink_dusk.cpp @@ -176,7 +176,7 @@ bool daAlink_c::checkAimContext() { } } -bool daAlink_c::checkTouchAimCaptureContext() { +bool daAlink_c::checkAimInputContext() { switch (mProcID) { case PROC_HOOKSHOT_ROOF_WAIT: case PROC_HOOKSHOT_WALL_WAIT: diff --git a/src/d/actor/d_a_alink_link.inc b/src/d/actor/d_a_alink_link.inc index 0cf53edf3e..cf2f2544d6 100644 --- a/src/d/actor/d_a_alink_link.inc +++ b/src/d/actor/d_a_alink_link.inc @@ -123,7 +123,7 @@ BOOL daAlink_c::setBodyAngleToCamera() { } #if TARGET_PC - if (dusk::getSettings().game.enableMouseAim && checkAimContext()) { + if (dusk::getSettings().game.enableMouseAim && checkAimInputContext()) { sp8 = mBodyAngle.x; } else #endif @@ -142,7 +142,7 @@ BOOL daAlink_c::setBodyAngleToCamera() { #if TARGET_PC if ((dusk::getSettings().game.enableGyroAim || dusk::getSettings().game.enableMouseAim) && - checkAimContext()) + checkAimInputContext()) { f32 gyro_scale = 1.0f; if (checkWolfEyeUp()) { @@ -174,7 +174,7 @@ BOOL daAlink_c::setBodyAngleToCamera() { } } - if (dusk::getSettings().game.enableTouchControls && checkAimContext()) { + if (dusk::getSettings().game.enableTouchControls && checkAimInputContext()) { f32 touchYawDp = 0.0f; f32 touchPitchDp = 0.0f; if (dusk::touch_camera::consume_delta(touchYawDp, touchPitchDp)) { diff --git a/src/d/d_camera.cpp b/src/d/d_camera.cpp index 483e052312..cf0f7cbb56 100644 --- a/src/d/d_camera.cpp +++ b/src/d/d_camera.cpp @@ -7505,7 +7505,7 @@ static bool sTouchFreeCameraActive = false; bool dCamera_c::isAimActive() { auto* link = daAlink_getAlinkActorClass(); - return link != nullptr && link->checkAimContext() && + return link != nullptr && link->checkAimInputContext() && dComIfGp_checkCameraAttentionStatus(link->field_0x317c, 0x10); } diff --git a/src/dusk/ui/touch_controls.cpp b/src/dusk/ui/touch_controls.cpp index e10ca399c6..a85a90930f 100644 --- a/src/dusk/ui/touch_controls.cpp +++ b/src/dusk/ui/touch_controls.cpp @@ -156,11 +156,6 @@ bool player_attention_locked() noexcept { return player != nullptr && (player->checkAttentionLock() || player->checkEnemyAttentionLock()); } -bool touch_aim_capture_active() noexcept { - auto* player = daAlink_getAlinkActorClass(); - return player != nullptr && player->checkTouchAimCaptureContext() && dCamera_c::isAimActive(); -} - bool item_wheel_active() noexcept { return dMeter2Info_getWindowStatus() == 2; } @@ -697,7 +692,7 @@ void TouchControls::sync_touch_state() noexcept { } sync_l_lock_state(); - const bool aimActive = touch_aim_capture_active(); + const bool aimActive = dCamera_c::isAimActive(); if (aimActive && mMoveTouch.active) { if (!mCameraTouch.active) { mCameraTouch = mMoveTouch; @@ -1213,7 +1208,7 @@ void TouchControls::handle_touch_down(Rml::Event& event) noexcept { } const auto id = touch_event_id(event); - if (touch_aim_capture_active()) { + if (dCamera_c::isAimActive()) { if (!mCameraTouch.active) { mCameraTouch = { .id = id, From cc9c15de54e465270b212b0a7cc7201806a4c09b Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 16 Jun 2026 13:51:17 -0600 Subject: [PATCH 036/150] Hawkeye support in touch controls --- src/dusk/ui/touch_controls.cpp | 36 +++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/dusk/ui/touch_controls.cpp b/src/dusk/ui/touch_controls.cpp index a85a90930f..6056f0d11d 100644 --- a/src/dusk/ui/touch_controls.cpp +++ b/src/dusk/ui/touch_controls.cpp @@ -156,6 +156,10 @@ bool player_attention_locked() noexcept { return player != nullptr && (player->checkAttentionLock() || player->checkEnemyAttentionLock()); } +bool hawkeye_active() noexcept { + return dCamera_c::isAimActive() && dComIfGp_checkPlayerStatus0(0, 0x200000); +} + bool item_wheel_active() noexcept { return dMeter2Info_getWindowStatus() == 2; } @@ -174,7 +178,7 @@ enum class StickOutput { }; StickOutput stick_output_mode() noexcept { - if (fishing_controls_active()) { + if (fishing_controls_active() || hawkeye_active()) { return StickOutput::CStick; } return StickOutput::MainStick; @@ -693,7 +697,7 @@ void TouchControls::sync_touch_state() noexcept { sync_l_lock_state(); const bool aimActive = dCamera_c::isAimActive(); - if (aimActive && mMoveTouch.active) { + if (aimActive && !hawkeye_active() && mMoveTouch.active) { if (!mCameraTouch.active) { mCameraTouch = mMoveTouch; mCameraTouch.start = mMoveTouch.current; @@ -1208,7 +1212,26 @@ void TouchControls::handle_touch_down(Rml::Event& event) noexcept { } const auto id = touch_event_id(event); + const auto dimensions = context->GetDimensions(); + const float top = mSafeInsets.top + kAnalogZoneTopDp * touch_dp_scale(); + const float bottom = static_cast(dimensions.y) - mSafeInsets.bottom - + kAnalogZoneBottomDp * touch_dp_scale(); + const auto width = static_cast(dimensions.x); + const bool inAnalogZone = position.y >= top && position.y <= bottom; + const bool inLeftZone = position.x < width * kLeftZoneWidth; if (dCamera_c::isAimActive()) { + if (hawkeye_active() && inAnalogZone && inLeftZone) { + if (!mMoveTouch.active) { + mMoveTouch = { + .id = id, + .start = position, + .current = position, + .active = true, + }; + } + return; + } + if (!mCameraTouch.active) { mCameraTouch = { .id = id, @@ -1220,16 +1243,11 @@ void TouchControls::handle_touch_down(Rml::Event& event) noexcept { return; } - const auto dimensions = context->GetDimensions(); - const float top = mSafeInsets.top + kAnalogZoneTopDp * touch_dp_scale(); - const float bottom = static_cast(dimensions.y) - mSafeInsets.bottom - - kAnalogZoneBottomDp * touch_dp_scale(); - if (position.y < top || position.y > bottom) { + if (!inAnalogZone) { return; } - const auto width = static_cast(dimensions.x); - if (!mMoveTouch.active && position.x < width * kLeftZoneWidth) { + if (!mMoveTouch.active && inLeftZone) { mMoveTouch = { .id = id, .start = position, From f5642f307384bd4b7b7a09f690e391152445c815 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 16 Jun 2026 15:10:09 -0600 Subject: [PATCH 037/150] UI: Split active/visible concepts & fix nav forwarding --- extern/aurora | 2 +- src/dusk/ui/document.cpp | 23 ++++++++++++++--------- src/dusk/ui/document.hpp | 6 +++--- src/dusk/ui/prelaunch.cpp | 2 +- src/dusk/ui/ui.cpp | 14 +++++++++----- src/dusk/ui/ui.hpp | 2 +- 6 files changed, 29 insertions(+), 20 deletions(-) diff --git a/extern/aurora b/extern/aurora index 0acac1320c..22351fb0b7 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 0acac1320c58d9083f9bbd8bd8b40647c0d52ecf +Subproject commit 22351fb0b76a4f4f2c4a4dff95aa300101e861aa diff --git a/src/dusk/ui/document.cpp b/src/dusk/ui/document.cpp index fc8be92a83..1ad03bc1ec 100644 --- a/src/dusk/ui/document.cpp +++ b/src/dusk/ui/document.cpp @@ -3,9 +3,7 @@ #include "aurora/rmlui.hpp" #include "ui.hpp" -#include "Z2AudioLib/Z2SeMgr.h" #include "m_Do/m_Do_audio.h" -#include namespace dusk::ui { namespace { @@ -30,19 +28,19 @@ Document::Document(const Rml::String& source, bool passive) return; } const auto cmd = map_nav_event(event); - if (cmd != NavCommand::Menu && !visible()) { + if (cmd != NavCommand::Menu && (!visible() || !active())) { event.StopImmediatePropagation(); } }, true); - const auto blockUnlessVisible = [this](Rml::Event& event) { - if (!visible()) { + const auto blockUnlessActive = [this](Rml::Event& event) { + if (!visible() || !active()) { event.StopImmediatePropagation(); } }; - listen(Rml::EventId::Mouseover, blockUnlessVisible, true); - listen(Rml::EventId::Click, blockUnlessVisible, true); - listen(Rml::EventId::Scroll, blockUnlessVisible, true); + listen(Rml::EventId::Mouseover, blockUnlessActive, true); + listen(Rml::EventId::Click, blockUnlessActive, true); + listen(Rml::EventId::Scroll, blockUnlessActive, true); listen(Rml::EventId::Keydown, [this](Rml::Event& event) { if (mPassive) { @@ -124,9 +122,16 @@ bool Document::visible() const { return *mDocument->GetProperty(Rml::PropertyId::Visibility) == Rml::Style::Visibility::Visible; } +bool Document::active() const { + return !mClosed && !mPendingClose; +} + bool Document::handle_nav_event(Rml::Event& event) { + if (!active()) { + return false; + } const auto cmd = map_nav_event(event); - if (cmd == NavCommand::None) { + if (cmd == NavCommand::None || (cmd != NavCommand::Menu && !visible())) { return false; } return handle_nav_command(event, cmd); diff --git a/src/dusk/ui/document.hpp b/src/dusk/ui/document.hpp index cf61267ee9..c3428d9fea 100644 --- a/src/dusk/ui/document.hpp +++ b/src/dusk/ui/document.hpp @@ -18,6 +18,7 @@ public: virtual void update(); virtual bool focus(); virtual bool visible() const; + virtual bool active() const; void listen(Rml::Element* element, Rml::EventId event, ScopedEventListener::Callback callback, bool capture = false); @@ -41,12 +42,11 @@ public: push_document(std::move(document)); hide(false); } - void pop() { + void pop(bool show = true) { hide(true); - show_top_document(); + focus_top_document(show); } - bool pending_close() const { return mPendingClose; } bool closed() const { return mClosed; } bool handle_nav_event(Rml::Event& event); diff --git a/src/dusk/ui/prelaunch.cpp b/src/dusk/ui/prelaunch.cpp index 44b73b75ef..998cdb87ea 100644 --- a/src/dusk/ui/prelaunch.cpp +++ b/src/dusk/ui/prelaunch.cpp @@ -715,7 +715,7 @@ Prelaunch::Prelaunch() : Document(kDocumentSource), mRoot(mDocument->GetElementB } IsGameLaunched = true; - hide(true); + pop(false); }); apply_intro_animation(mMenuButtons.back()->root(), "delay-1"); diff --git a/src/dusk/ui/ui.cpp b/src/dusk/ui/ui.cpp index 0d9d0b7e1e..ecff09f6aa 100644 --- a/src/dusk/ui/ui.cpp +++ b/src/dusk/ui/ui.cpp @@ -195,9 +195,13 @@ Document& push_document(std::unique_ptr doc, bool show, bool passive) return ret; } -void show_top_document() noexcept { +void focus_top_document(bool show) noexcept { if (auto* doc = top_document()) { - doc->show(); + if (show) { + doc->show(); + } else { + doc->focus(); + } } input::sync_input_block(); } @@ -210,13 +214,13 @@ bool any_document_visible() noexcept { bool is_prelaunch_open() noexcept { return std::any_of(sDocumentStack.begin(), sDocumentStack.end(), [](const auto& doc) { const auto* prelaunch = dynamic_cast(doc.get()); - return prelaunch != nullptr && !prelaunch->pending_close() && !prelaunch->closed(); + return prelaunch != nullptr && prelaunch->active(); }); } Document* top_document() noexcept { for (auto& doc : std::views::reverse(sDocumentStack)) { - if (!doc->closed() && !doc->pending_close()) { + if (doc->active()) { return doc.get(); } } @@ -259,7 +263,7 @@ void update() noexcept { context->GetFocusElement() == context->GetRootElement())) { for (auto& doc : std::views::reverse(sDocumentStack)) { - if (!doc->closed() && !doc->pending_close() && doc->focus()) { + if (doc->active() && doc->focus()) { break; } } diff --git a/src/dusk/ui/ui.hpp b/src/dusk/ui/ui.hpp index cbfe3dcc9d..a32bfbbcbc 100644 --- a/src/dusk/ui/ui.hpp +++ b/src/dusk/ui/ui.hpp @@ -74,7 +74,7 @@ void update() noexcept; Document& push_document( std::unique_ptr doc, bool show = true, bool passive = false) noexcept; -void show_top_document() noexcept; +void focus_top_document(bool show) noexcept; bool any_document_visible() noexcept; bool is_prelaunch_open() noexcept; Document* top_document() noexcept; From 427dcfab8237f764f0373f27a8c4e5a8f14ef2f7 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 17 Jun 2026 18:09:00 -0600 Subject: [PATCH 038/150] Show active Graphics Backend in Settings; not configured --- src/dusk/ui/settings.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dusk/ui/settings.cpp b/src/dusk/ui/settings.cpp index 1a21d4ae34..f4f075a6dc 100644 --- a/src/dusk/ui/settings.cpp +++ b/src/dusk/ui/settings.cpp @@ -668,7 +668,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { leftPane.register_control( leftPane.add_select_button({ .key = "Graphics Backend", - .getValue = [] { return Rml::String{backend_name(configured_backend())}; }, + .getValue = [] { return Rml::String{backend_name(aurora_get_backend())}; }, .isModified = [] { return getSettings().backend.graphicsBackend.getValue() != From 2d4e69466b41fab2743662bb835f11dc601a930a Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 17 Jun 2026 22:48:44 -0600 Subject: [PATCH 039/150] Refine menu_pointer click events Only short clicks/taps count & they must not move between targets --- include/d/d_menu_ring.h | 1 + include/dusk/menu_pointer.h | 9 +- src/d/d_file_select.cpp | 53 +++++++----- src/d/d_menu_collect.cpp | 1 + src/d/d_menu_insect.cpp | 1 + src/d/d_menu_letter.cpp | 1 + src/d/d_menu_option.cpp | 50 +++++++++-- src/d/d_menu_ring.cpp | 24 +++++- src/d/d_menu_save.cpp | 26 +++--- src/d/d_menu_skill.cpp | 1 + src/d/d_msg_scrn_3select.cpp | 3 +- src/dusk/menu_pointer.cpp | 156 +++++++++++++++++++++++++++++++---- 12 files changed, 272 insertions(+), 54 deletions(-) diff --git a/include/d/d_menu_ring.h b/include/d/d_menu_ring.h index f2137a9748..c7467d2a4b 100644 --- a/include/d/d_menu_ring.h +++ b/include/d/d_menu_ring.h @@ -218,6 +218,7 @@ private: bool mCursorInterpPrevAngular; bool mCursorInterpCurrAngular; bool mCursorInterpInit; + bool mPointerTouchPressHoveredCurrent; #endif }; diff --git a/include/dusk/menu_pointer.h b/include/dusk/menu_pointer.h index 17546877d6..86123f1163 100644 --- a/include/dusk/menu_pointer.h +++ b/include/dusk/menu_pointer.h @@ -6,6 +6,9 @@ class CPaneMgr; namespace dusk::menu_pointer { +using TargetId = u16; +constexpr TargetId InvalidTarget = 0xffff; + enum class Context { None, FileSelect, @@ -43,12 +46,14 @@ bool active() noexcept; bool enabled() noexcept; bool mouse_capture_active() noexcept; const State& state() noexcept; +void set_hover_target(TargetId target) noexcept; bool consume_click() noexcept; +bool peek_click() noexcept; void set_dialog_choice(u8 choice, bool clicked) noexcept; bool get_dialog_choice(u8& choice) noexcept; bool consume_dialog_click(u8& choice) noexcept; -void defer_activation(Context context, u8 target) noexcept; -bool consume_deferred_activation(Context context, u8 target) noexcept; +void defer_activation(Context context, TargetId target) noexcept; +bool consume_deferred_activation(Context context, TargetId target) noexcept; void clear_deferred_activation(Context context) noexcept; u32 suppressed_pad_buttons(u32 port) noexcept; void finish_pad_suppression_read(u32 port) noexcept; diff --git a/src/d/d_file_select.cpp b/src/d/d_file_select.cpp index 6e5e2e4954..19cdcfe81c 100644 --- a/src/d/d_file_select.cpp +++ b/src/d/d_file_select.cpp @@ -777,6 +777,7 @@ bool dFile_select_c::pointerDataSelect() { if (!dusk::menu_pointer::hit_pane(mSelFilePanes[i], 8.0f)) { continue; } + dusk::menu_pointer::set_hover_target(pointer_target(s_pointerDataSelectTarget, i)); const bool clicked = dusk::menu_pointer::consume_click(); if (mSelectNum != i) { mDoAud_seStart(Z2SE_FILE_SELECT_CURSOR, NULL, 0, 0); @@ -805,6 +806,7 @@ bool dFile_select_c::pointerMenuSelect() { if (!dusk::menu_pointer::hit_pane(m3mSelPane[i], 8.0f)) { continue; } + dusk::menu_pointer::set_hover_target(pointer_target(s_pointerMenuSelectTarget, i)); const bool clicked = dusk::menu_pointer::consume_click(); if (!mIsDataNew[mSelectNum] && mSelectMenuNum != i) { mDoAud_seStart(Z2SE_SY_MENU_CURSOR_COMMON, NULL, 0, 0); @@ -833,6 +835,7 @@ bool dFile_select_c::pointerCopyDataToSelect() { if (!dusk::menu_pointer::hit_pane(mCpSelPane[i], 8.0f)) { continue; } + dusk::menu_pointer::set_hover_target(pointer_target(s_pointerCopySelectTarget, i)); const bool clicked = dusk::menu_pointer::consume_click(); if (field_0x026b != i) { mDoAud_seStart(Z2SE_FILE_SELECT_CURSOR, NULL, 0, 0); @@ -861,6 +864,7 @@ bool dFile_select_c::pointerYesNoSelect(bool errorSelect) { if (!dusk::menu_pointer::hit_pane(mYnSelPane[i], 8.0f)) { continue; } + dusk::menu_pointer::set_hover_target(pointer_target(s_pointerYesNoSelectTarget, i)); const bool clicked = (!errorSelect || field_0x0268 == i) && dusk::menu_pointer::consume_click(); if (field_0x0268 != i) { @@ -1103,12 +1107,13 @@ void dFile_select_c::dataSelectAnmSet() { void dFile_select_c::dataSelectMoveAnime() { #if TARGET_PC dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::FileSelect); - if (mSelectNum != 0xFF && dusk::menu_pointer::hit_pane(mSelFilePanes[mSelectNum], 8.0f) && - dusk::menu_pointer::consume_click()) - { - dusk::menu_pointer::defer_activation( - dusk::menu_pointer::Context::FileSelect, - pointer_target(s_pointerDataSelectTarget, mSelectNum)); + if (mSelectNum != 0xFF && dusk::menu_pointer::hit_pane(mSelFilePanes[mSelectNum], 8.0f)) { + dusk::menu_pointer::set_hover_target(pointer_target(s_pointerDataSelectTarget, mSelectNum)); + if (dusk::menu_pointer::consume_click()) { + dusk::menu_pointer::defer_activation( + dusk::menu_pointer::Context::FileSelect, + pointer_target(s_pointerDataSelectTarget, mSelectNum)); + } } #endif bool iVar7 = true; @@ -1494,12 +1499,14 @@ void dFile_select_c::menuSelectMoveAnm() { #if TARGET_PC dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::FileSelect); if (mSelectMenuNum != 0xFF && - dusk::menu_pointer::hit_pane(m3mSelPane[mSelectMenuNum], 8.0f) && - dusk::menu_pointer::consume_click()) + dusk::menu_pointer::hit_pane(m3mSelPane[mSelectMenuNum], 8.0f)) { - dusk::menu_pointer::defer_activation( - dusk::menu_pointer::Context::FileSelect, - pointer_target(s_pointerMenuSelectTarget, mSelectMenuNum)); + dusk::menu_pointer::set_hover_target(pointer_target(s_pointerMenuSelectTarget, mSelectMenuNum)); + if (dusk::menu_pointer::consume_click()) { + dusk::menu_pointer::defer_activation( + dusk::menu_pointer::Context::FileSelect, + pointer_target(s_pointerMenuSelectTarget, mSelectMenuNum)); + } } #endif bool tmp1 = true; @@ -1997,12 +2004,14 @@ void dFile_select_c::copyDataToSelectMoveAnm() { #if TARGET_PC dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::FileSelect); if (field_0x026b != 0xFF && - dusk::menu_pointer::hit_pane(mCpSelPane[field_0x026b], 8.0f) && - dusk::menu_pointer::consume_click()) + dusk::menu_pointer::hit_pane(mCpSelPane[field_0x026b], 8.0f)) { - dusk::menu_pointer::defer_activation( - dusk::menu_pointer::Context::FileSelect, - pointer_target(s_pointerCopySelectTarget, field_0x026b)); + dusk::menu_pointer::set_hover_target(pointer_target(s_pointerCopySelectTarget, field_0x026b)); + if (dusk::menu_pointer::consume_click()) { + dusk::menu_pointer::defer_activation( + dusk::menu_pointer::Context::FileSelect, + pointer_target(s_pointerCopySelectTarget, field_0x026b)); + } } #endif bool iVar7 = true; @@ -2522,12 +2531,14 @@ void dFile_select_c::yesNoCursorMoveAnm() { #if TARGET_PC dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::FileSelect); if (field_0x0268 != 0xFF && - dusk::menu_pointer::hit_pane(mYnSelPane[field_0x0268], 8.0f) && - dusk::menu_pointer::consume_click()) + dusk::menu_pointer::hit_pane(mYnSelPane[field_0x0268], 8.0f)) { - dusk::menu_pointer::defer_activation( - dusk::menu_pointer::Context::FileSelect, - pointer_target(s_pointerYesNoSelectTarget, field_0x0268)); + dusk::menu_pointer::set_hover_target(pointer_target(s_pointerYesNoSelectTarget, field_0x0268)); + if (dusk::menu_pointer::consume_click()) { + dusk::menu_pointer::defer_activation( + dusk::menu_pointer::Context::FileSelect, + pointer_target(s_pointerYesNoSelectTarget, field_0x0268)); + } } #endif bool isYnSelMove = yesnoSelectMoveAnm(); diff --git a/src/d/d_menu_collect.cpp b/src/d/d_menu_collect.cpp index 3aea57e1d3..4e3ddf25f2 100644 --- a/src/d/d_menu_collect.cpp +++ b/src/d/d_menu_collect.cpp @@ -1960,6 +1960,7 @@ bool dMenu_Collect2D_c::pointerWait() { if (getItemTag(x, y, true) == 0 || !dusk::menu_pointer::hit_pane(mpSelPm[x][y], 8.0f)) { continue; } + dusk::menu_pointer::set_hover_target(static_cast(x + y * 7)); if (mCursorX != x || mCursorY != y) { mDoAud_seStart(Z2SE_SY_MENU_CURSOR_COMMON, NULL, 0, 0); mCursorX = x; diff --git a/src/d/d_menu_insect.cpp b/src/d/d_menu_insect.cpp index 7967ca6cdf..aa18407b09 100644 --- a/src/d/d_menu_insect.cpp +++ b/src/d/d_menu_insect.cpp @@ -320,6 +320,7 @@ bool dMenu_Insect_c::pointerWait() { if (!isGetInsect(x, y) || !dusk::menu_pointer::hit_pane(mpINSParent[index], 8.0f)) { continue; } + dusk::menu_pointer::set_hover_target(index); if (field_0xf4 != x || field_0xf5 != y) { field_0xf4 = x; diff --git a/src/d/d_menu_letter.cpp b/src/d/d_menu_letter.cpp index 0d7efd3adb..3f997bbda5 100644 --- a/src/d/d_menu_letter.cpp +++ b/src/d/d_menu_letter.cpp @@ -482,6 +482,7 @@ bool dMenu_Letter_c::pointerWait() { if (!dusk::menu_pointer::hit_pane(mpLetterParent[i], 8.0f)) { continue; } + dusk::menu_pointer::set_hover_target(i); if (mIndex != i) { mIndex = i; diff --git a/src/d/d_menu_option.cpp b/src/d/d_menu_option.cpp index cc4bb01f2a..94babc8342 100644 --- a/src/d/d_menu_option.cpp +++ b/src/d/d_menu_option.cpp @@ -83,6 +83,12 @@ enum SelectType { SelectType8, }; +#if TARGET_PC +static dusk::menu_pointer::TargetId option_yes_no_target(u8 index) noexcept { + return static_cast(0x100 + index); +} +#endif + dMenu_Option_c::dMenu_Option_c(JKRArchive* i_archive, STControl* i_stick) { mUseFlag = 0; mBarScale[0] = g_drawHIO.mOptionScreen.mBarScale[0]; @@ -1098,18 +1104,28 @@ void dMenu_Option_c::confirm_move_move() { if (!dusk::menu_pointer::hit_pane(mpYesNoSelBase_c[i], 8.0f)) { continue; } + dusk::menu_pointer::set_hover_target(option_yes_no_target(i)); + const bool clicked = dusk::menu_pointer::consume_click(); if (field_0x3f9 != i) { Z2GetAudioMgr()->seStart(Z2SE_SY_MENU_CURSOR_COMMON, NULL, 0, 0, 1.0f, 1.0f, -1.0f, -1.0f, 0); field_0x3fa = field_0x3f9; field_0x3f9 = i; + if (clicked) { + yesNoSelectStart(); + field_0x3ef = SelectType7; + dMeter2Info_set2DVibrationM(); + mpWarning->_move(); + setAnimation(); + return; + } yesnoSelectAnmSet(); field_0x3ef = SelectType6; mpWarning->_move(); setAnimation(); return; } - if (dusk::menu_pointer::consume_click()) { + if (clicked) { yesNoSelectStart(); field_0x3ef = SelectType7; dMeter2Info_set2DVibrationM(); @@ -1156,11 +1172,36 @@ void dMenu_Option_c::confirm_select_init() { } void dMenu_Option_c::confirm_select_move() { +#if TARGET_PC + dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::Options); + if (field_0x3f9 != 0xff && + dusk::menu_pointer::hit_pane(mpYesNoSelBase_c[field_0x3f9], 8.0f)) + { + const dusk::menu_pointer::TargetId target = option_yes_no_target(field_0x3f9); + dusk::menu_pointer::set_hover_target(target); + if (dusk::menu_pointer::consume_click()) { + dusk::menu_pointer::defer_activation(dusk::menu_pointer::Context::Options, target); + } + } +#endif u8 selectMoveAnm = yesnoSelectMoveAnm(); u8 wakuAlphaAnm = yesnoWakuAlpahAnm(field_0x3fa); if (selectMoveAnm == 1 && wakuAlphaAnm == 1) { yesnoCursorShow(); +#if TARGET_PC + if (field_0x3f9 != 0xff && + dusk::menu_pointer::consume_deferred_activation( + dusk::menu_pointer::Context::Options, option_yes_no_target(field_0x3f9))) + { + yesNoSelectStart(); + field_0x3ef = SelectType7; + dMeter2Info_set2DVibrationM(); + mpWarning->_move(); + setAnimation(); + return; + } +#endif field_0x3ef = SelectType5; } mpWarning->_move(); @@ -2196,16 +2237,14 @@ bool dMenu_Option_c::isRumbleSupported() { #if TARGET_PC bool dMenu_Option_c::pointerConfirmSelect() { dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::Options); - if (!dusk::menu_pointer::state().clicked) { - return false; - } - for (u8 i = 0; i < SelectType3; ++i) { if (dusk::menu_pointer::hit_pane(mpMenuPane[i], 8.0f)) { + dusk::menu_pointer::set_hover_target(i); return false; } } + dusk::menu_pointer::set_hover_target(0x200); if (!dusk::menu_pointer::consume_click()) { return false; } @@ -2226,6 +2265,7 @@ bool dMenu_Option_c::dpdMenuMove() { if (!dusk::menu_pointer::hit_pane(mpMenuPane[i], 8.0f)) { continue; } + dusk::menu_pointer::set_hover_target(i); if (getSelectType() != i) { field_0x3ef = i; setCursorPos(i); diff --git a/src/d/d_menu_ring.cpp b/src/d/d_menu_ring.cpp index fde1f35af8..c3f1ed2a4a 100644 --- a/src/d/d_menu_ring.cpp +++ b/src/d/d_menu_ring.cpp @@ -198,6 +198,7 @@ dMenu_Ring_c::dMenu_Ring_c(JKRExpHeap* i_heap, STControl* i_stick, CSTControl* i mCursorInterpPrevAngular = false; mCursorInterpCurrAngular = false; mCursorInterpInit = false; + mPointerTouchPressHoveredCurrent = false; #endif for (int i = 0; i < 4; i++) { field_0x674[i] = 0; @@ -1561,6 +1562,10 @@ bool dMenu_Ring_c::pointerMove() { if (hoveredSlot < 0) { return false; } + if (pointer.pressed) { + mPointerTouchPressHoveredCurrent = pointer.touch && hoveredSlot == mCurrentSlot; + } + dusk::menu_pointer::set_hover_target(static_cast(hoveredSlot)); if (mCurrentSlot != hoveredSlot) { mDirectSelectCursorPos.x = mItemSlotPosX[mCurrentSlot]; @@ -1573,10 +1578,27 @@ bool dMenu_Ring_c::pointerMove() { return true; } - if (dusk::menu_pointer::consume_click()) { + const bool clickOpensExplain = !pointer.touch || mPointerTouchPressHoveredCurrent; + if (clickOpensExplain && dusk::menu_pointer::consume_click()) { + const u8 item = dComIfGs_getItem(mItemSlots[mCurrentSlot], false); + if (!dMeter2Info_isTouchKeyCheck(0xe) && openExplain(item)) { + dMeter2Info_setItemExplainWindowStatus(1); + field_0x6c4 = mCurrentSlot; + setStatus(STATUS_EXPLAIN); + dMeter2Info_set2DVibration(); + setDoStatus(0); + } else { + Z2GetAudioMgr()->seStart(Z2SE_SYS_ERROR, NULL, 0, 0, 1.0f, 1.0f, -1.0f, + -1.0f, 0); + } + mPointerTouchPressHoveredCurrent = false; return true; } + if (pointer.released) { + mPointerTouchPressHoveredCurrent = false; + } + return false; } #endif diff --git a/src/d/d_menu_save.cpp b/src/d/d_menu_save.cpp index d272c57500..f97e50d331 100644 --- a/src/d/d_menu_save.cpp +++ b/src/d/d_menu_save.cpp @@ -1820,6 +1820,7 @@ bool dMenu_save_c::pointerSaveSelect() { if (!dusk::menu_pointer::hit_pane(mpSelData[i], 8.0f)) { continue; } + dusk::menu_pointer::set_hover_target(pointer_target(s_pointerSaveSelectTarget, i)); const bool clicked = dusk::menu_pointer::consume_click(); if (mSelectedFile != i) { mDoAud_seStart(Z2SE_FILE_SELECT_CURSOR, NULL, 0, 0); @@ -1848,6 +1849,7 @@ bool dMenu_save_c::pointerYesNoSelect(bool errorSelect, u8 errParam, u8 soundPar if (!dusk::menu_pointer::hit_pane(mpNoYes[i], 8.0f)) { continue; } + dusk::menu_pointer::set_hover_target(pointer_target(s_pointerYesNoSelectTarget, i)); const bool clicked = (!errorSelect || mYesNoCursor == i) && dusk::menu_pointer::consume_click(); if (mYesNoCursor != i) { @@ -1952,12 +1954,14 @@ void dMenu_save_c::saveSelectMoveAnime() { #if TARGET_PC dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::Save); if (mSelectedFile != 0xFF && - dusk::menu_pointer::hit_pane(mpSelData[mSelectedFile], 8.0f) && - dusk::menu_pointer::consume_click()) + dusk::menu_pointer::hit_pane(mpSelData[mSelectedFile], 8.0f)) { - dusk::menu_pointer::defer_activation( - dusk::menu_pointer::Context::Save, - pointer_target(s_pointerSaveSelectTarget, mSelectedFile)); + dusk::menu_pointer::set_hover_target(pointer_target(s_pointerSaveSelectTarget, mSelectedFile)); + if (dusk::menu_pointer::consume_click()) { + dusk::menu_pointer::defer_activation( + dusk::menu_pointer::Context::Save, + pointer_target(s_pointerSaveSelectTarget, mSelectedFile)); + } } #endif bool bookWakuAnmComplete = true; @@ -2130,12 +2134,14 @@ void dMenu_save_c::yesNoCursorMoveAnm() { #if TARGET_PC dusk::menu_pointer::begin_context(dusk::menu_pointer::Context::Save); if (mYesNoCursor != 0xFF && - dusk::menu_pointer::hit_pane(mpNoYes[mYesNoCursor], 8.0f) && - dusk::menu_pointer::consume_click()) + dusk::menu_pointer::hit_pane(mpNoYes[mYesNoCursor], 8.0f)) { - dusk::menu_pointer::defer_activation( - dusk::menu_pointer::Context::Save, - pointer_target(s_pointerYesNoSelectTarget, mYesNoCursor)); + dusk::menu_pointer::set_hover_target(pointer_target(s_pointerYesNoSelectTarget, mYesNoCursor)); + if (dusk::menu_pointer::consume_click()) { + dusk::menu_pointer::defer_activation( + dusk::menu_pointer::Context::Save, + pointer_target(s_pointerYesNoSelectTarget, mYesNoCursor)); + } } #endif bool selAnmComplete = yesnoSelectMoveAnm(0); diff --git a/src/d/d_menu_skill.cpp b/src/d/d_menu_skill.cpp index 601bae1eb6..0c970e76c3 100644 --- a/src/d/d_menu_skill.cpp +++ b/src/d/d_menu_skill.cpp @@ -316,6 +316,7 @@ bool dMenu_Skill_c::pointerWait() { if (!dusk::menu_pointer::hit_pane(mpLetterParent[i], 8.0f)) { continue; } + dusk::menu_pointer::set_hover_target(i); if (mIndex != i) { mIndex = i; diff --git a/src/d/d_msg_scrn_3select.cpp b/src/d/d_msg_scrn_3select.cpp index b021b52dec..fd85da423f 100644 --- a/src/d/d_msg_scrn_3select.cpp +++ b/src/d/d_msg_scrn_3select.cpp @@ -560,7 +560,8 @@ bool dMsgScrn3Select_c::pointerMove() { mDPDPoint = choice; field_0x110 = paneIndex; - dusk::menu_pointer::set_dialog_choice(choice, dusk::menu_pointer::state().clicked); + dusk::menu_pointer::set_hover_target(choice); + dusk::menu_pointer::set_dialog_choice(choice, dusk::menu_pointer::peek_click()); return true; } diff --git a/src/dusk/menu_pointer.cpp b/src/dusk/menu_pointer.cpp index 91b66cce55..83ed7afc84 100644 --- a/src/dusk/menu_pointer.cpp +++ b/src/dusk/menu_pointer.cpp @@ -1,16 +1,34 @@ #include "dusk/menu_pointer.h" -#include "m_Do/m_Do_graphic.h" #include "d/d_pane_class.h" #include "dusk/settings.h" +#include "m_Do/m_Do_graphic.h" #include #include #include +#include namespace dusk::menu_pointer { namespace { +using Clock = std::chrono::steady_clock; + +constexpr auto kTapMaxDuration = std::chrono::milliseconds(300); +constexpr f32 kTapMoveThresholdDp = 12.0f; + +struct Gesture { + bool active = false; + bool movedTooFar = false; + bool crossedTarget = false; + bool pressTargetValid = false; + Context pressContext = Context::None; + TargetId pressTarget = InvalidTarget; + f32 startX = 0.0f; + f32 startY = 0.0f; + Clock::time_point startedAt{}; +}; + State s_state; bool s_clickConsumed = false; Context s_lastContext = Context::None; @@ -27,7 +45,14 @@ s32 s_mouseButton = -1; u32 s_suppressedPadHoldMask = 0; u32 s_suppressedPadNextReadMask = 0; Context s_deferredActivationContext = Context::None; -u8 s_deferredActivationTarget = 0xFF; +TargetId s_deferredActivationTarget = InvalidTarget; +Gesture s_gesture; +bool s_hoverTargetValid = false; +TargetId s_hoverTarget = InvalidTarget; +bool s_clickPending = false; +Context s_clickContext = Context::None; +TargetId s_clickTarget = InvalidTarget; +bool s_clickTargetValid = false; s32 scancode_from_rml_button(s32 button) noexcept { switch (button) { @@ -104,6 +129,37 @@ void suppress_pad_for_mouse_button(s32 button, bool held) noexcept { } } +f32 tap_move_threshold() noexcept { + auto* context = aurora::rmlui::get_context(); + if (context == nullptr) { + return kTapMoveThresholdDp; + } + + return kTapMoveThresholdDp * std::max(context->GetDensityIndependentPixelRatio(), 1.0f); +} + +void update_gesture_movement(f32 x, f32 y) noexcept { + if (!s_gesture.active || s_gesture.movedTooFar) { + return; + } + + const f32 dx = x - s_gesture.startX; + const f32 dy = y - s_gesture.startY; + const f32 threshold = tap_move_threshold(); + if (dx * dx + dy * dy > threshold * threshold) { + s_gesture.movedTooFar = true; + } +} + +void clear_click_state() noexcept { + s_clickConsumed = false; + s_clickPending = false; + s_clickContext = Context::None; + s_clickTarget = InvalidTarget; + s_clickTargetValid = false; + s_state.clicked = false; +} + void set_position_from_rml(f32 x, f32 y) noexcept { auto* context = aurora::rmlui::get_context(); if (context == nullptr) { @@ -121,7 +177,7 @@ void set_position_from_rml(f32 x, f32 y) noexcept { void clear_input_state() noexcept { s_state = {}; - s_clickConsumed = false; + clear_click_state(); s_lastDialogChoice = 0xFF; s_currentDialogChoice = 0xFF; s_lastDialogChoiceValid = false; @@ -134,7 +190,10 @@ void clear_input_state() noexcept { s_suppressedPadHoldMask = 0; s_suppressedPadNextReadMask = 0; s_deferredActivationContext = Context::None; - s_deferredActivationTarget = 0xFF; + s_deferredActivationTarget = InvalidTarget; + s_gesture = {}; + s_hoverTargetValid = false; + s_hoverTarget = InvalidTarget; } } // namespace @@ -144,8 +203,6 @@ bool handle_fallthrough_pointer(f32 x, f32 y, Phase phase, bool touch, s32 mouse return false; } - s_clickConsumed = false; - if (!touch) { if (phase == Phase::Press) { if (!mouse_button_is_menu_confirm(mouseButton)) { @@ -174,21 +231,41 @@ bool handle_fallthrough_pointer(f32 x, f32 y, Phase phase, bool touch, s32 mouse } if (phase != Phase::Cancel) { + update_gesture_movement(x, y); set_position_from_rml(x, y); } s_state.touch = touch; switch (phase) { case Phase::Press: + clear_click_state(); + s_gesture = { + .active = true, + .startX = x, + .startY = y, + .startedAt = Clock::now(), + }; s_state.down = true; s_state.pressed = true; break; - case Phase::Release: + case Phase::Release: { + const bool shortEnough = + s_gesture.active && Clock::now() - s_gesture.startedAt <= kTapMaxDuration; + const bool stillEnough = s_gesture.active && !s_gesture.movedTooFar; + const bool targetClean = s_gesture.active && !s_gesture.crossedTarget; + s_clickContext = s_gesture.pressContext; + s_clickTarget = s_gesture.pressTarget; + s_clickTargetValid = s_gesture.pressTargetValid; + s_clickPending = shortEnough && stillEnough && targetClean; s_state.down = false; s_state.released = true; - s_state.clicked = true; + s_state.clicked = s_clickPending; + s_gesture = {}; break; + } case Phase::Cancel: + clear_click_state(); + s_gesture = {}; s_state.down = false; break; case Phase::Move: @@ -211,6 +288,12 @@ void begin_game_frame() noexcept { } void end_game_frame() noexcept { + if (s_gesture.active && s_gesture.pressTargetValid && + s_currentContext == s_gesture.pressContext && !s_hoverTargetValid) + { + s_gesture.crossedTarget = true; + } + s_lastContext = s_currentContext; s_lastDialogChoice = s_currentDialogChoice; s_lastDialogChoiceValid = s_currentDialogChoiceValid; @@ -222,6 +305,12 @@ void end_game_frame() noexcept { s_state.valid = false; } s_clickConsumed = false; + s_clickPending = false; + s_clickContext = Context::None; + s_clickTarget = InvalidTarget; + s_clickTargetValid = false; + s_hoverTargetValid = false; + s_hoverTarget = InvalidTarget; } void begin_context(Context context) noexcept { @@ -237,7 +326,11 @@ void begin_context(Context context) noexcept { s_suppressedPadHoldMask = 0; s_suppressedPadNextReadMask = 0; s_deferredActivationContext = Context::None; - s_deferredActivationTarget = 0xFF; + s_deferredActivationTarget = InvalidTarget; + s_gesture = {}; + s_hoverTargetValid = false; + s_hoverTarget = InvalidTarget; + clear_click_state(); } s_currentContext = context; @@ -259,15 +352,50 @@ const State& state() noexcept { return s_state; } +void set_hover_target(TargetId target) noexcept { + s_hoverTargetValid = true; + s_hoverTarget = target; + + if (s_gesture.active && !s_gesture.pressTargetValid && s_state.down) { + s_gesture.pressContext = s_currentContext; + s_gesture.pressTarget = target; + s_gesture.pressTargetValid = true; + } + + if (s_gesture.active && s_gesture.pressTargetValid && + (s_currentContext != s_gesture.pressContext || target != s_gesture.pressTarget)) + { + s_gesture.crossedTarget = true; + } +} + +bool click_matches_hover_target() noexcept { + if (!s_clickPending || !s_hoverTargetValid) { + return false; + } + + if (!s_clickTargetValid) { + return true; + } + + return s_currentContext == s_clickContext && s_hoverTarget == s_clickTarget; +} + bool consume_click() noexcept { - if (!s_state.clicked || s_clickConsumed) { + if (s_clickConsumed || !click_matches_hover_target()) { return false; } s_clickConsumed = true; + s_clickPending = false; + s_state.clicked = false; return true; } +bool peek_click() noexcept { + return !s_clickConsumed && click_matches_hover_target(); +} + void set_dialog_choice(u8 choice, bool clicked) noexcept { s_currentDialogChoice = choice; s_currentDialogChoiceValid = true; @@ -300,18 +428,18 @@ bool consume_dialog_click(u8& choice) noexcept { return false; } -void defer_activation(Context context, u8 target) noexcept { +void defer_activation(Context context, TargetId target) noexcept { s_deferredActivationContext = context; s_deferredActivationTarget = target; } -bool consume_deferred_activation(Context context, u8 target) noexcept { +bool consume_deferred_activation(Context context, TargetId target) noexcept { if (s_deferredActivationContext != context || s_deferredActivationTarget != target) { return false; } s_deferredActivationContext = Context::None; - s_deferredActivationTarget = 0xFF; + s_deferredActivationTarget = InvalidTarget; return true; } @@ -321,7 +449,7 @@ void clear_deferred_activation(Context context) noexcept { } s_deferredActivationContext = Context::None; - s_deferredActivationTarget = 0xFF; + s_deferredActivationTarget = InvalidTarget; } u32 suppressed_pad_buttons(u32 port) noexcept { From 5418b1831d3de6a7f0d357a4d3a0b63ed9b32346 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 17 Jun 2026 23:11:22 -0600 Subject: [PATCH 040/150] Update aurora & flake.nix versions --- extern/aurora | 2 +- flake.nix | 28 ++++++++++++++-------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/extern/aurora b/extern/aurora index 22351fb0b7..509021de0a 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 22351fb0b76a4f4f2c4a4dff95aa300101e861aa +Subproject commit 509021de0a45f3318769a0f15265b432747bc103 diff --git a/flake.nix b/flake.nix index ec87c5526a..8abce3969a 100644 --- a/flake.nix +++ b/flake.nix @@ -16,37 +16,37 @@ ]; forAllSystems = lib.genAttrs supportedSystems; - dawnVersion = "v20260423.175430"; - nodVersion = "v2.0.0-alpha.8"; + dawnVersion = "v20260618.032059"; + nodVersion = "v2.0.0-alpha.10"; versionSuffix = "nix-" + (self.shortRev or self.dirtyShortRev or "dirty"); dawnInfo = { "x86_64-linux" = { triple = "linux-x86_64"; - hash = "sha256-HXfKTLHtMPwupnFnaflCARtXVPuS/0PoCePXidjE5xs="; + hash = "sha256-GFSd573b+VQx/VmFdNQgWDd0V9ayQlcw0Zuopke12ak="; }; "aarch64-linux" = { triple = "linux-aarch64"; - hash = "sha256-34yyFpfqBZUwoFXQ41F0AwAU78FaNihOSY0oriwn6B0="; + hash = "sha256-ZaoP7BAjBMnfAv2/AMRi3FNH2ZtyqASCSFyU/oB2Mzg="; }; "aarch64-darwin" = { triple = "darwin-arm64"; - hash = "sha256-eQnzrBp6gjiBek1VYQ9A5W13ClYWrDDKjIqv/7eNTR4="; + hash = "sha256-HT+qtlLaSHyoXPrUcXgcTGa877X5YfzbxRD4bJb7i1Y="; }; "x86_64-darwin" = { triple = "darwin-x86_64"; - hash = "sha256-QGWiGdxiI9kci3NPXH6QFFirxn16851zB/w3jqhIBJ4="; + hash = "sha256-cUNaCbA7rlKSukDVKGaVEVw0Zt1+mSbaHbmUCMvMVWc="; }; }; nodPrebuiltInfo = { "x86_64-linux" = { triple = "linux-x86_64"; - hash = "sha256-mUqvLsbsqaZ+HAjMmHYPYO+MgtanGRTw7Gzn5uXR5rE="; + hash = "sha256-FVQWECVA2gWdc+n5OQ/Tvwn8z0qdgjSd1WlFt5HKOec="; }; "aarch64-darwin" = { triple = "macos-arm64"; - hash = "sha256-UPy1ywCcv0K6VJOU3uUelJuUdBh3UNaPRlyP5LOBeDw="; + hash = "sha256-8ZEejxksVgShNKUVRCBYaLOp9x/qOC9pAeVrElQUGUk="; }; }; @@ -75,7 +75,7 @@ ''; dawn = pkgs.fetchzip { - url = "https://github.com/encounter/dawn-build/releases/download/${dawnVersion}/dawn-${dawnInfo.${system}.triple}.tar.gz"; + url = "https://github.com/encounter/dawn/releases/download/${dawnVersion}/dawn-${dawnInfo.${system}.triple}.tar.gz"; hash = dawnInfo.${system}.hash; stripRoot = false; }; @@ -94,7 +94,7 @@ owner = "encounter"; repo = "nod"; rev = nodVersion; - hash = "sha256-+zrtVzjo0+X/6uMcNUn1+FaSR+jOhrcQSDNBFjw0NDs="; + hash = "sha256-r8qDlOVxv5iKiFjJQrcBuL9HVoOM3yEjRVnQIMqaICs="; }; patches = [ ./fix-cmake-paths.patch ]; cargoDeps = pkgs.rustPlatform.importCargoLock { @@ -141,12 +141,12 @@ XXHASH = pkgs.xxhash.src; ZSTD = pkgs.zstd.src; FMT = pkgs.fetchzip { - url = "https://github.com/fmtlib/fmt/archive/refs/tags/11.1.4.tar.gz"; - hash = "sha256-sUbxlYi/Aupaox3JjWFqXIjcaQa0LFjclQAOleT+FRA="; + url = "https://github.com/fmtlib/fmt/archive/refs/tags/12.1.0.tar.gz"; + hash = "sha256-ZmI1Dv0ZabPlxa02OpERI47jp7zFfjpeWCy1WyuPYZ0="; }; TRACY = pkgs.fetchzip { - url = "https://github.com/wolfpld/tracy/archive/a64b9a20294d59421a2f57aeca3c6383d8c48169.tar.gz"; - hash = "sha256-hbNGOsGeyGSvCJ2No8RkwOib1lX2on3vNZSzyVkZdXw="; + url = "https://github.com/wolfpld/tracy/archive/6789e7d6f9a65ec98926b602097a33a9676d2606.tar.gz"; + hash = "sha256-Xxyd7G/mnXEPpN+ehmwl0AkAhS3CwObpJNDgcqbdUJg="; }; IMGUI = pkgs.fetchFromGitHub { owner = "ocornut"; From 277538bb8121f6d67732ae4daf6773c54c482adf Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 24 Jun 2026 22:03:58 -0700 Subject: [PATCH 041/150] New pipeline progress UI --- extern/aurora | 2 +- include/dusk/settings.h | 1 - res/rml/overlay.rcss | 54 +++++++++++++++++++++++- src/dusk/imgui/ImGuiConsole.cpp | 28 ------------- src/dusk/imgui/ImGuiConsole.hpp | 1 - src/dusk/settings.cpp | 2 - src/dusk/ui/overlay.cpp | 74 +++++++++++++++++++++++++++++---- src/dusk/ui/overlay.hpp | 10 +++++ src/dusk/ui/settings.cpp | 5 --- 9 files changed, 129 insertions(+), 48 deletions(-) diff --git a/extern/aurora b/extern/aurora index 509021de0a..e145b9ec20 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 509021de0a45f3318769a0f15265b432747bc103 +Subproject commit e145b9ec206c70c3b67c4041e544b456f7037bbb diff --git a/include/dusk/settings.h b/include/dusk/settings.h index e4b28686ff..0718704afa 100644 --- a/include/dusk/settings.h +++ b/include/dusk/settings.h @@ -275,7 +275,6 @@ struct UserSettings { ConfigVar isoVerification; ConfigVar graphicsBackend; ConfigVar skipPreLaunchUI; - ConfigVar showPipelineCompilation; ConfigVar wasPresetChosen; ConfigVar checkForUpdates; ConfigVar cardFileType; diff --git a/res/rml/overlay.rcss b/res/rml/overlay.rcss index 9a86843295..384c7a2a9b 100644 --- a/res/rml/overlay.rcss +++ b/res/rml/overlay.rcss @@ -21,6 +21,7 @@ body { } fps, +pipeline-progress, toast { position: absolute; border: 1dp #92875B; @@ -98,7 +99,7 @@ toast message row.muted { opacity: 0.5; } -toast progress { +progress { height: 4dp; position: absolute; left: 0; @@ -106,10 +107,50 @@ toast progress { width: 100%; } -toast progress fill { +progress fill { background-color: rgba(194, 164, 45, 80%); } +pipeline-progress { + left: 12dp; + bottom: 12dp; + display: flex; + flex-flow: column; + z-index: 100; + min-width: 260dp; + max-width: 90%; + padding: 10dp 16dp 12dp; + border-radius: 7dp; + overflow: hidden; + filter: opacity(0); + transition: filter 0.2s linear-in-out; + pointer-events: none; +} + +pipeline-progress[open] { + filter: opacity(1); +} + +pipeline-status { + display: flex; + align-items: center; + gap: 8dp; + font-size: 18dp; + font-weight: normal; + white-space: nowrap; +} + +icon.pipeline-spinner { + width: 1.2em; + height: 1.2em; + line-height: 1.2em; + font-size: 1.2em; + color: #C2A42D; + text-align: center; + transform-origin: center; + animation: 1s linear infinite pipeline-spinner-spin; +} + toast.achievement { border: 1dp #C2A42D; } @@ -310,6 +351,15 @@ logo img.outer { } } +@keyframes pipeline-spinner-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + @media (max-height: 640dp) { toast { top: 20dp; diff --git a/src/dusk/imgui/ImGuiConsole.cpp b/src/dusk/imgui/ImGuiConsole.cpp index 5248bc9d36..4c0f2e6de6 100644 --- a/src/dusk/imgui/ImGuiConsole.cpp +++ b/src/dusk/imgui/ImGuiConsole.cpp @@ -375,7 +375,6 @@ namespace dusk { void ImGuiConsole::PostDraw() { m_menuTools.afterDraw(); - ShowPipelineProgress(); } void ImGuiConsole::UpdateDragScroll() { @@ -524,31 +523,4 @@ namespace dusk { return false; } - void ImGuiConsole::ShowPipelineProgress() { - const auto* stats = aurora_get_stats(); - const u32 queuedPipelines = stats->queuedPipelines; - if (queuedPipelines == 0 || !getSettings().backend.showPipelineCompilation) { - return; - } - const u32 createdPipelines = stats->createdPipelines; - const u32 totalPipelines = queuedPipelines + createdPipelines; - - const auto* viewport = ImGui::GetMainViewport(); - const auto padding = viewport->WorkPos.y + 10.f; - const auto halfWidth = viewport->GetWorkCenter().x; - ImGui::SetNextWindowPos(ImVec2{halfWidth, padding}, ImGuiCond_Always, ImVec2{0.5f, 0.f}); - ImGui::SetNextWindowSize(ImVec2{halfWidth, 0.f}, ImGuiCond_Always); - ImGui::SetNextWindowBgAlpha(0.65f); - ImGui::Begin("Pipelines", nullptr, - ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing); - const auto percent = static_cast(createdPipelines) / static_cast(totalPipelines); - const auto progressStr = fmt::format("Processing pipelines: {} / {}", createdPipelines, totalPipelines); - const auto textSize = ImGui::CalcTextSize(progressStr.data(), progressStr.data() + progressStr.size()); - ImGui::NewLine(); - ImGui::SameLine(ImGui::GetWindowWidth() / 2.f - textSize.x + textSize.x / 2.f); - ImGuiStringViewText(progressStr); - ImGui::ProgressBar(percent); - ImGui::End(); - } } diff --git a/src/dusk/imgui/ImGuiConsole.hpp b/src/dusk/imgui/ImGuiConsole.hpp index d97f1b00e6..be344afa3f 100644 --- a/src/dusk/imgui/ImGuiConsole.hpp +++ b/src/dusk/imgui/ImGuiConsole.hpp @@ -35,7 +35,6 @@ private: // Keep always last ImGuiMenuTools m_menuTools; - void ShowPipelineProgress(); void UpdateDragScroll(); }; diff --git a/src/dusk/settings.cpp b/src/dusk/settings.cpp index 37585c5190..39783d2d8f 100644 --- a/src/dusk/settings.cpp +++ b/src/dusk/settings.cpp @@ -160,7 +160,6 @@ UserSettings g_userSettings = { .isoVerification {"backend.isoVerification", DiscVerificationState::Unknown}, .graphicsBackend {"backend.graphicsBackend", "auto"}, .skipPreLaunchUI {"backend.skipPreLaunchUI", false}, - .showPipelineCompilation {"backend.showPipelineCompilation", false}, .wasPresetChosen {"backend.wasPresetChosen", false}, .checkForUpdates {"backend.checkForUpdates", true}, .cardFileType {"backend.cardFileType", static_cast(CARD_GCIFOLDER)}, @@ -345,7 +344,6 @@ void registerSettings() { Register(g_userSettings.backend.isoVerification); Register(g_userSettings.backend.graphicsBackend); Register(g_userSettings.backend.skipPreLaunchUI); - Register(g_userSettings.backend.showPipelineCompilation); Register(g_userSettings.backend.wasPresetChosen); Register(g_userSettings.backend.checkForUpdates); Register(g_userSettings.backend.cardFileType); diff --git a/src/dusk/ui/overlay.cpp b/src/dusk/ui/overlay.cpp index 192b0ebf6a..49ca861a5e 100644 --- a/src/dusk/ui/overlay.cpp +++ b/src/dusk/ui/overlay.cpp @@ -1,9 +1,9 @@ #include "overlay.hpp" #include "aurora/lib/logging.hpp" +#include "controller_config.hpp" #include "dusk/achievements.h" #include "dusk/action_bindings.h" -#include "controller_config.hpp" #include "dusk/livesplit.h" #include "dusk/settings.h" #include "dusk/speedrun.h" @@ -33,6 +33,13 @@ const Rml::String kDocumentSource = R"RML( + + + + + + + @@ -48,6 +55,7 @@ constexpr std::array, 3> kAutoSaveLayers{{ }}; constexpr auto kMenuNotificationDuration = std::chrono::milliseconds(2500); +constexpr auto kPipelineProgressOpenDelay = std::chrono::milliseconds(250); constexpr std::array kFpsCorners = {"tl", "tr", "bl", "br"}; @@ -160,8 +168,8 @@ Rml::Element* create_menu_notification(Rml::Element* parent) { Rml::String padButton{}; SDL_Gamepad* gamepad = gamepad_for_port(PAD_CHAN0); if (isActionBound(ActionBinds::OPEN_DUSKLIGHT_MENU, PAD_CHAN0) && gamepad != nullptr) { - padButton = native_button_name(gamepad, - getActionBindButton(ActionBinds::OPEN_DUSKLIGHT_MENU, PAD_CHAN0)); + padButton = native_button_name( + gamepad, getActionBindButton(ActionBinds::OPEN_DUSKLIGHT_MENU, PAD_CHAN0)); } else { padButton = back_button_name(); } @@ -197,6 +205,9 @@ static std::string FormatTime(OSTime ticks) { Overlay::Overlay() : Document(kDocumentSource, true) { mFpsCounter = mDocument->GetElementById("fps"); + mPipelineProgress = mDocument->GetElementById("pipeline-progress"); + mPipelineProgressLabel = mDocument->GetElementById("pipeline-progress-label"); + mPipelineProgressBar = mDocument->GetElementById("pipeline-progress-bar"); mSpeedrunTimer = mDocument->GetElementById("speedrun-timer"); mSpeedrunRta = mDocument->GetElementById("speedrun-rta"); mSpeedrunIgt = mDocument->GetElementById("speedrun-igt"); @@ -258,6 +269,8 @@ void Overlay::update() { } } + update_pipeline_progress(); + #if !(defined(__ANDROID__) || (defined(__APPLE__) && TARGET_OS_IOS && !TARGET_OS_MACCATALYST)) if (getSettings().game.speedrunMode && getSettings().game.liveSplitEnabled) { dusk::speedrun::updateLiveSplit(); @@ -309,7 +322,8 @@ void Overlay::update() { mSpeedrunRta->RemoveAttribute("open"); } - mSpeedrunIgt->SetInnerRML(escape(fmt::format("IGT {}", FormatTime(m_speedrunInfo.m_igtTimer)))); + mSpeedrunIgt->SetInnerRML( + escape(fmt::format("IGT {}", FormatTime(m_speedrunInfo.m_igtTimer)))); } else { mSpeedrunTimer->RemoveAttribute("open"); } @@ -373,10 +387,8 @@ void Overlay::update() { std::chrono::duration(clock::now() - mCurrentToastStartTime).count(); const float ratio = duration > 0.0f ? std::clamp(elapsed / duration, 0.0f, 1.0f) : 1.0f; const auto remaining = 1.f - ratio; - Rml::ElementList list; - mDocument->GetElementsByTagName(list, "progress"); - for (auto* elem : list) { - elem->SetAttribute("value", remaining); + if (auto* progress = mCurrentToast->QuerySelector("progress")) { + progress->SetAttribute("value", remaining); } if (remaining == 0.f) { if (mCurrentToast->IsPseudoClassSet("done") || @@ -395,6 +407,52 @@ void Overlay::update() { } } +void Overlay::update_pipeline_progress() { + if (mPipelineProgress == nullptr || mPipelineProgressLabel == nullptr || + mPipelineProgressBar == nullptr) + { + return; + } + + const auto* stats = aurora_get_stats(); + const uint32_t queuedPipelines = stats != nullptr ? stats->queuedPipelines : 0; + if (queuedPipelines == 0) { + mPipelineProgress->RemoveAttribute("open"); + mPipelineProgressActive = false; + mPipelineBatchCreatedBase = 0; + mLastQueuedPipelines = 0; + return; + } + + const uint32_t createdPipelines = stats->createdPipelines; + if (!mPipelineProgressActive || createdPipelines < mPipelineBatchCreatedBase) { + mPipelineProgressActive = true; + mPipelineBatchCreatedBase = createdPipelines; + mPipelineProgressStartTime = clock::now(); + mLastQueuedPipelines = 0; + } + + const uint32_t builtPipelines = createdPipelines - mPipelineBatchCreatedBase; + const uint32_t totalPipelines = queuedPipelines + builtPipelines; + const float progress = totalPipelines > 0 ? static_cast(builtPipelines) / + static_cast(totalPipelines) : + 0.0f; + + if (queuedPipelines != mLastQueuedPipelines) { + mLastQueuedPipelines = queuedPipelines; + const auto noun = queuedPipelines == 1 ? "pipeline" : "pipelines"; + mPipelineProgressLabel->SetInnerRML( + escape(fmt::format("Building {} {}", queuedPipelines, noun))); + } + mPipelineProgressBar->SetAttribute("value", progress); + + if (clock::now() >= mPipelineProgressStartTime + kPipelineProgressOpenDelay) { + mPipelineProgress->SetAttribute("open", ""); + } else { + mPipelineProgress->RemoveAttribute("open"); + } +} + bool Overlay::handle_nav_command(Rml::Event& event, NavCommand cmd) { Log.warn("Overlay received nav command: {}", magic_enum::enum_name(cmd)); return false; diff --git a/src/dusk/ui/overlay.hpp b/src/dusk/ui/overlay.hpp index a8ccde5b9d..34af2b1d52 100644 --- a/src/dusk/ui/overlay.hpp +++ b/src/dusk/ui/overlay.hpp @@ -16,7 +16,13 @@ public: protected: bool handle_nav_command(Rml::Event& event, NavCommand cmd) override; +private: + void update_pipeline_progress(); + Rml::Element* mFpsCounter = nullptr; + Rml::Element* mPipelineProgress = nullptr; + Rml::Element* mPipelineProgressLabel = nullptr; + Rml::Element* mPipelineProgressBar = nullptr; Rml::Element* mCurrentToast = nullptr; Rml::Element* mControllerWarning = nullptr; Rml::Element* mMenuNotification = nullptr; @@ -25,7 +31,11 @@ protected: Rml::Element* mSpeedrunIgt = nullptr; clock::time_point mCurrentToastStartTime; clock::time_point mMenuNotificationStartTime; + clock::time_point mPipelineProgressStartTime; Uint64 mFpsLastUpdate = 0; + uint32_t mPipelineBatchCreatedBase = 0; + uint32_t mLastQueuedPipelines = 0; + bool mPipelineProgressActive = false; }; } // namespace dusk::ui diff --git a/src/dusk/ui/settings.cpp b/src/dusk/ui/settings.cpp index f4f075a6dc..b255e101c1 100644 --- a/src/dusk/ui/settings.cpp +++ b/src/dusk/ui/settings.cpp @@ -1485,11 +1485,6 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .helpText = "When starting Dusklight, skip the main menu and boot straight into the " "game if a disc image is available.", }); - config_bool_select(leftPane, rightPane, getSettings().backend.showPipelineCompilation, - { - .key = "Show Pipeline Compilation", - .helpText = "Show an overlay when shaders are being compiled for your hardware.", - }); config_bool_select(leftPane, rightPane, getSettings().backend.checkForUpdates, { .key = "Check for Updates", From ed21cd4fd0439f6953fa27f6541cd42dd1086c95 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Sat, 27 Jun 2026 20:35:17 -0600 Subject: [PATCH 042/150] Update README.md --- README.md | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 5a9701bede..624fd7dcfc 100644 --- a/README.md +++ b/README.md @@ -20,31 +20,17 @@ It aims to be as accurate as possible to the original while also providing new o > Dusklight does *not* provide any copyrighted assets. You must provide your own copy of the original game. > [!IMPORTANT] -> At a minimum, Dusklight requires a GPU with support for either D3D12, Vulkan, or Metal. Your experience with specific hardware, operating systems, and drivers may vary. In particular, older Intel iGPUs have a high likelihood of incompatibility. We are also aware of a number of issues on devices with Adreno GPUs and are working to resolve them. +> At a minimum, Dusklight requires a GPU with support for D3D12, Vulkan 1.1+, or Metal. For older devices, best-effort support is provided for D3D11 and OpenGL ES (Android), but will not acheive full accuracy or performance. Your experience with specific hardware, operating systems, and drivers may vary. ### 1. Dump your game -You must dump your own copy of the game, please see [this article](https://wiki.dolphin-emu.org/index.php?title=Ripping_Games) for instructions. After dumping, you can use a program like [Dolphin](https://dolphin-emu.org/) or [nodtool](https://github.com/encounter/nod/releases) to convert the `.iso` to a `.rvz` to save space. +You must dump your own copy of the game. Please see [this article](https://wiki.dolphin-emu.org/index.php?title=Ripping_Games) for instructions. After dumping, you can use a program like [Dolphin](https://dolphin-emu.org/) or [nodtool](https://github.com/encounter/nod/releases) to convert the `.iso` to `.rvz` to save space. Currently, only the GameCube USA and EUR releases are supported. Support for other versions of the game is planned in the future. -### 2. Download [Dusklight](https://github.com/TwilitRealm/dusklight/releases) +### 2. Install Dusklight -### 3. Setup the game -**Windows / macOS / Linux** -- Extract the .zip file -- Launch Dusklight -- Press **Select Disc Image** and provide the path to your supported game dump -- Press **Play**! - -**iOS** -- Follow the [iOS setup guide](docs/ios-install-altstore.md) - -**Android** -- Install the Dusklight APK -- Launch Dusklight -- Press **Select Disc Image** and provide the path to your supported game dump -- Press **Play**! +Visit the [official installation guide](https://twilitrealm.dev/install/) for full instructions. # Building From 590d209f76b00f49d7fb531a9f4452c8eb9b2312 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Sat, 27 Jun 2026 21:09:04 -0600 Subject: [PATCH 043/150] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 624fd7dcfc..7584c09c46 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ It aims to be as accurate as possible to the original while also providing new o > Dusklight does *not* provide any copyrighted assets. You must provide your own copy of the original game. > [!IMPORTANT] -> At a minimum, Dusklight requires a GPU with support for D3D12, Vulkan 1.1+, or Metal. For older devices, best-effort support is provided for D3D11 and OpenGL ES (Android), but will not acheive full accuracy or performance. Your experience with specific hardware, operating systems, and drivers may vary. +> At a minimum, Dusklight requires a GPU with support for D3D12, Vulkan 1.1+, or Metal. For older devices, best-effort support is provided for D3D11 and OpenGL ES (Android), but will not achieve full accuracy or performance. Your experience with specific hardware, operating systems, and drivers may vary. ### 1. Dump your game From ebf6f317197812acd4c816700c33b5ae14d9db59 Mon Sep 17 00:00:00 2001 From: qubitnano <146656568+qubitnano@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:15:57 -0400 Subject: [PATCH 044/150] flake.nix: patchelf libvulkan (#2124) Since aurora 7ccdae18077caa67acb0f61a525aa5a423cc3b2c, dusklight can't find libvulkan.so on nix --- flake.nix | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/flake.nix b/flake.nix index 8abce3969a..6be8c03aef 100644 --- a/flake.nix +++ b/flake.nix @@ -269,6 +269,12 @@ runHook postInstall ''; + postFixup = lib.optionalString (!isDarwin) '' + patchelf \ + --add-needed "${pkgs.vulkan-loader}/lib/libvulkan.so" \ + $out/bin/dusklight + ''; + dontStrip = true; meta = { From f81d25b4258d04919610a9b562d5c8215a1e6731 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Sat, 27 Jun 2026 21:20:14 -0600 Subject: [PATCH 045/150] Add "Touch Targeting" option --- extern/aurora | 2 +- include/dusk/settings.h | 13 +++++ src/dusk/config.cpp | 1 + src/dusk/settings.cpp | 2 + src/dusk/ui/settings.cpp | 59 ++++++++++++++++++++++ src/dusk/ui/touch_controls.cpp | 91 ++++++++++++++++++++++++---------- 6 files changed, 141 insertions(+), 27 deletions(-) diff --git a/extern/aurora b/extern/aurora index e145b9ec20..9087a409da 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit e145b9ec206c70c3b67c4041e544b456f7037bbb +Subproject commit 9087a409da35b17446af12d7456ec6563cf2dd43 diff --git a/include/dusk/settings.h b/include/dusk/settings.h index 0718704afa..fdff069226 100644 --- a/include/dusk/settings.h +++ b/include/dusk/settings.h @@ -46,6 +46,12 @@ enum class FrameInterpMode : u8 { Unlimited = 2, }; +enum class TouchTargeting : u8 { + Hybrid = 0, + Hold = 1, + Switch = 2, +}; + enum class MenuScaling : u8 { GameCube = 0, Wii = 1, @@ -97,6 +103,12 @@ struct ConfigEnumRange { static constexpr auto max = FrameInterpMode::Unlimited; }; +template <> +struct ConfigEnumRange { + static constexpr auto min = TouchTargeting::Hybrid; + static constexpr auto max = TouchTargeting::Switch; +}; + template <> struct ConfigEnumRange { static constexpr auto min = MenuScaling::GameCube; @@ -216,6 +228,7 @@ struct UserSettings { ConfigVar invertMouseY; ConfigVar freeCamera; ConfigVar enableTouchControls; + ConfigVar touchTargeting; ConfigVar enableMenuPointer; ConfigVar touchControlsLayout; ConfigVar invertCameraXAxis; diff --git a/src/dusk/config.cpp b/src/dusk/config.cpp index fa331eb518..43d3f19dad 100644 --- a/src/dusk/config.cpp +++ b/src/dusk/config.cpp @@ -378,6 +378,7 @@ nlohmann::json ConfigImpl::dumpToJson(const ConfigVar; +template class ConfigImpl; template class ConfigImpl; template class ConfigImpl; template class ConfigImpl; diff --git a/src/dusk/settings.cpp b/src/dusk/settings.cpp index 39783d2d8f..7831c5c41f 100644 --- a/src/dusk/settings.cpp +++ b/src/dusk/settings.cpp @@ -96,6 +96,7 @@ UserSettings g_userSettings = { .invertMouseY {"game.invertMouseY", false}, .freeCamera {"game.freeCamera", false}, .enableTouchControls {"game.enableTouchControls", false}, + .touchTargeting {"game.touchTargeting", TouchTargeting::Hybrid}, .enableMenuPointer {"game.enableMenuPointer", true}, .touchControlsLayout {"game.touchControlsLayout", ui::ControlLayout{}}, .invertCameraXAxis {"game.invertCameraXAxis", false}, @@ -329,6 +330,7 @@ void registerSettings() { Register(g_userSettings.game.invertMouseY); Register(g_userSettings.game.freeCamera); Register(g_userSettings.game.enableTouchControls); + Register(g_userSettings.game.touchTargeting); Register(g_userSettings.game.enableMenuPointer); Register(g_userSettings.game.touchControlsLayout); Register(g_userSettings.game.debugFlyCam); diff --git a/src/dusk/ui/settings.cpp b/src/dusk/ui/settings.cpp index b255e101c1..3c89f34136 100644 --- a/src/dusk/ui/settings.cpp +++ b/src/dusk/ui/settings.cpp @@ -77,6 +77,18 @@ constexpr std::array kInterpolationModes = { "Unlimited", }; +constexpr std::array kTouchTargetingLabels = { + "Hybrid", + "Hold", + "Switch", +}; + +constexpr std::array kTouchTargetingDescriptions = { + "Tap once to lock on when a target is found. Double-tap when none is found to hold L.", + "L stays held only while your finger is on the button.", + "Tap L to keep it held. Tap again to release it.", +}; + constexpr std::array kGyroInputModeLabels = { "Sensor", "Mouse", @@ -407,6 +419,14 @@ bool gyro_enabled() { return getSettings().game.enableGyroAim || getSettings().game.enableGyroRollgoal; } +Rml::String touch_targeting_label(TouchTargeting targeting) { + const auto index = static_cast(targeting); + if (index >= kTouchTargetingLabels.size()) { + return "Unknown"; + } + return kTouchTargetingLabels[index]; +} + struct ConfigBoolProps { Rml::String key; Rml::String icon; @@ -1003,6 +1023,45 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { pane.clear(); pane.add_text("Open the touch controls layout editor."); }); + leftPane.register_control(leftPane.add_select_button({ + .key = "Touch Targeting", + .getValue = + [] { + return touch_targeting_label( + getSettings().game.touchTargeting.getValue()); + }, + .isDisabled = + [] { return !getSettings().game.enableTouchControls; }, + .isModified = + [] { + const auto& targeting = + getSettings().game.touchTargeting; + return targeting.getValue() != + targeting.getDefaultValue(); + }, + }), + rightPane, [](Pane& pane) { + pane.clear(); + for (int i = 0; i < static_cast(kTouchTargetingLabels.size()); ++i) { + pane.add_button({ + .text = kTouchTargetingLabels[i], + .isSelected = + [i] { + return getSettings().game.touchTargeting.getValue() == + static_cast(i); + }, + }) + .on_pressed([i] { + mDoAud_seStartMenu(kSoundItemChange); + getSettings().game.touchTargeting.setValue( + static_cast(i)); + config::Save(); + }); + } + pane.add_rml(fmt::format("
Hybrid: {}
Hold: {}
Switch: {}", + kTouchTargetingDescriptions[0], kTouchTargetingDescriptions[1], + kTouchTargetingDescriptions[2])); + }); config_percent_select(leftPane, rightPane, getSettings().game.touchCameraXSensitivity, "Touch Camera X Sensitivity", "Adjusts touch camera horizontal sensitivity.

Applies to touch input only.", diff --git a/src/dusk/ui/touch_controls.cpp b/src/dusk/ui/touch_controls.cpp index 6056f0d11d..f6b0019a98 100644 --- a/src/dusk/ui/touch_controls.cpp +++ b/src/dusk/ui/touch_controls.cpp @@ -485,43 +485,71 @@ void TouchControls::set_control_pressed(Control control, bool pressed) { mLastLTapTime = {}; break; } - if (pressed && (mLLatched || mManualLLatched)) { + switch (getSettings().game.touchTargeting.getValue()) { + case TouchTargeting::Hold: + mLPressed = pressed; mLLatched = false; mManualLLatched = false; - mLPressed = false; - mLReleasePending = true; + mLReleasePending = false; mLPressStartTime = {}; mLastLTapTime = {}; - set_control_visual(control, false); - } else if (pressed) { - const auto now = clock::now(); - if (!player_attention_locked() && mLastLTapTime != clock::time_point{} && - now - mLastLTapTime <= kLDoubleTapWindow) - { - mManualLLatched = true; + break; + case TouchTargeting::Switch: + if (pressed) { + const bool wasLatched = mLPressed || mLLatched || mManualLLatched; + mLPressed = false; + mLLatched = false; + mManualLLatched = !wasLatched; + mLReleasePending = true; + } else { + mLPressed = false; + mLLatched = false; + mLReleasePending = false; + } + mLPressStartTime = {}; + mLastLTapTime = {}; + break; + case TouchTargeting::Hybrid: + default: + if (pressed && (mLLatched || mManualLLatched)) { + mLLatched = false; + mManualLLatched = false; mLPressed = false; mLReleasePending = true; mLPressStartTime = {}; mLastLTapTime = {}; + set_control_visual(control, false); + } else if (pressed) { + const auto now = clock::now(); + if (!player_attention_locked() && mLastLTapTime != clock::time_point{} && + now - mLastLTapTime <= kLDoubleTapWindow) + { + mManualLLatched = true; + mLPressed = false; + mLReleasePending = true; + mLPressStartTime = {}; + mLastLTapTime = {}; + } else if (!mLReleasePending) { + mLPressed = true; + mLPressStartTime = now; + } } else if (!mLReleasePending) { - mLPressed = true; - mLPressStartTime = now; + mLPressed = false; } - } else if (!mLReleasePending) { - mLPressed = false; - } - if (!pressed) { - const auto now = clock::now(); - if (!mLReleasePending) { - const bool wasQuickTap = mLPressStartTime != clock::time_point{} && - now - mLPressStartTime <= kLDoubleTapWindow; - mLastLTapTime = wasQuickTap ? now : clock::time_point{}; + if (!pressed) { + const auto now = clock::now(); + if (!mLReleasePending) { + const bool wasQuickTap = mLPressStartTime != clock::time_point{} && + now - mLPressStartTime <= kLDoubleTapWindow; + mLastLTapTime = wasQuickTap ? now : clock::time_point{}; + } + mLPressStartTime = {}; + mLReleasePending = false; } - mLPressStartTime = {}; - mLReleasePending = false; - } - if (!pressed && !player_attention_locked()) { - mLLatched = false; + if (!pressed && !player_attention_locked()) { + mLLatched = false; + } + break; } break; case Control::R: @@ -635,6 +663,17 @@ void TouchControls::apply_control_transform(Control control) noexcept { } void TouchControls::sync_l_lock_state() noexcept { + const auto targeting = getSettings().game.touchTargeting.getValue(); + if (targeting == TouchTargeting::Hold) { + mLLatched = false; + mManualLLatched = false; + return; + } + if (targeting == TouchTargeting::Switch) { + mLLatched = false; + return; + } + if (player_attention_locked()) { if (mLPressed) { mLLatched = true; From cfadf7607a51ee80aff863373764b28cfcc44b4e Mon Sep 17 00:00:00 2001 From: jdflyer Date: Sun, 28 Jun 2026 21:42:52 -0700 Subject: [PATCH 046/150] Various Mirror Mode Fixes (#2149) * Various Mirror Mode Fixes * Avoid mutating mArrowPos2DX --------- Co-authored-by: Luke Street --- src/d/d_camera.cpp | 4 ++++ src/d/d_menu_fmap2D.cpp | 32 ++++++++++++++++++++------------ src/dusk/touch_camera.cpp | 3 +++ 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/d/d_camera.cpp b/src/d/d_camera.cpp index cf0f7cbb56..b92a0cc4b6 100644 --- a/src/d/d_camera.cpp +++ b/src/d/d_camera.cpp @@ -7602,6 +7602,10 @@ bool dCamera_c::executeDebugFlyCam() { sFlyCamLastMousePos = mouseValid ? io.MousePos : ImVec2{-1.0f, -1.0f}; } + if (dusk::getSettings().game.enableMirrorMode) { + stickX *= -1.0f; + } + f32 verticalDisp = 0.0f; if (trigR >= FLYCAM_TRIGGER_DEADZONE) { verticalDisp += trigR; diff --git a/src/d/d_menu_fmap2D.cpp b/src/d/d_menu_fmap2D.cpp index 795e3a5f64..6483fda7db 100644 --- a/src/d/d_menu_fmap2D.cpp +++ b/src/d/d_menu_fmap2D.cpp @@ -426,7 +426,15 @@ void dMenu_Fmap2DBack_c::draw() { } mpPointParent->setAlphaRate(mArrowAlpha * mSpotTextureFadeAlpha); - mpPointParent->translate(mArrowPos2DX + mTransX, mArrowPos2DY + mTransZ); + + f32 drawX = mArrowPos2DX + mTransX; +#ifdef TARGET_PC + if (dusk::getSettings().game.enableMirrorMode) { + drawX = getMirrorPosX(drawX, 0.0f); + } +#endif + + mpPointParent->translate(drawX, mArrowPos2DY + mTransZ); mpPointScreen->draw(0.0f, 0.0f, grafPort); } @@ -745,7 +753,7 @@ void dMenu_Fmap2DBack_c::zoomMapCalc(f32 i_zoom) { f32 tmp2 = (dVar12 + (i_zoom * (centerX - dVar12))); f32 tmp2_ = (dVar11 + (i_zoom * (centerY - dVar11))); - + field_0xf0c[mRegionCursor] = ((tmp2 + (tmp3 * mZoom)) - mRegionMapSizeX[mRegionCursor] * mZoom * 0.5f) - mRegionMinMapX[mRegionCursor]; @@ -1005,6 +1013,11 @@ void dMenu_Fmap2DBack_c::allmap_move2(STControl* param_0) { f32 stickValue = param_0->getValueStick(); if (stickValue >= spC) { s16 angle = param_0->getAngleStick(); +#ifdef TARGET_PC + if (dusk::getSettings().game.enableMirrorMode) { + angle = -angle; + } +#endif f32 local_68 = (mTexMaxX - mTexMinX); f32 zoomRate = local_68 / getAllMapZoomRate(); f32 sp24; @@ -1046,11 +1059,6 @@ void dMenu_Fmap2DBack_c::allmap_move2(STControl* param_0) { calcAllMapPos2D((mArrowPos3DX + control_xpos) - mStageTransX, (mArrowPos3DZ + control_ypos) - mStageTransZ, &sp14, &sp10); -#if TARGET_PC - if (dusk::getSettings().game.enableMirrorMode) { - sp14 = getMirrorPosX(sp14, 0.0f); - } -#endif mSelectRegion = 0xff; for (int i = 7; i >= 0; i--) { @@ -1907,6 +1915,11 @@ void dMenu_Fmap2DBack_c::regionMapMove(STControl* i_stick) { f32 stick_value = i_stick->getValueStick(); if (stick_value >= slow_bound) { s16 angle = i_stick->getAngleStick(); + #ifdef TARGET_PC + if (dusk::getSettings().game.enableMirrorMode) { + angle = -angle; + } + #endif f32 local_68 = mTexMaxX - mTexMinX; f32 spot_zoom = getSpotMapZoomRate(); f32 region_zoom = getRegionMapZoomRate(mRegionCursor); @@ -1946,11 +1959,6 @@ void dMenu_Fmap2DBack_c::regionMapMove(STControl* i_stick) { calcAllMapPos2D(mArrowPos3DX + control_xpos - mStageTransX, mArrowPos3DZ + control_ypos - mStageTransZ, &pos_x, &pos_y); -#if TARGET_PC - if (dusk::getSettings().game.enableMirrorMode) { - pos_x = getMirrorPosX(pos_x, 0.0f); - } -#endif mSelectRegion = 0xff; int region = mRegionCursor; diff --git a/src/dusk/touch_camera.cpp b/src/dusk/touch_camera.cpp index 6f48d22c38..a0f8ca99dc 100644 --- a/src/dusk/touch_camera.cpp +++ b/src/dusk/touch_camera.cpp @@ -7,6 +7,9 @@ float s_pitch_dp = 0.0f; } // namespace void add_delta(float yaw_dp, float pitch_dp) noexcept { + if (getSettings().game.enableMirrorMode) { + yaw_dp *= -1.0; + } s_yaw_dp += yaw_dp; s_pitch_dp += pitch_dp; } From fe15366912dd2a8195d494773d7cd02914c408af Mon Sep 17 00:00:00 2001 From: Irastris Date: Mon, 29 Jun 2026 00:44:26 -0400 Subject: [PATCH 047/150] Make Ganondorf cape tearing deterministic when using texture replacements (#2145) * Make Ganondorf cape tearing deterministic when using texture replacements * GXIsTexObjReplaced -> has_replacement * Only check for a replacement once during creation * Update Aurora --- include/d/actor/d_a_mant.h | 9 +- src/d/actor/d_a_mant.cpp | 25 ++++- tools/mant_tear_exporter.py | 189 ++++++++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 tools/mant_tear_exporter.py diff --git a/include/d/actor/d_a_mant.h b/include/d/actor/d_a_mant.h index 7514bc6fca..6d5b173d1a 100644 --- a/include/d/actor/d_a_mant.h +++ b/include/d/actor/d_a_mant.h @@ -88,9 +88,14 @@ public: /* 0x396A */ u8 field_0x396A[0x399E - 0x396A]; /* 0x399E */ s16 field_0x399e; /* 0x39A0 */ u8 field_0x39A0[0x39A4 - 0x39A0]; - +#if TARGET_PC + /* 0x39A4 */ cM_rnd_c mMantRng; +#endif }; - +#if TARGET_PC +STATIC_ASSERT(sizeof(mant_class) == 0x39ac); +#else STATIC_ASSERT(sizeof(mant_class) == 0x39a4); +#endif #endif /* D_A_MANT_H */ diff --git a/src/d/actor/d_a_mant.cpp b/src/d/actor/d_a_mant.cpp index 47b7831c0e..956bd6cc72 100644 --- a/src/d/actor/d_a_mant.cpp +++ b/src/d/actor/d_a_mant.cpp @@ -11,6 +11,7 @@ #include "d/d_com_inf_game.h" #if TARGET_PC +#include #include "dusk/dvd_asset.hpp" #include "dusk/frame_interpolation.h" @@ -40,6 +41,8 @@ static f32* l_texCoord_get() { alignas(32) static f32 buf[338]; static bool _ //#define l_pos (l_pos_get()) #define l_normal (l_normal_get()) #define l_texCoord (l_texCoord_get()) + +static bool l_Egnd_mantTEX_hasReplacement = false; #else #include "assets/l_Egnd_mantTEX.h" @@ -223,6 +226,7 @@ void daMant_packet_c::draw() { GXInitTexObjCI( &undersideTexObj, l_Egnd_mantTEX_U, 0x80, 0x80, GX_TF_C8, GX_CLAMP, GX_CLAMP, 0, 0); GXInitTexObjLOD(&undersideTexObj, GX_LINEAR, GX_LINEAR, 0.0, 0.0, 0.0, 0, 0, GX_ANISO_1); + l_Egnd_mantTEX_hasReplacement = aurora::texture::has_replacement(&mainTexObj, &tlutObj); textureObjsInitialized = true; } #else @@ -636,7 +640,11 @@ static int daMant_Execute(mant_class* i_this) { iVar8 = 0; if (i_this->field_0x3967 != 0) { +#if TARGET_PC + mant_cut_type = l_Egnd_mantTEX_hasReplacement ? 1 : i_this->field_0x3967; +#else mant_cut_type = i_this->field_0x3967; +#endif if (i_this->field_0x3968 < 15) { i_this->field_0x3968++; @@ -648,9 +656,18 @@ static int daMant_Execute(mant_class* i_this) { iVar8 = 20; } - unaff_r29 = cM_rndF(65536.0f); - var_f31 = cM_rndFX(32.0f); - var_f30 = cM_rndFX(32.0f); +#if TARGET_PC + if (l_Egnd_mantTEX_hasReplacement) { + unaff_r29 = i_this->mMantRng.getF(65536.0f); + var_f31 = i_this->mMantRng.getFX(32.0f); + var_f30 = i_this->mMantRng.getFX(32.0f); + } else +#endif + { + unaff_r29 = cM_rndF(65536.0f); + var_f31 = cM_rndFX(32.0f); + var_f30 = cM_rndFX(32.0f); + } } i_this->field_0x3967 = 0; @@ -760,6 +777,8 @@ static int daMant_Create(fopAc_ac_c* i_this) { if(textureObjsInitialized) { GXInitTlutObjData(&tlutObj, l_Egnd_mantPAL); // make sure the cached textures are updated } + + m_this->mMantRng.init(66, 16983, 855); #endif lbl_277_bss_0 = 0; diff --git a/tools/mant_tear_exporter.py b/tools/mant_tear_exporter.py new file mode 100644 index 0000000000..b24918203a --- /dev/null +++ b/tools/mant_tear_exporter.py @@ -0,0 +1,189 @@ +""" +Loads d_a_mant.rel from CWD, applies tears using seed (66, 16983, 855) and cut type 1, writes PNGs to tear_textures/ +Requires pillow and xxhash. +""" + +import math +import struct +import xxhash +from pathlib import Path +from PIL import Image + +def yaz0_decompress(data): + expand_size = struct.unpack_from(">I", data, 4)[0] + out = bytearray(expand_size) + src_pos = 0x10 + dst_pos = 0 + chunk_bits_left = 0 + chunk_bits = 0 + + while dst_pos < expand_size: + if chunk_bits_left == 0: + chunk_bits = data[src_pos] + src_pos += 1 + chunk_bits_left = 8 + + if chunk_bits & 0x80: + out[dst_pos] = data[src_pos] + src_pos += 1 + dst_pos += 1 + else: + b0 = data[src_pos] + b1 = data[src_pos + 1] + src_pos += 2 + dist = ((b0 & 0x0F) << 8) | b1 + count = b0 >> 4 + if count == 0: + count = data[src_pos] + 0x12 + src_pos += 1 + else: + count += 2 + + copy_pos = dst_pos - dist - 1 + for _ in range(count): + out[dst_pos] = out[copy_pos] + dst_pos += 1 + copy_pos += 1 + + chunk_bits <<= 1 + chunk_bits_left -= 1 + + return bytes(out) + +SINCOS_TABLE = tuple( + ( + math.sin((i * math.tau) / (1 << 13)), + math.cos((i * math.tau) / (1 << 13)), + ) + for i in range(1 << 13) +) + +def rnd(rng): + rng[0] = (rng[0] * 171) % 30269 + rng[1] = (rng[1] * 172) % 30307 + rng[2] = (rng[2] * 170) % 30323 + value = rng[0] / 30269.0 + rng[1] / 30307.0 + rng[2] / 30323.0 + return abs(value % 1.0) + +def rnd_f(rng, max_value): + return rnd(rng) * max_value + +def rnd_fx(rng, max_value): + return max_value * (rnd(rng) - 0.5) * 2.0 + +def linear_index_to_swizzled(linear_index): + within_tile_x = linear_index & 0x7 + tile_row_offset = (linear_index & 0x78) * 4 + tile_column_offset = (linear_index >> 4) & 0x18 + macro_row_offset = linear_index & 0x3E00 + + return within_tile_x + tile_row_offset + tile_column_offset + macro_row_offset + +SWIZZLED_TO_XY = [(0, 0)] * 0x4000 +for linear_index in range(0x4000): + x = linear_index & 0x7F + y = linear_index >> 7 + swizzled_index = linear_index_to_swizzled(linear_index) + SWIZZLED_TO_XY[swizzled_index] = (x, y) + +NEIGHBOR_OFFSETS = (0, 1, 0x80, 0x81, 2, 0x82, 0x102, 0x101, 0x100) + +def write_c8_texture(c8_data, stage, output_dir, palette, palette_data): + min_index = min(c8_data) + max_index = max(c8_data) + tlut_offset = 2 * min_index + tlut_size = 2 * (max_index + 1 - min_index) + tlut_end = tlut_offset + tlut_size + path = output_dir / ( + f"[{stage:02d}] tex1_{0x80}x{0x80}_" + f"{xxhash.xxh64(c8_data, seed=0).intdigest():016x}_" + f"{xxhash.xxh64(palette_data[tlut_offset:tlut_end], seed=0).intdigest():016x}_" + f"{0x9}.png" + ) + + rgba = Image.new("RGBA", (0x80, 0x80)) + pixels = rgba.load() + for swizzled_index, palette_index in enumerate(c8_data): + x, y = SWIZZLED_TO_XY[swizzled_index] + pixels[x, y] = palette[palette_index] + rgba.save(path) + return path + +def write_stage(tex, tex_u, pal, stage, output_dir, palette): + return [ + write_c8_texture(bytes(tex), stage, output_dir, palette, pal), + write_c8_texture(bytes(tex_u), stage, output_dir, palette, pal), + ] + +def export_mant_tears(): + rel = yaz0_decompress(Path("d_a_mant.rel").read_bytes()) + tex = bytearray(rel[0x1C00 : 0x1C00 + 0x4000]) + tex_u = bytearray([6] * 0x4000) + pal = bytes(rel[0x9C00 : 0x9C00 + 0x60]) + rng = [int(66), int(16983), int(855)] + + Path("tear_textures").mkdir(parents=True, exist_ok=True) + written = [] + + rgba_palette = [] + for offset in range(0, len(pal), 2): + color16 = struct.unpack_from(">H", pal, offset)[0] + if color16 & 0x8000: + r = (color16 >> 7) & 0xF8 + r |= r >> 5 + g = (color16 >> 2) & 0xF8 + g |= g >> 5 + b = (color16 << 3) & 0xF8 + b |= b >> 5 + a = 255 + else: + r = (color16 >> 4) & 0xF0 + r |= r >> 4 + g = color16 & 0xF0 + g |= g >> 4 + b = (color16 << 4) & 0xF0 + b |= b >> 4 + a = (color16 >> 7) & 0xE0 + a |= (a >> 3) | (a >> 6) + rgba_palette.append((r, g, b, a)) + while len(rgba_palette) < 256: + rgba_palette.append((0, 0, 0, 0)) + + written.extend(write_stage(tex, tex_u, pal, 0, Path("tear_textures"), rgba_palette)) + + cut_step = 0 + for _ in range(15): + cut_step += 1 + + angle = int(rnd_f(rng, 65536.0)) & 0xFFFF + if angle >= 0x8000: + angle -= 0x10000 + + x = rnd_fx(rng, 32.0) + y = rnd_fx(rng, 32.0) + sincos_index = (angle & 0xFFFF) >> (16 - 13) + sin_v, cos_v = SINCOS_TABLE[sincos_index] + + for i, texel_count in enumerate( + tuple( + 1 if i <= 3 or i >= 26 else (9 if 12 <= i <= 18 else 4) + for i in range(30) + ) + ): + x += sin_v + y -= cos_v + + packed = int(x + 64.0) | (int(y + 64.0) << 7) + for j in range(texel_count): + u_var1 = packed + NEIGHBOR_OFFSETS[j] + if 0 <= u_var1 < 0x4000: + i_var5 = linear_index_to_swizzled(u_var1) + tex[i_var5] = 0 + tex_u[i_var5] = 0 + + written.extend(write_stage(tex, tex_u, pal, cut_step, Path("tear_textures"), rgba_palette)) + + return written + +if __name__ == "__main__": + export_mant_tears() From 09f087656afca428700f695a2372e5af7f122898 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 30 Jun 2026 15:19:30 -0600 Subject: [PATCH 048/150] Guard against null dMsgObject_getMsgObjectClass() in a few places --- include/d/d_msg_object.h | 10 ++++++++++ src/d/d_meter2.cpp | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/include/d/d_msg_object.h b/include/d/d_msg_object.h index 6cf9ea7681..d4d8bb7f47 100644 --- a/include/d/d_msg_object.h +++ b/include/d/d_msg_object.h @@ -360,7 +360,12 @@ inline void dMsgObject_demoMessageGroup() { } inline bool dMsgObject_isTalkNowCheck() { +#if TARGET_PC + dMsgObject_c* msgObject = dMsgObject_getMsgObjectClass(); + return msgObject != NULL && msgObject->getStatus() != 1; +#else return dMsgObject_getMsgObjectClass()->getStatus() == 1 ? false : true; +#endif } inline bool dMsgObject_isKillMessageFlag() { @@ -497,7 +502,12 @@ inline void dMsgObject_onMsgSend() { } inline bool dMsgObject_isFukidashiCheck() { +#if TARGET_PC + dMsgObject_c* msgObject = dMsgObject_getMsgObjectClass(); + return msgObject != NULL && msgObject->getScrnDrawPtr() != NULL; +#else return dMsgObject_getMsgObjectClass()->getScrnDrawPtr() == NULL ? false : true; +#endif } inline void* dMsgObject_getTalkHeap() { diff --git a/src/d/d_meter2.cpp b/src/d/d_meter2.cpp index f628466919..e0855461d4 100644 --- a/src/d/d_meter2.cpp +++ b/src/d/d_meter2.cpp @@ -437,7 +437,12 @@ void dMeter2_c::checkStatus() { field_0x128 = daPy_py_c::checkNowWolf(); +#if TARGET_PC + dMsgObject_c* msgObject = dMsgObject_getMsgObjectClass(); + if (!dComIfGp_2dShowCheck() || (msgObject != NULL && msgObject->isPlaceMessage())) { +#else if (!dComIfGp_2dShowCheck() || dMsgObject_getMsgObjectClass()->isPlaceMessage()) { +#endif mStatus |= 0x4000; } else if (dComIfGp_checkPlayerStatus1(0, 1) && dComIfGp_getAStatus() == 0x12) { mStatus |= 0x200000; @@ -2870,8 +2875,14 @@ void dMeter2_c::alphaAnimeButton() { u8 var_31; var_31 = 0; +#if TARGET_PC + dMsgObject_c* msgObject = dMsgObject_getMsgObjectClass(); + if ((mStatus & 0x4000) || + ((mStatus & 0x100) && (msgObject != NULL && msgObject->isAutoMessageFlag())) || +#else if ((mStatus & 0x4000) || ((mStatus & 0x100) && dMsgObject_getMsgObjectClass()->isAutoMessageFlag()) || +#endif ((mStatus & 0x40000000) && !(mStatus & 0x100)) || (mStatus & 0x80000000) || (mStatus & 8) || (mStatus & 0x10) || (mStatus & 0x20) || (mStatus & 0x04000000) || (mStatus & 0x10000000)) { From f32e069c4bb97765f1e0f760db22cc3d737cc4d9 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Thu, 2 Jul 2026 00:03:18 -0600 Subject: [PATCH 049/150] Improve mouse hiding logic (#2163) * Improve mouse hiding logic * Restore ImGui logic --- include/dusk/mouse.h | 8 +-- src/d/actor/d_a_alink_link.inc | 2 +- src/d/d_camera.cpp | 2 +- src/dusk/mouse.cpp | 126 +++++++++++++++++++-------------- src/m_Do/m_Do_main.cpp | 4 +- 5 files changed, 82 insertions(+), 60 deletions(-) diff --git a/include/dusk/mouse.h b/include/dusk/mouse.h index 514d14534a..c98d53c18f 100644 --- a/include/dusk/mouse.h +++ b/include/dusk/mouse.h @@ -4,9 +4,9 @@ namespace dusk::mouse { void read(); -void getAimDeltas(float& out_yaw, float& out_pitch); -void getCameraDeltas(float& out_yaw, float& out_pitch); +void get_aim_deltas(float& out_yaw, float& out_pitch); +void get_camera_deltas(float& out_yaw, float& out_pitch); void handle_event(const SDL_Event& event) noexcept; -void onFocusLost(); -void onFocusGained(); +void on_focus_lost(); +void on_focus_gained(); } // namespace dusk::mouse diff --git a/src/d/actor/d_a_alink_link.inc b/src/d/actor/d_a_alink_link.inc index cf2f2544d6..b5dcfe06e6 100644 --- a/src/d/actor/d_a_alink_link.inc +++ b/src/d/actor/d_a_alink_link.inc @@ -156,7 +156,7 @@ BOOL daAlink_c::setBodyAngleToCamera() { f32 final_yaw = 0.f; f32 final_pitch = 0.f; if (dusk::getSettings().game.enableMouseAim) { - dusk::mouse::getAimDeltas(final_yaw, final_pitch); + dusk::mouse::get_aim_deltas(final_yaw, final_pitch); } if (dusk::getSettings().game.enableGyroAim) { f32 gyro_yaw = 0.f; diff --git a/src/d/d_camera.cpp b/src/d/d_camera.cpp index b92a0cc4b6..ed0de09b63 100644 --- a/src/d/d_camera.cpp +++ b/src/d/d_camera.cpp @@ -7716,7 +7716,7 @@ bool dCamera_c::freeCamera() { f32 yaw_rad = 0.0f; f32 pitch_rad = 0.0f; - dusk::mouse::getCameraDeltas(yaw_rad, pitch_rad); + dusk::mouse::get_camera_deltas(yaw_rad, pitch_rad); if (dusk::getSettings().game.enableMouseCamera && (yaw_rad != 0.0f || pitch_rad != 0.0f) && !dComIfGp_checkCameraAttentionStatus(dComIfGp_getPlayerCameraID(0), 0x8)) { diff --git a/src/dusk/mouse.cpp b/src/dusk/mouse.cpp index f1aa5672fd..a1fd4efda8 100644 --- a/src/dusk/mouse.cpp +++ b/src/dusk/mouse.cpp @@ -1,54 +1,69 @@ #include "dusk/mouse.h" +#include "d/actor/d_a_alink.h" +#include "d/d_com_inf_game.h" #include "dusk/menu_pointer.h" #include "dusk/settings.h" #include "dusk/ui/ui.hpp" -#include "d/actor/d_a_alink.h" -#include "d/d_com_inf_game.h" -#include -#include #include #include +#include +#include + +#include namespace dusk::mouse { namespace { -constexpr float kMousePixelToRad = 0.0025f; -constexpr int kIdleHideFrames = 99; // Approx. 3 seconds with 33ms ticks +using Clock = std::chrono::steady_clock; -float s_aim_yaw_rad = 0.0f; -float s_aim_pitch_rad = 0.0f; -float s_camera_yaw_rad = 0.0f; +constexpr float kMousePixelToRad = 0.0025f; +constexpr auto kCursorIdleDuration = std::chrono::seconds(1); + +float s_aim_yaw_rad = 0.0f; +float s_aim_pitch_rad = 0.0f; +float s_camera_yaw_rad = 0.0f; float s_camera_pitch_rad = 0.0f; -int s_idle_frames = 0; +Clock::time_point s_last_cursor_motion = Clock::now(); void reset_deltas() { s_aim_yaw_rad = s_aim_pitch_rad = 0.0f; s_camera_yaw_rad = s_camera_pitch_rad = 0.0f; } -bool queryMouseAimContext() { +bool query_mouse_aim_context() { return getSettings().game.enableMouseAim.getValue() && dCamera_c::isAimActive(); } -bool wantMouseCapture() { - return getSettings().game.enableMouseCamera.getValue() || queryMouseAimContext(); +bool want_mouse_capture() { + return getSettings().game.enableMouseCamera.getValue() || query_mouse_aim_context(); } -bool isWindowFocused(SDL_Window* window) { +bool mouse_input_enabled() { + const auto& game = getSettings().game; + return game.enableMouseAim.getValue() || game.enableMouseCamera.getValue(); +} + +bool is_window_focused(SDL_Window* window) { if (window == nullptr) { return false; } return (SDL_GetWindowFlags(window) & SDL_WINDOW_INPUT_FOCUS) != 0; } -bool shouldCaptureMouse(SDL_Window* window) { - if (window == nullptr || ui::any_document_visible() || menu_pointer::active()) { - return false; - } - return wantMouseCapture() && isWindowFocused(window); +bool imgui_windows_visible() { + return ImGui::GetIO().MetricsRenderWindows > 0; } -bool syncCaptureState(SDL_Window* window, bool should_capture) { +bool should_capture_mouse(SDL_Window* window) { + if (window == nullptr || ui::any_document_visible() || imgui_windows_visible() || + menu_pointer::active()) + { + return false; + } + return want_mouse_capture() && is_window_focused(window); +} + +bool sync_capture_state(SDL_Window* window, bool should_capture) { if (window == nullptr) { reset_deltas(); return false; @@ -78,7 +93,7 @@ bool syncCaptureState(SDL_Window* window, bool should_capture) { return is_captured; } -void accumulateDeltas(float mx_rel, float my_rel, bool camera_active, bool aim_active) { +void accumulate_deltas(float mx_rel, float my_rel, bool camera_active, bool aim_active) { const auto& game = getSettings().game; const bool mirror_mode = game.enableMirrorMode.getValue(); const bool invert_y = game.invertMouseY.getValue(); @@ -114,57 +129,62 @@ void set_cursor_visible(bool visible) { } } -void update_cursor_visibility(SDL_Window* window, bool captured) { - if (window == nullptr || !isWindowFocused(window)) { - return; - } +bool cursor_idle() { + return Clock::now() - s_last_cursor_motion >= kCursorIdleDuration; +} +bool should_show_cursor(bool captured) { if (captured) { - s_idle_frames = 0; - set_cursor_visible(false); + return false; + } + if (ui::any_document_visible()) { + return true; + } + if (imgui_windows_visible()) { + return true; + } + if (menu_pointer::enabled() && menu_pointer::active()) { + return true; + } + if (mouse_input_enabled()) { + return false; + } + return !cursor_idle(); +} + +void update_cursor_visibility(SDL_Window* window, bool captured) { + if (window == nullptr || !is_window_focused(window)) { return; } - const ImGuiIO& io = ImGui::GetIO(); - if (io.MouseDelta.x != 0.0f || io.MouseDelta.y != 0.0f) { - s_idle_frames = 0; - set_cursor_visible(true); - return; - } - - if (s_idle_frames < kIdleHideFrames) { - ++s_idle_frames; - set_cursor_visible(true); - } else { - set_cursor_visible(false); - } + set_cursor_visible(should_show_cursor(captured)); } } // namespace void read() { SDL_Window* window = aurora::window::get_sdl_window(); - const bool capture_active = syncCaptureState(window, shouldCaptureMouse(window)); + const bool capture_active = sync_capture_state(window, should_capture_mouse(window)); update_cursor_visibility(window, capture_active); if (!capture_active) { return; } - const bool aim_active = capture_active && queryMouseAimContext(); + const bool aim_active = capture_active && query_mouse_aim_context(); const bool camera_active = capture_active && getSettings().game.enableMouseCamera; float mx_rel = 0.0f; float my_rel = 0.0f; SDL_GetRelativeMouseState(&mx_rel, &my_rel); - accumulateDeltas(mx_rel, my_rel, camera_active, aim_active); + accumulate_deltas(mx_rel, my_rel, camera_active, aim_active); } -void getAimDeltas(float& out_yaw, float& out_pitch) { +void get_aim_deltas(float& out_yaw, float& out_pitch) { out_yaw = s_aim_yaw_rad; out_pitch = s_aim_pitch_rad; } -void getCameraDeltas(float& out_yaw, float& out_pitch) { +void get_camera_deltas(float& out_yaw, float& out_pitch) { out_yaw = 0.0f; out_pitch = 0.0f; @@ -178,26 +198,28 @@ void getCameraDeltas(float& out_yaw, float& out_pitch) { void handle_event(const SDL_Event& event) noexcept { switch (event.type) { + case SDL_EVENT_MOUSE_MOTION: + s_last_cursor_motion = Clock::now(); + break; case SDL_EVENT_WINDOW_FOCUS_LOST: - onFocusLost(); + on_focus_lost(); break; case SDL_EVENT_WINDOW_FOCUS_GAINED: - onFocusGained(); + on_focus_gained(); break; } } -void onFocusLost() { +void on_focus_lost() { SDL_Window* window = aurora::window::get_sdl_window(); if (window != nullptr) { - syncCaptureState(window, false); + sync_capture_state(window, false); } - s_idle_frames = 0; set_cursor_visible(true); } -void onFocusGained() { +void on_focus_gained() { SDL_Window* window = aurora::window::get_sdl_window(); - syncCaptureState(window, shouldCaptureMouse(window)); + sync_capture_state(window, should_capture_mouse(window)); } } // namespace dusk::mouse diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index 0d1e695524..0a190ed3dd 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -249,12 +249,12 @@ void main01(void) { goto eventsDone; case AURORA_PAUSED: dusk::audio::SetPaused(true); - dusk::mouse::onFocusLost(); + dusk::mouse::on_focus_lost(); break; case AURORA_UNPAUSED: dusk::audio::SetPaused(false); dusk::game_clock::reset_frame_timer(); - dusk::mouse::onFocusGained(); + dusk::mouse::on_focus_gained(); break; case AURORA_SDL_EVENT: dusk::mouse::handle_event(event->sdl); From f54892b2c2200ec84bbb3b06fed96bd9e140c176 Mon Sep 17 00:00:00 2001 From: Olivia!! Date: Thu, 2 Jul 2026 08:03:53 +0200 Subject: [PATCH 050/150] do not disable regular touch input if menu touch is disabled (#2152) --- src/dusk/menu_pointer.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dusk/menu_pointer.cpp b/src/dusk/menu_pointer.cpp index 83ed7afc84..0f1688f698 100644 --- a/src/dusk/menu_pointer.cpp +++ b/src/dusk/menu_pointer.cpp @@ -314,7 +314,7 @@ void end_game_frame() noexcept { } void begin_context(Context context) noexcept { - if (context == Context::None) { + if (context == Context::None || !enabled()) { return; } From 42910ab2fd6e34031ff9caabdb5298c0526c1552 Mon Sep 17 00:00:00 2001 From: Irastris Date: Thu, 2 Jul 2026 02:13:22 -0400 Subject: [PATCH 051/150] Restore simultaneous gyro and joystick input for first person aiming (#2164) --- src/d/actor/d_a_alink_link.inc | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/d/actor/d_a_alink_link.inc b/src/d/actor/d_a_alink_link.inc index b5dcfe06e6..6150fe09d2 100644 --- a/src/d/actor/d_a_alink_link.inc +++ b/src/d/actor/d_a_alink_link.inc @@ -122,18 +122,11 @@ BOOL daAlink_c::setBodyAngleToCamera() { var_f31 /= dComIfGp_getCameraZoomScale(field_0x317c); } -#if TARGET_PC - if (dusk::getSettings().game.enableMouseAim && checkAimInputContext()) { - sp8 = mBodyAngle.x; - } else -#endif - { - shape_angle.y = shape_angle.y + (var_f31 * cM_ssin(mStickAngle) IF_DUSK(* (dusk::getSettings().game.invertFirstPersonXAxis ? -1.0f : 1.0f))); - sp8 = mBodyAngle.x + (var_f31 * cM_scos(mStickAngle) IF_DUSK(* (dusk::getSettings().game.invertFirstPersonYAxis ? -1.0f : 1.0f))); + shape_angle.y = shape_angle.y + (var_f31 * cM_ssin(mStickAngle) IF_DUSK(* (dusk::getSettings().game.invertFirstPersonXAxis ? -1.0f : 1.0f))); + sp8 = mBodyAngle.x + (var_f31 * cM_scos(mStickAngle) IF_DUSK(* (dusk::getSettings().game.invertFirstPersonYAxis ? -1.0f : 1.0f))); - if (checkNotItemSinkLimit() && sp8 > 0 && sp8 > mBodyAngle.x) { - sp8 = mBodyAngle.x; - } + if (checkNotItemSinkLimit() && sp8 > 0 && sp8 > mBodyAngle.x) { + sp8 = mBodyAngle.x; } } else { sp8 = mBodyAngle.x; From b772b6d95257fb12f213c60651f76a824a7aa612 Mon Sep 17 00:00:00 2001 From: "hector.bonhoure@gmail.com" Date: Sun, 5 Jul 2026 16:05:55 +0200 Subject: [PATCH 052/150] Fixed invincible enemies cheat not deactivating on game startup when speedrun mode is activated --- src/dusk/speedrun.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dusk/speedrun.cpp b/src/dusk/speedrun.cpp index 38488600b1..0a70125da2 100644 --- a/src/dusk/speedrun.cpp +++ b/src/dusk/speedrun.cpp @@ -34,6 +34,7 @@ void resetForSpeedrunMode() { getSettings().game.fastRoll.setSpeedrunValue(false); getSettings().game.fastSpinner.setSpeedrunValue(false); getSettings().game.armorRupeeDrain.setSpeedrunValue(MagicArmorMode::NORMAL); + getSettings().game.invincibleEnemies.setSpeedrunValue(false); getSettings().game.pauseOnFocusLost.setSpeedrunValue(false); aurora_set_pause_on_focus_lost(false); From 4089a80a17d29b8368ce8fbccd967c93d8ce29e6 Mon Sep 17 00:00:00 2001 From: SuperDude88 <82904174+SuperDude88@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:23:03 -0400 Subject: [PATCH 053/150] Mirror Mode Map Fixes Will probably need to resolve conflicts with Wii code once decomp has related PR merged + we sync things Change to setPlusZoomCenterX is not present on Wii (but fixes controls), probably from its lack of CStick --- src/d/d_map.cpp | 7 +++- src/d/d_menu_dmap.cpp | 24 ++++++++++- src/d/d_menu_dmap_map.cpp | 21 +--------- src/d/d_menu_fmap.cpp | 25 +++++------- src/d/d_menu_fmap2D.cpp | 80 +++++++++++++++++++++++++++++-------- src/d/d_menu_map_common.cpp | 22 ++++------ 6 files changed, 110 insertions(+), 69 deletions(-) diff --git a/src/d/d_map.cpp b/src/d/d_map.cpp index 765facc335..404123f594 100644 --- a/src/d/d_map.cpp +++ b/src/d/d_map.cpp @@ -1640,7 +1640,7 @@ void dMap_c::_move(f32 i_centerX, f32 i_centerZ, int i_roomNo, f32 param_3) { mCenterX += IF_DUSK(dusk::getSettings().game.enableMirrorMode ? -mPackX :) mPackX; mCenterZ -= mPackZ; - mCenterX += field_0x64; + mCenterX += IF_DUSK(dusk::getSettings().game.enableMirrorMode ? -field_0x64 : ) field_0x64; mCenterZ += mPackPlusZ; } @@ -1649,6 +1649,8 @@ void dMap_c::_move(f32 i_centerX, f32 i_centerZ, int i_roomNo, f32 param_3) { { #if DEBUG field_0x64 = 33830.0f; +#elif TARGET_PC + field_0x64 = dusk::getSettings().game.enableMirrorMode ? 33830.0f : 0.0f; #else field_0x64 = 0.0f; #endif @@ -1657,6 +1659,9 @@ void dMap_c::_move(f32 i_centerX, f32 i_centerZ, int i_roomNo, f32 param_3) { f32 temp = (field_0x58 * (f32)(field_0x74 + 4)) * 0.5f; #if DEBUG mRightEdgePlus = -(((dMpath_c::getMinZ() - (-127103.67f)) - temp) / field_0x58); + +#elif TARGET_PC + mRightEdgePlus = dusk::getSettings().game.enableMirrorMode ? -(((dMpath_c::getMinX() - (-127103.67f)) - temp) / field_0x58) : 0.0f; #else mRightEdgePlus = 0.0f; #endif diff --git a/src/d/d_menu_dmap.cpp b/src/d/d_menu_dmap.cpp index 609d8ef6be..4bbc56543e 100644 --- a/src/d/d_menu_dmap.cpp +++ b/src/d/d_menu_dmap.cpp @@ -960,6 +960,15 @@ void dMenu_DmapBg_c::draw() { mpBackTexture->setAlpha(dVar17 * (field_0xdbc * field_0xd9c)); f32 local_28c = mpBackTexture->getBounds().i.x; + + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + CPaneMgr mgr; + Vec local_94 = mgr.getGlobalVtxCenter(mMapPane, true, 0); + local_28c = (local_94.x * 2.0f) - (local_28c + 0.5f * mpBackTexture->getWidth()) - 0.5f * mpBackTexture->getWidth(); + } + #endif + mpBackTexture->setBlackWhite(color_black, color_white); mpBackTexture->draw(local_28c, field_0xd94 + mpBackTexture->getBounds().i.y, mpBackTexture->getWidth(), mpBackTexture->getHeight(), @@ -1242,6 +1251,12 @@ void dMenu_Dmap_c::screenInit() { } else { mpDrawBg->mFloorScreen->search('wolf')->hide(); mpDrawBg->mFloorScreen->search('rink')->show(); + + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + ((J2DPicture*)mpDrawBg->mFloorScreen->search('rink'))->setMirror(J2DMirror_X); + } + #endif } } } @@ -1285,7 +1300,14 @@ void dMenu_Dmap_c::screenInit() { field_0x7c[1] = JKR_NEW CPaneMgr(mpDrawBg->mBaseScreen, MULTI_CHAR('con_n'), 3, NULL); field_0x7c[2] = JKR_NEW CPaneMgr(mpDrawBg->mBaseScreen, MULTI_CHAR('key_n'), 3, NULL); field_0x88[0] = JKR_NEW CPaneMgr(mpDrawBg->mBaseScreen, MULTI_CHAR('map000'), 3, NULL); + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + ((J2DPicture*)field_0x88[0]->getPanePtr())->setMirror(J2DMirror_X); + } + #endif field_0x88[1] = JKR_NEW CPaneMgr(mpDrawBg->mBaseScreen, MULTI_CHAR('con000'), 3, NULL); + + ((J2DPicture*)field_0x88[0]->getPanePtr())->setMirror(J2DMirror_X); if (dStage_stagInfo_GetSaveTbl(dComIfGp_getStageStagInfo()) == dStage_SaveTbl_LV2) { field_0x88[2] = JKR_NEW CPaneMgr(mpDrawBg->mBaseScreen, MULTI_CHAR('i_key_n'), 3, NULL); @@ -1981,7 +2003,7 @@ void dMenu_Dmap_c::mapControl() { f32 temp_f28 = (var_f29 / 100.0f) * var_f31; f32 sp18 = temp_f28 * cM_ssin(stick_angle); f32 sp14 = temp_f28 * cM_scos(stick_angle); - mMapCtrl->setPlusZoomCenterX(sp18); + mMapCtrl->setPlusZoomCenterX(IF_DUSK(dusk::getSettings().game.enableMirrorMode ? -sp18 :) sp18); mMapCtrl->setPlusZoomCenterZ(sp14); } diff --git a/src/d/d_menu_dmap_map.cpp b/src/d/d_menu_dmap_map.cpp index c4029b766e..e3239d3678 100644 --- a/src/d/d_menu_dmap_map.cpp +++ b/src/d/d_menu_dmap_map.cpp @@ -359,14 +359,7 @@ f32 dMenu_StageMapCtrl_c::getPixelStageSizeZ() const { f32 dMenu_StageMapCtrl_c::getPixelCenterX() const { f32 var_f31 = dMpath_c::getCenterX(); - #if TARGET_PC - if (dusk::getSettings().game.enableMirrorMode) { - return (1.0f / field_0xbc) * (field_0x9c + var_f31); - } - else return (1.0f / field_0xbc) * (field_0x9c - var_f31); - #else return (1.0f / field_0xbc) * (field_0x9c - var_f31); - #endif } f32 dMenu_StageMapCtrl_c::getPixelCenterZ() const { @@ -425,18 +418,7 @@ inline static f32 rightModeCnvPos(f32 param_0) { void dMenu_StageMapCtrl_c::cnvPosTo2Dpos(f32 param_0, f32 param_1, f32* param_2, f32* param_3) const { if (param_2 != NULL) { - #if TARGET_PC - if (dusk::getSettings().game.enableMirrorMode) { - *param_2 = - (0.5f * field_0x94) + rightModeCnvPos((1.0f / field_0xbc) * (field_0x9c + param_0)); - } else { - *param_2 = - (0.5f * field_0x94) + rightModeCnvPos((1.0f / field_0xbc) * (param_0 - field_0x9c)); - } - #else *param_2 = (0.5f * field_0x94) + rightModeCnvPos((1.0f / field_0xbc) * (param_0 - field_0x9c)); - #endif - } if (param_3 != NULL) { @@ -933,8 +915,7 @@ void dMenu_StageMapCtrl_c::move() { void dMenu_DmapMapCtrl_c::draw() { if (field_0xef != 0) { - setPos(field_0xeb, field_0xec, - IF_DUSK(dusk::getSettings().game.enableMirrorMode ? -field_0x9c :) field_0x9c, + setPos(field_0xeb, field_0xec, field_0x9c, field_0xa0, field_0xbc, true, field_0xd8); } } diff --git a/src/d/d_menu_fmap.cpp b/src/d/d_menu_fmap.cpp index 813427f35e..64cd70cc0f 100644 --- a/src/d/d_menu_fmap.cpp +++ b/src/d/d_menu_fmap.cpp @@ -931,17 +931,8 @@ void dMenu_Fmap_c::region_map_proc() { mpDraw2DBack->regionMapMove(mpStick); int stage_no, room_no; -#if TARGET_PC - f32 arrow_pos_x = mpDraw2DBack->getArrowPos2DX(); - if (dusk::getSettings().game.enableMirrorMode) { - arrow_pos_x = mpDraw2DBack->getMirrorPosX(arrow_pos_x, 0.0f); - } - - f32 pos_x = arrow_pos_x - mDoGph_gInf_c::getMinXF() - mDoGph_gInf_c::getWidthF() * 0.5f; -#else f32 pos_x = mpDraw2DBack->getArrowPos2DX() - mDoGph_gInf_c::getMinXF() - mDoGph_gInf_c::getWidthF() * 0.5f; -#endif f32 pos_y = mpDraw2DBack->getArrowPos2DY() - mDoGph_gInf_c::getHeightF() * 0.5f; mpMenuFmapMap->getPointStagePathInnerNo(getNowFmapRegionData(), pos_x, pos_y, @@ -2486,12 +2477,6 @@ void dMenu_Fmap_c::portalWarpMapMove(STControl* i_stick) { f32 arrow_y = mpDraw2DBack->getArrowPos2DY(); u8 uVar6 = 0xff; -#if TARGET_PC - if (dusk::getSettings().game.enableMirrorMode) { - arrow_x = mpDraw2DBack->getMirrorPosX(arrow_x, 0.0f); - } -#endif - for (int i = 0; i < portal_dat->mCount; i++) { if (portals[i].mRegionNo == mpDraw2DBack->getRegionCursor() + 1 @@ -2561,6 +2546,11 @@ void dMenu_Fmap_c::drawIcon(f32 param_0, bool param_1) { if (mProcess == PROC_PORTAL_DEMO1) { is_portal_demo1 = 1; } + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + angle = 0x10000 - angle; + } + #endif mpDraw2DBack->setIcon2DPos(0x11, stage_name, pos.x, pos.z, cM_sht2d(angle), is_portal_demo1, param_1); @@ -2649,6 +2639,11 @@ void dMenu_Fmap_c::drawPlayEnterIcon() { angle = dComIfGs_getPlayerFieldLastStayAngleY(); SAFE_STRCPY(stage_name, dComIfGs_getPlayerFieldLastStayName()); } + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + angle = 0x10000 - angle; + } + #endif mpDraw2DBack->setIcon2DPos(0x15, stage_name, pos.x, pos.z, cM_sht2d(angle), 0, false); } } diff --git a/src/d/d_menu_fmap2D.cpp b/src/d/d_menu_fmap2D.cpp index 6483fda7db..8b4e4b59b9 100644 --- a/src/d/d_menu_fmap2D.cpp +++ b/src/d/d_menu_fmap2D.cpp @@ -1013,11 +1013,6 @@ void dMenu_Fmap2DBack_c::allmap_move2(STControl* param_0) { f32 stickValue = param_0->getValueStick(); if (stickValue >= spC) { s16 angle = param_0->getAngleStick(); -#ifdef TARGET_PC - if (dusk::getSettings().game.enableMirrorMode) { - angle = -angle; - } -#endif f32 local_68 = (mTexMaxX - mTexMinX); f32 zoomRate = local_68 / getAllMapZoomRate(); f32 sp24; @@ -1031,6 +1026,11 @@ void dMenu_Fmap2DBack_c::allmap_move2(STControl* param_0) { f32 delta_y = speed * cM_ssin(angle); f32 delta_x = speed * cM_scos(angle); +#ifdef TARGET_PC + if (dusk::getSettings().game.enableMirrorMode) { + delta_y = -delta_y; + } +#endif control_xpos = control_xpos + delta_y; control_ypos = control_ypos + delta_x; } @@ -1473,6 +1473,12 @@ void dMenu_Fmap2DBack_c::worldGridDraw() { f32 dVar8 = -mStageTransZ; calcAllMapPos2D(dVar9, dVar8, &local_74, &local_78); + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + local_74 = getMirrorPosX(local_74, 0.0f); + } + #endif + J2DDrawLine(local_74, mDoGph_gInf_c::getMinYF(), local_74, mDoGph_gInf_c::getMinYF() + mDoGph_gInf_c::getHeightF(), JUtility::TColor(255, 255, 255, 255), 6); @@ -1481,6 +1487,11 @@ void dMenu_Fmap2DBack_c::worldGridDraw() { while (true) { calcAllMapPos2D(xPos, dVar8, &local_74, &local_78); if (local_74 >= getMapScissorAreaLX()) { + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + local_74 = getMirrorPosX(local_74, 0.0f); + } + #endif J2DDrawLine(local_74, mDoGph_gInf_c::getMinYF(), local_74, mDoGph_gInf_c::getMinYF() + mDoGph_gInf_c::getHeightF(), JUtility::TColor(255, 255, 255, 255), 6); @@ -1542,6 +1553,12 @@ void dMenu_Fmap2DBack_c::regionGridDraw() { f32 dVar8 = mRegionOriginZ[mRegionCursor] - mStageTransZ; calcAllMapPos2D(dVar9, dVar8, &local_74, &local_78); + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + local_74 = getMirrorPosX(local_74, 0.0f); + } + #endif + J2DDrawLine(local_74, mDoGph_gInf_c::getMinYF(), local_74, mDoGph_gInf_c::getMinYF() + mDoGph_gInf_c::getHeightF(), JUtility::TColor(180, 0, 0, 255), 6); @@ -1550,6 +1567,12 @@ void dMenu_Fmap2DBack_c::regionGridDraw() { while (true) { calcAllMapPos2D(xPos, dVar8, &local_74, &local_78); if (local_74 >= getMapScissorAreaLX()) { + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + local_74 = getMirrorPosX(local_74, 0.0f); + } + #endif + J2DDrawLine(local_74, mDoGph_gInf_c::getMinYF(), local_74, mDoGph_gInf_c::getMinYF() + mDoGph_gInf_c::getHeightF(), JUtility::TColor(180, 0, 0, 255), 6); @@ -1612,6 +1635,12 @@ void dMenu_Fmap2DBack_c::worldOriginDraw() { f32 local_44, local_48; calcAllMapPos2D(-mStageTransX, -mStageTransZ, &local_44, &local_48); + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + local_44 = getMirrorPosX(local_44, 0.0f); + } + #endif + J2DDrawLine(mDoGph_gInf_c::getMinXF(), local_48 - local_44 + mDoGph_gInf_c::getMinXF(), mDoGph_gInf_c::getMinXF() + mDoGph_gInf_c::getWidthF(), local_48 - local_44 + (mDoGph_gInf_c::getMinXF() + mDoGph_gInf_c::getWidthF()), @@ -1646,6 +1675,13 @@ void dMenu_Fmap2DBack_c::scrollAreaDraw() { calcAllMapPos2D(x_min - mStageTransX, z_min - mStageTransZ, &local_4c, &local_50); calcAllMapPos2D(x_max - mStageTransX, z_max - mStageTransZ, &local_54, &local_58); + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + local_4c = getMirrorPosX(local_4c, 0.0f); + local_54 = getMirrorPosX(local_54, 0.0f); + } + #endif + J2DDrawLine(local_4c, local_50, local_4c, local_58, JUtility::TColor(255, 255, 255, 255), 6); J2DDrawLine(local_54, local_50, local_54, local_58, @@ -1666,6 +1702,11 @@ void dMenu_Fmap2DBack_c::regionOriginDraw() { f32 center_x, center_y; calcAllMapPos2D(mRegionOriginX[i] - mStageTransX, mRegionOriginZ[i] - mStageTransZ, ¢er_x, ¢er_y); + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + center_x = getMirrorPosX(center_x - 3.0f, 3.0f); + } + #endif J2DFillBox(center_x - 3.0f, center_y - 3.0f, 6.0f, 6.0f, JUtility::TColor(255, 0, 0, 255)); } } @@ -1683,6 +1724,11 @@ void dMenu_Fmap2DBack_c::stageOriginDraw() { f32 v1 = mRegionOriginX[mRegionCursor] + stage_data[i].mOffsetX - mStageTransX; f32 v2 = mRegionOriginZ[mRegionCursor] + stage_data[i].mOffsetZ - mStageTransZ; calcAllMapPos2D(v1, v2, ¢er_x, ¢er_y); + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + center_x = getMirrorPosX(center_x - 3.0f, 3.0f); + } + #endif J2DFillBox(center_x - 3.0f, center_y - 3.0f, 6.0f, 6.0f, JUtility::TColor(0, 0, 255, 255)); } @@ -1915,11 +1961,6 @@ void dMenu_Fmap2DBack_c::regionMapMove(STControl* i_stick) { f32 stick_value = i_stick->getValueStick(); if (stick_value >= slow_bound) { s16 angle = i_stick->getAngleStick(); - #ifdef TARGET_PC - if (dusk::getSettings().game.enableMirrorMode) { - angle = -angle; - } - #endif f32 local_68 = mTexMaxX - mTexMinX; f32 spot_zoom = getSpotMapZoomRate(); f32 region_zoom = getRegionMapZoomRate(mRegionCursor); @@ -1934,7 +1975,7 @@ void dMenu_Fmap2DBack_c::regionMapMove(STControl* i_stick) { f32 speed = base_speed / 100.0f * local_78; f32 speed_y = speed * cM_ssin(angle); f32 speed_x = speed * cM_scos(angle); - control_xpos += speed_y; + control_xpos += IF_DUSK(dusk::getSettings().game.enableMirrorMode ? -speed_y :) speed_y; control_ypos += speed_x; } } @@ -1990,11 +2031,6 @@ void dMenu_Fmap2DBack_c::stageMapMove(STControl* i_stick, u8 param_1, bool param if (stick_value >= slow_bound && param_2 && field_0x1238 != 2) { bVar6 = true; s16 angle = i_stick->getAngleStick(); -#if TARGET_PC - if (dusk::getSettings().game.enableMirrorMode) { - angle = -angle; - } -#endif f32 local_68 = mTexMaxX - mTexMinX; f32 spot_zoom = getSpotMapZoomRate(); f32 region_zoom = getRegionMapZoomRate(mRegionCursor); @@ -2009,7 +2045,7 @@ void dMenu_Fmap2DBack_c::stageMapMove(STControl* i_stick, u8 param_1, bool param f32 speed = base_speed / 100.0f * local_78; f32 speed_x = speed * cM_ssin(angle); f32 speed_z = speed * cM_scos(angle); - mStageTransX += speed_x; + mStageTransX += IF_DUSK(dusk::getSettings().game.enableMirrorMode ? -speed_x :) speed_x; mStageTransZ += speed_z; } else if (!param_2) { return; @@ -2103,6 +2139,11 @@ void dMenu_Fmap2DBack_c::drawDebugStageArea() { if (stage_no >= 0) { f32 v = i + mDoGph_gInf_c::getMinXF(); f32 v2 = j; + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + v = getMirrorPosX(v - 3.0f, 3.0f); + } + #endif J2DFillBox(v - 3.0f, v2 - 3.0f, 6.0f, 6.0f, colors[stage_no % 6]); } } @@ -2138,6 +2179,11 @@ void dMenu_Fmap2DBack_c::drawDebugRegionArea() { mRegionMapSizeX[region] * mZoom, mRegionMapSizeY[region] * mZoom, mpAreaTex[region]->getTexture(0)->getTexInfo()); if (u) { + #if TARGET_PC + if(dusk::getSettings().game.enableMirrorMode) { + pos_x = getMirrorPosX(pos_x - 3.0f, 3.0f); + } + #endif J2DFillBox(pos_x - 3.0f, pos_y - 3.0f, 6.0f, 6.0f, colors[region]); break; } diff --git a/src/d/d_menu_map_common.cpp b/src/d/d_menu_map_common.cpp index 5ae820931a..c18f5825ff 100644 --- a/src/d/d_menu_map_common.cpp +++ b/src/d/d_menu_map_common.cpp @@ -394,21 +394,8 @@ void dMenuMapCommon_c::drawIcon(f32 i_posX, f32 i_posY, f32 param_3, f32 param_4 icon_size_y *= _c7c; } -#if TARGET_PC - f32 rotation = mIconInfo[info_idx].rotation; - if (dusk::getSettings().game.enableMirrorMode && - (mIconInfo[info_idx].icon_no == ICON_LINK_e || - mIconInfo[info_idx].icon_no == ICON_LINK_ENTER_e)) - { - rotation = -rotation; - } - - mPictures[mIconInfo[info_idx].icon_no]->rotate(icon_size_x / 2, icon_size_y / 2, ROTATE_Z, - rotation); -#else mPictures[mIconInfo[info_idx].icon_no]->rotate(icon_size_x / 2, icon_size_y / 2, ROTATE_Z, mIconInfo[info_idx].rotation); -#endif if (mIconInfo[info_idx].icon_no == ICON_LIGHT_DROP_e) { mPictures[mIconInfo[info_idx].icon_no]->setAlpha((180.0f * _c80) / 255.0f); @@ -423,10 +410,15 @@ void dMenuMapCommon_c::drawIcon(f32 i_posX, f32 i_posY, f32 param_3, f32 param_4 } f32 pos_x = i_posX + (icon_pos_x - (icon_size_x / 2)); + bool r4 = false; #if TARGET_PC if (dusk::getSettings().game.enableMirrorMode) { pos_x = getMirrorCenterPosX(i_posX + (icon_pos_x - (icon_size_x / 2)), icon_size_x / 2); } + + if(mIconInfo[info_idx].icon_no == ICON_GOLD_WOLF_e) { + r4 = true; + } #endif mPictures[mIconInfo[info_idx].icon_no]->draw(pos_x, (i_posY + (icon_pos_y - icon_size_y / 2)), @@ -435,7 +427,7 @@ void dMenuMapCommon_c::drawIcon(f32 i_posX, f32 i_posY, f32 param_3, f32 param_4 if (mIconInfo[info_idx].icon_no == ICON_LIGHT_DROP_e) { mLightDropPic->draw((pos_x + (icon_size_x / 2)) - (var_f29 / 2), ((icon_size_y / 2) + (i_posY + (icon_pos_y - icon_size_y / 2))) - (var_f28 / 2), - var_f29, var_f28, false, false, false); + var_f29, var_f28, r4, false, false); } } } @@ -869,4 +861,4 @@ void dMenuMapCommon_c::getFmapPoeCount(const int regionNo, int& nowCount, int& t } } } -#endif \ No newline at end of file +#endif From 8145dec2a18163760cd1e2bb16b34752ff885ce0 Mon Sep 17 00:00:00 2001 From: SuperDude88 <82904174+SuperDude88@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:19:42 -0400 Subject: [PATCH 054/150] Remove Outdated MIrror Mode TODO This was done! --- src/d/d_msg_object.cpp | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/d/d_msg_object.cpp b/src/d/d_msg_object.cpp index 4caec360a9..6177ddc3ac 100644 --- a/src/d/d_msg_object.cpp +++ b/src/d/d_msg_object.cpp @@ -428,17 +428,6 @@ static void dummyStrings() { dMsgObject_HIO_c g_MsgObject_HIO_c; int dMsgObject_c::_execute() { -// TODO: enabling wii message overrides fixes direction text, but gives wrong item control text -/*#if TARGET_PC - if (dusk::getSettings().game.enableMirrorMode) { - // enable wii message index override - g_MsgObject_HIO_c.mMessageDisplay = 1; - } else if (!dusk::getSettings().game.enableMirrorMode && g_MsgObject_HIO_c.mMessageDisplay == 1) { - g_MsgObject_HIO_c.mMessageDisplay = 0; - } -#endif*/ - - field_0x4c7 = 0; if (mpTalkHeap != NULL) { field_0x148 = mDoExt_setCurrentHeap(mpTalkHeap); From 41389f4a8ee0270e4e15781268b756c4160290d6 Mon Sep 17 00:00:00 2001 From: SuperDude88 <82904174+SuperDude88@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:58:45 -0400 Subject: [PATCH 055/150] Remove Extra Mirror Accidentally left this in from testing --- src/d/d_menu_dmap.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/d/d_menu_dmap.cpp b/src/d/d_menu_dmap.cpp index 4bbc56543e..7144c2719f 100644 --- a/src/d/d_menu_dmap.cpp +++ b/src/d/d_menu_dmap.cpp @@ -1306,8 +1306,6 @@ void dMenu_Dmap_c::screenInit() { } #endif field_0x88[1] = JKR_NEW CPaneMgr(mpDrawBg->mBaseScreen, MULTI_CHAR('con000'), 3, NULL); - - ((J2DPicture*)field_0x88[0]->getPanePtr())->setMirror(J2DMirror_X); if (dStage_stagInfo_GetSaveTbl(dComIfGp_getStageStagInfo()) == dStage_SaveTbl_LV2) { field_0x88[2] = JKR_NEW CPaneMgr(mpDrawBg->mBaseScreen, MULTI_CHAR('i_key_n'), 3, NULL); From 8e8dc305e6afb3006046756076c28dd1cea1c24e Mon Sep 17 00:00:00 2001 From: SuperDude88 <82904174+SuperDude88@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:33:15 -0400 Subject: [PATCH 056/150] Update Pane Mirroring - Update mirroring of Link + the dungeon map icon properly when the setting changes --- src/d/d_menu_dmap.cpp | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/d/d_menu_dmap.cpp b/src/d/d_menu_dmap.cpp index 7144c2719f..ec046959d6 100644 --- a/src/d/d_menu_dmap.cpp +++ b/src/d/d_menu_dmap.cpp @@ -914,6 +914,20 @@ void dMenu_DmapBg_c::dMapBgWide() { void dMenu_DmapBg_c::draw() { #if TARGET_PC dMapBgWide(); + + static bool prevMirror = false; // default state of panes is not mirrored + if(prevMirror != dusk::getSettings().game.enableMirrorMode) { + if(dusk::getSettings().game.enableMirrorMode) { + static_cast(mFloorScreen->search(MULTI_CHAR('rink')))->setMirror(J2DMirror_X); + static_cast(mBaseScreen->search(MULTI_CHAR('map000')))->setMirror(J2DMirror_X); + } + else { + static_cast(mFloorScreen->search(MULTI_CHAR('rink')))->setMirror(MIRROR0); + static_cast(mBaseScreen->search(MULTI_CHAR('map000')))->setMirror(MIRROR0); + } + + prevMirror = dusk::getSettings().game.enableMirrorMode; + } #endif u32 scissor_left; @@ -1251,12 +1265,6 @@ void dMenu_Dmap_c::screenInit() { } else { mpDrawBg->mFloorScreen->search('wolf')->hide(); mpDrawBg->mFloorScreen->search('rink')->show(); - - #if TARGET_PC - if(dusk::getSettings().game.enableMirrorMode) { - ((J2DPicture*)mpDrawBg->mFloorScreen->search('rink'))->setMirror(J2DMirror_X); - } - #endif } } } @@ -1300,11 +1308,6 @@ void dMenu_Dmap_c::screenInit() { field_0x7c[1] = JKR_NEW CPaneMgr(mpDrawBg->mBaseScreen, MULTI_CHAR('con_n'), 3, NULL); field_0x7c[2] = JKR_NEW CPaneMgr(mpDrawBg->mBaseScreen, MULTI_CHAR('key_n'), 3, NULL); field_0x88[0] = JKR_NEW CPaneMgr(mpDrawBg->mBaseScreen, MULTI_CHAR('map000'), 3, NULL); - #if TARGET_PC - if(dusk::getSettings().game.enableMirrorMode) { - ((J2DPicture*)field_0x88[0]->getPanePtr())->setMirror(J2DMirror_X); - } - #endif field_0x88[1] = JKR_NEW CPaneMgr(mpDrawBg->mBaseScreen, MULTI_CHAR('con000'), 3, NULL); if (dStage_stagInfo_GetSaveTbl(dComIfGp_getStageStagInfo()) == dStage_SaveTbl_LV2) { From d40a30ee13dc3f063a29ef2b09e5933ec05c847d Mon Sep 17 00:00:00 2001 From: Luke Street Date: Mon, 6 Jul 2026 23:48:21 -0600 Subject: [PATCH 057/150] Config: renaming & add change subscriptions --- include/dusk/config.hpp | 77 ++++- include/dusk/config_var.hpp | 88 +++++- include/dusk/settings.h | 3 +- src/dusk/config.cpp | 431 +++++++++++++++++--------- src/dusk/imgui/ImGuiConfig.hpp | 8 +- src/dusk/imgui/ImGuiConsole.cpp | 4 +- src/dusk/imgui/ImGuiMenuTools.cpp | 2 +- src/dusk/settings.cpp | 4 +- src/dusk/speedrun.cpp | 1 - src/dusk/ui/controller_config.cpp | 10 +- src/dusk/ui/controls.hpp | 4 + src/dusk/ui/graphics_tuner.cpp | 2 +- src/dusk/ui/prelaunch.cpp | 2 +- src/dusk/ui/preset.cpp | 2 +- src/dusk/ui/settings.cpp | 57 ++-- src/dusk/ui/touch_controls_editor.cpp | 2 +- src/m_Do/m_Do_main.cpp | 30 +- 17 files changed, 495 insertions(+), 232 deletions(-) diff --git a/include/dusk/config.hpp b/include/dusk/config.hpp index 382c4c24c6..b56a4cbd43 100644 --- a/include/dusk/config.hpp +++ b/include/dusk/config.hpp @@ -1,9 +1,11 @@ #ifndef DUSK_CONFIG_HPP #define DUSK_CONFIG_HPP +#include #include +#include #include -#include "nlohmann/json.hpp" + #include "config_var.hpp" namespace dusk::config { @@ -40,7 +42,7 @@ public: [[nodiscard]] virtual nlohmann::json dumpToJson(const ConfigVarBase& cVar) const = 0; }; -template +template class ConfigImpl : public ConfigImplBase { // Just downcasting the references... void loadFromJson(ConfigVarBase& cVar, const nlohmann::json& jsonValue) const final { @@ -90,20 +92,28 @@ public: void Register(ConfigVarBase& configVar); /** - * \brief Indicate that all registrations have happened and everything should lock in. + * \brief Unregister a CVar, detaching it from the config system. + * + * If the CVar carries a user-set value (Value or Speedrun layer), it is stashed as an + * unregistered key: Save() keeps writing it, and a later Register() of the same name restores + * it through the normal back-fill path. The CVar may be destroyed after this returns. */ -void FinishRegistration(); +void unregister(ConfigVarBase& configVar); /** * \brief Load config from the standard user preferences location. */ -void LoadFromUserPreferences(); -void LoadFromFileName(const char* path); +void load_from_user_preferences(); +void load_from_file_name(const char* path); + +void load_arg_override(std::string_view name, std::string_view value); + +void shutdown(); /** * \brief Save the config to file. */ -void Save(); +void save(); /** * \brief Get a registered CVar by name. @@ -124,6 +134,59 @@ void ClearAllActionBindings(int port); */ void EnumerateRegistered(std::function callback); +/** + * \brief Type-erased change callback. previousValue points at the value before the mutation + * (a `const T*` for a `ConfigVar`) and is valid only for the duration of the call. + */ +using ChangeCallback = std::function; + +/** + * \brief Token identifying a change subscription. 0 is never a valid token. + */ +using Subscription = u64; + +/** + * \brief Subscribe to changes of the named CVar (registered or not yet registered). + * + * Fired synchronously on the mutating thread (in practice the game thread) whenever the CVar's + * effective value changes at runtime: setValue, override/speedrun setters and clears. Values + * applied by config load or launch arguments do *not* notify: loads happen during startup + * before the subsystems callbacks push values into are initialized, and each subsystem reads + * its initial value itself at its own init. Callbacks may mutate other CVars; a nested + * mutation of the same CVar applies but does not re-notify. + */ +Subscription subscribe(std::string_view name, ChangeCallback callback); + +/** + * \brief Typed convenience overload: the callback receives the current and previous values. + */ +template +requires std::invocable Subscription subscribe( + ConfigVar& cVar, Callback&& callback) { + return subscribe(cVar.getName(), + [&cVar, cb = std::forward(callback)](ConfigVarBase&, const void* previousValue) { + cb(cVar.getValue(), *static_cast(previousValue)); + }); +} + +void unsubscribe(Subscription token); + +/** + * \brief Register a CVar and attach a change callback in one step. + * + * Useful for pushing settings into external systems (e.g. aurora) from one place instead of + * every UI setter. The callback fires only for runtime changes (see subscribe); the value + * loaded from config or launch arguments does not fire it — the external system reads its + * initial value itself at its own initialization. + */ +template +requires std::invocable Subscription Register( + ConfigVar& cVar, Callback&& onChange) { + auto subscription = subscribe(cVar, std::forward(onChange)); + Register(static_cast(cVar)); + return subscription; +} + template const ConfigImplBase* GetConfigImpl() { static ConfigImpl config; diff --git a/include/dusk/config_var.hpp b/include/dusk/config_var.hpp index 258bf4143c..52e62172cb 100644 --- a/include/dusk/config_var.hpp +++ b/include/dusk/config_var.hpp @@ -2,10 +2,12 @@ #define DUSK_CONFIG_VAR_HPP #include "dolphin/types.h" -#include +#include #include #include +#include #include +#include /** * The configuration system. @@ -69,7 +71,7 @@ protected: /** * The name of this CVar, used in the configuration file. */ - const char* name; + std::string name; /** * Whether this CVar has been registered with the global managing logic. @@ -87,8 +89,8 @@ protected: */ const ConfigImplBase* impl; - ConfigVarBase(const char* name, const ConfigImplBase* impl); - virtual ~ConfigVarBase() = default; + ConfigVarBase(const ConfigVarBase&) = delete; + ConfigVarBase(std::string name, const ConfigImplBase* impl); /** * Check that the CVar is registered, aborting if this is not the case. @@ -98,7 +100,22 @@ protected: abort(); } + /** + * Whether any change subscriber (see config::subscribe) is attached to this CVar's name. + */ + [[nodiscard]] bool has_subscribers() const; + + /** + * Notify change subscribers (see config::subscribe) that the effective value of this CVar + * changed. Called by mutators after the change has been applied; previousValue points at + * the old value (a `const T*` for a `ConfigVar`), valid only for the duration of the + * call. + */ + void notify_changed(const void* previousValue); + public: + virtual ~ConfigVarBase(); + /** * Get the name of this CVar, used in the configuration file. */ @@ -121,6 +138,7 @@ public: * This is necessary to make it legal to access. */ void markRegistered(); + void unmarkRegistered(); /** * Clear a speedrun-mode override if one is active on this CVar. @@ -155,6 +173,7 @@ template concept ConfigValue = !std::is_const_v && !std::is_volatile_v + && std::equality_comparable && (std::is_same_v || ConfigValueInteger || std::is_same_v @@ -166,6 +185,9 @@ concept ConfigValue = template const ConfigImplBase* GetConfigImpl(); +template +class ConfigImpl; + template struct ConfigEnumRange { static constexpr auto min = std::numeric_limits>::min(); @@ -192,10 +214,12 @@ public: * @param arg Arguments to forward to construct the default value. */ template - ConfigVar(const char* name, Args&&... arg) - : ConfigVarBase(name, GetConfigImpl()), defaultValue(std::forward(arg)...), + ConfigVar(std::string name, Args&&... arg) + : ConfigVarBase(std::move(name), GetConfigImpl()), defaultValue(std::forward(arg)...), value(), overrideValue() {} + ConfigVar(ConfigVar const&) = delete; + /** * \brief Get the current value of the CVar. * @@ -234,6 +258,7 @@ public: */ void setValue(T newValue, bool replaceOverride = true) { checkRegistered(); + const auto previous = previous_for_notify(); value = std::move(newValue); if (replaceOverride) { @@ -242,6 +267,7 @@ public: } else if (layer != ConfigVarLayer::Override) { layer = ConfigVarLayer::Value; } + notify_if_changed(previous); } operator const T&() { @@ -258,8 +284,10 @@ public: */ void setOverrideValue(T newValue) { checkRegistered(); + const auto previous = previous_for_notify(); overrideValue = std::move(newValue); layer = ConfigVarLayer::Override; + notify_if_changed(previous); } /** @@ -273,25 +301,31 @@ public: void setSpeedrunValue(T newValue) { checkRegistered(); if (layer != ConfigVarLayer::Override) { + const auto previous = previous_for_notify(); priorLayer = layer; overrideValue = std::move(newValue); layer = ConfigVarLayer::Speedrun; + notify_if_changed(previous); } } void clearOverride() { checkRegistered(); if (layer == ConfigVarLayer::Override) { + const auto previous = previous_for_notify(); overrideValue = {}; layer = ConfigVarLayer::Value; + notify_if_changed(previous); } } void clearSpeedrunOverride() override { checkRegistered(); if (layer == ConfigVarLayer::Speedrun) { + const auto previous = previous_for_notify(); overrideValue = {}; layer = priorLayer; + notify_if_changed(previous); } } @@ -305,6 +339,48 @@ public: const ConfigVarLayer effectiveLayer = (layer == ConfigVarLayer::Speedrun) ? priorLayer : layer; return effectiveLayer == ConfigVarLayer::Default ? defaultValue : value; } + +private: + // The config loader applies values through the silent load_* methods below. + friend class ConfigImpl; + + /** + * Copy of the effective value before a mutation, taken only when someone is subscribed. + */ + [[nodiscard]] std::optional previous_for_notify() const { + return has_subscribers() ? std::optional{getValue()} : std::nullopt; + } + + /** + * Notify subscribers if the effective value actually changed across a mutation. + */ + void notify_if_changed(const std::optional& previous) { + if (previous.has_value() && !(getValue() == *previous)) { + notify_changed(&*previous); + } + } + + /** + * setValue(newValue, false) without notifying change subscribers. Used when loading config: + * loads happen during startup before the subsystems change callbacks push values into are + * initialized, and each subsystem applies the loaded value itself at its own init. + */ + void load_value(T newValue) { + checkRegistered(); + value = std::move(newValue); + if (layer != ConfigVarLayer::Override) { + layer = ConfigVarLayer::Value; + } + } + + /** + * setOverrideValue without notifying change subscribers (see load_value). + */ + void load_override_value(T newValue) { + checkRegistered(); + overrideValue = std::move(newValue); + layer = ConfigVarLayer::Override; + } }; using ActionBindConfigVar = ConfigVar; diff --git a/include/dusk/settings.h b/include/dusk/settings.h index fdff069226..326f7f321c 100644 --- a/include/dusk/settings.h +++ b/include/dusk/settings.h @@ -7,7 +7,8 @@ namespace dusk { -using namespace config; +using config::ConfigVar; +using config::ActionBindConfigVar; enum class BloomMode : int { Off = 0, diff --git a/src/dusk/config.cpp b/src/dusk/config.cpp index 43d3f19dad..698dceaaac 100644 --- a/src/dusk/config.cpp +++ b/src/dusk/config.cpp @@ -7,6 +7,7 @@ #include "dusk/io.hpp" #include "dusk/settings.h" +#include #include #include #include @@ -15,77 +16,90 @@ #include #include #include +#include #include "dusk/action_bindings.h" +#include "dusk/logging.h" #include "dusk/main.h" -using namespace dusk::config; - +namespace dusk::config { +namespace { constexpr auto ConfigFileName = "config.json"; using json = nlohmann::json; aurora::Module DuskConfigLog("dusk::config"); -static absl::flat_hash_map RegisteredConfigVars; -static bool RegistrationDone = false; +absl::flat_hash_map RegisteredConfigVars; +absl::flat_hash_map UnregisteredConfigVars; +absl::flat_hash_map UnregisteredConfigVarOverrides; -static std::optional parse_control_anchor(std::string_view value) { +struct ChangeSubscription { + Subscription token; + ChangeCallback callback; +}; +absl::flat_hash_map > s_changeSubscriptions; +absl::flat_hash_map s_changeTokenNames; +Subscription s_nextChangeToken = 1; +// Names currently being notified; guards against a callback re-notifying its own CVar. +std::vector s_activeChangeNotifications; + +std::optional parse_control_anchor(std::string_view value) { if (value == "none") { - return dusk::ui::ControlAnchor::None; + return ui::ControlAnchor::None; } if (value == "top") { - return dusk::ui::ControlAnchor::Top; + return ui::ControlAnchor::Top; } if (value == "left") { - return dusk::ui::ControlAnchor::Left; + return ui::ControlAnchor::Left; } if (value == "bottom") { - return dusk::ui::ControlAnchor::Bottom; + return ui::ControlAnchor::Bottom; } if (value == "right") { - return dusk::ui::ControlAnchor::Right; + return ui::ControlAnchor::Right; } if (value == "topLeft") { - return dusk::ui::ControlAnchor::TopLeft; + return ui::ControlAnchor::TopLeft; } if (value == "topRight") { - return dusk::ui::ControlAnchor::TopRight; + return ui::ControlAnchor::TopRight; } if (value == "bottomLeft") { - return dusk::ui::ControlAnchor::BottomLeft; + return ui::ControlAnchor::BottomLeft; } if (value == "bottomRight") { - return dusk::ui::ControlAnchor::BottomRight; + return ui::ControlAnchor::BottomRight; } return std::nullopt; } -static const char* control_anchor_value(dusk::ui::ControlAnchor anchor) { +const char* control_anchor_value(ui::ControlAnchor anchor) { switch (anchor) { - case dusk::ui::ControlAnchor::None: + case ui::ControlAnchor::None: return "none"; - case dusk::ui::ControlAnchor::Top: + case ui::ControlAnchor::Top: return "top"; - case dusk::ui::ControlAnchor::Left: + case ui::ControlAnchor::Left: return "left"; - case dusk::ui::ControlAnchor::Bottom: + case ui::ControlAnchor::Bottom: return "bottom"; - case dusk::ui::ControlAnchor::Right: + case ui::ControlAnchor::Right: return "right"; - case dusk::ui::ControlAnchor::TopLeft: + case ui::ControlAnchor::TopLeft: return "topLeft"; - case dusk::ui::ControlAnchor::TopRight: + case ui::ControlAnchor::TopRight: return "topRight"; - case dusk::ui::ControlAnchor::BottomLeft: + case ui::ControlAnchor::BottomLeft: return "bottomLeft"; - case dusk::ui::ControlAnchor::BottomRight: + case ui::ControlAnchor::BottomRight: return "bottomRight"; } return "none"; } -static std::optional json_finite_float(const json& object, const char* key) { +std::optional json_finite_float(const json& object, const char* key) { const auto iter = object.find(key); if (iter == object.end() || !iter->is_number()) { return std::nullopt; @@ -99,7 +113,7 @@ static std::optional json_finite_float(const json& object, const char* ke return value; } -static std::optional parse_control_props(const json& value) { +std::optional parse_control_props(const json& value) { if (!value.is_object()) { return std::nullopt; } @@ -118,7 +132,7 @@ static std::optional parse_control_props(const json& val if (!anchor || *w <= 0.0f || *h <= 0.0f || *scale <= 0.0f) { return std::nullopt; } - return dusk::ui::ControlProps{ + return ui::ControlProps{ .x = *x, .y = *y, .w = *w, @@ -128,17 +142,17 @@ static std::optional parse_control_props(const json& val }; } -static std::filesystem::path GetConfigJsonPath() { - return dusk::ConfigPath / ConfigFileName; +std::filesystem::path GetConfigJsonPath() { + return ConfigPath / ConfigFileName; } -static std::filesystem::path GetTempConfigJsonPath(const std::filesystem::path& configJsonPath) { +std::filesystem::path GetTempConfigJsonPath(const std::filesystem::path& configJsonPath) { auto tempPath = configJsonPath; tempPath.replace_filename(fmt::format(".{}.tmp", configJsonPath.filename().string())); return tempPath; } -static void ReplaceFile(const std::filesystem::path& source, const std::filesystem::path& target) { +void ReplaceFile(const std::filesystem::path& source, const std::filesystem::path& target) { std::error_code ec; std::filesystem::rename(source, target, ec); if (ec) { @@ -148,19 +162,8 @@ static void ReplaceFile(const std::filesystem::path& source, const std::filesyst } } -ConfigVarBase::ConfigVarBase(const char* name, const ConfigImplBase* impl) - : name(name), registered(false), layer(ConfigVarLayer::Default), impl(impl) {} - -const char* ConfigVarBase::getName() const noexcept { - return name; -} - -const ConfigImplBase* ConfigVarBase::getImpl() const noexcept { - return impl; -} - template -static T sanitizeEnumValue(const ConfigVar& cVar, T value) { +T sanitizeEnumValue(const ConfigVar& cVar, T value) { if constexpr (std::is_enum_v) { using Underlying = std::underlying_type_t; const Underlying raw = static_cast(value); @@ -174,6 +177,83 @@ static T sanitizeEnumValue(const ConfigVar& cVar, T value) { return value; } +template +requires std::is_integral_v&& std::is_signed_v T parse_arg_value( + const ConfigVar&, const std::string_view stringValue) { + const std::string str(stringValue); + const auto result = std::stoll(str); + if (result >= std::numeric_limits::min() && result <= std::numeric_limits::max()) { + return static_cast(result); + } + throw std::out_of_range("Value is too large"); +} + +template +requires std::is_integral_v&& std::is_unsigned_v T parse_arg_value( + const ConfigVar&, const std::string_view stringValue) { + const std::string str(stringValue); + const auto result = std::stoull(str); + if (result <= std::numeric_limits::max()) { + return static_cast(result); + } + throw std::out_of_range("Value is too large"); +} + +f32 parse_arg_value(const ConfigVar&, const std::string_view stringValue) { + const std::string str(stringValue); + return std::stof(str); +} + +f64 parse_arg_value(const ConfigVar&, const std::string_view stringValue) { + const std::string str(stringValue); + return std::stod(str); +} + +std::string parse_arg_value(const ConfigVar&, const std::string_view stringValue) { + return std::string(stringValue); +} + +template +requires std::is_enum_v T parse_arg_value( + const ConfigVar& cVar, const std::string_view stringValue) { + using Underlying = std::underlying_type_t; + const std::string str(stringValue); + + if constexpr (std::is_signed_v) { + const auto result = std::stoll(str); + if (result >= std::numeric_limits::min() && + result <= std::numeric_limits::max()) + { + return sanitizeEnumValue(cVar, static_cast(result)); + } + throw std::out_of_range("Value is too large"); + } else { + const auto result = std::stoull(str); + if (result <= std::numeric_limits::max()) { + return sanitizeEnumValue(cVar, static_cast(result)); + } + throw std::out_of_range("Value is too large"); + } +} +} // namespace + +ConfigVarBase::ConfigVarBase(std::string name, const ConfigImplBase* impl) + : name(std::move(name)), registered(false), layer(ConfigVarLayer::Default), impl(impl) {} + +const char* ConfigVarBase::getName() const noexcept { + return name.c_str(); +} + +const ConfigImplBase* ConfigVarBase::getImpl() const noexcept { + return impl; +} + +ConfigVarBase::~ConfigVarBase() { + if (registered) { + DuskLog.fatal("CVar '{}' was destroyed while still registered!", name); + } +} + template void ConfigImpl::loadFromJson(ConfigVar& cVar, const json& jsonValue) { if constexpr (std::is_enum_v) { @@ -187,12 +267,12 @@ void ConfigImpl::loadFromJson(ConfigVar& cVar, const json& jsonValue) { const Underlying raw = b ? static_cast(1) : static_cast(0); - cVar.setValue(sanitizeEnumValue(cVar, static_cast(raw)), false); + cVar.load_value(sanitizeEnumValue(cVar, static_cast(raw))); return; } } - cVar.setValue(sanitizeEnumValue(cVar, jsonValue.get()), false); + cVar.load_value(sanitizeEnumValue(cVar, jsonValue.get())); } template @@ -200,74 +280,9 @@ nlohmann::json ConfigImpl::dumpToJson(const ConfigVar& cVar) { return cVar.getValueForSave(); } -template -requires std::is_integral_v&& std::is_signed_v static void loadFromArgImpl( - ConfigVar& cVar, const std::string_view stringValue) { - const std::string str(stringValue); - const auto result = std::stoll(str); - if (result >= std::numeric_limits::min() && result <= std::numeric_limits::max()) { - cVar.setOverrideValue(result); - } else { - throw std::out_of_range("Value is too large"); - } -} - -template -requires std::is_integral_v&& std::is_unsigned_v static void loadFromArgImpl( - ConfigVar& cVar, const std::string_view stringValue) { - const std::string str(stringValue); - const auto result = std::stoull(str); - if (result <= std::numeric_limits::max()) { - cVar.setOverrideValue(result); - } else { - throw std::out_of_range("Value is too large"); - } -} - -static void loadFromArgImpl(ConfigVar& cVar, const std::string_view stringValue) { - const std::string str(stringValue); - const auto result = std::stof(str); - cVar.setOverrideValue(result); -} - -static void loadFromArgImpl(ConfigVar& cVar, const std::string_view stringValue) { - const std::string str(stringValue); - const auto result = std::stod(str); - cVar.setOverrideValue(result); -} - -static void loadFromArgImpl(ConfigVar& cVar, const std::string_view stringValue) { - cVar.setOverrideValue(std::string(stringValue)); -} - -template -requires std::is_enum_v static void loadFromArgImpl( - ConfigVar& cVar, const std::string_view stringValue) { - using Underlying = std::underlying_type_t; - const std::string str(stringValue); - - if constexpr (std::is_signed_v) { - const auto result = std::stoll(str); - if (result >= std::numeric_limits::min() && - result <= std::numeric_limits::max()) - { - cVar.setOverrideValue(sanitizeEnumValue(cVar, static_cast(result))); - } else { - throw std::out_of_range("Value is too large"); - } - } else { - const auto result = std::stoull(str); - if (result <= std::numeric_limits::max()) { - cVar.setOverrideValue(sanitizeEnumValue(cVar, static_cast(result))); - } else { - throw std::out_of_range("Value is too large"); - } - } -} - template void ConfigImpl::loadFromArg(ConfigVar& cVar, const std::string_view stringValue) { - loadFromArgImpl(cVar, stringValue); + cVar.load_override_value(parse_arg_value(cVar, stringValue)); } template <> @@ -275,18 +290,16 @@ void ConfigImpl::loadFromArg(ConfigVar& cVar, const std::string_view if (stringValue == "1" || stringValue == "TRUE" || stringValue == "true" || stringValue == "True") { - cVar.setOverrideValue(true); + cVar.load_override_value(true); } else if (stringValue == "0" || stringValue == "FALSE" || stringValue == "false" || stringValue == "False") { - cVar.setOverrideValue(false); + cVar.load_override_value(false); } else { throw InvalidConfigError("Value cannot be parsed as boolean"); } } -// My IDE is convinced this namespace is necessary. It shouldn't be AFAICT? -namespace dusk::config { template class ConfigImpl; template class ConfigImpl; template class ConfigImpl; @@ -299,10 +312,10 @@ template class ConfigImpl; template class ConfigImpl; template class ConfigImpl; template class ConfigImpl; -template class ConfigImpl; -template class ConfigImpl; -template class ConfigImpl; -template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; template <> void ConfigImpl::loadFromJson( @@ -312,11 +325,11 @@ void ConfigImpl::loadFromJson( const FrameInterpMode mode = b ? FrameInterpMode::Unlimited : FrameInterpMode::Off; - cVar.setValue(sanitizeEnumValue(cVar, mode), false); + cVar.load_value(sanitizeEnumValue(cVar, mode)); return; } - cVar.setValue(sanitizeEnumValue(cVar, jsonValue.get()), false); + cVar.load_value(sanitizeEnumValue(cVar, jsonValue.get())); } template <> @@ -347,7 +360,7 @@ void ConfigImpl::loadFromJson( } } - cVar.setValue(std::move(layout), false); + cVar.load_value(std::move(layout)); } template <> @@ -377,26 +390,59 @@ nlohmann::json ConfigImpl::dumpToJson(const ConfigVar; -template class ConfigImpl; -template class ConfigImpl; -template class ConfigImpl; -template class ConfigImpl; -template class ConfigImpl; -} // namespace dusk::config - -void dusk::config::Register(ConfigVarBase& configVar) { - const auto& name = configVar.getName(); - if (RegistrationDone) { - DuskConfigLog.fatal("Tried to register CVar {} after registrations closed!", name); - } +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +template class ConfigImpl; +void Register(ConfigVarBase& configVar) { + const std::string_view name = configVar.getName(); if (RegisteredConfigVars.contains(name)) { DuskConfigLog.fatal("Tried to register CVar {} twice!", name); } RegisteredConfigVars[name] = &configVar; configVar.markRegistered(); + + const auto unregPair = UnregisteredConfigVars.find(name); + if (unregPair != UnregisteredConfigVars.end()) { + const auto value = std::move(unregPair->second); + UnregisteredConfigVars.erase(name); + + try { + configVar.getImpl()->loadFromJson(configVar, value); + } catch (std::exception& e) { + DuskConfigLog.error("Failed to load key '{}' from config value: {}", name, e.what()); + } + } + + const auto overridePair = UnregisteredConfigVarOverrides.find(name); + if (overridePair != UnregisteredConfigVarOverrides.end()) { + try { + configVar.getImpl()->loadFromArg(configVar, overridePair->second); + } catch (std::exception& e) { + DuskConfigLog.error("Failed to load key '{}' from override arg: {}", name, e.what()); + } + } +} + +void unregister(ConfigVarBase& configVar) { + const std::string_view name = configVar.getName(); + const auto it = RegisteredConfigVars.find(name); + if (it == RegisteredConfigVars.end() || it->second != &configVar) { + DuskConfigLog.fatal("Tried to unregister CVar '{}' that is not registered!", name); + } + + const auto layer = configVar.getLayer(); + if (layer == ConfigVarLayer::Value || layer == ConfigVarLayer::Speedrun) { + UnregisteredConfigVars.insert_or_assign( + std::string{name}, configVar.getImpl()->dumpToJson(configVar)); + } + + RegisteredConfigVars.erase(it); + configVar.unmarkRegistered(); } void ConfigVarBase::markRegistered() { @@ -406,21 +452,24 @@ void ConfigVarBase::markRegistered() { registered = true; } -void dusk::config::FinishRegistration() { - RegistrationDone = true; +void ConfigVarBase::unmarkRegistered() { + if (!registered) + abort(); + + registered = false; } -void dusk::config::LoadFromUserPreferences() { +void load_from_user_preferences() { const auto configJsonPath = GetConfigJsonPath(); if (configJsonPath.empty()) { return; } const auto configPathString = io::fs_path_to_string(configJsonPath); - LoadFromFileName(configPathString.c_str()); + load_from_file_name(configPathString.c_str()); } static void LoadFromPath(const char* path) { - auto data = dusk::io::FileStream::ReadAllBytes(path); + auto data = io::FileStream::ReadAllBytes(path); json j = json::parse(data); if (!j.is_object()) { @@ -428,11 +477,16 @@ static void LoadFromPath(const char* path) { return; } + UnregisteredConfigVars.clear(); + for (const auto& el : j.items()) { const auto& key = el.key(); auto configVar = RegisteredConfigVars.find(key); if (configVar == RegisteredConfigVars.end()) { - DuskConfigLog.error("Unknown key '{}' found in config!", key); + DuskConfigLog.debug("Unknown key '{}' found in config! If this gets registered later, " + "that's acceptable!", + key); + UnregisteredConfigVars.emplace(key, el.value()); continue; } @@ -444,11 +498,7 @@ static void LoadFromPath(const char* path) { } } -void dusk::config::LoadFromFileName(const char* path) { - if (!RegistrationDone) { - DuskConfigLog.fatal("Registration not finished yet!"); - } - +void load_from_file_name(const char* path) { DuskConfigLog.info("Loading config from '{}'", path); try { @@ -466,7 +516,21 @@ void dusk::config::LoadFromFileName(const char* path) { } } -void dusk::config::Save() { +void load_arg_override(std::string_view name, std::string_view value) { + const auto cVar = GetConfigVar(name); + if (!cVar) { + UnregisteredConfigVarOverrides.emplace(name, value); + return; + } + + try { + cVar->getImpl()->loadFromArg(*cVar, value); + } catch (const std::exception& e) { + DuskLog.fatal("Unable to parse: '{}': {}", value, e.what()); + } +} + +void save() { const auto configJsonPath = GetConfigJsonPath(); if (configJsonPath.empty()) { return; @@ -484,6 +548,10 @@ void dusk::config::Save() { } } + for (const auto& pair : UnregisteredConfigVars) { + j[pair.first] = pair.second; + } + try { const auto tempConfigJsonPath = GetTempConfigJsonPath(configJsonPath); io::FileStream::WriteAllText(tempConfigJsonPath, j.dump(4)); @@ -493,14 +561,14 @@ void dusk::config::Save() { } } -void dusk::config::ClearAllActionBindings(int port) { +void ClearAllActionBindings(int port) { for (auto& actionBinding : getActionBinds() | std::views::values) { actionBinding.configVars->at(port).setValue(PAD_NATIVE_BUTTON_INVALID); } - Save(); + save(); } -ConfigVarBase* dusk::config::GetConfigVar(std::string_view name) { +ConfigVarBase* GetConfigVar(std::string_view name) { const auto configVar = RegisteredConfigVars.find(name); if (configVar != RegisteredConfigVars.end()) { return configVar->second; @@ -509,8 +577,69 @@ ConfigVarBase* dusk::config::GetConfigVar(std::string_view name) { return nullptr; } -void dusk::config::EnumerateRegistered(std::function callback) { +void EnumerateRegistered(std::function callback) { for (auto& pair : RegisteredConfigVars) { callback(*pair.second); } } + +Subscription subscribe(std::string_view name, ChangeCallback callback) { + const auto token = s_nextChangeToken++; + s_changeSubscriptions[std::string{name}].push_back({token, std::move(callback)}); + s_changeTokenNames.emplace(token, std::string{name}); + return token; +} + +void unsubscribe(Subscription token) { + const auto nameIt = s_changeTokenNames.find(token); + if (nameIt == s_changeTokenNames.end()) { + DuskConfigLog.fatal("Tried to unsubscribe unknown change token {}!", token); + } + + const auto subsIt = s_changeSubscriptions.find(nameIt->second); + auto& subscriptions = subsIt->second; + std::erase_if( + subscriptions, [token](const ChangeSubscription& sub) { return sub.token == token; }); + if (subscriptions.empty()) { + s_changeSubscriptions.erase(subsIt); + } + s_changeTokenNames.erase(nameIt); +} + +bool ConfigVarBase::has_subscribers() const { + return s_changeSubscriptions.contains(name); +} + +void ConfigVarBase::notify_changed(const void* previousValue) { + const auto subsIt = s_changeSubscriptions.find(name); + if (subsIt == s_changeSubscriptions.end()) { + return; + } + if (std::ranges::find(s_activeChangeNotifications, name) != s_activeChangeNotifications.end()) { + DuskConfigLog.error("Recursive change notification for CVar '{}' suppressed", name); + return; + } + + s_activeChangeNotifications.push_back(name); + // Copied so callbacks can subscribe/unsubscribe safely. + const auto subscriptions = subsIt->second; + for (const auto& sub : subscriptions) { + sub.callback(*this, previousValue); + } + s_activeChangeNotifications.pop_back(); +} + +void shutdown() { + for (auto& pair : RegisteredConfigVars) { + pair.second->unmarkRegistered(); + } + + RegisteredConfigVars.clear(); + UnregisteredConfigVars.clear(); + UnregisteredConfigVarOverrides.clear(); + s_changeSubscriptions.clear(); + s_changeTokenNames.clear(); + s_activeChangeNotifications.clear(); +} + +} // namespace dusk::config \ No newline at end of file diff --git a/src/dusk/imgui/ImGuiConfig.hpp b/src/dusk/imgui/ImGuiConfig.hpp index e3e80b9920..4bceb36a38 100644 --- a/src/dusk/imgui/ImGuiConfig.hpp +++ b/src/dusk/imgui/ImGuiConfig.hpp @@ -9,7 +9,7 @@ namespace dusk::config { bool copy = var.getValue(); if (ImGui::Checkbox(title, ©)) { var.setValue(copy); - Save(); + save(); return true; } @@ -20,7 +20,7 @@ namespace dusk::config { float val = var; if (ImGui::SliderFloat(label, &val, v_min, v_max, format, flags)) { var.setValue(val); - Save(); + save(); return true; } @@ -31,7 +31,7 @@ namespace dusk::config { int val = var; if (ImGui::SliderInt(label, &val, v_min, v_max, format, flags)) { var.setValue(val); - Save(); + save(); return true; } @@ -42,7 +42,7 @@ namespace dusk::config { bool copy = p_selected.getValue(); if (ImGui::MenuItem(label, shortcut, ©, enabled)) { p_selected.setValue(copy); - Save(); + save(); return true; } diff --git a/src/dusk/imgui/ImGuiConsole.cpp b/src/dusk/imgui/ImGuiConsole.cpp index 4c0f2e6de6..3c0131c729 100644 --- a/src/dusk/imgui/ImGuiConsole.cpp +++ b/src/dusk/imgui/ImGuiConsole.cpp @@ -254,7 +254,7 @@ namespace dusk { if (ImGui::IsKeyPressed(ImGuiKey_F11)) { getSettings().video.enableFullscreen.setValue(!getSettings().video.enableFullscreen); VISetWindowFullscreen(getSettings().video.enableFullscreen); - config::Save(); + config::save(); } if (getSettings().game.enableResetKeybind && ImGui::GetIO().KeyCtrl && @@ -336,7 +336,7 @@ namespace dusk { if constexpr (SupportsProcessRestart) { if (ImGui::Button("Retry (Auto backend)")) { getSettings().backend.graphicsBackend.setValue("auto"); - config::Save(); + config::save(); RestartRequested = true; IsRunning = false; } diff --git a/src/dusk/imgui/ImGuiMenuTools.cpp b/src/dusk/imgui/ImGuiMenuTools.cpp index 02326eceea..cf5b22a270 100644 --- a/src/dusk/imgui/ImGuiMenuTools.cpp +++ b/src/dusk/imgui/ImGuiMenuTools.cpp @@ -73,7 +73,7 @@ namespace dusk { bool disableWaterRefraction = getSettings().game.disableWaterRefraction; if (ImGui::Checkbox("Disable Water Refraction", &disableWaterRefraction)) { getSettings().game.disableWaterRefraction.setValue(disableWaterRefraction); - config::Save(); + config::save(); } ImGui::Checkbox("Enable LOD Bias", &aurora::gx::enableLodBias); ImGui::EndMenu(); diff --git a/src/dusk/settings.cpp b/src/dusk/settings.cpp index 7831c5c41f..e2a2169485 100644 --- a/src/dusk/settings.cpp +++ b/src/dusk/settings.cpp @@ -1,5 +1,6 @@ #include "dusk/settings.h" #include "dusk/config.hpp" +#include namespace dusk { @@ -267,7 +268,8 @@ void registerSettings() { Register(g_userSettings.game.touchCameraYSensitivity); Register(g_userSettings.game.minimalHUD); Register(g_userSettings.game.hudScale); - Register(g_userSettings.game.pauseOnFocusLost); + Register(g_userSettings.game.pauseOnFocusLost, + [](const bool& value, const bool&) { aurora_set_pause_on_focus_lost(value); }); Register(g_userSettings.game.enableDiscordPresence); Register(g_userSettings.game.bloomMode); Register(g_userSettings.game.bloomMultiplier); diff --git a/src/dusk/speedrun.cpp b/src/dusk/speedrun.cpp index 0a70125da2..e396197040 100644 --- a/src/dusk/speedrun.cpp +++ b/src/dusk/speedrun.cpp @@ -37,7 +37,6 @@ void resetForSpeedrunMode() { getSettings().game.invincibleEnemies.setSpeedrunValue(false); getSettings().game.pauseOnFocusLost.setSpeedrunValue(false); - aurora_set_pause_on_focus_lost(false); getSettings().backend.enableAdvancedSettings.setSpeedrunValue(false); getSettings().game.recordingMode.setSpeedrunValue(false); diff --git a/src/dusk/ui/controller_config.cpp b/src/dusk/ui/controller_config.cpp index a506a593a6..7c8e720ecd 100644 --- a/src/dusk/ui/controller_config.cpp +++ b/src/dusk/ui/controller_config.cpp @@ -278,7 +278,7 @@ ControllerConfigWindow::ControllerConfigWindow(bool prelaunch) { void ControllerConfigWindow::hide(bool close) { stop_rumble_test(); cancel_pending_binding(); - config::Save(); + config::save(); Window::hide(close); } @@ -403,7 +403,7 @@ void ControllerConfigWindow::render_page(Pane& pane, int port, Page page) { PADClearPort(port); PADSetKeyboardActive(static_cast(port), FALSE); PADSerializeMappings(); - ClearAllActionBindings(port); + config::ClearAllActionBindings(port); refresh_controller_page(); }); @@ -417,7 +417,7 @@ void ControllerConfigWindow::render_page(Pane& pane, int port, Page page) { PADClearPort(port); PADSetKeyboardActive(static_cast(port), TRUE); PADSerializeMappings(); - ClearAllActionBindings(port); + config::ClearAllActionBindings(port); }); const u32 controllerCount = PADCount(); @@ -439,7 +439,7 @@ void ControllerConfigWindow::render_page(Pane& pane, int port, Page page) { PADSetKeyboardActive(static_cast(port), FALSE); PADSetPortForIndex(i, port); PADSerializeMappings(); - ClearAllActionBindings(port); + config::ClearAllActionBindings(port); }); } break; @@ -1125,7 +1125,7 @@ void ControllerConfigWindow::poll_pending_binding() { return; } mPendingActionBinding->setValue(button); - config::Save(); + config::save(); finish_pending_binding(completedPort); } return; diff --git a/src/dusk/ui/controls.hpp b/src/dusk/ui/controls.hpp index 2a83d6127f..5b03bf179a 100644 --- a/src/dusk/ui/controls.hpp +++ b/src/dusk/ui/controls.hpp @@ -51,6 +51,8 @@ struct ControlProps { float h = 0.0f; float scale = 1.0f; ControlAnchor anchor = ControlAnchor::None; + + bool operator==(const ControlProps&) const = default; }; struct ControlRect { @@ -76,6 +78,8 @@ struct ControlLayout { int version = Version; std::map > controls; + + bool operator==(const ControlLayout&) const = default; }; constexpr std::array kControlLayoutIds = { diff --git a/src/dusk/ui/graphics_tuner.cpp b/src/dusk/ui/graphics_tuner.cpp index 57fa3ee329..054c0b302c 100644 --- a/src/dusk/ui/graphics_tuner.cpp +++ b/src/dusk/ui/graphics_tuner.cpp @@ -300,7 +300,7 @@ void GraphicsTuner::show() { } void GraphicsTuner::hide(bool close) { - config::Save(); + config::save(); mRoot->RemoveAttribute("open"); if (close) { mPendingClose = true; diff --git a/src/dusk/ui/prelaunch.cpp b/src/dusk/ui/prelaunch.cpp index 998cdb87ea..cef030f811 100644 --- a/src/dusk/ui/prelaunch.cpp +++ b/src/dusk/ui/prelaunch.cpp @@ -309,7 +309,7 @@ void persist_disc_choice(const std::string& path, iso::ValidationError validatio getSettings().backend.isoPath.setValue(path); getSettings().backend.isoVerification.setValue(verification); - config::Save(); + config::save(); if (previousPath != path || previousVerification != verification) { iso::log_verification_state(path, verification); diff --git a/src/dusk/ui/preset.cpp b/src/dusk/ui/preset.cpp index 1a75a27e6b..7fc7d956b4 100644 --- a/src/dusk/ui/preset.cpp +++ b/src/dusk/ui/preset.cpp @@ -106,7 +106,7 @@ PresetWindow::PresetWindow() : WindowSmall("modal", "modal-dialog") { if (cmd == NavCommand::Confirm) { apply(); getSettings().backend.wasPresetChosen.setValue(true); - config::Save(); + config::save(); hide(true); return true; } diff --git a/src/dusk/ui/settings.cpp b/src/dusk/ui/settings.cpp index 3c89f34136..401d2aee89 100644 --- a/src/dusk/ui/settings.cpp +++ b/src/dusk/ui/settings.cpp @@ -248,7 +248,6 @@ void reset_for_speedrun_mode() { getSettings().game.invincibleEnemies.setSpeedrunValue(false); getSettings().game.pauseOnFocusLost.setSpeedrunValue(false); - aurora_set_pause_on_focus_lost(false); getSettings().backend.enableAdvancedSettings.setSpeedrunValue(false); getSettings().game.recordingMode.setSpeedrunValue(false); @@ -263,7 +262,6 @@ void clear_speedrun_overrides() { void restore_from_speedrun_mode() { clear_speedrun_overrides(); - aurora_set_pause_on_focus_lost(getSettings().game.pauseOnFocusLost.getValue()); } std::filesystem::path normalized_display_path(const std::filesystem::path& path) { @@ -447,7 +445,7 @@ SelectButton& config_bool_select( return; } var.setValue(value); - config::Save(); + config::save(); if (callback) { callback(value); } @@ -468,7 +466,7 @@ void add_speedrun_disabled_option(Pane& leftPane, Pane& rightPane, ConfigVar& std::string suffix = "") { auto& button = leftPane.add_child(NumberButton::Props{ .key = std::move(key), - .getValue = [&var] { return var; }, + .getValue = [&var] { return var.getValue(); }, .setValue = [&var, min, max, callback = std::move(onChange)](int value) { const int clampedValue = std::clamp(value, min, max); var.setValue(clampedValue); - config::Save(); + config::save(); if (callback) { callback(clampedValue); } @@ -680,7 +678,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .on_pressed([i] { mDoAud_seStartMenu(kSoundItemChange); getSettings().game.language.setValue(static_cast(i)); - config::Save(); + config::save(); }); } pane.add_rml("
Changes require a restart."); @@ -707,7 +705,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { mDoAud_seStartMenu(kSoundItemChange); getSettings().backend.graphicsBackend.setValue( std::string{backend_id(backend)}); - config::Save(); + config::save(); }); } pane.add_rml("
Changes require a restart."); @@ -738,7 +736,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .on_pressed([i] { mDoAud_seStartMenu(kSoundItemChange); getSettings().backend.cardFileType.setValue(i); - config::Save(); + config::save(); }); } }); @@ -755,7 +753,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { mDoAud_seStartMenu(kSoundItemChange); getSettings().video.enableFullscreen.setValue(!getSettings().video.enableFullscreen); VISetWindowFullscreen(getSettings().video.enableFullscreen); - config::Save(); + config::save(); }), rightPane, [](Pane& pane) { pane.clear(); }); leftPane.register_control(leftPane.add_button("Restore Default Window Size").on_pressed([] { @@ -786,7 +784,6 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { { .key = "Pause on Focus Lost", .helpText = "Pause the game when window focus is lost.", - .onChange = [](bool value) { aurora_set_pause_on_focus_lost(value); }, .isDisabled = [] { return IsMobile || getSettings().game.speedrunMode; }, }); leftPane.register_control( @@ -818,7 +815,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .on_pressed([] { mDoAud_seStartMenu(kSoundItemChange); getSettings().video.enableFpsOverlay.setValue(false); - config::Save(); + config::save(); }); for (int i = 0; i < static_cast(kFpsOverlayCornerNames.size()); ++i) { pane.add_button( @@ -834,7 +831,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { mDoAud_seStartMenu(kSoundItemChange); getSettings().video.enableFpsOverlay.setValue(true); getSettings().video.fpsOverlayCorner.setValue(i); - config::Save(); + config::save(); }); } pane.add_rml( @@ -850,7 +847,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { const auto windowSize = aurora::window::get_window_size(); dusk::getSettings().video.lastWindowWidth.setValue(windowSize.width); dusk::getSettings().video.lastWindowHeight.setValue(windowSize.height); - dusk::config::Save(); + dusk::config::save(); } }, .isDisabled = [] { return IsMobile; }, @@ -956,7 +953,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { mDoAud_seStartMenu(kSoundItemChange); getSettings().game.enableFrameInterpolation.setValue(static_cast(i)); android::update_surface_frame_rate(); - config::Save(); + config::save(); }); } pane.add_rml(kUnlockFramerateHelpText); @@ -1151,7 +1148,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { leftPane.add_section("Tools"); addOption("Turbo Key", getSettings().game.enableTurboKeybind, "Hold Tab to increase game speed by up to 4x.", - [] { return getSettings().game.speedrunMode; }); + [] { return getSettings().game.speedrunMode.getValue(); }); addOption("Reset Key (" + Rml::String{hotkeys::DO_RESET} + ")", getSettings().game.enableResetKeybind, "Press " + Rml::String{hotkeys::DO_RESET} + " to reset the game."); @@ -1170,7 +1167,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .setValue = [](int value) { getSettings().audio.masterVolume.setValue(value); - config::Save(); + config::save(); audio::SetMasterVolume(audio::MasterVolumeToLinear(value / 100.0f)); }, .isModified = @@ -1262,9 +1259,9 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .setValue = [](int value) { getSettings().game.damageMultiplier.setValue(value); - config::Save(); + config::save(); }, - .isDisabled = [] { return getSettings().game.speedrunMode; }, + .isDisabled = [] { return getSettings().game.speedrunMode.getValue(); }, .isModified = [] { return getSettings().game.damageMultiplier.getValue() != @@ -1408,7 +1405,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { [] { return kMagicArmorModes[static_cast(getSettings().game.armorRupeeDrain.getValue())]; }, - .isDisabled = [] { return getSettings().game.speedrunMode; }, + .isDisabled = [] { return getSettings().game.speedrunMode.getValue(); }, .isModified = [] { return getSettings().game.armorRupeeDrain.getValue() != @@ -1427,7 +1424,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .on_pressed([i] { mDoAud_seStartMenu(kSoundItemChange); getSettings().game.armorRupeeDrain.setValue(static_cast(i)); - config::Save(); + config::save(); }); } pane.add_rml( @@ -1480,13 +1477,13 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { mDoAud_seStartMenu(kSoundItemChange); getSettings().game.enableAchievementToasts.setValue(true); getSettings().game.enableControllerToasts.setValue(true); - config::Save(); + config::save(); }); pane.add_button("Select None").on_pressed([] { mDoAud_seStartMenu(kSoundItemChange); getSettings().game.enableAchievementToasts.setValue(false); getSettings().game.enableControllerToasts.setValue(false); - config::Save(); + config::save(); }); pane.add_section("Types"); @@ -1502,7 +1499,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { mDoAud_seStartMenu(kSoundItemChange); auto& v = getSettings().game.enableAchievementToasts; v.setValue(!v.getValue()); - config::Save(); + config::save(); }); pane.add_button( { @@ -1514,7 +1511,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { mDoAud_seStartMenu(kSoundItemChange); auto& v = getSettings().game.enableControllerToasts; v.setValue(!v.getValue()); - config::Save(); + config::save(); }); pane.add_rml("
Choose which notifications can be displayed."); }); @@ -1580,7 +1577,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { } } }, - .isDisabled = [] { return getSettings().game.speedrunMode; }, + .isDisabled = [] { return getSettings().game.speedrunMode.getValue(); }, }); config_bool_select(leftPane, rightPane, getSettings().game.showInputViewer, { @@ -1617,15 +1614,13 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { [i] { return getSettings().game.menuScalingMode.getValue() == static_cast(i); - ; }, }) .on_pressed([i] { mDoAud_seStartMenu(kSoundItemChange); getSettings().game.menuScalingMode.setValue( static_cast(i)); - ; - config::Save(); + config::save(); }); } pane.add_rml("
Changes how the Collection and File Select menus scale to your " @@ -1651,7 +1646,7 @@ void SettingsWindow::update() { } void SettingsWindow::hide(bool close) { - config::Save(); + config::save(); Window::hide(close); } diff --git a/src/dusk/ui/touch_controls_editor.cpp b/src/dusk/ui/touch_controls_editor.cpp index 539494d42f..ca7a4fb218 100644 --- a/src/dusk/ui/touch_controls_editor.cpp +++ b/src/dusk/ui/touch_controls_editor.cpp @@ -585,7 +585,7 @@ bool TouchControlsEditor::handle_nav_command(Rml::Event& event, NavCommand cmd) void TouchControlsEditor::save_layout() { mWorkingLayout.version = ControlLayout::Version; getSettings().game.touchControlsLayout.setValue(mWorkingLayout); - config::Save(); + config::save(); mDoAud_seStartMenu(kSoundItemChange); pop(); } diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index 0a190ed3dd..8ed11c54c4 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -265,7 +265,7 @@ void main01(void) { if (dusk::getSettings().video.rememberWindowSize && !dusk::getSettings().video.enableFullscreen) { dusk::getSettings().video.lastWindowWidth.setValue(event->windowSize.width); dusk::getSettings().video.lastWindowHeight.setValue(event->windowSize.height); - dusk::config::Save(); + dusk::config::save(); } break; case AURORA_DISPLAY_SCALE_CHANGED: @@ -430,16 +430,7 @@ static void ApplyCVarOverrides(const cxxopts::OptionValue& option) { const auto name = std::string_view(cvarArg).substr(0, sep); const auto value = std::string_view(cvarArg).substr(sep + 1); - const auto cVar = dusk::config::GetConfigVar(name); - if (!cVar) { - DuskLog.fatal("Unknown --cvar name: '{}'", name); - } - - try { - cVar->getImpl()->loadFromArg(*cVar, value); - } catch (const std::exception& e) { - DuskLog.fatal("Unable to parse: '{}': {}", value, e.what()); - } + dusk::config::load_arg_override(name, value); } } @@ -510,7 +501,6 @@ int game_main(int argc, char* argv[]) { mainCalled = true; dusk::registerSettings(); - dusk::config::FinishRegistration(); cxxopts::ParseResult parsed_arg_options; @@ -551,10 +541,7 @@ int game_main(int argc, char* argv[]) { log_build_info(); - dusk::config::LoadFromUserPreferences(); - if (dusk::getSettings().game.speedrunMode) { - dusk::resetForSpeedrunMode(); - } + dusk::config::load_from_user_preferences(); ApplyCVarOverrides(parsed_arg_options["cvar"]); dusk::android::update_surface_frame_rate(); dusk::crash_reporting::initialize(); @@ -618,6 +605,12 @@ int game_main(int argc, char* argv[]) { auroraInfo = aurora_initialize(argc, argv, &config); } + // Apply after aurora_initialize: speedrun mode mutates cvars whose change callbacks push + // values into aurora. + if (dusk::getSettings().game.speedrunMode) { + dusk::resetForSpeedrunMode(); + } + #ifdef DUSK_DISCORD if (dusk::getSettings().game.enableDiscordPresence) { dusk::discord::initialize(); @@ -700,7 +693,7 @@ int game_main(int argc, char* argv[]) { dusk::getSettings().backend.isoPath.setValue(dvd_path); dusk::getSettings().backend.isoVerification.setValue( dusk::DiscVerificationState::Unknown); - dusk::config::Save(); + dusk::config::save(); dusk::IsGameLaunched = true; } } else { @@ -723,7 +716,7 @@ int game_main(int argc, char* argv[]) { saveConfigBeforePrelaunch = true; } if (saveConfigBeforePrelaunch) { - dusk::config::Save(); + dusk::config::save(); } if (!dusk::getSettings().backend.skipPreLaunchUI) { @@ -814,6 +807,7 @@ int game_main(int argc, char* argv[]) { #endif dusk::ui::shutdown(); dusk::texture_replacements::shutdown(); + dusk::config::shutdown(); aurora_shutdown(); return 0; From e0ab36b7d93d82edad928ed55f154971ae4bbfcd Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 7 Jul 2026 01:14:29 -0600 Subject: [PATCH 058/150] Stable game ABI: Use PARTIAL_DEBUG To make sure that mods can work across debug and release build, we can utilize PARTIAL_DEBUG to enable only DEBUG changes that affect struct and vtable layout --- CMakeLists.txt | 9 +++++---- include/SSystem/SComponent/c_bg_s_shdw_draw.h | 2 +- include/Z2AudioLib/Z2AudioMgr.h | 2 ++ include/Z2AudioLib/Z2SeqMgr.h | 2 +- include/Z2AudioLib/Z2SoundObjMgr.h | 4 ++-- include/Z2AudioLib/Z2SoundObject.h | 2 +- include/d/d_attention.h | 4 ++-- include/d/d_bg_s_acch.h | 4 ++-- include/d/d_cc_s.h | 2 +- include/d/d_com_inf_game.h | 2 +- include/d/d_event_manager.h | 2 +- include/d/d_kankyo.h | 2 +- include/d/d_particle.h | 4 ++-- include/d/d_resorce.h | 2 +- include/d/d_save.h | 5 ++++- include/d/d_stage.h | 8 ++++---- include/d/d_vibration.h | 3 +++ include/f_pc/f_pc_node_req.h | 2 +- include/m_Do/m_Do_audio.h | 4 ++-- include/m_Do/m_Do_hostIO.h | 5 +++++ .../include/JSystem/JHostIO/JORReflexible.h | 15 +++++++++++++++ .../JSystem/JStudio/JStudio/functionvalue.h | 8 ++++---- src/Z2AudioLib/Z2SoundObject.cpp | 2 +- 23 files changed, 62 insertions(+), 33 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 1abe420b67..5c485a0c6a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -363,7 +363,8 @@ set(DUSK_COPYRIGHT "Copyright (C) Twilit Realm contributors") source_group("dolzel" FILES ${DOLZEL_FILES} ${Z2AUDIOLIB_FILES} ${REL_FILES}) source_group("dusklight" FILES ${DUSK_FILES} ${DUSK_HTTP_BACKEND_FILES}) -set(GAME_COMPILE_DEFS TARGET_PC WIDESCREEN_SUPPORT=1 AVOID_UB=1 VERSION=0 MTX_USE_PS=1) +# PARTIAL_DEBUG makes debug and release share one struct/vtable ABI so a mod binary loads into either +set(GAME_COMPILE_DEFS TARGET_PC WIDESCREEN_SUPPORT=1 AVOID_UB=1 VERSION=0 MTX_USE_PS=1 PARTIAL_DEBUG=1) set(GAME_INCLUDE_DIRS include @@ -474,7 +475,7 @@ set(GAME_DEBUG_FILES set_source_files_properties( ${GAME_DEBUG_FILES} PROPERTIES - COMPILE_DEFINITIONS "$<$:DEBUG=1>;$<$:PARTIAL_DEBUG=1>" + COMPILE_DEFINITIONS "$<$:DEBUG=1>;PARTIAL_DEBUG=1" ) # game_base is for all other game code files @@ -488,14 +489,14 @@ set(GAME_BASE_FILES set_source_files_properties( ${GAME_BASE_FILES} PROPERTIES - COMPILE_DEFINITIONS "NDEBUG=1;NDEBUG_DEFINED=1;DEBUG_DEFINED=0;$<$:PARTIAL_DEBUG=1>" + COMPILE_DEFINITIONS "NDEBUG=1;NDEBUG_DEFINED=1;DEBUG_DEFINED=0;PARTIAL_DEBUG=1" ) foreach(jsystem_lib IN LISTS JSYSTEM_LIBRARIES) target_compile_definitions(${jsystem_lib} PRIVATE ${GAME_COMPILE_DEFS} $<$:DEBUG=1> - $<$:PARTIAL_DEBUG=1> + PARTIAL_DEBUG=1 ) target_include_directories(${jsystem_lib} PRIVATE ${GAME_INCLUDE_DIRS}) target_link_libraries(${jsystem_lib} PRIVATE ${GAME_LIBS}) diff --git a/include/SSystem/SComponent/c_bg_s_shdw_draw.h b/include/SSystem/SComponent/c_bg_s_shdw_draw.h index 9ec3023306..55792f8410 100644 --- a/include/SSystem/SComponent/c_bg_s_shdw_draw.h +++ b/include/SSystem/SComponent/c_bg_s_shdw_draw.h @@ -20,7 +20,7 @@ public: /* 0x14 */ cM3dGAab mM3dGAab; /* 0x30 */ cBgS_ShdwDraw_Callback mCallbackFun; - #if DEBUG + #if PARTIAL_DEBUG || DEBUG /* 0x34 */ int field_0x34; #endif }; diff --git a/include/Z2AudioLib/Z2AudioMgr.h b/include/Z2AudioLib/Z2AudioMgr.h index e543bf6061..0f8e5a17ea 100644 --- a/include/Z2AudioLib/Z2AudioMgr.h +++ b/include/Z2AudioLib/Z2AudioMgr.h @@ -48,6 +48,8 @@ public: /* 0x1370 */ Z2FxLineMgr mFxLineMgr; #if DEBUG /* 0x13BC */ Z2DebugSys mDebugSys; + #elif PARTIAL_DEBUG + alignas(Z2DebugSys) u8 mDebugSys[sizeof(Z2DebugSys)]; #endif }; // Size: 0x138C diff --git a/include/Z2AudioLib/Z2SeqMgr.h b/include/Z2AudioLib/Z2SeqMgr.h index 76c2674697..94191a1318 100644 --- a/include/Z2AudioLib/Z2SeqMgr.h +++ b/include/Z2AudioLib/Z2SeqMgr.h @@ -194,7 +194,7 @@ public: JAISoundHandle* getMainBgmHandle() { return &mMainBgmHandle; } JAISoundHandle* getSubBgmHandle() { return &mSubBgmHandle; } - #if DEBUG + #if PARTIAL_DEBUG || DEBUG f32 field_0x00_debug; u8 field_0x04_debug; #endif diff --git a/include/Z2AudioLib/Z2SoundObjMgr.h b/include/Z2AudioLib/Z2SoundObjMgr.h index c0b4ed778c..687f9eaf0a 100644 --- a/include/Z2AudioLib/Z2SoundObjMgr.h +++ b/include/Z2AudioLib/Z2SoundObjMgr.h @@ -100,13 +100,13 @@ public: bool isForceBattle() { return forceBattle_; } JSUList* getEnemyList() { return &field_0x0; } - #if DEBUG + #if PARTIAL_DEBUG || DEBUG JSUList* getAllList() { return &allList_; } #endif private: /* 0x00 */ JSUList field_0x0; - #if DEBUG + #if PARTIAL_DEBUG || DEBUG /* 0x0C */ JSUList allList_; #endif /* 0x0C */ Z2EnemyArea enemyArea_; diff --git a/include/Z2AudioLib/Z2SoundObject.h b/include/Z2AudioLib/Z2SoundObject.h index ca4b69a520..2d85db34c0 100644 --- a/include/Z2AudioLib/Z2SoundObject.h +++ b/include/Z2AudioLib/Z2SoundObject.h @@ -7,7 +7,7 @@ struct Z2SoundStarter; class Z2SoundObjBase : public Z2SoundHandles -#if DEBUG +#if PARTIAL_DEBUG || DEBUG , public JSULink #endif { diff --git a/include/d/d_attention.h b/include/d/d_attention.h index 455516e54f..5b8930b5dd 100644 --- a/include/d/d_attention.h +++ b/include/d/d_attention.h @@ -45,7 +45,7 @@ private: class dAttParam_c : public JORReflexible { public: -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x04 */ s8 mHIOChildNo; #endif @@ -66,7 +66,7 @@ public: /* 0x35 */ u8 mAttnCursorDisappearFrames; /* 0x38 */ f32 field_0x38; /* 0x3C */ f32 field_0x3c; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x44 */ s32 mDebugDispPosX; /* 0x48 */ s32 mDebugDispPosY; #endif diff --git a/include/d/d_bg_s_acch.h b/include/d/d_bg_s_acch.h index 33bca24c0a..04a388c61b 100644 --- a/include/d/d_bg_s_acch.h +++ b/include/d/d_bg_s_acch.h @@ -201,7 +201,7 @@ private: /* 0x02C */ u32 m_flags; /* 0x030 */ cXyz* pm_pos; /* 0x034 */ cXyz* pm_old_pos; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x038 */ cXyz unk_0x38; #endif /* 0x038 */ cXyz* pm_speed; @@ -229,7 +229,7 @@ private: /* 0x0CC */ f32 field_0xcc; /* 0x0D0 */ f32 m_wtr_chk_offset; /* 0x0D4 */ cBgS_PolyInfo* pm_out_poly_info; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x0E4 */ cXyz unk_0xe4; #endif /* 0x0D8 */ f32 field_0xd8; diff --git a/include/d/d_cc_s.h b/include/d/d_cc_s.h index b4d90787ae..d156867cce 100644 --- a/include/d/d_cc_s.h +++ b/include/d/d_cc_s.h @@ -79,7 +79,7 @@ public: // /* 0x0000 */ cCcS mCCcS; /* 0x284C */ dCcMassS_Mng mMass_Mng; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x2AD0 */ u8 m_is_mass_all_timer; #endif }; // Size = 0x2AC4 diff --git a/include/d/d_com_inf_game.h b/include/d/d_com_inf_game.h index fbc9964ca2..d240507f97 100644 --- a/include/d/d_com_inf_game.h +++ b/include/d/d_com_inf_game.h @@ -1037,7 +1037,7 @@ public: /* 0x1DE09 */ u8 field_0x1de09; /* 0x1DE0A */ u8 field_0x1de0a; /* 0x1DE0B */ u8 mIsDebugMode; - #if DEBUG + #if PARTIAL_DEBUG || DEBUG /* 0x1DE0C */ OSStopwatch mStopwatch; #endif diff --git a/include/d/d_event_manager.h b/include/d/d_event_manager.h index 03a8ef7ac3..de046797ff 100644 --- a/include/d/d_event_manager.h +++ b/include/d/d_event_manager.h @@ -41,7 +41,7 @@ public: BASE_ROOM5, BASE_DEMO, - #if DEBUG + #if PARTIAL_DEBUG || DEBUG BASE_DEBUG, #endif diff --git a/include/d/d_kankyo.h b/include/d/d_kankyo.h index 3c81f08d10..06c8f0eb56 100644 --- a/include/d/d_kankyo.h +++ b/include/d/d_kankyo.h @@ -259,7 +259,7 @@ public: /* 0x09B8 */ DUNGEON_LIGHT dungeonlight[8]; /* 0x0C18 */ BOSS_LIGHT field_0x0c18[8]; /* 0x0D58 */ BOSS_LIGHT field_0x0d58[6]; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x0E48 */ NAVYCHAN navy; /* 0x0E58 */ u8 field_0xe58[0xE68 - 0xE58]; // part of NAVYCHAN? #endif diff --git a/include/d/d_particle.h b/include/d/d_particle.h index c9db174fcd..3098dbd521 100644 --- a/include/d/d_particle.h +++ b/include/d/d_particle.h @@ -521,13 +521,13 @@ private: /* 0x019 */ u8 field_0x19; /* 0x01A */ u8 field_0x1a; /* 0x01B */ u8 field_0x1b; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x01C */ dPa_simpleEcallBack field_0x1c[48]; #else /* 0x01C */ dPa_simpleEcallBack field_0x1c[25]; #endif /* 0x210 */ level_c field_0x210; - #if DEBUG + #if PARTIAL_DEBUG || DEBUG u8 mSceneCount; #endif }; diff --git a/include/d/d_resorce.h b/include/d/d_resorce.h index 31d028a082..b70d2d5452 100644 --- a/include/d/d_resorce.h +++ b/include/d/d_resorce.h @@ -59,7 +59,7 @@ private: /* 0x18 */ JKRHeap* heap; /* 0x1C */ JKRSolidHeap* mDataHeap; /* 0x20 */ void** mRes; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x24 */ int mSize; #endif }; // Size: 0x24 diff --git a/include/d/d_save.h b/include/d/d_save.h index 69d4d3174d..fc54e541f4 100644 --- a/include/d/d_save.h +++ b/include/d/d_save.h @@ -1008,7 +1008,7 @@ public: static const int ZONE_MAX = 0x20; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x000 */ u8 unk_0x0; /* 0x001 */ char unk_0x1; /* 0x000 */ u8 unk_0x2[0x48 - 0x2]; @@ -1029,6 +1029,9 @@ public: /* 0xF30 */ s64 mSaveTotalTime; #if DEBUG /* 0xF80 */ flagFile_c mFlagFile; +#elif PARTIAL_DEBUG + // flagFile_c's ctor/virtuals are only defined under #if DEBUG (d_save.cpp) + alignas(flagFile_c) u8 mFlagFile[sizeof(flagFile_c)]; #endif }; // Size: 0xF38 diff --git a/include/d/d_stage.h b/include/d/d_stage.h index 04a6115fd0..83a1c808fd 100644 --- a/include/d/d_stage.h +++ b/include/d/d_stage.h @@ -538,7 +538,7 @@ public: /* vt[86] */ virtual stage_tgsc_class* getDrTg(void) const = 0; /* vt[87] */ virtual void setDoor(stage_tgsc_class*) = 0; /* vt[88] */ virtual stage_tgsc_class* getDoor(void) const = 0; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG virtual void setUnit(void*) = 0; virtual void* getUnit() = 0; #endif @@ -796,7 +796,7 @@ public: virtual stage_tgsc_class* getDrTg(void) const { return mDrTg; } virtual void setDoor(stage_tgsc_class* i_Door) { mDoor = i_Door; } virtual stage_tgsc_class* getDoor(void) const { return mDoor; } -#if DEBUG +#if PARTIAL_DEBUG || DEBUG virtual void setUnit(void* i_Unit) { mUnit = i_Unit; } virtual void* getUnit() { return mUnit; } #endif @@ -845,7 +845,7 @@ public: /* 0x54 */ stage_tgsc_class* mDrTg; /* 0x58 */ stage_tgsc_class* mDoor; /* 0x5C */ dStage_FloorInfo_c* mFloorInfo; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x60 */ void* mUnit; #endif /* 0x60 */ u16 mPlayerNum; @@ -990,7 +990,7 @@ public: /* vt[86] */ virtual stage_tgsc_class* getDrTg(void) const { return mDrTg; } /* vt[87] */ virtual void setDoor(stage_tgsc_class* i_Door) { mDoor = i_Door; } /* vt[88] */ virtual stage_tgsc_class* getDoor(void) const { return mDoor; } -#if DEBUG +#if PARTIAL_DEBUG || DEBUG virtual void setUnit(void* i_Unit) { UNUSED(i_Unit); OSReport("stage non unit list data !!\n"); diff --git a/include/d/d_vibration.h b/include/d/d_vibration.h index 4018acea50..ee87f7dcc4 100644 --- a/include/d/d_vibration.h +++ b/include/d/d_vibration.h @@ -97,6 +97,9 @@ public: private: #if DEBUG /* 0x00 */ dVibTest_c mVibTest; +#elif PARTIAL_DEBUG + // dVibTest_c's ctor/virtuals are only defined under #if DEBUG (d_vibration.cpp) + alignas(dVibTest_c) u8 mVibTest[sizeof(dVibTest_c)]; #endif class { diff --git a/include/f_pc/f_pc_node_req.h b/include/f_pc/f_pc_node_req.h index d710379c89..880e733ac9 100644 --- a/include/f_pc/f_pc_node_req.h +++ b/include/f_pc/f_pc_node_req.h @@ -36,7 +36,7 @@ typedef struct node_create_request { /* 0x58 */ s16 name; /* 0x5C */ void* data; /* 0x60 */ s16 unk_0x60; -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x64 */ int unk_0x64; /* 0x68 */ int unk_0x68; #endif diff --git a/include/m_Do/m_Do_audio.h b/include/m_Do/m_Do_audio.h index 4bc2f20c94..e1e15c1ef1 100644 --- a/include/m_Do/m_Do_audio.h +++ b/include/m_Do/m_Do_audio.h @@ -11,13 +11,13 @@ class mDoAud_zelAudio_c : public Z2AudioMgr { public: void reset(); mDoAud_zelAudio_c() { -#if DEBUG +#if PARTIAL_DEBUG || DEBUG setMode(2); #endif } ~mDoAud_zelAudio_c() {} -#if DEBUG +#if PARTIAL_DEBUG || DEBUG u8 getMode() { return field_0x13bd; } void setMode(u8 mode) { field_0x13bd = mode; } diff --git a/include/m_Do/m_Do_hostIO.h b/include/m_Do/m_Do_hostIO.h index fa4eb8b1af..d88a0a9853 100644 --- a/include/m_Do/m_Do_hostIO.h +++ b/include/m_Do/m_Do_hostIO.h @@ -35,6 +35,11 @@ public: /* 0x4 */ s8 mNo; /* 0x5 */ u8 mCount; #else +#if PARTIAL_DEBUG + // Initialized here since the DEBUG ctor doesn't run. + /* 0x4 */ s8 mNo = -1; + /* 0x5 */ u8 mCount = 0; +#endif virtual ~mDoHIO_entry_c() {} #endif }; diff --git a/libs/JSystem/include/JSystem/JHostIO/JORReflexible.h b/libs/JSystem/include/JSystem/JHostIO/JORReflexible.h index 533a3184ba..84894de275 100644 --- a/libs/JSystem/include/JSystem/JHostIO/JORReflexible.h +++ b/libs/JSystem/include/JSystem/JHostIO/JORReflexible.h @@ -12,12 +12,23 @@ struct JORNodeEvent; class JORMContext; class JORServer; +// NOTE (stable game ABI): these classes stay non-polymorphic outside DEBUG +// on purpose. Making them polymorphic under PARTIAL_DEBUG would give every one of the ~250 +// derived HIO classes a vptr and turn their plain `void genMessage(JORMContext*);` +// declarations into implicit virtual overrides whose definitions are #if DEBUG-gated; every +// instantiated one then fails to link (missing vtable). Closure types shared with DEBUG TUs +// either declare their own unconditional virtuals (vptr in all TUs anyway) or add a +// PARTIAL_DEBUG-only virtual dtor for vptr parity (see dAttParam_c). class JOREventListener { public: #if DEBUG JOREventListener() {} +#if TARGET_PC + virtual void listenPropertyEvent(const JORPropertyEvent*) {} +#else virtual void listenPropertyEvent(const JORPropertyEvent*) = 0; #endif +#endif }; class JORReflexible : public JOREventListener { @@ -30,7 +41,11 @@ public: virtual void listenPropertyEvent(const JORPropertyEvent*); virtual void listen(u32, const JOREvent*); virtual void genObjectInfo(const JORGenEvent*); +#if TARGET_PC + virtual void genMessage(JORMContext*) {} +#else virtual void genMessage(JORMContext*) = 0; +#endif virtual void listenNodeEvent(const JORNodeEvent*); #endif }; diff --git a/libs/JSystem/include/JSystem/JStudio/JStudio/functionvalue.h b/libs/JSystem/include/JSystem/JStudio/JStudio/functionvalue.h index 7b2c935272..8acdbe1623 100644 --- a/libs/JSystem/include/JSystem/JStudio/JStudio/functionvalue.h +++ b/libs/JSystem/include/JSystem/JStudio/JStudio/functionvalue.h @@ -314,7 +314,7 @@ public: > { TIterator_data_(const TFunctionValue_list_parameter& rParent, const f32* value) { -#if DEBUG +#if PARTIAL_DEBUG || DEBUG pOwn_ = &rParent; #endif pf_ = value; @@ -372,7 +372,7 @@ public: return (r1.pf_ - r2.pf_) / suData_size; } -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x00 */ const TFunctionValue_list_parameter* pOwn_; #endif /* 0x00 */ const f32* pf_; @@ -425,7 +425,7 @@ public: > { TIterator_data_(const TFunctionValue_hermite& rParent, const f32* value) { -#if DEBUG +#if PARTIAL_DEBUG || DEBUG pOwn_ = &rParent; #endif pf_ = value; @@ -491,7 +491,7 @@ public: return (r1.pf_ - r2.pf_) / r1.uSize_; } -#if DEBUG +#if PARTIAL_DEBUG || DEBUG /* 0x00 */ const TFunctionValue_hermite* pOwn_; /* 0x04 */ const f32* pf_; /* 0x08 */ u32 uSize_; diff --git a/src/Z2AudioLib/Z2SoundObject.cpp b/src/Z2AudioLib/Z2SoundObject.cpp index 1c81be0f17..23e8171736 100644 --- a/src/Z2AudioLib/Z2SoundObject.cpp +++ b/src/Z2AudioLib/Z2SoundObject.cpp @@ -22,7 +22,7 @@ #include "os_report.h" Z2SoundObjBase::Z2SoundObjBase() -#if DEBUG +#if PARTIAL_DEBUG || DEBUG : JSULink(this) #endif { From b474aafe9d6f7e1b9603adcc3b491c1fc32e0245 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 7 Jul 2026 23:22:18 -0600 Subject: [PATCH 059/150] Fix compile error & cleanup --- include/dusk/config.hpp | 7 +++---- src/dusk/config.cpp | 3 --- src/dusk/ui/settings.cpp | 2 +- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/include/dusk/config.hpp b/include/dusk/config.hpp index b56a4cbd43..6d36dff1f3 100644 --- a/include/dusk/config.hpp +++ b/include/dusk/config.hpp @@ -173,11 +173,10 @@ void unsubscribe(Subscription token); /** * \brief Register a CVar and attach a change callback in one step. - * +* * Useful for pushing settings into external systems (e.g. aurora) from one place instead of - * every UI setter. The callback fires only for runtime changes (see subscribe); the value - * loaded from config or launch arguments does not fire it — the external system reads its - * initial value itself at its own initialization. + * every UI setter. The callback fires only for runtime changes (see subscribe); not when + * loaded from config or launch arguments. */ template requires std::invocable Subscription Register( diff --git a/src/dusk/config.cpp b/src/dusk/config.cpp index 698dceaaac..a2b79eaac5 100644 --- a/src/dusk/config.cpp +++ b/src/dusk/config.cpp @@ -483,9 +483,6 @@ static void LoadFromPath(const char* path) { const auto& key = el.key(); auto configVar = RegisteredConfigVars.find(key); if (configVar == RegisteredConfigVars.end()) { - DuskConfigLog.debug("Unknown key '{}' found in config! If this gets registered later, " - "that's acceptable!", - key); UnregisteredConfigVars.emplace(key, el.value()); continue; } diff --git a/src/dusk/ui/settings.cpp b/src/dusk/ui/settings.cpp index 401d2aee89..124515d64f 100644 --- a/src/dusk/ui/settings.cpp +++ b/src/dusk/ui/settings.cpp @@ -1052,7 +1052,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { mDoAud_seStartMenu(kSoundItemChange); getSettings().game.touchTargeting.setValue( static_cast(i)); - config::Save(); + config::save(); }); } pane.add_rml(fmt::format("
Hybrid: {}
Hold: {}
Switch: {}", From 5c9c76cc0b1444cb9f0f70bf962e0a63429abd23 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 7 Jul 2026 01:21:01 -0600 Subject: [PATCH 060/150] Dusklight mod API core Co-authored-by: qwertyquerty Co-authored-by: PJB3005 --- CMakeLists.txt | 195 ++--- CMakePresets.json | 20 +- cmake/DetectVersion.cmake | 121 +++ cmake/GameABIConfig.cmake | 27 + cmake/ModSDK.cmake | 215 +++++ docs/modding.md | 259 ++++++ files.cmake | 13 + include/dusk/mod_loader.hpp | 245 ++++++ include/mods/api.h | 152 ++++ include/mods/service.hpp | 138 +++ include/mods/svc/host.h | 63 ++ include/mods/svc/log.h | 47 + platforms/macos/Dusklight.entitlements | 8 + sdk/CMakeLists.txt | 33 + src/dusk/data.cpp | 16 +- src/dusk/data.hpp | 1 + src/dusk/mods/loader/bundle_disk.cpp | 60 ++ src/dusk/mods/loader/bundle_zip.cpp | 68 ++ src/dusk/mods/loader/context.cpp | 70 ++ src/dusk/mods/loader/depgraph.cpp | 207 +++++ src/dusk/mods/loader/depgraph.hpp | 17 + src/dusk/mods/loader/loader.cpp | 1088 ++++++++++++++++++++++++ src/dusk/mods/loader/loader.hpp | 67 ++ src/dusk/mods/loader/native_module.cpp | 83 ++ src/dusk/mods/loader/native_module.hpp | 35 + src/dusk/mods/svc/host.cpp | 76 ++ src/dusk/mods/svc/log.cpp | 64 ++ src/dusk/mods/svc/registry.cpp | 259 ++++++ src/dusk/mods/svc/registry.hpp | 36 + src/f_ap/f_ap_game.cpp | 3 + src/m_Do/m_Do_main.cpp | 58 +- tools/mod_template/CMakeLists.txt | 19 + tools/mod_template/mod.json | 7 + tools/mod_template/res/.gitkeep | 0 tools/mod_template/src/mod.cpp | 22 + 35 files changed, 3645 insertions(+), 147 deletions(-) create mode 100644 cmake/DetectVersion.cmake create mode 100644 cmake/GameABIConfig.cmake create mode 100644 cmake/ModSDK.cmake create mode 100644 docs/modding.md create mode 100644 include/dusk/mod_loader.hpp create mode 100644 include/mods/api.h create mode 100644 include/mods/service.hpp create mode 100644 include/mods/svc/host.h create mode 100644 include/mods/svc/log.h create mode 100644 platforms/macos/Dusklight.entitlements create mode 100644 sdk/CMakeLists.txt create mode 100644 src/dusk/mods/loader/bundle_disk.cpp create mode 100644 src/dusk/mods/loader/bundle_zip.cpp create mode 100644 src/dusk/mods/loader/context.cpp create mode 100644 src/dusk/mods/loader/depgraph.cpp create mode 100644 src/dusk/mods/loader/depgraph.hpp create mode 100644 src/dusk/mods/loader/loader.cpp create mode 100644 src/dusk/mods/loader/loader.hpp create mode 100644 src/dusk/mods/loader/native_module.cpp create mode 100644 src/dusk/mods/loader/native_module.hpp create mode 100644 src/dusk/mods/svc/host.cpp create mode 100644 src/dusk/mods/svc/log.cpp create mode 100644 src/dusk/mods/svc/registry.cpp create mode 100644 src/dusk/mods/svc/registry.hpp create mode 100644 tools/mod_template/CMakeLists.txt create mode 100644 tools/mod_template/mod.json create mode 100644 tools/mod_template/res/.gitkeep create mode 100644 tools/mod_template/src/mod.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5c485a0c6a..d7093b097c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,96 +5,8 @@ if (NOT CMAKE_BUILD_TYPE) "Build type options: Debug Release RelWithDebInfo MinSizeRel" FORCE) endif () -set(DUSK_VERSION_OVERRIDE "" CACHE STRING "Override version string (skips git detection and format validation)") - -if (DUSK_VERSION_OVERRIDE) - set(DUSK_WC_DESCRIBE "${DUSK_VERSION_OVERRIDE}") - set(DUSK_VERSION_STRING "0.0.0.0") - set(DUSK_SHORT_VERSION_STRING "0.0.0") - set(DUSK_VERSION_CODE "1") - set(DUSK_WC_REVISION "") - set(DUSK_WC_BRANCH "") - set(DUSK_WC_DATE "") - message(STATUS "Dusklight version overridden to ${DUSK_WC_DESCRIBE}") -else () - # obtain revision info from git - find_package(Git) -if (GIT_FOUND) - # make sure version information gets re-run when the current Git HEAD changes - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} rev-parse --git-path HEAD - OUTPUT_VARIABLE dusk_git_head_filename - OUTPUT_STRIP_TRAILING_WHITESPACE) - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${dusk_git_head_filename}") - - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} rev-parse --symbolic-full-name HEAD - OUTPUT_VARIABLE dusk_git_head_symbolic - OUTPUT_STRIP_TRAILING_WHITESPACE) - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - COMMAND ${GIT_EXECUTABLE} rev-parse --git-path ${dusk_git_head_symbolic} - OUTPUT_VARIABLE dusk_git_head_symbolic_filename - OUTPUT_STRIP_TRAILING_WHITESPACE) - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${dusk_git_head_symbolic_filename}") - - # defines DUSK_WC_REVISION - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} rev-parse HEAD - OUTPUT_VARIABLE DUSK_WC_REVISION - OUTPUT_STRIP_TRAILING_WHITESPACE) - # defines DUSK_WC_DESCRIBE - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} describe --tags --long --dirty --match "v*" - OUTPUT_VARIABLE DUSK_WC_DESCRIBE - OUTPUT_STRIP_TRAILING_WHITESPACE) - - # remove the git hash, then collapse a clean "-0" suffix only - string(REGEX REPLACE "-[^-]+(-dirty|)$" "\\1" DUSK_WC_DESCRIBE "${DUSK_WC_DESCRIBE}") - string(REGEX REPLACE "-0$" "" DUSK_WC_DESCRIBE "${DUSK_WC_DESCRIBE}") - - # defines DUSK_WC_BRANCH - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} rev-parse --abbrev-ref HEAD - OUTPUT_VARIABLE DUSK_WC_BRANCH - OUTPUT_STRIP_TRAILING_WHITESPACE) - # defines DUSK_WC_DATE - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} log -1 --format=%ad - OUTPUT_VARIABLE DUSK_WC_DATE - OUTPUT_STRIP_TRAILING_WHITESPACE) -else () - message(STATUS "Unable to find git, commit information will not be available") -endif () - -if (DUSK_WC_DESCRIBE MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)([-+].*)?$") - set(DUSK_SHORT_VERSION_STRING "${CMAKE_MATCH_1}.${CMAKE_MATCH_2}.${CMAKE_MATCH_3}") - set(_ver_major ${CMAKE_MATCH_1}) - set(_ver_minor ${CMAKE_MATCH_2}) - set(_ver_patch ${CMAKE_MATCH_3}) - set(DUSK_VERSION_TWEAK "0") - if (DUSK_WC_DESCRIBE MATCHES "^v[0-9]+\\.[0-9]+\\.[0-9]+-([0-9]+)(-dirty)?$") - set(DUSK_VERSION_TWEAK "${CMAKE_MATCH_1}") - elseif (DUSK_WC_DESCRIBE MATCHES "^v[0-9]+\\.[0-9]+\\.[0-9]+-[0-9A-Za-z.-]+-([0-9]+)(-dirty)?$") - set(DUSK_VERSION_TWEAK "${CMAKE_MATCH_1}") - endif () - set(DUSK_VERSION_STRING "${DUSK_SHORT_VERSION_STRING}.${DUSK_VERSION_TWEAK}") - if(DUSK_VERSION_TWEAK GREATER 999) - set(_tweak 999) - else() - set(_tweak ${DUSK_VERSION_TWEAK}) - endif() - # encoding: major*1e7 + minor*1e5 + patch*1e3 + tweak; collision-free for major<210, minor<100, patch<100, tweak<=999 - math(EXPR DUSK_VERSION_CODE - "${_ver_major} * 10000000 + ${_ver_minor} * 100000 + ${_ver_patch} * 1000 + ${_tweak}") -else () - set(DUSK_WC_DESCRIBE "UNKNOWN-VERSION") - set(DUSK_VERSION_STRING "0.0.0.0") - set(DUSK_SHORT_VERSION_STRING "0.0.0") - set(DUSK_VERSION_CODE "1") -endif () - -endif () - -# Add version information to CI environment variables -if(DEFINED ENV{GITHUB_ENV}) - file(APPEND "$ENV{GITHUB_ENV}" "DUSK_VERSION=${DUSK_WC_DESCRIBE}\n") - file(APPEND "$ENV{GITHUB_ENV}" "DUSK_VERSION_CODE=${DUSK_VERSION_CODE}\n") -endif() -message(STATUS "Dusklight version set to ${DUSK_WC_DESCRIBE}") +include(cmake/DetectVersion.cmake) +detect_version() message(STATUS "Build type: ${CMAKE_BUILD_TYPE}") project(dusklight LANGUAGES C CXX VERSION ${DUSK_VERSION_STRING}) if (APPLE) @@ -177,12 +89,12 @@ option(DUSK_ENABLE_UPDATE_CHECKER "Enable update checking support" ON) option(DUSK_ENABLE_SENTRY_NATIVE "Enable sentry-native crash reporting support" OFF) option(DUSK_PACKAGE_INSTALL "Install Dusklight with a Linux-native file structure" OFF) option(DUSK_GFX_DEBUG_GROUPS "Report debug groups to the native graphics API" ${DUSK_GFX_DEBUG_GROUPS_DEFAULT}) -set(DUSK_SENTRY_DSN "" CACHE STRING "Sentry DSN") -set(DUSK_SENTRY_ENVIRONMENT "development" CACHE STRING "Sentry environment") +option(DUSK_ENABLE_CODE_MODS "Enable code mods" OFF) # Edit & Continue if (MSVC) - if ("${CMAKE_MSVC_DEBUG_INFORMATION_FORMAT}" STREQUAL "" AND CMAKE_BUILD_TYPE STREQUAL "Debug") + if ("${CMAKE_MSVC_DEBUG_INFORMATION_FORMAT}" STREQUAL "" AND CMAKE_BUILD_TYPE STREQUAL "Debug" + AND CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "EditAndContinue") endif () if (CMAKE_MSVC_DEBUG_INFORMATION_FORMAT STREQUAL "EditAndContinue") @@ -299,7 +211,13 @@ FetchContent_Declare(json URL_HASH SHA256=42f6e95cad6ec532fd372391373363b62a14af6d771056dbfc86160e6dfff7aa DOWNLOAD_EXTRACT_TIMESTAMP FALSE ) -FetchContent_MakeAvailable(cxxopts json) +message(STATUS "dusklight: Fetching miniz") +FetchContent_Declare(miniz + URL https://github.com/richgel999/miniz/releases/download/3.0.2/miniz-3.0.2.zip + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + EXCLUDE_FROM_ALL +) +FetchContent_MakeAvailable(cxxopts json miniz) if (DUSK_ENABLE_SENTRY_NATIVE) message(STATUS "dusklight: Fetching sentry-native") @@ -333,21 +251,7 @@ if(_arch MATCHES "^(arm|aarch64)" AND CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQU add_compile_options(-fsigned-char) endif() -if (CMAKE_SYSTEM_NAME STREQUAL Windows) - set(PLATFORM_NAME win32) -elseif (CMAKE_SYSTEM_NAME STREQUAL Darwin) - if (IOS) - set(PLATFORM_NAME ios) - elseif (TVOS) - set(PLATFORM_NAME tvos) - else () - set(PLATFORM_NAME macos) - endif () -else () - string(TOLOWER CMAKE_SYSTEM_NAME PLATFORM_NAME) -endif () - -configure_file(${CMAKE_SOURCE_DIR}/version.h.in ${CMAKE_BINARY_DIR}/version.h) +configure_version_header() include(files.cmake) @@ -363,23 +267,12 @@ set(DUSK_COPYRIGHT "Copyright (C) Twilit Realm contributors") source_group("dolzel" FILES ${DOLZEL_FILES} ${Z2AUDIOLIB_FILES} ${REL_FILES}) source_group("dusklight" FILES ${DUSK_FILES} ${DUSK_HTTP_BACKEND_FILES}) -# PARTIAL_DEBUG makes debug and release share one struct/vtable ABI so a mod binary loads into either -set(GAME_COMPILE_DEFS TARGET_PC WIDESCREEN_SUPPORT=1 AVOID_UB=1 VERSION=0 MTX_USE_PS=1 PARTIAL_DEBUG=1) - -set(GAME_INCLUDE_DIRS - include - src - assets/GZ2E01 # TODO: make this dynamic if needed? - libs/JSystem/include - libs - extern/aurora/include/dolphin - extern - ${CMAKE_BINARY_DIR}) +include(cmake/GameABIConfig.cmake) find_package(Threads REQUIRED) set(GAME_LIBS aurora::core aurora::gx aurora::gd aurora::si aurora::vi aurora::pad aurora::mtx aurora::os aurora::dvd aurora::card freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt - Threads::Threads zstd::libzstd) + Threads::Threads zstd::libzstd dusklight_game_headers) if (DUSK_ENABLE_SENTRY_NATIVE) list(APPEND GAME_LIBS sentry) @@ -447,8 +340,8 @@ if (DUSK_ENABLE_DISCORD AND NOT ANDROID AND NOT IOS AND NOT TVOS) list(APPEND GAME_COMPILE_DEFS DUSK_DISCORD=1) endif () -if(ANDROID) - list(APPEND GAME_COMPILE_DEFS TARGET_ANDROID=1) +if (DUSK_ENABLE_CODE_MODS) + list(APPEND GAME_COMPILE_DEFS DUSK_CODE_MODS=1) endif () if (DUSK_PACKAGE_INSTALL) @@ -475,7 +368,7 @@ set(GAME_DEBUG_FILES set_source_files_properties( ${GAME_DEBUG_FILES} PROPERTIES - COMPILE_DEFINITIONS "$<$:DEBUG=1>;PARTIAL_DEBUG=1" + COMPILE_DEFINITIONS "$<$:DEBUG=1>" ) # game_base is for all other game code files @@ -489,16 +382,11 @@ set(GAME_BASE_FILES set_source_files_properties( ${GAME_BASE_FILES} PROPERTIES - COMPILE_DEFINITIONS "NDEBUG=1;NDEBUG_DEFINED=1;DEBUG_DEFINED=0;PARTIAL_DEBUG=1" + COMPILE_DEFINITIONS "NDEBUG=1;NDEBUG_DEFINED=1;DEBUG_DEFINED=0" ) foreach(jsystem_lib IN LISTS JSYSTEM_LIBRARIES) - target_compile_definitions(${jsystem_lib} PRIVATE - ${GAME_COMPILE_DEFS} - $<$:DEBUG=1> - PARTIAL_DEBUG=1 - ) - target_include_directories(${jsystem_lib} PRIVATE ${GAME_INCLUDE_DIRS}) + target_compile_definitions(${jsystem_lib} PRIVATE ${GAME_COMPILE_DEFS} $<$:DEBUG=1>) target_link_libraries(${jsystem_lib} PRIVATE ${GAME_LIBS}) set_target_properties(${jsystem_lib} PROPERTIES FOLDER "JSystem") endforeach() @@ -510,7 +398,7 @@ if (CMAKE_CXX_LINK_GROUP_USING_RESCAN_SUPPORTED OR CMAKE_LINK_GROUP_USING_RESCAN set(JSYSTEM_LINK_LIBRARIES "$") endif () -set(DUSK_FILES src/dusk/main.cpp ${GAME_BASE_FILES} ${GAME_DEBUG_FILES}) +set(DUSK_FILES src/dusk/main.cpp ${GAME_BASE_FILES} ${GAME_DEBUG_FILES} ${miniz_SOURCE_DIR}/miniz.c) if(ANDROID) add_library(dusklight SHARED ${DUSK_FILES}) set_target_properties(dusklight PROPERTIES OUTPUT_NAME main) @@ -522,7 +410,7 @@ if (ENABLE_ASAN) endif () target_compile_definitions(dusklight PRIVATE ${GAME_COMPILE_DEFS}) -target_include_directories(dusklight PRIVATE ${GAME_INCLUDE_DIRS}) +target_include_directories(dusklight PRIVATE ${miniz_SOURCE_DIR}) target_link_libraries(dusklight PRIVATE aurora::main ${GAME_LIBS} ${JSYSTEM_LINK_LIBRARIES}) target_precompile_headers(dusklight PRIVATE "$<$:${CMAKE_SOURCE_DIR}/include/dusk_pch.hpp>") if (TARGET crashpad_handler) @@ -543,6 +431,7 @@ endif () if (CMAKE_SYSTEM_NAME STREQUAL Linux) target_link_options(dusklight PRIVATE "-Wl,--build-id=sha1") + target_link_libraries(dusklight PRIVATE dl) endif () if (NOT APPLE) @@ -583,6 +472,13 @@ if (WIN32) endif () endif () +include(cmake/ModSDK.cmake) + +if (DUSK_ENABLE_CODE_MODS AND CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + add_custom_target(dusklight_mods) # Aggregate target for all in-tree mods + add_subdirectory(tools/mod_template) +endif () + if (APPLE) if (IOS) set(DUSK_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios) @@ -590,6 +486,7 @@ if (APPLE) set(DUSK_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/platforms/tvos) else () set(DUSK_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/platforms/macos) + set(DUSK_ENTITLEMENTS ${DUSK_RESOURCE_DIR}/Dusklight.entitlements) endif () set(DUSK_INFO_PLIST ${DUSK_RESOURCE_DIR}/Info.plist.in) file(GLOB_RECURSE DUSK_RESOURCE_FILES @@ -609,8 +506,7 @@ if (APPLE) get_filename_component(NEW_FILE_PATH ${NEW_FILE} DIRECTORY) set_property(SOURCE ${FILE} PROPERTY MACOSX_PACKAGE_LOCATION "Resources/${NEW_FILE_PATH}") endforeach () - set_target_properties( - dusklight PROPERTIES + set(_apple_bundle_properties MACOSX_BUNDLE TRUE MACOSX_BUNDLE_BUNDLE_NAME ${DUSK_BUNDLE_NAME} MACOSX_BUNDLE_GUI_IDENTIFIER ${DUSK_BUNDLE_IDENTIFIER} @@ -621,6 +517,31 @@ if (APPLE) XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "YES" XCODE_ATTRIBUTE_CODE_SIGNING_REQUIRED "YES" ) + if (DUSK_ENTITLEMENTS) + list(APPEND _apple_bundle_properties + XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS ${DUSK_ENTITLEMENTS}) + endif () + if (NOT IOS AND NOT TVOS) + list(APPEND _apple_bundle_properties + XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME "YES") + endif () + set_target_properties(dusklight PROPERTIES ${_apple_bundle_properties}) + + if (NOT IOS AND NOT TVOS AND NOT "${CMAKE_GENERATOR}" STREQUAL "Xcode") + set(_sign_nested_commands) + if (TARGET crashpad_handler) + list(APPEND _sign_nested_commands + COMMAND /usr/bin/codesign --force --sign - + "$/crashpad_handler") + endif () + add_custom_command(TARGET dusklight POST_BUILD + ${_sign_nested_commands} + COMMAND /usr/bin/codesign --force --sign - --entitlements + "${DUSK_ENTITLEMENTS}" "$" + COMMENT "Signing Dusklight.app with entitlements" + VERBATIM + ) + endif () endif () if (APPLE AND NOT IOS AND NOT TVOS) @@ -744,3 +665,5 @@ foreach (target IN LISTS BINARY_TARGETS) endif () endforeach () endforeach () + +install_bundled_mods() diff --git a/CMakePresets.json b/CMakePresets.json index b7cb18c2d1..394a1ddef5 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -72,7 +72,11 @@ "type": "BOOL", "value": false }, - "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install" + "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install", + "DUSK_ENABLE_CODE_MODS": { + "type": "BOOL", + "value": true + } }, "vendor": { "microsoft.com/VisualStudioSettings/CMake/1.0": { @@ -159,6 +163,10 @@ "CMAKE_C_COMPILER": "cl", "CMAKE_CXX_COMPILER": "cl", "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install", + "DUSK_ENABLE_CODE_MODS": { + "type": "BOOL", + "value": true + }, "CMAKE_DISABLE_FIND_PACKAGE_PkgConfig": { "type": "BOOL", "value": true @@ -262,7 +270,11 @@ "type": "BOOL", "value": false }, - "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install" + "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install", + "DUSK_ENABLE_CODE_MODS": { + "type": "BOOL", + "value": true + } }, "vendor": { "microsoft.com/VisualStudioSettings/CMake/1.0": { @@ -394,6 +406,10 @@ "type": "BOOL", "value": false }, + "DUSK_ENABLE_CODE_MODS": { + "type": "BOOL", + "value": true + }, "CMAKE_DISABLE_FIND_PACKAGE_PkgConfig": { "type": "BOOL", "value": true diff --git a/cmake/DetectVersion.cmake b/cmake/DetectVersion.cmake new file mode 100644 index 0000000000..ec72a97a2e --- /dev/null +++ b/cmake/DetectVersion.cmake @@ -0,0 +1,121 @@ +# Version detection shared by the main build and the mod SDK (sdk/CMakeLists.txt) +include_guard(GLOBAL) + +get_filename_component(_DUSK_VERSION_ROOT "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE) + +set(DUSK_SENTRY_DSN "" CACHE STRING "Sentry DSN") +set(DUSK_SENTRY_ENVIRONMENT "development" CACHE STRING "Sentry environment") + +set(DUSK_VERSION_OVERRIDE "" CACHE STRING "Override version string (skips git detection and format validation)") + +macro(detect_version) + if (DUSK_VERSION_OVERRIDE) + set(DUSK_WC_DESCRIBE "${DUSK_VERSION_OVERRIDE}") + set(DUSK_VERSION_STRING "0.0.0.0") + set(DUSK_SHORT_VERSION_STRING "0.0.0") + set(DUSK_VERSION_CODE "1") + set(DUSK_WC_REVISION "") + set(DUSK_WC_BRANCH "") + set(DUSK_WC_DATE "") + message(STATUS "Dusklight version overridden to ${DUSK_WC_DESCRIBE}") + else () + # obtain revision info from git + find_package(Git) + if (GIT_FOUND) + # make sure version information gets re-run when the current Git HEAD changes + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} rev-parse --git-path HEAD + OUTPUT_VARIABLE dusk_git_head_filename + OUTPUT_STRIP_TRAILING_WHITESPACE) + get_filename_component(dusk_git_head_filename "${dusk_git_head_filename}" ABSOLUTE BASE_DIR "${_DUSK_VERSION_ROOT}") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${dusk_git_head_filename}") + + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} rev-parse --symbolic-full-name HEAD + OUTPUT_VARIABLE dusk_git_head_symbolic + OUTPUT_STRIP_TRAILING_WHITESPACE) + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} + COMMAND ${GIT_EXECUTABLE} rev-parse --git-path ${dusk_git_head_symbolic} + OUTPUT_VARIABLE dusk_git_head_symbolic_filename + OUTPUT_STRIP_TRAILING_WHITESPACE) + get_filename_component(dusk_git_head_symbolic_filename "${dusk_git_head_symbolic_filename}" ABSOLUTE BASE_DIR "${_DUSK_VERSION_ROOT}") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${dusk_git_head_symbolic_filename}") + + # defines DUSK_WC_REVISION + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} rev-parse HEAD + OUTPUT_VARIABLE DUSK_WC_REVISION + OUTPUT_STRIP_TRAILING_WHITESPACE) + # defines DUSK_WC_DESCRIBE + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} describe --tags --long --dirty --match "v*" + OUTPUT_VARIABLE DUSK_WC_DESCRIBE + OUTPUT_STRIP_TRAILING_WHITESPACE) + + # remove the git hash, then collapse a clean "-0" suffix only + string(REGEX REPLACE "-[^-]+(-dirty|)$" "\\1" DUSK_WC_DESCRIBE "${DUSK_WC_DESCRIBE}") + string(REGEX REPLACE "-0$" "" DUSK_WC_DESCRIBE "${DUSK_WC_DESCRIBE}") + + # defines DUSK_WC_BRANCH + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} rev-parse --abbrev-ref HEAD + OUTPUT_VARIABLE DUSK_WC_BRANCH + OUTPUT_STRIP_TRAILING_WHITESPACE) + # defines DUSK_WC_DATE + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} log -1 --format=%ad + OUTPUT_VARIABLE DUSK_WC_DATE + OUTPUT_STRIP_TRAILING_WHITESPACE) + else () + message(STATUS "Unable to find git, commit information will not be available") + endif () + + if (DUSK_WC_DESCRIBE MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)([-+].*)?$") + set(DUSK_SHORT_VERSION_STRING "${CMAKE_MATCH_1}.${CMAKE_MATCH_2}.${CMAKE_MATCH_3}") + set(_ver_major ${CMAKE_MATCH_1}) + set(_ver_minor ${CMAKE_MATCH_2}) + set(_ver_patch ${CMAKE_MATCH_3}) + set(DUSK_VERSION_TWEAK "0") + if (DUSK_WC_DESCRIBE MATCHES "^v[0-9]+\\.[0-9]+\\.[0-9]+-([0-9]+)(-dirty)?$") + set(DUSK_VERSION_TWEAK "${CMAKE_MATCH_1}") + elseif (DUSK_WC_DESCRIBE MATCHES "^v[0-9]+\\.[0-9]+\\.[0-9]+-[0-9A-Za-z.-]+-([0-9]+)(-dirty)?$") + set(DUSK_VERSION_TWEAK "${CMAKE_MATCH_1}") + endif () + set(DUSK_VERSION_STRING "${DUSK_SHORT_VERSION_STRING}.${DUSK_VERSION_TWEAK}") + if (DUSK_VERSION_TWEAK GREATER 999) + set(_tweak 999) + else () + set(_tweak ${DUSK_VERSION_TWEAK}) + endif () + # encoding: major*1e7 + minor*1e5 + patch*1e3 + tweak; collision-free for major<210, minor<100, patch<100, tweak<=999 + math(EXPR DUSK_VERSION_CODE + "${_ver_major} * 10000000 + ${_ver_minor} * 100000 + ${_ver_patch} * 1000 + ${_tweak}") + else () + set(DUSK_WC_DESCRIBE "UNKNOWN-VERSION") + set(DUSK_VERSION_STRING "0.0.0.0") + set(DUSK_SHORT_VERSION_STRING "0.0.0") + set(DUSK_VERSION_CODE "1") + endif () + + endif () + + # Add version information to CI environment variables + if (DEFINED ENV{GITHUB_ENV}) + file(APPEND "$ENV{GITHUB_ENV}" "DUSK_VERSION=${DUSK_WC_DESCRIBE}\n") + file(APPEND "$ENV{GITHUB_ENV}" "DUSK_VERSION_CODE=${DUSK_VERSION_CODE}\n") + endif () + message(STATUS "Dusklight version set to ${DUSK_WC_DESCRIBE}") +endmacro() + +# Sets PLATFORM_NAME and configures version.h into the caller's binary dir. +macro(configure_version_header) + if (CMAKE_SYSTEM_NAME STREQUAL Windows) + set(PLATFORM_NAME win32) + elseif (CMAKE_SYSTEM_NAME STREQUAL Darwin) + if (IOS) + set(PLATFORM_NAME ios) + elseif (TVOS) + set(PLATFORM_NAME tvos) + else () + set(PLATFORM_NAME macos) + endif () + else () + string(TOLOWER CMAKE_SYSTEM_NAME PLATFORM_NAME) + endif () + + configure_file(${_DUSK_VERSION_ROOT}/version.h.in ${CMAKE_CURRENT_BINARY_DIR}/version.h) +endmacro() diff --git a/cmake/GameABIConfig.cmake b/cmake/GameABIConfig.cmake new file mode 100644 index 0000000000..030f1181a0 --- /dev/null +++ b/cmake/GameABIConfig.cmake @@ -0,0 +1,27 @@ +# The game ABI surface shared by the main build and the mod SDK (sdk/CMakeLists.txt) +include_guard(GLOBAL) + +get_filename_component(_game_root "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE) + +# PARTIAL_DEBUG makes debug and release share one struct/vtable ABI so a mod binary loads into either +set(_game_compile_defs TARGET_PC=1 WIDESCREEN_SUPPORT=1 AVOID_UB=1 VERSION=0 MTX_USE_PS=1 PARTIAL_DEBUG=1) +if (ANDROID) + list(APPEND _game_compile_defs TARGET_ANDROID=1) +endif () + +set(_game_include_dirs + ${_game_root}/include + ${_game_root}/src + ${_game_root}/assets/GZ2E01 # TODO: make this dynamic if needed? + ${_game_root}/libs/JSystem/include + ${_game_root}/libs + ${_game_root}/extern/aurora/include/dolphin + ${_game_root}/extern/aurora/include + ${_game_root}/extern + ${CMAKE_CURRENT_BINARY_DIR} +) + +# Interface target for mods and sub-projects to inherit game headers/defines. +add_library(dusklight_game_headers INTERFACE) +target_include_directories(dusklight_game_headers INTERFACE ${_game_include_dirs}) +target_compile_definitions(dusklight_game_headers INTERFACE ${_game_compile_defs}) diff --git a/cmake/ModSDK.cmake b/cmake/ModSDK.cmake new file mode 100644 index 0000000000..e15e892a58 --- /dev/null +++ b/cmake/ModSDK.cmake @@ -0,0 +1,215 @@ +# add_mod( SOURCES ... MOD_JSON [RES_DIR ] [OUTPUT_DIR ] [BUNDLE]) +set(DUSK_MODS_OUTPUT_DIR "${CMAKE_BINARY_DIR}/mods" CACHE PATH "Directory to write mod packages into") + +function(_mod_lib_name out_var) + set(_arch "${CMAKE_SYSTEM_PROCESSOR}") + if (APPLE AND CMAKE_OSX_ARCHITECTURES) + list(LENGTH CMAKE_OSX_ARCHITECTURES _count) + if (_count GREATER 1) + message(FATAL_ERROR "add_mod: universal binaries are not supported") + endif () + set(_arch "${CMAKE_OSX_ARCHITECTURES}") + endif () + string(TOLOWER "${CMAKE_SYSTEM_NAME}" _platform) + string(TOLOWER "${_arch}" _arch) + if (_arch MATCHES "^(i[3-6]86|x86)$") + set(_arch "x86") + endif () + if (WIN32) + set(_ext ".dll") + elseif (APPLE) + set(_ext ".dylib") + else () + set(_ext ".so") + endif () + set(${out_var} "${_platform}-${_arch}${_ext}" PARENT_SCOPE) +endfunction() + +function(_mod_resolve_source_path out_var path) + if (IS_ABSOLUTE "${path}") + set(_path "${path}") + else () + set(_path "${CMAKE_CURRENT_SOURCE_DIR}/${path}") + endif () + set(${out_var} "${_path}" PARENT_SCOPE) +endfunction() + +function(_mod_collect_assets out_var dir) + if (NOT IS_DIRECTORY "${dir}") + message(FATAL_ERROR "add_mod: asset directory does not exist: ${dir}") + endif () + + file(GLOB_RECURSE _files CONFIGURE_DEPENDS LIST_DIRECTORIES false "${dir}/*") + set(${out_var} ${_files} PARENT_SCOPE) +endfunction() + +function(add_mod target_name) + cmake_parse_arguments(ARG "BUNDLE" "MOD_JSON;RES_DIR;OUTPUT_DIR" "SOURCES" ${ARGN}) + if (NOT ARG_MOD_JSON) + message(FATAL_ERROR "add_mod: MOD_JSON is required") + endif () + _mod_resolve_source_path(_mod_json "${ARG_MOD_JSON}") + if (NOT EXISTS "${_mod_json}") + message(FATAL_ERROR "add_mod: MOD_JSON does not exist: ${_mod_json}") + endif () + + add_library(${target_name} SHARED ${ARG_SOURCES}) + _mod_lib_name(_lib_name) + set_target_properties(${target_name} PROPERTIES + PREFIX "" + C_VISIBILITY_PRESET hidden + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN ON + WINDOWS_EXPORT_ALL_SYMBOLS OFF) + target_compile_features(${target_name} PRIVATE cxx_std_20) + target_link_libraries(${target_name} PRIVATE dusklight_game_headers) + + if (NOT TARGET dusklight) + # Apply global compile options for out-of-tree mod builds + if (CMAKE_SYSTEM_NAME STREQUAL Linux) + target_compile_options(${target_name} PRIVATE + -Wno-multichar -Wno-trigraphs -Wno-deprecated-declarations) + elseif (APPLE) + target_compile_options(${target_name} PRIVATE + -Wno-declaration-after-statement -Wno-non-pod-varargs) + elseif (MSVC) + target_compile_options(${target_name} PRIVATE + "$<$:/bigobj>" + "$<$:/utf-8>") + endif () + # Use signed char on ARM to match the original game (and x86) + string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" _mod_arch) + if (_mod_arch MATCHES "^(arm|aarch64)" AND CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "GNU") + target_compile_options(${target_name} PRIVATE -fsigned-char) + endif () + endif () + + set(_output_dir "${DUSK_MODS_OUTPUT_DIR}") + if (ARG_OUTPUT_DIR) + set(_output_dir "${ARG_OUTPUT_DIR}") + endif () + set(_stage "${CMAKE_CURRENT_BINARY_DIR}/${target_name}_stage") + set(_out "${_output_dir}/${target_name}.dusk") + + set(_zip_args "${_lib_name}" mod.json) + set(_package_deps "${_mod_json}") + set(_package_inputs "${_mod_json}") + set(_extra_cmds "") + if (ARG_RES_DIR) + _mod_resolve_source_path(_res_dir "${ARG_RES_DIR}") + _mod_collect_assets(_res_deps "${_res_dir}") + list(APPEND _package_deps ${_res_deps}) + list(APPEND _package_inputs "${_res_dir}" ${_res_deps}) + list(APPEND _zip_args res) + list(APPEND _extra_cmds COMMAND ${CMAKE_COMMAND} -E copy_directory + "${_res_dir}" "${_stage}/res") + endif () + + set(_bundle_cmds "") + if (ARG_BUNDLE AND TARGET dusklight) + file(READ "${_mod_json}" _mod_json_text) + string(JSON _mod_id GET "${_mod_json_text}" id) + set_property(GLOBAL APPEND PROPERTY DUSK_BUNDLED_MOD_TARGETS "${target_name}") + set_property(GLOBAL APPEND PROPERTY DUSK_BUNDLED_MOD_IDS "${_mod_id}") + set_property(GLOBAL APPEND PROPERTY DUSK_BUNDLED_MOD_STAGES "${_stage}") + set_property(GLOBAL APPEND PROPERTY DUSK_BUNDLED_MOD_PACKAGES "${_out}") + set_property(GLOBAL APPEND PROPERTY DUSK_BUNDLED_MOD_LIB_NAMES "${_lib_name}") + set(_bundle_cmds + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/bundled_mods" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_out}" "${CMAKE_BINARY_DIR}/bundled_mods/${target_name}.dusk") + endif () + + set(_package_target "${target_name}_package") + set(_package_inputs_file "${CMAKE_CURRENT_BINARY_DIR}/${target_name}_package_inputs.txt") + list(SORT _package_inputs) + set(_package_inputs_text "") + foreach (_package_input IN LISTS _package_inputs) + string(APPEND _package_inputs_text "${_package_input}\n") + endforeach () + file(GENERATE OUTPUT "${_package_inputs_file}" CONTENT "${_package_inputs_text}") + add_custom_command(OUTPUT "${_out}" + COMMAND ${CMAKE_COMMAND} -E rm -rf "${_stage}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${_stage}" "${_output_dir}" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "$" "${_stage}/${_lib_name}" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_mod_json}" "${_stage}/mod.json" + ${_extra_cmds} + COMMAND ${CMAKE_COMMAND} -E chdir "${_stage}" ${CMAKE_COMMAND} -E tar cvf "${_out}" --format=zip ${_zip_args} + ${_bundle_cmds} + DEPENDS ${target_name} ${_package_deps} "${_package_inputs_file}" + COMMENT "Packaging ${target_name} -> ${_out}" + COMMAND_EXPAND_LISTS + VERBATIM + ) + add_custom_target(${_package_target} ALL DEPENDS "${_out}") + if (TARGET dusklight_mods) + add_dependencies(dusklight_mods ${_package_target}) + endif () +endfunction() + +# Install rules for BUNDLE mods. +# - Windows: the .dusk archives into /mods (the loader extracts native libs to the +# user cache). +# - Linux: pre-extracted stage dirs into /mods so native libs dlopen in place from +# read-only installs. +# - macOS: pre-extracted stage dirs into the installed app's Contents/Resources/mods, dylibs +# ad-hoc signed in place, then the whole bundle re-signed. +# - iOS/tvOS: assets into /mods/ and the dylib into Frameworks/.dylib. +# - Android: nothing here; gradle packs ${CMAKE_BINARY_DIR}/bundled_mods into APK assets. +function(install_bundled_mods) + get_property(_targets GLOBAL PROPERTY DUSK_BUNDLED_MOD_TARGETS) + if (NOT _targets OR ANDROID) + return () + endif () + get_property(_ids GLOBAL PROPERTY DUSK_BUNDLED_MOD_IDS) + get_property(_stages GLOBAL PROPERTY DUSK_BUNDLED_MOD_STAGES) + get_property(_lib_names GLOBAL PROPERTY DUSK_BUNDLED_MOD_LIB_NAMES) + list(LENGTH _targets _count) + math(EXPR _last "${_count} - 1") + + if (APPLE) + get_target_property(_app_name dusklight OUTPUT_NAME) + if (NOT _app_name) + set(_app_name dusklight) + endif () + set(_bundle_dir "${CMAKE_INSTALL_PREFIX}/${_app_name}.app") + if (IOS OR TVOS) + foreach (_i RANGE ${_last}) + list(GET _targets ${_i} _target) + list(GET _ids ${_i} _id) + list(GET _stages ${_i} _stage) + list(GET _lib_names ${_i} _lib_name) + install(DIRECTORY "${_stage}/" DESTINATION "${_bundle_dir}/mods/${_id}" + PATTERN "${_lib_name}" EXCLUDE) + install(PROGRAMS "$" + DESTINATION "${_bundle_dir}/Frameworks" RENAME "${_id}.dylib") + endforeach () + else () + foreach (_i RANGE ${_last}) + list(GET _ids ${_i} _id) + list(GET _stages ${_i} _stage) + list(GET _lib_names ${_i} _lib_name) + install(DIRECTORY "${_stage}/" DESTINATION "${_bundle_dir}/Contents/Resources/mods/${_id}") + install(CODE "execute_process(COMMAND /usr/bin/codesign --force --sign - \"${_bundle_dir}/Contents/Resources/mods/${_id}/${_lib_name}\" COMMAND_ERROR_IS_FATAL ANY)") + endforeach () + install(CODE "execute_process(COMMAND /usr/bin/codesign --force --sign - --entitlements \"${DUSK_ENTITLEMENTS}\" \"${_bundle_dir}\" COMMAND_ERROR_IS_FATAL ANY)") + endif () + return () + endif () + + if (DUSK_PACKAGE_INSTALL) + set(_mods_dest "${CMAKE_INSTALL_DATAROOTDIR}/dusklight/mods") + else () + set(_mods_dest "${CMAKE_INSTALL_PREFIX}/mods") + endif () + if (WIN32) + foreach (_target IN LISTS _targets) + install(FILES "${CMAKE_BINARY_DIR}/bundled_mods/${_target}.dusk" DESTINATION "${_mods_dest}") + endforeach () + else () + foreach (_i RANGE ${_last}) + list(GET _ids ${_i} _id) + list(GET _stages ${_i} _stage) + install(DIRECTORY "${_stage}/" DESTINATION "${_mods_dest}/${_id}") + endforeach () + endif () +endfunction() diff --git a/docs/modding.md b/docs/modding.md new file mode 100644 index 0000000000..12c7881ade --- /dev/null +++ b/docs/modding.md @@ -0,0 +1,259 @@ +# Dusklight Mod API + +Mods are distributed as `.dusk` files: zip archives containing a `mod.json` manifest and, optionally, compiled code libraries and resources. + +Everything a mod does goes through **services**: small, versioned C APIs. Dusklight provides built-in services, and mods can define their own to talk to each other. + +## Table of Contents + +1. [Getting Started](#getting-started) +2. [mod.json](#modjson) +3. [Anatomy of a Code Mod](#anatomy-of-a-code-mod) +4. [Services](#services) +5. [Built-in Services](#built-in-services) +6. [Runtime Lifecycle](#runtime-lifecycle) +7. [Error Handling](#error-handling) +8. [Advanced: Exporting Services](#advanced-exporting-services) + +--- + +## Getting Started + +Fork the [mod template](../tools/mod_template/), a self-contained CMake project that uses the Dusklight mod SDK: + +``` +my_mod/ +├── CMakeLists.txt +├── mod.json +├── src/mod.cpp +└── res/ (optional bundled resources) +``` + +**CMakeLists.txt:** + +```cmake +cmake_minimum_required(VERSION 3.25) +project(my_mod CXX) + +set(DUSKLIGHT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/dusklight" CACHE PATH "Path to dusklight source root") +add_subdirectory("${DUSKLIGHT_DIR}/sdk" dusklight-sdk EXCLUDE_FROM_ALL) + +add_mod(my_mod + SOURCES src/mod.cpp + MOD_JSON mod.json + RES_DIR res # optional +) +``` + +Building produces `my_mod.dusk` in `build//mods/` (configurable via the `DUSK_MODS_OUTPUT_DIR` cache variable). Copy it into the game's mods folder and launch: + +- Windows: `%APPDATA%\TwilitRealm\Dusklight\mods` +- Linux: `~/.local/share/TwilitRealm/Dusklight/mods` +- macOS: `~/Library/Application Support/TwilitRealm/Dusklight/mods` + +You can also pass `--mods ` on the command line, which is handy during development. Mods will load from there instead of the user directory above. + +--- + +## mod.json + +```json +{ + "id": "com.example.my_mod", + "name": "My Mod", + "version": "1.0.0", + "author": "Your Name", + "description": "A short description shown in the mod manager.", + "icon": "res/my_icon.png", + "banner": "res/my_banner.png" +} +``` + +`id` is required: a unique, stable identifier (reverse-DNS style; periods, underscores, and alphanumerics). Everything else is optional but recommended. + +`icon` and `banner` are bundle-relative paths to PNG images for the in-game mod manager: the square icon (e.g. 512x512), the banner (roughly 3.5:1). +Both keys are optional; if omitted, `res/icon.png` and `res/banner.png` are used automatically when present. + +--- + +## Anatomy of a Code Mod + +```cpp +#include "mods/service.hpp" +#include "mods/svc/log.h" + +DEFINE_MOD(); // once, in exactly one translation unit +IMPORT_SERVICE(LogService, svc_log); // resolved by the loader before mod_initialize + +extern "C" { + +MOD_EXPORT ModResult mod_initialize(ModError* error) { + svc_log->info(mod_ctx, "hello from my_mod"); + return MOD_OK; +} + +MOD_EXPORT ModResult mod_update(ModError* error) { // called every frame + return MOD_OK; +} + +MOD_EXPORT ModResult mod_shutdown(ModError* error) { + return MOD_OK; +} +} +``` + +All three lifecycle exports are required. `mod_ctx` is your mod's identity token, set by the loader before `mod_initialize` runs. Pass it as the first argument to every service call. + +--- + +## Services + +A service is a struct of C function pointers with a version header. You declare what you use at file scope, and the loader resolves it before your mod initializes: + +```cpp +IMPORT_SERVICE(LogService, svc_log); // required, any minor version +IMPORT_SERVICE_VERSION(LogService, svc_log, 2); // required, minor version >= 2 +IMPORT_OPTIONAL_SERVICE(SomeService, svc_maybe); // may be null +``` + +The rules (see `include/mods/api.h` for the full contract): + +- **A required import is guaranteed valid.** If the service is missing or too old, the mod fails to load with a clear error. No need to null check at call sites. +- **Anything at or below the minor version you imported can be called unconditionally.** +- Optional imports may be null; check once in `mod_initialize`. +- Fields newer than your imported minor must be gated behind `SERVICE_HAS(service, ServiceType, field)` plus a null check. + +Service versions follow one rule: a **major** bump is a breaking change (treated as a different service entirely), a **minor** bump only appends functions. + +--- + +## Built-in Services + +### LogService (`mods/svc/log.h`) + +```cpp +IMPORT_SERVICE(LogService, svc_log); + +svc_log->info(mod_ctx, "spawned the thing"); +svc_log->warn(mod_ctx, "that looks wrong"); +svc_log->error(mod_ctx, "very bad"); +svc_log->write(mod_ctx, LOG_LEVEL_DEBUG, "verbose details"); +``` + +Messages appear in the console prefixed with your mod ID. Messages are plain strings: use `snprintf` or `fmt::format` for formatting. + +### HostService (`mods/svc/host.h`) + +Mod metadata and runtime interaction with the loader: + +```cpp +IMPORT_SERVICE(HostService, svc_host); + +const char* id = svc_host->mod_id(mod_ctx); +const char* dir = svc_host->mod_dir(mod_ctx); // writable per-mod directory +svc_host->fail(mod_ctx, MOD_ERROR, "something unrecoverable happened"); // disables the mod +``` + +`get_service`/`publish_service` provide dynamic service lookup; see [Advanced](#advanced-exporting-services). + +--- + +## Runtime Lifecycle + +Mods can be disabled, re-enabled, and reloaded at runtime without restarting the game (the enabled state persists as the `mod..enabled` config var). Write your mod assuming this happens: + +- **Disable** calls `mod_shutdown`, removes your services, and unloads your library. +- **Enable** and **Reload** load a *fresh copy* of your library, imports are re-resolved, and `mod_initialize` runs again. You never see a second `mod_initialize` on the same image, so just make `mod_shutdown` release anything the loader doesn't manage for you (threads, files, game-side state you mutated). +- **Reload** additionally re-reads the `.dusk` from disk, picking up a rebuilt library and changed assets. This is the fast iteration loop during development: rebuild, click Reload. + +**Dependents restart too.** Disabling or reloading a mod that exports services shuts down the mods importing them first (in reverse dependency order) and brings them back afterward. A mod whose *required* provider is disabled stays suspended and resumes automatically when the provider returns. Mods with an *optional* import of a disabled provider restart with that import null. + +--- + +## Error Handling + +Service calls report failure through `ModResult` return values (`MOD_OK`, `MOD_UNAVAILABLE`, `MOD_INVALID_ARGUMENT`, ...). Lifecycle exports additionally receive a `ModError*`: fill it (e.g. with `dusk::mods::set_error(error, code, "message")`) and return the code, and the loader disables the mod and shows the message to the user. + +```cpp +MOD_EXPORT ModResult mod_initialize(ModError* error) { + if (!load_my_data()) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to load data"); + } + return MOD_OK; +} +``` + +Throwing exceptions out of lifecycle functions also disables the mod (they are caught by the loader), but prefer explicit results. + +--- + +## Advanced: Exporting Services + +Mods can provide services to other mods. Define the interface in a header both mods share: + +```cpp +// my_mod_api.h +#include "mods/api.h" + +#define MY_MOD_SERVICE_ID "com.example.my_mod.api" +#define MY_MOD_SERVICE_MAJOR 1u +#define MY_MOD_SERVICE_MINOR 0u + +typedef struct MyModService { + ServiceHeader header; + ModResult (*do_thing)(ModContext* ctx, int value); +} MyModService; + +#ifdef __cplusplus +#include "mods/service.hpp" +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = MY_MOD_SERVICE_ID; + static constexpr uint16_t major_version = MY_MOD_SERVICE_MAJOR; +}; +#endif +``` + +**Provider:** + +```cpp +ModResult do_thing(ModContext* ctx, int value) { ... } + +constexpr MyModService g_service{ + .header = SERVICE_HEADER(MyModService, MY_MOD_SERVICE_MAJOR, MY_MOD_SERVICE_MINOR), + .do_thing = do_thing, +}; +EXPORT_SERVICE(g_service); +``` + +**Consumer:** + +```cpp +IMPORT_SERVICE(MyModService, svc_my_mod); +// or IMPORT_OPTIONAL_SERVICE if the dependency is optional + +svc_my_mod->do_thing(mod_ctx, 42); +``` + +The loader registers all exports before resolving any imports, so declaration order between mods doesn't matter. Note that the `ctx` a provider receives identifies the *calling* mod. + +### Dependencies between mods + +Service imports are also dependency declarations: the loader initializes mods in dependency order, so by the time your `mod_initialize` runs, every mod you import services from (required *or* optional) has already finished its own `mod_initialize`. This includes deferred services: a service the provider publishes during its initialization resolves into your import slot just like a static export. + +Consequences of that contract: + +- If a provider fails to load, every mod that *requires* one of its services is disabled too, with an error naming the provider. Optional imports of a failed provider simply resolve to `NULL`. +- Mods whose **required** imports form a cycle all fail to load. If the cycle runs through an **optional** import, the loader breaks it there: the optional import still resolves, but its provider may not be initialized yet when you run. +- `svc_host->get_service(...)` is outside this system. It sees whatever is published at call time and gives no initialization-order guarantee, which also makes it the escape hatch for intentionally cyclic designs. + +Mods shut down in reverse initialization order, so services you import remain safe to call from `mod_shutdown`. + +Rules for providers: + +- Service IDs are global and use reverse-DNS names (e.g. `com.mydomain.mod.service`) +- Every function pointer covered by your declared minor version must be populated. +- Within a major version, only append fields; never reorder, remove, or repurpose them. Breaking changes require a major bump (which is, in effect, a new service). +- Only one provider per `(id, major)` pair may be registered; duplicates are load errors. + +For services whose construction can't happen at static-init time, declare the export with `EXPORT_DEFERRED_SERVICE(...)` and publish the pointer later via `svc_host->publish_service(...)`. Consumers can fetch services dynamically with `svc_host->get_service(...)`; prefer manifest imports whenever possible, since they give the loader dependency information and fail fast with good errors. diff --git a/files.cmake b/files.cmake index f891024e8a..fcf603d360 100644 --- a/files.cmake +++ b/files.cmake @@ -1541,6 +1541,19 @@ set(DUSK_FILES src/dusk/OSReport.cpp src/dusk/OSThread.cpp src/dusk/OSMutex.cpp + src/dusk/mods/loader/bundle_disk.cpp + src/dusk/mods/loader/bundle_zip.cpp + src/dusk/mods/loader/context.cpp + src/dusk/mods/loader/depgraph.cpp + src/dusk/mods/loader/depgraph.hpp + src/dusk/mods/loader/loader.cpp + src/dusk/mods/loader/loader.hpp + src/dusk/mods/loader/native_module.cpp + src/dusk/mods/loader/native_module.hpp + src/dusk/mods/svc/host.cpp + src/dusk/mods/svc/log.cpp + src/dusk/mods/svc/registry.cpp + src/dusk/mods/svc/registry.hpp src/dusk/discord.cpp src/dusk/discord.hpp src/dusk/discord_presence.cpp diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp new file mode 100644 index 0000000000..2a67e209f7 --- /dev/null +++ b/include/dusk/mod_loader.hpp @@ -0,0 +1,245 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "dusk/config.hpp" +#include "dusk/config_var.hpp" +#include "mods/api.h" + +namespace dusk::mods { +struct LoadedMod; +class ModBundle; +} // namespace dusk::mods + +struct ModContext { + dusk::mods::LoadedMod* mod = nullptr; +}; + +namespace dusk::mods::loader { +class NativeModule; +} // namespace dusk::mods::loader + +namespace dusk::mods { + +struct ModDependencyEdge { + LoadedMod* mod = nullptr; + bool required = false; +}; + +struct ModManifestInfo { + struct Import { + std::string id; + uint16_t major = 0; + bool required = false; + bool operator==(const Import&) const = default; + }; + struct Export { + std::string id; + uint16_t major = 0; + bool operator==(const Export&) const = default; + }; + std::vector imports; + std::vector exports; + bool operator==(const ModManifestInfo&) const = default; +}; + +struct ModMetadata { + std::string id; + std::string name; + std::string version; + std::string author; + std::string description; + std::string iconPath; + std::string bannerPath; +}; + +struct ModSearchDir { + std::filesystem::path path; + // Directory bundles dlopen their native lib in place instead of extracting it to the cache. + // Required where extracted code cannot run (iOS), desirable for signed/read-only installs. + bool inPlaceNative = false; + // Native library location for platforms that restrict placement (e.g. iOS/tvOS Frameworks/) + std::filesystem::path nativeLibDir; +}; + +struct NativeMod { + std::unique_ptr handle; + const ModManifest* manifest = nullptr; + ModContext** contextSymbol = nullptr; + + ModInitializeFn fn_initialize = nullptr; + ModUpdateFn fn_update = nullptr; + ModShutdownFn fn_shutdown = nullptr; +}; + +enum class NativeModStatus : u8 { + /** + * Mod does not have native code included. + */ + None, + + /** + * Native code mod loaded successfully. + * + * Note that this only indicates load status of the native library. If the native lib throws in + * its init function, it will still be disabled! + */ + Loaded, + + /** + * This build was compiled without native mod support! + */ + BuildDisabled, + + /** + * Mod ships native libraries, but none matches this build's platform and architecture. + */ + ModMissingPlatform, + + /** + * Mod is built for a different ABI version than this build of the game. + */ + ApiVersionMismatch, + + /** + * Mod is missing a required native API export. + */ + MissingExport, + + /** + * Unknown error loading the native mod. + */ + Unknown, +}; + +struct LoadedMod { + ModMetadata metadata; + std::string modPath; + std::string dir; + + uint32_t searchDirIndex = 0; + // Native lib is dlopen'd in place and stays resident for the session. Reload is unsupported. + bool inPlace = false; + + std::unique_ptr > cvarIsEnabled; + config::Subscription enabledSubscription = 0; + + bool active = false; + bool loadFailed = false; + std::string failureReason; + + // mod_initialize succeeded; a mod_shutdown is owed on deactivation. + bool initialized = false; + // Static service exports are currently present in the registry. + bool servicesRegistered = false; + // Lifecycle state last applied by the loader; diffed against cvarIsEnabled to pick up + // runtime enable/disable requests. + bool enabledApplied = false; + // Deactivated because a provider it imports from was disabled, not by its own cvar. + bool suspendedByProvider = false; + // Bumped per native lib extraction so every dlopen sees a fresh path (and thus a fresh + // image with fresh statics; a previous dlclose may not fully unmap). Also bumped by + // asset-only reloads, so it doubles as a generation for anything caching per-mod content. + uint32_t cacheGeneration = 0; + // Currently extracted native library, empty if none. + std::string nativePath; + + NativeModStatus nativeStatus = NativeModStatus::None; + std::unique_ptr native; + std::unique_ptr context; + + // Shared so in-flight readers keep their bundle alive across disable/reload. + std::shared_ptr bundle; + + ModManifestInfo manifestInfo; + + // Mods this mod imports services from, and mods importing services from this mod. + std::vector dependencies; + std::vector dependents; +}; + +class ModLoader { +public: + static ModLoader& instance(); + + void set_search_dirs(std::vector dirs) { m_searchDirs = std::move(dirs); } + void set_cache_dir(std::filesystem::path dir) { m_cacheDir = std::move(dir); } + void init(); + void tick(); + void shutdown(); + + void request_enable(std::string_view id); + void request_disable(std::string_view id); + void request_reload(std::string_view id); + void notify_mod_failure(LoadedMod& mod); + + [[nodiscard]] auto mods() const { + return m_mods | std::views::transform([](const auto& m) -> LoadedMod& { return *m; }); + } + + [[nodiscard]] auto active_mods() const { + return mods() | std::views::filter([](const auto& m) { return m.active; }); + } + +private: + enum class RequestKind : u8 { Enable, Disable, Reload }; + struct Request { + std::string modId; + RequestKind kind; + }; + // ModLoader::tick runs inside fapGm_Execute, so code from an unloading mod can still be + // live on the stack (its frame unwinds after the tick). dlclose is therefore deferred to + // the next tick, by which point every per-frame entry into the mod should have returned. + struct RetiredNative { + std::unique_ptr native; + std::string path; + }; + + std::vector > m_mods; + std::vector m_searchDirs; + std::filesystem::path m_cacheDir; + std::vector m_pendingRequests; + std::vector m_retiredNatives; + bool m_initialized = false; + bool m_startupComplete = false; + + void try_load_mod(const std::filesystem::path& modPath, bool fromDir, uint32_t searchDirIndex); + void load_native(LoadedMod& mod, const std::string& dllEntry); + // Resolved / if it exists on disk, empty otherwise. + [[nodiscard]] std::filesystem::path external_native_lib_path(const LoadedMod& mod) const; + void unload_native(LoadedMod& mod); + // Registers exports (if needed), resolves imports and runs mod_initialize. + // Returns whether the mod ended up active; failures go through fail_mod. + bool activate_mod(LoadedMod& mod); + // Runs mod_shutdown (if needed), removes the mod's services, and unloads the native lib. + // Must only run with no mod code on the stack (startup, shutdown, or top of tick). + void deactivate_mod(LoadedMod& mod); + void init_services(); + bool register_static_service_exports(LoadedMod& mod); + bool resolve_service_imports(LoadedMod& mod); + [[nodiscard]] std::string describe_missing_import( + const char* serviceId, uint16_t majorVersion, uint16_t minMinorVersion) const; + void clear_services(); + void fail_mod(LoadedMod& mod, ModResult code, std::string_view message); + + LoadedMod* find_mod(std::string_view id); + void drain_retired_natives(); + void apply_pending_requests(); + void on_enabled_changed(LoadedMod& mod); + // Deactivates `target` (if needed) and its transitive dependents, optionally re-reads the + // bundle from disk, then reactivates whatever the current cvar/provider state allows. + void apply_lifecycle_change(LoadedMod& target, bool reload); + // `target` plus transitive active/suspended dependents, in m_mods (init) order. + std::vector collect_lifecycle_set(LoadedMod& target); + bool reload_bundle(LoadedMod& mod); + bool ensure_native_loaded(LoadedMod& mod); +}; + +using ModIndex = std::ranges::range_difference_t().mods())>; + +} // namespace dusk::mods diff --git a/include/mods/api.h b/include/mods/api.h new file mode 100644 index 0000000000..85e2f76545 --- /dev/null +++ b/include/mods/api.h @@ -0,0 +1,152 @@ +#pragma once + +#ifdef __cplusplus +#include +#include +extern "C" { +#else +#include +#include +#include +#include +#endif + +#if defined(_WIN32) +#define MOD_EXPORT __declspec(dllexport) +#else +#define MOD_EXPORT __attribute__((visibility("default"))) +#endif + +#ifdef __cplusplus +#define MOD_EXTERN_C extern "C" +#else +#define MOD_EXTERN_C +#endif + +#define MOD_ABI_VERSION 5u +#define MOD_ERROR_MESSAGE_SIZE 512u + +typedef struct ModContext ModContext; + +typedef enum ModResult { + MOD_OK = 0, + MOD_ERROR = 1, + MOD_UNAVAILABLE = 2, + MOD_UNSUPPORTED = 3, + MOD_CONFLICT = 4, + MOD_INVALID_ARGUMENT = 5, +} ModResult; + +static_assert(sizeof(ModResult) == 4, "mod SDK enums must be int-sized; do not build mods with -fshort-enums"); + +typedef struct ModError { + uint32_t struct_size; + ModResult code; + char message[MOD_ERROR_MESSAGE_SIZE]; +} ModError; + +#define MOD_ERROR_INIT {sizeof(ModError), MOD_OK, {0}} + +/* + * Opaque per-mod context, populated by the host before mod_initialize is called. + * Pass it as the first argument to every service call; it identifies the calling + * mod for attribution (logging, resource lookup, hook ownership, etc.). + */ +MOD_EXPORT extern ModContext* mod_ctx; + +/* + * Service versioning contract: + * + * A service is a struct of function pointers, beginning with a ServiceHeader. + * Compatibility is tracked with a major/minor version pair: + * + * - A major version bump is a breaking change. Different majors are distinct + * services; the registry never matches an import against a different major. + * - A minor version bump may only append fields to the end of the struct. + * Existing fields must keep their offsets and semantics. + * + * Providers: exporting minor N means every function pointer introduced at or + * below N is populated (non-NULL). struct_size reflects the compiled struct. + * + * Importers: importing with min_minor_version N guarantees (enforced at load + * time) that the resolved service is at least minor N, so any field introduced + * at or below N may be used unconditionally, with no availability checks. + * Fields newer than the declared min_minor_version must be gated behind + * SERVICE_HAS plus a NULL check on the pointer itself. + * + * Load ordering: a manifest import of another mod's service (required or + * optional) guarantees that the provider's mod_initialize completed before the + * importer's runs, and deferred services published during the provider's + * initialization resolve into import slots just like static exports. If a + * provider fails to load, mods that required its services fail in turn. Mods + * whose required imports form a cycle all fail to load; a cycle involving an + * optional import is broken by dropping the ordering guarantee (not the + * resolution) of that optional import. Dynamic lookups via + * HostService::get_service carry no ordering guarantee: they see whatever has + * been published at call time. + */ +typedef struct ServiceHeader { + uint32_t struct_size; + uint16_t major_version; + uint16_t minor_version; +} ServiceHeader; + +#define SERVICE_HEADER(service_type, major, minor) {sizeof(service_type), (major), (minor)} + +#define SERVICE_HAS(service, service_type, field) \ + ((service) != NULL && \ + (service)->header.struct_size >= \ + (uint32_t)(offsetof(service_type, field) + sizeof(((service_type*)0)->field))) + +typedef enum ServiceImportFlags { + SERVICE_IMPORT_REQUIRED = 0u, + SERVICE_IMPORT_OPTIONAL = 1u << 0u, +} ServiceImportFlags; + +typedef enum ServiceExportFlags { + SERVICE_EXPORT_STATIC = 0u, + SERVICE_EXPORT_DEFERRED = 1u << 0u, +} ServiceExportFlags; + +typedef struct ServiceImport { + uint32_t struct_size; + const char* service_id; + uint16_t major_version; + uint16_t min_minor_version; + uint32_t flags; + void* slot; +} ServiceImport; + +typedef struct ServiceExport { + uint32_t struct_size; + const char* service_id; + uint16_t major_version; + uint16_t minor_version; + uint32_t flags; + const void* service; +} ServiceExport; + +typedef struct ModManifest { + uint32_t struct_size; + uint32_t abi_version; + const ServiceImport* imports; + size_t import_count; + const ServiceExport* exports; + size_t export_count; +} ModManifest; + +typedef const ModManifest* (*ModGetManifestFn)(void); + +typedef ModResult (*ModInitializeFn)(ModError* out_error); +typedef ModResult (*ModUpdateFn)(ModError* out_error); +typedef ModResult (*ModShutdownFn)(ModError* out_error); + +MOD_EXPORT const ModManifest* mod_get_manifest(void); + +MOD_EXPORT ModResult mod_initialize(ModError* out_error); +MOD_EXPORT ModResult mod_update(ModError* out_error); +MOD_EXPORT ModResult mod_shutdown(ModError* out_error); + +#ifdef __cplusplus +} +#endif diff --git a/include/mods/service.hpp b/include/mods/service.hpp new file mode 100644 index 0000000000..d79b53f24d --- /dev/null +++ b/include/mods/service.hpp @@ -0,0 +1,138 @@ +#pragma once + +#include "mods/api.h" + +#include +#include +#include +#include + +namespace dusk::mods { + +template +struct ServiceTraits; + +namespace detail { + +inline std::vector& imports() { + static std::vector entries; + return entries; +} + +inline std::vector& exports() { + static std::vector entries; + return entries; +} + +inline int register_import(ServiceImport entry) { + imports().push_back(entry); + return 0; +} + +inline int register_export(ServiceExport entry) { + exports().push_back(entry); + return 0; +} + +inline const ModManifest* manifest() { + static ModManifest manifest{ + sizeof(ModManifest), + MOD_ABI_VERSION, + nullptr, + 0, + nullptr, + 0, + }; + + auto& importEntries = imports(); + auto& exportEntries = exports(); + manifest.imports = importEntries.data(); + manifest.import_count = importEntries.size(); + manifest.exports = exportEntries.data(); + manifest.export_count = exportEntries.size(); + return &manifest; +} + +} // namespace detail + +inline ModResult set_error(ModError* outError, ModResult code, const char* message) { + if (outError != nullptr && outError->struct_size >= sizeof(ModError)) { + outError->code = code; + outError->message[0] = '\0'; + if (message != nullptr) { + std::snprintf(outError->message, sizeof(outError->message), "%s", message); + } + } + return code; +} + +} // namespace dusk::mods + +#define DEFINE_MOD() \ + extern "C" { \ + MOD_EXPORT ModContext* mod_ctx = nullptr; \ + MOD_EXPORT const ModManifest* mod_get_manifest(void) { \ + return ::dusk::mods::detail::manifest(); \ + } \ + } + +// Declares `static const service_type* variable`, filled in by the host before mod_initialize. +// Required imports are guaranteed non-null (the mod fails to load otherwise); optional imports +// must be checked against nullptr before use. +#define IMPORT_SERVICE_EX( \ + service_type, variable, service_id_value, major_value, min_minor_value, flags_value) \ + static const service_type* variable = nullptr; \ + [[maybe_unused]] static const int mod_import_registration_##variable = \ + ::dusk::mods::detail::register_import(ServiceImport{ \ + sizeof(ServiceImport), \ + (service_id_value), \ + static_cast(major_value), \ + static_cast(min_minor_value), \ + static_cast(flags_value), \ + &(variable), \ + }) + +#define IMPORT_SERVICE_VERSION(service_type, variable, min_minor_value) \ + IMPORT_SERVICE_EX(service_type, variable, ::dusk::mods::ServiceTraits::id, \ + ::dusk::mods::ServiceTraits::major_version, min_minor_value, \ + SERVICE_IMPORT_REQUIRED) + +#define IMPORT_SERVICE(service_type, variable) IMPORT_SERVICE_VERSION(service_type, variable, 0) + +#define IMPORT_OPTIONAL_SERVICE_VERSION(service_type, variable, min_minor_value) \ + IMPORT_SERVICE_EX(service_type, variable, ::dusk::mods::ServiceTraits::id, \ + ::dusk::mods::ServiceTraits::major_version, min_minor_value, \ + SERVICE_IMPORT_OPTIONAL) + +#define IMPORT_OPTIONAL_SERVICE(service_type, variable) \ + IMPORT_OPTIONAL_SERVICE_VERSION(service_type, variable, 0) + +#define EXPORT_SERVICE_AS(instance, service_id_value) \ + namespace { \ + const int mod_export_registration_##instance = \ + ::dusk::mods::detail::register_export(ServiceExport{ \ + sizeof(ServiceExport), \ + (service_id_value), \ + (instance).header.major_version, \ + (instance).header.minor_version, \ + SERVICE_EXPORT_STATIC, \ + &(instance), \ + }); \ + } + +#define EXPORT_SERVICE(instance) \ + EXPORT_SERVICE_AS( \ + instance, ::dusk::mods::ServiceTraits >::id) + +#define EXPORT_DEFERRED_SERVICE(token, service_id_value, major_value, minor_value) \ + namespace { \ + const int mod_deferred_export_registration_##token = \ + ::dusk::mods::detail::register_export(ServiceExport{ \ + sizeof(ServiceExport), \ + (service_id_value), \ + static_cast(major_value), \ + static_cast(minor_value), \ + SERVICE_EXPORT_DEFERRED, \ + nullptr, \ + }); \ + } diff --git a/include/mods/svc/host.h b/include/mods/svc/host.h new file mode 100644 index 0000000000..6f5844a88f --- /dev/null +++ b/include/mods/svc/host.h @@ -0,0 +1,63 @@ +#pragma once + +#include "mods/api.h" + +/* + * The host service: the calling mod's identity and its runtime interface to the loader. + * Always available; every other service can be reached from it. + */ + +#define HOST_SERVICE_ID "dev.twilitrealm.dusklight.host" +#define HOST_SERVICE_MAJOR 1u +#define HOST_SERVICE_MINOR 0u + +typedef struct HostService { + ServiceHeader header; + + /* + * Look up a service by id at call time. Unlike a manifest import, this sees whatever is + * currently published and carries no initialization-order guarantee (see mods/api.h). + * MOD_UNAVAILABLE if no matching service is published; *out_service is null on failure. + */ + ModResult (*get_service)(ModContext* ctx, const char* service_id, uint16_t major_version, + uint16_t min_minor_version, const void** out_service); + + /* + * Publish a service the calling mod declared as a deferred export in its manifest. + * Must happen during mod_initialize so importers can resolve it; `service` must stay + * valid until the mod shuts down. + */ + ModResult (*publish_service)( + ModContext* ctx, const char* service_id, uint16_t major_version, const void* service); + + /* + * Report an unrecoverable failure. The calling mod's services stop resolving immediately + * and the loader fully disables it at the next safe point; `message` is shown to the user. + * Safe to call from any mod callback. + */ + void (*fail)(ModContext* ctx, ModResult code, const char* message); + + /* + * The calling mod's manifest metadata. Returned strings remain valid while the mod is + * loaded. + */ + const char* (*mod_id)(ModContext* ctx); + const char* (*mod_name)(ModContext* ctx); + const char* (*mod_version)(ModContext* ctx); + + /* + * A writable scratch directory reserved for the calling mod. Contents survive disable + * and reload within a session, but the directory is wiped at game startup. + */ + const char* (*mod_dir)(ModContext* ctx); +} HostService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = HOST_SERVICE_ID; + static constexpr uint16_t major_version = HOST_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/log.h b/include/mods/svc/log.h new file mode 100644 index 0000000000..bad94f9503 --- /dev/null +++ b/include/mods/svc/log.h @@ -0,0 +1,47 @@ +#pragma once + +#include "mods/api.h" + +/* + * Logging into the game's console and log files. Messages are attributed to the calling mod + * (prefixed with its ID). + */ + +#define LOG_SERVICE_ID "dev.twilitrealm.dusklight.log" +#define LOG_SERVICE_MAJOR 1u +#define LOG_SERVICE_MINOR 0u + +typedef enum LogLevel { + LOG_LEVEL_TRACE = 0, + LOG_LEVEL_DEBUG = 1, + LOG_LEVEL_INFO = 2, + LOG_LEVEL_WARN = 3, + LOG_LEVEL_ERROR = 4, +} LogLevel; + +typedef struct LogService { + ServiceHeader header; + + /* + * Write a log message at the given level. + * `message` is a plain UTF-8 string and is copied before returning. + */ + void (*write)(ModContext* ctx, LogLevel level, const char* message); + + /* Per-level shorthands for write. */ + void (*trace)(ModContext* ctx, const char* message); + void (*debug)(ModContext* ctx, const char* message); + void (*info)(ModContext* ctx, const char* message); + void (*warn)(ModContext* ctx, const char* message); + void (*error)(ModContext* ctx, const char* message); +} LogService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = LOG_SERVICE_ID; + static constexpr uint16_t major_version = LOG_SERVICE_MAJOR; +}; +#endif diff --git a/platforms/macos/Dusklight.entitlements b/platforms/macos/Dusklight.entitlements new file mode 100644 index 0000000000..123d12a53e --- /dev/null +++ b/platforms/macos/Dusklight.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.disable-library-validation + + + diff --git a/sdk/CMakeLists.txt b/sdk/CMakeLists.txt new file mode 100644 index 0000000000..63953e78bf --- /dev/null +++ b/sdk/CMakeLists.txt @@ -0,0 +1,33 @@ +# Dusklight Mod SDK entry point +# +# Provides game/service headers, compile definitions and version.h without +# configuring the full game tree. +# +# Usage (from a mod project): +# add_subdirectory(/sdk dusk-sdk EXCLUDE_FROM_ALL) +# add_mod(my_mod SOURCES ... MOD_JSON mod.json) + +cmake_minimum_required(VERSION 3.25) + +if (TARGET dusklight_game_headers) + message(FATAL_ERROR "Mod SDK already configured") +endif () + +include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/WindowsTargetProcessor.cmake") + +if (NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE "RelWithDebInfo" CACHE STRING + "Build type options: Debug Release RelWithDebInfo MinSizeRel" FORCE) +endif () + +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +# Version detection & version.h +include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/DetectVersion.cmake") +detect_version() +configure_version_header() + +# Game ABI headers & compile definitions +include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/GameABIConfig.cmake") + +include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/ModSDK.cmake") diff --git a/src/dusk/data.cpp b/src/dusk/data.cpp index 40e7c30a21..69ee5bebc8 100644 --- a/src/dusk/data.cpp +++ b/src/dusk/data.cpp @@ -121,14 +121,6 @@ std::filesystem::path active_pref_path() { return get_pref_path(); } -std::filesystem::path base_path_relative(const std::filesystem::path& path) { - const auto* basePath = SDL_GetBasePath(); - if (!basePath) { - return path; - } - return path_from_utf8(basePath) / path; -} - std::filesystem::path default_data_path(const std::filesystem::path& prefPath) { #ifdef __APPLE__ #if TARGET_OS_IOS && !TARGET_OS_TV @@ -888,6 +880,14 @@ void ensure_data_directory(const std::filesystem::path& dataPath) { } // namespace +std::filesystem::path base_path_relative(const std::filesystem::path& path) { + const auto* basePath = SDL_GetBasePath(); + if (!basePath) { + return path; + } + return path_from_utf8(basePath) / path; +} + bool open_data_path() { #if DUSK_CAN_OPEN_DATA_FOLDER std::error_code ec; diff --git a/src/dusk/data.hpp b/src/dusk/data.hpp index bf20035a2d..7e7861dbf0 100644 --- a/src/dusk/data.hpp +++ b/src/dusk/data.hpp @@ -29,6 +29,7 @@ struct Paths { }; Paths initialize_data(); +std::filesystem::path base_path_relative(const std::filesystem::path& path); std::filesystem::path configured_data_path(); std::filesystem::path cache_path(); bool open_data_path(); diff --git a/src/dusk/mods/loader/bundle_disk.cpp b/src/dusk/mods/loader/bundle_disk.cpp new file mode 100644 index 0000000000..26e1bbd722 --- /dev/null +++ b/src/dusk/mods/loader/bundle_disk.cpp @@ -0,0 +1,60 @@ +#include + +#include "dusk/io.hpp" +#include "loader.hpp" + +namespace fs = std::filesystem; + +namespace dusk::mods { +ModBundleDisk::ModBundleDisk(fs::path root) : root_path(std::move(root)) {} + +std::vector ModBundleDisk::readFile(const std::string& fileName) { + return io::FileStream::ReadAllBytes(toRealPath(fileName)); +} + +std::vector ModBundleDisk::getFileNames() { + std::vector files; + + std::error_code ec; + for (fs::recursive_directory_iterator it(root_path, + fs::directory_options::skip_permission_denied | + fs::directory_options::follow_directory_symlink, + ec); + it != fs::recursive_directory_iterator(); it.increment(ec)) + { + if (ec) { + break; + } + + if (!it->is_regular_file()) { + continue; + } + + const auto& path = it->path(); + const auto relPath = fs::relative(path, root_path); + auto string = io::fs_path_to_string(relPath); + if constexpr (fs::path::preferred_separator != '/') { + // Convert \ to / on Windows + for (auto& chr : string) { + if (chr == fs::path::preferred_separator) { + chr = '/'; + } + } + } + + files.emplace_back(std::move(string)); + } + + return files; +} + +size_t ModBundleDisk::getFileSize(const std::string& fileName) { + return std::filesystem::file_size(toRealPath(fileName)); +} + +std::filesystem::path ModBundleDisk::toRealPath(const std::string& fileName) const { + const fs::path filePath = reinterpret_cast(fileName.c_str()); + return root_path / filePath; +} + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/bundle_zip.cpp b/src/dusk/mods/loader/bundle_zip.cpp new file mode 100644 index 0000000000..517c5943c2 --- /dev/null +++ b/src/dusk/mods/loader/bundle_zip.cpp @@ -0,0 +1,68 @@ +#include "fmt/format.h" +#include "loader.hpp" + +#include + +namespace dusk::mods { + +ModBundleZip::ModBundleZip(std::vector&& data) : zip_data(std::move(data)) { + if (!mz_zip_reader_init_mem(&res_zip, zip_data.data(), zip_data.size(), 0)) { + const auto error = mz_zip_get_last_error(&res_zip); + throw std::runtime_error( + fmt::format("Opening zip failed: {}", mz_zip_get_error_string(error))); + } +} + +ModBundleZip::~ModBundleZip() { + mz_zip_reader_end(&res_zip); +} + +std::vector ModBundleZip::readFile(const std::string& fileName) { + std::lock_guard lock{m_mutex}; + size_t size; + const auto ptr = mz_zip_reader_extract_file_to_heap(&res_zip, fileName.c_str(), &size, 0); + + if (!ptr) { + throw std::runtime_error(fmt::format("File does not exist: {}", fileName)); + } + + std::span data(static_cast(ptr), size); + std::vector vec(data.begin(), data.end()); + + mz_free(ptr); + + return vec; +} + +std::vector ModBundleZip::getFileNames() { + std::lock_guard lock{m_mutex}; + std::vector results; + + for (mz_uint i = 0, n = mz_zip_reader_get_num_files(&res_zip); i < n; ++i) { + mz_zip_archive_file_stat stat{}; + if (!mz_zip_reader_file_stat(&res_zip, i, &stat)) { + continue; + } + if (mz_zip_reader_is_file_a_directory(&res_zip, i)) { + continue; + } + + results.emplace_back(stat.m_filename); + } + + return results; +} + +size_t ModBundleZip::getFileSize(const std::string& fileName) { + std::lock_guard lock{m_mutex}; + const auto idx = mz_zip_reader_locate_file(&res_zip, fileName.c_str(), nullptr, 0); + if (idx < 0) { + throw std::runtime_error(fmt::format("Unable to locate file in zip: {}", fileName)); + } + + mz_zip_archive_file_stat stat{}; + mz_zip_reader_file_stat(&res_zip, idx, &stat); + return stat.m_uncomp_size; +} + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/context.cpp b/src/dusk/mods/loader/context.cpp new file mode 100644 index 0000000000..99417117d6 --- /dev/null +++ b/src/dusk/mods/loader/context.cpp @@ -0,0 +1,70 @@ +#include "loader.hpp" + +#include "dusk/logging.h" +#include "dusk/mods/svc/registry.hpp" +#include "dusk/ui/ui.hpp" +#include "fmt/format.h" + +#include + +namespace dusk::mods { + +LoadedMod* mod_from_context(ModContext* context) { + return context != nullptr ? context->mod : nullptr; +} + +const LoadedMod* mod_from_context(const ModContext* context) { + return context != nullptr ? context->mod : nullptr; +} + +const char* mod_id_from_context(ModContext* context) { + const auto* mod = mod_from_context(context); + return mod != nullptr ? mod->metadata.id.c_str() : "mod"; +} + +bool is_safe_resource_path(std::string_view path) { + if (path.empty() || path.starts_with('/') || path.starts_with('\\') || + path.find(':') != std::string_view::npos) + { + return false; + } + + while (!path.empty()) { + const auto slash = path.find_first_of("/\\"); + const auto segment = path.substr(0, slash); + if (segment.empty() || segment == "." || segment == "..") { + return false; + } + if (slash == std::string_view::npos) { + break; + } + path.remove_prefix(slash + 1); + } + + return true; +} + +void fail_mod(LoadedMod& mod, ModResult code, std::string_view message) { + const bool firstFailure = !mod.loadFailed; + mod.active = false; + mod.loadFailed = true; + mod.failureReason = message; + // Stop the failed mod's services from resolving; mods that required them fail in turn. + // Pointers already handed to other mods stay callable since the library remains loaded. + // Nothing else is torn down here: fail_mod can run mid-frame (e.g. from a failing mod + // callback), and full teardown happens via deactivate_mod at a safe point. + svc::remove_services_for_provider(mod); + mod.servicesRegistered = false; + ModLoader::instance().notify_mod_failure(mod); + DuskLog.error("[{}] disabled: {} ({})", mod.metadata.id, message, static_cast(code)); + if (firstFailure) { + ui::push_toast({ + .type = "warning", + .title = "Mod Disabled", + .content = ui::escape(fmt::format("{}: {}", mod.metadata.name, message)), + .duration = std::chrono::seconds(5), + }); + } +} + +} // namespace dusk::mods::loader diff --git a/src/dusk/mods/loader/depgraph.cpp b/src/dusk/mods/loader/depgraph.cpp new file mode 100644 index 0000000000..20c831075a --- /dev/null +++ b/src/dusk/mods/loader/depgraph.cpp @@ -0,0 +1,207 @@ +#include "depgraph.hpp" + +#include +#include + +#include "dusk/logging.h" +#include "loader.hpp" +#include "native_module.hpp" + +static aurora::Module Log("dusk::modLoader"); + +namespace dusk::mods::loader { + +namespace { + +struct Edge { + size_t provider; + size_t importer; + bool required; + bool alive = true; +}; + +std::vector collect_edges(const std::vector>& mods) { + for (auto& mod : mods) { + mod->dependencies.clear(); + mod->dependents.clear(); + } + + // Mirrors the registry's first-registration-wins rule for duplicate exports. + const auto findProvider = [&](const ModManifestInfo::Import& serviceImport) -> size_t { + for (size_t i = 0; i < mods.size(); ++i) { + const auto matches = [&](const ModManifestInfo::Export& serviceExport) { + return serviceExport.major == serviceImport.major && + serviceExport.id == serviceImport.id; + }; + if (std::ranges::any_of(mods[i]->manifestInfo.exports, matches)) { + return i; + } + } + return mods.size(); // Host-provided or unavailable: no ordering constraint. + }; + + std::vector edges; + for (size_t importer = 0; importer < mods.size(); ++importer) { + auto& mod = *mods[importer]; + for (const auto& serviceImport : mod.manifestInfo.imports) { + const size_t provider = findProvider(serviceImport); + if (provider >= mods.size() || provider == importer) { + continue; + } + auto& providerMod = *mods[provider]; + const bool required = serviceImport.required; + + const auto existing = std::ranges::find_if(edges, [&](const Edge& edge) { + return edge.provider == provider && edge.importer == importer; + }); + if (existing != edges.end()) { + if (required && !existing->required) { + existing->required = true; + for (auto& dep : mod.dependencies) { + if (dep.mod == &providerMod) { + dep.required = true; + } + } + for (auto& dep : providerMod.dependents) { + if (dep.mod == &mod) { + dep.required = true; + } + } + } + } else { + edges.push_back({provider, importer, required}); + mod.dependencies.push_back({&providerMod, required}); + providerMod.dependents.push_back({&mod, required}); + } + } + } + return edges; +} + +// True if `start` can reach itself following live required edges through unplaced mods. +bool in_required_cycle( + const size_t start, const std::vector& edges, const std::vector& placed) { + std::vector pending{start}; + std::vector visited(placed.size(), false); + while (!pending.empty()) { + const size_t current = pending.back(); + pending.pop_back(); + for (const auto& edge : edges) { + if (!edge.alive || !edge.required || edge.provider != current || placed[edge.importer]) + { + continue; + } + if (edge.importer == start) { + return true; + } + if (!visited[edge.importer]) { + visited[edge.importer] = true; + pending.push_back(edge.importer); + } + } + } + return false; +} + +} // namespace + +void sort_mods(std::vector>& mods) { + const size_t count = mods.size(); + auto edges = collect_edges(mods); + if (edges.empty()) { + return; + } + + std::vector indegree(count, 0); + for (const auto& edge : edges) { + ++indegree[edge.importer]; + } + + std::vector order; + order.reserve(count); + std::vector placed(count, false); + + const auto place = [&](const size_t index) { + placed[index] = true; + order.push_back(index); + for (auto& edge : edges) { + if (edge.alive && edge.provider == index) { + edge.alive = false; + --indegree[edge.importer]; + } + } + }; + + while (order.size() < count) { + // Always take the lowest unplaced scan index that is ready, keeping the + // final order as close to scan (filename) order as the graph allows. + const auto ready = [&]() -> size_t { + for (size_t i = 0; i < count; ++i) { + if (!placed[i] && indegree[i] == 0) { + return i; + } + } + return count; + }(); + if (ready < count) { + place(ready); + continue; + } + + // Stalled: every unplaced mod is on or downstream of a cycle. + std::vector cycleMods; + for (size_t i = 0; i < count; ++i) { + if (!placed[i] && in_required_cycle(i, edges, placed)) { + cycleMods.push_back(i); + } + } + if (!cycleMods.empty()) { + std::string names; + for (const size_t index : cycleMods) { + if (!names.empty()) { + names += ", "; + } + names += mods[index]->metadata.id; + } + for (const size_t index : cycleMods) { + fail_mod(*mods[index], MOD_CONFLICT, + "required service import cycle between mods: " + names); + place(index); + } + continue; + } + + // Only optional imports left in the cycle: drop one edge and retry. The + // import still resolves, but without any initialization-order guarantee. + const auto optionalEdge = std::ranges::find_if(edges, [&](const Edge& edge) { + return edge.alive && !edge.required && !placed[edge.provider] && + !placed[edge.importer]; + }); + if (optionalEdge == edges.end()) { + // Unreachable: a stall with no required cycle implies an optional edge. + Log.error("mod dependency sort stalled unexpectedly"); + break; + } + Log.warn("optional service import cycle: '{}' will initialize before its optional " + "provider '{}'", + mods[optionalEdge->importer]->metadata.id, mods[optionalEdge->provider]->metadata.id); + optionalEdge->alive = false; + --indegree[optionalEdge->importer]; + } + + // Defensive: append anything a stall break left behind, in scan order. + for (size_t i = 0; i < count; ++i) { + if (!placed[i]) { + place(i); + } + } + + std::vector> sorted; + sorted.reserve(count); + for (const size_t index : order) { + sorted.push_back(std::move(mods[index])); + } + mods = std::move(sorted); +} + +} // namespace dusk::mods::loader diff --git a/src/dusk/mods/loader/depgraph.hpp b/src/dusk/mods/loader/depgraph.hpp new file mode 100644 index 0000000000..a6f5b30d50 --- /dev/null +++ b/src/dusk/mods/loader/depgraph.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +#include "dusk/mod_loader.hpp" + +namespace dusk::mods::loader { + +// Reorders mods so service providers precede their importers, populating +// LoadedMod::dependencies/dependents from manifest imports along the way. +// Mods whose required imports form a cycle are failed; a cycle that can be +// broken by dropping an optional import is broken with a warning. Scan order +// is preserved between mods with no dependency relationship. +void sort_mods(std::vector>& mods); + +} // namespace dusk::mods::loader diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp new file mode 100644 index 0000000000..769ec28e87 --- /dev/null +++ b/src/dusk/mods/loader/loader.cpp @@ -0,0 +1,1088 @@ +#include "loader.hpp" +#include "dusk/logging.h" +#include "dusk/mod_loader.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "depgraph.hpp" +#include "dusk/config.hpp" +#include "dusk/io.hpp" +#include "dusk/mods/svc/registry.hpp" +#include "miniz.h" +#include "native_module.hpp" +#include "nlohmann/json.hpp" + +static aurora::Module Log("dusk::modLoader"); + +using namespace std::string_literals; +using namespace std::string_view_literals; + +#if defined(_WIN32) +#if defined(_M_ARM64) +static constexpr std::string_view k_nativeLibName = "windows-arm64.dll"sv; +#elif defined(_M_X64) +static constexpr std::string_view k_nativeLibName = "windows-amd64.dll"sv; +#elif defined(_M_IX86) +static constexpr std::string_view k_nativeLibName = "windows-x86.dll"sv; +#else +static constexpr std::string_view k_nativeLibName = ""sv; +#endif +#elif defined(__ANDROID__) +#if defined(__aarch64__) +static constexpr std::string_view k_nativeLibName = "android-aarch64.so"sv; +#elif defined(__x86_64__) +static constexpr std::string_view k_nativeLibName = "android-x86_64.so"sv; +#else +static constexpr std::string_view k_nativeLibName = ""sv; +#endif +#elif defined(__APPLE__) +#include +#if TARGET_OS_IOS +static constexpr std::string_view k_nativeLibName = "ios-arm64.dylib"sv; +#elif TARGET_OS_TV +static constexpr std::string_view k_nativeLibName = "tvos-arm64.dylib"sv; +#elif defined(__aarch64__) +static constexpr std::string_view k_nativeLibName = "darwin-arm64.dylib"sv; +#elif defined(__x86_64__) +static constexpr std::string_view k_nativeLibName = "darwin-x86_64.dylib"sv; +#else +static constexpr std::string_view k_nativeLibName = ""sv; +#endif +#elif defined(__linux__) +#if defined(__aarch64__) +static constexpr std::string_view k_nativeLibName = "linux-aarch64.so"sv; +#elif defined(__x86_64__) +static constexpr std::string_view k_nativeLibName = "linux-x86_64.so"sv; +#elif defined(__i386__) +static constexpr std::string_view k_nativeLibName = "linux-x86.so"sv; +#else +static constexpr std::string_view k_nativeLibName = ""sv; +#endif +#else +static constexpr std::string_view k_nativeLibName = ""sv; +#endif + +namespace dusk::mods { +namespace { +ModLoader g_modLoader; + +std::unique_ptr load_bundle(const std::filesystem::path& modPath, bool fromDir) { + if (fromDir) { + return std::make_unique(modPath); + } else { + std::vector data = io::FileStream::ReadAllBytes(modPath); + return std::make_unique(std::move(data)); + } +} + +struct DllLocateResult { + std::string entry; + bool anyLibs = false; +}; + +DllLocateResult locate_dll_in_bundle(ModBundle& bundle) { + DllLocateResult result; + for (const auto& name : bundle.getFileNames()) { + if (name.find('/') != std::string::npos || + (!name.ends_with(".dll"sv) && !name.ends_with(".dylib"sv) && !name.ends_with(".so"sv))) + { + continue; + } + result.anyLibs = true; + if (name == k_nativeLibName) { + result.entry = name; + } + } + return result; +} +} // namespace + +ModLoader& ModLoader::instance() { + return g_modLoader; +} + +class InvalidModDataException : public std::runtime_error { +public: + explicit InvalidModDataException(const std::string& msg) : runtime_error(msg) {} + explicit InvalidModDataException(const char* msg) : runtime_error(msg) {} +}; + +static void validate_mod_id(std::string_view const str) { + if (str.empty()) { + throw InvalidModDataException("Missing ID value in mod metadata!"); + } + + bool lastWasPeriod = false; + for (auto const chr : str) { + if (chr == '.') { + if (lastWasPeriod) { + throw InvalidModDataException("Cannot have two consecutive periods in mod ID!"); + } + lastWasPeriod = true; + continue; + } + + lastWasPeriod = false; + + if (chr == '_') + continue; + + if (chr >= '0' && chr <= '9') + continue; + + if (chr >= 'a' && chr <= 'z') + continue; + + if (chr >= 'A' && chr <= 'Z') + continue; + + throw InvalidModDataException( + fmt::format("Invalid character '{}' in mod ID. Valid characters are period, " + "underscore, and alphanumerics.", + chr)); + } +} + +static bool bundle_has_file(ModBundle& bundle, const std::string& path) { + try { + bundle.getFileSize(path); + return true; + } catch (const std::runtime_error&) { + return false; + } +} + +static std::string resolve_image_path(ModBundle& bundle, const std::string& modId, + std::string_view key, const std::string& manifestPath, const std::string& defaultPath) { + if (!manifestPath.empty()) { + if (!is_safe_resource_path(manifestPath)) { + Log.warn("{}: invalid {} path '{}' in mod.json", modId, key, manifestPath); + } else if (!bundle_has_file(bundle, manifestPath)) { + Log.warn("{}: {} path '{}' not found in bundle", modId, key, manifestPath); + } else { + return manifestPath; + } + } + if (bundle_has_file(bundle, defaultPath)) { + return defaultPath; + } + return {}; +} + +static ModMetadata load_metadata(const std::filesystem::path& modPath, ModBundle& bundle) { + const auto metaJson = bundle.readFile("mod.json"); + auto j = nlohmann::json::parse(metaJson); + + std::string metaId = j.value("id", ""); + std::string metaName = j.value("name", ""); + std::string metaVersion = j.value("version", ""); + std::string metaAuthor = j.value("author", ""); + std::string metaDescription = j.value("description", ""); + std::string metaIcon = j.value("icon", ""); + std::string metaBanner = j.value("banner", ""); + + validate_mod_id(metaId); + + if (metaName.empty()) { + metaName = io::fs_path_to_string(modPath.stem()); + } + if (metaVersion.empty()) { + metaVersion = "?"s; + } + if (metaAuthor.empty()) { + metaAuthor = "unknown"s; + } + + std::string iconPath = resolve_image_path(bundle, metaId, "icon", metaIcon, "res/icon.png"s); + std::string bannerPath = + resolve_image_path(bundle, metaId, "banner", metaBanner, "res/banner.png"s); + + return ModMetadata{ + std::move(metaId), + std::move(metaName), + std::move(metaVersion), + std::move(metaAuthor), + std::move(metaDescription), + std::move(iconPath), + std::move(bannerPath), + }; +} + +static bool validate_manifest(const ModManifest* manifest, LoadedMod& mod) { + if (manifest == nullptr) { + Log.error("{} returned a null mod manifest", mod.metadata.id); + mod.nativeStatus = NativeModStatus::MissingExport; + return false; + } + if (manifest->struct_size != sizeof(ModManifest)) { + Log.error("{} manifest has invalid size {} (expected {})", mod.metadata.id, + manifest->struct_size, sizeof(ModManifest)); + mod.nativeStatus = NativeModStatus::ApiVersionMismatch; + return false; + } + if (manifest->abi_version != MOD_ABI_VERSION) { + Log.error("{} expects ABI v{} but engine is v{}, skipping", mod.metadata.id, + manifest->abi_version, MOD_ABI_VERSION); + mod.nativeStatus = NativeModStatus::ApiVersionMismatch; + return false; + } + if ((manifest->import_count > 0 && manifest->imports == nullptr) || + (manifest->export_count > 0 && manifest->exports == nullptr)) + { + Log.error("{} manifest has invalid import/export arrays", mod.metadata.id); + mod.nativeStatus = NativeModStatus::MissingExport; + return false; + } + return true; +} + +static bool validate_context_symbol(ModContext** contextSymbol, LoadedMod& mod) { + if (contextSymbol == nullptr) { + Log.error("{} missing required mod_ctx export", mod.metadata.id); + mod.nativeStatus = NativeModStatus::MissingExport; + return false; + } + return true; +} + +static std::string lifecycle_error_message( + const char* fnName, const ModResult result, const ModError& error) { + if (error.message[0] != '\0') { + return error.message; + } + return fmt::format("{} failed with result {}", fnName, static_cast(result)); +} + +static std::string native_status_message(const NativeModStatus status) { + switch (status) { + case NativeModStatus::BuildDisabled: + return "Code mods are disabled on this Dusklight build"; + case NativeModStatus::ModMissingPlatform: + return fmt::format("Mod not supported on this platform ({})", k_nativeLibName); + case NativeModStatus::ApiVersionMismatch: + // TODO: differentiate whether mod or Dusklight is out of date + return "Mod ABI version mismatch"; + case NativeModStatus::MissingExport: + return "Missing required mod API exports"; + case NativeModStatus::Unknown: + return "Unknown mod load failure"; + case NativeModStatus::None: + case NativeModStatus::Loaded: + break; + } + return "native mod failed to load"; +} + +std::filesystem::path ModLoader::external_native_lib_path(const LoadedMod& mod) const { + namespace fs = std::filesystem; + if (k_nativeLibName.empty()) { + return {}; + } + const auto& libDir = m_searchDirs[mod.searchDirIndex].nativeLibDir; + if (libDir.empty()) { + return {}; + } + fs::path path = libDir / fs::path(mod.metadata.id + io::fs_path_to_string( + fs::path(k_nativeLibName).extension())); + std::error_code ec; + if (!fs::is_regular_file(path, ec)) { + return {}; + } + return path; +} + +void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { + if (!EnableCodeMods) { + Log.error("Code mods are not available in this build"); + mod.nativeStatus = NativeModStatus::BuildDisabled; + return; + } + + namespace fs = std::filesystem; + + const fs::path cacheDir = m_cacheDir / mod.metadata.id; + std::error_code ec; + fs::create_directories(cacheDir, ec); + + fs::path libPath; + if (mod.inPlace) { + if (!dllEntry.empty()) { + libPath = fs::path(mod.modPath) / dllEntry; + } else if (auto external = external_native_lib_path(mod); !external.empty()) { + libPath = std::move(external); + } else { + Log.error("no native library named {} found in {}; skipping", k_nativeLibName, + mod.metadata.id); + mod.nativeStatus = NativeModStatus::ModMissingPlatform; + return; + } + } else { + if (dllEntry.empty()) { + Log.error("no native library named {} found in {}; skipping", k_nativeLibName, + mod.metadata.id); + mod.nativeStatus = NativeModStatus::ModMissingPlatform; + return; + } + + // Generation-versioned filename: every dlopen gets a path it has never seen, so a reload + // always yields a fresh image with fresh statics even if the previous dlclose did not + // fully unmap the old one (TLS/ObjC pinning). The .cache dir is wiped on startup. + const fs::path dllCachePath = + cacheDir / fmt::format("{}.g{}{}", mod.metadata.id, ++mod.cacheGeneration, + io::fs_path_to_string(fs::path(dllEntry).extension())); + + std::vector dllData; + try { + dllData = mod.bundle->readFile(dllEntry); + } catch (const std::exception& e) { + Log.error("failed to extract {} from {}", dllEntry, mod.metadata.id); + return; + } + + { + std::ofstream out(dllCachePath, std::ios::binary | std::ios::out); + if (!out) { + Log.error("failed to write {}", io::fs_path_to_string(dllCachePath)); + return; + } + + out.write(reinterpret_cast(dllData.data()), + static_cast(dllData.size())); + } + + libPath = dllCachePath; + } + + auto nativeMod = std::make_unique(); + try { + nativeMod->handle = std::make_unique(libPath); + } catch (const std::runtime_error& e) { + Log.error("failed to open {}: {}", io::fs_path_to_string(libPath), e.what()); + return; + } + + const auto getManifest = nativeMod->handle->LookupSymbol("mod_get_manifest"); + nativeMod->contextSymbol = nativeMod->handle->LookupSymbol("mod_ctx"); + nativeMod->fn_initialize = nativeMod->handle->LookupSymbol("mod_initialize"); + nativeMod->fn_update = nativeMod->handle->LookupSymbol("mod_update"); + nativeMod->fn_shutdown = nativeMod->handle->LookupSymbol("mod_shutdown"); + + if (!getManifest || !nativeMod->contextSymbol || !nativeMod->fn_initialize || + !nativeMod->fn_update || !nativeMod->fn_shutdown) + { + Log.error("{} missing required mod API exports; skipping", + io::fs_path_to_string(libPath.filename())); + mod.nativeStatus = NativeModStatus::MissingExport; + return; + } + + nativeMod->manifest = getManifest(); + if (!validate_manifest(nativeMod->manifest, mod)) { + return; + } + + if (!validate_context_symbol(nativeMod->contextSymbol, mod)) { + return; + } + *nativeMod->contextSymbol = mod.context.get(); + + mod.dir = io::fs_path_to_string(fs::absolute(cacheDir)); + mod.nativePath = io::fs_path_to_string(fs::absolute(libPath)); + mod.native = std::move(nativeMod); + mod.nativeStatus = NativeModStatus::Loaded; +} + +void ModLoader::unload_native(LoadedMod& mod) { + if (!mod.native || mod.inPlace) { + return; + } + // Deferred dlclose: this mod's code may still be on the stack below the current tick + m_retiredNatives.push_back({std::move(mod.native), std::move(mod.nativePath)}); + mod.nativePath.clear(); +} + +void ModLoader::drain_retired_natives() { + for (auto& retired : m_retiredNatives) { + retired.native.reset(); + if (!retired.path.empty()) { + std::error_code ec; + std::filesystem::remove(retired.path, ec); + } + } + m_retiredNatives.clear(); +} + +static ModManifestInfo build_manifest_info(const ModManifest& manifest) { + ModManifestInfo info; + info.imports.reserve(manifest.import_count); + for (size_t i = 0; i < manifest.import_count; ++i) { + const auto& serviceImport = manifest.imports[i]; + if (serviceImport.struct_size != sizeof(ServiceImport) || + !svc::valid_service_id(serviceImport.service_id)) + { + continue; + } + info.imports.push_back({serviceImport.service_id, serviceImport.major_version, + (serviceImport.flags & SERVICE_IMPORT_OPTIONAL) == 0}); + } + info.exports.reserve(manifest.export_count); + for (size_t i = 0; i < manifest.export_count; ++i) { + const auto& serviceExport = manifest.exports[i]; + if (serviceExport.struct_size != sizeof(ServiceExport) || + !svc::valid_service_id(serviceExport.service_id)) + { + continue; + } + info.exports.push_back({serviceExport.service_id, serviceExport.major_version}); + } + return info; +} + +std::string escape_mod_id_for_config(std::string_view const id) { + std::string buf; + + // Simple escaping. All characters in mod IDs literal, except for '.' and '_'. + // '.' -> '_', '_' -> '__' + for (char const chr : id) { + if (chr == '.') { + buf.push_back('_'); + } else if (chr == '_') { + buf.push_back('_'); + buf.push_back('_'); + } else { + buf.push_back(chr); + } + } + + return buf; +} + +static std::string mod_enabled_cvar_name(std::string_view const id) { + return fmt::format("mod.{}.enabled", escape_mod_id_for_config(id)); +} + +// A deferred export that was not published by the end of the provider's initialization can +// never resolve, which is almost certainly a bug in the provider. +static void warn_unpublished_deferred_exports(const LoadedMod& mod) { + if (!mod.active || !mod.native || mod.native->manifest == nullptr) { + return; + } + + const auto& manifest = *mod.native->manifest; + for (size_t i = 0; i < manifest.export_count; ++i) { + const auto& serviceExport = manifest.exports[i]; + if (serviceExport.struct_size != sizeof(ServiceExport) || + (serviceExport.flags & SERVICE_EXPORT_DEFERRED) == 0) + { + continue; + } + const auto* record = + svc::find_service_record(serviceExport.service_id, serviceExport.major_version); + if (record != nullptr && record->service == nullptr) { + Log.warn("'{}' declared deferred service '{}@{}' but never published it during " + "initialization", + mod.metadata.id, serviceExport.service_id, serviceExport.major_version); + } + } +} + +void ModLoader::try_load_mod( + const std::filesystem::path& modPath, bool fromDir, uint32_t searchDirIndex) { + namespace fs = std::filesystem; + + std::unique_ptr bundle; + try { + bundle = load_bundle(modPath, fromDir); + } catch (const std::exception& e) { + Log.error( + "Failed to open {} bundle: {}", io::fs_path_to_string(modPath.filename()), e.what()); + return; + } + + ModMetadata metadata; + try { + metadata = load_metadata(modPath, *bundle); + } catch (const std::exception& e) { + Log.error("bad mod.json in {}: {}", io::fs_path_to_string(modPath.filename()), e.what()); + return; + } + + if (const auto* existing = find_mod(metadata.id)) { + if (existing->searchDirIndex < searchDirIndex) { + Log.info("'{}' ({}) shadowed by higher-priority copy {}", metadata.id, + io::fs_path_to_string(modPath.filename()), existing->modPath); + } else { + Log.error("mod with id '{}' already exists, not loading {}", metadata.id, + io::fs_path_to_string(modPath.filename())); + } + return; + } + + const auto& inserted = m_mods.emplace_back(std::make_unique()); + + auto& mod = *inserted; + mod.active = true; + mod.modPath = io::fs_path_to_string(fs::absolute(modPath)); + mod.searchDirIndex = searchDirIndex; + mod.inPlace = m_searchDirs[searchDirIndex].inPlaceNative && fromDir; + mod.metadata = std::move(metadata); + mod.bundle = std::move(bundle); + mod.context = std::make_unique(); + mod.context->mod = &mod; + mod.cvarIsEnabled = + std::make_unique >(mod_enabled_cvar_name(mod.metadata.id), true); + + const auto [dllEntry, anyLibs] = locate_dll_in_bundle(*mod.bundle); + if (anyLibs || (mod.inPlace && !external_native_lib_path(mod).empty())) { + mod.nativeStatus = NativeModStatus::Unknown; + load_native(mod, dllEntry); + if (mod.nativeStatus != NativeModStatus::Loaded) { + Log.error("Native mod '{}' failed to load, disabling", mod.metadata.id); + mod.active = false; + mod.loadFailed = true; + mod.failureReason = native_status_message(mod.nativeStatus); + } else { + mod.manifestInfo = build_manifest_info(*mod.native->manifest); + } + } + + Log.info("found '{}' ('{}') v{} by {} ({})", mod.metadata.name, mod.metadata.id, + mod.metadata.version, mod.metadata.author, io::fs_path_to_string(modPath.filename())); +} + +bool ModLoader::activate_mod(LoadedMod& mod) { + mod.active = true; + + // Asset-only mods have no lifecycle beyond their overlay files. + if (!mod.native) { + mod.enabledApplied = true; + return true; + } + + if (!mod.servicesRegistered) { + if (!register_static_service_exports(mod)) { + Log.error("'{}' failed to register service exports", mod.metadata.id); + return false; + } + mod.servicesRegistered = true; + } + + if (!resolve_service_imports(mod)) { + Log.error("'{}' failed to resolve service imports", mod.metadata.id); + return false; + } + + *mod.native->contextSymbol = mod.context.get(); + + Log.debug("Initializing '{}'", mod.metadata.id); + try { + ModError error = MOD_ERROR_INIT; + const auto result = mod.native->fn_initialize(&error); + if (result == MOD_OK && !mod.loadFailed) { + mod.initialized = true; + Log.info("'{}' initialized", mod.metadata.id); + } else { + fail_mod(mod, result, lifecycle_error_message("mod_initialize", result, error)); + } + } catch (const std::exception& e) { + fail_mod(mod, MOD_ERROR, fmt::format("exception in mod_initialize: {}", e.what())); + } catch (...) { + fail_mod(mod, MOD_ERROR, "unknown exception in mod_initialize"); + } + + warn_unpublished_deferred_exports(mod); + + if (!mod.active) { + return false; + } + + mod.enabledApplied = true; + return true; +} + +void ModLoader::deactivate_mod(LoadedMod& mod) { + if (mod.initialized && mod.native && mod.native->fn_shutdown) { + try { + ModError error = MOD_ERROR_INIT; + const auto result = mod.native->fn_shutdown(&error); + if (result != MOD_OK) { + Log.error("mod_shutdown failed for '{}': {}", mod.metadata.id, + lifecycle_error_message("mod_shutdown", result, error)); + } + } catch (...) { + } + } + mod.initialized = false; + + if (mod.servicesRegistered) { + svc::remove_services_for_provider(mod); + mod.servicesRegistered = false; + } + unload_native(mod); + + mod.active = false; + mod.enabledApplied = false; +} + +void ModLoader::init() { + if (m_initialized) { + return; + } + m_initialized = true; + + if (m_searchDirs.empty()) { + Log.warn("no mod search directories configured; mod loading skipped"); + return; + } + + if (m_cacheDir.empty()) { + m_cacheDir = m_searchDirs.front().path / ".cache"; + } + + namespace fs = std::filesystem; + std::error_code ec; + + // Stale libs from previous sessions (see load_native). + fs::remove_all(m_cacheDir, ec); + + for (size_t dirIndex = 0; dirIndex < m_searchDirs.size(); ++dirIndex) { + const auto& searchDir = m_searchDirs[dirIndex]; + + // --mods can point the user dir at the bundled dir; don't scan the same dir twice. + bool alreadyScanned = false; + for (size_t earlier = 0; earlier < dirIndex && !alreadyScanned; ++earlier) { + alreadyScanned = fs::equivalent(m_searchDirs[earlier].path, searchDir.path, ec); + } + if (alreadyScanned) { + continue; + } + + if (!fs::is_directory(searchDir.path)) { + if (dirIndex == 0) { + Log.info("mods directory '{}' not found", io::fs_path_to_string(searchDir.path)); + } else { + Log.debug("mods directory '{}' not found", io::fs_path_to_string(searchDir.path)); + } + continue; + } + + std::vector entries; + for (auto& e : fs::directory_iterator(searchDir.path, ec)) { + if (e.is_directory() && std::filesystem::exists(e.path() / "mod.json")) { + entries.push_back(e); + } else if (e.is_regular_file() && e.path().extension() == ".dusk") { + entries.push_back(e); + } + } + std::sort(entries.begin(), entries.end(), + [](const fs::directory_entry& a, const fs::directory_entry& b) { + return a.path().filename() < b.path().filename(); + }); + + for (auto& entry : entries) { + try_load_mod(entry.path(), entry.is_directory(), static_cast(dirIndex)); + } + } + + if (m_mods.empty()) { + Log.info("no mods found"); + return; + } + + std::stable_sort(m_mods.begin(), m_mods.end(), + [](const auto& a, const auto& b) { return a->searchDirIndex > b->searchDirIndex; }); + + Log.info("initializing {} mod(s)...", m_mods.size()); + for (auto& mod : mods()) { + mod.enabledSubscription = Register(*mod.cvarIsEnabled, + [this, &mod](const bool&, const bool&) { on_enabled_changed(mod); }); + } + + init_services(); + + for (auto& mod : mods()) { + if (!mod.native) { + continue; + } + if (register_static_service_exports(mod)) { + mod.servicesRegistered = true; + } else { + Log.error("'{}' failed to register service exports", mod.metadata.id); + } + } + + // Providers must initialize (and publish deferred services) before their importers, so + // imports are resolved per mod, interleaved with initialization, in dependency order. + loader::sort_mods(m_mods); + + // Config-disabled mods keep their dependency edges but must not resolve until enabled. + for (auto& mod : mods()) { + if (!mod.cvarIsEnabled->getValue()) { + Log.info("Mod '{}' is disabled by config", mod.metadata.id); + mod.active = false; + if (mod.servicesRegistered) { + svc::remove_services_for_provider(mod); + mod.servicesRegistered = false; + } + } + } + + for (auto& mod : mods()) { + if (mod.active) { + activate_mod(mod); + } + } + + auto active = std::ranges::count_if(mods(), [](const LoadedMod& m) { return m.active; }); + Log.info("{}/{} mod(s) active", active, m_mods.size()); + + m_startupComplete = true; +} + +LoadedMod* ModLoader::find_mod(std::string_view id) { + for (auto& mod : mods()) { + if (mod.metadata.id == id) { + return &mod; + } + } + return nullptr; +} + +void ModLoader::request_enable(std::string_view id) { + if (auto* mod = find_mod(id)) { + mod->cvarIsEnabled->setValue(true); + } +} + +void ModLoader::request_disable(std::string_view id) { + if (auto* mod = find_mod(id)) { + mod->cvarIsEnabled->setValue(false); + } +} + +void ModLoader::request_reload(std::string_view id) { + m_pendingRequests.push_back({std::string{id}, RequestKind::Reload}); +} + +void ModLoader::notify_mod_failure(LoadedMod& mod) { + // Startup failures are handled inline by activate_mod; the queue only exists for failures + // raised while mod code may be on the stack (mod_update, hook callbacks, UI callbacks). + if (!m_startupComplete) { + return; + } + m_pendingRequests.push_back({mod.metadata.id, RequestKind::Disable}); +} + +static bool requiredDepsActive(const LoadedMod& mod) { + return std::ranges::all_of(mod.dependencies, + [](const ModDependencyEdge& edge) { return !edge.required || edge.mod->active; }); +} + +std::vector ModLoader::collect_lifecycle_set(LoadedMod& target) { + std::vector included{&target}; + std::vector pending{&target}; + while (!pending.empty()) { + auto* current = pending.back(); + pending.pop_back(); + for (const auto& edge : current->dependents) { + auto* dependent = edge.mod; + if (!dependent->active && !dependent->suspendedByProvider) { + continue; + } + if (std::ranges::find(included, dependent) != included.end()) { + continue; + } + included.push_back(dependent); + pending.push_back(dependent); + } + } + + std::vector ordered; + ordered.reserve(included.size()); + for (auto& mod : mods()) { + if (std::ranges::find(included, &mod) != included.end()) { + ordered.push_back(&mod); + } + } + return ordered; +} + +bool ModLoader::ensure_native_loaded(LoadedMod& mod) { + if (mod.native || mod.nativeStatus == NativeModStatus::None) { + return true; + } + + const auto [dllEntry, anyLibs] = locate_dll_in_bundle(*mod.bundle); + if (!anyLibs && !(mod.inPlace && !external_native_lib_path(mod).empty())) { + mod.nativeStatus = NativeModStatus::None; + return true; + } + + mod.nativeStatus = NativeModStatus::Unknown; + load_native(mod, dllEntry); + if (mod.nativeStatus != NativeModStatus::Loaded) { + fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus)); + return false; + } + return true; +} + +bool ModLoader::reload_bundle(LoadedMod& mod) { + namespace fs = std::filesystem; + Log.info("reloading '{}' from {}", mod.metadata.id, mod.modPath); + + std::shared_ptr newBundle; + ModMetadata newMetadata; + try { + std::error_code ec; + newBundle = load_bundle(mod.modPath, fs::is_directory(mod.modPath, ec)); + newMetadata = load_metadata(mod.modPath, *newBundle); + } catch (const std::exception& e) { + fail_mod(mod, MOD_ERROR, fmt::format("reload failed: {}", e.what())); + return false; + } + + if (newMetadata.id != mod.metadata.id) { + fail_mod(mod, MOD_CONFLICT, + fmt::format("mod id changed on reload ('{}'); restart the game", newMetadata.id)); + return false; + } + + mod.metadata = std::move(newMetadata); + // In-flight readers of the old bundle keep it alive through their shared_ptr. + mod.bundle = std::move(newBundle); + mod.loadFailed = false; + mod.failureReason.clear(); + + ModManifestInfo newInfo; + if (const auto [dllEntry, anyLibs] = locate_dll_in_bundle(*mod.bundle); anyLibs) { + mod.nativeStatus = NativeModStatus::Unknown; + load_native(mod, dllEntry); + if (mod.nativeStatus != NativeModStatus::Loaded) { + fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus)); + return false; + } + newInfo = build_manifest_info(*mod.native->manifest); + } else { + mod.nativeStatus = NativeModStatus::None; + ++mod.cacheGeneration; + } + + if (newInfo != mod.manifestInfo) { + // The reload changes the mod's imports/exports; rebuild the dependency graph so edges, + // init/tick/shutdown order and cascade sets reflect the new manifest. + Log.info("'{}' changed its service imports/exports; rebuilding mod dependency graph", + mod.metadata.id); + mod.manifestInfo = std::move(newInfo); + loader::sort_mods(m_mods); + } + + return true; +} + +void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) { + auto affected = collect_lifecycle_set(target); + + // Dependents first (reverse init order), like shutdown. + for (auto* mod : affected | std::views::reverse) { + const bool needsTeardown = + mod->active || + (mod == &target && (mod->initialized || (reload && mod->native != nullptr))); + if (!needsTeardown) { + continue; + } + const bool wasActive = mod->active; + Log.info("deactivating '{}'", mod->metadata.id); + deactivate_mod(*mod); + if (mod != &target && wasActive) { + // Provisional; cleared below if the mod comes straight back up. + mod->suspendedByProvider = true; + } + } + + if (reload) { + // On failure the target is failed and stays down; dependents get resume attempts below + // and suspend against the failed provider where required. + reload_bundle(target); + + // The reload may have rebuilt the dependency graph and reordered m_mods; refresh the + // set's iteration order so reactivation still runs providers first. + std::vector reordered; + reordered.reserve(affected.size()); + for (auto& mod : mods()) { + if (std::ranges::find(affected, &mod) != affected.end()) { + reordered.push_back(&mod); + } + } + affected = std::move(reordered); + } + + // Mirror startup: publish every candidate's static exports before any of them initialize, + // so importers within the set resolve providers regardless of initialization order + // (optional cycles rely on this). + for (auto* mod : affected) { + if (mod->active || mod->loadFailed || !mod->cvarIsEnabled->getValue()) { + continue; + } + if (!ensure_native_loaded(*mod)) { + continue; + } + if (mod->native && !mod->servicesRegistered && register_static_service_exports(*mod)) { + mod->servicesRegistered = true; + } + } + + // Providers first (init order). The target is naturally first among the affected mods. + for (auto* mod : affected) { + if (mod->active || mod->loadFailed || !mod->cvarIsEnabled->getValue()) { + continue; + } + if (!requiredDepsActive(*mod)) { + mod->suspendedByProvider = true; + Log.info("'{}' suspended: a required provider is disabled", mod->metadata.id); + continue; + } + mod->suspendedByProvider = false; + activate_mod(*mod); + } + + // Mods that stayed down must not leave their exports resolvable. + for (auto* mod : affected) { + if (!mod->active && mod->servicesRegistered) { + svc::remove_services_for_provider(*mod); + mod->servicesRegistered = false; + } + } +} + +namespace { +bool s_configDirty = false; +std::chrono::steady_clock::time_point s_lastConfigSave{}; +constexpr std::chrono::seconds kConfigSaveDebounce{2}; +} // namespace + +void config_mark_dirty() { + s_configDirty = true; +} + +void config_flush_if_dirty(const bool force) { + if (!s_configDirty) { + return; + } + const auto now = std::chrono::steady_clock::now(); + if (!force && now - s_lastConfigSave < kConfigSaveDebounce) { + return; + } + s_configDirty = false; + s_lastConfigSave = now; + config::save(); +} + +void ModLoader::on_enabled_changed(LoadedMod& mod) { + config_mark_dirty(); + if (mod.loadFailed) { + return; + } + if (mod.suspendedByProvider) { + if (!mod.cvarIsEnabled->getValue()) { + // The user disabled a suspended mod; stop waiting for its providers. + mod.suspendedByProvider = false; + } + return; + } + m_pendingRequests.push_back({mod.metadata.id, + mod.cvarIsEnabled->getValue() ? RequestKind::Enable : RequestKind::Disable}); +} + +void ModLoader::apply_pending_requests() { + // Images retired by the previous tick have had a full frame to unwind off the stack. + drain_retired_natives(); + + if (m_pendingRequests.empty()) { + return; + } + + // Coalesce per mod, last request wins. Failures during apply re-enqueue for next tick. + const auto requests = std::exchange(m_pendingRequests, {}); + std::vector coalesced; + for (const auto& request : requests) { + const auto existing = std::ranges::find_if( + coalesced, [&](const Request& r) { return r.modId == request.modId; }); + if (existing != coalesced.end()) { + existing->kind = request.kind; + } else { + coalesced.push_back(request); + } + } + + for (const auto& request : coalesced) { + auto* mod = find_mod(request.modId); + if (mod == nullptr) { + Log.warn("lifecycle request for unknown mod '{}'", request.modId); + continue; + } + if (request.kind == RequestKind::Reload && mod->inPlace) { + Log.warn("'{}' is a built-in mod and can't be reloaded", mod->metadata.id); + continue; + } + if (request.kind == RequestKind::Enable && mod->enabledApplied) { + continue; + } + if (request.kind == RequestKind::Disable && !mod->enabledApplied && !mod->active) { + continue; + } + apply_lifecycle_change(*mod, request.kind == RequestKind::Reload); + } + + auto active = std::ranges::count_if(mods(), [](const LoadedMod& m) { return m.active; }); + Log.info("{}/{} mod(s) active", active, m_mods.size()); +} + +void ModLoader::tick() { + apply_pending_requests(); + + for (auto& mod : mods()) { + if (!mod.active || !mod.native) { + continue; + } + try { + ModError error = MOD_ERROR_INIT; + const auto result = mod.native->fn_update(&error); + if (result != MOD_OK) { + fail_mod(mod, result, lifecycle_error_message("mod_update", result, error)); + } + } catch (const std::exception& e) { + fail_mod(mod, MOD_ERROR, fmt::format("exception in mod_update: {}", e.what())); + } catch (...) { + fail_mod(mod, MOD_ERROR, "unknown exception in mod_update"); + } + } + + config_flush_if_dirty(false); +} + +void ModLoader::shutdown() { + // Reverse initialization order, so importers shut down before their service providers. + for (auto& mod : mods() | std::views::reverse) { + deactivate_mod(mod); + if (mod.enabledSubscription != 0) { + config::unsubscribe(mod.enabledSubscription); + mod.enabledSubscription = 0; + } + unregister(*mod.cvarIsEnabled); + } + + m_mods.clear(); + drain_retired_natives(); + clear_services(); + config_flush_if_dirty(true); + Log.info("all mods unloaded"); +} + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/loader.hpp b/src/dusk/mods/loader/loader.hpp new file mode 100644 index 0000000000..cb81f1059b --- /dev/null +++ b/src/dusk/mods/loader/loader.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include "miniz.h" + +#include "dusk/mod_loader.hpp" + +namespace dusk::mods { + +#if DUSK_CODE_MODS +constexpr bool EnableCodeMods = true; +#else +constexpr bool EnableCodeMods = false; +#endif + +// Implementations must be safe for concurrent calls; bundle reads are not limited to the +// game thread. +class ModBundle { +public: + virtual ~ModBundle() = default; + + virtual std::vector readFile(const std::string& fileName) = 0; + virtual std::vector getFileNames() = 0; + virtual size_t getFileSize(const std::string& fileName) = 0; +}; + +class ModBundleZip final : public ModBundle { +public: + explicit ModBundleZip(std::vector&& data); + ~ModBundleZip() override; + std::vector readFile(const std::string& fileName) override; + std::vector getFileNames() override; + size_t getFileSize(const std::string& fileName) override; + +private: + std::vector zip_data; + mz_zip_archive res_zip{}; + bool res_zip_open = false; + std::mutex m_mutex; +}; + +class ModBundleDisk final : public ModBundle { +public: + explicit ModBundleDisk(std::filesystem::path root); + ~ModBundleDisk() override = default; + std::vector readFile(const std::string& fileName) override; + std::vector getFileNames() override; + size_t getFileSize(const std::string& fileName) override; + +private: + [[nodiscard]] std::filesystem::path toRealPath(const std::string& fileName) const; + std::filesystem::path root_path; +}; + +LoadedMod* mod_from_context(ModContext* context); +const LoadedMod* mod_from_context(const ModContext* context); +const char* mod_id_from_context(ModContext* context); +void fail_mod(LoadedMod& mod, ModResult code, std::string_view message); +bool is_safe_resource_path(std::string_view path); +std::string escape_mod_id_for_config(std::string_view id); + +void config_mark_dirty(); +void config_flush_if_dirty(bool force); + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/native_module.cpp b/src/dusk/mods/loader/native_module.cpp new file mode 100644 index 0000000000..aa4a369fb0 --- /dev/null +++ b/src/dusk/mods/loader/native_module.cpp @@ -0,0 +1,83 @@ +#include "native_module.hpp" + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#endif + +namespace { +#if defined(_WIN32) +void* pl_dlopen(const std::filesystem::path& p) { + return LoadLibraryW(p.wstring().c_str()); +} +void* pl_dlsym(void* h, const char* name) { + return reinterpret_cast(GetProcAddress(static_cast(h), name)); +} +void pl_dlclose(void* h) { + FreeLibrary(static_cast(h)); +} +std::string pl_dlerror() { + char buf[256]{}; + FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, + GetLastError(), 0, buf, sizeof(buf), nullptr); + std::string s = buf; + while (!s.empty() && (s.back() == '\r' || s.back() == '\n')) { + s.pop_back(); + } + return s; +} +#else +#include +void* pl_dlopen(const std::filesystem::path& p) { + int flags = RTLD_LAZY | RTLD_LOCAL; +#if defined(RTLD_DEEPBIND) + flags |= RTLD_DEEPBIND; +#endif + return dlopen(p.c_str(), flags); +} +void* pl_dlsym(void* h, const char* name) { + return dlsym(h, name); +} +void pl_dlclose(void* h) { + dlclose(h); +} +std::string pl_dlerror() { + const char* e = dlerror(); + return e ? e : "(unknown error)"; +} +#endif +} + +namespace dusk::mods::loader { +NativeModule::NativeModule() noexcept : handle(nullptr) { +} + +NativeModule::NativeModule(NativeModule&& other) noexcept { + handle = other.handle; + other.handle = nullptr; +} + +NativeModule& NativeModule::operator=(NativeModule&& other) noexcept { + handle = other.handle; + other.handle = nullptr; + return *this; +} + +NativeModule::NativeModule(const std::filesystem::path& path) { + handle = pl_dlopen(path); + if (!handle) { + throw std::runtime_error(pl_dlerror()); + } +} + +NativeModule::~NativeModule() { + if (handle) { + pl_dlclose(handle); + } +} + +void* NativeModule::LookupSymbol(const char* name) const { + return pl_dlsym(handle, name); +} +} // namespace dusk::mods::loader diff --git a/src/dusk/mods/loader/native_module.hpp b/src/dusk/mods/loader/native_module.hpp new file mode 100644 index 0000000000..ff2acf9632 --- /dev/null +++ b/src/dusk/mods/loader/native_module.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include + +namespace dusk::mods::loader { +class NativeModule final { +public: + NativeModule() noexcept; + NativeModule(const NativeModule& other) = delete; + NativeModule(NativeModule&& other) noexcept; + explicit NativeModule(const std::filesystem::path& path); + ~NativeModule(); + + void* LookupSymbol(const char* name) const; + + template + T LookupSymbol(const char* name) const { + return reinterpret_cast(LookupSymbol(name)); + } + + NativeModule& operator=(NativeModule&& other) noexcept; + +#if defined(_WIN32) + static constexpr auto LibraryExtension = ".dll"; +#elif defined(__APPLE__) + static constexpr auto LibraryExtension = ".dylib"; +#else + static constexpr auto LibraryExtension = ".so"; +#endif + +private: + void* handle; +}; + +} diff --git a/src/dusk/mods/svc/host.cpp b/src/dusk/mods/svc/host.cpp new file mode 100644 index 0000000000..f107aa0b84 --- /dev/null +++ b/src/dusk/mods/svc/host.cpp @@ -0,0 +1,76 @@ +#include "registry.hpp" + +#include "dusk/mods/loader/loader.hpp" + +namespace dusk::mods::svc { +namespace { + +ModResult host_get_service(ModContext*, const char* serviceId, const uint16_t majorVersion, + const uint16_t minMinorVersion, const void** outService) { + if (outService == nullptr) { + return MOD_INVALID_ARGUMENT; + } + *outService = nullptr; + const auto* service = find_service(serviceId, majorVersion, minMinorVersion); + if (service == nullptr) { + return MOD_UNAVAILABLE; + } + *outService = service->service; + return MOD_OK; +} + +ModResult host_publish_service( + ModContext* context, const char* serviceId, const uint16_t majorVersion, const void* service) { + auto* mod = mod_from_context(context); + if (mod == nullptr || !valid_service_id(serviceId) || service == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + return publish_deferred_service(*mod, serviceId, majorVersion, service); +} + +void host_fail(ModContext* context, const ModResult code, const char* message) { + auto* mod = mod_from_context(context); + if (mod != nullptr) { + fail_mod(*mod, code, message != nullptr ? message : "mod reported failure"); + } +} + +const char* host_mod_id(ModContext* context) { + const auto* mod = mod_from_context(context); + return mod != nullptr ? mod->metadata.id.c_str() : ""; +} + +const char* host_mod_name(ModContext* context) { + const auto* mod = mod_from_context(context); + return mod != nullptr ? mod->metadata.name.c_str() : ""; +} + +const char* host_mod_version(ModContext* context) { + const auto* mod = mod_from_context(context); + return mod != nullptr ? mod->metadata.version.c_str() : ""; +} + +const char* host_mod_dir(ModContext* context) { + const auto* mod = mod_from_context(context); + return mod != nullptr ? mod->dir.c_str() : ""; +} + +constexpr HostService s_hostService{ + .header = SERVICE_HEADER(HostService, HOST_SERVICE_MAJOR, HOST_SERVICE_MINOR), + .get_service = host_get_service, + .publish_service = host_publish_service, + .fail = host_fail, + .mod_id = host_mod_id, + .mod_name = host_mod_name, + .mod_version = host_mod_version, + .mod_dir = host_mod_dir, +}; + +} // namespace + +const HostService& host_service() { + return s_hostService; +} + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/log.cpp b/src/dusk/mods/svc/log.cpp new file mode 100644 index 0000000000..5d10d8cac2 --- /dev/null +++ b/src/dusk/mods/svc/log.cpp @@ -0,0 +1,64 @@ +#include "registry.hpp" + +#include "dusk/logging.h" +#include "dusk/mods/loader/loader.hpp" + +namespace dusk::mods::svc { +namespace { + +void log_write(ModContext* context, const LogLevel level, const char* message) { + const char* text = message != nullptr ? message : ""; + switch (level) { + case LOG_LEVEL_TRACE: + case LOG_LEVEL_DEBUG: + DuskLog.debug("[{}] {}", mod_id_from_context(context), text); + break; + case LOG_LEVEL_INFO: + DuskLog.info("[{}] {}", mod_id_from_context(context), text); + break; + case LOG_LEVEL_WARN: + DuskLog.warn("[{}] {}", mod_id_from_context(context), text); + break; + case LOG_LEVEL_ERROR: + DuskLog.error("[{}] {}", mod_id_from_context(context), text); + break; + } +} + +void log_trace(ModContext* context, const char* message) { + log_write(context, LOG_LEVEL_TRACE, message); +} + +void log_debug(ModContext* context, const char* message) { + log_write(context, LOG_LEVEL_DEBUG, message); +} + +void log_info(ModContext* context, const char* message) { + log_write(context, LOG_LEVEL_INFO, message); +} + +void log_warn(ModContext* context, const char* message) { + log_write(context, LOG_LEVEL_WARN, message); +} + +void log_error(ModContext* context, const char* message) { + log_write(context, LOG_LEVEL_ERROR, message); +} + +constexpr LogService s_logService{ + .header = SERVICE_HEADER(LogService, LOG_SERVICE_MAJOR, LOG_SERVICE_MINOR), + .write = log_write, + .trace = log_trace, + .debug = log_debug, + .info = log_info, + .warn = log_warn, + .error = log_error, +}; + +} // namespace + +const LogService& log_service() { + return s_logService; +} + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp new file mode 100644 index 0000000000..9e61d0791f --- /dev/null +++ b/src/dusk/mods/svc/registry.cpp @@ -0,0 +1,259 @@ +#include "registry.hpp" + +#include "dusk/logging.h" +#include "dusk/mods/loader/loader.hpp" + +#include +#include + +namespace dusk::mods::svc { +namespace { + +std::unordered_map s_services; + +std::string service_key(std::string_view id, const uint16_t majorVersion) { + std::string key{id}; + key.push_back('\x1f'); + key += std::to_string(majorVersion); + return key; +} + +const char* mod_id(const LoadedMod* mod) { + return mod != nullptr ? mod->metadata.id.c_str() : "dusklight"; +} + +bool validate_service_header(const ServiceHeader* header, const char* serviceId, + const uint16_t majorVersion, const uint16_t minorVersion, LoadedMod* provider) { + if (header == nullptr) { + DuskLog.error("[{}] service '{}' has null header", mod_id(provider), serviceId); + return false; + } + if (header->struct_size < sizeof(ServiceHeader)) { + DuskLog.error("[{}] service '{}' has invalid header size {}", mod_id(provider), serviceId, + header->struct_size); + return false; + } + if (header->major_version != majorVersion || header->minor_version != minorVersion) { + DuskLog.error("[{}] service '{}' header version {}.{} does not match export {}.{}", + mod_id(provider), serviceId, header->major_version, header->minor_version, majorVersion, + minorVersion); + return false; + } + return true; +} + +} // namespace + +bool valid_service_id(const char* serviceId) { + return serviceId != nullptr && serviceId[0] != '\0'; +} + +ModResult register_service(const char* serviceId, const uint16_t majorVersion, + const uint16_t minorVersion, const void* service, LoadedMod* provider, const bool deferred) { + if (!valid_service_id(serviceId)) { + DuskLog.error("[{}] attempted to register a service with no id", mod_id(provider)); + return MOD_INVALID_ARGUMENT; + } + + if (!deferred && !validate_service_header(static_cast(service), serviceId, + majorVersion, minorVersion, provider)) + { + return MOD_INVALID_ARGUMENT; + } + + const auto key = service_key(serviceId, majorVersion); + if (s_services.contains(key)) { + DuskLog.error("[{}] duplicate service '{}@{}'", mod_id(provider), serviceId, majorVersion); + return MOD_CONFLICT; + } + + s_services.emplace(key, ServiceRecord{ + serviceId, + majorVersion, + minorVersion, + service, + provider, + deferred, + }); + return MOD_OK; +} + +ModResult publish_deferred_service( + LoadedMod& provider, const char* serviceId, const uint16_t majorVersion, const void* service) { + if (!valid_service_id(serviceId) || service == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + const auto it = s_services.find(service_key(serviceId, majorVersion)); + if (it == s_services.end() || !it->second.deferred || it->second.provider != &provider) { + DuskLog.error("[{}] tried to publish undeclared service '{}@{}'", provider.metadata.id, + serviceId, majorVersion); + return MOD_UNSUPPORTED; + } + auto& record = it->second; + if (record.service != nullptr) { + return MOD_CONFLICT; + } + + const auto* header = static_cast(service); + if (!validate_service_header(header, serviceId, majorVersion, record.minorVersion, &provider)) { + return MOD_INVALID_ARGUMENT; + } + + record.service = service; + record.minorVersion = header->minor_version; + return MOD_OK; +} + +void remove_services_for_provider(const LoadedMod& provider) { + std::erase_if( + s_services, [&](const auto& entry) { return entry.second.provider == &provider; }); +} + +const ServiceRecord* find_service( + const char* serviceId, const uint16_t majorVersion, const uint16_t minMinorVersion) { + const auto* record = find_service_record(serviceId, majorVersion); + if (record == nullptr || record->service == nullptr || record->minorVersion < minMinorVersion) { + return nullptr; + } + return record; +} + +const ServiceRecord* find_service_record(const char* serviceId, const uint16_t majorVersion) { + if (!valid_service_id(serviceId)) { + return nullptr; + } + + const auto it = s_services.find(service_key(serviceId, majorVersion)); + return it != s_services.end() ? &it->second : nullptr; +} + +void clear_services() { + s_services.clear(); +} + +} // namespace dusk::mods::svc + +namespace dusk::mods { + +void ModLoader::init_services() { + svc::clear_services(); + svc::register_service(HOST_SERVICE_ID, HOST_SERVICE_MAJOR, HOST_SERVICE_MINOR, + &svc::host_service(), nullptr, false); + svc::register_service( + LOG_SERVICE_ID, LOG_SERVICE_MAJOR, LOG_SERVICE_MINOR, &svc::log_service(), nullptr, false); +} + +bool ModLoader::register_static_service_exports(LoadedMod& mod) { + if (!mod.native || mod.native->manifest == nullptr) { + return true; + } + + const auto& manifest = *mod.native->manifest; + for (size_t i = 0; i < manifest.export_count; ++i) { + const auto& serviceExport = manifest.exports[i]; + if (serviceExport.struct_size != sizeof(ServiceExport) || + !svc::valid_service_id(serviceExport.service_id)) + { + fail_mod(mod, MOD_INVALID_ARGUMENT, "invalid service export descriptor"); + return false; + } + + const bool deferred = (serviceExport.flags & SERVICE_EXPORT_DEFERRED) != 0; + if (!deferred && serviceExport.service == nullptr) { + fail_mod(mod, MOD_INVALID_ARGUMENT, "static service export has null service pointer"); + return false; + } + + const auto result = + svc::register_service(serviceExport.service_id, serviceExport.major_version, + serviceExport.minor_version, serviceExport.service, &mod, deferred); + if (result != MOD_OK) { + fail_mod(mod, result, "service export registration failed"); + return false; + } + } + + return true; +} + +std::string ModLoader::describe_missing_import( + const char* serviceId, const uint16_t majorVersion, const uint16_t minMinorVersion) const { + if (const auto* record = svc::find_service_record(serviceId, majorVersion)) { + if (record->service == nullptr) { + return fmt::format("required service {}@{} was never published by provider '{}'", + serviceId, majorVersion, svc::mod_id(record->provider)); + } + return fmt::format("required service {}@{} only provides minor version {} (need >= {})", + serviceId, majorVersion, record->minorVersion, minMinorVersion); + } + + // No record can also mean the provider failed or is disabled and its services were removed. + for (const auto& other : mods()) { + if ((other.active && !other.loadFailed) || !other.native || + other.native->manifest == nullptr) + { + continue; + } + const auto& manifest = *other.native->manifest; + for (size_t i = 0; i < manifest.export_count; ++i) { + const auto& serviceExport = manifest.exports[i]; + if (serviceExport.struct_size == sizeof(ServiceExport) && + svc::valid_service_id(serviceExport.service_id) && + std::string_view{serviceExport.service_id} == serviceId && + serviceExport.major_version == majorVersion) + { + return fmt::format("required service {}@{} unavailable: provider '{}' {}", + serviceId, majorVersion, other.metadata.id, + other.loadFailed ? "failed to load" : "is disabled"); + } + } + } + + return fmt::format("required service unavailable: {}@{}", serviceId, majorVersion); +} + +bool ModLoader::resolve_service_imports(LoadedMod& mod) { + if (!mod.native || mod.native->manifest == nullptr) { + return true; + } + + const auto& manifest = *mod.native->manifest; + for (size_t i = 0; i < manifest.import_count; ++i) { + const auto& serviceImport = manifest.imports[i]; + if (serviceImport.struct_size != sizeof(ServiceImport) || + !svc::valid_service_id(serviceImport.service_id) || serviceImport.slot == nullptr) + { + fail_mod(mod, MOD_INVALID_ARGUMENT, "invalid service import descriptor"); + return false; + } + + const auto* service = svc::find_service( + serviceImport.service_id, serviceImport.major_version, serviceImport.min_minor_version); + if (service == nullptr) { + *static_cast(serviceImport.slot) = nullptr; + if ((serviceImport.flags & SERVICE_IMPORT_OPTIONAL) != 0) { + continue; + } + + fail_mod(mod, MOD_UNAVAILABLE, + describe_missing_import(serviceImport.service_id, serviceImport.major_version, + serviceImport.min_minor_version)); + return false; + } + + *static_cast(serviceImport.slot) = service->service; + } + + return true; +} + +void ModLoader::clear_services() { + svc::clear_services(); +} + +void ModLoader::fail_mod(LoadedMod& mod, const ModResult code, std::string_view message) { + mods::fail_mod(mod, code, message); +} + +} // namespace dusk::mods diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp new file mode 100644 index 0000000000..0b850840d9 --- /dev/null +++ b/src/dusk/mods/svc/registry.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "dusk/mod_loader.hpp" +#include "mods/svc/host.h" +#include "mods/svc/log.h" + +#include +#include + +namespace dusk::mods::svc { + +struct ServiceRecord { + std::string id; + uint16_t majorVersion = 0; + uint16_t minorVersion = 0; + const void* service = nullptr; + LoadedMod* provider = nullptr; + bool deferred = false; +}; + +bool valid_service_id(const char* serviceId); +ModResult register_service(const char* serviceId, uint16_t majorVersion, uint16_t minorVersion, + const void* service, LoadedMod* provider, bool deferred); +ModResult publish_deferred_service( + LoadedMod& provider, const char* serviceId, uint16_t majorVersion, const void* service); +void remove_services_for_provider(const LoadedMod& provider); +const ServiceRecord* find_service( + const char* serviceId, uint16_t majorVersion, uint16_t minMinorVersion); +// Unlike find_service, also returns deferred records that have not been published yet. +const ServiceRecord* find_service_record(const char* serviceId, uint16_t majorVersion); +void clear_services(); + +const HostService& host_service(); +const LogService& log_service(); + +} // namespace dusk::mods::svc diff --git a/src/f_ap/f_ap_game.cpp b/src/f_ap/f_ap_game.cpp index ca11ccbe64..f4ccc32da0 100644 --- a/src/f_ap/f_ap_game.cpp +++ b/src/f_ap/f_ap_game.cpp @@ -18,6 +18,7 @@ #include "dusk/frame_interpolation.h" #include "dusk/livesplit.h" #include "dusk/logging.h" +#include "dusk/mod_loader.hpp" #include "f_op/f_op_camera_mng.h" #include "f_op/f_op_draw_tag.h" #include "f_op/f_op_overlap_mng.h" @@ -815,6 +816,8 @@ static void duskExecute() { if (dusk::getSettings().game.infiniteOxygen) { dComIfGp_setOxygen(dComIfGp_getMaxOxygen()); } + + dusk::mods::ModLoader::instance().tick(); } #endif diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index 8ed11c54c4..ce029035b1 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -60,6 +60,7 @@ #include "dusk/imgui/ImGuiConsole.hpp" #include "dusk/imgui/ImGuiEngine.hpp" #include "dusk/iso_validate.hpp" +#include "dusk/mod_loader.hpp" #include "dusk/logging.h" #include "dusk/main.h" #include "dusk/ui/menu_bar.hpp" @@ -363,6 +364,7 @@ void main01(void) { } while (dusk::IsRunning); exit:; + dusk::mods::ModLoader::instance().shutdown(); dusk::ui::shutdown(); } @@ -512,6 +514,7 @@ int game_main(int argc, char* argv[]) { ("h,help", "Print usage") ("console", "Show the Windows console window for logs", cxxopts::value()->default_value("false")->implicit_value("true")) ("dvd", "Path to DVD image file", cxxopts::value()) + ("mods", "Path to mods directory", cxxopts::value()) ("backend", "Graphics API backend to use (auto, d3d12, d3d11, metal, vulkan, null)", cxxopts::value()) ("cvar", "Override configuration variables without modifying config", cxxopts::value>()); @@ -785,8 +788,61 @@ int game_main(int argc, char* argv[]) { // mDoMain::developmentMode = 1; // Force Dev Mode for Debugging mDoDvdThd::SyncWidthSound = false; - OSReport("Starting main01 (Game Loop)...\n"); + // Mod search directories, highest priority first: user dir (--mods replaces it), then + // mods/ next to the app, then install-bundled mods inside the app bundle. + { + std::vector modDirs; + if (parsed_arg_options.contains("mods") && + !parsed_arg_options["mods"].as().empty()) + { + modDirs.push_back({.path = parsed_arg_options["mods"].as()}); + } else { + modDirs.push_back({.path = dusk::ConfigPath / "mods"}); + } +#if TARGET_ANDROID + // APK-bundled mods are extracted to internal storage + // by DuskActivity before SDL_main runs. + modDirs.push_back({ + .path = dusk::CachePath / "bundled_mods", + }); +#elif defined(__APPLE__) && (TARGET_OS_IOS || TARGET_OS_TV) + modDirs.push_back({ + .path = dusk::data::base_path_relative("mods"), + .inPlaceNative = true, + .nativeLibDir = dusk::data::base_path_relative("Frameworks"), + }); +#else +#if defined(__APPLE__) + // Base path is Contents/Resources; search up for dev mods + // TODO: scope to non-CI builds + modDirs.push_back({ + .path = dusk::data::base_path_relative("../../../mods").lexically_normal(), + .inPlaceNative = true, + }); + // Contents/Resources/mods + modDirs.push_back({ + .path = dusk::data::base_path_relative("mods"), + .inPlaceNative = true, + }); +#else + modDirs.push_back({ + .path = dusk::data::base_path_relative("mods"), + .inPlaceNative = true, + }); +#endif +#endif + dusk::mods::ModLoader::instance().set_search_dirs(std::move(modDirs)); + } +#if TARGET_ANDROID + // A user-relocated data dir can live on external storage, which is mounted noexec. + // Native mod libraries must be extracted to internal storage. + dusk::mods::ModLoader::instance().set_cache_dir(dusk::CachePath / "mod_cache"); +#endif + DuskLog.info("Initializing mods..."); + dusk::mods::ModLoader::instance().init(); + + OSReport("Starting main01 (Game Loop)...\n"); main01(); diff --git a/tools/mod_template/CMakeLists.txt b/tools/mod_template/CMakeLists.txt new file mode 100644 index 0000000000..3d40c3c5c4 --- /dev/null +++ b/tools/mod_template/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.25) +project(template_mod CXX) + +if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + set(DUSK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../.." CACHE PATH "Path to dusk source root") + option(DUSK_MOD_USE_FULL_TREE "Use full build instead of the minimal mod SDK" OFF) + set(CMAKE_POSITION_INDEPENDENT_CODE ON) + if (DUSK_MOD_USE_FULL_TREE) + add_subdirectory("${DUSK_DIR}" dusk EXCLUDE_FROM_ALL) + else () + add_subdirectory("${DUSK_DIR}/sdk" dusk-sdk EXCLUDE_FROM_ALL) + endif () +endif () + +add_mod(template_mod + SOURCES src/mod.cpp + MOD_JSON mod.json + RES_DIR res +) diff --git a/tools/mod_template/mod.json b/tools/mod_template/mod.json new file mode 100644 index 0000000000..46507a87ee --- /dev/null +++ b/tools/mod_template/mod.json @@ -0,0 +1,7 @@ +{ + "id": "com.example.mod", + "name": "Template Mod", + "version": "1.0.0", + "author": "You", + "description": "An example Dusklight mod" +} diff --git a/tools/mod_template/res/.gitkeep b/tools/mod_template/res/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/mod_template/src/mod.cpp b/tools/mod_template/src/mod.cpp new file mode 100644 index 0000000000..35d19331fd --- /dev/null +++ b/tools/mod_template/src/mod.cpp @@ -0,0 +1,22 @@ +#include "mods/service.hpp" +#include "mods/svc/log.h" + +DEFINE_MOD(); +IMPORT_SERVICE(LogService, svc_log); + +extern "C" { + +MOD_EXPORT ModResult mod_initialize(ModError*) { + svc_log->info(mod_ctx, "template_mod initialized"); + return MOD_OK; +} + +MOD_EXPORT ModResult mod_update(ModError*) { + return MOD_OK; +} + +MOD_EXPORT ModResult mod_shutdown(ModError*) { + svc_log->info(mod_ctx, "template_mod unloaded"); + return MOD_OK; +} +} From 9b75dc5fd2fb4eb702e1a8c5f3e9bcb593edf7a6 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 7 Jul 2026 11:09:44 -0600 Subject: [PATCH 061/150] Service lifecycle events --- docs/modding.md | 20 +++++++++ include/dusk/mod_loader.hpp | 4 +- include/mods/svc/host.h | 35 +++++++++++++++- src/dusk/mods/loader/loader.cpp | 10 +++++ src/dusk/mods/svc/host.cpp | 73 +++++++++++++++++++++++++++++++-- src/dusk/mods/svc/log.cpp | 9 ++-- src/dusk/mods/svc/registry.cpp | 69 +++++++++++++++++++++++++++++-- src/dusk/mods/svc/registry.hpp | 36 +++++++++++++++- 8 files changed, 241 insertions(+), 15 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index 12c7881ade..f3332da0e9 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -156,6 +156,26 @@ svc_host->fail(mod_ctx, MOD_ERROR, "something unrecoverable happened"); // disa `get_service`/`publish_service` provide dynamic service lookup; see [Advanced](#advanced-exporting-services). +**Lifecycle watches.** If your mod provides a service that hands out per-caller state (registrations, callbacks, handles), +watch other mods' lifecycle and drop what you hold for a mod when it detaches. + +```cpp +IMPORT_SERVICE_VERSION(HostService, svc_host, 1); + +void on_mod_lifecycle(ModContext* ctx, ModContext* subject, const char* subject_id, + ModLifecycleEvent event, void* user_data) { + if (event == MOD_LIFECYCLE_DETACHED) { + drop_state_for(subject); // same ModContext* the subject passed into your service + } +} + +uint64_t watch = 0; +svc_host->watch_mod_lifecycle(mod_ctx, on_mod_lifecycle, nullptr, &watch); +``` + +`MOD_LIFECYCLE_DETACHED` fires on the game thread at a lifecycle safe point, after the subject's `mod_shutdown` ran and +every service dropped its state. For your own mod's teardown, use `mod_shutdown` instead. + --- ## Runtime Lifecycle diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index 2a67e209f7..20b4de8e70 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -216,8 +216,8 @@ private: // Registers exports (if needed), resolves imports and runs mod_initialize. // Returns whether the mod ended up active; failures go through fail_mod. bool activate_mod(LoadedMod& mod); - // Runs mod_shutdown (if needed), removes the mod's services, and unloads the native lib. - // Must only run with no mod code on the stack (startup, shutdown, or top of tick). + // Runs mod_shutdown (if needed), detaches the mod from every service, and unloads the + // native lib. Must only run with no mod code on the stack (startup, shutdown, or top of tick). void deactivate_mod(LoadedMod& mod); void init_services(); bool register_static_service_exports(LoadedMod& mod); diff --git a/include/mods/svc/host.h b/include/mods/svc/host.h index 6f5844a88f..61d0831dae 100644 --- a/include/mods/svc/host.h +++ b/include/mods/svc/host.h @@ -9,7 +9,28 @@ #define HOST_SERVICE_ID "dev.twilitrealm.dusklight.host" #define HOST_SERVICE_MAJOR 1u -#define HOST_SERVICE_MINOR 0u +#define HOST_SERVICE_MINOR 1u + +/* + * Ignore unknown values: later service minors may add events. + */ +typedef enum ModLifecycleEvent { + /* + * The subject mod is gone: its mod_shutdown has run (when it initialized at all) and + * every service has already dropped the state it held for it. The subject's library is + * still mapped, so pointers into it are valid to compare against, but they must not be + * called or dereferenced after the callback returns. Drop everything keyed to the + * mod: callbacks it registered, its ModContext*, state indexed by it. + */ + MOD_LIFECYCLE_DETACHED = 0, +} ModLifecycleEvent; + +/* + * ctx is the watching mod's own context; subject identifies the mod the event is about. + * subject_id is valid only for the duration of the call. + */ +typedef void (*ModLifecycleFn)(ModContext* ctx, ModContext* subject, const char* subject_id, + ModLifecycleEvent event, void* user_data); typedef struct HostService { ServiceHeader header; @@ -50,6 +71,18 @@ typedef struct HostService { * and reload within a session, but the directory is wiped at game startup. */ const char* (*mod_dir)(ModContext* ctx); + + /* + * Observe other mods' lifecycle events. Any mod whose service hands out per-caller state + * (registrations, callbacks, handles) should watch for MOD_LIFECYCLE_DETACHED and drop what it + * holds for the subject. + * + * Callbacks fire on the game thread at a lifecycle safe point (never mid-frame), for + * every mod but the watcher itself (use mod_shutdown for self-cleanup). + */ + ModResult (*watch_mod_lifecycle)( + ModContext* ctx, ModLifecycleFn fn, void* user_data, uint64_t* out_handle); + ModResult (*unwatch_mod_lifecycle)(ModContext* ctx, uint64_t handle); } HostService; #ifdef __cplusplus diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp index 769ec28e87..a8459717e6 100644 --- a/src/dusk/mods/loader/loader.cpp +++ b/src/dusk/mods/loader/loader.cpp @@ -599,6 +599,8 @@ bool ModLoader::activate_mod(LoadedMod& mod) { warn_unpublished_deferred_exports(mod); if (!mod.active) { + // Failed initialization may have left hooks or other service state behind + svc::modules_mod_detached(mod); return false; } @@ -624,6 +626,7 @@ void ModLoader::deactivate_mod(LoadedMod& mod) { svc::remove_services_for_provider(mod); mod.servicesRegistered = false; } + svc::modules_mod_detached(mod); unload_native(mod); mod.active = false; @@ -739,6 +742,8 @@ void ModLoader::init() { } } + svc::modules_lifecycle_applied(); + auto active = std::ranges::count_if(mods(), [](const LoadedMod& m) { return m.active; }); Log.info("{}/{} mod(s) active", active, m_mods.size()); @@ -1040,11 +1045,14 @@ void ModLoader::apply_pending_requests() { apply_lifecycle_change(*mod, request.kind == RequestKind::Reload); } + svc::modules_lifecycle_applied(); + auto active = std::ranges::count_if(mods(), [](const LoadedMod& m) { return m.active; }); Log.info("{}/{} mod(s) active", active, m_mods.size()); } void ModLoader::tick() { + svc::modules_frame_begin(); apply_pending_requests(); for (auto& mod : mods()) { @@ -1064,6 +1072,7 @@ void ModLoader::tick() { } } + svc::modules_frame_end(); config_flush_if_dirty(false); } @@ -1080,6 +1089,7 @@ void ModLoader::shutdown() { m_mods.clear(); drain_retired_natives(); + svc::modules_shutdown(); clear_services(); config_flush_if_dirty(true); Log.info("all mods unloaded"); diff --git a/src/dusk/mods/svc/host.cpp b/src/dusk/mods/svc/host.cpp index f107aa0b84..6255b13c42 100644 --- a/src/dusk/mods/svc/host.cpp +++ b/src/dusk/mods/svc/host.cpp @@ -1,6 +1,10 @@ #include "registry.hpp" #include "dusk/mods/loader/loader.hpp" +#include "fmt/format.h" + +#include +#include namespace dusk::mods::svc { namespace { @@ -56,6 +60,63 @@ const char* host_mod_dir(ModContext* context) { return mod != nullptr ? mod->dir.c_str() : ""; } +struct LifecycleWatcher { + uint64_t handle = 0; + LoadedMod* owner = nullptr; + ModLifecycleFn fn = nullptr; + void* userData = nullptr; +}; + +std::vector s_watchers; +uint64_t s_nextWatchHandle = 1; + +ModResult host_watch_mod_lifecycle( + ModContext* context, ModLifecycleFn fn, void* userData, uint64_t* outHandle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || fn == nullptr || outHandle == nullptr) { + return MOD_INVALID_ARGUMENT; + } + const auto handle = s_nextWatchHandle++; + s_watchers.push_back({handle, mod, fn, userData}); + *outHandle = handle; + return MOD_OK; +} + +ModResult host_unwatch_mod_lifecycle(ModContext* context, const uint64_t handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr) { + return MOD_INVALID_ARGUMENT; + } + const auto erased = std::erase_if(s_watchers, + [&](const LifecycleWatcher& w) { return w.handle == handle && w.owner == mod; }); + return erased != 0 ? MOD_OK : MOD_INVALID_ARGUMENT; +} + +void host_mod_detached(LoadedMod& mod) { + // The subject's own watches go first: a mod is never notified about its own teardown. + std::erase_if(s_watchers, [&](const LifecycleWatcher& w) { return w.owner == &mod; }); + + // Iterate a snapshot: callbacks may watch/unwatch, and a failing callback erases the + // failing mod's services. + const auto snapshot = s_watchers; + for (const auto& watcher : snapshot) { + const bool alive = std::ranges::any_of( + s_watchers, [&](const LifecycleWatcher& w) { return w.handle == watcher.handle; }); + if (!alive) { + continue; + } + try { + watcher.fn(watcher.owner->context.get(), mod.context.get(), mod.metadata.id.c_str(), + MOD_LIFECYCLE_DETACHED, watcher.userData); + } catch (const std::exception& e) { + fail_mod(*watcher.owner, MOD_ERROR, + fmt::format("exception in mod lifecycle callback: {}", e.what())); + } catch (...) { + fail_mod(*watcher.owner, MOD_ERROR, "unknown exception in mod lifecycle callback"); + } + } +} + constexpr HostService s_hostService{ .header = SERVICE_HEADER(HostService, HOST_SERVICE_MAJOR, HOST_SERVICE_MINOR), .get_service = host_get_service, @@ -65,12 +126,18 @@ constexpr HostService s_hostService{ .mod_name = host_mod_name, .mod_version = host_mod_version, .mod_dir = host_mod_dir, + .watch_mod_lifecycle = host_watch_mod_lifecycle, + .unwatch_mod_lifecycle = host_unwatch_mod_lifecycle, }; } // namespace -const HostService& host_service() { - return s_hostService; -} +constinit const ServiceModule g_hostModule{ + .id = HOST_SERVICE_ID, + .majorVersion = HOST_SERVICE_MAJOR, + .minorVersion = HOST_SERVICE_MINOR, + .service = &s_hostService, + .modDetached = host_mod_detached, +}; } // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/log.cpp b/src/dusk/mods/svc/log.cpp index 5d10d8cac2..d9fc88eae7 100644 --- a/src/dusk/mods/svc/log.cpp +++ b/src/dusk/mods/svc/log.cpp @@ -57,8 +57,11 @@ constexpr LogService s_logService{ } // namespace -const LogService& log_service() { - return s_logService; -} +constinit const ServiceModule g_logModule{ + .id = LOG_SERVICE_ID, + .majorVersion = LOG_SERVICE_MAJOR, + .minorVersion = LOG_SERVICE_MINOR, + .service = &s_logService, +}; } // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 9e61d0791f..3951822bad 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -3,13 +3,16 @@ #include "dusk/logging.h" #include "dusk/mods/loader/loader.hpp" +#include #include #include +#include namespace dusk::mods::svc { namespace { std::unordered_map s_services; +std::vector s_modules; std::string service_key(std::string_view id, const uint16_t majorVersion) { std::string key{id}; @@ -130,6 +133,60 @@ const ServiceRecord* find_service_record(const char* serviceId, const uint16_t m void clear_services() { s_services.clear(); + s_modules.clear(); +} + +ModResult register_module(const ServiceModule& module) { + const auto result = register_service( + module.id, module.majorVersion, module.minorVersion, module.service, nullptr, false); + if (result != MOD_OK) { + return result; + } + s_modules.push_back(&module); + if (module.initialize != nullptr) { + module.initialize(); + } + return MOD_OK; +} + +void modules_mod_detached(LoadedMod& mod) { + for (const auto* module : s_modules | std::views::reverse) { + if (module->modDetached != nullptr) { + module->modDetached(mod); + } + } +} + +void modules_lifecycle_applied() { + for (const auto* module : s_modules) { + if (module->lifecycleApplied != nullptr) { + module->lifecycleApplied(); + } + } +} + +void modules_frame_begin() { + for (const auto* module : s_modules) { + if (module->frameBegin != nullptr) { + module->frameBegin(); + } + } +} + +void modules_frame_end() { + for (const auto* module : s_modules) { + if (module->frameEnd != nullptr) { + module->frameEnd(); + } + } +} + +void modules_shutdown() { + for (const auto* module : s_modules | std::views::reverse) { + if (module->shutdown != nullptr) { + module->shutdown(); + } + } } } // namespace dusk::mods::svc @@ -138,10 +195,14 @@ namespace dusk::mods { void ModLoader::init_services() { svc::clear_services(); - svc::register_service(HOST_SERVICE_ID, HOST_SERVICE_MAJOR, HOST_SERVICE_MINOR, - &svc::host_service(), nullptr, false); - svc::register_service( - LOG_SERVICE_ID, LOG_SERVICE_MAJOR, LOG_SERVICE_MINOR, &svc::log_service(), nullptr, false); + for (const auto* module : + { + &svc::g_hostModule, + &svc::g_logModule, + }) + { + svc::register_module(*module); + } } bool ModLoader::register_static_service_exports(LoadedMod& mod) { diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 0b850840d9..4f59aabff1 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -18,6 +18,31 @@ struct ServiceRecord { bool deferred = false; }; +// A host service and its lifecycle hooks. Every hook is optional. Frame and lifecycle hooks run in +// registration order, modDetached in reverse registration order. +struct ServiceModule { + const char* id = nullptr; + uint16_t majorVersion = 0; + uint16_t minorVersion = 0; + const void* service = nullptr; + + // One-time setup, at registration (ModLoader::init_services). + void (*initialize)() = nullptr; + // A mod is going away (deactivation or failed activation): drop all state held for it. + // Runs after the mod's mod_shutdown and before its library unloads, so pointers into + // the mod are still valid but must not be called. + void (*modDetached)(LoadedMod& mod) = nullptr; + // A batch of (de)activations finished applying: startup, and runtime enable/disable/ + // reload requests. The set of active mods is stable when this runs. + void (*lifecycleApplied)() = nullptr; + // Top of ModLoader::tick, before pending lifecycle requests apply. + void (*frameBegin)() = nullptr; + // End of ModLoader::tick, after every mod_update. + void (*frameEnd)() = nullptr; + // ModLoader::shutdown, after every mod has deactivated. + void (*shutdown)() = nullptr; +}; + bool valid_service_id(const char* serviceId); ModResult register_service(const char* serviceId, uint16_t majorVersion, uint16_t minorVersion, const void* service, LoadedMod* provider, bool deferred); @@ -30,7 +55,14 @@ const ServiceRecord* find_service( const ServiceRecord* find_service_record(const char* serviceId, uint16_t majorVersion); void clear_services(); -const HostService& host_service(); -const LogService& log_service(); +ModResult register_module(const ServiceModule& module); +void modules_mod_detached(LoadedMod& mod); +void modules_lifecycle_applied(); +void modules_frame_begin(); +void modules_frame_end(); +void modules_shutdown(); + +extern const ServiceModule g_hostModule; +extern const ServiceModule g_logModule; } // namespace dusk::mods::svc From c876ea558f2f0e28e5a6045418facbbf0249c8f6 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 7 Jul 2026 11:32:13 -0600 Subject: [PATCH 062/150] Config service --- docs/modding.md | 36 +++ files.cmake | 2 + include/mods/svc/config.h | 107 ++++++++ src/dusk/mods/loader/loader.cpp | 29 +- src/dusk/mods/loader/loader.hpp | 3 - src/dusk/mods/svc/config.cpp | 451 ++++++++++++++++++++++++++++++++ src/dusk/mods/svc/config.hpp | 9 + src/dusk/mods/svc/registry.cpp | 1 + src/dusk/mods/svc/registry.hpp | 1 + 9 files changed, 609 insertions(+), 30 deletions(-) create mode 100644 include/mods/svc/config.h create mode 100644 src/dusk/mods/svc/config.cpp create mode 100644 src/dusk/mods/svc/config.hpp diff --git a/docs/modding.md b/docs/modding.md index f3332da0e9..28fc19af5f 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -176,6 +176,42 @@ svc_host->watch_mod_lifecycle(mod_ctx, on_mod_lifecycle, nullptr, &watch); `MOD_LIFECYCLE_DETACHED` fires on the game thread at a lifecycle safe point, after the subject's `mod_shutdown` ran and every service dropped its state. For your own mod's teardown, use `mod_shutdown` instead. +### ConfigService (`mods/svc/config.h`) + +Persistent, mod-scoped configuration variables. Each var is stored in the user's `config.json` under +`mod..` (escaping: `.` → `_`, `_` → `__`, so `com.example.my_mod` becomes `com_example_my__mod`), +next to the host's own settings: + +```cpp +IMPORT_SERVICE(ConfigService, svc_config); + +ConfigVarDesc desc = CONFIG_VAR_DESC_INIT; +desc.name = "speedMultiplier"; // 1-64 chars from [A-Za-z0-9_-]; "enabled" is reserved +desc.type = CONFIG_VAR_FLOAT; +desc.default_float = 1.0; +ConfigVarHandle var = 0; +svc_config->register_var(mod_ctx, &desc, &var); + +double speed = 1.0; +svc_config->get_float(mod_ctx, var, &speed); +svc_config->set_float(mod_ctx, var, 2.0); + +// Optional: get notified when the value changes. +void on_speed_changed(ModContext* ctx, ConfigVarHandle var, const ConfigVarValue* value, + const ConfigVarValue* previous, void* user_data) { + /* value->float_value is the new value, previous->float_value the old one */ +} +svc_config->subscribe(mod_ctx, var, on_speed_changed, nullptr, nullptr); +``` + +Types: `CONFIG_VAR_BOOL` (`bool`), `CONFIG_VAR_INT` (`int64_t`), `CONFIG_VAR_FLOAT` (`double`), `CONFIG_VAR_STRING` +(UTF-8; `get_string` copies into a caller buffer, pass a `NULL` buffer with size 0 to query the length). Accessors are +typed and must match the registration. + +Change callbacks fire on the game thread whenever the value changes at runtime (your own `set_*` calls included). +Writes that store the same value are silent. Values applied from `config.json` or `--cvar` at registration do +**not** fire callbacks; read the value after `register_var` for the starting state. + --- ## Runtime Lifecycle diff --git a/files.cmake b/files.cmake index fcf603d360..fb4b8dc25c 100644 --- a/files.cmake +++ b/files.cmake @@ -1550,6 +1550,8 @@ set(DUSK_FILES src/dusk/mods/loader/loader.hpp src/dusk/mods/loader/native_module.cpp src/dusk/mods/loader/native_module.hpp + src/dusk/mods/svc/config.cpp + src/dusk/mods/svc/config.hpp src/dusk/mods/svc/host.cpp src/dusk/mods/svc/log.cpp src/dusk/mods/svc/registry.cpp diff --git a/include/mods/svc/config.h b/include/mods/svc/config.h new file mode 100644 index 0000000000..2a26210295 --- /dev/null +++ b/include/mods/svc/config.h @@ -0,0 +1,107 @@ +#pragma once + +#include "mods/api.h" + +#define CONFIG_SERVICE_ID "dev.twilitrealm.dusklight.config" +#define CONFIG_SERVICE_MAJOR 1u +#define CONFIG_SERVICE_MINOR 0u + +/* Handle for a config var registered by the calling mod. 0 is never a valid handle. */ +typedef uint64_t ConfigVarHandle; +/* Handle for a change subscription. 0 is never a valid handle. */ +typedef uint64_t ConfigSubscriptionHandle; + +typedef enum ConfigVarType { + CONFIG_VAR_BOOL = 0, /* bool */ + CONFIG_VAR_INT = 1, /* int64_t */ + CONFIG_VAR_FLOAT = 2, /* double */ + CONFIG_VAR_STRING = 3, /* UTF-8 */ +} ConfigVarType; + +typedef struct ConfigVarDesc { + uint32_t struct_size; + /* Name fragment: 1-64 characters from [A-Za-z0-9_-]. The full config key is + * "mod..", persisted in config.json alongside host settings. + * "enabled" is reserved by the loader. */ + const char* name; + ConfigVarType type; + /* Default value; only the field matching `type` is read. */ + bool default_bool; + int64_t default_int; + double default_float; + const char* default_string; /* NULL means "" */ +} ConfigVarDesc; + +#define CONFIG_VAR_DESC_INIT {sizeof(ConfigVarDesc), NULL, CONFIG_VAR_BOOL, false, 0, 0.0, NULL} + +/* Snapshot of a var's value; only the field matching `type` is meaningful. */ +typedef struct ConfigVarValue { + uint32_t struct_size; + ConfigVarType type; + bool bool_value; + int64_t int_value; + double float_value; + const char* string_value; /* NUL-terminated; NULL for non-string vars */ + size_t string_length; /* excludes the NUL */ +} ConfigVarValue; + +/* + * Fired on the game thread whenever the var's effective value changes at runtime: the calling mod's + * own set_* calls and any other runtime writer. Writes that leave the value unchanged do not fire, + * and neither do values applied from config.json or --cvar during registration. `value` holds the + * new (current) value and `previous` the one it replaced; both snapshots are valid only for the + * duration of the call (copy string_value if you need to keep it). Setting the same var from inside + * its own callback applies the write but is not re-notified. + */ +typedef void (*ConfigChangedFn)(ModContext* ctx, ConfigVarHandle var, const ConfigVarValue* value, + const ConfigVarValue* previous, void* user_data); + +/* + * Scoped configuration variables. + * + * Registrations are owned by the calling mod and removed automatically (subscriptions included) + * when it is disabled, reloaded, or fails. Values are saved to config.json. Writes are debounced, + * not flushed per set. + */ +typedef struct ConfigService { + ServiceHeader header; + + /* Register a config var. If a value for the full key was saved earlier (or set via --cvar), + * it takes effect immediately; otherwise the var starts at the default. Registering a name + * that is already live is MOD_CONFLICT. */ + ModResult (*register_var)( + ModContext* ctx, const ConfigVarDesc* desc, ConfigVarHandle* out_handle); + /* Unregister a var previously registered by the calling mod. Its persisted value is kept. */ + ModResult (*unregister_var)(ModContext* ctx, ConfigVarHandle var); + + /* Typed accessors; the type must match the registration (MOD_INVALID_ARGUMENT otherwise). */ + ModResult (*get_bool)(ModContext* ctx, ConfigVarHandle var, bool* out_value); + ModResult (*set_bool)(ModContext* ctx, ConfigVarHandle var, bool value); + ModResult (*get_int)(ModContext* ctx, ConfigVarHandle var, int64_t* out_value); + ModResult (*set_int)(ModContext* ctx, ConfigVarHandle var, int64_t value); + ModResult (*get_float)(ModContext* ctx, ConfigVarHandle var, double* out_value); + ModResult (*set_float)(ModContext* ctx, ConfigVarHandle var, double value); + /* Copies the NUL-terminated value into buffer. out_length (optional) receives the full + * length excluding the NUL regardless of buffer size; call with buffer == NULL and + * buffer_size == 0 to query the length. A non-NULL buffer that is too small fails with + * MOD_INVALID_ARGUMENT and writes nothing. */ + ModResult (*get_string)( + ModContext* ctx, ConfigVarHandle var, char* buffer, size_t buffer_size, size_t* out_length); + ModResult (*set_string)(ModContext* ctx, ConfigVarHandle var, const char* value); + + /* Subscribe to changes of a var registered by the calling mod. out_handle may be NULL if + * the subscription is never removed manually (cleanup on mod teardown is automatic). */ + ModResult (*subscribe)(ModContext* ctx, ConfigVarHandle var, ConfigChangedFn callback, + void* user_data, ConfigSubscriptionHandle* out_handle); + ModResult (*unsubscribe)(ModContext* ctx, ConfigSubscriptionHandle handle); +} ConfigService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = CONFIG_SERVICE_ID; + static constexpr uint16_t major_version = CONFIG_SERVICE_MAJOR; +}; +#endif diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp index a8459717e6..00c2cc69e8 100644 --- a/src/dusk/mods/loader/loader.cpp +++ b/src/dusk/mods/loader/loader.cpp @@ -3,7 +3,6 @@ #include "dusk/mod_loader.hpp" #include -#include #include #include #include @@ -12,6 +11,7 @@ #include "depgraph.hpp" #include "dusk/config.hpp" +#include "dusk/mods/svc/config.hpp" #include "dusk/io.hpp" #include "dusk/mods/svc/registry.hpp" #include "miniz.h" @@ -966,31 +966,8 @@ void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) { } } -namespace { -bool s_configDirty = false; -std::chrono::steady_clock::time_point s_lastConfigSave{}; -constexpr std::chrono::seconds kConfigSaveDebounce{2}; -} // namespace - -void config_mark_dirty() { - s_configDirty = true; -} - -void config_flush_if_dirty(const bool force) { - if (!s_configDirty) { - return; - } - const auto now = std::chrono::steady_clock::now(); - if (!force && now - s_lastConfigSave < kConfigSaveDebounce) { - return; - } - s_configDirty = false; - s_lastConfigSave = now; - config::save(); -} - void ModLoader::on_enabled_changed(LoadedMod& mod) { - config_mark_dirty(); + svc::config_mark_dirty(); if (mod.loadFailed) { return; } @@ -1073,7 +1050,6 @@ void ModLoader::tick() { } svc::modules_frame_end(); - config_flush_if_dirty(false); } void ModLoader::shutdown() { @@ -1091,7 +1067,6 @@ void ModLoader::shutdown() { drain_retired_natives(); svc::modules_shutdown(); clear_services(); - config_flush_if_dirty(true); Log.info("all mods unloaded"); } diff --git a/src/dusk/mods/loader/loader.hpp b/src/dusk/mods/loader/loader.hpp index cb81f1059b..6b9fd39232 100644 --- a/src/dusk/mods/loader/loader.hpp +++ b/src/dusk/mods/loader/loader.hpp @@ -61,7 +61,4 @@ void fail_mod(LoadedMod& mod, ModResult code, std::string_view message); bool is_safe_resource_path(std::string_view path); std::string escape_mod_id_for_config(std::string_view id); -void config_mark_dirty(); -void config_flush_if_dirty(bool force); - } // namespace dusk::mods diff --git a/src/dusk/mods/svc/config.cpp b/src/dusk/mods/svc/config.cpp new file mode 100644 index 0000000000..b7ea9c9e9d --- /dev/null +++ b/src/dusk/mods/svc/config.cpp @@ -0,0 +1,451 @@ +#include "config.hpp" + +#include "registry.hpp" + +#include "aurora/lib/logging.hpp" +#include "dusk/config.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/config.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dusk::mods::svc { +namespace { + +aurora::Module Log("dusk::mods::config"); + +struct ModConfigVarEntry { + uint64_t handle = 0; + ConfigVarType type = CONFIG_VAR_BOOL; + std::unique_ptr var; +}; + +struct ModConfigSubscription { + uint64_t handle = 0; + uint64_t varHandle = 0; + config::Subscription coreSubscription = 0; +}; + +struct ModConfigRecord { + std::vector vars; + std::vector subscriptions; +}; + +std::unordered_map s_modConfig; +uint64_t s_nextHandle = 1; +bool s_dirty = false; +std::chrono::steady_clock::time_point s_lastSave{}; +constexpr std::chrono::seconds kSaveDebounce{2}; + +void config_flush_if_dirty(const bool force) { + if (!s_dirty) { + return; + } + const auto now = std::chrono::steady_clock::now(); + if (!force && now - s_lastSave < kSaveDebounce) { + return; + } + s_dirty = false; + s_lastSave = now; + config::save(); +} + +// Translate the type-erased previous-value pointer into the C ABI snapshot struct. The string +// pointer aliases the previous std::string, which outlives the notification. +ConfigVarValue translate_previous(const uint32_t type, const void* previous) { + ConfigVarValue value{}; + value.struct_size = sizeof(ConfigVarValue); + value.type = static_cast(type); + switch (type) { + case CONFIG_VAR_BOOL: + value.bool_value = *static_cast(previous); + break; + case CONFIG_VAR_INT: + value.int_value = *static_cast(previous); + break; + case CONFIG_VAR_FLOAT: + value.float_value = *static_cast(previous); + break; + case CONFIG_VAR_STRING: { + const auto* str = static_cast(previous); + value.string_value = str->c_str(); + value.string_length = str->size(); + break; + } + default: + break; + } + return value; +} + +// Snapshot the var's current (new) value for the notification. Strings are copied into +// stringStorage so the snapshot stays valid even if the callback writes the var again. +ConfigVarValue translate_current( + const uint32_t type, config::ConfigVarBase& varBase, std::string& stringStorage) { + ConfigVarValue value{}; + value.struct_size = sizeof(ConfigVarValue); + value.type = static_cast(type); + switch (type) { + case CONFIG_VAR_BOOL: + value.bool_value = static_cast&>(varBase).getValue(); + break; + case CONFIG_VAR_INT: + value.int_value = static_cast&>(varBase).getValue(); + break; + case CONFIG_VAR_FLOAT: + value.float_value = static_cast&>(varBase).getValue(); + break; + case CONFIG_VAR_STRING: + stringStorage = static_cast&>(varBase).getValue(); + value.string_value = stringStorage.c_str(); + value.string_length = stringStorage.size(); + break; + default: + break; + } + return value; +} + +bool valid_var_fragment(const char* name) { + if (name == nullptr) { + return false; + } + const std::string_view fragment{name}; + if (fragment.empty() || fragment.size() > 64) { + return false; + } + return std::ranges::all_of(fragment, [](char ch) { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || + ch == '_' || ch == '-'; + }); +} + +config::ConfigVarBase* find_var(LoadedMod& mod, const uint64_t handle, uint32_t expectedType) { + const auto recordIt = s_modConfig.find(&mod); + if (recordIt == s_modConfig.end()) { + return nullptr; + } + const auto& vars = recordIt->second.vars; + const auto entry = + std::ranges::find_if(vars, [&](const auto& e) { return e.handle == handle; }); + if (entry == vars.end() || entry->type != expectedType) { + return nullptr; + } + return entry->var.get(); +} + +template +ConfigVar* find_typed_var(ModContext* context, ConfigVarHandle handle, uint32_t type) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return nullptr; + } + // The type tag was checked, so the downcast is safe. + return static_cast*>(find_var(*mod, handle, type)); +} + +void config_remove_mod(LoadedMod& mod) { + const auto it = s_modConfig.find(&mod); + if (it == s_modConfig.end()) { + return; + } + for (const auto& sub : it->second.subscriptions) { + config::unsubscribe(sub.coreSubscription); + } + for (const auto& entry : it->second.vars) { + config::unregister(*entry.var); + } + s_modConfig.erase(it); +} + +ModResult config_register_var( + ModContext* context, const ConfigVarDesc* desc, ConfigVarHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(ConfigVarDesc) || + !valid_var_fragment(desc->name)) + { + return MOD_INVALID_ARGUMENT; + } + + const auto fullName = + fmt::format("mod.{}.{}", escape_mod_id_for_config(mod->metadata.id), desc->name); + if (config::GetConfigVar(fullName) != nullptr) { + Log.error("[{}] config var '{}' conflicts with an existing config key", mod->metadata.id, + fullName); + return MOD_CONFLICT; + } + + std::unique_ptr var; + switch (desc->type) { + case CONFIG_VAR_BOOL: + var = std::make_unique >(fullName, desc->default_bool); + break; + case CONFIG_VAR_INT: + var = std::make_unique >(fullName, desc->default_int); + break; + case CONFIG_VAR_FLOAT: + var = std::make_unique >(fullName, desc->default_float); + break; + case CONFIG_VAR_STRING: + var = std::make_unique >( + fullName, desc->default_string != nullptr ? desc->default_string : ""); + break; + default: + return MOD_INVALID_ARGUMENT; + } + + // Back-fills a stashed/saved value (or a --cvar override) if one exists for this key. + // Loads apply silently: the registering mod cannot have subscribed to this var yet, and it + // reads the value right after registration anyway. + config::Register(*var); + + auto& record = s_modConfig[mod]; + auto& entry = record.vars.emplace_back(); + entry.handle = s_nextHandle++; + entry.type = desc->type; + entry.var = std::move(var); + if (outHandle != nullptr) { + *outHandle = entry.handle; + } + return MOD_OK; +} + +ModResult config_unregister_var(ModContext* context, ConfigVarHandle var) { + auto* mod = mod_from_context(context); + if (mod == nullptr || var == 0) { + return MOD_INVALID_ARGUMENT; + } + const auto recordIt = s_modConfig.find(mod); + if (recordIt != s_modConfig.end()) { + auto& record = recordIt->second; + const auto entry = + std::ranges::find_if(record.vars, [&](const auto& e) { return e.handle == var; }); + if (entry != record.vars.end()) { + std::erase_if(record.subscriptions, [&](const ModConfigSubscription& sub) { + if (sub.varHandle != var) { + return false; + } + config::unsubscribe(sub.coreSubscription); + return true; + }); + + // The persisted value is stashed and restored by a future registration of the + // same name. + config::unregister(*entry->var); + record.vars.erase(entry); + return MOD_OK; + } + } + Log.error("[{}] config unregister failed: unknown handle {}", mod->metadata.id, var); + return MOD_INVALID_ARGUMENT; +} + +ModResult config_get_bool(ModContext* context, ConfigVarHandle var, bool* outValue) { + if (outValue != nullptr) { + *outValue = false; + } + auto* cvar = find_typed_var(context, var, CONFIG_VAR_BOOL); + if (cvar == nullptr || outValue == nullptr) { + return MOD_INVALID_ARGUMENT; + } + *outValue = cvar->getValue(); + return MOD_OK; +} + +ModResult config_set_bool(ModContext* context, ConfigVarHandle var, bool value) { + auto* cvar = find_typed_var(context, var, CONFIG_VAR_BOOL); + if (cvar == nullptr) { + return MOD_INVALID_ARGUMENT; + } + cvar->setValue(value); + config_mark_dirty(); + return MOD_OK; +} + +ModResult config_get_int(ModContext* context, ConfigVarHandle var, int64_t* outValue) { + if (outValue != nullptr) { + *outValue = 0; + } + auto* cvar = find_typed_var(context, var, CONFIG_VAR_INT); + if (cvar == nullptr || outValue == nullptr) { + return MOD_INVALID_ARGUMENT; + } + *outValue = cvar->getValue(); + return MOD_OK; +} + +ModResult config_set_int(ModContext* context, ConfigVarHandle var, int64_t value) { + auto* cvar = find_typed_var(context, var, CONFIG_VAR_INT); + if (cvar == nullptr) { + return MOD_INVALID_ARGUMENT; + } + cvar->setValue(value); + config_mark_dirty(); + return MOD_OK; +} + +ModResult config_get_float(ModContext* context, ConfigVarHandle var, double* outValue) { + if (outValue != nullptr) { + *outValue = 0.0; + } + auto* cvar = find_typed_var(context, var, CONFIG_VAR_FLOAT); + if (cvar == nullptr || outValue == nullptr) { + return MOD_INVALID_ARGUMENT; + } + *outValue = cvar->getValue(); + return MOD_OK; +} + +ModResult config_set_float(ModContext* context, ConfigVarHandle var, double value) { + auto* cvar = find_typed_var(context, var, CONFIG_VAR_FLOAT); + if (cvar == nullptr) { + return MOD_INVALID_ARGUMENT; + } + cvar->setValue(value); + config_mark_dirty(); + return MOD_OK; +} + +ModResult config_get_string( + ModContext* context, ConfigVarHandle var, char* buffer, size_t bufferSize, size_t* outLength) { + if (outLength != nullptr) { + *outLength = 0; + } + auto* cvar = find_typed_var(context, var, CONFIG_VAR_STRING); + if (cvar == nullptr) { + return MOD_INVALID_ARGUMENT; + } + const auto& value = cvar->getValue(); + if (outLength != nullptr) { + *outLength = value.size(); + } + if (buffer == nullptr) { + // Length query; any other use of a null buffer is a caller bug. + return bufferSize == 0 ? MOD_OK : MOD_INVALID_ARGUMENT; + } + if (bufferSize < value.size() + 1) { + return MOD_INVALID_ARGUMENT; + } + std::memcpy(buffer, value.c_str(), value.size() + 1); + return MOD_OK; +} + +ModResult config_set_string(ModContext* context, ConfigVarHandle var, const char* value) { + auto* cvar = find_typed_var(context, var, CONFIG_VAR_STRING); + if (cvar == nullptr || value == nullptr) { + return MOD_INVALID_ARGUMENT; + } + cvar->setValue(std::string{value}); + config_mark_dirty(); + return MOD_OK; +} + +ModResult config_subscribe(ModContext* context, ConfigVarHandle var, ConfigChangedFn callback, + void* userData, ConfigSubscriptionHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || var == 0 || callback == nullptr) { + return MOD_INVALID_ARGUMENT; + } + const auto recordIt = s_modConfig.find(mod); + if (recordIt == s_modConfig.end()) { + return MOD_INVALID_ARGUMENT; + } + auto& record = recordIt->second; + const auto entry = + std::ranges::find_if(record.vars, [&](const auto& e) { return e.handle == var; }); + if (entry == record.vars.end()) { + return MOD_INVALID_ARGUMENT; + } + + auto& sub = record.subscriptions.emplace_back(); + sub.handle = s_nextHandle++; + sub.varHandle = var; + sub.coreSubscription = config::subscribe(entry->var->getName(), + [modPtr = mod, callback, userData, varHandle = var, type = entry->type]( + config::ConfigVarBase& varBase, const void* previous) { + const ConfigVarValue previousValue = translate_previous(type, previous); + std::string stringStorage; + const ConfigVarValue currentValue = translate_current(type, varBase, stringStorage); + try { + callback(modPtr->context.get(), varHandle, ¤tValue, &previousValue, userData); + } catch (const std::exception& e) { + fail_mod(*modPtr, MOD_ERROR, + fmt::format("exception in config change callback: {}", e.what())); + } catch (...) { + fail_mod(*modPtr, MOD_ERROR, "unknown exception in config change callback"); + } + }); + if (outHandle != nullptr) { + *outHandle = sub.handle; + } + return MOD_OK; +} + +ModResult config_unsubscribe(ModContext* context, ConfigSubscriptionHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return MOD_INVALID_ARGUMENT; + } + const auto recordIt = s_modConfig.find(mod); + if (recordIt != s_modConfig.end()) { + auto& subscriptions = recordIt->second.subscriptions; + const auto sub = + std::ranges::find_if(subscriptions, [&](const auto& s) { return s.handle == handle; }); + if (sub != subscriptions.end()) { + config::unsubscribe(sub->coreSubscription); + subscriptions.erase(sub); + return MOD_OK; + } + } + Log.error("[{}] config unsubscribe failed: unknown handle {}", mod->metadata.id, handle); + return MOD_INVALID_ARGUMENT; +} + +constexpr ConfigService s_configService{ + .header = SERVICE_HEADER(ConfigService, CONFIG_SERVICE_MAJOR, CONFIG_SERVICE_MINOR), + .register_var = config_register_var, + .unregister_var = config_unregister_var, + .get_bool = config_get_bool, + .set_bool = config_set_bool, + .get_int = config_get_int, + .set_int = config_set_int, + .get_float = config_get_float, + .set_float = config_set_float, + .get_string = config_get_string, + .set_string = config_set_string, + .subscribe = config_subscribe, + .unsubscribe = config_unsubscribe, +}; + +} // namespace + +void config_mark_dirty() { + s_dirty = true; +} + +constinit const ServiceModule g_configModule{ + .id = CONFIG_SERVICE_ID, + .majorVersion = CONFIG_SERVICE_MAJOR, + .minorVersion = CONFIG_SERVICE_MINOR, + .service = &s_configService, + .modDetached = config_remove_mod, + .frameEnd = [] { config_flush_if_dirty(false); }, + .shutdown = [] { config_flush_if_dirty(true); }, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/config.hpp b/src/dusk/mods/svc/config.hpp new file mode 100644 index 0000000000..657c1cbf14 --- /dev/null +++ b/src/dusk/mods/svc/config.hpp @@ -0,0 +1,9 @@ +#pragma once + +namespace dusk::mods::svc { + +// Marks the mods' config keys dirty for the config service's debounced save. The loader +// calls this for the enabled cvars it owns. +void config_mark_dirty(); + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 3951822bad..fd5909d996 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -199,6 +199,7 @@ void ModLoader::init_services() { { &svc::g_hostModule, &svc::g_logModule, + &svc::g_configModule, }) { svc::register_module(*module); diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 4f59aabff1..4954c97077 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -64,5 +64,6 @@ void modules_shutdown(); extern const ServiceModule g_hostModule; extern const ServiceModule g_logModule; +extern const ServiceModule g_configModule; } // namespace dusk::mods::svc From af5635dd42c16cc14626564e5eb7f95f26cd83ec Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 7 Jul 2026 13:55:55 -0600 Subject: [PATCH 063/150] Resource, texture and overlay services --- cmake/ModSDK.cmake | 23 +- docs/modding.md | 221 ++++++++++--- extern/aurora | 2 +- files.cmake | 3 + include/dusk/mod_loader.hpp | 2 +- include/dusk/texture_replacements.hpp | 5 + include/mods/svc/overlay.h | 57 ++++ include/mods/svc/resource.h | 52 +++ include/mods/svc/texture.h | 88 +++++ src/dusk/mods/svc/overlay.cpp | 351 ++++++++++++++++++++ src/dusk/mods/svc/registry.cpp | 3 + src/dusk/mods/svc/registry.hpp | 3 + src/dusk/mods/svc/resource.cpp | 102 ++++++ src/dusk/mods/svc/texture.cpp | 455 ++++++++++++++++++++++++++ src/dusk/texture_replacements.cpp | 3 +- 15 files changed, 1322 insertions(+), 48 deletions(-) create mode 100644 include/mods/svc/overlay.h create mode 100644 include/mods/svc/resource.h create mode 100644 include/mods/svc/texture.h create mode 100644 src/dusk/mods/svc/overlay.cpp create mode 100644 src/dusk/mods/svc/resource.cpp create mode 100644 src/dusk/mods/svc/texture.cpp diff --git a/cmake/ModSDK.cmake b/cmake/ModSDK.cmake index e15e892a58..909e8abcba 100644 --- a/cmake/ModSDK.cmake +++ b/cmake/ModSDK.cmake @@ -1,4 +1,5 @@ -# add_mod( SOURCES ... MOD_JSON [RES_DIR ] [OUTPUT_DIR ] [BUNDLE]) +# add_mod( SOURCES ... MOD_JSON [RES_DIR ] [OVERLAY_DIR ] +# [TEXTURES_DIR ] [OUTPUT_DIR ] [BUNDLE]) set(DUSK_MODS_OUTPUT_DIR "${CMAKE_BINARY_DIR}/mods" CACHE PATH "Directory to write mod packages into") function(_mod_lib_name out_var) @@ -44,7 +45,7 @@ function(_mod_collect_assets out_var dir) endfunction() function(add_mod target_name) - cmake_parse_arguments(ARG "BUNDLE" "MOD_JSON;RES_DIR;OUTPUT_DIR" "SOURCES" ${ARGN}) + cmake_parse_arguments(ARG "BUNDLE" "MOD_JSON;RES_DIR;OVERLAY_DIR;TEXTURES_DIR;OUTPUT_DIR" "SOURCES" ${ARGN}) if (NOT ARG_MOD_JSON) message(FATAL_ERROR "add_mod: MOD_JSON is required") endif () @@ -104,6 +105,24 @@ function(add_mod target_name) list(APPEND _extra_cmds COMMAND ${CMAKE_COMMAND} -E copy_directory "${_res_dir}" "${_stage}/res") endif () + if (ARG_OVERLAY_DIR) + _mod_resolve_source_path(_overlay_dir "${ARG_OVERLAY_DIR}") + _mod_collect_assets(_overlay_deps "${_overlay_dir}") + list(APPEND _package_deps ${_overlay_deps}) + list(APPEND _package_inputs "${_overlay_dir}" ${_overlay_deps}) + list(APPEND _zip_args overlay) + list(APPEND _extra_cmds COMMAND ${CMAKE_COMMAND} -E copy_directory + "${_overlay_dir}" "${_stage}/overlay") + endif () + if (ARG_TEXTURES_DIR) + _mod_resolve_source_path(_textures_dir "${ARG_TEXTURES_DIR}") + _mod_collect_assets(_textures_deps "${_textures_dir}") + list(APPEND _package_deps ${_textures_deps}) + list(APPEND _package_inputs "${_textures_dir}" ${_textures_deps}) + list(APPEND _zip_args textures) + list(APPEND _extra_cmds COMMAND ${CMAKE_COMMAND} -E copy_directory + "${_textures_dir}" "${_stage}/textures") + endif () set(_bundle_cmds "") if (ARG_BUNDLE AND TARGET dusklight) diff --git a/docs/modding.md b/docs/modding.md index 28fc19af5f..77becddf0b 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -1,8 +1,10 @@ # Dusklight Mod API -Mods are distributed as `.dusk` files: zip archives containing a `mod.json` manifest and, optionally, compiled code libraries and resources. +Mods are distributed as `.dusk` files: zip archives containing a `mod.json` manifest and, optionally, compiled code +libraries and resources. -Everything a mod does goes through **services**: small, versioned C APIs. Dusklight provides built-in services, and mods can define their own to talk to each other. +Everything a mod does goes through **services**: small, versioned C APIs. Dusklight provides built-in services, and mods +can define their own to talk to each other. ## Table of Contents @@ -11,22 +13,25 @@ Everything a mod does goes through **services**: small, versioned C APIs. Duskli 3. [Anatomy of a Code Mod](#anatomy-of-a-code-mod) 4. [Services](#services) 5. [Built-in Services](#built-in-services) -6. [Runtime Lifecycle](#runtime-lifecycle) -7. [Error Handling](#error-handling) -8. [Advanced: Exporting Services](#advanced-exporting-services) +6. [Asset Overlays](#asset-overlays) +7. [Runtime Lifecycle](#runtime-lifecycle) +8. [Error Handling](#error-handling) +9. [Advanced: Exporting Services](#advanced-exporting-services) --- ## Getting Started -Fork the [mod template](../tools/mod_template/), a self-contained CMake project that uses the Dusklight mod SDK: +Fork the [mod template](../tools/mod_template/), a self-contained CMake project that uses the Dusklight mod SDK. ``` my_mod/ ├── CMakeLists.txt ├── mod.json ├── src/mod.cpp -└── res/ (optional bundled resources) +├── res/ (optional bundled resources) +├── overlay/ (optional game file overrides) +└── textures/ (optional texture replacements) ``` **CMakeLists.txt:** @@ -39,19 +44,23 @@ set(DUSKLIGHT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/dusklight" CACHE PATH "Path to du add_subdirectory("${DUSKLIGHT_DIR}/sdk" dusklight-sdk EXCLUDE_FROM_ALL) add_mod(my_mod - SOURCES src/mod.cpp - MOD_JSON mod.json - RES_DIR res # optional + SOURCES src/mod.cpp + MOD_JSON mod.json + RES_DIR res # optional + OVERLAY_DIR overlay # optional + TEXTURES_DIR textures # optional ) ``` -Building produces `my_mod.dusk` in `build//mods/` (configurable via the `DUSK_MODS_OUTPUT_DIR` cache variable). Copy it into the game's mods folder and launch: +Building produces `my_mod.dusk` in `build//mods/` (configurable via the `DUSK_MODS_OUTPUT_DIR` cache variable). +Copy it into the game's mods folder and launch: - Windows: `%APPDATA%\TwilitRealm\Dusklight\mods` - Linux: `~/.local/share/TwilitRealm/Dusklight/mods` - macOS: `~/Library/Application Support/TwilitRealm/Dusklight/mods` -You can also pass `--mods ` on the command line, which is handy during development. Mods will load from there instead of the user directory above. +You can also pass `--mods ` on the command line, which is handy during development. Mods will load from there +instead of the user directory above. --- @@ -59,19 +68,21 @@ You can also pass `--mods ` on the command line, which is handy during deve ```json { - "id": "com.example.my_mod", - "name": "My Mod", - "version": "1.0.0", - "author": "Your Name", - "description": "A short description shown in the mod manager.", - "icon": "res/my_icon.png", - "banner": "res/my_banner.png" + "id": "com.example.my_mod", + "name": "My Mod", + "version": "1.0.0", + "author": "Your Name", + "description": "A short description shown in the mod manager.", + "icon": "res/my_icon.png", + "banner": "res/my_banner.png" } ``` -`id` is required: a unique, stable identifier (reverse-DNS style; periods, underscores, and alphanumerics). Everything else is optional but recommended. +`id` is required: a unique, stable identifier (reverse-DNS style; periods, underscores, and alphanumerics). Everything +else is optional but recommended. -`icon` and `banner` are bundle-relative paths to PNG images for the in-game mod manager: the square icon (e.g. 512x512), the banner (roughly 3.5:1). +`icon` and `banner` are bundle-relative paths to PNG images for the in-game mod manager: the square icon (e.g. 512x512), +the banner (roughly 3.5:1). Both keys are optional; if omitted, `res/icon.png` and `res/banner.png` are used automatically when present. --- @@ -102,13 +113,15 @@ MOD_EXPORT ModResult mod_shutdown(ModError* error) { } ``` -All three lifecycle exports are required. `mod_ctx` is your mod's identity token, set by the loader before `mod_initialize` runs. Pass it as the first argument to every service call. +All three lifecycle exports are required. `mod_ctx` is your mod's identity token, set by the loader before +`mod_initialize` runs. Pass it as the first argument to every service call. --- ## Services -A service is a struct of C function pointers with a version header. You declare what you use at file scope, and the loader resolves it before your mod initializes: +A service is a struct of C function pointers with a version header. You declare what you use at file scope, and the +loader resolves it before your mod initializes: ```cpp IMPORT_SERVICE(LogService, svc_log); // required, any minor version @@ -118,12 +131,15 @@ IMPORT_OPTIONAL_SERVICE(SomeService, svc_maybe); // may be null The rules (see `include/mods/api.h` for the full contract): -- **A required import is guaranteed valid.** If the service is missing or too old, the mod fails to load with a clear error. No need to null check at call sites. +- **A required import is guaranteed valid.** If the service is missing or too old, the mod fails to load with a clear + error. No need to null check at call sites. - **Anything at or below the minor version you imported can be called unconditionally.** - Optional imports may be null; check once in `mod_initialize`. -- Fields newer than your imported minor must be gated behind `SERVICE_HAS(service, ServiceType, field)` plus a null check. +- Fields newer than your imported minor must be gated behind `SERVICE_HAS(service, ServiceType, field)` plus a null + check. -Service versions follow one rule: a **major** bump is a breaking change (treated as a different service entirely), a **minor** bump only appends functions. +Service versions follow one rule: a **major** bump is a breaking change (treated as a different service entirely), a * +*minor** bump only appends functions. --- @@ -140,7 +156,26 @@ svc_log->error(mod_ctx, "very bad"); svc_log->write(mod_ctx, LOG_LEVEL_DEBUG, "verbose details"); ``` -Messages appear in the console prefixed with your mod ID. Messages are plain strings: use `snprintf` or `fmt::format` for formatting. +Messages appear in the console prefixed with your mod ID. Messages are plain strings: use `snprintf` or `fmt::format` +for formatting. + +### ResourceService (`mods/svc/resource.h`) + +Loads files from the `res/` tree of your `.dusk` archive. Paths are relative to `res/` (pass `"config.txt"`, not +`"res/config.txt"`); absolute paths and `..` are rejected. + +```cpp +IMPORT_SERVICE(ResourceService, svc_resource); + +ResourceBuffer buf = RESOURCE_BUFFER_INIT; +if (svc_resource->load(mod_ctx, "config.txt", &buf) == MOD_OK) { + // buf.data / buf.size + svc_resource->free(mod_ctx, &buf); +} +``` + +Missing files return `MOD_UNAVAILABLE`. Always `free` what you `load`. For writable storage, use the directory from +`svc_host->mod_dir(mod_ctx)`. ### HostService (`mods/svc/host.h`) @@ -156,8 +191,8 @@ svc_host->fail(mod_ctx, MOD_ERROR, "something unrecoverable happened"); // disa `get_service`/`publish_service` provide dynamic service lookup; see [Advanced](#advanced-exporting-services). -**Lifecycle watches.** If your mod provides a service that hands out per-caller state (registrations, callbacks, handles), -watch other mods' lifecycle and drop what you hold for a mod when it detaches. +**Lifecycle watches.** If your mod provides a service that hands out per-caller state (registrations, callbacks, +handles), watch other mods' lifecycle and drop what you hold for a mod when it detaches. ```cpp IMPORT_SERVICE_VERSION(HostService, svc_host, 1); @@ -176,6 +211,59 @@ svc_host->watch_mod_lifecycle(mod_ctx, on_mod_lifecycle, nullptr, &watch); `MOD_LIFECYCLE_DETACHED` fires on the game thread at a lifecycle safe point, after the subject's `mod_shutdown` ran and every service dropped its state. For your own mod's teardown, use `mod_shutdown` instead. +### OverlayService (`mods/svc/overlay.h`) + +Registers DVD file overlays at runtime. The dynamic counterpart to the static `overlay/` directory ( +see [Asset Overlays](#asset-overlays)). Overlay a disc path with a file from your bundle, or with a caller-owned +buffer (copied on registration): + +```cpp +IMPORT_SERVICE(OverlayService, svc_overlay); + +OverlayHandle handle = 0; +svc_overlay->add_file(mod_ctx, "/res/Msgus.arc", "res/replacement.arc", &handle); +svc_overlay->add_buffer(mod_ctx, "/generated.txt", data, size, nullptr); +svc_overlay->remove(mod_ctx, handle); +``` + +`disc_path` must be absolute (leading `/`) and is matched against the disc case-insensitively. Paths that don't exist on +the disc are added as new files. Changes are applied at the next frame boundary, and data the game already read stays in +memory until the file is re-read (sometimes a scene reload, sometimes never; a restart may be required). + +See [Asset Overlays](#asset-overlays) for priority and conflict handling. + +### TextureService (`mods/svc/texture.h`) + +Registers texture replacements at runtime. The dynamic counterpart to the static `textures/` directory ( +see [Asset Overlays](#asset-overlays)). Two forms: raw texel data with an explicit key, or an encoded `.dds`/`.png` from +your bundle whose filename encodes the key: + +```cpp +IMPORT_SERVICE(TextureService, svc_texture); + +// Encoded file; filename follows the replacement naming convention. +TextureReplacementHandle handle = 0; +svc_texture->register_file(mod_ctx, "res/tex1_32x32_$_6.png", &handle); + +// Raw data: match by texel-data pointer or by content hash (TEXTURE_KEY_SOURCE). +TextureKey key = TEXTURE_KEY_INIT; +key.kind = TEXTURE_KEY_POINTER; +key.pointer = someTexObj.data; +TextureData data = TEXTURE_DATA_INIT; +data.data = pixels; data.size = pixelsSize; +data.width = 32; data.height = 32; data.gx_format = GX_TF_RGBA8_PC; +svc_texture->register_data(mod_ctx, &key, &data, nullptr); + +svc_texture->unregister(mod_ctx, handle); +``` + +Filenames use the same convention as the user's `texture_replacements` directory: +`tex1_{w}x{h}_{texhash}[_{tluthash}]_{fmt}.dds|.png`, where hashes may be `$` (wildcard). `_mipN` sidecar files next to +a registered file are picked up automatically. Files are decoded lazily on first use by the renderer; raw data is copied +at registration. Registrations follow your mod's lifecycle. + +See [Asset Overlays](#asset-overlays) for priority and conflict handling. + ### ConfigService (`mods/svc/config.h`) Persistent, mod-scoped configuration variables. Each var is stored in the user's `config.json` under @@ -214,21 +302,56 @@ Writes that store the same value are silent. Values applied from `config.json` o --- +## Asset Overlays + +Files placed under `overlay/` in the `.dusk` archive override game files at the corresponding path. For example, +`overlay/res/Stage/...` shadows that file on the game disc image. This requires no code: an archive with just +`mod.json` and `overlay/` is a complete mod. + +Files placed under `textures/` register as texture replacements the same way. Filenames follow the replacement naming +convention (`tex1_{w}x{h}_{texhash}[_{tluthash}]_{fmt}.dds|.png`, `$` as a hash wildcard). Subdirectories are scanned +recursively; only the filename needs to match. + +Both follow the mod's lifecycle: disabling the mod removes its overrides (files revert to the disc contents on their +next open; added files stop existing), and reloading serves the new bundle's content. Game data the engine already read +stays as-is until it is loaded again, which may require a scene reload or a full restart. Texture replacements usually +take effect immediately. + +If multiple sources replace the same file or texture, the last one wins: runtime registrations override static +`textures/` or `overlay/` files, and later-loaded mods override earlier ones. Cross-mod conflicts log warnings. +**All** mod-provided texture replacements override the user's `texture_replacements/`. + +To configure overlays and texture replacements at runtime instead, see [OverlayService](#overlayservice-modssvcoverlayh) +and [TextureService](#textureservice-modssvctextureh). + +--- + ## Runtime Lifecycle -Mods can be disabled, re-enabled, and reloaded at runtime without restarting the game (the enabled state persists as the `mod..enabled` config var). Write your mod assuming this happens: +Mods can be disabled, re-enabled, and reloaded at runtime without restarting the game (the enabled state persists as the +`mod..enabled` config var). Write your mod assuming this happens: -- **Disable** calls `mod_shutdown`, removes your services, and unloads your library. -- **Enable** and **Reload** load a *fresh copy* of your library, imports are re-resolved, and `mod_initialize` runs again. You never see a second `mod_initialize` on the same image, so just make `mod_shutdown` release anything the loader doesn't manage for you (threads, files, game-side state you mutated). -- **Reload** additionally re-reads the `.dusk` from disk, picking up a rebuilt library and changed assets. This is the fast iteration loop during development: rebuild, click Reload. +- **Disable** calls `mod_shutdown`, removes your services, overlays, and texture replacements (both static and + runtime-registered), and unloads your library. +- **Enable** and **Reload** load a *fresh copy* of your library, imports are re-resolved, and `mod_initialize` runs + again. You never see a second `mod_initialize` on the same image, so just make `mod_shutdown` release anything the + loader doesn't manage for you (threads, files, game-side state you mutated). +- **Reload** additionally re-reads the `.dusk` from disk, picking up a rebuilt library and changed assets. This is the + fast iteration loop during development: rebuild, click Reload. -**Dependents restart too.** Disabling or reloading a mod that exports services shuts down the mods importing them first (in reverse dependency order) and brings them back afterward. A mod whose *required* provider is disabled stays suspended and resumes automatically when the provider returns. Mods with an *optional* import of a disabled provider restart with that import null. +**Dependents restart too.** Disabling or reloading a mod that exports services shuts down the mods importing them +first (in reverse dependency order) and brings them back afterward. A mod whose *required* provider is disabled stays +suspended and resumes automatically when the provider returns. Mods with an *optional* import of a disabled provider +restart with that import null. --- ## Error Handling -Service calls report failure through `ModResult` return values (`MOD_OK`, `MOD_UNAVAILABLE`, `MOD_INVALID_ARGUMENT`, ...). Lifecycle exports additionally receive a `ModError*`: fill it (e.g. with `dusk::mods::set_error(error, code, "message")`) and return the code, and the loader disables the mod and shows the message to the user. +Service calls report failure through `ModResult` return values (`MOD_OK`, `MOD_UNAVAILABLE`, +`MOD_INVALID_ARGUMENT`, ...). Lifecycle exports additionally receive a `ModError*`: fill it (e.g. with +`dusk::mods::set_error(error, code, "message")`) and return the code, and the loader disables the mod and shows the +message to the user. ```cpp MOD_EXPORT ModResult mod_initialize(ModError* error) { @@ -239,7 +362,8 @@ MOD_EXPORT ModResult mod_initialize(ModError* error) { } ``` -Throwing exceptions out of lifecycle functions also disables the mod (they are caught by the loader), but prefer explicit results. +Throwing exceptions out of lifecycle functions also disables the mod (they are caught by the loader), but prefer +explicit results. --- @@ -291,17 +415,24 @@ IMPORT_SERVICE(MyModService, svc_my_mod); svc_my_mod->do_thing(mod_ctx, 42); ``` -The loader registers all exports before resolving any imports, so declaration order between mods doesn't matter. Note that the `ctx` a provider receives identifies the *calling* mod. +The loader registers all exports before resolving any imports, so declaration order between mods doesn't matter. Note +that the `ctx` a provider receives identifies the *calling* mod. ### Dependencies between mods -Service imports are also dependency declarations: the loader initializes mods in dependency order, so by the time your `mod_initialize` runs, every mod you import services from (required *or* optional) has already finished its own `mod_initialize`. This includes deferred services: a service the provider publishes during its initialization resolves into your import slot just like a static export. +Service imports are also dependency declarations: the loader initializes mods in dependency order, so by the time your +`mod_initialize` runs, every mod you import services from (required *or* optional) has already finished its own +`mod_initialize`. This includes deferred services: a service the provider publishes during its initialization resolves +into your import slot just like a static export. Consequences of that contract: -- If a provider fails to load, every mod that *requires* one of its services is disabled too, with an error naming the provider. Optional imports of a failed provider simply resolve to `NULL`. -- Mods whose **required** imports form a cycle all fail to load. If the cycle runs through an **optional** import, the loader breaks it there: the optional import still resolves, but its provider may not be initialized yet when you run. -- `svc_host->get_service(...)` is outside this system. It sees whatever is published at call time and gives no initialization-order guarantee, which also makes it the escape hatch for intentionally cyclic designs. +- If a provider fails to load, every mod that *requires* one of its services is disabled too, with an error naming the + provider. Optional imports of a failed provider simply resolve to `NULL`. +- Mods whose **required** imports form a cycle all fail to load. If the cycle runs through an **optional** import, the + loader breaks it there: the optional import still resolves, but its provider may not be initialized yet when you run. +- `svc_host->get_service(...)` is outside this system. It sees whatever is published at call time and gives no + initialization-order guarantee, which also makes it the escape hatch for intentionally cyclic designs. Mods shut down in reverse initialization order, so services you import remain safe to call from `mod_shutdown`. @@ -309,7 +440,11 @@ Rules for providers: - Service IDs are global and use reverse-DNS names (e.g. `com.mydomain.mod.service`) - Every function pointer covered by your declared minor version must be populated. -- Within a major version, only append fields; never reorder, remove, or repurpose them. Breaking changes require a major bump (which is, in effect, a new service). +- Within a major version, only append fields; never reorder, remove, or repurpose them. Breaking changes require a major + bump (which is, in effect, a new service). - Only one provider per `(id, major)` pair may be registered; duplicates are load errors. -For services whose construction can't happen at static-init time, declare the export with `EXPORT_DEFERRED_SERVICE(...)` and publish the pointer later via `svc_host->publish_service(...)`. Consumers can fetch services dynamically with `svc_host->get_service(...)`; prefer manifest imports whenever possible, since they give the loader dependency information and fail fast with good errors. +For services whose construction can't happen at static-init time, declare the export with `EXPORT_DEFERRED_SERVICE(...)` +and publish the pointer later via `svc_host->publish_service(...)`. Consumers can fetch services dynamically with +`svc_host->get_service(...)`; prefer manifest imports whenever possible, since they give the loader dependency +information and fail fast with good errors. diff --git a/extern/aurora b/extern/aurora index 9087a409da..0a5a5d90ef 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 9087a409da35b17446af12d7456ec6563cf2dd43 +Subproject commit 0a5a5d90efd2dbc7b402ab61f1021922dbf7496f diff --git a/files.cmake b/files.cmake index fb4b8dc25c..03d7a3af66 100644 --- a/files.cmake +++ b/files.cmake @@ -1554,6 +1554,9 @@ set(DUSK_FILES src/dusk/mods/svc/config.hpp src/dusk/mods/svc/host.cpp src/dusk/mods/svc/log.cpp + src/dusk/mods/svc/overlay.cpp + src/dusk/mods/svc/resource.cpp + src/dusk/mods/svc/texture.cpp src/dusk/mods/svc/registry.cpp src/dusk/mods/svc/registry.hpp src/dusk/discord.cpp diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index 20b4de8e70..7f9d1ad340 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -153,7 +153,7 @@ struct LoadedMod { std::unique_ptr native; std::unique_ptr context; - // Shared so in-flight readers keep their bundle alive across disable/reload. + // Shared with overlay file registrations so in-flight DVD reads survive disable/reload. std::shared_ptr bundle; ModManifestInfo manifestInfo; diff --git a/include/dusk/texture_replacements.hpp b/include/dusk/texture_replacements.hpp index ffb3fe8d62..85cb59b001 100644 --- a/include/dusk/texture_replacements.hpp +++ b/include/dusk/texture_replacements.hpp @@ -1,8 +1,13 @@ #ifndef DUSK_TEXTURE_REPLACEMENTS_HPP #define DUSK_TEXTURE_REPLACEMENTS_HPP +#include + namespace dusk::texture_replacements { +// Mod replacements are prioritized *over* user replacements (/texture_replacements/) +inline constexpr int32_t kUserTextureReplacementPriority = -1'000'000; + void reload(); void set_enabled(bool enabled); void shutdown(); diff --git a/include/mods/svc/overlay.h b/include/mods/svc/overlay.h new file mode 100644 index 0000000000..9fc86fc850 --- /dev/null +++ b/include/mods/svc/overlay.h @@ -0,0 +1,57 @@ +#pragma once + +#include "mods/api.h" + +#define OVERLAY_SERVICE_ID "dev.twilitrealm.dusklight.overlay" +#define OVERLAY_SERVICE_MAJOR 1u +#define OVERLAY_SERVICE_MINOR 0u + +/* Handle for a runtime overlay registration. 0 is never a valid handle. */ +typedef uint64_t OverlayHandle; + +/* + * Runtime DVD file overlays. + * + * Registrations are owned by the calling mod and removed automatically when it is disabled, + * reloaded, or fails. Changes are applied at the next frame boundary; data the game has already + * read stays in memory until it re-reads the file (sometimes on scene reload, sometimes on + * restart). + * + * disc_path names the file to overlay: absolute with a leading '/', matched against the disc + * case-insensitively (e.g. "/res/Stage/R04_00.arc"). Paths that do not exist on the disc are added + * as new files. + * + * If multiple sources overlay the same path, the last one wins: a mod's runtime registrations beat + * its static overlay/ files, and later-loaded mods beat earlier ones. + */ +typedef struct OverlayService { + ServiceHeader header; + + /* + * Overlay disc_path with a file from the calling mod's bundle (bundle-relative path, e.g. + * "res/replacement.arc"). The file's contents are read lazily on each open, so the bundle + * file must not change size while registered. + */ + ModResult (*add_file)( + ModContext* ctx, const char* disc_path, const char* bundle_path, OverlayHandle* out_handle); + + /* + * Overlay disc_path with a caller-owned buffer. The data is copied; the caller may free it + * as soon as this returns. + */ + ModResult (*add_buffer)(ModContext* ctx, const char* disc_path, const void* data, size_t size, + OverlayHandle* out_handle); + + /* Remove a runtime overlay previously added by the calling mod. */ + ModResult (*remove)(ModContext* ctx, OverlayHandle handle); +} OverlayService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = OVERLAY_SERVICE_ID; + static constexpr uint16_t major_version = OVERLAY_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/resource.h b/include/mods/svc/resource.h new file mode 100644 index 0000000000..1173b2ab1d --- /dev/null +++ b/include/mods/svc/resource.h @@ -0,0 +1,52 @@ +#pragma once + +#include "mods/api.h" + +/* + * Read-only access to the res/ tree of the calling mod's own bundle. Reload serves the new + * bundle's contents. For writable storage, use HostService::mod_dir. + */ + +#define RESOURCE_SERVICE_ID "dev.twilitrealm.dusklight.resource" +#define RESOURCE_SERVICE_MAJOR 1u +#define RESOURCE_SERVICE_MINOR 0u + +/* + * A loaded resource, allocated by the service. Return every successful load with free; + * buffers still live when the mod is disabled or reloaded are reclaimed with a warning. + */ +typedef struct ResourceBuffer { + uint32_t struct_size; + void* data; + size_t size; +} ResourceBuffer; + +#define RESOURCE_BUFFER_INIT {sizeof(ResourceBuffer), NULL, 0u} + +typedef struct ResourceService { + ServiceHeader header; + + /* + * Load a file into a fresh allocation. `relative_path` is resolved against the bundle's + * res/ directory. Absolute paths and ".." are rejected. MOD_UNAVAILABLE if the file does not + * exist. An empty file loads as data == NULL with size 0. Previous contents of `out_buffer` are + * overwritten, not freed. + */ + ModResult (*load)(ModContext* ctx, const char* relative_path, ResourceBuffer* out_buffer); + + /* + * Release a loaded buffer and reset it to the empty state. Safe to call on an empty or + * already-freed buffer. + */ + void (*free)(ModContext* ctx, ResourceBuffer* buffer); +} ResourceService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = RESOURCE_SERVICE_ID; + static constexpr uint16_t major_version = RESOURCE_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/texture.h b/include/mods/svc/texture.h new file mode 100644 index 0000000000..d664c5f99d --- /dev/null +++ b/include/mods/svc/texture.h @@ -0,0 +1,88 @@ +#pragma once + +#include "mods/api.h" + +#define TEXTURE_SERVICE_ID "dev.twilitrealm.dusklight.texture" +#define TEXTURE_SERVICE_MAJOR 1u +#define TEXTURE_SERVICE_MINOR 0u + +/* Handle for a runtime texture replacement registration. 0 is never a valid handle. */ +typedef uint64_t TextureReplacementHandle; + +typedef enum TextureKeyKind { + /* Match a texture by the address of its in-memory GX texel data. */ + TEXTURE_KEY_POINTER = 0, + /* Match by content: XXH64 of the base mip level (and of the referenced TLUT range for + * palette formats), as encoded in replacement filenames / texture dumps. */ + TEXTURE_KEY_SOURCE = 1, +} TextureKeyKind; + +/* Wildcard values for TEXTURE_KEY_SOURCE hashes ("$" in the filename convention). */ +#define TEXTURE_HASH_WILDCARD UINT64_C(0xFFFFFFFFFFFFFFFF) +#define TEXTURE_TLUT_WILDCARD UINT64_C(0xFFFFFFFFFFFFFFFE) + +typedef struct TextureKey { + uint32_t struct_size; + TextureKeyKind kind; + const void* pointer; /* TEXTURE_KEY_POINTER only */ + uint64_t texture_hash; /* TEXTURE_KEY_SOURCE */ + uint64_t tlut_hash; /* TEXTURE_KEY_SOURCE, palette formats only */ + uint32_t width; + uint32_t height; + uint32_t gx_format; + bool has_tlut; +} TextureKey; + +#define TEXTURE_KEY_INIT {sizeof(TextureKey), TEXTURE_KEY_POINTER, NULL, 0u, 0u, 0u, 0u, 0u, false} + +typedef struct TextureData { + uint32_t struct_size; + const void* data; /* texel data laid out in gx_format; copied by the service */ + size_t size; + uint32_t width; + uint32_t height; + uint32_t mip_count; + uint32_t gx_format; /* any GX texture format supported by Aurora's converter */ +} TextureData; + +#define TEXTURE_DATA_INIT {sizeof(TextureData), NULL, 0u, 0u, 0u, 1u, 0u} + +/* + * Runtime texture replacements. + * + * Registrations are owned by the calling mod and removed automatically when it is disabled, + * reloaded, or fails. When multiple sources replace the same texture, the highest priority wins: + * later-loaded mods beat earlier ones, and any mod beats the user's texture_replacements config + * directory. Files shipped in a mod's textures/ directory register automatically with the same + * ownership and priority; this service is for replacements decided at runtime. + */ +typedef struct TextureService { + ServiceHeader header; + + /* Register a replacement from raw texel data. The data is copied; the caller may free it as + * soon as this returns. */ + ModResult (*register_data)(ModContext* ctx, const TextureKey* key, const TextureData* data, + TextureReplacementHandle* out_handle); + + /* + * Register a replacement from an encoded .dds/.png inside the calling mod's bundle. The + * filename encodes the key (same convention as the texture_replacements directory, e.g. + * "tex1_{w}x{h}_{hash}_{fmt}.dds"); "_mipN" sidecars next to it are picked up automatically. + * The file is decoded lazily on first use by the renderer. + */ + ModResult (*register_file)(ModContext* ctx, const char* bundle_path, + TextureReplacementHandle* out_handle); + + /* Remove a replacement previously registered by the calling mod. */ + ModResult (*unregister)(ModContext* ctx, TextureReplacementHandle handle); +} TextureService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = TEXTURE_SERVICE_ID; + static constexpr uint16_t major_version = TEXTURE_SERVICE_MAJOR; +}; +#endif diff --git a/src/dusk/mods/svc/overlay.cpp b/src/dusk/mods/svc/overlay.cpp new file mode 100644 index 0000000000..13cab1ac0f --- /dev/null +++ b/src/dusk/mods/svc/overlay.cpp @@ -0,0 +1,351 @@ +#include "registry.hpp" + +#include "aurora/dvd.h" +#include "aurora/lib/logging.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/overlay.h" + +#include +#include +#include +#include +#include +#include + +using namespace std::string_literals; + +namespace dusk::mods::svc { +namespace { + +aurora::Module Log("dusk::mods::overlay"); + +struct OverlayFileData { + std::string bundlePath; + std::shared_ptr bundle; + std::shared_ptr > buffer; +}; + +// Keyed by the id passed to Aurora as per-file userdata. Guarded by s_overlayMutex: Aurora may +// call cbOpen from a DVD thread while the game thread replaces the set in overlay_sync_files. +// The shared bundle/buffer pointer keeps a disabled/reloaded mod's data readable until the last +// open completes. +std::unordered_map s_overlayFiles; +uintptr_t s_nextOverlayId = 1; +std::mutex s_overlayMutex; + +struct RuntimeOverlayEntry { + uint64_t handle = 0; + std::string discPath; + std::string bundlePath; // bundle-backed if non-empty + std::shared_ptr> buffer; // buffer-backed otherwise + size_t size = 0; +}; +std::unordered_map> s_runtimeOverlays; +uint64_t s_nextRuntimeHandle = 1; +bool s_overlaysDirty = false; + +// Aurora matches overlay paths against the disc case-insensitively and later entries win, so +// claims are tracked by lowercased path and re-claims by a different mod warn. +void claim_overlay_path(std::unordered_map& claims, + const std::string& discPath, const LoadedMod& mod) { + std::string key = discPath; + for (auto& ch : key) { + if (ch >= 'A' && ch <= 'Z') { + ch += 'a' - 'A'; + } + } + const auto [it, inserted] = claims.try_emplace(std::move(key), &mod); + if (!inserted && it->second != &mod) { + Log.warn("Overlay conflict: '{}' is provided by both '{}' and '{}'; '{}' wins.", discPath, + it->second->metadata.id, mod.metadata.id, mod.metadata.id); + it->second = &mod; + } +} + +void find_overlay_files(std::vector& files, LoadedMod& mod, + std::unordered_map& claims) { + for (const auto& file : mod.bundle->getFileNames()) { + if (!file.starts_with("overlay/")) { + continue; + } + + auto overlayPath = file.substr("overlay/"s.size()); + assert(!overlayPath.starts_with('/')); + overlayPath.insert(0, "/"); + + const auto size = mod.bundle->getFileSize(file); + + const auto id = s_nextOverlayId++; + s_overlayFiles.emplace(id, OverlayFileData{file, mod.bundle, nullptr}); + claim_overlay_path(claims, overlayPath, mod); + files.emplace_back(strdup(overlayPath.c_str()), reinterpret_cast(id), size); + } +} + +void append_runtime_overlays(std::vector& files, LoadedMod& mod, + std::unordered_map& claims) { + const auto it = s_runtimeOverlays.find(&mod); + if (it == s_runtimeOverlays.end()) { + return; + } + + for (const auto& entry : it->second) { + const auto id = s_nextOverlayId++; + if (entry.buffer != nullptr) { + s_overlayFiles.emplace(id, OverlayFileData{{}, nullptr, entry.buffer}); + } else { + s_overlayFiles.emplace(id, OverlayFileData{entry.bundlePath, mod.bundle, nullptr}); + } + claim_overlay_path(claims, entry.discPath, mod); + files.emplace_back(strdup(entry.discPath.c_str()), reinterpret_cast(id), entry.size); + } +} + +struct OpenOverlayFile { + std::vector ownedData; + std::shared_ptr > shared; + size_t pos = 0; + + [[nodiscard]] const std::vector& data() const { + return shared != nullptr ? *shared : ownedData; + } +}; + +void* cbOpen(void* userdata) { + const auto id = reinterpret_cast(userdata); + OverlayFileData fileData; + { + std::lock_guard lock{s_overlayMutex}; + const auto it = s_overlayFiles.find(id); + if (it == s_overlayFiles.end()) { + // The overlay set was re-pushed between the FST lookup and this call. + return nullptr; + } + fileData = it->second; + } + + if (fileData.buffer != nullptr) { + return new OpenOverlayFile{.shared = std::move(fileData.buffer)}; + } + + try { + auto fileContents = fileData.bundle->readFile(fileData.bundlePath); + return new OpenOverlayFile{.ownedData = std::move(fileContents)}; + } catch (const std::runtime_error& e) { + Log.error("Failed to read overlay file {}: {}", fileData.bundlePath, e.what()); + return nullptr; + } +} + +void cbClose(void* handle) { + const auto openFile = static_cast(handle); + delete openFile; +} + +int64_t cbRead(void* handle, uint8_t* buf, const size_t len) { + auto& openFile = *static_cast(handle); + + const auto remainingSpace = openFile.data().size() - openFile.pos; + const auto toRead = std::min(remainingSpace, len); + std::memcpy(buf, openFile.data().data() + openFile.pos, toRead); + openFile.pos += toRead; + return static_cast(toRead); +} + +int64_t cbSeek(void* handle, int64_t offset, int32_t whence) { + if (whence != 0) { + Log.fatal("Invalid seek mode from aurora: {}", whence); + } + + auto& openFile = *static_cast(handle); + const auto posSigned = + std::clamp(offset, static_cast(0), static_cast(openFile.data().size())); + openFile.pos = static_cast(posSigned); + return posSigned; +} + +constexpr AuroraOverlayCallbacks s_overlayCallbacks = { + .open = cbOpen, + .close = cbClose, + .read = cbRead, + .seek = cbSeek, +}; + +void overlay_sync_files() { + static bool callbacksRegistered = false; + if (!callbacksRegistered) { + aurora_dvd_overlay_callbacks(&s_overlayCallbacks); + callbacksRegistered = true; + } + + s_overlaysDirty = false; + + std::vector files; + std::unordered_map claims; + { + std::lock_guard lock{s_overlayMutex}; + s_overlayFiles.clear(); + for (auto& mod : ModLoader::instance().active_mods()) { + find_overlay_files(files, mod, claims); + append_runtime_overlays(files, mod, claims); + } + } + + Log.debug("Registering {} overlay file(s).", files.size()); + aurora_dvd_overlay_files(files.data(), files.size(), nullptr); + + for (const auto& file : files) { + std::free(const_cast(file.fileName)); + } +} + +uint64_t overlay_add_file( + LoadedMod& mod, std::string discPath, std::string bundlePath, size_t size) { + const auto handle = s_nextRuntimeHandle++; + s_runtimeOverlays[&mod].push_back({ + .handle = handle, + .discPath = std::move(discPath), + .bundlePath = std::move(bundlePath), + .size = size, + }); + s_overlaysDirty = true; + return handle; +} + +uint64_t overlay_add_buffer(LoadedMod& mod, std::string discPath, std::vector data) { + const auto handle = s_nextRuntimeHandle++; + const auto size = data.size(); + s_runtimeOverlays[&mod].push_back({ + .handle = handle, + .discPath = std::move(discPath), + .buffer = std::make_shared>(std::move(data)), + .size = size, + }); + s_overlaysDirty = true; + return handle; +} + +bool overlay_remove(LoadedMod& mod, uint64_t handle) { + const auto it = s_runtimeOverlays.find(&mod); + if (it == s_runtimeOverlays.end()) { + return false; + } + if (std::erase_if(it->second, [&](const auto& entry) { return entry.handle == handle; }) == 0) { + return false; + } + if (it->second.empty()) { + s_runtimeOverlays.erase(it); + } + s_overlaysDirty = true; + return true; +} + +void overlay_remove_mod(LoadedMod& mod) { + if (s_runtimeOverlays.erase(&mod) != 0) { + s_overlaysDirty = true; + } +} + +bool consume_overlays_dirty() { + return std::exchange(s_overlaysDirty, false); +} + +constexpr size_t kMaxOverlayFileSize = UINT32_MAX; + +bool is_valid_disc_path(const char* discPath) { + if (discPath == nullptr) { + return false; + } + const std::string_view path{discPath}; + return path.starts_with('/') && is_safe_resource_path(path.substr(1)); +} + +ModResult overlay_add_file( + ModContext* context, const char* discPath, const char* bundlePath, OverlayHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || !is_valid_disc_path(discPath) || bundlePath == nullptr || + !is_safe_resource_path(bundlePath)) + { + return MOD_INVALID_ARGUMENT; + } + + size_t size = 0; + try { + size = mod->bundle->getFileSize(bundlePath); + } catch (const std::exception& e) { + Log.error( + "[{}] overlay add_file '{}' failed: {}", mod->metadata.id, bundlePath, e.what()); + return MOD_UNAVAILABLE; + } + if (size > kMaxOverlayFileSize) { + Log.error("[{}] overlay add_file '{}' failed: file too large ({} bytes)", + mod->metadata.id, bundlePath, size); + return MOD_INVALID_ARGUMENT; + } + + const auto handle = overlay_add_file(*mod, discPath, bundlePath, size); + if (outHandle != nullptr) { + *outHandle = handle; + } + return MOD_OK; +} + +ModResult overlay_add_buffer(ModContext* context, const char* discPath, const void* data, + size_t size, OverlayHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || !is_valid_disc_path(discPath) || (data == nullptr && size != 0) || + size > kMaxOverlayFileSize) + { + return MOD_INVALID_ARGUMENT; + } + + const auto* bytes = static_cast(data); + const auto handle = overlay_add_buffer(*mod, discPath, std::vector{bytes, bytes + size}); + if (outHandle != nullptr) { + *outHandle = handle; + } + return MOD_OK; +} + +ModResult overlay_remove(ModContext* context, OverlayHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return MOD_INVALID_ARGUMENT; + } + if (!overlay_remove(*mod, handle)) { + Log.error("[{}] overlay remove failed: unknown handle {}", mod->metadata.id, handle); + return MOD_INVALID_ARGUMENT; + } + return MOD_OK; +} + +constexpr OverlayService s_overlayService{ + .header = SERVICE_HEADER(OverlayService, OVERLAY_SERVICE_MAJOR, OVERLAY_SERVICE_MINOR), + .add_file = overlay_add_file, + .add_buffer = overlay_add_buffer, + .remove = overlay_remove, +}; + +} // namespace + +constinit const ServiceModule g_overlayModule{ + .id = OVERLAY_SERVICE_ID, + .majorVersion = OVERLAY_SERVICE_MAJOR, + .minorVersion = OVERLAY_SERVICE_MINOR, + .service = &s_overlayService, + .modDetached = overlay_remove_mod, + .lifecycleApplied = overlay_sync_files, + .frameEnd = + [] { + if (consume_overlays_dirty()) { + overlay_sync_files(); + } + }, +}; +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index fd5909d996..31f67bc7ab 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -199,6 +199,9 @@ void ModLoader::init_services() { { &svc::g_hostModule, &svc::g_logModule, + &svc::g_resourceModule, + &svc::g_overlayModule, + &svc::g_textureModule, &svc::g_configModule, }) { diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 4954c97077..6ffa1a7582 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -64,6 +64,9 @@ void modules_shutdown(); extern const ServiceModule g_hostModule; extern const ServiceModule g_logModule; +extern const ServiceModule g_resourceModule; +extern const ServiceModule g_overlayModule; +extern const ServiceModule g_textureModule; extern const ServiceModule g_configModule; } // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/resource.cpp b/src/dusk/mods/svc/resource.cpp new file mode 100644 index 0000000000..d96973f785 --- /dev/null +++ b/src/dusk/mods/svc/resource.cpp @@ -0,0 +1,102 @@ +#include "registry.hpp" + +#include "aurora/lib/logging.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/resource.h" + +#include + +#include +#include +#include +#include + +namespace dusk::mods::svc { +namespace { + +aurora::Module Log("dusk::mods::resource"); + +// Allocations by owning mod, so buffers still live when a mod detaches can be freed. +std::unordered_map s_buffers; + +void resource_remove_mod(LoadedMod& mod) { + size_t reclaimed = 0; + std::erase_if(s_buffers, [&](const auto& entry) { + if (entry.second != &mod) { + return false; + } + std::free(entry.first); + ++reclaimed; + return true; + }); + if (reclaimed != 0) { + Log.warn("[{}] reclaimed {} resource buffer(s) that were never freed", mod.metadata.id, + reclaimed); + } +} + +ModResult resource_load(ModContext* context, const char* relativePath, ResourceBuffer* outBuffer) { + if (outBuffer == nullptr || outBuffer->struct_size < sizeof(ResourceBuffer)) { + return MOD_INVALID_ARGUMENT; + } + outBuffer->data = nullptr; + outBuffer->size = 0; + auto* mod = mod_from_context(context); + if (mod == nullptr || relativePath == nullptr || !is_safe_resource_path(relativePath)) { + return MOD_INVALID_ARGUMENT; + } + + const auto entry = fmt::format("res/{}", relativePath); + std::vector data; + try { + data = mod->bundle->readFile(entry); + } catch (const std::runtime_error& e) { + Log.error("[{}] resource load '{}' failed: {}", mod->metadata.id, entry, e.what()); + return MOD_UNAVAILABLE; + } + + if (!data.empty()) { + void* copy = std::malloc(data.size()); + if (copy == nullptr) { + return MOD_ERROR; + } + std::memcpy(copy, data.data(), data.size()); + s_buffers.emplace(copy, mod); + outBuffer->data = copy; + outBuffer->size = data.size(); + } + return MOD_OK; +} + +void resource_free(ModContext* context, ResourceBuffer* buffer) { + if (buffer == nullptr || buffer->struct_size < sizeof(ResourceBuffer) || + buffer->data == nullptr) + { + return; + } + if (s_buffers.erase(buffer->data) == 0) { + Log.error("[{}] resource free: not a live loaded buffer", mod_id_from_context(context)); + return; + } + std::free(buffer->data); + buffer->data = nullptr; + buffer->size = 0; +} + +constexpr ResourceService s_resourceService{ + .header = SERVICE_HEADER(ResourceService, RESOURCE_SERVICE_MAJOR, RESOURCE_SERVICE_MINOR), + .load = resource_load, + .free = resource_free, +}; + +} // namespace + +constinit const ServiceModule g_resourceModule{ + .id = RESOURCE_SERVICE_ID, + .majorVersion = RESOURCE_SERVICE_MAJOR, + .minorVersion = RESOURCE_SERVICE_MINOR, + .service = &s_resourceService, + .modDetached = resource_remove_mod, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/texture.cpp b/src/dusk/mods/svc/texture.cpp new file mode 100644 index 0000000000..7eada55739 --- /dev/null +++ b/src/dusk/mods/svc/texture.cpp @@ -0,0 +1,455 @@ +#include "registry.hpp" + +#include "aurora/lib/logging.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/texture.h" + +#include +#include + +#include +#include +#include +#include +#include + +using namespace std::string_literals; + +static_assert(TEXTURE_HASH_WILDCARD == aurora::texture::kWildcardTextureHash); +static_assert(TEXTURE_TLUT_WILDCARD == aurora::texture::kWildcardTlutHash); + +namespace dusk::mods::svc { +namespace { + +struct TextureRawData { + std::vector data; + uint32_t width = 0; + uint32_t height = 0; + uint32_t mipCount = 1; + uint32_t gxFormat = 0; +}; + +aurora::Module Log("dusk::mods::textures"); + +// Referenced by Aurora's lazy virtual-file reads (from arbitrary threads, under Aurora's registry +// lock) and by raw-entry spans. Immutable after construction; freed only after the corresponding +// unregister_replacement returns, at which point Aurora guarantees no further reads. +struct TextureKeepalive { + std::shared_ptr bundle; + std::string bundlePath; + std::vector ownedData; +}; + +// Called with Aurora's registry lock held: must not take any Dusk lock or re-enter +// aurora::texture. ModBundle reads are documented thread-safe. +bool texture_read_cb(void* userData, const char* path, std::vector& outBytes) { + auto* keepalive = static_cast(userData); + try { + outBytes = keepalive->bundle->readFile(path); + return true; + } catch (...) { + return false; + } +} + +struct RuntimeTextureEntry { + uint64_t handle = 0; + aurora::texture::ReplacementRegistration registration; + std::shared_ptr keepalive; + // Original inputs, kept for re-registration when the mod's priority changes. + bool isVirtual = false; + aurora::texture::ReplacementKey key; // raw entries only + uint32_t width = 0; + uint32_t height = 0; + uint32_t mipCount = 1; + uint32_t gxFormat = 0; + std::string label; +}; + +struct ModTextureRecord { + int32_t appliedPriority = 0; + bool staticRegistered = false; + aurora::texture::ReplacementGroup staticGroup; + std::vector> staticKeepalives; + std::vector runtime; +}; + +// Game thread only: all mutations happen in service calls made from mod code (init/update/hooks +// run inside ModLoader::tick), in the loader's sync/deactivate paths, or at shutdown. +std::unordered_map s_modTextures; +uint64_t s_nextTextureHandle = 1; + +// Position in m_mods (dependency-sorted load order) + 1; later-loaded mods win. The user +// texture_replacements directory uses kUserTextureReplacementPriority, below any mod. +int32_t compute_mod_priority(const LoadedMod& mod) { + int32_t index = 0; + for (const auto& other : ModLoader::instance().mods()) { + ++index; + if (&other == &mod) { + return index; + } + } + return index + 1; +} + +bool is_sidecar_mip(std::string_view stem) { + constexpr std::string_view tag = "_mip"; + size_t i = stem.size(); + while (i > 0 && stem[i - 1] >= '0' && stem[i - 1] <= '9') { + --i; + } + if (i == stem.size() || i < tag.size()) { + return false; + } + return stem.substr(i - tag.size(), tag.size()) == tag; +} + +bool has_replacement_extension(std::string_view filename) { + const auto dot = filename.rfind('.'); + if (dot == std::string_view::npos) { + return false; + } + std::string ext{filename.substr(dot)}; + std::ranges::transform(ext, ext.begin(), + [](char ch) { return ch >= 'A' && ch <= 'Z' ? static_cast(ch + 'a' - 'A') : ch; }); + return ext == ".dds" || ext == ".png"; +} + +std::string_view final_path_component(std::string_view path) { + const auto slash = path.rfind('/'); + return slash == std::string_view::npos ? path : path.substr(slash + 1); +} + +const LoadedMod* find_static_conflict( + const aurora::texture::ReplacementKey& key, const LoadedMod* exclude) { + for (const auto& [mod, record] : s_modTextures) { + if (mod == exclude) { + continue; + } + for (const auto& registration : record.staticGroup.registrations) { + if (registration.key == key) { + return mod; + } + } + } + return nullptr; +} + +void register_static_textures(LoadedMod& mod, ModTextureRecord& record) { + std::vector candidates; + for (const auto& file : mod.bundle->getFileNames()) { + if (!file.starts_with("textures/") || !has_replacement_extension(file)) { + continue; + } + auto filename = final_path_component(file); + if (is_sidecar_mip(filename.substr(0, filename.rfind('.')))) { + continue; + } + candidates.push_back(file); + } + // Deterministic order; with the first parse of a key winning, this mirrors Aurora's + // load_replacement_directory dedupe semantics. + std::ranges::sort(candidates); + + std::vector seenKeys; + for (const auto& path : candidates) { + const auto parsed = aurora::texture::parse_replacement_filename(final_path_component(path)); + if (!parsed.has_value()) { + Log.warn( + "[{}] '{}' does not follow the texture replacement naming convention; skipped.", + mod.metadata.id, path); + continue; + } + const aurora::texture::ReplacementKey key{*parsed}; + if (std::ranges::find(seenKeys, key) != seenKeys.end()) { + continue; + } + seenKeys.push_back(key); + + if (const auto* other = find_static_conflict(key, &mod); other != nullptr) { + const auto& winner = + s_modTextures.find(other)->second.appliedPriority > record.appliedPriority ? + *other : + mod; + Log.warn( + "Texture replacement conflict: '{}' is replaced by both '{}' and '{}'; '{}' wins.", + path, other->metadata.id, mod.metadata.id, winner.metadata.id); + } + + auto keepalive = std::make_shared(mod.bundle, path); + const auto registration = aurora::texture::register_virtual_replacement(path, + {.read = texture_read_cb, .userData = keepalive.get()}, + {.priority = record.appliedPriority}); + if (registration.id == 0) { + continue; + } + record.staticGroup.registrations.push_back(registration); + record.staticKeepalives.push_back(std::move(keepalive)); + } + + record.staticRegistered = true; + if (!record.staticGroup.registrations.empty()) { + Log.info("[{}] registered {} texture replacement(s).", mod.metadata.id, + record.staticGroup.registrations.size()); + } +} + +void register_runtime_entry(RuntimeTextureEntry& entry, int32_t priority) { + if (entry.isVirtual) { + entry.registration = aurora::texture::register_virtual_replacement( + entry.keepalive->bundlePath, + {.read = texture_read_cb, .userData = entry.keepalive.get()}, {.priority = priority}); + } else { + entry.registration = aurora::texture::register_replacement(entry.key, + { + .bytes = {entry.keepalive->ownedData.data(), entry.keepalive->ownedData.size()}, + .width = entry.width, + .height = entry.height, + .mipCount = entry.mipCount, + .gxFormat = entry.gxFormat, + .label = entry.label, + }, + {.priority = priority}); + } +} + +void unregister_record(ModTextureRecord& record) { + aurora::texture::unregister_replacements(record.staticGroup); + record.staticGroup.registrations.clear(); + record.staticKeepalives.clear(); + record.staticRegistered = false; + for (auto& entry : record.runtime) { + aurora::texture::unregister_replacement(entry.registration); + entry.registration = {}; + } +} + +void textures_sync_replacements() { + // Module detach removes records eagerly, but a record whose mod is no + // longer active must not linger with stale priority. + std::erase_if(s_modTextures, [&](auto& item) { + if (item.first->active) { + return false; + } + unregister_record(item.second); + return true; + }); + + for (auto& mod : ModLoader::instance().active_mods()) { + const auto priority = compute_mod_priority(mod); + auto& record = s_modTextures[&mod]; + + if (record.staticRegistered && record.appliedPriority == priority) { + continue; + } + + if (record.staticRegistered) { + // A reload re-sorted m_mods and changed this mod's priority: re-register everything + // at the new priority. Cheap, since file-backed entries decode lazily. + aurora::texture::unregister_replacements(record.staticGroup); + record.staticGroup.registrations.clear(); + record.staticKeepalives.clear(); + record.appliedPriority = priority; + register_static_textures(mod, record); + for (auto& entry : record.runtime) { + aurora::texture::unregister_replacement(entry.registration); + register_runtime_entry(entry, priority); + } + } else { + record.appliedPriority = priority; + register_static_textures(mod, record); + } + } +} + +uint64_t texture_register_raw( + LoadedMod& mod, const aurora::texture::ReplacementKey& key, TextureRawData data) { + auto& record = s_modTextures[&mod]; + if (record.appliedPriority == 0) { + record.appliedPriority = compute_mod_priority(mod); + } + + auto& entry = record.runtime.emplace_back(); + entry.handle = s_nextTextureHandle++; + entry.keepalive = std::make_shared(); + entry.keepalive->ownedData = std::move(data.data); + entry.isVirtual = false; + entry.key = key; + entry.width = data.width; + entry.height = data.height; + entry.mipCount = data.mipCount; + entry.gxFormat = data.gxFormat; + entry.label = fmt::format("mod {} texture {}", mod.metadata.id, entry.handle); + register_runtime_entry(entry, record.appliedPriority); + return entry.handle; +} + +uint64_t texture_register_file(LoadedMod& mod, std::string bundlePath) { + auto& record = s_modTextures[&mod]; + if (record.appliedPriority == 0) { + record.appliedPriority = compute_mod_priority(mod); + } + + auto& entry = record.runtime.emplace_back(); + entry.handle = s_nextTextureHandle++; + entry.keepalive = std::make_shared(mod.bundle, std::move(bundlePath)); + entry.isVirtual = true; + register_runtime_entry(entry, record.appliedPriority); + if (entry.registration.id == 0) { + record.runtime.pop_back(); + return 0; + } + return entry.handle; +} + +bool texture_unregister(LoadedMod& mod, uint64_t handle) { + const auto it = s_modTextures.find(&mod); + if (it == s_modTextures.end()) { + return false; + } + auto& runtime = it->second.runtime; + const auto entry = + std::ranges::find_if(runtime, [&](const auto& e) { return e.handle == handle; }); + if (entry == runtime.end()) { + return false; + } + aurora::texture::unregister_replacement(entry->registration); + runtime.erase(entry); + return true; +} + +void textures_remove_mod(LoadedMod& mod) { + const auto it = s_modTextures.find(&mod); + if (it == s_modTextures.end()) { + return; + } + unregister_record(it->second); + s_modTextures.erase(it); +} + +std::optional translate_key(const TextureKey* key) { + if (key == nullptr || key->struct_size < sizeof(TextureKey)) { + return std::nullopt; + } + switch (key->kind) { + case TEXTURE_KEY_POINTER: + if (key->pointer == nullptr) { + return std::nullopt; + } + return aurora::texture::ReplacementKey{aurora::texture::TexturePointerKey{key->pointer}}; + case TEXTURE_KEY_SOURCE: + if (key->width == 0 || key->height == 0) { + return std::nullopt; + } + return aurora::texture::ReplacementKey{aurora::texture::TextureSourceKey{ + .textureHash = key->texture_hash, + .tlutHash = key->tlut_hash, + .width = key->width, + .height = key->height, + .format = key->gx_format, + .hasTlut = key->has_tlut, + }}; + default: + return std::nullopt; + } +} + +ModResult texture_register_data(ModContext* context, const TextureKey* key, const TextureData* data, + TextureReplacementHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + const auto translatedKey = translate_key(key); + if (mod == nullptr || !translatedKey.has_value() || data == nullptr || + data->struct_size < sizeof(TextureData) || data->data == nullptr || data->size == 0 || + data->width == 0 || data->height == 0 || data->mip_count == 0) + { + return MOD_INVALID_ARGUMENT; + } + + const auto* bytes = static_cast(data->data); + const auto handle = texture_register_raw(*mod, *translatedKey, + { + .data = std::vector{bytes, bytes + data->size}, + .width = data->width, + .height = data->height, + .mipCount = data->mip_count, + .gxFormat = data->gx_format, + }); + if (outHandle != nullptr) { + *outHandle = handle; + } + return MOD_OK; +} + +ModResult texture_register_file( + ModContext* context, const char* bundlePath, TextureReplacementHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || bundlePath == nullptr || !is_safe_resource_path(bundlePath)) { + return MOD_INVALID_ARGUMENT; + } + + const std::string_view path{bundlePath}; + const auto slash = path.rfind('/'); + const auto filename = slash == std::string_view::npos ? path : path.substr(slash + 1); + if (!aurora::texture::parse_replacement_filename(filename).has_value()) { + Log.error("[{}] texture register_file '{}' failed: " + "filename does not follow the replacement naming convention", + mod->metadata.id, bundlePath); + return MOD_INVALID_ARGUMENT; + } + + try { + mod->bundle->getFileSize(bundlePath); + } catch (const std::exception& e) { + Log.error( + "[{}] texture register_file '{}' failed: {}", mod->metadata.id, bundlePath, e.what()); + return MOD_UNAVAILABLE; + } + + const auto handle = texture_register_file(*mod, bundlePath); + if (handle == 0) { + return MOD_INVALID_ARGUMENT; + } + if (outHandle != nullptr) { + *outHandle = handle; + } + return MOD_OK; +} + +ModResult texture_unregister(ModContext* context, TextureReplacementHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return MOD_INVALID_ARGUMENT; + } + if (!texture_unregister(*mod, handle)) { + Log.error( + "[{}] texture unregister failed: unknown handle {}", mod->metadata.id, handle); + return MOD_INVALID_ARGUMENT; + } + return MOD_OK; +} + +constexpr TextureService s_textureService{ + .header = SERVICE_HEADER(TextureService, TEXTURE_SERVICE_MAJOR, TEXTURE_SERVICE_MINOR), + .register_data = texture_register_data, + .register_file = texture_register_file, + .unregister = texture_unregister, +}; + +} // namespace + +constinit const ServiceModule g_textureModule{ + .id = TEXTURE_SERVICE_ID, + .majorVersion = TEXTURE_SERVICE_MAJOR, + .minorVersion = TEXTURE_SERVICE_MINOR, + .service = &s_textureService, + .modDetached = textures_remove_mod, + .lifecycleApplied = textures_sync_replacements, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/texture_replacements.cpp b/src/dusk/texture_replacements.cpp index 5477a38dd9..2866af855f 100644 --- a/src/dusk/texture_replacements.cpp +++ b/src/dusk/texture_replacements.cpp @@ -20,7 +20,8 @@ void reload() { } const auto root = ConfigPath / "texture_replacements"; - s_directoryGroup = aurora::texture::load_replacement_directory(root); + s_directoryGroup = aurora::texture::load_replacement_directory( + root, {.priority = kUserTextureReplacementPriority}); DuskLog.info("Texture replacement directory loaded: {} registration(s)", s_directoryGroup.registrations.size()); } From 4339e60ba65dc16702b019d614bfaf7dc724d5b9 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 7 Jul 2026 15:14:58 -0600 Subject: [PATCH 064/150] Hook service, symgen manifest & data linking --- CMakeLists.txt | 85 +++++- cmake/ModSDK.cmake | 45 +++ cmake/PatchCapstone.cmake | 13 + cmake/PatchFunchook.cmake | 60 ++++ cmake/SymbolManifest.cmake | 125 ++++++++ cmake/WindowsExports.cmake | 76 +++++ docs/modding.md | 172 ++++++++++- files.cmake | 4 + include/d/d_com_inf_game.h | 2 +- include/dusk/gx_helper.h | 3 +- include/dusk/profiling.hpp | 12 + include/global.h | 13 + include/mods/hook.hpp | 464 +++++++++++++++++++++++++++++ include/mods/svc/game.h | 30 ++ include/mods/svc/hook.h | 131 ++++++++ include/mods/svc/host.h | 12 +- sdk/CMakeLists.txt | 10 + sdk/pseudo_reloc.cpp | 139 +++++++++ src/dusk/mods/loader/loader.cpp | 5 +- src/dusk/mods/loader/manifest.cpp | 386 ++++++++++++++++++++++++ src/dusk/mods/loader/manifest.hpp | 50 ++++ src/dusk/mods/svc/game.cpp | 21 ++ src/dusk/mods/svc/hook.cpp | 480 ++++++++++++++++++++++++++++++ src/dusk/mods/svc/host.cpp | 13 +- src/dusk/mods/svc/registry.cpp | 2 + src/dusk/mods/svc/registry.hpp | 2 + 26 files changed, 2341 insertions(+), 14 deletions(-) create mode 100644 cmake/PatchCapstone.cmake create mode 100644 cmake/PatchFunchook.cmake create mode 100644 cmake/SymbolManifest.cmake create mode 100644 cmake/WindowsExports.cmake create mode 100644 include/dusk/profiling.hpp create mode 100644 include/mods/hook.hpp create mode 100644 include/mods/svc/game.h create mode 100644 include/mods/svc/hook.h create mode 100644 sdk/pseudo_reloc.cpp create mode 100644 src/dusk/mods/loader/manifest.cpp create mode 100644 src/dusk/mods/loader/manifest.hpp create mode 100644 src/dusk/mods/svc/game.cpp create mode 100644 src/dusk/mods/svc/hook.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d7093b097c..e62704fe1c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -217,7 +217,41 @@ FetchContent_Declare(miniz DOWNLOAD_EXTRACT_TIMESTAMP TRUE EXCLUDE_FROM_ALL ) -FetchContent_MakeAvailable(cxxopts json miniz) + +set(_fetch_content_deps cxxopts json miniz) +if (DUSK_ENABLE_CODE_MODS) + message(STATUS "dusklight: Fetching funchook") + # cmake/PatchFunchook.cmake patches funchook's cmake/capstone.cmake.in to inject a + # PATCH_COMMAND into capstone's inner ExternalProject. That PATCH_COMMAND runs + # cmake/PatchCapstone.cmake after capstone is cloned, which removes the + # cmake_policy(SET CMP0048 OLD) line that CMake >= 3.27 rejects. + # This is incredibly scuffed and we should probably think of a better way to do this + set(CAPSTONE_FIX_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/cmake/PatchCapstone.cmake") + FetchContent_Declare(funchook + GIT_REPOSITORY https://github.com/kubo/funchook.git + GIT_TAG v1.1.3 + GIT_SHALLOW TRUE + GIT_PROGRESS TRUE + PATCH_COMMAND ${CMAKE_COMMAND} -DSOURCE_DIR= -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/PatchFunchook.cmake + EXCLUDE_FROM_ALL + ) + set(FUNCHOOK_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(FUNCHOOK_BUILD_SHARED OFF CACHE BOOL "" FORCE) + set(FUNCHOOK_INSTALL OFF CACHE BOOL "" FORCE) + if (APPLE AND CMAKE_OSX_ARCHITECTURES) + list(LENGTH CMAKE_OSX_ARCHITECTURES _osx_arch_count) + if (_osx_arch_count EQUAL 1) + list(GET CMAKE_OSX_ARCHITECTURES 0 _osx_arch) + if (_osx_arch MATCHES "^(arm64|aarch64|ARM64)$") + set(FUNCHOOK_CPU arm64 CACHE STRING "" FORCE) + elseif (_osx_arch MATCHES "^(x86_64|AMD64|amd64|i[3-6]86|x86)$") + set(FUNCHOOK_CPU x86 CACHE STRING "" FORCE) + endif () + endif () + endif () + list(APPEND _fetch_content_deps funchook) +endif () +FetchContent_MakeAvailable(${_fetch_content_deps}) if (DUSK_ENABLE_SENTRY_NATIVE) message(STATUS "dusklight: Fetching sentry-native") @@ -273,6 +307,9 @@ find_package(Threads REQUIRED) set(GAME_LIBS aurora::core aurora::gx aurora::gd aurora::si aurora::vi aurora::pad aurora::mtx aurora::os aurora::dvd aurora::card freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt Threads::Threads zstd::libzstd dusklight_game_headers) +if (DUSK_ENABLE_CODE_MODS) + list(APPEND GAME_LIBS funchook-static) +endif () if (DUSK_ENABLE_SENTRY_NATIVE) list(APPEND GAME_LIBS sentry) @@ -413,6 +450,52 @@ target_compile_definitions(dusklight PRIVATE ${GAME_COMPILE_DEFS}) target_include_directories(dusklight PRIVATE ${miniz_SOURCE_DIR}) target_link_libraries(dusklight PRIVATE aurora::main ${GAME_LIBS} ${JSYSTEM_LINK_LIBRARIES}) target_precompile_headers(dusklight PRIVATE "$<$:${CMAKE_SOURCE_DIR}/include/dusk_pch.hpp>") + +if (DUSK_ENABLE_CODE_MODS) + include(cmake/SymbolManifest.cmake) + if (WIN32) + # Game ABI exports & import library for mod linking + include(cmake/WindowsExports.cmake) + setup_windows_exports(dusklight) + endif () + # Post-link symbol manifest: hookable-surface name->address map keyed to the build. + setup_symbol_manifest(dusklight) +endif () + +# Hook reliability: guaranteed patchable entries on the game ABI surface, and no identical-code folding. +if (MSVC) + if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + set(DUSK_PATCHABLE_ENTRY_FLAG $<$:/hotpatch>) + endif () + if (CMAKE_SYSTEM_PROCESSOR STREQUAL "ARM64") + target_link_options(dusklight PRIVATE /FUNCTIONPADMIN:16 /OPT:NOICF) + else () + target_link_options(dusklight PRIVATE /FUNCTIONPADMIN /OPT:NOICF) + endif () +elseif (CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + if (CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64" OR CMAKE_OSX_ARCHITECTURES MATCHES "arm64") + set(DUSK_PATCHABLE_ENTRY_FLAG $<$:-fpatchable-function-entry=2,1>) + else () + set(DUSK_PATCHABLE_ENTRY_FLAG $<$:-fpatchable-function-entry=10,5>) + endif () +endif () +if (DEFINED DUSK_PATCHABLE_ENTRY_FLAG) + target_compile_options(dusklight PRIVATE ${DUSK_PATCHABLE_ENTRY_FLAG}) + foreach(jsystem_lib IN LISTS JSYSTEM_LIBRARIES) + target_compile_options(${jsystem_lib} PRIVATE ${DUSK_PATCHABLE_ENTRY_FLAG}) + endforeach() +endif () + +if (WIN32) + target_link_libraries(dusklight PRIVATE Psapi) +endif () +if (APPLE) + # Mods resolve game symbols from the executable at dlopen time. + target_link_options(dusklight PRIVATE -Wl,-export_dynamic) +elseif (UNIX AND NOT ANDROID) + target_link_options(dusklight PRIVATE -rdynamic) +endif () + if (TARGET crashpad_handler) add_dependencies(dusklight crashpad_handler) add_custom_command(TARGET dusklight POST_BUILD diff --git a/cmake/ModSDK.cmake b/cmake/ModSDK.cmake index 909e8abcba..c320dcb5f4 100644 --- a/cmake/ModSDK.cmake +++ b/cmake/ModSDK.cmake @@ -85,6 +85,51 @@ function(add_mod target_name) endif () endif () + if (APPLE) + # Game symbols resolve against the host executable at dlopen time. + target_link_options(${target_name} PRIVATE -undefined dynamic_lookup) + elseif (ANDROID) + if (TARGET dusklight) + target_link_libraries(${target_name} PRIVATE dusklight) + elseif (DUSK_GAME_SOLIB) + target_link_libraries(${target_name} PRIVATE "${DUSK_GAME_SOLIB}") + else () + message(FATAL_ERROR "add_mod: DUSK_GAME_SOLIB is not set (libmain.so)") + endif () + elseif (UNIX) + target_link_options(${target_name} PRIVATE -Wl,--allow-shlib-undefined) + elseif (WIN32) + # Link against the generated import library (game ABI surface). Function calls + # resolve through import thunks. Data is toolchain dependent: + # - clang-cl: lld's mingw mode auto-imports data references, fixed up at load by + # the mod SDK's pseudo-relocation runtime (pseudo_reloc.cpp). + # - cl (MSVC): only DUSK_GAME_DATA-annotated data is reachable. Un-annotated + # references fail to link. + if (NOT DUSK_GAME_IMPLIB) + message(FATAL_ERROR "add_mod: DUSK_GAME_IMPLIB is not set.") + endif () + target_link_libraries(${target_name} PRIVATE "${DUSK_GAME_IMPLIB}") + set_target_properties(${target_name} PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreadedDLL") + target_compile_definitions(${target_name} PRIVATE _ITERATOR_DEBUG_LEVEL=0) + if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + target_compile_options(${target_name} PRIVATE "$<$:/clang:-mcmodel=large>") + target_sources(${target_name} PRIVATE "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../sdk/pseudo_reloc.cpp") + # lld mingw mode rewrites /DEFAULTLIB directives to -l style and skips %LIB%, so + # the CRT libraries and search paths are spelled out explicitly. + target_link_options(${target_name} PRIVATE -lldmingw /nodefaultlib /INCREMENTAL:NO) + target_link_libraries(${target_name} PRIVATE + msvcrt.lib msvcprt.lib vcruntime.lib ucrt.lib + oldnames.lib uuid.lib kernel32.lib user32.lib) + set(_lib_dirs "$ENV{LIB}") + if ("${_lib_dirs}" STREQUAL "") + message(FATAL_ERROR "add_mod: %LIB% is empty; configure from a VS dev shell") + endif () + foreach (_libdir IN LISTS _lib_dirs) + target_link_options(${target_name} PRIVATE "/libpath:${_libdir}") + endforeach () + endif () + endif () + set(_output_dir "${DUSK_MODS_OUTPUT_DIR}") if (ARG_OUTPUT_DIR) set(_output_dir "${ARG_OUTPUT_DIR}") diff --git a/cmake/PatchCapstone.cmake b/cmake/PatchCapstone.cmake new file mode 100644 index 0000000000..fa8a1dd160 --- /dev/null +++ b/cmake/PatchCapstone.cmake @@ -0,0 +1,13 @@ +# Patches capstone's CMakeLists.txt for compatibility with CMake >= 4.0: +# - Bumps cmake_minimum_required to 3.10 (CMake >= 4.0 dropped < 3.5 support; < 3.10 warns) +# - Removes cmake_policy(SET CMP0048 OLD) (rejected by CMake >= 3.27) +file(READ "${DIR}/CMakeLists.txt" _content) +string(REGEX REPLACE + "cmake_minimum_required[ \t]*\\([ \t]*VERSION[ \t]+[0-9]+\\.[0-9]+(\\.[0-9]+)?[ \t]*\\)" + "cmake_minimum_required(VERSION 3.10)" + _content "${_content}") +string(REGEX REPLACE + "cmake_policy[ \t]*\\([ \t]*SET[ \t]+CMP0048[ \t]+OLD[ \t]*\\)" + "# cmake_policy(SET CMP0048 OLD)" + _content "${_content}") +file(WRITE "${DIR}/CMakeLists.txt" "${_content}") diff --git a/cmake/PatchFunchook.cmake b/cmake/PatchFunchook.cmake new file mode 100644 index 0000000000..6d5a6e36b4 --- /dev/null +++ b/cmake/PatchFunchook.cmake @@ -0,0 +1,60 @@ +file(READ "${SOURCE_DIR}/cmake/capstone.cmake.in" _content) + +# Insert PATCH_COMMAND before CONFIGURE_COMMAND in the ExternalProject_Add. +# Bracket args prevent cmake from substituting ${...} while writing this file. +string(REPLACE + " CONFIGURE_COMMAND \"\"" + [=[ PATCH_COMMAND "${CMAKE_COMMAND}" -DDIR=${CMAKE_CURRENT_BINARY_DIR}/capstone-src -P "${CAPSTONE_FIX_SCRIPT}" + CONFIGURE_COMMAND ""]=] + _content "${_content}") + +file(WRITE "${SOURCE_DIR}/cmake/capstone.cmake.in" "${_content}") + +file(READ "${SOURCE_DIR}/src/funchook_unix.c" _unix_content) + +# macOS rejects the POSIX mprotect RWX/RW transition for executable image pages on arm64. +# Use Mach VM_PROT_COPY for the short patch window, then restore RX permissions. +if (NOT _unix_content MATCHES "VM_PROT_READ \\| VM_PROT_WRITE \\| VM_PROT_COPY") + string(REPLACE + [=[ rv = mprotect(mstate->addr, mstate->size, prot);]=] + [=[#ifdef __APPLE__ + kern_return_t kr = vm_protect(mach_task_self(), (vm_address_t)mstate->addr, + (vm_size_t)mstate->size, FALSE, + VM_PROT_READ | VM_PROT_WRITE | VM_PROT_COPY); + if (kr == KERN_SUCCESS) { + funchook_log(funchook, " unprotect memory %p (size=%"PRIuPTR", prot=read,write,copy) <- %p (size=%"PRIuPTR")\n", + mstate->addr, mstate->size, start, len); + return 0; + } + funchook_set_error_message(funchook, "Failed to unprotect memory %p (size=%"PRIuPTR", prot=read,write,copy) <- %p (size=%"PRIuPTR", error=%s)", + mstate->addr, mstate->size, start, len, + mach_error_string(kr)); + return FUNCHOOK_ERROR_MEMORY_FUNCTION; +#endif + rv = mprotect(mstate->addr, mstate->size, prot);]=] + _unix_content "${_unix_content}") + + string(REPLACE + [=[ char errbuf[128]; + int rv = mprotect(mstate->addr, mstate->size, PROT_READ | PROT_EXEC);]=] + [=[ char errbuf[128]; +#ifdef __APPLE__ + kern_return_t kr = vm_protect(mach_task_self(), (vm_address_t)mstate->addr, + (vm_size_t)mstate->size, FALSE, + VM_PROT_READ | VM_PROT_EXECUTE); + + if (kr == KERN_SUCCESS) { + funchook_log(funchook, " protect memory %p (size=%"PRIuPTR", prot=read,exec)\n", + mstate->addr, mstate->size); + return 0; + } + funchook_set_error_message(funchook, "Failed to protect memory %p (size=%"PRIuPTR", prot=read,exec, error=%s)", + mstate->addr, mstate->size, + mach_error_string(kr)); + return FUNCHOOK_ERROR_MEMORY_FUNCTION; +#endif + int rv = mprotect(mstate->addr, mstate->size, PROT_READ | PROT_EXEC);]=] + _unix_content "${_unix_content}") +endif () + +file(WRITE "${SOURCE_DIR}/src/funchook_unix.c" "${_unix_content}") diff --git a/cmake/SymbolManifest.cmake b/cmake/SymbolManifest.cmake new file mode 100644 index 0000000000..ffaae9177e --- /dev/null +++ b/cmake/SymbolManifest.cmake @@ -0,0 +1,125 @@ +include_guard(GLOBAL) + +get_filename_component(_SYMBOL_MANIFEST_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) + +set(_SYMGEN_VERSION "1.1.0") +set(_SYMGEN_RELEASE_BASE_URL "https://github.com/encounter/symgen/releases/download/v${_SYMGEN_VERSION}") +set(SYMGEN_PATH "" CACHE FILEPATH "Path to a symgen executable; empty downloads the pinned release") +mark_as_advanced(SYMGEN_PATH) + +function(symgen_host_asset out_name) + string(TOLOWER "${CMAKE_HOST_SYSTEM_PROCESSOR}" _host_processor) + set(_asset "") + + if (CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") + if (_host_processor MATCHES "^(arm64|aarch64)$") + set(_asset "symgen-macos-arm64") + elseif (_host_processor MATCHES "^(x86_64|amd64)$") + set(_asset "symgen-macos-x86_64") + endif () + elseif (CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux") + if (_host_processor MATCHES "^(aarch64|arm64)$") + set(_asset "symgen-linux-aarch64") + elseif (_host_processor MATCHES "^(x86_64|amd64)$") + set(_asset "symgen-linux-x86_64") + elseif (_host_processor MATCHES "^(i[3-6]86|x86)$") + set(_asset "symgen-linux-i686") + endif () + elseif (CMAKE_HOST_WIN32) + if (_host_processor MATCHES "^(arm64|aarch64)$") + set(_asset "symgen-windows-arm64.exe") + elseif (_host_processor MATCHES "^(x86_64|amd64)$") + set(_asset "symgen-windows-x86_64.exe") + elseif (_host_processor MATCHES "^(i[3-6]86|x86)$") + set(_asset "symgen-windows-x86.exe") + endif () + endif () + + set(${out_name} "${_asset}" PARENT_SCOPE) +endfunction() + +function(ensure_symgen required) + if (TARGET symgen) + return() + endif () + + if (SYMGEN_PATH) + get_filename_component(_symgen "${SYMGEN_PATH}" ABSOLUTE) + if (NOT EXISTS "${_symgen}") + if (required) + message(FATAL_ERROR "symgen: SYMGEN_PATH does not exist: ${_symgen}") + endif () + message(STATUS "symgen: SYMGEN_PATH does not exist, symbol manifest generation " + "skipped (by-name hook resolution will be unavailable)") + return() + endif () + else () + symgen_host_asset(_asset) + if (_asset STREQUAL "") + if (required) + message(FATAL_ERROR "symgen: no prebuilt binary for host " + "${CMAKE_HOST_SYSTEM_NAME}/${CMAKE_HOST_SYSTEM_PROCESSOR} " + "(configure with -DDUSK_ENABLE_CODE_MODS=OFF)") + endif () + message(STATUS "symgen: no prebuilt binary for host " + "${CMAKE_HOST_SYSTEM_NAME}/${CMAKE_HOST_SYSTEM_PROCESSOR}; " + "symbol manifest generation skipped (by-name hook resolution will be unavailable)") + return() + endif () + + set(_symgen_dir "${CMAKE_BINARY_DIR}/_deps/symgen") + set(_symgen "${_symgen_dir}/${_asset}") + set(_url "${_SYMGEN_RELEASE_BASE_URL}/${_asset}") + message(STATUS "dusklight: Fetching symgen ${_SYMGEN_VERSION} (${_asset})") + file(MAKE_DIRECTORY "${_symgen_dir}") + file(DOWNLOAD "${_url}" "${_symgen}" + TLS_VERIFY ON + STATUS _download_status + SHOW_PROGRESS) + list(GET _download_status 0 _download_code) + if (NOT _download_code EQUAL 0) + list(GET _download_status 1 _download_message) + file(REMOVE "${_symgen}") + if (required) + message(FATAL_ERROR "symgen: failed to download ${_url}: ${_download_message}") + endif () + message(STATUS "symgen: failed to download ${_url}: ${_download_message}; " + "symbol manifest generation skipped (by-name hook resolution will be unavailable)") + return() + endif () + if (NOT CMAKE_HOST_WIN32) + file(CHMOD "${_symgen}" PERMISSIONS + OWNER_READ OWNER_WRITE OWNER_EXECUTE + GROUP_READ GROUP_EXECUTE + WORLD_READ WORLD_EXECUTE) + endif () + endif () + + add_custom_target(symgen DEPENDS "${_symgen}") + set(SYMGEN_EXE "${_symgen}" CACHE INTERNAL "symgen executable" FORCE) +endfunction() + +function(setup_symbol_manifest target) + ensure_symgen(TRUE) + if (NOT TARGET symgen) + return() + endif () + add_dependencies(${target} symgen) + + if (WIN32) + set(_input --pdb "$") + set(_out "$/dusklight.symdb") + else () + set(_input --binary "$") + if (APPLE) + set(_out "$/Resources/dusklight.symdb") + else () + set(_out "$/dusklight.symdb") + endif () + endif () + + add_custom_command(TARGET ${target} POST_BUILD + COMMAND "${SYMGEN_EXE}" manifest ${_input} --out "${_out}" + COMMENT "Generating symbol manifest" + VERBATIM) +endfunction() diff --git a/cmake/WindowsExports.cmake b/cmake/WindowsExports.cmake new file mode 100644 index 0000000000..f380094b78 --- /dev/null +++ b/cmake/WindowsExports.cmake @@ -0,0 +1,76 @@ +include_guard(GLOBAL) + +get_filename_component(_DUSK_WINDOWS_EXPORTS_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) + +# Windows mod linking: generate the curated export surface for the game executable and the +# import library mods link against. symgen scans the built objects, filters by source, and +# writes a .def used by the main link and import library generation. +function(setup_windows_exports target) + if (NOT CMAKE_SIZEOF_VOID_P EQUAL 8) + message(WARNING "dusklight: Windows code-mod exports are x64-only for now; skipping") + return() + endif () + + include("${_DUSK_WINDOWS_EXPORTS_CMAKE_DIR}/SymbolManifest.cmake") + ensure_symgen(TRUE) + set(_symgen "${SYMGEN_EXE}") + add_dependencies(${target} symgen) + + set(_rsp_lines "$") + foreach (_lib IN LISTS JSYSTEM_LIBRARIES) + list(APPEND _rsp_lines "$") + endforeach () + list(JOIN _rsp_lines "\n" _rsp_content) + set(_rsp "${CMAKE_BINARY_DIR}/dusklight_exports_input.rsp") + file(GENERATE OUTPUT "${_rsp}" CONTENT "${_rsp_content}") + + set(_sdk_args) + foreach (_lib aurora_card aurora_core aurora_dvd aurora_gd aurora_gx aurora_mtx + aurora_os aurora_pad aurora_si aurora_vi) + if (TARGET ${_lib}) + list(APPEND _sdk_args --sdk-lib "$") + endif () + endforeach () + + # Generate curated exports list from the main binary + set(_def "${CMAKE_BINARY_DIR}/dusklight_exports.def") + add_custom_command(TARGET ${target} PRE_LINK + # TODO: src/dusk/ is NOT excluded: inline code in game headers + # currently call into it (e.g. dusk::frame_interp::lookup_replacement). + COMMAND "${_symgen}" def + --rsp "${_rsp}" + --out "${_def}" + --exclude cmake_pch + --exclude miniz + --exclude asan_options + --max-exports 58000 + ${_sdk_args} + COMMENT "Generating dusklight exports" + VERBATIM) + target_link_options(${target} PRIVATE "/DEF:${_def}") + + # Generate import library for mods to link against. + set(_implib "${CMAKE_BINARY_DIR}/dusklight_imports.lib") + get_filename_component(_compiler_dir "${CMAKE_CXX_COMPILER}" DIRECTORY) + find_program(DUSK_LLVM_DLLTOOL llvm-dlltool HINTS "${_compiler_dir}") + if (DUSK_LLVM_DLLTOOL) + set(_implib_cmd "${DUSK_LLVM_DLLTOOL}" -d "${_def}" -D dusklight.exe -m i386:x86-64 + -l "${_implib}") + else () + set(_implib_cmd "${CMAKE_AR}" /nologo "/def:${_def}" /machine:x64 /name:dusklight.exe + "/out:${_implib}") + endif () + add_custom_command(TARGET ${target} POST_BUILD + COMMAND ${_implib_cmd} + BYPRODUCTS "${_implib}" + COMMENT "Generating dusklight import library" + VERBATIM) + if ("$CACHE{DUSK_GAME_IMPLIB}" STREQUAL "") + set(DUSK_GAME_IMPLIB "${_implib}" CACHE INTERNAL "Import library for Windows mod linking") + endif () + set(DUSK_GAME_DEF "${_def}" CACHE INTERNAL "Curated export .def for the game executable") + + # Ship the import library as sdk/dusklight.lib in the install tree: mods may use it to + # compile against Dusklight without having to build the whole game. (See DUSK_GAME_IMPLIB) + install(FILES "${_implib}" DESTINATION sdk RENAME dusklight.lib) +endfunction() diff --git a/docs/modding.md b/docs/modding.md index 77becddf0b..2330181179 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -3,8 +3,9 @@ Mods are distributed as `.dusk` files: zip archives containing a `mod.json` manifest and, optionally, compiled code libraries and resources. -Everything a mod does goes through **services**: small, versioned C APIs. Dusklight provides built-in services, and mods -can define their own to talk to each other. +Most things a mod does goes through **services**: small, versioned C APIs. Dusklight provides built-in services, and +mods can define their own to talk to each other. Mods also link against the game itself: include game headers and call +game functions directly. ## Table of Contents @@ -13,10 +14,11 @@ can define their own to talk to each other. 3. [Anatomy of a Code Mod](#anatomy-of-a-code-mod) 4. [Services](#services) 5. [Built-in Services](#built-in-services) -6. [Asset Overlays](#asset-overlays) -7. [Runtime Lifecycle](#runtime-lifecycle) -8. [Error Handling](#error-handling) -9. [Advanced: Exporting Services](#advanced-exporting-services) +6. [Hooking Game Functions](#hooking-game-functions) +7. [Asset Overlays](#asset-overlays) +8. [Runtime Lifecycle](#runtime-lifecycle) +9. [Error Handling](#error-handling) +10. [Advanced: Exporting Services](#advanced-exporting-services) --- @@ -211,6 +213,11 @@ svc_host->watch_mod_lifecycle(mod_ctx, on_mod_lifecycle, nullptr, &watch); `MOD_LIFECYCLE_DETACHED` fires on the game thread at a lifecycle safe point, after the subject's `mod_shutdown` ran and every service dropped its state. For your own mod's teardown, use `mod_shutdown` instead. +### HookService (`mods/svc/hook.h`) + +Install hooks on game functions. You'll rarely call it directly; use the typed helpers in `mods/hook.hpp` described +below. + ### OverlayService (`mods/svc/overlay.h`) Registers DVD file overlays at runtime. The dynamic counterpart to the static `overlay/` directory ( @@ -302,6 +309,153 @@ Writes that store the same value are silent. Values applied from `config.json` o --- +## Hooking Game Functions + +`mods/hook.hpp` provides typed helpers over the hook service: + +```cpp +#include "mods/hook.hpp" +#include "mods/svc/hook.h" + +IMPORT_SERVICE(HookService, svc_hook); +``` + +### Pre-hooks + +Run before the original. Return `HOOK_SKIP_ORIGINAL` to cancel it (post-hooks still run). + +```cpp +HookAction on_pos_move_pre(ModContext*, void* args, void* retval, void* userdata) { + daAlink_c* link = dusk::mods::arg(args, 0); // arg 0 is `this` + if (link->shape_angle.y > 10000) { + return HOOK_SKIP_ORIGINAL; + } + return HOOK_CONTINUE; +} + +dusk::mods::hook_add_pre<&daAlink_c::posMove>(svc_hook, on_pos_move_pre); +``` + +### Post-hooks + +Run after the original (or after a replace-hook, or after a cancelled original). `retval` points to the return value, if +any. + +```cpp +void on_pos_move_post(ModContext*, void* args, void* retval, void* userdata) { ... } + +dusk::mods::hook_add_post<&daAlink_c::posMove>(svc_hook, on_pos_move_post); +``` + +### Replace-hooks + +Substitute the original entirely. Call through to it via `Hook<...>::g_orig` if needed: + +```cpp +using ExecuteEntry = dusk::mods::Hook<&daAlink_c::execute>; + +void on_execute_replace(ModContext*, void* args, void* retval, void*) { + int result = ExecuteEntry::g_orig(dusk::mods::arg(args, 0)); + if (retval != nullptr) { + *static_cast(retval) = result; + } +} + +dusk::mods::hook_replace<&daAlink_c::execute>(svc_hook, on_execute_replace); +``` + +By default a second replace-hook on the same function is a conflict; `HookOptions` (`replace_policy`, `priority`, +`userdata`) controls this and callback ordering. Multiple mods can attach pre/post hooks to the same function +independently. + +### Hooking by name + +Functions you can't name in C++ (file-local statics, private class members, anything not in a header) can be hooked by +symbol name instead. You must supply the signature along with the name. + +```cpp +// static callback used by Link's hookshot collider in d_a_alink_hook.inc +using HookshotHit = dusk::mods::NamedHook< + "daAlink_hookshotAtHitCallBack", + void(fopAc_ac_c*, dCcD_GObjInf*, fopAc_ac_c*, dCcD_GObjInf*)>; + +dusk::mods::hook_add_pre(svc_hook, on_hookshot_hit_pre); +... +HookshotHit::g_orig(link, atObjInf, target, tgObjInf); // call through to the original +``` + +Class member functions must include `Class*` as the first argument. + +The install fails with the resolve error when the name is missing (`MOD_UNAVAILABLE`), ambiguous (`MOD_CONFLICT`), +or the manifest is absent (`MOD_UNSUPPORTED`). Unlike `Hook<&Class::method>`, the signature is **not** +compiler-checked: a mismatched signature will corrupt the call. + +### Reading and writing arguments + +`args` is an array of pointers to the arguments. For member functions, index 0 is `this`; parameters follow in +declaration order. + +```cpp +T value = dusk::mods::arg(args, n); // copy +T& ref = dusk::mods::arg_ref(args, n); // read/write reference +``` + +```cpp +// fpc_ProcID fopAcM_createItem(..., int itemNo, ...): turn heart drops into green rupees +HookAction on_create_item_pre(ModContext*, void* args, void*, void*) { + int& itemNo = dusk::mods::arg_ref(args, 1); + if (itemNo == dItemNo_HEART_e) { + itemNo = dItemNo_GREEN_RUPEE_e; + } + return HOOK_CONTINUE; +} + +dusk::mods::hook_add_pre<&fopAcM_createItem>(svc_hook, on_create_item_pre); +``` + +For reference parameters (e.g. `const cXyz& pos`), `arg_ref` yields a direct reference. + +### Resolving symbols by name + +`resolve` looks a symbol up in the **symbol manifest**: a name→address map generated alongside every game build and +keyed to that exact binary. It covers the whole image, including functions that aren't exported (file-local statics), +which makes them hookable: + +```cpp +IMPORT_SERVICE(HookService, svc_hook); + +void* addr = nullptr; +uint32_t flags = 0; +if (svc_hook->resolve(mod_ctx, "GXSetProjection", &addr, &flags) == MOD_OK) { + // addr is the function's real address in the running game; hook or call it. +} +``` + +Two spellings work on every platform: + +- **Display names** (`daAlink_c::posMove`, `fapGm_Before`): the qualified name with no parameter list. They carry no + signature, so overload sets (and file-local statics sharing a name) return `MOD_CONFLICT`. +- **Decorated names** (`_ZN9daAlink_c7posMoveEv` / `?posMove@daAlink_c@@...`): the platform's mangled spelling in + dlsym convention (no Mach-O leading underscore). The escape hatch for overloads. + +`MOD_UNSUPPORTED` means the manifest is missing or was built for a different game binary. + +### Game code ABI contract + +If your mod calls or hooks game code directly (anything beyond the service APIs), import `GameService` ( +`mods/svc/game.h`): + +```cpp +IMPORT_SERVICE(GameService, svc_game); +``` + +Its major version is the game code ABI epoch: it's bumped when game struct or vtable layouts change incompatibly, and +the ordinary service version check then rejects your mod with a clear error instead of letting it corrupt memory in a +version it wasn't built for. Service-only and asset-only mods should *not* import it; they stay compatible across game +ABI changes. + +--- + ## Asset Overlays Files placed under `overlay/` in the `.dusk` archive override game files at the corresponding path. For example, @@ -331,7 +485,7 @@ and [TextureService](#textureservice-modssvctextureh). Mods can be disabled, re-enabled, and reloaded at runtime without restarting the game (the enabled state persists as the `mod..enabled` config var). Write your mod assuming this happens: -- **Disable** calls `mod_shutdown`, removes your services, overlays, and texture replacements (both static and +- **Disable** calls `mod_shutdown`, removes your hooks, services, overlays, and texture replacements (both static and runtime-registered), and unloads your library. - **Enable** and **Reload** load a *fresh copy* of your library, imports are re-resolved, and `mod_initialize` runs again. You never see a second `mod_initialize` on the same image, so just make `mod_shutdown` release anything the @@ -344,6 +498,10 @@ first (in reverse dependency order) and brings them back afterward. A mod whose suspended and resumes automatically when the provider returns. Mods with an *optional* import of a disabled provider restart with that import null. +**One caution for hooks:** lifecycle changes are applied between frames, which is safe for hooks on functions +that return every frame (effectively everything you'd normally hook). Avoid hooking a function that stays on +the stack for the whole session (e.g. the outermost main loop); a mod that does cannot be safely unloaded. + --- ## Error Handling diff --git a/files.cmake b/files.cmake index 03d7a3af66..524c53943f 100644 --- a/files.cmake +++ b/files.cmake @@ -1550,8 +1550,12 @@ set(DUSK_FILES src/dusk/mods/loader/loader.hpp src/dusk/mods/loader/native_module.cpp src/dusk/mods/loader/native_module.hpp + src/dusk/mods/loader/manifest.cpp + src/dusk/mods/loader/manifest.hpp src/dusk/mods/svc/config.cpp src/dusk/mods/svc/config.hpp + src/dusk/mods/svc/game.cpp + src/dusk/mods/svc/hook.cpp src/dusk/mods/svc/host.cpp src/dusk/mods/svc/log.cpp src/dusk/mods/svc/overlay.cpp diff --git a/include/d/d_com_inf_game.h b/include/d/d_com_inf_game.h index d240507f97..b66c1ee8e8 100644 --- a/include/d/d_com_inf_game.h +++ b/include/d/d_com_inf_game.h @@ -17,7 +17,7 @@ #include "m_Do/m_Do_graphic.h" #include -#include "tracy/Tracy.hpp" +#include "dusk/profiling.hpp" enum dComIfG_ButtonStatus { /* 0x00 */ BUTTON_STATUS_NONE, diff --git a/include/dusk/gx_helper.h b/include/dusk/gx_helper.h index bf5f3c4d3a..02a36fc6db 100644 --- a/include/dusk/gx_helper.h +++ b/include/dusk/gx_helper.h @@ -5,7 +5,8 @@ #include #include -#include "tracy/Tracy.hpp" + +#include "profiling.hpp" #if DUSK_GFX_DEBUG_GROUPS #define GX_DEBUG_GROUP(name, ...) \ diff --git a/include/dusk/profiling.hpp b/include/dusk/profiling.hpp new file mode 100644 index 0000000000..82dab45216 --- /dev/null +++ b/include/dusk/profiling.hpp @@ -0,0 +1,12 @@ +#pragma once + +#if defined(__has_include) +#if __has_include() +#include +#endif +#endif + +#ifndef ZoneScoped +#define ZoneScoped +#define ZoneScopedN(name) +#endif diff --git a/include/global.h b/include/global.h index 2a8e182bfa..5add912748 100644 --- a/include/global.h +++ b/include/global.h @@ -114,6 +114,19 @@ inline int __builtin_clz(unsigned int v) { #endif +// Data symbols exported from the main exe need dllimport on the mod side. +// DUSK_BUILDING_GAME is defined for the game build so the same headers work in both. +#if defined(TARGET_PC) && defined(_WIN32) && !defined(DUSK_BUILDING_GAME) +#define DUSK_GAME_EXTERN extern __declspec(dllimport) +#define DUSK_GAME_DATA __declspec(dllimport) +#elif defined(TARGET_PC) && defined(_WIN32) && defined(DUSK_BUILDING_GAME) +#define DUSK_GAME_EXTERN extern __declspec(dllexport) +#define DUSK_GAME_DATA __declspec(dllexport) +#else +#define DUSK_GAME_EXTERN extern +#define DUSK_GAME_DATA +#endif + #define FAST_DIV(x, n) (x >> (n / 2)) #define SQUARE(x) ((x) * (x)) diff --git a/include/mods/hook.hpp b/include/mods/hook.hpp new file mode 100644 index 0000000000..c58bd719c9 --- /dev/null +++ b/include/mods/hook.hpp @@ -0,0 +1,464 @@ +#pragma once + +#include "mods/svc/hook.h" + +#include +#include +#include +#include +#include +#include + +namespace dusk::mods { + +template +T arg(void* argsRaw, int n) noexcept { + void** args = static_cast(argsRaw); + return *static_cast > >(args[n]); +} + +template +std::remove_reference_t& arg_ref(void* argsRaw, int n) noexcept { + void** args = static_cast(argsRaw); + return *static_cast > >(args[n]); +} + +template +void* mfp_addr(F fn) noexcept { + void* p = nullptr; + static_assert(sizeof(fn) >= sizeof(void*), "unexpected function pointer size"); + std::memcpy(&p, &fn, sizeof(void*)); + return p; +} + +/* A string usable as a template argument: carries the hook target's symbol name and + * makes each NamedHook instantiation's static state unique. */ +template +struct FixedString { + char chars[N]{}; + constexpr FixedString(const char (&s)[N]) noexcept { + for (size_t i = 0; i < N; ++i) { + chars[i] = s[i]; + } + } +}; + +namespace detail { + +template +constexpr std::string_view class_name() { +#if defined(__clang__) || defined(__GNUC__) + // "... class_name() [T = daAlink_c]" / "... [with T = daAlink_c; ...]" + constexpr std::string_view fn = __PRETTY_FUNCTION__; + constexpr size_t start = fn.find("T = ") + 4; + return fn.substr(start, fn.find_first_of(";]", start) - start); +#elif defined(_MSC_VER) + // "... class_name(void)" + constexpr std::string_view fn = __FUNCSIG__; + constexpr size_t start = fn.find("class_name<") + 11; + constexpr std::string_view name = fn.substr(start, fn.rfind(">(") - start); + if constexpr (name.starts_with("class ")) { + return name.substr(6); + } else if constexpr (name.starts_with("struct ")) { + return name.substr(7); + } else { + return name; + } +#else +#error "unsupported compiler" +#endif +} + +/* The manifest name of C's vtable. Only unscoped, non-template class names are + * supported (an empty result fails resolution and the install reports it). */ +template +constexpr auto vtable_symbol() { + constexpr std::string_view name = class_name(); + constexpr bool simple = name.find_first_of(":<> ") == std::string_view::npos; + // "_ZTV" + decimal length + name / "??_7" + name + "@@6B@", NUL-terminated + std::array out{}; + if constexpr (!simple) { + return out; + } + size_t n = 0; +#if defined(_WIN32) + for (char c : {'?', '?', '_', '7'}) { + out[n++] = c; + } + for (char c : name) { + out[n++] = c; + } + for (char c : {'@', '@', '6', 'B', '@'}) { + out[n++] = c; + } +#else + for (char c : {'_', 'Z', 'T', 'V'}) { + out[n++] = c; + } + size_t len = name.size(); + char digits[8]{}; + size_t d = 0; + while (len != 0) { + digits[d++] = static_cast('0' + len % 10); + len /= 10; + } + while (d != 0) { + out[n++] = digits[--d]; + } + for (char c : name) { + out[n++] = c; + } +#endif + return out; +} + +#if defined(_WIN32) +/* Follow jump stubs, then match the MSVC vcall thunk a virtual mfp points at. + * Returns the vtable slot's byte offset, or npos when fn is not a vcall thunk. */ +inline size_t vcall_slot_offset(const void*& fn) noexcept { + constexpr size_t npos = static_cast(-1); +#if defined(_M_X64) || defined(__x86_64__) + const auto* p = static_cast(fn); + for (int i = 0; i < 8 && p[0] == 0xE9; ++i) { // incremental-link stubs + int32_t rel; + std::memcpy(&rel, p + 1, 4); + p += 5 + rel; + } + fn = p; + // The vptr load. Unoptimized clang-cl thunks spill/reload rcx first + // (push rax; mov [rsp], rcx; mov rcx, [rsp]), so scan a short window. + const uint8_t* q = nullptr; + for (int i = 0; i <= 12; ++i) { + if (p[i] == 0x48 && p[i + 1] == 0x8B && p[i + 2] == 0x01) { // mov rax, [rcx] + q = p + i + 3; + break; + } + } + if (q == nullptr) { + return npos; + } + if (q[0] == 0xFF && q[1] == 0x20) { // jmp [rax] (MSVC) + return 0; + } + if (q[0] == 0xFF && q[1] == 0x60) { // jmp [rax + imm8] + return static_cast(q[2]); + } + if (q[0] == 0xFF && q[1] == 0xA0) { // jmp [rax + imm32] + int32_t off; + std::memcpy(&off, q + 2, 4); + return off; + } + // clang-cl: mov rax, [rax + off]; (pop r10;) jmp rax. Requiring the jmp rax + // distinguishes the thunk from an ordinary getter that begins the same way. + if (q[0] == 0x48 && q[1] == 0x8B && (q[2] == 0x00 || q[2] == 0x40 || q[2] == 0x80)) { + size_t off = 0; + const uint8_t* r = q + 3; + if (q[2] == 0x40) { + off = static_cast(q[3]); + r = q + 4; + } else if (q[2] == 0x80) { + int32_t off32; + std::memcpy(&off32, q + 3, 4); + off = off32; + r = q + 7; + } + for (int i = 0; i <= 8; ++i) { + if (r[i] == 0xFF && r[i + 1] == 0xE0) { // jmp rax (48 REX optional) + return off; + } + } + } + return npos; +#elif defined(_M_ARM64) || defined(__aarch64__) + const auto* p = static_cast(fn); + uint32_t insn[3]; + for (int i = 0; i < 8; ++i) { // incremental-link `b` stubs + std::memcpy(insn, p, 4); + if ((insn[0] & 0xFC000000u) != 0x14000000u) { + break; + } + const auto imm26 = static_cast(insn[0] << 6) >> 6; + p += static_cast(imm26) * 4; + } + fn = p; + std::memcpy(insn, p, 12); + // ldr Xt, [x0]; ldr Xs, [Xt, #imm12*8]; br Xs + if ((insn[0] & 0xFFFFFFE0u) != 0xF9400000u) { + return npos; + } + const uint32_t t = insn[0] & 0x1Fu; + if ((insn[1] & 0xFFC003E0u) != (0xF9400000u | (t << 5))) { + return npos; + } + const uint32_t s = insn[1] & 0x1Fu; + if (insn[2] != (0xD61F0000u | (s << 5))) { + return npos; + } + return ((insn[1] >> 10) & 0xFFFu) * 8; +#else + (void)fn; + return npos; +#endif +} +#endif + +/* Code address of the member function a mfp designates. Virtual mfps don't carry + * one; recover it from the class's vtable (resolved from the symbol manifest), so + * Hook works uniformly on virtual and non-virtual members. */ +template +ModResult member_target(const HookService* hooks, F mfp, void** out) { + *out = nullptr; + uintptr_t words[sizeof(F) > sizeof(uintptr_t) ? 2 : 1] = {}; + std::memcpy(words, &mfp, sizeof(words) < sizeof(F) ? sizeof(words) : sizeof(F)); + +#if defined(_WIN32) + const void* fn = reinterpret_cast(words[0]); + const size_t slot = vcall_slot_offset(fn); + if (slot == static_cast(-1)) { // not a vcall thunk: direct address + *out = const_cast(fn); + return MOD_OK; + } + void* vtable = nullptr; + const ModResult resolved = hooks->resolve(mod_ctx, vtable_symbol().data(), &vtable, nullptr); + if (resolved != MOD_OK) { + return resolved; + } + // ??_7 points at the first slot. + *out = *reinterpret_cast(static_cast(vtable) + slot); +#else +#if defined(__aarch64__) || defined(__arm__) + // AAPCS C++ ABI: the virtual flag is bit 0 of the adjustment word (function + // addresses can't spare their low bit), and ptr holds the slot offset directly. + const bool isVirtual = (words[1] & 1) != 0; + const uintptr_t thisAdjust = words[1] >> 1; + const uintptr_t slotOffset = words[0]; +#else + // Itanium C++ ABI: virtual mfps set bit 0 of ptr; the slot offset is ptr - 1. + const bool isVirtual = (words[0] & 1) != 0; + const uintptr_t thisAdjust = words[1]; + const uintptr_t slotOffset = words[0] - 1; +#endif + if (!isVirtual) { // non-virtual: the address itself + *out = reinterpret_cast(words[0]); + return MOD_OK; + } + if (thisAdjust != 0) { + // this-adjusting mfp (member of a secondary base): the slot offset is + // relative to a vtable we can't locate. Hook the overrider by name instead. + return MOD_UNSUPPORTED; + } + void* vtable = nullptr; + const ModResult resolved = hooks->resolve(mod_ctx, vtable_symbol().data(), &vtable, nullptr); + if (resolved != MOD_OK) { + return resolved; + } + // _ZTV points at the offset-to-top slot; the address point mfps index from is + // two pointers in (past offset-to-top and the typeinfo pointer). + *out = *reinterpret_cast(static_cast(vtable) + 2 * sizeof(void*) + slotOffset); +#endif + return *out != nullptr ? MOD_OK : MOD_UNAVAILABLE; +} + +} // namespace detail + +/* Trampoline generator + per-target state shared by Hook and NamedHook. Tag makes + * each hooked target's statics distinct; the target address is filled in at install. */ +template +struct HookImpl { + static inline R (*g_orig)(A...) = nullptr; + static inline const HookService* hooks = nullptr; + static inline void* target = nullptr; + + static bool dispatch_pre(void* args, void* retval) { + if (hooks == nullptr) { + return false; + } + + int skipOriginal = 0; + const ModResult result = hooks->dispatch_pre(mod_ctx, target, args, retval, &skipOriginal); + return result == MOD_OK && skipOriginal != 0; + } + + static void dispatch_post(void* args, void* retval) { + if (hooks != nullptr) { + hooks->dispatch_post(mod_ctx, target, args, retval); + } + } + + static R trampoline(A... args) { + if constexpr (sizeof...(A) == 0) { + if constexpr (std::is_void_v) { + const bool skipOriginal = dispatch_pre(nullptr, nullptr); + if (!skipOriginal) { + g_orig(args...); + } + dispatch_post(nullptr, nullptr); + } else { + R result{}; + const bool skipOriginal = + dispatch_pre(nullptr, static_cast(std::addressof(result))); + if (!skipOriginal) { + result = g_orig(args...); + } + dispatch_post(nullptr, static_cast(std::addressof(result))); + return result; + } + } else { + void* ptrs[] = {static_cast(std::addressof(args))...}; + if constexpr (std::is_void_v) { + const bool skipOriginal = dispatch_pre(static_cast(ptrs), nullptr); + if (!skipOriginal) { + g_orig(args...); + } + dispatch_post(static_cast(ptrs), nullptr); + } else { + R result{}; + const bool skipOriginal = dispatch_pre( + static_cast(ptrs), static_cast(std::addressof(result))); + if (!skipOriginal) { + result = g_orig(args...); + } + dispatch_post(static_cast(ptrs), static_cast(std::addressof(result))); + return result; + } + } + } +}; + +namespace detail { +template +using TargetTag = std::integral_constant; +template +struct NameTag {}; +} // namespace detail + +/* + * Typed hook on a function named at compile time (&daAlink_c::execute, &free_fn). + * Member functions may be virtual: the install decodes the member function pointer and hooks the + * class's own overrider. + */ +template +struct Hook; + +template +struct Hook : HookImpl, R, C*, A...> { + static ModResult resolve_target(const HookService* hooks, void** out) { + return detail::member_target(hooks, Target, out); + } +}; + +template +struct Hook : HookImpl, R, const C*, A...> { + static ModResult resolve_target(const HookService* hooks, void** out) { + return detail::member_target(hooks, Target, out); + } +}; + +template +struct Hook : HookImpl, R, A...> { + static ModResult resolve_target(const HookService*, void** out) { + *out = mfp_addr(Target); + return MOD_OK; + } +}; + +/* + * Typed hook on a function by its symbol name, for targets you can't name in C++: file-local + * statics, private members, or symbols without a header. The signature is written free-style with + * the receiver first and is *not* compiler-checked. + * + * using HookshotHit = dusk::mods::NamedHook< + * "daAlink_hookshotAtHitCallBack", + * void(fopAc_ac_c*, dCcD_GObjInf*, fopAc_ac_c*, dCcD_GObjInf*)>; + * dusk::mods::hook_add_pre(svc_hook, on_hookshot_hit); + */ +template +struct NamedHook; + +template +struct NamedHook : HookImpl, R, A...> { + static ModResult resolve_target(const HookService* hooks, void** out) { + HookSymbolFlags flags{}; + const ModResult resolved = hooks->resolve(mod_ctx, Name.chars, out, &flags); + if (resolved == MOD_OK && (flags & HOOK_SYMBOL_CODE) == 0) { + *out = nullptr; + return MOD_INVALID_ARGUMENT; + } + return resolved; + } +}; + +template +ModResult hook_install(const HookService* hooks) { + if (hooks == nullptr) { + return MOD_UNAVAILABLE; + } + + Entry::hooks = hooks; + if (Entry::target == nullptr) { + const ModResult resolved = Entry::resolve_target(hooks, &Entry::target); + if (resolved != MOD_OK) { + return resolved; + } + } + return hooks->install(mod_ctx, Entry::target, reinterpret_cast(Entry::trampoline), + reinterpret_cast(&Entry::g_orig)); +} + +template +ModResult hook_install(const HookService* hooks) { + return hook_install >(hooks); +} + +template +ModResult hook_add_pre( + const HookService* hooks, HookPreFn callback, const HookOptions* options = nullptr) { + const ModResult installed = hook_install(hooks); + if (installed != MOD_OK) { + return installed; + } + + return hooks->add_pre(mod_ctx, Entry::target, callback, options); +} + +template +ModResult hook_add_pre( + const HookService* hooks, HookPreFn callback, const HookOptions* options = nullptr) { + return hook_add_pre >(hooks, callback, options); +} + +template +ModResult hook_add_post( + const HookService* hooks, HookPostFn callback, const HookOptions* options = nullptr) { + const ModResult installed = hook_install(hooks); + if (installed != MOD_OK) { + return installed; + } + + return hooks->add_post(mod_ctx, Entry::target, callback, options); +} + +template +ModResult hook_add_post( + const HookService* hooks, HookPostFn callback, const HookOptions* options = nullptr) { + return hook_add_post >(hooks, callback, options); +} + +template +ModResult hook_replace( + const HookService* hooks, HookReplaceFn callback, const HookOptions* options = nullptr) { + const ModResult installed = hook_install(hooks); + if (installed != MOD_OK) { + return installed; + } + + return hooks->replace(mod_ctx, Entry::target, callback, options); +} + +template +ModResult hook_replace( + const HookService* hooks, HookReplaceFn callback, const HookOptions* options = nullptr) { + return hook_replace >(hooks, callback, options); +} + +} // namespace dusk::mods diff --git a/include/mods/svc/game.h b/include/mods/svc/game.h new file mode 100644 index 0000000000..fbd914d4bc --- /dev/null +++ b/include/mods/svc/game.h @@ -0,0 +1,30 @@ +#pragma once + +#include "mods/api.h" + +/* + * Mods that link or hook game code directly must import this service; service-only and asset-only + * mods must not. + * + * Major version is the game-code ABI epoch: it is bumped when game-visible struct or vtable layouts + * change incompatibly (e.g. a TARGET_PC field added to an existing game struct). The loader's + * ordinary version check then fails mods built against the old epoch with a clear message instead + * of letting them corrupt memory. + */ +#define GAME_SERVICE_ID "dev.twilitrealm.dusklight.game" +#define GAME_SERVICE_MAJOR 1u +#define GAME_SERVICE_MINOR 0u + +typedef struct GameService { + ServiceHeader header; +} GameService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = GAME_SERVICE_ID; + static constexpr uint16_t major_version = GAME_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/hook.h b/include/mods/svc/hook.h new file mode 100644 index 0000000000..5eb710d89d --- /dev/null +++ b/include/mods/svc/hook.h @@ -0,0 +1,131 @@ +#pragma once + +#include "mods/api.h" + +/* + * Intercept game functions by address. Prefer the typed helpers in mods/hook.hpp + * (hook_add_pre/hook_add_post/hook_replace over a &Class::method): they generate the + * trampoline and hide install/dispatch, which are the low-level primitives those helpers + * build. resolve() maps a symbol name to an address for targets you can't name at compile time + * (file-local statics included). + * + * Every call is game-thread-only. Install and removal must run with no hooked function on the + * stack; the loader guarantees this by applying mod lifecycle changes between frames, which is + * why hooking a function that never returns (the outermost loop) makes a mod un-unloadable. + */ + +#define HOOK_SERVICE_ID "dev.twilitrealm.dusklight.hook" +#define HOOK_SERVICE_MAJOR 1u +#define HOOK_SERVICE_MINOR 0u + +/* Symbol flags reported by resolve() */ +typedef enum HookSymbolFlags { + HOOK_SYMBOL_CODE = 1u << 0u, + HOOK_SYMBOL_DATA = 1u << 1u, + /* Not exported/dynamically visible: hookable, but never linkable. */ + HOOK_SYMBOL_LOCAL = 1u << 2u, + /* Other names share this address (ICF fold/alias): a hook intercepts them all. */ + HOOK_SYMBOL_MULTI_NAME = 1u << 3u, + /* Resolved through a demangled display-name alias rather than the real symbol. */ + HOOK_SYMBOL_DISPLAY = 1u << 6u, +} HookSymbolFlags; + +/* A pre-hook's return value: whether to run the original function. */ +typedef enum HookAction { + HOOK_CONTINUE = 0, /* run the original (and any lower-priority pre-hooks) */ + HOOK_SKIP_ORIGINAL = 1, /* cancel the original and remaining pre-hooks; post-hooks still run */ +} HookAction; + +/* How replace resolves a second replace-hook on a target that already has one. */ +typedef enum HookReplacePolicy { + HOOK_REPLACE_CONFLICT = 0, /* refuse with MOD_CONFLICT (the default) */ + HOOK_REPLACE_PRIORITY = 1, /* take over only if this options.priority is strictly higher */ + HOOK_REPLACE_OVERRIDE = 2, /* take over unconditionally */ +} HookReplacePolicy; + +typedef enum HookFlags { + HOOK_FLAG_NONE = 0u, + HOOK_FLAG_FAIL_ON_CONFLICT = 1u << 0u, +} HookFlags; + +/* + * Hook callbacks. `args` is an array of pointers to the call's arguments (index 0 is `this` + * for member functions); `retval` points at the return slot (NULL for void). Read and write + * them through dusk::mods::arg / arg_ref from mods/hook.hpp. `userdata` is the pointer + * from HookOptions. All run on the game thread, in the hooked call's own stack frame. + */ +typedef HookAction (*HookPreFn)(ModContext* ctx, void* args, void* retval, void* userdata); +typedef void (*HookPostFn)(ModContext* ctx, void* args, void* retval, void* userdata); +typedef void (*HookReplaceFn)(ModContext* ctx, void* args, void* retval, void* userdata); + +typedef struct HookOptions { + uint32_t struct_size; + /* Higher runs first; ties break by registration order. Applies to pre/post ordering and, + * with HOOK_REPLACE_PRIORITY, to replace-hook takeover. */ + int32_t priority; + uint32_t flags; /* HookFlags */ + HookReplacePolicy replace_policy; + void* userdata; /* passed back to the callback */ +} HookOptions; + +#define HOOK_OPTIONS_INIT {sizeof(HookOptions), 0, HOOK_FLAG_NONE, HOOK_REPLACE_CONFLICT, NULL} + +typedef struct HookService { + ServiceHeader header; + + /* + * Install a trampoline detour on fn_addr and return the address to call the original through in + * *out_original_fn. The typed helpers generate the trampoline and call this; mods normally + * don't. The first mod to install a given target owns the live detour; later mods register as + * candidates so a hook survives the owner unloading (the detour is handed off and every + * original pointer is rewritten). Idempotent per (mod, out slot). + */ + ModResult (*install)( + ModContext* ctx, void* fn_addr, void* trampoline_fn, void** out_original_fn); + + /* + * Register a callback on an already-installed target. Pre runs before the original (and can + * cancel it), post runs after (even if cancelled). Any number of mods may add pre/post to the + * same target; they run in priority then registration order. replace installs a single + * substitute for the original, managed by options.replace_policy, MOD_CONFLICT if refused. + */ + ModResult (*add_pre)( + ModContext* ctx, void* fn_addr, HookPreFn callback, const HookOptions* options); + ModResult (*add_post)( + ModContext* ctx, void* fn_addr, HookPostFn callback, const HookOptions* options); + ModResult (*replace)( + ModContext* ctx, void* fn_addr, HookReplaceFn callback, const HookOptions* options); + + /* + * Run the registered callbacks for a target. The generated trampoline calls these; they + * are not a mod-facing entry point. dispatch_pre reports through *out_skip_original + * whether the original should be skipped (a pre-hook returned HOOK_SKIP_ORIGINAL, or a + * replace-hook ran). + */ + ModResult (*dispatch_pre)( + ModContext* ctx, void* fn_addr, void* args, void* retval, int* out_skip_original); + ModResult (*dispatch_post)(ModContext* ctx, void* fn_addr, void* args, void* retval); + + /* + * Resolve a game symbol by name from the symbol manifest, including non-exported (static) + * functions. Names can be either the platform's mangled name (i.e. the name passed to dlopen; + * no Mach-O leading underscore) or the qualified function name without parameters (e.g. + * "daAlink_c::execute"). out_flags (optional) receives HookSymbolFlags. + * + * Results: MOD_OK; MOD_UNSUPPORTED (no manifest for this build, missing or stale); + * MOD_UNAVAILABLE (symbol not found); MOD_CONFLICT (name maps to more than one address: C++ + * overloads or per-TU statics; use the mangled name). + */ + ModResult (*resolve)( + ModContext* ctx, const char* symbol, void** out_addr, HookSymbolFlags* out_flags); +} HookService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = HOOK_SERVICE_ID; + static constexpr uint16_t major_version = HOOK_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/host.h b/include/mods/svc/host.h index 61d0831dae..43c19cadfb 100644 --- a/include/mods/svc/host.h +++ b/include/mods/svc/host.h @@ -8,8 +8,8 @@ */ #define HOST_SERVICE_ID "dev.twilitrealm.dusklight.host" -#define HOST_SERVICE_MAJOR 1u -#define HOST_SERVICE_MINOR 1u +#define HOST_SERVICE_MAJOR 2u +#define HOST_SERVICE_MINOR 0u /* * Ignore unknown values: later service minors may add events. @@ -35,6 +35,14 @@ typedef void (*ModLifecycleFn)(ModContext* ctx, ModContext* subject, const char* typedef struct HostService { ServiceHeader header; + /* Version string of the current Dusklight build. (e.g. "1.4.2") */ + const char* version; + + /* Build id of the running game binary: PDB GUID+age on Windows, LC_UUID on macOS, GNU build-id + * on Linux. May be empty (len 0) if the identity could not be determined. */ + const uint8_t* build_id; + uint32_t build_id_len; + /* * Look up a service by id at call time. Unlike a manifest import, this sees whatever is * currently published and carries no initialization-order guarantee (see mods/api.h). diff --git a/sdk/CMakeLists.txt b/sdk/CMakeLists.txt index 63953e78bf..533bcc22c1 100644 --- a/sdk/CMakeLists.txt +++ b/sdk/CMakeLists.txt @@ -6,6 +6,9 @@ # Usage (from a mod project): # add_subdirectory(/sdk dusk-sdk EXCLUDE_FROM_ALL) # add_mod(my_mod SOURCES ... MOD_JSON mod.json) +# +# On Windows, pass -DDUSK_GAME_IMPLIB= from the +# matching game release. TODO: auto-download from tag cmake_minimum_required(VERSION 3.25) @@ -30,4 +33,11 @@ configure_version_header() # Game ABI headers & compile definitions include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/GameABIConfig.cmake") +if (WIN32) + set(DUSK_GAME_IMPLIB "" CACHE FILEPATH "Path to the dusklight import library (sdk/dusklight.lib)") + if (DUSK_GAME_IMPLIB AND NOT EXISTS "${DUSK_GAME_IMPLIB}") + message(FATAL_ERROR "Mod SDK: DUSK_GAME_IMPLIB does not exist: ${DUSK_GAME_IMPLIB}") + endif () +endif () + include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/ModSDK.cmake") diff --git a/sdk/pseudo_reloc.cpp b/sdk/pseudo_reloc.cpp new file mode 100644 index 0000000000..f86d783f2d --- /dev/null +++ b/sdk/pseudo_reloc.cpp @@ -0,0 +1,139 @@ +// Mod SDK runtime (Windows): applies lld's MinGW-style runtime pseudo-relocations on +// the plain MSVC CRT. This is what lets mods reference game data (`extern` globals) with no +// __declspec(dllimport) annotations: `lld-link -lldmingw` auto-imports the data through IAT +// slots and records fixups, and this module applies them at load time. +// +// The fixup pass runs as an early CRT initializer, and the IAT slots it reads were already +// bound by the OS loader, so even mod static initializers observe patched references. + +#include + +#include +#include +#include + +extern "C" char __RUNTIME_PSEUDO_RELOC_LIST__; +extern "C" char __RUNTIME_PSEUDO_RELOC_LIST_END__; +extern "C" IMAGE_DOS_HEADER __ImageBase; + +namespace { + +struct HdrV2 { + uint32_t magic1; + uint32_t magic2; + uint32_t version; +}; +struct ItemV2 { + uint32_t sym; // RVA of the __imp_ slot (OS-bound IAT entry) + uint32_t target; // RVA of the reference to patch + uint32_t flags; // low 8 bits: bit width of the reference +}; + +bool g_relocsFailed = false; + +void report(const char* fmt, ...) { + char buf[512]; + va_list args; + va_start(args, fmt); + vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + fprintf(stderr, "%s\n", buf); + OutputDebugStringA(buf); +} + +bool compute_fixup(const ItemV2& item, char* base, intptr_t* out) { + char* impSlot = base + item.sym; + const intptr_t real = *reinterpret_cast(impSlot); + char* target = base + item.target; + const int bits = static_cast(item.flags & 0xff); + + intptr_t reldata; + switch (bits) { + case 8: + reldata = *reinterpret_cast(target); + break; + case 16: + reldata = *reinterpret_cast(target); + break; + case 32: + reldata = *reinterpret_cast(target); + break; + case 64: + reldata = *reinterpret_cast(target); + break; + default: + report("unsupported %d-bit pseudo-relocation at RVA 0x%x", bits, item.target); + return false; + } + reldata -= reinterpret_cast(impSlot); + reldata += real; + + if (bits < 64) { + const intptr_t maxUnsigned = (intptr_t{1} << bits) - 1; + const intptr_t minSigned = -(intptr_t{1} << (bits - 1)); + if (reldata > maxUnsigned || reldata < minSigned) { + report("%d-bit data fixup at RVA 0x%x is out of range (delta %+lld)", bits, item.target, + static_cast(reldata)); + return false; + } + } + *out = reldata; + return true; +} + +} // namespace + +// lld refuses to emit runtime pseudo-relocs unless a function with exactly this (mingw CRT) +// name exists in the image. It is also our actual entry point. +extern "C" void _pei386_runtime_relocator() { + char* base = reinterpret_cast(&__ImageBase); + char* start = &__RUNTIME_PSEUDO_RELOC_LIST__; + char* end = &__RUNTIME_PSEUDO_RELOC_LIST_END__; + if (end - start < static_cast(sizeof(HdrV2))) { + return; // no data auto-imports in this mod + } + const HdrV2* hdr = reinterpret_cast(start); + if (hdr->magic1 != 0 || hdr->magic2 != 0 || hdr->version != 1) { + report("unexpected pseudo-relocation list format"); + g_relocsFailed = true; + return; + } + const ItemV2* items = reinterpret_cast(hdr + 1); + const ItemV2* itemsEnd = reinterpret_cast(end); + + // Validate everything before writing anything, so a bad fixup can't leave the image + // half-patched. + intptr_t scratch; + for (const ItemV2* it = items; it < itemsEnd; ++it) { + if (!compute_fixup(*it, base, &scratch)) { + g_relocsFailed = true; + return; + } + } + for (const ItemV2* it = items; it < itemsEnd; ++it) { + intptr_t reldata; + compute_fixup(*it, base, &reldata); + char* target = base + it->target; + const size_t len = static_cast(it->flags & 0xff) / 8; + DWORD old = 0; + if (!VirtualProtect(target, len, PAGE_EXECUTE_READWRITE, &old)) { + report("VirtualProtect failed at RVA 0x%x", it->target); + g_relocsFailed = true; + return; + } + std::memcpy(target, &reldata, len); + VirtualProtect(target, len, old, &old); + } +} + +using PVFV = void (*)(); +#pragma section(".CRT$XCB", read) +extern "C" __declspec(allocate(".CRT$XCB")) PVFV dusk_mod_pseudo_reloc_init = + _pei386_runtime_relocator; + +BOOL WINAPI DllMain(HINSTANCE, DWORD reason, LPVOID) { + if (reason == DLL_PROCESS_ATTACH && g_relocsFailed) { + return FALSE; + } + return TRUE; +} diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp index 00c2cc69e8..fb293a55b4 100644 --- a/src/dusk/mods/loader/loader.cpp +++ b/src/dusk/mods/loader/loader.cpp @@ -11,9 +11,10 @@ #include "depgraph.hpp" #include "dusk/config.hpp" -#include "dusk/mods/svc/config.hpp" #include "dusk/io.hpp" +#include "dusk/mods/svc/config.hpp" #include "dusk/mods/svc/registry.hpp" +#include "manifest.hpp" #include "miniz.h" #include "native_module.hpp" #include "nlohmann/json.hpp" @@ -639,6 +640,8 @@ void ModLoader::init() { } m_initialized = true; + manifest::initialize(); + if (m_searchDirs.empty()) { Log.warn("no mod search directories configured; mod loading skipped"); return; diff --git a/src/dusk/mods/loader/manifest.cpp b/src/dusk/mods/loader/manifest.cpp new file mode 100644 index 0000000000..7e83dfb8f6 --- /dev/null +++ b/src/dusk/mods/loader/manifest.cpp @@ -0,0 +1,386 @@ +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include "manifest.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "aurora/lib/logging.hpp" + +#include "dusk/io.hpp" + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include +#elif defined(__APPLE__) +#include +#include +#elif defined(__linux__) +#include +#include +#endif + +namespace dusk::mods::manifest { +namespace { + +aurora::Module Log("dusk::mods::manifest"); + +constexpr char kMagic[8] = {'S', 'Y', 'M', 'G', 'E', 'N', '\0', '\0'}; +constexpr uint32_t kVersion = 2; + +enum class Compression : uint32_t { + None = 0, + Zstd = 1, +}; + +// Mirrors the symgen manifest writer. +struct Header { + char magic[8]; + uint32_t version; + uint32_t compression; + uint64_t uncompressedLen; + uint64_t compressedLen; + uint32_t buildIdLen; + uint8_t buildId[32]; + uint32_t entryCount; +}; +static_assert(sizeof(Header) == 72); + +struct Entry { + uint64_t hash; + uint64_t rva; + uint32_t nameOff; + HookSymbolFlags flags; +}; +static_assert(sizeof(Entry) == 24); + +struct State { + std::vector data; + const Entry* entries = nullptr; + uint32_t entryCount = 0; + const char* strings = nullptr; + uint64_t stringsLen = 0; + uintptr_t imageBase = 0; + // (rva, nameOff) of entries flagged kFlagInlineSites, sorted by rva + std::vector > inlineSites; + bool loaded = false; + bool initialized = false; +}; +State s_state; + +uint64_t fnv1a64(const char* str) { + uint64_t hash = 0xcbf29ce484222325ull; + for (const char* p = str; *p != '\0'; ++p) { + hash ^= static_cast(*p); + hash *= 0x100000001b3ull; + } + return hash; +} + +// Build id of the running executable image, matching what symgen recorded: +// PDB GUID (RFC 4122 byte order) + age on Windows, LC_UUID on Mach-O, GNU +// build-id on ELF. Also reports the address RVAs are relative to. +bool running_image_identity(std::vector& outId, uintptr_t& outBase) { +#if defined(_WIN32) + auto* base = reinterpret_cast(GetModuleHandleW(nullptr)); + outBase = reinterpret_cast(base); + const auto* dos = reinterpret_cast(base); + const auto* nt = reinterpret_cast(base + dos->e_lfanew); + const auto& dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG]; + if (dir.VirtualAddress == 0) { + return false; + } + const auto* entries = reinterpret_cast(base + dir.VirtualAddress); + for (size_t i = 0; i < dir.Size / sizeof(IMAGE_DEBUG_DIRECTORY); ++i) { + if (entries[i].Type != IMAGE_DEBUG_TYPE_CODEVIEW) { + continue; + } + struct CvInfo { + uint32_t signature; // 'RSDS' + uint8_t guid[16]; + uint32_t age; + }; + if (entries[i].SizeOfData < sizeof(CvInfo)) { + continue; + } + const auto* cv = reinterpret_cast(base + entries[i].AddressOfRawData); + if (cv->signature != 0x53445352) { // "RSDS" + continue; + } + // The GUID struct stores Data1..Data3 little-endian in memory; the manifest + // stores RFC 4122 (big-endian) order, so swap them here. + outId.assign(cv->guid, cv->guid + 16); + std::swap(outId[0], outId[3]); + std::swap(outId[1], outId[2]); + std::swap(outId[4], outId[5]); + std::swap(outId[6], outId[7]); + for (int b = 0; b < 4; ++b) { + outId.push_back(static_cast(cv->age >> (8 * b))); + } + return true; + } + return false; +#elif defined(__APPLE__) + // Image 0 is the main executable. The manifest stores link-time vmaddrs + // (nm convention, __TEXT vmaddr included). + const auto* header = _dyld_get_image_header(0); + outBase = static_cast(_dyld_get_image_vmaddr_slide(0)); + const auto* header64 = reinterpret_cast(header); + const auto* cmd = reinterpret_cast(header64 + 1); + for (uint32_t i = 0; i < header64->ncmds; ++i) { + if (cmd->cmd == LC_UUID) { + const auto* uuidCmd = reinterpret_cast(cmd); + outId.assign(uuidCmd->uuid, uuidCmd->uuid + 16); + return true; + } + cmd = reinterpret_cast( + reinterpret_cast(cmd) + cmd->cmdsize); + } + return false; +#elif defined(__linux__) + struct Ctx { + std::vector* id; + uintptr_t base = 0; + bool found = false; + } ctx{&outId}; + dl_iterate_phdr( + [](dl_phdr_info* info, size_t, void* data) -> int { + auto* ctx = static_cast(data); + // The first callback is the main executable. + ctx->base = info->dlpi_addr; + for (int i = 0; i < info->dlpi_phnum; ++i) { + const auto& phdr = info->dlpi_phdr[i]; + if (phdr.p_type != PT_NOTE) { + continue; + } + const auto* p = reinterpret_cast(info->dlpi_addr + phdr.p_vaddr); + const auto* end = p + phdr.p_memsz; + while (p + sizeof(ElfW(Nhdr)) <= end) { + const auto* note = reinterpret_cast(p); + const auto* name = p + sizeof(ElfW(Nhdr)); + const auto* desc = name + ((note->n_namesz + 3) & ~3u); + if (note->n_type == NT_GNU_BUILD_ID && note->n_namesz == 4 && + std::memcmp(name, "GNU", 4) == 0) + { + ctx->id->assign(desc, desc + note->n_descsz); + ctx->found = true; + return 1; + } + p = desc + ((note->n_descsz + 3) & ~3u); + } + } + return 1; // only inspect the main executable + }, + &ctx); + outBase = ctx.base; + return ctx.found; +#else + (void)outId; + (void)outBase; + return false; +#endif +} + +std::filesystem::path manifest_path() { + const char* basePath = SDL_GetBasePath(); + std::filesystem::path dir = + basePath != nullptr ? std::filesystem::path{basePath} : std::filesystem::current_path(); + return dir / "dusklight.symdb"; +} + +std::string hex_string(const uint8_t* data, size_t len) { + std::string out; + out.reserve(len * 2); + for (size_t i = 0; i < len; ++i) { + constexpr char kHex[] = "0123456789abcdef"; + out.push_back(kHex[data[i] >> 4]); + out.push_back(kHex[data[i] & 0xF]); + } + return out; +} + +} // namespace + +void initialize() { + if (s_state.initialized) { + return; + } + s_state.initialized = true; + + const auto path = manifest_path(); + std::error_code ec; + if (!std::filesystem::exists(path, ec)) { + Log.info("no symbol manifest at {}; by-name resolution unavailable", + io::fs_path_to_string(path)); + return; + } + std::vector data; + try { + data = io::FileStream::ReadAllBytes(path); + } catch (const std::exception& e) { + Log.error("failed to read symbol manifest {}: {}", io::fs_path_to_string(path), e.what()); + return; + } + if (data.size() < sizeof(Header)) { + Log.error( + "symbol manifest {} is truncated ({} bytes)", io::fs_path_to_string(path), data.size()); + return; + } + + Header header{}; + std::memcpy(&header, data.data(), sizeof(header)); + if (std::memcmp(header.magic, kMagic, sizeof(kMagic)) != 0 || header.version != kVersion) { + Log.error("symbol manifest {} has wrong magic/version", io::fs_path_to_string(path)); + return; + } + const auto compression = static_cast(header.compression); + if ((compression != Compression::None && compression != Compression::Zstd) || + header.buildIdLen > sizeof(header.buildId) || + header.compressedLen > data.size() - sizeof(Header) || + header.uncompressedLen > std::numeric_limits::max() || + (compression == Compression::None && header.compressedLen != header.uncompressedLen)) + { + Log.error("symbol manifest {} is malformed", io::fs_path_to_string(path)); + return; + } + + std::vector imageId; + uintptr_t imageBase = 0; + if (!running_image_identity(imageId, imageBase)) { + Log.error("cannot determine the running image's build id; ignoring symbol manifest"); + return; + } + if (imageId.size() != header.buildIdLen || + std::memcmp(imageId.data(), header.buildId, imageId.size()) != 0) + { + Log.error("symbol manifest {} is stale: built for {}, running image is {}", + io::fs_path_to_string(path), hex_string(header.buildId, header.buildIdLen), + hex_string(imageId.data(), imageId.size())); + return; + } + + const auto compressedLen = static_cast(header.compressedLen); + const auto uncompressedLen = static_cast(header.uncompressedLen); + std::vector payload; + const auto* storedPayload = data.data() + sizeof(Header); + if (compression == Compression::None) { + payload.assign(storedPayload, storedPayload + compressedLen); + } else { + payload.resize(uncompressedLen); + const size_t decompressedLen = + ZSTD_decompress(payload.data(), payload.size(), storedPayload, compressedLen); + if (ZSTD_isError(decompressedLen)) { + Log.error("failed to decompress symbol manifest {}: {}", io::fs_path_to_string(path), + ZSTD_getErrorName(decompressedLen)); + return; + } + if (decompressedLen != payload.size()) { + Log.error("symbol manifest {} decompressed to {} bytes, expected {}", + io::fs_path_to_string(path), decompressedLen, payload.size()); + return; + } + } + data = std::move(payload); + + const uint64_t entriesEnd = uint64_t{header.entryCount} * sizeof(Entry); + if (entriesEnd > data.size()) { + Log.error("decompressed symbol manifest {} is malformed", io::fs_path_to_string(path)); + return; + } + + s_state.data = std::move(data); + s_state.entries = reinterpret_cast(s_state.data.data()); + s_state.entryCount = header.entryCount; + s_state.strings = reinterpret_cast(s_state.data.data() + entriesEnd); + s_state.stringsLen = s_state.data.size() - entriesEnd; + s_state.imageBase = imageBase; + for (uint32_t i = 0; i < s_state.entryCount; ++i) { + const Entry& entry = s_state.entries[i]; + if ((entry.flags & kFlagInlineSites) != 0 && entry.nameOff < s_state.stringsLen) { + s_state.inlineSites.emplace_back(entry.rva, entry.nameOff); + } + } + std::sort(s_state.inlineSites.begin(), s_state.inlineSites.end()); + s_state.inlineSites.erase(std::unique(s_state.inlineSites.begin(), s_state.inlineSites.end(), + [](const auto& a, const auto& b) { return a.first == b.first; }), + s_state.inlineSites.end()); + s_state.loaded = true; + Log.info("symbol manifest loaded: {} symbols, build id {}", s_state.entryCount, + hex_string(header.buildId, header.buildIdLen)); +} + +bool available() { + return s_state.loaded; +} + +const std::vector& image_build_id() { + static const std::vector s_id = [] { + std::vector id; + uintptr_t base = 0; + running_image_identity(id, base); + return id; + }(); + return s_id; +} + +ResolveStatus resolve(const char* name, void** outAddr, HookSymbolFlags* outFlags) { + if (!s_state.loaded) { + return ResolveStatus::Unavailable; + } + const uint64_t hash = fnv1a64(name); + const Entry* begin = s_state.entries; + const Entry* end = begin + s_state.entryCount; + size_t lo = 0; + size_t hi = s_state.entryCount; + while (lo < hi) { + const size_t mid = lo + (hi - lo) / 2; + if (begin[mid].hash < hash) { + lo = mid + 1; + } else { + hi = mid; + } + } + for (const Entry* entry = begin + lo; entry != end && entry->hash == hash; ++entry) { + if (entry->nameOff >= s_state.stringsLen || + std::strcmp(s_state.strings + entry->nameOff, name) != 0) + { + continue; + } + if ((entry->flags & kFlagDupName) != 0) { + return ResolveStatus::Ambiguous; + } + *outAddr = reinterpret_cast(s_state.imageBase + entry->rva); + if (outFlags != nullptr) { + *outFlags = entry->flags; + } + return ResolveStatus::Ok; + } + return ResolveStatus::NotFound; +} + +bool has_inline_sites(const void* addr, const char** outName) { + if (!s_state.loaded || s_state.inlineSites.empty()) { + return false; + } + const auto rva = static_cast(reinterpret_cast(addr) - s_state.imageBase); + const auto it = std::lower_bound(s_state.inlineSites.begin(), s_state.inlineSites.end(), + std::pair{rva, 0}); + if (it == s_state.inlineSites.end() || it->first != rva) { + return false; + } + if (outName != nullptr) { + *outName = s_state.strings + it->second; + } + return true; +} + +} // namespace dusk::mods::manifest diff --git a/src/dusk/mods/loader/manifest.hpp b/src/dusk/mods/loader/manifest.hpp new file mode 100644 index 0000000000..c6bc03f038 --- /dev/null +++ b/src/dusk/mods/loader/manifest.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include +#include + +#include "mods/svc/hook.h" + +namespace dusk::mods::manifest { + +// Symbol flags mirrored from symgen. +constexpr uint32_t kFlagCode = 1u << 0; +constexpr uint32_t kFlagData = 1u << 1; +constexpr uint32_t kFlagLocal = 1u << 2; +constexpr uint32_t kFlagMultiName = 1u << 3; +constexpr uint32_t kFlagDupName = 1u << 4; +constexpr uint32_t kFlagInlineSites = 1u << 5; +constexpr uint32_t kFlagDisplay = 1u << 6; + +enum class ResolveStatus { + Ok, + Unavailable, // no manifest loaded (missing, stale, or malformed) + NotFound, + Ambiguous, // name maps to multiple addresses (overloads / per-TU statics) +}; + +// Maps the symbol manifest next to the game binary and validates it against the +// running image's build id (PDB GUID+age / Mach-O UUID / GNU build-id). A missing or +// stale manifest logs and leaves by-name resolution unavailable; hooks by address are +// unaffected. Safe to call more than once. +void initialize(); + +bool available(); + +// Build id of the running executable image (PDB GUID+age / Mach-O UUID / GNU +// build-id), computed once on first use; empty if it couldn't be determined. +// Independent of whether a manifest file was loaded. +const std::vector& image_build_id(); + +// Resolve a symbol name to its address in the running image. Names can be either the platform's +// mangled name (i.e. the name passed to dlopen; no Mach-O leading underscore) or the function name +// without parameters (e.g. "daAlink_c::execute"). +ResolveStatus resolve(const char* name, void** outAddr, HookSymbolFlags* outFlags = nullptr); + +// True if the manifest records that the function at this code address was inlined into +// at least one caller in this build. An entry hook on it only intercepts the calls +// that were not inlined. outName receives the symbol name (valid for the process lifetime) +// when known. False when no manifest is loaded. +bool has_inline_sites(const void* addr, const char** outName = nullptr); + +} // namespace dusk::mods::manifest diff --git a/src/dusk/mods/svc/game.cpp b/src/dusk/mods/svc/game.cpp new file mode 100644 index 0000000000..f0bb8639a3 --- /dev/null +++ b/src/dusk/mods/svc/game.cpp @@ -0,0 +1,21 @@ +#include "registry.hpp" + +#include "mods/svc/game.h" + +namespace dusk::mods::svc { +namespace { + +constexpr GameService s_gameService{ + .header = SERVICE_HEADER(GameService, GAME_SERVICE_MAJOR, GAME_SERVICE_MINOR), +}; + +} // namespace + +constinit const ServiceModule g_gameModule{ + .id = GAME_SERVICE_ID, + .majorVersion = GAME_SERVICE_MAJOR, + .minorVersion = GAME_SERVICE_MINOR, + .service = &s_gameService, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/hook.cpp b/src/dusk/mods/svc/hook.cpp new file mode 100644 index 0000000000..f565776183 --- /dev/null +++ b/src/dusk/mods/svc/hook.cpp @@ -0,0 +1,480 @@ +#include "registry.hpp" + +#include "dusk/mods/loader/loader.hpp" +#include "dusk/mods/loader/manifest.hpp" +#include "mods/svc/hook.h" + +#if DUSK_CODE_MODS +#include "dusk/logging.h" + +#include +#include +#include +#include +#include +#include +#endif + +namespace dusk::mods::svc { +namespace { + +#if DUSK_CODE_MODS + +struct PreHookFn { + ModContext* context = nullptr; + HookPreFn callback = nullptr; + HookOptions options = HOOK_OPTIONS_INIT; + uint64_t order = 0; +}; + +struct VoidHookFn { + ModContext* context = nullptr; + HookReplaceFn replaceCallback = nullptr; + HookPostFn postCallback = nullptr; + HookOptions options = HOOK_OPTIONS_INIT; + uint64_t order = 0; +}; + +struct HookSlot { + std::vector pre; + VoidHookFn replace{}; + std::vector post; +}; + +// One per mod that requested a hook on a target: its template-generated trampoline and the +// address of its Hook::g_orig, both living in the mod's dylib. Any candidate's trampoline +// is interchangeable (dispatch walks the shared HookSlot), so when the active installer's mod +// unloads, the funchook detour is handed off to a surviving candidate and every candidate's +// *orig_store is rewritten to the new original pointer. +struct HookCandidate { + ModContext* context = nullptr; + void* trampoline = nullptr; + void** origStore = nullptr; + uint64_t order = 0; +}; + +struct InstalledHook { + funchook_t* handle = nullptr; + void* original = nullptr; + ModContext* active = nullptr; + std::vector candidates; +}; + +std::unordered_map s_registry; +std::unordered_map s_installed; +uint64_t s_nextOrder = 0; + +HookOptions normalize_options(const HookOptions* options) { + if (options == nullptr || options->struct_size < sizeof(HookOptions)) { + return HOOK_OPTIONS_INIT; + } + return *options; +} + +template +void sort_hooks(std::vector& hooks) { + std::ranges::stable_sort(hooks, [](const T& a, const T& b) { + if (a.options.priority != b.options.priority) { + return a.options.priority > b.options.priority; + } + return a.order < b.order; + }); +} + +// Follow E9/FF25 chains to skip MSVC incremental-link and import stubs. +void* resolve_import_thunk(void* addr) { +#if defined(_WIN32) && (defined(_M_X64) || defined(__x86_64__)) + for (int i = 0; i < 8; ++i) { + const auto* p = static_cast(addr); + if (p[0] == 0x48 && p[1] == 0xFF && p[2] == 0x25) { // lld emits a REX.W prefix + ++p; + } + if (p[0] == 0xFF && p[1] == 0x25) { + int32_t offset; + std::memcpy(&offset, p + 2, 4); + addr = const_cast(*reinterpret_cast(p + 6 + offset)); + break; + } + if (p[0] == 0xE9) { + int32_t offset; + std::memcpy(&offset, p + 1, 4); + addr = const_cast(p) + 5 + offset; + } else { + break; + } + } +#elif defined(_WIN32) && (defined(_M_ARM64) || defined(__aarch64__)) + // Import thunks are `adrp x16; ldr x16, [x16, #off]; br x16` (deref the IAT slot); + // incremental-link stubs are a plain `b`, or `adrp x16; add x16, x16, #off; br x16` + // range-extension thunks when the target is out of B range. + for (int i = 0; i < 8; ++i) { + const auto* p = static_cast(addr); + uint32_t insn0, insn1, insn2; + std::memcpy(&insn0, p, 4); + if ((insn0 & 0xFC000000u) == 0x14000000u) { // b imm26 + auto imm26 = static_cast(insn0 << 6) >> 6; + addr = const_cast(p) + static_cast(imm26) * 4; + continue; + } + if ((insn0 & 0x9F00001Fu) != 0x90000010u) { // adrp x16, page + break; + } + std::memcpy(&insn1, p + 4, 4); + std::memcpy(&insn2, p + 8, 4); + if (insn2 != 0xD61F0200u) { // br x16 + break; + } + auto immhi = static_cast(static_cast(insn0 << 8) >> 13); // bits 23:5 + auto immlo = static_cast((insn0 >> 29) & 3); + auto page = (reinterpret_cast(p) & ~uintptr_t{0xFFF}) + + (static_cast((immhi << 2) | immlo) << 12); + if ((insn1 & 0xFFC003FFu) == 0xF9400210u) { // ldr x16, [x16, #imm12*8] + auto slot = page + ((insn1 >> 10) & 0xFFF) * 8; + addr = *reinterpret_cast(slot); + break; + } + if ((insn1 & 0xFF8003FFu) == 0x91000210u) { // add x16, x16, #imm12{, lsl #12} + auto imm = static_cast((insn1 >> 10) & 0xFFF); + addr = reinterpret_cast(page + (((insn1 >> 22) & 1) != 0 ? imm << 12 : imm)); + continue; + } + break; + } +#endif + return addr; +} + +funchook_t* install_trampoline(void* fnAddr, void* trampoline, void** outOriginal) { + funchook_t* fh = funchook_create(); + if (fh == nullptr) { + DuskLog.warn("HookSystem: funchook_create failed for {:p}", fnAddr); + return nullptr; + } + + void* fn = fnAddr; + const int prep = funchook_prepare(fh, &fn, trampoline); + const int inst = prep == 0 ? funchook_install(fh, 0) : -1; + if (prep != 0 || inst != 0) { + const char* message = funchook_error_message(fh); + DuskLog.warn("HookSystem: funchook failed for {:p} (prepare={} install={}): {}", fnAddr, + prep, inst, message != nullptr && message[0] != '\0' ? message : "no details"); + funchook_destroy(fh); + return nullptr; + } + + *outOriginal = fn; + return fh; +} + +ModResult hook_install(ModContext* context, void* fnAddr, void* trampolineFn, void** outOriginal) { + if (fnAddr == nullptr || trampolineFn == nullptr || outOriginal == nullptr) { + return MOD_INVALID_ARGUMENT; + } + // Try to detect an invalid function pointer (possibly a vtable slot offset or Itanium mfp + // value) and provide a helpful warning instead of faulting + const auto raw = reinterpret_cast(fnAddr); + if (raw < 0x10000 +#if defined(__aarch64__) || defined(_M_ARM64) + || (raw & 3) != 0 // code is 4-aligned +#endif + ) + { + DuskLog.warn("HookSystem: {:p} from {} is not a code address (virtual member function " + "pointer? hook via dusk::mods::Hook or resolve())", + fnAddr, mod_id_from_context(context)); + return MOD_INVALID_ARGUMENT; + } + + fnAddr = resolve_import_thunk(fnAddr); + const auto key = reinterpret_cast(fnAddr); + if (const auto it = s_installed.find(key); it != s_installed.end()) { + auto& entry = it->second; + // hook_add_pre + hook_add_post on the same target share one g_orig per mod. + const bool known = std::ranges::any_of(entry.candidates, [&](const HookCandidate& cand) { + return cand.context == context && cand.origStore == outOriginal; + }); + if (!known) { + entry.candidates.push_back({context, trampolineFn, outOriginal, s_nextOrder++}); + } + *outOriginal = entry.original; + return MOD_OK; + } + + // Inlining can't be intercepted by an entry patch: warn once per target when this + // build inlined the function into callers. + if (const char* name = nullptr; manifest::has_inline_sites(fnAddr, &name)) { + DuskLog.warn("HookSystem: '{}' ({:p}) for {} was inlined into callers in this build; " + "the hook only covers the calls that were not inlined", + name != nullptr ? name : "?", fnAddr, mod_id_from_context(context)); + } + + void* original = nullptr; + funchook_t* fh = install_trampoline(fnAddr, trampolineFn, &original); + if (fh == nullptr) { + return MOD_ERROR; + } + + auto& entry = s_installed[key]; + entry.handle = fh; + entry.original = original; + entry.active = context; + entry.candidates.push_back({context, trampolineFn, outOriginal, s_nextOrder++}); + *outOriginal = original; + return MOD_OK; +} + +ModResult hook_add_pre( + ModContext* context, void* fnAddr, HookPreFn callback, const HookOptions* options) { + if (fnAddr == nullptr || context == nullptr || callback == nullptr) { + return MOD_INVALID_ARGUMENT; + } + fnAddr = resolve_import_thunk(fnAddr); + auto& hooks = s_registry[reinterpret_cast(fnAddr)].pre; + hooks.push_back({context, callback, normalize_options(options), s_nextOrder++}); + sort_hooks(hooks); + return MOD_OK; +} + +ModResult hook_add_post( + ModContext* context, void* fnAddr, HookPostFn callback, const HookOptions* options) { + if (fnAddr == nullptr || context == nullptr || callback == nullptr) { + return MOD_INVALID_ARGUMENT; + } + fnAddr = resolve_import_thunk(fnAddr); + auto& hooks = s_registry[reinterpret_cast(fnAddr)].post; + hooks.push_back({context, nullptr, callback, normalize_options(options), s_nextOrder++}); + sort_hooks(hooks); + return MOD_OK; +} + +ModResult hook_replace( + ModContext* context, void* fnAddr, HookReplaceFn callback, const HookOptions* options) { + if (fnAddr == nullptr || context == nullptr || callback == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + const HookOptions normalized = normalize_options(options); + fnAddr = resolve_import_thunk(fnAddr); + auto& slot = s_registry[reinterpret_cast(fnAddr)]; + if (slot.replace.replaceCallback == nullptr) { + slot.replace = {context, callback, nullptr, normalized, s_nextOrder++}; + return MOD_OK; + } + + switch (normalized.replace_policy) { + case HOOK_REPLACE_CONFLICT: + DuskLog.error("HookSystem: '{}' conflicts with '{}', both replace the same function", + mod_id_from_context(context), mod_id_from_context(slot.replace.context)); + return MOD_CONFLICT; + case HOOK_REPLACE_PRIORITY: + if (normalized.priority <= slot.replace.options.priority) { + return MOD_CONFLICT; + } + slot.replace = {context, callback, nullptr, normalized, s_nextOrder++}; + return MOD_OK; + case HOOK_REPLACE_OVERRIDE: + slot.replace = {context, callback, nullptr, normalized, s_nextOrder++}; + return MOD_OK; + } + return MOD_INVALID_ARGUMENT; +} + +ModResult hook_dispatch_pre( + ModContext*, void* fnAddr, void* args, void* retval, int* outSkipOriginal) { + if (outSkipOriginal != nullptr) { + *outSkipOriginal = 0; + } + if (fnAddr == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + fnAddr = resolve_import_thunk(fnAddr); + const auto it = s_registry.find(reinterpret_cast(fnAddr)); + if (it == s_registry.end()) { + return MOD_OK; + } + auto& slot = it->second; + for (auto& hook : slot.pre) { + if (hook.callback != nullptr && + hook.callback(hook.context, args, retval, hook.options.userdata) == HOOK_SKIP_ORIGINAL) + { + if (outSkipOriginal != nullptr) { + *outSkipOriginal = 1; + } + return MOD_OK; + } + } + if (slot.replace.replaceCallback != nullptr) { + slot.replace.replaceCallback( + slot.replace.context, args, retval, slot.replace.options.userdata); + if (outSkipOriginal != nullptr) { + *outSkipOriginal = 1; + } + } + return MOD_OK; +} + +ModResult hook_dispatch_post(ModContext*, void* fnAddr, void* args, void* retval) { + if (fnAddr == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + fnAddr = resolve_import_thunk(fnAddr); + const auto it = s_registry.find(reinterpret_cast(fnAddr)); + if (it == s_registry.end()) { + return MOD_OK; + } + for (auto& hook : it->second.post) { + if (hook.postCallback != nullptr) { + hook.postCallback(hook.context, args, retval, hook.options.userdata); + } + } + return MOD_OK; +} + +void hook_remove_mod(LoadedMod& mod) { + ModContext* context = mod.context.get(); + + for (auto it = s_registry.begin(); it != s_registry.end();) { + auto& slot = it->second; + std::erase_if(slot.pre, [&](const PreHookFn& hook) { return hook.context == context; }); + std::erase_if(slot.post, [&](const VoidHookFn& hook) { return hook.context == context; }); + if (slot.replace.context == context) { + slot.replace = {}; + } + if (slot.pre.empty() && slot.post.empty() && slot.replace.replaceCallback == nullptr) { + it = s_registry.erase(it); + } else { + ++it; + } + } + + for (auto it = s_installed.begin(); it != s_installed.end();) { + auto& entry = it->second; + // The departing mod's g_orig slots are about to be unmapped; drop its candidates before + // any orig_store rewrites below. + std::erase_if(entry.candidates, + [context](const HookCandidate& cand) { return cand.context == context; }); + if (entry.active != context) { + ++it; + continue; + } + + auto* target = reinterpret_cast(it->first); + const int uninst = funchook_uninstall(entry.handle, 0); + const int destr = funchook_destroy(entry.handle); + if (uninst != 0 || destr != 0) { + DuskLog.warn("HookSystem: funchook uninstall/destroy for {:p} returned {}/{}", target, + uninst, destr); + } + entry.handle = nullptr; + entry.active = nullptr; + + if (entry.candidates.empty()) { + it = s_installed.erase(it); + continue; + } + + // Hand the detour off to a surviving candidate (lowest registration order first; the + // vector is append-ordered). A candidate whose install fails stays in the list: its + // g_orig must still track the current original pointer. + for (auto& cand : entry.candidates) { + void* original = nullptr; + funchook_t* fh = install_trampoline(target, cand.trampoline, &original); + if (fh == nullptr) { + continue; + } + entry.handle = fh; + entry.original = original; + entry.active = cand.context; + DuskLog.info("HookSystem: reinstalled trampoline for {:p}: {} -> {} (tramp={:p})", + target, mod_id_from_context(context), mod_id_from_context(cand.context), + cand.trampoline); + break; + } + + if (entry.active == nullptr) { + DuskLog.warn("HookSystem: no reinstallable trampoline for {:p}; hooks there are " + "disabled until a mod reinstalls one", + target); + for (auto& cand : entry.candidates) { + *cand.origStore = target; + } + it = s_installed.erase(it); + continue; + } + + for (auto& cand : entry.candidates) { + *cand.origStore = entry.original; + } + ++it; + } +} + +#else // DUSK_CODE_MODS + +ModResult hook_install(ModContext*, void*, void*, void**) { + return MOD_UNSUPPORTED; +} +ModResult hook_add_pre(ModContext*, void*, HookPreFn, const HookOptions*) { + return MOD_UNSUPPORTED; +} +ModResult hook_add_post(ModContext*, void*, HookPostFn, const HookOptions*) { + return MOD_UNSUPPORTED; +} +ModResult hook_replace(ModContext*, void*, HookReplaceFn, const HookOptions*) { + return MOD_UNSUPPORTED; +} +ModResult hook_dispatch_pre(ModContext*, void*, void*, void*, int* outSkipOriginal) { + if (outSkipOriginal != nullptr) { + *outSkipOriginal = 0; + } + return MOD_UNSUPPORTED; +} +ModResult hook_dispatch_post(ModContext*, void*, void*, void*) { + return MOD_UNSUPPORTED; +} +void hook_remove_mod(LoadedMod&) {} + +#endif // DUSK_CODE_MODS + +// By-name resolution reads the symbol manifest, which is independent of the hook engine. +ModResult hook_resolve(ModContext*, const char* symbol, void** outAddr, HookSymbolFlags* outFlags) { + if (symbol == nullptr || outAddr == nullptr) { + return MOD_INVALID_ARGUMENT; + } + switch (manifest::resolve(symbol, outAddr, outFlags)) { + case manifest::ResolveStatus::Ok: + return MOD_OK; + case manifest::ResolveStatus::Unavailable: + return MOD_UNSUPPORTED; + case manifest::ResolveStatus::NotFound: + return MOD_UNAVAILABLE; + case manifest::ResolveStatus::Ambiguous: + return MOD_CONFLICT; + } + return MOD_ERROR; +} + +constexpr HookService s_hookService{ + .header = SERVICE_HEADER(HookService, HOOK_SERVICE_MAJOR, HOOK_SERVICE_MINOR), + .install = hook_install, + .add_pre = hook_add_pre, + .add_post = hook_add_post, + .replace = hook_replace, + .dispatch_pre = hook_dispatch_pre, + .dispatch_post = hook_dispatch_post, + .resolve = hook_resolve, +}; + +} // namespace + +constinit const ServiceModule g_hookModule{ + .id = HOOK_SERVICE_ID, + .majorVersion = HOOK_SERVICE_MAJOR, + .minorVersion = HOOK_SERVICE_MINOR, + .service = &s_hookService, + .modDetached = hook_remove_mod, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/host.cpp b/src/dusk/mods/svc/host.cpp index 6255b13c42..05971d5be1 100644 --- a/src/dusk/mods/svc/host.cpp +++ b/src/dusk/mods/svc/host.cpp @@ -1,10 +1,12 @@ #include "registry.hpp" #include "dusk/mods/loader/loader.hpp" +#include "dusk/mods/loader/manifest.hpp" #include "fmt/format.h" #include #include +#include namespace dusk::mods::svc { namespace { @@ -117,8 +119,11 @@ void host_mod_detached(LoadedMod& mod) { } } -constexpr HostService s_hostService{ +constinit HostService s_hostService{ .header = SERVICE_HEADER(HostService, HOST_SERVICE_MAJOR, HOST_SERVICE_MINOR), + .version = DUSK_VERSION_STRING, + .build_id = nullptr, + .build_id_len = 0, .get_service = host_get_service, .publish_service = host_publish_service, .fail = host_fail, @@ -137,6 +142,12 @@ constinit const ServiceModule g_hostModule{ .majorVersion = HOST_SERVICE_MAJOR, .minorVersion = HOST_SERVICE_MINOR, .service = &s_hostService, + .initialize = + [] { + const auto& buildId = manifest::image_build_id(); + s_hostService.build_id = buildId.empty() ? nullptr : buildId.data(); + s_hostService.build_id_len = static_cast(buildId.size()); + }, .modDetached = host_mod_detached, }; diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 31f67bc7ab..0a2da3add3 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -200,9 +200,11 @@ void ModLoader::init_services() { &svc::g_hostModule, &svc::g_logModule, &svc::g_resourceModule, + &svc::g_hookModule, &svc::g_overlayModule, &svc::g_textureModule, &svc::g_configModule, + &svc::g_gameModule, }) { svc::register_module(*module); diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 6ffa1a7582..bdbe2c2e72 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -65,8 +65,10 @@ void modules_shutdown(); extern const ServiceModule g_hostModule; extern const ServiceModule g_logModule; extern const ServiceModule g_resourceModule; +extern const ServiceModule g_hookModule; extern const ServiceModule g_overlayModule; extern const ServiceModule g_textureModule; extern const ServiceModule g_configModule; +extern const ServiceModule g_gameModule; } // namespace dusk::mods::svc From e9e16a8ac1e72394933efda7d4cd073def42e304 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 8 Jul 2026 00:30:16 -0600 Subject: [PATCH 065/150] Remove redundant OSInitAlloc --- src/dusk/stubs.cpp | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/dusk/stubs.cpp b/src/dusk/stubs.cpp index c6832d2cac..b283eb43bf 100644 --- a/src/dusk/stubs.cpp +++ b/src/dusk/stubs.cpp @@ -179,14 +179,6 @@ int OSJamMessage(OSMessageQueue* mq, void* msg, s32 flags) { return 1; } -// ========================================================================== -// Arena Functions -// ========================================================================== - -void* OSInitAlloc(void* arenaStart, void* arenaEnd, int maxHeaps) { - return arenaStart; -} - // ========================================================================== // Remaining OS Stubs // ========================================================================== From 16c573a1705d0ebee5229094549f018be7427880 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 8 Jul 2026 00:44:17 -0600 Subject: [PATCH 066/150] Doc updates --- docs/modding.md | 109 +++++++++++++++++++++++++++--------------------- 1 file changed, 62 insertions(+), 47 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index 2330181179..0df1fe6dfc 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -1,11 +1,15 @@ # Dusklight Mod API -Mods are distributed as `.dusk` files: zip archives containing a `mod.json` manifest and, optionally, compiled code -libraries and resources. +Mods are `.dusk` bundles: zip archives that can contain code (in the form of native libraries), resources, DVD overlay +files, and texture replacements. Mods may be enabled, disabled and reloaded at runtime. -Most things a mod does goes through **services**: small, versioned C APIs. Dusklight provides built-in services, and -mods can define their own to talk to each other. Mods also link against the game itself: include game headers and call -game functions directly. +When code mods are loaded, they get dynamically linked by the operating system to the running game process. The mod +exports lifecycle functions that Dusklight calls into (`mod_initialize`, `mod_update`, `mod_shutdown`), and the mod +communicates with the host via **services**: plain C APIs, individually versioned. Dusklight exports several built-in +services, and mods may export services of their own, permitting framework mods and cross-mod integration. + +Beyond services, mods have full access to the original game's code: include game headers, call directly into any public +function, read and write data fields, and hook the vast majority of game functions. ## Table of Contents @@ -55,14 +59,16 @@ add_mod(my_mod ``` Building produces `my_mod.dusk` in `build//mods/` (configurable via the `DUSK_MODS_OUTPUT_DIR` cache variable). -Copy it into the game's mods folder and launch: +Dusklight searches a `mods/` directory next to the app in addition to the user directory, so a dev build launched from +`build//` picks these up automatically: rebuild, relaunch (or click Reload), done. + +For a regular game install, copy the `.dusk` into the user mods folder: - Windows: `%APPDATA%\TwilitRealm\Dusklight\mods` - Linux: `~/.local/share/TwilitRealm/Dusklight/mods` - macOS: `~/Library/Application Support/TwilitRealm/Dusklight/mods` -You can also pass `--mods ` on the command line, which is handy during development. Mods will load from there -instead of the user directory above. +Passing `--mods ` on the command line replaces the user directory with one of your choosing. --- @@ -83,9 +89,8 @@ instead of the user directory above. `id` is required: a unique, stable identifier (reverse-DNS style; periods, underscores, and alphanumerics). Everything else is optional but recommended. -`icon` and `banner` are bundle-relative paths to PNG images for the in-game mod manager: the square icon (e.g. 512x512), -the banner (roughly 3.5:1). -Both keys are optional; if omitted, `res/icon.png` and `res/banner.png` are used automatically when present. +`icon` and `banner` are bundle-relative paths to PNG images for the in-game mod manager: the square icon (e.g. +512x512), the banner (~3.5:1). If omitted, `res/icon.png` and `res/banner.png` are used automatically when present. --- @@ -131,7 +136,14 @@ IMPORT_SERVICE_VERSION(LogService, svc_log, 2); // required, minor versi IMPORT_OPTIONAL_SERVICE(SomeService, svc_maybe); // may be null ``` -The rules (see `include/mods/api.h` for the full contract): +Each service is individually versioned, and there may be multiple major versions of a service provided at once, +allowing backwards compatibility with older mods while still changing services fundamentally if necessary. A **major** +bump is a breaking change, treated as a different service entirely. For **additive** changes, a service appends new +functions to the end of the struct without breaking existing callers and simply bumps the minor version. Mods that +want the newer functions may use `IMPORT_SERVICE_VERSION` to require that minor at **load time**, or `SERVICE_HAS` to +check at **runtime** whether a specific function is available. + +The contract (see `include/mods/api.h` for the full version): - **A required import is guaranteed valid.** If the service is missing or too old, the mod fails to load with a clear error. No need to null check at call sites. @@ -140,9 +152,6 @@ The rules (see `include/mods/api.h` for the full contract): - Fields newer than your imported minor must be gated behind `SERVICE_HAS(service, ServiceType, field)` plus a null check. -Service versions follow one rule: a **major** bump is a breaking change (treated as a different service entirely), a * -*minor** bump only appends functions. - --- ## Built-in Services @@ -158,8 +167,8 @@ svc_log->error(mod_ctx, "very bad"); svc_log->write(mod_ctx, LOG_LEVEL_DEBUG, "verbose details"); ``` -Messages appear in the console prefixed with your mod ID. Messages are plain strings: use `snprintf` or `fmt::format` -for formatting. +Messages appear in the console prefixed with your mod ID. Messages are plain UTF-8 strings and are copied before the +call returns; use `snprintf` or `fmt::format` for formatting. ### ResourceService (`mods/svc/resource.h`) @@ -176,8 +185,8 @@ if (svc_resource->load(mod_ctx, "config.txt", &buf) == MOD_OK) { } ``` -Missing files return `MOD_UNAVAILABLE`. Always `free` what you `load`. For writable storage, use the directory from -`svc_host->mod_dir(mod_ctx)`. +Missing files return `MOD_UNAVAILABLE`. Always `free` what you `load`. Note that the bundle is read-only; for writable +storage, use the directory from `svc_host->mod_dir(mod_ctx)`. ### HostService (`mods/svc/host.h`) @@ -215,14 +224,14 @@ every service dropped its state. For your own mod's teardown, use `mod_shutdown` ### HookService (`mods/svc/hook.h`) -Install hooks on game functions. You'll rarely call it directly; use the typed helpers in `mods/hook.hpp` described -below. +Installs hooks on game functions and resolves symbols by name. You'll rarely call it directly; use the typed helpers in +`mods/hook.hpp` described in [Hooking Game Functions](#hooking-game-functions). ### OverlayService (`mods/svc/overlay.h`) -Registers DVD file overlays at runtime. The dynamic counterpart to the static `overlay/` directory ( -see [Asset Overlays](#asset-overlays)). Overlay a disc path with a file from your bundle, or with a caller-owned -buffer (copied on registration): +Registers DVD file overlays at runtime: the dynamic counterpart to the static `overlay/` directory (see +[Asset Overlays](#asset-overlays)). Overlay a disc path with a file from your bundle, or with a caller-owned buffer +(copied on registration): ```cpp IMPORT_SERVICE(OverlayService, svc_overlay); @@ -233,16 +242,16 @@ svc_overlay->add_buffer(mod_ctx, "/generated.txt", data, size, nullptr); svc_overlay->remove(mod_ctx, handle); ``` -`disc_path` must be absolute (leading `/`) and is matched against the disc case-insensitively. Paths that don't exist on -the disc are added as new files. Changes are applied at the next frame boundary, and data the game already read stays in -memory until the file is re-read (sometimes a scene reload, sometimes never; a restart may be required). +`disc_path` must be absolute (leading `/`) and is matched against the disc case-insensitively. Paths that don't exist +on the disc are added as new files. Changes are applied at the next frame boundary, and data the game already read +stays in memory until the file is re-read: sometimes a scene reload, and in the worst case, a full restart. See [Asset Overlays](#asset-overlays) for priority and conflict handling. ### TextureService (`mods/svc/texture.h`) -Registers texture replacements at runtime. The dynamic counterpart to the static `textures/` directory ( -see [Asset Overlays](#asset-overlays)). Two forms: raw texel data with an explicit key, or an encoded `.dds`/`.png` from +Registers texture replacements at runtime: the dynamic counterpart to the static `textures/` directory (see +[Asset Overlays](#asset-overlays)). Two forms: raw texel data with an explicit key, or an encoded `.dds`/`.png` from your bundle whose filename encodes the key: ```cpp @@ -264,7 +273,7 @@ svc_texture->register_data(mod_ctx, &key, &data, nullptr); svc_texture->unregister(mod_ctx, handle); ``` -Filenames use the same convention as the user's `texture_replacements` directory: +Filenames use the same Dolphin-style convention as the user's `texture_replacements` directory: `tex1_{w}x{h}_{texhash}[_{tluthash}]_{fmt}.dds|.png`, where hashes may be `$` (wildcard). `_mipN` sidecar files next to a registered file are picked up automatically. Files are decoded lazily on first use by the renderer; raw data is copied at registration. Registrations follow your mod's lifecycle. @@ -311,6 +320,7 @@ Writes that store the same value are silent. Values applied from `config.json` o ## Hooking Game Functions +Mods may hook the vast majority of game functions, including file-local static, private and virtual functions. `mods/hook.hpp` provides typed helpers over the hook service: ```cpp @@ -338,8 +348,8 @@ dusk::mods::hook_add_pre<&daAlink_c::posMove>(svc_hook, on_pos_move_pre); ### Post-hooks -Run after the original (or after a replace-hook, or after a cancelled original). `retval` points to the return value, if -any. +Run after the original (or after a replace-hook, or after a cancelled original). `retval` points to the return value, +if any. ```cpp void on_pos_move_post(ModContext*, void* args, void* retval, void* userdata) { ... } @@ -442,8 +452,9 @@ Two spellings work on every platform: ### Game code ABI contract -If your mod calls or hooks game code directly (anything beyond the service APIs), import `GameService` ( -`mods/svc/game.h`): +A primary consideration when letting mods link against the game is maintaining ABI stability across Dusklight +versions. If your mod calls or hooks game code directly (anything beyond the service APIs), import `GameService` +(`mods/svc/game.h`): ```cpp IMPORT_SERVICE(GameService, svc_game); @@ -451,25 +462,28 @@ IMPORT_SERVICE(GameService, svc_game); Its major version is the game code ABI epoch: it's bumped when game struct or vtable layouts change incompatibly, and the ordinary service version check then rejects your mod with a clear error instead of letting it corrupt memory in a -version it wasn't built for. Service-only and asset-only mods should *not* import it; they stay compatible across game -ABI changes. +version it wasn't built for. Service-only and asset-only mods should *not* import it and will continue to work across +game ABI changes. + +The more you can do through services, the better: a mod that avoids touching game code directly sidesteps future ABI +breaks entirely and plays nicer with other enabled mods. --- ## Asset Overlays -Files placed under `overlay/` in the `.dusk` archive override game files at the corresponding path. For example, -`overlay/res/Stage/...` shadows that file on the game disc image. This requires no code: an archive with just -`mod.json` and `overlay/` is a complete mod. +Files placed under `overlay/` in the `.dusk` archive override game files at the corresponding path, equivalent to +replacing files in the .iso. This requires no code: an archive with just `mod.json` and `overlay/` is a complete mod. -Files placed under `textures/` register as texture replacements the same way. Filenames follow the replacement naming -convention (`tex1_{w}x{h}_{texhash}[_{tluthash}]_{fmt}.dds|.png`, `$` as a hash wildcard). Subdirectories are scanned -recursively; only the filename needs to match. +Files placed under `textures/` register as texture replacements, and act just like the user's general +`texture_replacements/` directory: Dolphin-style naming, matched by texture hash +(`tex1_{w}x{h}_{texhash}[_{tluthash}]_{fmt}.dds|.png`, `$` as a hash wildcard). Subdirectories are scanned recursively; +only the filename needs to match. -Both follow the mod's lifecycle: disabling the mod removes its overrides (files revert to the disc contents on their -next open; added files stop existing), and reloading serves the new bundle's content. Game data the engine already read -stays as-is until it is loaded again, which may require a scene reload or a full restart. Texture replacements usually -take effect immediately. +Both mechanisms are tied to the mod's lifecycle: disabling the mod removes its overrides (files revert to the disc +contents on their next open; added files stop existing), and reloading serves the new bundle's content. However, game +data the engine already read stays as-is until it is loaded again, which may require a scene change or, in the worst +case, a full restart. Texture replacements usually take effect immediately. If multiple sources replace the same file or texture, the last one wins: runtime registrations override static `textures/` or `overlay/` files, and later-loaded mods override earlier ones. Cross-mod conflicts log warnings. @@ -527,7 +541,8 @@ explicit results. ## Advanced: Exporting Services -Mods can provide services to other mods. Define the interface in a header both mods share: +Mods may export services of their own, permitting framework mods and cross-mod integration. Define the interface in a +header both mods share: ```cpp // my_mod_api.h From 6136d9bbf7dd18edee78ecfb9251ef0cc9d7615a Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 8 Jul 2026 17:49:48 -0600 Subject: [PATCH 067/150] Set .clang-format to C++20 --- .clang-format | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.clang-format b/.clang-format index 8ffd4ebe96..1cf581604c 100644 --- a/.clang-format +++ b/.clang-format @@ -1,6 +1,6 @@ --- Language: Cpp -Standard: C++03 +Standard: c++20 AccessModifierOffset: -4 AlignAfterOpenBracket: DontAlign AlignConsecutiveAssignments: false From 36635f8c6286190807469e2be7b87b53f0635c85 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 8 Jul 2026 17:50:03 -0600 Subject: [PATCH 068/150] Fix multi-configuration builds with CMake --- cmake/WindowsExports.cmake | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/cmake/WindowsExports.cmake b/cmake/WindowsExports.cmake index f380094b78..86dc9414f2 100644 --- a/cmake/WindowsExports.cmake +++ b/cmake/WindowsExports.cmake @@ -16,12 +16,17 @@ function(setup_windows_exports target) set(_symgen "${SYMGEN_EXE}") add_dependencies(${target} symgen) + set(_config_subdir "") + if (CMAKE_CONFIGURATION_TYPES) + set(_config_subdir "$/") + endif () + set(_rsp_lines "$") foreach (_lib IN LISTS JSYSTEM_LIBRARIES) list(APPEND _rsp_lines "$") endforeach () list(JOIN _rsp_lines "\n" _rsp_content) - set(_rsp "${CMAKE_BINARY_DIR}/dusklight_exports_input.rsp") + set(_rsp "${CMAKE_BINARY_DIR}/${_config_subdir}dusklight_exports_input.rsp") file(GENERATE OUTPUT "${_rsp}" CONTENT "${_rsp_content}") set(_sdk_args) @@ -33,7 +38,7 @@ function(setup_windows_exports target) endforeach () # Generate curated exports list from the main binary - set(_def "${CMAKE_BINARY_DIR}/dusklight_exports.def") + set(_def "${CMAKE_BINARY_DIR}/${_config_subdir}dusklight_exports.def") add_custom_command(TARGET ${target} PRE_LINK # TODO: src/dusk/ is NOT excluded: inline code in game headers # currently call into it (e.g. dusk::frame_interp::lookup_replacement). @@ -50,7 +55,7 @@ function(setup_windows_exports target) target_link_options(${target} PRIVATE "/DEF:${_def}") # Generate import library for mods to link against. - set(_implib "${CMAKE_BINARY_DIR}/dusklight_imports.lib") + set(_implib "${CMAKE_BINARY_DIR}/${_config_subdir}dusklight_imports.lib") get_filename_component(_compiler_dir "${CMAKE_CXX_COMPILER}" DIRECTORY) find_program(DUSK_LLVM_DLLTOOL llvm-dlltool HINTS "${_compiler_dir}") if (DUSK_LLVM_DLLTOOL) @@ -65,9 +70,7 @@ function(setup_windows_exports target) BYPRODUCTS "${_implib}" COMMENT "Generating dusklight import library" VERBATIM) - if ("$CACHE{DUSK_GAME_IMPLIB}" STREQUAL "") - set(DUSK_GAME_IMPLIB "${_implib}" CACHE INTERNAL "Import library for Windows mod linking") - endif () + set(DUSK_GAME_IMPLIB "${_implib}" CACHE INTERNAL "Import library for Windows mod linking") set(DUSK_GAME_DEF "${_def}" CACHE INTERNAL "Curated export .def for the game executable") # Ship the import library as sdk/dusklight.lib in the install tree: mods may use it to From bc11bf356359fa5883ecd5ed84a98fe76fcecb63 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 8 Jul 2026 17:51:13 -0600 Subject: [PATCH 069/150] Mods manager UI & logs viewer --- docs/modding.md | 6 +- files.cmake | 12 +- include/dusk/mod_loader.hpp | 8 +- res/rml/logs.rcss | 97 +++++++ res/rml/mods.rcss | 218 ++++++++++++++++ res/rml/overlay.rcss | 8 + res/rml/prelaunch.rcss | 4 + res/rml/tabbing.rcss | 15 +- src/dusk/mods/loader/context.cpp | 21 +- src/dusk/mods/loader/depgraph.cpp | 13 +- src/dusk/mods/loader/loader.cpp | 163 +++++++----- src/dusk/mods/log_buffer.cpp | 103 ++++++++ src/dusk/mods/log_buffer.hpp | 55 ++++ src/dusk/mods/{loader => }/manifest.cpp | 0 src/dusk/mods/{loader => }/manifest.hpp | 0 src/dusk/mods/svc/config.cpp | 4 +- src/dusk/mods/svc/hook.cpp | 2 +- src/dusk/mods/svc/host.cpp | 8 +- src/dusk/mods/svc/log.cpp | 18 +- src/dusk/mods/svc/registry.cpp | 38 ++- src/dusk/mods/svc/registry.hpp | 1 - src/dusk/ui/logs_window.cpp | 301 ++++++++++++++++++++++ src/dusk/ui/logs_window.hpp | 44 ++++ src/dusk/ui/menu_bar.cpp | 18 +- src/dusk/ui/menu_bar.hpp | 2 + src/dusk/ui/mod_texture_provider.cpp | 210 +++++++++++++++ src/dusk/ui/mod_texture_provider.hpp | 19 ++ src/dusk/ui/mods_window.cpp | 325 ++++++++++++++++++++++++ src/dusk/ui/mods_window.hpp | 38 +++ src/dusk/ui/overlay.cpp | 3 + src/dusk/ui/prelaunch.cpp | 14 +- src/dusk/ui/settings.cpp | 17 +- src/dusk/ui/ui.cpp | 5 +- src/dusk/ui/window.cpp | 82 +++++- src/dusk/ui/window.hpp | 13 +- 35 files changed, 1709 insertions(+), 176 deletions(-) create mode 100644 res/rml/logs.rcss create mode 100644 res/rml/mods.rcss create mode 100644 src/dusk/mods/log_buffer.cpp create mode 100644 src/dusk/mods/log_buffer.hpp rename src/dusk/mods/{loader => }/manifest.cpp (100%) rename src/dusk/mods/{loader => }/manifest.hpp (100%) create mode 100644 src/dusk/ui/logs_window.cpp create mode 100644 src/dusk/ui/logs_window.hpp create mode 100644 src/dusk/ui/mod_texture_provider.cpp create mode 100644 src/dusk/ui/mod_texture_provider.hpp create mode 100644 src/dusk/ui/mods_window.cpp create mode 100644 src/dusk/ui/mods_window.hpp diff --git a/docs/modding.md b/docs/modding.md index 0df1fe6dfc..9208f550e6 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -131,9 +131,9 @@ A service is a struct of C function pointers with a version header. You declare loader resolves it before your mod initializes: ```cpp -IMPORT_SERVICE(LogService, svc_log); // required, any minor version -IMPORT_SERVICE_VERSION(LogService, svc_log, 2); // required, minor version >= 2 -IMPORT_OPTIONAL_SERVICE(SomeService, svc_maybe); // may be null +IMPORT_SERVICE(LogService, svc_log); // required, any minor version +IMPORT_SERVICE_VERSION(LogService, svc_log, 2); // required, minor version >= 2 +IMPORT_OPTIONAL_SERVICE(SomeService, svc_maybe); // may be null ``` Each service is individually versioned, and there may be multiple major versions of a service provided at once, diff --git a/files.cmake b/files.cmake index 524c53943f..1cbef1694b 100644 --- a/files.cmake +++ b/files.cmake @@ -1496,6 +1496,10 @@ set(DUSK_FILES src/dusk/ui/input.hpp src/dusk/ui/icon_provider.cpp src/dusk/ui/icon_provider.hpp + src/dusk/ui/logs_window.cpp + src/dusk/ui/logs_window.hpp + src/dusk/ui/mod_texture_provider.cpp + src/dusk/ui/mod_texture_provider.hpp src/dusk/ui/modal.cpp src/dusk/ui/modal.hpp src/dusk/ui/nav_types.hpp @@ -1507,6 +1511,8 @@ set(DUSK_FILES src/dusk/ui/pane.hpp src/dusk/ui/menu_bar.cpp src/dusk/ui/menu_bar.hpp + src/dusk/ui/mods_window.cpp + src/dusk/ui/mods_window.hpp src/dusk/ui/prelaunch.cpp src/dusk/ui/prelaunch.hpp src/dusk/ui/preset.cpp @@ -1541,6 +1547,10 @@ set(DUSK_FILES src/dusk/OSReport.cpp src/dusk/OSThread.cpp src/dusk/OSMutex.cpp + src/dusk/mods/log_buffer.cpp + src/dusk/mods/log_buffer.hpp + src/dusk/mods/manifest.cpp + src/dusk/mods/manifest.hpp src/dusk/mods/loader/bundle_disk.cpp src/dusk/mods/loader/bundle_zip.cpp src/dusk/mods/loader/context.cpp @@ -1550,8 +1560,6 @@ set(DUSK_FILES src/dusk/mods/loader/loader.hpp src/dusk/mods/loader/native_module.cpp src/dusk/mods/loader/native_module.hpp - src/dusk/mods/loader/manifest.cpp - src/dusk/mods/loader/manifest.hpp src/dusk/mods/svc/config.cpp src/dusk/mods/svc/config.hpp src/dusk/mods/svc/game.cpp diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index 7f9d1ad340..9d77f6984a 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -176,7 +176,7 @@ public: void request_enable(std::string_view id); void request_disable(std::string_view id); void request_reload(std::string_view id); - void notify_mod_failure(LoadedMod& mod); + void notify_mod_failure(LoadedMod& mod, bool firstFailure); [[nodiscard]] auto mods() const { return m_mods | std::views::transform([](const auto& m) -> LoadedMod& { return *m; }); @@ -204,6 +204,7 @@ private: std::vector m_searchDirs; std::filesystem::path m_cacheDir; std::vector m_pendingRequests; + std::vector m_pendingFailures; std::vector m_retiredNatives; bool m_initialized = false; bool m_startupComplete = false; @@ -224,12 +225,11 @@ private: bool resolve_service_imports(LoadedMod& mod); [[nodiscard]] std::string describe_missing_import( const char* serviceId, uint16_t majorVersion, uint16_t minMinorVersion) const; - void clear_services(); - void fail_mod(LoadedMod& mod, ModResult code, std::string_view message); - LoadedMod* find_mod(std::string_view id); + LoadedMod* find_mod(std::string_view id) const; void drain_retired_natives(); void apply_pending_requests(); + void flush_toasts(); void on_enabled_changed(LoadedMod& mod); // Deactivates `target` (if needed) and its transitive dependents, optionally re-reads the // bundle from disk, then reactivates whatever the current cvar/provider state allows. diff --git a/res/rml/logs.rcss b/res/rml/logs.rcss new file mode 100644 index 0000000000..126684f9d2 --- /dev/null +++ b/res/rml/logs.rcss @@ -0,0 +1,97 @@ +window.logs content { + flex-flow: column; +} + +window.logs .log-toolbar { + display: flex; + flex-flow: row; + flex: 0 0 64dp; + height: 64dp; + align-items: center; + gap: 8dp; + padding-right: 72dp; + background-color: rgba(217, 217, 217, 10%); + border-bottom: 2dp #92875B; + font-family: "Fira Sans Condensed"; + font-weight: bold; + font-size: 18dp; +} + +window.logs > close { + top: 8dp; + right: 8dp; +} + +window.logs .log-title { + align-self: stretch; + flex: 0 0 auto; + padding: 0 24dp; + line-height: 64dp; + text-transform: uppercase; + border-bottom: 4dp #C2A42D; + font-effect: glow(0dp 4dp 0dp 4dp black); +} + +window.logs .log-title-mod { + flex: 0 1 auto; + min-width: 0; + white-space: nowrap; + overflow: hidden; + font-family: "Fira Sans"; + font-weight: normal; + font-size: 15dp; + color: rgba(224, 219, 200, 55%); +} + +.log-toolbar-spacer { + flex: 1 1 0; +} + +.log-toolbar button { + flex: 0 0 auto; + font-family: "Fira Sans"; + font-weight: normal; + font-size: 15dp; + padding: 5dp 12dp; +} + +window.logs content pane.log-view { + flex: 1 1 0; + padding: 12dp 16dp; + padding-bottom: 0dp; + gap: 0dp; +} + +.log-lines { + display: block; +} + +.log-line { + display: block; + font-family: "Noto Mono"; + font-size: 13dp; + line-height: 1.5; + word-break: break-word; + white-space: pre-wrap; +} + +.log-line .log-time { + color: rgba(224, 219, 200, 45%); +} + +.log-line .log-mod { + color: rgba(194, 164, 45, 80%); +} + +.log-line.lvl-trace, +.log-line.lvl-debug { + opacity: 0.55; +} + +.log-line.lvl-warn .log-msg { + color: #ffa826; +} + +.log-line.lvl-error .log-msg { + color: #cc4444; +} diff --git a/res/rml/mods.rcss b/res/rml/mods.rcss new file mode 100644 index 0000000000..99aa33d236 --- /dev/null +++ b/res/rml/mods.rcss @@ -0,0 +1,218 @@ +window.mods content pane.mod-list { + flex: 0 0 360dp; + padding: 16dp; + padding-bottom: 0dp; + gap: 4dp; +} + +@media (max-height: 640dp) { + window.mods content pane.mod-list { + flex: 1 1 0; + } +} + +window.mods content pane.mod-detail { + gap: 12dp; +} + +.mod-info-row { + display: flex; + align-items: center; + gap: 12dp; + padding: 4dp 0; +} + +.mod-info-label { + font-family: "Fira Sans Condensed"; + font-weight: bold; + opacity: 0.55; + flex: 0 0 auto; +} + +.mod-info-value { + flex: 1 1 0; +} + +.mod-path { + font-size: 14dp; + word-break: break-all; + opacity: 0.7; +} + +mod-entry { + display: flex; + flex-flow: row; + gap: 12dp; + padding: 10dp; + border-radius: 10dp; + decorator: vertical-gradient(#c2a42d00 #c2a42d00); + transition: decorator 0.1s linear-in-out; + cursor: pointer; + focus: auto; +} + +mod-entry.current { + box-shadow: rgba(146, 135, 91, 40%) 0 0 0 1dp; +} + +mod-entry:hover, +mod-entry:focus-visible { + decorator: vertical-gradient(#c2a42d00 #c2a42d26); +} + +mod-entry:selected { + decorator: vertical-gradient(#c2a42d10 #c2a42d40); +} + +mod-entry .mod-icon { + flex: 0 0 auto; + width: 56dp; + height: 56dp; + border-radius: 8dp; +} + +mod-entry icon.mod-icon { + font-size: 36dp; + background-color: rgba(17, 16, 10, 20%); + color: rgba(224, 219, 200, 45%); + decorator: text("" center center); +} + +mod-entry .mod-entry-info { + display: flex; + flex-flow: column; + flex: 1 1 0; + min-width: 0; + gap: 2dp; +} + +mod-entry .mod-entry-name { + display: flex; + flex-flow: row; + align-items: baseline; + gap: 6dp; +} + +mod-entry .mod-entry-name-text { + flex: 0 1 auto; + min-width: 0; + font-weight: bold; + white-space: nowrap; + overflow: hidden; +} + +mod-entry .mod-entry-version { + flex: 0 0 auto; + font-size: 13dp; + color: rgba(224, 219, 200, 50%); +} + +mod-entry .mod-entry-status.active { + color: #44cc55; +} + +mod-entry .mod-entry-status.failed { + color: #cc4444; +} + +mod-entry .mod-entry-desc { + font-size: 14dp; + line-height: 1.3; + color: rgba(224, 219, 200, 65%); + max-height: 2.6em; + overflow: hidden; +} + +mod-entry .mod-entry-sub { + font-size: 13dp; + color: rgba(224, 219, 200, 50%); +} + +mod-entry.inactive .mod-icon { + filter: grayscale(1); +} + +mod-entry.inactive .mod-entry-info { + opacity: 0.5; +} + +mod-header { + position: relative; + flex: 0 0 auto; +} + +mod-header.has-banner { + height: 180dp; + margin: -24dp -24dp 0dp -24dp; +} + +mod-header .mod-actions { + position: absolute; + top: 24dp; + left: 24dp; + display: flex; + flex-flow: row; + gap: 8dp; +} + +mod-header .mod-actions button { + font-size: 16dp; + padding: 6dp 14dp; + background-color: rgba(21, 22, 16, 80%); + box-shadow: rgba(146, 135, 91, 60%) 0 0 0 1dp; +} + +mod-header.no-banner { + display: flex; + flex-flow: row; + align-items: center; +} + +mod-header.no-banner .mod-actions { + position: static; +} + +window.mods .mod-title { + display: block; + font-size: 28dp; + font-weight: bold; +} + +window.mods .mod-title .mod-title-version { + font-weight: normal; + font-size: 16dp; + color: rgba(224, 219, 200, 55%); +} + +window.mods .mod-author { + display: block; + font-size: 15dp; + color: rgba(224, 219, 200, 55%); +} + +window.mods .mod-restart-note { + font-size: 15dp; + color: #ffa826; + opacity: 0.85; +} + +window.mods .mod-description { + line-height: 1.5; +} + +.status-badge { + font-size: 14dp; + opacity: 0.7; +} + +.status-badge.active, +.mod-info-label.active { + color: #44cc55; + opacity: 1; +} + +.status-badge.failed, +.mod-info-label.failed { + color: #cc4444; + opacity: 1; +} \ No newline at end of file diff --git a/res/rml/overlay.rcss b/res/rml/overlay.rcss index 384c7a2a9b..98f8bfa27a 100644 --- a/res/rml/overlay.rcss +++ b/res/rml/overlay.rcss @@ -159,6 +159,14 @@ toast.achievement heading { color: #C2A42D; } +toast.warning { + border: 1dp #C2A42D; +} + +toast.warning heading { + color: #C2A42D; +} + toast.controller-warning { top: auto; right: auto; diff --git a/res/rml/prelaunch.rcss b/res/rml/prelaunch.rcss index 73efc18086..054b6e0183 100644 --- a/res/rml/prelaunch.rcss +++ b/res/rml/prelaunch.rcss @@ -362,6 +362,10 @@ body.animate-in .intro-item { transition: opacity transform 0.3s 0.6s cubic-in-out; } +.delay-6 { + transition: opacity transform 0.3s 0.7s cubic-in-out; +} + /* Mobile layout */ @media (max-height: 640dp) { .gradient { diff --git a/res/rml/tabbing.rcss b/res/rml/tabbing.rcss index 194ca89f05..8f42dd84d5 100644 --- a/res/rml/tabbing.rcss +++ b/res/rml/tabbing.rcss @@ -50,7 +50,8 @@ tab-bar[closable] tab-end-spacer { pointer-events: none; } -tab-bar[closable] close { +tab-bar[closable] close, +window > close { display: block; position: fixed; top: 8dp; @@ -70,12 +71,20 @@ tab-bar[closable] close { } tab-bar[closable] close:hover, -tab-bar[closable] close:focus-visible { +tab-bar[closable] close:focus-visible, +window > close:hover, +window > close:focus-visible { color: #fff; background-color: rgba(194, 164, 45, 24%); } -tab-bar[closable] close:active { +window > close { + top: 16dp; + right: 16dp; +} + +tab-bar[closable] close:active, +window > close:active { color: #fff; background-color: rgba(194, 164, 45, 40%); } diff --git a/src/dusk/mods/loader/context.cpp b/src/dusk/mods/loader/context.cpp index 99417117d6..9c8fe71db4 100644 --- a/src/dusk/mods/loader/context.cpp +++ b/src/dusk/mods/loader/context.cpp @@ -1,11 +1,7 @@ #include "loader.hpp" -#include "dusk/logging.h" +#include "dusk/mods/log_buffer.hpp" #include "dusk/mods/svc/registry.hpp" -#include "dusk/ui/ui.hpp" -#include "fmt/format.h" - -#include namespace dusk::mods { @@ -55,16 +51,9 @@ void fail_mod(LoadedMod& mod, ModResult code, std::string_view message) { // callback), and full teardown happens via deactivate_mod at a safe point. svc::remove_services_for_provider(mod); mod.servicesRegistered = false; - ModLoader::instance().notify_mod_failure(mod); - DuskLog.error("[{}] disabled: {} ({})", mod.metadata.id, message, static_cast(code)); - if (firstFailure) { - ui::push_toast({ - .type = "warning", - .title = "Mod Disabled", - .content = ui::escape(fmt::format("{}: {}", mod.metadata.name, message)), - .duration = std::chrono::seconds(5), - }); - } + ModLoader::instance().notify_mod_failure(mod, firstFailure); + log::write( + mod.metadata.id, LOG_LEVEL_ERROR, "failed: {} ({})", message, static_cast(code)); } -} // namespace dusk::mods::loader +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/depgraph.cpp b/src/dusk/mods/loader/depgraph.cpp index 20c831075a..d1748b56c4 100644 --- a/src/dusk/mods/loader/depgraph.cpp +++ b/src/dusk/mods/loader/depgraph.cpp @@ -5,13 +5,11 @@ #include "dusk/logging.h" #include "loader.hpp" -#include "native_module.hpp" - -static aurora::Module Log("dusk::modLoader"); +#include "native_module.hpp" // IWYU pragma: keep namespace dusk::mods::loader { - namespace { +aurora::Module Log{"dusk::mods::loader"}; struct Edge { size_t provider; @@ -31,7 +29,7 @@ std::vector collect_edges(const std::vector>& m for (size_t i = 0; i < mods.size(); ++i) { const auto matches = [&](const ModManifestInfo::Export& serviceExport) { return serviceExport.major == serviceImport.major && - serviceExport.id == serviceImport.id; + serviceExport.id == serviceImport.id; }; if (std::ranges::any_of(mods[i]->manifestInfo.exports, matches)) { return i; @@ -165,7 +163,7 @@ void sort_mods(std::vector>& mods) { } for (const size_t index : cycleMods) { fail_mod(*mods[index], MOD_CONFLICT, - "required service import cycle between mods: " + names); + "Required service import cycle between mods: " + names); place(index); } continue; @@ -174,8 +172,7 @@ void sort_mods(std::vector>& mods) { // Only optional imports left in the cycle: drop one edge and retry. The // import still resolves, but without any initialization-order guarantee. const auto optionalEdge = std::ranges::find_if(edges, [&](const Edge& edge) { - return edge.alive && !edge.required && !placed[edge.provider] && - !placed[edge.importer]; + return edge.alive && !edge.required && !placed[edge.provider] && !placed[edge.importer]; }); if (optionalEdge == edges.end()) { // Unreachable: a stall with no required cycle implies an optional edge. diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp index fb293a55b4..77b6e7f1a9 100644 --- a/src/dusk/mods/loader/loader.cpp +++ b/src/dusk/mods/loader/loader.cpp @@ -3,24 +3,26 @@ #include "dusk/mod_loader.hpp" #include +#include #include #include #include #include #include +#include "../manifest.hpp" #include "depgraph.hpp" #include "dusk/config.hpp" #include "dusk/io.hpp" +#include "dusk/mods/log_buffer.hpp" #include "dusk/mods/svc/config.hpp" #include "dusk/mods/svc/registry.hpp" -#include "manifest.hpp" +#include "dusk/ui/mods_window.hpp" +#include "dusk/ui/ui.hpp" #include "miniz.h" #include "native_module.hpp" #include "nlohmann/json.hpp" -static aurora::Module Log("dusk::modLoader"); - using namespace std::string_literals; using namespace std::string_view_literals; @@ -71,6 +73,7 @@ static constexpr std::string_view k_nativeLibName = ""sv; namespace dusk::mods { namespace { +aurora::Module Log{"dusk::mods::loader"}; ModLoader g_modLoader; std::unique_ptr load_bundle(const std::filesystem::path& modPath, bool fromDir) { @@ -163,9 +166,11 @@ static std::string resolve_image_path(ModBundle& bundle, const std::string& modI std::string_view key, const std::string& manifestPath, const std::string& defaultPath) { if (!manifestPath.empty()) { if (!is_safe_resource_path(manifestPath)) { - Log.warn("{}: invalid {} path '{}' in mod.json", modId, key, manifestPath); + log::write( + modId, LOG_LEVEL_WARN, "invalid {} path '{}' in mod.json", key, manifestPath); } else if (!bundle_has_file(bundle, manifestPath)) { - Log.warn("{}: {} path '{}' not found in bundle", modId, key, manifestPath); + log::write( + modId, LOG_LEVEL_WARN, "{} path '{}' not found in bundle", key, manifestPath); } else { return manifestPath; } @@ -217,18 +222,18 @@ static ModMetadata load_metadata(const std::filesystem::path& modPath, ModBundle static bool validate_manifest(const ModManifest* manifest, LoadedMod& mod) { if (manifest == nullptr) { - Log.error("{} returned a null mod manifest", mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "returned a null mod manifest"); mod.nativeStatus = NativeModStatus::MissingExport; return false; } if (manifest->struct_size != sizeof(ModManifest)) { - Log.error("{} manifest has invalid size {} (expected {})", mod.metadata.id, + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "manifest has invalid size {} (expected {})", manifest->struct_size, sizeof(ModManifest)); mod.nativeStatus = NativeModStatus::ApiVersionMismatch; return false; } if (manifest->abi_version != MOD_ABI_VERSION) { - Log.error("{} expects ABI v{} but engine is v{}, skipping", mod.metadata.id, + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "expects ABI v{} but engine is v{}, skipping", manifest->abi_version, MOD_ABI_VERSION); mod.nativeStatus = NativeModStatus::ApiVersionMismatch; return false; @@ -236,7 +241,7 @@ static bool validate_manifest(const ModManifest* manifest, LoadedMod& mod) { if ((manifest->import_count > 0 && manifest->imports == nullptr) || (manifest->export_count > 0 && manifest->exports == nullptr)) { - Log.error("{} manifest has invalid import/export arrays", mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "manifest has invalid import/export arrays"); mod.nativeStatus = NativeModStatus::MissingExport; return false; } @@ -245,7 +250,7 @@ static bool validate_manifest(const ModManifest* manifest, LoadedMod& mod) { static bool validate_context_symbol(ModContext** contextSymbol, LoadedMod& mod) { if (contextSymbol == nullptr) { - Log.error("{} missing required mod_ctx export", mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "missing required mod_ctx export"); mod.nativeStatus = NativeModStatus::MissingExport; return false; } @@ -289,8 +294,8 @@ std::filesystem::path ModLoader::external_native_lib_path(const LoadedMod& mod) if (libDir.empty()) { return {}; } - fs::path path = libDir / fs::path(mod.metadata.id + io::fs_path_to_string( - fs::path(k_nativeLibName).extension())); + fs::path path = libDir / fs::path(mod.metadata.id + + io::fs_path_to_string(fs::path(k_nativeLibName).extension())); std::error_code ec; if (!fs::is_regular_file(path, ec)) { return {}; @@ -300,7 +305,7 @@ std::filesystem::path ModLoader::external_native_lib_path(const LoadedMod& mod) void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { if (!EnableCodeMods) { - Log.error("Code mods are not available in this build"); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "Code mods are not available in this build"); mod.nativeStatus = NativeModStatus::BuildDisabled; return; } @@ -318,15 +323,15 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { } else if (auto external = external_native_lib_path(mod); !external.empty()) { libPath = std::move(external); } else { - Log.error("no native library named {} found in {}; skipping", k_nativeLibName, - mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "no native library named {} found; skipping", k_nativeLibName); mod.nativeStatus = NativeModStatus::ModMissingPlatform; return; } } else { if (dllEntry.empty()) { - Log.error("no native library named {} found in {}; skipping", k_nativeLibName, - mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "no native library named {} found; skipping", k_nativeLibName); mod.nativeStatus = NativeModStatus::ModMissingPlatform; return; } @@ -342,14 +347,15 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { try { dllData = mod.bundle->readFile(dllEntry); } catch (const std::exception& e) { - Log.error("failed to extract {} from {}", dllEntry, mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to extract {}", dllEntry); return; } { std::ofstream out(dllCachePath, std::ios::binary | std::ios::out); if (!out) { - Log.error("failed to write {}", io::fs_path_to_string(dllCachePath)); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to write {}", + io::fs_path_to_string(dllCachePath)); return; } @@ -364,7 +370,8 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { try { nativeMod->handle = std::make_unique(libPath); } catch (const std::runtime_error& e) { - Log.error("failed to open {}: {}", io::fs_path_to_string(libPath), e.what()); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to open {}: {}", + io::fs_path_to_string(libPath), e.what()); return; } @@ -377,7 +384,8 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { if (!getManifest || !nativeMod->contextSymbol || !nativeMod->fn_initialize || !nativeMod->fn_update || !nativeMod->fn_shutdown) { - Log.error("{} missing required mod API exports; skipping", + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "{} missing required mod API exports; skipping", io::fs_path_to_string(libPath.filename())); mod.nativeStatus = NativeModStatus::MissingExport; return; @@ -486,9 +494,9 @@ static void warn_unpublished_deferred_exports(const LoadedMod& mod) { const auto* record = svc::find_service_record(serviceExport.service_id, serviceExport.major_version); if (record != nullptr && record->service == nullptr) { - Log.warn("'{}' declared deferred service '{}@{}' but never published it during " - "initialization", - mod.metadata.id, serviceExport.service_id, serviceExport.major_version); + log::write(mod.metadata.id, LOG_LEVEL_WARN, + "declared deferred service '{}@{}' but never published it during initialization", + serviceExport.service_id, serviceExport.major_version); } } } @@ -516,10 +524,10 @@ void ModLoader::try_load_mod( if (const auto* existing = find_mod(metadata.id)) { if (existing->searchDirIndex < searchDirIndex) { - Log.info("'{}' ({}) shadowed by higher-priority copy {}", metadata.id, + log::write(metadata.id, LOG_LEVEL_INFO, "{} shadowed by higher-priority copy {}", io::fs_path_to_string(modPath.filename()), existing->modPath); } else { - Log.error("mod with id '{}' already exists, not loading {}", metadata.id, + log::write(metadata.id, LOG_LEVEL_ERROR, "duplicate mod id, not loading {}", io::fs_path_to_string(modPath.filename())); } return; @@ -537,7 +545,7 @@ void ModLoader::try_load_mod( mod.context = std::make_unique(); mod.context->mod = &mod; mod.cvarIsEnabled = - std::make_unique >(mod_enabled_cvar_name(mod.metadata.id), true); + std::make_unique>(mod_enabled_cvar_name(mod.metadata.id), true); const auto [dllEntry, anyLibs] = locate_dll_in_bundle(*mod.bundle); if (anyLibs || (mod.inPlace && !external_native_lib_path(mod).empty())) { @@ -545,19 +553,18 @@ void ModLoader::try_load_mod( load_native(mod, dllEntry); if (mod.nativeStatus != NativeModStatus::Loaded) { Log.error("Native mod '{}' failed to load, disabling", mod.metadata.id); - mod.active = false; - mod.loadFailed = true; - mod.failureReason = native_status_message(mod.nativeStatus); + fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus)); } else { mod.manifestInfo = build_manifest_info(*mod.native->manifest); } } - Log.info("found '{}' ('{}') v{} by {} ({})", mod.metadata.name, mod.metadata.id, + log::write(mod.metadata.id, LOG_LEVEL_INFO, "found '{}' v{} by {} ({})", mod.metadata.name, mod.metadata.version, mod.metadata.author, io::fs_path_to_string(modPath.filename())); } bool ModLoader::activate_mod(LoadedMod& mod) { + log::write(mod.metadata.id, LOG_LEVEL_INFO, "activating mod"); mod.active = true; // Asset-only mods have no lifecycle beyond their overlay files. @@ -568,33 +575,33 @@ bool ModLoader::activate_mod(LoadedMod& mod) { if (!mod.servicesRegistered) { if (!register_static_service_exports(mod)) { - Log.error("'{}' failed to register service exports", mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to register service exports"); return false; } mod.servicesRegistered = true; } if (!resolve_service_imports(mod)) { - Log.error("'{}' failed to resolve service imports", mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to resolve service imports"); return false; } *mod.native->contextSymbol = mod.context.get(); - Log.debug("Initializing '{}'", mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_TRACE, "calling mod_initialize"); try { ModError error = MOD_ERROR_INIT; const auto result = mod.native->fn_initialize(&error); if (result == MOD_OK && !mod.loadFailed) { mod.initialized = true; - Log.info("'{}' initialized", mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_TRACE, "mod_initialize succeeded"); } else { fail_mod(mod, result, lifecycle_error_message("mod_initialize", result, error)); } } catch (const std::exception& e) { - fail_mod(mod, MOD_ERROR, fmt::format("exception in mod_initialize: {}", e.what())); + fail_mod(mod, MOD_ERROR, fmt::format("Exception in mod_initialize: {}", e.what())); } catch (...) { - fail_mod(mod, MOD_ERROR, "unknown exception in mod_initialize"); + fail_mod(mod, MOD_ERROR, "Unknown exception in mod_initialize"); } warn_unpublished_deferred_exports(mod); @@ -611,11 +618,14 @@ bool ModLoader::activate_mod(LoadedMod& mod) { void ModLoader::deactivate_mod(LoadedMod& mod) { if (mod.initialized && mod.native && mod.native->fn_shutdown) { + log::write(mod.metadata.id, LOG_LEVEL_TRACE, "calling mod_shutdown"); try { ModError error = MOD_ERROR_INIT; const auto result = mod.native->fn_shutdown(&error); - if (result != MOD_OK) { - Log.error("mod_shutdown failed for '{}': {}", mod.metadata.id, + if (result == MOD_OK) { + log::write(mod.metadata.id, LOG_LEVEL_TRACE, "mod_shutdown succeeded"); + } else { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "mod_shutdown failed: {}", lifecycle_error_message("mod_shutdown", result, error)); } } catch (...) { @@ -719,7 +729,7 @@ void ModLoader::init() { if (register_static_service_exports(mod)) { mod.servicesRegistered = true; } else { - Log.error("'{}' failed to register service exports", mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to register service exports"); } } @@ -730,7 +740,7 @@ void ModLoader::init() { // Config-disabled mods keep their dependency edges but must not resolve until enabled. for (auto& mod : mods()) { if (!mod.cvarIsEnabled->getValue()) { - Log.info("Mod '{}' is disabled by config", mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_INFO, "disabled by config"); mod.active = false; if (mod.servicesRegistered) { svc::remove_services_for_provider(mod); @@ -753,7 +763,7 @@ void ModLoader::init() { m_startupComplete = true; } -LoadedMod* ModLoader::find_mod(std::string_view id) { +LoadedMod* ModLoader::find_mod(std::string_view id) const { for (auto& mod : mods()) { if (mod.metadata.id == id) { return &mod; @@ -778,23 +788,55 @@ void ModLoader::request_reload(std::string_view id) { m_pendingRequests.push_back({std::string{id}, RequestKind::Reload}); } -void ModLoader::notify_mod_failure(LoadedMod& mod) { - // Startup failures are handled inline by activate_mod; the queue only exists for failures - // raised while mod code may be on the stack (mod_update, hook callbacks, UI callbacks). +void ModLoader::notify_mod_failure(LoadedMod& mod, bool firstFailure) { + if (firstFailure) { + m_pendingFailures.push_back(mod.metadata.name); + } + // Startup failures are handled inline by activate_mod if (!m_startupComplete) { return; } m_pendingRequests.push_back({mod.metadata.id, RequestKind::Disable}); } +void ModLoader::flush_toasts() { + if (m_pendingFailures.empty()) { + return; + } + + const auto names = std::exchange(m_pendingFailures, {}); + + // Skip displaying toasts if the mods window is currently open + if (const auto* window = dynamic_cast(ui::top_document())) { + if (window->visible()) { + return; + } + } + + ui::Toast toast{.type = "warning", .duration = std::chrono::seconds{5}}; + if (names.size() == 1) { + toast.title = "Mod failed"; + toast.content = + fmt::format("
{} failed and was disabled.
Check Mods for " + "more information.
", + ui::escape(names.front())); + } else { + toast.title = "Mods failed"; + toast.content = fmt::format("
{} mods failed and were disabled.
Check " + "Mods for more information.
", + names.size()); + } + ui::push_toast(std::move(toast)); +} + static bool requiredDepsActive(const LoadedMod& mod) { return std::ranges::all_of(mod.dependencies, [](const ModDependencyEdge& edge) { return !edge.required || edge.mod->active; }); } std::vector ModLoader::collect_lifecycle_set(LoadedMod& target) { - std::vector included{&target}; - std::vector pending{&target}; + std::vector included{&target}; + std::vector pending{&target}; while (!pending.empty()) { auto* current = pending.back(); pending.pop_back(); @@ -843,7 +885,7 @@ bool ModLoader::ensure_native_loaded(LoadedMod& mod) { bool ModLoader::reload_bundle(LoadedMod& mod) { namespace fs = std::filesystem; - Log.info("reloading '{}' from {}", mod.metadata.id, mod.modPath); + log::write(mod.metadata.id, LOG_LEVEL_INFO, "reloading from {}", mod.modPath); std::shared_ptr newBundle; ModMetadata newMetadata; @@ -852,13 +894,13 @@ bool ModLoader::reload_bundle(LoadedMod& mod) { newBundle = load_bundle(mod.modPath, fs::is_directory(mod.modPath, ec)); newMetadata = load_metadata(mod.modPath, *newBundle); } catch (const std::exception& e) { - fail_mod(mod, MOD_ERROR, fmt::format("reload failed: {}", e.what())); + fail_mod(mod, MOD_ERROR, fmt::format("Reload failed: {}", e.what())); return false; } if (newMetadata.id != mod.metadata.id) { fail_mod(mod, MOD_CONFLICT, - fmt::format("mod id changed on reload ('{}'); restart the game", newMetadata.id)); + fmt::format("Mod ID changed on reload ('{}'); restart required", newMetadata.id)); return false; } @@ -885,8 +927,8 @@ bool ModLoader::reload_bundle(LoadedMod& mod) { if (newInfo != mod.manifestInfo) { // The reload changes the mod's imports/exports; rebuild the dependency graph so edges, // init/tick/shutdown order and cascade sets reflect the new manifest. - Log.info("'{}' changed its service imports/exports; rebuilding mod dependency graph", - mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_INFO, + "changed its service imports/exports; rebuilding mod dependency graph"); mod.manifestInfo = std::move(newInfo); loader::sort_mods(m_mods); } @@ -906,7 +948,7 @@ void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) { continue; } const bool wasActive = mod->active; - Log.info("deactivating '{}'", mod->metadata.id); + log::write(mod->metadata.id, LOG_LEVEL_INFO, "deactivating mod"); deactivate_mod(*mod); if (mod != &target && wasActive) { // Provisional; cleared below if the mod comes straight back up. @@ -953,7 +995,8 @@ void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) { } if (!requiredDepsActive(*mod)) { mod->suspendedByProvider = true; - Log.info("'{}' suspended: a required provider is disabled", mod->metadata.id); + log::write( + mod->metadata.id, LOG_LEVEL_INFO, "suspended: a required provider is disabled"); continue; } mod->suspendedByProvider = false; @@ -972,6 +1015,10 @@ void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) { void ModLoader::on_enabled_changed(LoadedMod& mod) { svc::config_mark_dirty(); if (mod.loadFailed) { + if (!mod.cvarIsEnabled->getValue()) { + mod.loadFailed = false; + mod.failureReason.clear(); + } return; } if (mod.suspendedByProvider) { @@ -1013,7 +1060,7 @@ void ModLoader::apply_pending_requests() { continue; } if (request.kind == RequestKind::Reload && mod->inPlace) { - Log.warn("'{}' is a built-in mod and can't be reloaded", mod->metadata.id); + log::write(mod->metadata.id, LOG_LEVEL_WARN, "is a built-in mod and can't be reloaded"); continue; } if (request.kind == RequestKind::Enable && mod->enabledApplied) { @@ -1046,13 +1093,14 @@ void ModLoader::tick() { fail_mod(mod, result, lifecycle_error_message("mod_update", result, error)); } } catch (const std::exception& e) { - fail_mod(mod, MOD_ERROR, fmt::format("exception in mod_update: {}", e.what())); + fail_mod(mod, MOD_ERROR, fmt::format("Exception in mod_update: {}", e.what())); } catch (...) { - fail_mod(mod, MOD_ERROR, "unknown exception in mod_update"); + fail_mod(mod, MOD_ERROR, "Unknown exception in mod_update"); } } svc::modules_frame_end(); + flush_toasts(); } void ModLoader::shutdown() { @@ -1069,7 +1117,6 @@ void ModLoader::shutdown() { m_mods.clear(); drain_retired_natives(); svc::modules_shutdown(); - clear_services(); Log.info("all mods unloaded"); } diff --git a/src/dusk/mods/log_buffer.cpp b/src/dusk/mods/log_buffer.cpp new file mode 100644 index 0000000000..bfee810767 --- /dev/null +++ b/src/dusk/mods/log_buffer.cpp @@ -0,0 +1,103 @@ +#include "log_buffer.hpp" + +#include +#include + +#include "dusk/logging.h" + +namespace dusk::mods::log { +namespace { + +struct BufferState { + std::mutex mutex; + std::vector ring; + size_t head = 0; + size_t count = 0; + uint64_t nextSeq = 0; + std::vector modIds; + + uint16_t intern(std::string_view modId) { + for (size_t i = 0; i < modIds.size(); ++i) { + if (modIds[i] == modId) { + return static_cast(i); + } + } + modIds.emplace_back(modId); + return static_cast(modIds.size() - 1); + } +}; +BufferState g_buffer; + +} // namespace + +void emit(Source source, const std::string& modId, LogLevel level, const std::string& message) { + const auto timeMs = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + + { + std::lock_guard lock{g_buffer.mutex}; + if (g_buffer.ring.empty()) { + g_buffer.ring.resize(k_capacity); + } + auto& slot = g_buffer.ring[(g_buffer.head + g_buffer.count) % k_capacity]; + if (g_buffer.count == k_capacity) { + g_buffer.head = (g_buffer.head + 1) % k_capacity; + } else { + ++g_buffer.count; + } + slot.seq = g_buffer.nextSeq++; + slot.timeMs = timeMs; + slot.level = level; + slot.modIndex = g_buffer.intern(modId); + slot.source = source; + // assign() reuses the slot's capacity once the ring has wrapped + slot.message.assign(message); + } + + AuroraLogLevel auroraLevel = LOG_INFO; + switch (level) { + case LOG_LEVEL_TRACE: + case LOG_LEVEL_DEBUG: + auroraLevel = LOG_DEBUG; + break; + case LOG_LEVEL_INFO: + auroraLevel = LOG_INFO; + break; + case LOG_LEVEL_WARN: + auroraLevel = LOG_WARNING; + break; + case LOG_LEVEL_ERROR: + auroraLevel = LOG_ERROR; + break; + } + if (aurora::g_config.logLevel <= auroraLevel) { + aurora::log_internal(auroraLevel, modId.c_str(), message.c_str(), + static_cast(message.length())); + } +} + +Range copy_since(uint64_t sinceSeq, std::vector& out) { + std::lock_guard lock{g_buffer.mutex}; + const Range range{ + .firstSeq = g_buffer.nextSeq - g_buffer.count, + .nextSeq = g_buffer.nextSeq, + }; + for (uint64_t seq = std::max(sinceSeq, range.firstSeq); seq < range.nextSeq; ++seq) { + out.push_back(g_buffer.ring[(g_buffer.head + (seq - range.firstSeq)) % k_capacity]); + } + return range; +} + +std::vector ids() { + std::lock_guard lock{g_buffer.mutex}; + return g_buffer.modIds; +} + +void clear() { + std::lock_guard lock{g_buffer.mutex}; + g_buffer.head = 0; + g_buffer.count = 0; +} + +} // namespace dusk::mods::log diff --git a/src/dusk/mods/log_buffer.hpp b/src/dusk/mods/log_buffer.hpp new file mode 100644 index 0000000000..33a143705d --- /dev/null +++ b/src/dusk/mods/log_buffer.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "mods/svc/log.h" + +namespace dusk::mods::log { + +constexpr size_t k_capacity = 2048; + +enum class Source : uint8_t { + Loader, + Mod, +}; + +struct Line { + uint64_t seq = 0; // monotonic, never reset (survives clear) + int64_t timeMs = 0; + LogLevel level = LOG_LEVEL_INFO; + uint16_t modIndex = 0; // index into ids() + Source source = Source::Loader; + std::string message; +}; + +struct Range { + uint64_t firstSeq = 0; // oldest retained entry + uint64_t nextSeq = 0; // one past the newest entry +}; + +// Appends to the mod log buffer and emits through aurora logging with the mod ID as the +// module tag. Console/file output honors the configured log level; the buffer captures +// every level. Thread-safe; mods may log from worker threads. +void emit(Source source, const std::string& modId, LogLevel level, const std::string& message); + +// Appends entries with seq >= sinceSeq to `out` and returns the retained range, +// so callers diffing by seq can detect both new entries and truncation. +Range copy_since(uint64_t sinceSeq, std::vector& out); + +// Append-only intern table of mod ids; indices are stable for the session. +std::vector ids(); + +void clear(); + +// Formatting helper for loader messages. +template +void write(const std::string& modId, LogLevel level, fmt::format_string format, T&&... args) { + emit(Source::Loader, modId, level, fmt::format(format, std::forward(args)...)); +} + +} // namespace dusk::mods::log diff --git a/src/dusk/mods/loader/manifest.cpp b/src/dusk/mods/manifest.cpp similarity index 100% rename from src/dusk/mods/loader/manifest.cpp rename to src/dusk/mods/manifest.cpp diff --git a/src/dusk/mods/loader/manifest.hpp b/src/dusk/mods/manifest.hpp similarity index 100% rename from src/dusk/mods/loader/manifest.hpp rename to src/dusk/mods/manifest.hpp diff --git a/src/dusk/mods/svc/config.cpp b/src/dusk/mods/svc/config.cpp index b7ea9c9e9d..cae6bf840e 100644 --- a/src/dusk/mods/svc/config.cpp +++ b/src/dusk/mods/svc/config.cpp @@ -385,9 +385,9 @@ ModResult config_subscribe(ModContext* context, ConfigVarHandle var, ConfigChang callback(modPtr->context.get(), varHandle, ¤tValue, &previousValue, userData); } catch (const std::exception& e) { fail_mod(*modPtr, MOD_ERROR, - fmt::format("exception in config change callback: {}", e.what())); + fmt::format("Exception in config change callback: {}", e.what())); } catch (...) { - fail_mod(*modPtr, MOD_ERROR, "unknown exception in config change callback"); + fail_mod(*modPtr, MOD_ERROR, "Unknown exception in config change callback"); } }); if (outHandle != nullptr) { diff --git a/src/dusk/mods/svc/hook.cpp b/src/dusk/mods/svc/hook.cpp index f565776183..1439a80619 100644 --- a/src/dusk/mods/svc/hook.cpp +++ b/src/dusk/mods/svc/hook.cpp @@ -1,7 +1,7 @@ #include "registry.hpp" #include "dusk/mods/loader/loader.hpp" -#include "dusk/mods/loader/manifest.hpp" +#include "dusk/mods/manifest.hpp" #include "mods/svc/hook.h" #if DUSK_CODE_MODS diff --git a/src/dusk/mods/svc/host.cpp b/src/dusk/mods/svc/host.cpp index 05971d5be1..5904903ee7 100644 --- a/src/dusk/mods/svc/host.cpp +++ b/src/dusk/mods/svc/host.cpp @@ -1,7 +1,7 @@ #include "registry.hpp" #include "dusk/mods/loader/loader.hpp" -#include "dusk/mods/loader/manifest.hpp" +#include "dusk/mods/manifest.hpp" #include "fmt/format.h" #include @@ -38,7 +38,7 @@ ModResult host_publish_service( void host_fail(ModContext* context, const ModResult code, const char* message) { auto* mod = mod_from_context(context); if (mod != nullptr) { - fail_mod(*mod, code, message != nullptr ? message : "mod reported failure"); + fail_mod(*mod, code, message != nullptr ? message : "Mod reported an unknown failure"); } } @@ -112,9 +112,9 @@ void host_mod_detached(LoadedMod& mod) { MOD_LIFECYCLE_DETACHED, watcher.userData); } catch (const std::exception& e) { fail_mod(*watcher.owner, MOD_ERROR, - fmt::format("exception in mod lifecycle callback: {}", e.what())); + fmt::format("Exception in mod lifecycle callback: {}", e.what())); } catch (...) { - fail_mod(*watcher.owner, MOD_ERROR, "unknown exception in mod lifecycle callback"); + fail_mod(*watcher.owner, MOD_ERROR, "Unknown exception in mod lifecycle callback"); } } } diff --git a/src/dusk/mods/svc/log.cpp b/src/dusk/mods/svc/log.cpp index d9fc88eae7..498a20aeaf 100644 --- a/src/dusk/mods/svc/log.cpp +++ b/src/dusk/mods/svc/log.cpp @@ -1,28 +1,14 @@ #include "registry.hpp" -#include "dusk/logging.h" #include "dusk/mods/loader/loader.hpp" +#include "dusk/mods/log_buffer.hpp" namespace dusk::mods::svc { namespace { void log_write(ModContext* context, const LogLevel level, const char* message) { const char* text = message != nullptr ? message : ""; - switch (level) { - case LOG_LEVEL_TRACE: - case LOG_LEVEL_DEBUG: - DuskLog.debug("[{}] {}", mod_id_from_context(context), text); - break; - case LOG_LEVEL_INFO: - DuskLog.info("[{}] {}", mod_id_from_context(context), text); - break; - case LOG_LEVEL_WARN: - DuskLog.warn("[{}] {}", mod_id_from_context(context), text); - break; - case LOG_LEVEL_ERROR: - DuskLog.error("[{}] {}", mod_id_from_context(context), text); - break; - } + log::emit(log::Source::Mod, mod_id_from_context(context), level, text); } void log_trace(ModContext* context, const char* message) { diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 0a2da3add3..0c7c61276a 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -1,5 +1,6 @@ #include "registry.hpp" +#include "dusk/app_info.hpp" #include "dusk/logging.h" #include "dusk/mods/loader/loader.hpp" @@ -22,7 +23,7 @@ std::string service_key(std::string_view id, const uint16_t majorVersion) { } const char* mod_id(const LoadedMod* mod) { - return mod != nullptr ? mod->metadata.id.c_str() : "dusklight"; + return mod != nullptr ? mod->metadata.id.c_str() : AppName; } bool validate_service_header(const ServiceHeader* header, const char* serviceId, @@ -45,6 +46,11 @@ bool validate_service_header(const ServiceHeader* header, const char* serviceId, return true; } +void clear_services() { + s_services.clear(); + s_modules.clear(); +} + } // namespace bool valid_service_id(const char* serviceId) { @@ -131,11 +137,6 @@ const ServiceRecord* find_service_record(const char* serviceId, const uint16_t m return it != s_services.end() ? &it->second : nullptr; } -void clear_services() { - s_services.clear(); - s_modules.clear(); -} - ModResult register_module(const ServiceModule& module) { const auto result = register_service( module.id, module.majorVersion, module.minorVersion, module.service, nullptr, false); @@ -187,6 +188,7 @@ void modules_shutdown() { module->shutdown(); } } + clear_services(); } } // namespace dusk::mods::svc @@ -222,13 +224,13 @@ bool ModLoader::register_static_service_exports(LoadedMod& mod) { if (serviceExport.struct_size != sizeof(ServiceExport) || !svc::valid_service_id(serviceExport.service_id)) { - fail_mod(mod, MOD_INVALID_ARGUMENT, "invalid service export descriptor"); + fail_mod(mod, MOD_INVALID_ARGUMENT, "Invalid service export descriptor"); return false; } const bool deferred = (serviceExport.flags & SERVICE_EXPORT_DEFERRED) != 0; if (!deferred && serviceExport.service == nullptr) { - fail_mod(mod, MOD_INVALID_ARGUMENT, "static service export has null service pointer"); + fail_mod(mod, MOD_INVALID_ARGUMENT, "Static service export has null service pointer"); return false; } @@ -236,7 +238,7 @@ bool ModLoader::register_static_service_exports(LoadedMod& mod) { svc::register_service(serviceExport.service_id, serviceExport.major_version, serviceExport.minor_version, serviceExport.service, &mod, deferred); if (result != MOD_OK) { - fail_mod(mod, result, "service export registration failed"); + fail_mod(mod, result, "Service export registration failed"); return false; } } @@ -248,10 +250,10 @@ std::string ModLoader::describe_missing_import( const char* serviceId, const uint16_t majorVersion, const uint16_t minMinorVersion) const { if (const auto* record = svc::find_service_record(serviceId, majorVersion)) { if (record->service == nullptr) { - return fmt::format("required service {}@{} was never published by provider '{}'", + return fmt::format("Required service {}@{} was never published by provider '{}'", serviceId, majorVersion, svc::mod_id(record->provider)); } - return fmt::format("required service {}@{} only provides minor version {} (need >= {})", + return fmt::format("Required service {}@{} only provides minor version {} (need >= {})", serviceId, majorVersion, record->minorVersion, minMinorVersion); } @@ -270,14 +272,14 @@ std::string ModLoader::describe_missing_import( std::string_view{serviceExport.service_id} == serviceId && serviceExport.major_version == majorVersion) { - return fmt::format("required service {}@{} unavailable: provider '{}' {}", + return fmt::format("Required service {}@{} unavailable: provider '{}' {}", serviceId, majorVersion, other.metadata.id, other.loadFailed ? "failed to load" : "is disabled"); } } } - return fmt::format("required service unavailable: {}@{}", serviceId, majorVersion); + return fmt::format("Required service unavailable: {}@{}", serviceId, majorVersion); } bool ModLoader::resolve_service_imports(LoadedMod& mod) { @@ -291,7 +293,7 @@ bool ModLoader::resolve_service_imports(LoadedMod& mod) { if (serviceImport.struct_size != sizeof(ServiceImport) || !svc::valid_service_id(serviceImport.service_id) || serviceImport.slot == nullptr) { - fail_mod(mod, MOD_INVALID_ARGUMENT, "invalid service import descriptor"); + fail_mod(mod, MOD_INVALID_ARGUMENT, "Invalid service import descriptor"); return false; } @@ -315,12 +317,4 @@ bool ModLoader::resolve_service_imports(LoadedMod& mod) { return true; } -void ModLoader::clear_services() { - svc::clear_services(); -} - -void ModLoader::fail_mod(LoadedMod& mod, const ModResult code, std::string_view message) { - mods::fail_mod(mod, code, message); -} - } // namespace dusk::mods diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index bdbe2c2e72..e8d613057e 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -53,7 +53,6 @@ const ServiceRecord* find_service( const char* serviceId, uint16_t majorVersion, uint16_t minMinorVersion); // Unlike find_service, also returns deferred records that have not been published yet. const ServiceRecord* find_service_record(const char* serviceId, uint16_t majorVersion); -void clear_services(); ModResult register_module(const ServiceModule& module); void modules_mod_detached(LoadedMod& mod); diff --git a/src/dusk/ui/logs_window.cpp b/src/dusk/ui/logs_window.cpp new file mode 100644 index 0000000000..9fdd89e50d --- /dev/null +++ b/src/dusk/ui/logs_window.cpp @@ -0,0 +1,301 @@ +#include "logs_window.hpp" + +#include +#include + +#include +#include + +#include "pane.hpp" + +namespace dusk::ui { +namespace { + +const char* level_name(LogLevel level) { + switch (level) { + case LOG_LEVEL_TRACE: + return "Trace"; + case LOG_LEVEL_DEBUG: + return "Debug"; + case LOG_LEVEL_INFO: + return "Info"; + case LOG_LEVEL_WARN: + return "Warn"; + case LOG_LEVEL_ERROR: + return "Error"; + } + return "?"; +} + +const char* level_logger_name(LogLevel level) { + switch (level) { + case LOG_LEVEL_TRACE: + return "TRACE"; + case LOG_LEVEL_DEBUG: + return "DEBUG"; + case LOG_LEVEL_INFO: + return "INFO"; + case LOG_LEVEL_WARN: + return "WARNING"; + case LOG_LEVEL_ERROR: + return "ERROR"; + } + return "?"; +} + +const char* level_class(LogLevel level) { + switch (level) { + case LOG_LEVEL_TRACE: + return "lvl-trace"; + case LOG_LEVEL_DEBUG: + return "lvl-debug"; + case LOG_LEVEL_INFO: + return "lvl-info"; + case LOG_LEVEL_WARN: + return "lvl-warn"; + case LOG_LEVEL_ERROR: + return "lvl-error"; + } + return "lvl-info"; +} + +std::string format_time(int64_t timeMs) { + const auto seconds = static_cast(timeMs / 1000); + std::tm localTime{}; +#if _WIN32 + localtime_s(&localTime, &seconds); +#else + localtime_r(&seconds, &localTime); +#endif + std::array buffer{}; + std::strftime(buffer.data(), buffer.size(), "%H:%M:%S", &localTime); + return fmt::format("{}.{:03}", buffer.data(), timeMs % 1000); +} + +void append_text(Rml::ElementDocument* doc, Rml::Element* parent, const Rml::String& text) { + parent->AppendChild(doc->CreateTextNode(text)); +} + +Rml::Element* append_span(Rml::ElementDocument* doc, Rml::Element* parent, const char* className, + const Rml::String& text) { + auto span = doc->CreateElement("span"); + span->SetClass(className, true); + append_text(doc, span.get(), text); + return parent->AppendChild(std::move(span)); +} + +} // namespace + +LogsWindow::LogsWindow(std::string modFilter) + : Window{Props{.tabBar = false, .styleSheets = {"res/rml/logs.rcss"}}}, + mModFilter{std::move(modFilter)} { + mRoot->SetClass("logs", true); + set_content([this](Rml::Element* content) { build_content(content); }); +} + +void LogsWindow::build_content(Rml::Element* content) { + auto* toolbar = append(content, "div"); + toolbar->SetClass("log-toolbar", true); + + auto* title = append(toolbar, "div"); + title->SetClass("log-title", true); + title->SetInnerRML("Logs"); + + auto* modLabel = append(toolbar, "div"); + modLabel->SetClass("log-title-mod", true); + modLabel->SetInnerRML(mModFilter.empty() ? "All mods" : fmt::format("{}", escape(mModFilter))); + + append(toolbar, "div")->SetClass("log-toolbar-spacer", true); + + for (const LogLevel level : + {LOG_LEVEL_TRACE, LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_WARN, LOG_LEVEL_ERROR}) + { + add_child(toolbar, + ControlledButton::Props{ + .text = level_name(level), + .isSelected = [this, level] { return mMinLevel <= level; }, + }) + .on_pressed([this, level] { + mMinLevel = level; + rebuild_lines(); + }); + } + + append(toolbar, "div")->SetClass("log-toolbar-spacer", true); + + add_child