diff --git a/.gitignore b/.gitignore index 9a83657357a..27753204551 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,5 @@ Data/*.big Platform/MacOS/Build/Logs/ *.gif +# Firebase Hosting (maintainer-local deployment) +/Dependencies/general_online_zh/ diff --git a/Core/GameEngine/Include/Common/Debug.h b/Core/GameEngine/Include/Common/Debug.h index fed652afdb4..87dc353752f 100644 --- a/Core/GameEngine/Include/Common/Debug.h +++ b/Core/GameEngine/Include/Common/Debug.h @@ -281,9 +281,11 @@ class SimpleProfiler #define DEBUG_CRASH_MAC(m) ((void)0) #endif -#define DEBUG_BUILDMAPCACHE_FLAG -#define DEBUG_INFO_MAC_FLAG -#define DEBUG_FILESYSTEM_MAC_FLAG +// #define DEBUG_BUILDMAPCACHE_FLAG +// #define DEBUG_INFO_MAC_FLAG +// #define DEBUG_FILESYSTEM_MAC_FLAG +// #define DEBUG_RENDER_CORE_MAC_FLAG +// #define DEBUG_NETWORK_MAC_FLAG #ifdef DEBUG_BUILDMAPCACHE_FLAG #define DEBUG_BUILDMAPCACHE(m) MAC_LOG_TAG("DEBUG_BUILDMAPCACHE", m) @@ -303,3 +305,15 @@ class SimpleProfiler #define DEBUG_FILESYSTEM_MAC(m) ((void)0) #endif +#ifdef DEBUG_RENDER_CORE_MAC_FLAG + #define DEBUG_RENDER_CORE_MAC(m) MAC_LOG_TAG("DEBUG_RENDER_CORE_MAC", m) +#else + #define DEBUG_RENDER_CORE_MAC(m) ((void)0) +#endif + +#ifdef DEBUG_NETWORK_MAC_FLAG + #define DEBUG_NETWORK_MAC(m) MAC_LOG_TAG("DEBUG_NETWORK_MAC", m) +#else + #define DEBUG_NETWORK_MAC(m) ((void)0) +#endif + diff --git a/Core/GameEngine/Source/Common/System/UnicodeString.cpp b/Core/GameEngine/Source/Common/System/UnicodeString.cpp index 386778d321b..a27fa4e6731 100644 --- a/Core/GameEngine/Source/Common/System/UnicodeString.cpp +++ b/Core/GameEngine/Source/Common/System/UnicodeString.cpp @@ -394,7 +394,52 @@ void UnicodeString::format_va(const WideChar* format, va_list args) { validate(); WideChar buf[MAX_FORMAT_BUF_LEN]; + +#ifdef __APPLE__ + WideChar fixedFmt[MAX_FORMAT_BUF_LEN]; + size_t di = 0; + for (size_t si = 0; format[si] != 0 && di < MAX_FORMAT_BUF_LEN - 2; ) { + if (format[si] == L'%') { + if (format[si+1] == L'%') { + fixedFmt[di++] = format[si++]; + fixedFmt[di++] = format[si++]; + continue; + } + fixedFmt[di++] = format[si++]; + while (format[si] == L'-' || format[si] == L'+' || format[si] == L'0' || + format[si] == L' ' || format[si] == L'#') { + fixedFmt[di++] = format[si++]; + } + while ((format[si] >= L'0' && format[si] <= L'9') || format[si] == L'*') { + fixedFmt[di++] = format[si++]; + } + if (format[si] == L'.') { + fixedFmt[di++] = format[si++]; + while ((format[si] >= L'0' && format[si] <= L'9') || format[si] == L'*') { + fixedFmt[di++] = format[si++]; + } + } + if (format[si] == L'h' || format[si] == L'l') { + fixedFmt[di++] = format[si++]; + if (format[si] == L'h' || format[si] == L'l') fixedFmt[di++] = format[si++]; + fixedFmt[di++] = format[si++]; + continue; + } + if (format[si] == L's') { + fixedFmt[di++] = L'l'; + fixedFmt[di++] = format[si++]; + } else { + fixedFmt[di++] = format[si++]; + } + } else { + fixedFmt[di++] = format[si++]; + } + } + fixedFmt[di] = 0; + const int result = vswprintf(buf, sizeof(buf)/sizeof(WideChar), fixedFmt, args); +#else const int result = vswprintf(buf, sizeof(buf)/sizeof(WideChar), format, args); +#endif if (result >= 0) { set(buf); diff --git a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DShaderManager.cpp b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DShaderManager.cpp index 9520cecef1f..cce8f9be026 100644 --- a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DShaderManager.cpp +++ b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DShaderManager.cpp @@ -2606,12 +2606,15 @@ void W3DShaderManager::init() if ((res=W3DShaderManager::getChipset()) != 0) { m_currentChipset = res; //cache the current chipset. + DEBUG_RENDER_CORE_MAC(("W3DShaderManager::init chipset=%d", (int)res)); //Some of our effects require an offscreen render target, so try creating it here. HRESULT hr=DX8Wrapper::_Get_D3D_Device8()->GetRenderTarget(&m_oldRenderSurface); - if (hr != S_OK || !m_oldRenderSurface) + if (hr != S_OK || !m_oldRenderSurface) { + DEBUG_RENDER_CORE_MAC(("W3DShaderManager::init EARLY EXIT: GetRenderTarget hr=0x%x surf=%p", (unsigned)hr, m_oldRenderSurface)); return; + } m_oldRenderSurface->GetDesc(&desc); @@ -2620,9 +2623,11 @@ void W3DShaderManager::init() if (desc.MultiSampleType == D3DMULTISAMPLE_NONE) { hr=DX8Wrapper::_Get_D3D_Device8()->CreateTexture(desc.Width,desc.Height,1,D3DUSAGE_RENDERTARGET,desc.Format,D3DPOOL_DEFAULT,&m_renderTexture); + DEBUG_RENDER_CORE_MAC(("RTT CreateTexture (no MSAA) hr=0x%x tex=%p", (unsigned)hr, m_renderTexture)); } else { + DEBUG_RENDER_CORE_MAC(("RTT SKIPPED: MSAA type=%u", (unsigned)desc.MultiSampleType)); // Force failure path to avoid MSAA mismatch hr = E_FAIL; } @@ -2935,6 +2940,8 @@ ChipsetType W3DShaderManager::getChipset() ChipsetType chip=DC_UNKNOWN; IDirect3D8* d3d8Interface=DX8Wrapper::_Get_D3D8(); + DEBUG_RENDER_CORE_MAC(("getChipset: d3d8=%p device=%p globalOverride=%d", d3d8Interface, DX8Wrapper::_Get_D3D_Device8(), TheGlobalData ? TheGlobalData->m_chipSetType : -1)); + if (d3d8Interface && DX8Wrapper::_Get_D3D_Device8()) { @@ -2943,6 +2950,8 @@ ChipsetType W3DShaderManager::getChipset() /* HRESULT res = */ d3d8Interface->GetAdapterIdentifier(0,D3DENUM_NO_WHQL_LEVEL,&did); *((LARGE_INTEGER*)&m_driverVersion) = did.DriverVersion; + DEBUG_RENDER_CORE_MAC(("getChipset: VendorId=0x%x DeviceId=0x%x", did.VendorId, did.DeviceId)); + if(did.VendorId == DC_NVIDIA_VENDOR_ID) { m_currentVendor = DC_NVIDIA_VENDOR_ID; @@ -3001,6 +3010,8 @@ ChipsetType W3DShaderManager::getChipset() sprintf(buf,"%d.%d",DX8Wrapper::Get_Current_Caps()->Get_Pixel_Shader_Major_Version(),DX8Wrapper::Get_Current_Caps()->Get_Pixel_Shader_Minor_Version()); sscanf(buf,"%f",&pixelShaderVersion); + DEBUG_RENDER_CORE_MAC(("getChipset: maxTex=%d psVer=%f psMajor=%d psMinor=%d", maxTextures, pixelShaderVersion, DX8Wrapper::Get_Current_Caps()->Get_Pixel_Shader_Major_Version(), DX8Wrapper::Get_Current_Caps()->Get_Pixel_Shader_Minor_Version())); + if (maxTextures >= 4) { if (pixelShaderVersion >= 1.1f) chip=DC_GENERIC_PIXEL_SHADER_1_1; @@ -3009,6 +3020,8 @@ ChipsetType W3DShaderManager::getChipset() if (maxTextures >= 8 && pixelShaderVersion >= 2.0f) chip=DC_GENERIC_PIXEL_SHADER_2_0; } + + DEBUG_RENDER_CORE_MAC(("getChipset: result=%d", (int)chip)); } return chip; @@ -3021,6 +3034,7 @@ ChipsetType W3DShaderManager::getChipset() //============================================================================= HRESULT W3DShaderManager::LoadAndCreateD3DShader(const char* strFilePath, const DWORD* pDeclaration, DWORD Usage, Bool ShaderType, DWORD* pHandle) { + DEBUG_RENDER_CORE_MAC(("LoadAndCreateD3DShader: '%s' type=%s chipset=%d", strFilePath, ShaderType ? "VS" : "PS", (int)getChipset())); if (getChipset() < DC_GENERIC_PIXEL_SHADER_1_1) return E_FAIL; //don't allow loading any shaders if hardware can't handle it. diff --git a/Core/GameEngineDevice/Source/W3DDevice/GameClient/Water/W3DWater.cpp b/Core/GameEngineDevice/Source/W3DDevice/GameClient/Water/W3DWater.cpp index 74fdaaac696..ef97853809c 100644 --- a/Core/GameEngineDevice/Source/W3DDevice/GameClient/Water/W3DWater.cpp +++ b/Core/GameEngineDevice/Source/W3DDevice/GameClient/Water/W3DWater.cpp @@ -1001,6 +1001,7 @@ Int WaterRenderObjClass::init(Real waterLevel, Real dx, Real dy, SceneClass *par m_parentScene=parentScene; m_waterType = type; + DEBUG_RENDER_CORE_MAC(("W3DWater::init waterType=%d (0=TRANSLUCENT,1=FB_REFL,2=PVSHADER,3=GRIDMESH)", (int)type)); /// Hack for now //m_waterType = WATER_TYPE_0_TRANSLUCENT; diff --git a/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.cpp b/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.cpp index ac2c6e8d838..e71fa3a18cc 100644 --- a/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.cpp +++ b/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.cpp @@ -631,6 +631,11 @@ Render2DSentenceClass::Record_Sentence_Chunk () void Render2DSentenceClass::Allocate_New_Surface (const WCHAR *text, bool justCalcExtents) { +#ifdef __APPLE__ + if (text == nullptr) { + return; + } +#endif if (!justCalcExtents) { // @@ -991,6 +996,11 @@ void Render2DSentenceClass::Build_Sentence_Centered (const WCHAR *text, int *hkX //////////////////////////////////////////////////////////////////////////////////// Vector2 Render2DSentenceClass::Build_Sentence_Not_Centered (const WCHAR *text, int *hkX, int *hkY, bool justCalcExtents) { +#ifdef __APPLE__ + if (text == nullptr) { + return Vector2(0.0f, 0.0f); + } +#endif Vector2 cursor = Cursor; int textureStartX = TextureStartX; float maxX = 0; diff --git a/Dependencies/fdlibm-deterministic b/Dependencies/fdlibm-deterministic new file mode 160000 index 00000000000..02d7d2c44b8 --- /dev/null +++ b/Dependencies/fdlibm-deterministic @@ -0,0 +1 @@ +Subproject commit 02d7d2c44b88174e092be6d033a86acdf4888fd2 diff --git a/Generals/Code/Libraries/Source/WWVegas/WW3D2/part_emt.cpp b/Generals/Code/Libraries/Source/WWVegas/WW3D2/part_emt.cpp index cb82ff508dd..4d41b144234 100644 --- a/Generals/Code/Libraries/Source/WWVegas/WW3D2/part_emt.cpp +++ b/Generals/Code/Libraries/Source/WWVegas/WW3D2/part_emt.cpp @@ -156,7 +156,11 @@ ParticleEmitterClass::ParticleEmitterClass(const ParticleEmitterClass & src) : FirstTime = true; IsComplete = false; +#ifdef __APPLE__ + NameString = src.NameString ? ::_strdup (src.NameString) : nullptr; +#else NameString = ::_strdup (src.NameString); +#endif } diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBar.h b/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBar.h index c98ff132065..aec9ae4fbac 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBar.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBar.h @@ -773,6 +773,11 @@ class ControlBar : public SubsystemInterface void initSpecialPowershortcutBar( Player *player); +#ifdef __APPLE__ + // TheSuperHackers @feature okji 26/04/2026 Reposition right-edge-anchored UI during gameplay resize + void repositionForResolution(Int oldW, Int newW); +#endif + void triggerRadarAttackGlow(); void drawSpecialPowerShortcutMultiplierText(); diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp index 057d085adcd..e2f18b3a819 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp @@ -1288,6 +1288,10 @@ void GlobalData::parseGameDataDefinition( INI* ini ) } } } + // TheSuperHackers @tweak macOS: Force the game to launch in windowed mode + // to prevent UI layout desynchronization issues. The user can toggle + // to fullscreen in-game via the Options menu or Cmd+Enter. + TheWritableGlobalData->m_windowed = true; #endif TheWritableGlobalData->m_xResolution = xres; diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp index 4108346857b..2f061907967 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp @@ -3772,6 +3772,42 @@ void ControlBar::hideSpecialPowerShortcut() } +#ifdef __APPLE__ +// TheSuperHackers @feature okji 26/04/2026 Reposition right-edge-anchored UI +// elements (special power shortcut bar, right HUD) when the display resolution +// changes during gameplay. Full recreateControlBar() crashes mid-game, so this +// lightweight approach only adjusts X coordinates by the width delta. +void ControlBar::repositionForResolution(Int oldW, Int newW) +{ + if (oldW == newW || oldW == 0) return; + + if (m_specialPowerShortcutParent) { + if (m_animateWindowManagerForGenShortcuts + && !m_animateWindowManagerForGenShortcuts->isEmpty()) { + m_animateWindowManagerForGenShortcuts->reset(); + } + + Int x, y, w, h; + m_specialPowerShortcutParent->winGetPosition(&x, &y); + m_specialPowerShortcutParent->winGetSize(&w, &h); + Int newW_panel = static_cast(static_cast(w) * newW / oldW); + Int newX = newW - newW_panel; + m_specialPowerShortcutParent->winSetPosition(newX, y); + m_specialPowerShortcutParent->winSetSize(newW_panel, h); + } + + if (m_rightHUDWindow) { + Int x, y, w, h; + m_rightHUDWindow->winGetPosition(&x, &y); + m_rightHUDWindow->winGetSize(&w, &h); + Int newX = static_cast(static_cast(x) * newW / oldW); + Int newW_hud = static_cast(static_cast(w) * newW / oldW); + m_rightHUDWindow->winSetPosition(newX, y); + m_rightHUDWindow->winSetSize(newW_hud, h); + } +} +#endif + void ControlBar::setFullViewportHeight() { TheTacticalView->setHeight(TheDisplay->getHeight()); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLBuddyOverlay.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLBuddyOverlay.cpp index 397a4321b92..18ccf37660e 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLBuddyOverlay.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLBuddyOverlay.cpp @@ -1970,7 +1970,13 @@ WindowMsgHandledType WOLBuddyOverlayRCMenuSystem( GameWindow *window, UnsignedIn DEBUG_LOG(("buttonStatsID was pushed")); #if defined(GENERALS_ONLINE) +#ifdef __APPLE__ + UnicodeString uniNick; + uniNick.translate(nick); + SetLookAtPlayer(profileID, uniNick); +#else SetLookAtPlayer(profileID, UnicodeString(from_utf8(nick.str()).c_str())); +#endif GameSpyOpenOverlay(GSOVERLAY_PLAYERINFO); #else SetLookAtPlayer(profileID, nick); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GameClient.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GameClient.cpp index f3eeb329de8..ad3d5bc33b5 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GameClient.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GameClient.cpp @@ -795,8 +795,16 @@ void GameClient::update() } #if defined(GENERALS_ONLINE_HIGH_FPS_RENDER) +#ifdef __APPLE__ int64_t currTime = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); - m_legacyFrameMSAccured += currTime - m_LegacyFrameEndLastFrame; +#else + int64_t currTime = std::chrono::duration_cast(std::chrono::utc_clock::now().time_since_epoch()).count(); +#endif + + if (!freezeTime) + { + m_legacyFrameMSAccured += currTime - m_LegacyFrameEndLastFrame; + } m_LegacyFrameEndLastFrame = currTime; // TODO_NGMP: This should really use partial frame intervals instead of a fixed 60hz update diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp index 33b4b5f4416..bded7b4c17a 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -389,15 +389,18 @@ void NGMP_OnlineServicesManager::FetchMacParityCRC(std::function fnC VersionManifestResponse manifestResp = jsonObject.get(); rawExeCRC = manifestResp.execrc_60; NetworkLog(ELogVerbosity::LOG_RELEASE, "VERSION MANIFEST: Dynamic CRC obtained: %llu", (unsigned long long)rawExeCRC); + DEBUG_NETWORK_MAC(("VERSION MANIFEST: Dynamic CRC obtained: %llu", (unsigned long long)rawExeCRC)); } catch (...) { NetworkLog(ELogVerbosity::LOG_RELEASE, "VERSION MANIFEST: Failed to parse response. Body: %s", strBody.c_str()); + DEBUG_NETWORK_MAC(("VERSION MANIFEST: Failed to parse response. Body: %s", strBody.c_str())); } } else { NetworkLog(ELogVerbosity::LOG_RELEASE, "VERSION MANIFEST: Failed to get manifest (status %d)", statusCode); + DEBUG_NETWORK_MAC(("VERSION MANIFEST: Failed to get manifest (status %d)", statusCode)); } fnCallback(rawExeCRC); @@ -460,20 +463,24 @@ void NGMP_OnlineServicesManager::StartVersionCheck(std::functionGetHTTPManager()->SendPOSTRequest(strURI.c_str(), EIPProtocolVersion::DONT_CARE, mapHeaders, strPostData.c_str(), [=](bool bSuccess, int statusCode, std::string strBody, HTTPRequest* pReq) { NetworkLog(ELogVerbosity::LOG_RELEASE, "Version Check: Response code was %d and body was %s", statusCode, strBody.c_str()); + DEBUG_NETWORK_MAC(("Version Check: Response code was %d and body was %s", statusCode, strBody.c_str())); try { NetworkLog(ELogVerbosity::LOG_RELEASE, "VERSION CHECK: Up To Date"); + DEBUG_NETWORK_MAC(("VERSION CHECK: Up To Date")); nlohmann::json jsonObject = nlohmann::json::parse(strBody); VersionCheckResponse authResp = jsonObject.get(); if (authResp.result == EVersionCheckResponseResult::OK) { NetworkLog(ELogVerbosity::LOG_RELEASE, "VERSION CHECK: Up To Date"); + DEBUG_NETWORK_MAC(("VERSION CHECK: Up To Date")); fnCallback(true, false); } else if (authResp.result == EVersionCheckResponseResult::NEEDS_UPDATE) { NetworkLog(ELogVerbosity::LOG_RELEASE, "VERSION CHECK: Needs Update"); + DEBUG_NETWORK_MAC(("VERSION CHECK: Needs Update")); // cache the data m_patcher_name = authResp.patcher_name; @@ -485,12 +492,14 @@ void NGMP_OnlineServicesManager::StartVersionCheck(std::functiongetBitDepth(); - Bool windowed = TheDisplay->getWindowed(); - printf("[MacOS] ApplyDisplayResolution: %dx%d (bitDepth=%d windowed=%d)\n", w, h, bitDepth, windowed); + printf("[MacOS] ApplyDisplayResolution: %dx%d (bitDepth=%d windowed=%d)\n", w, h, bitDepth, isWindowed); fflush(stdout); - if (!TheDisplay->setDisplayMode(w, h, bitDepth, windowed)) { + if (!TheDisplay->setDisplayMode(w, h, bitDepth, isWindowed)) { printf("[MacOS] ApplyDisplayResolution: setDisplayMode FAILED, aborting\n"); fflush(stdout); return; } + Int oldXRes = TheWritableGlobalData->m_xResolution; + TheWritableGlobalData->m_xResolution = w; TheWritableGlobalData->m_yResolution = h; + TheWritableGlobalData->m_windowed = isWindowed; if (TheHeaderTemplateManager) { TheHeaderTemplateManager->onResolutionChanged(); @@ -3450,12 +3453,19 @@ extern "C" void MacOS_ApplyDisplayResolution(int w, int h) { TheInGameUI->recreateControlBar(); TheInGameUI->refreshCustomUiResources(); } + } else if (TheControlBar) { + // TheSuperHackers @feature okji 26/04/2026 Reposition right-edge-anchored + // UI elements during gameplay resize (shortcut bar, right HUD). + TheControlBar->repositionForResolution(oldXRes, w); } OptionPreferences pref; - AsciiString resStr; - resStr.format("%d %d", w, h); - pref["Resolution"] = resStr; + if (isWindowed) { + AsciiString resStr; + resStr.format("%d %d", w, h); + pref["Resolution"] = resStr; + } + pref["Windowed"] = isWindowed ? "yes" : "no"; pref.write(); printf("[MacOS] ApplyDisplayResolution: completed %dx%d (shell=%s)\n", diff --git a/GeneralsMD/Code/Libraries/Source/WWVegas/WW3D2/dx8caps.cpp b/GeneralsMD/Code/Libraries/Source/WWVegas/WW3D2/dx8caps.cpp index 25e8a6d3051..6bc0c4b5822 100644 --- a/GeneralsMD/Code/Libraries/Source/WWVegas/WW3D2/dx8caps.cpp +++ b/GeneralsMD/Code/Libraries/Source/WWVegas/WW3D2/dx8caps.cpp @@ -1188,37 +1188,28 @@ DX8Caps::DX8Caps( MaxDisplayWidth(0), MaxDisplayHeight(0) { - memset(&Caps, 0, sizeof(Caps)); - Caps.MaxSimultaneousTextures = 8; - Caps.MaxTextureWidth = 4096; - Caps.MaxTextureHeight = 4096; - Caps.MaxTextureBlendStages = 8; - Caps.MaxPointSize = 256.0f; - Caps.RasterCaps = D3DPRASTERCAPS_ZBIAS | D3DPRASTERCAPS_FOGRANGE; - Caps.Caps2 = D3DCAPS2_FULLSCREENGAMMA; - Caps.TextureOpCaps = 0xFFFFFFFF; - Caps.TextureCaps = D3DPTEXTURECAPS_CUBEMAP; - Caps.TextureFilterCaps = D3DPTFILTERCAPS_MAGFANISOTROPIC | D3DPTFILTERCAPS_MINFANISOTROPIC; - Caps.DevCaps = D3DDEVCAPS_HWTRANSFORMANDLIGHT; - - SupportTnL = true; + D3DDevice->GetDeviceCaps(&Caps); + + SupportTnL = (Caps.DevCaps & D3DDEVCAPS_HWTRANSFORMANDLIGHT) != 0; SupportDXTC = true; - supportGamma = true; + supportGamma = (Caps.Caps2 & D3DCAPS2_FULLSCREENGAMMA) != 0; SupportNPatches = false; - SupportBumpEnvmap = true; - SupportBumpEnvmapLuminance = true; - SupportZBias = true; - SupportAnisotropicFiltering = true; - SupportModAlphaAddClr = true; - SupportDot3 = true; - SupportPointSprites = true; - SupportCubemaps = true; + SupportBumpEnvmap = (Caps.TextureOpCaps & D3DTEXOPCAPS_BUMPENVMAP) != 0; + SupportBumpEnvmapLuminance = (Caps.TextureOpCaps & D3DTEXOPCAPS_BUMPENVMAPLUMINANCE) != 0; + SupportZBias = (Caps.RasterCaps & D3DPRASTERCAPS_ZBIAS) != 0; + SupportAnisotropicFiltering = + (Caps.TextureFilterCaps & D3DPTFILTERCAPS_MAGFANISOTROPIC) && + (Caps.TextureFilterCaps & D3DPTFILTERCAPS_MINFANISOTROPIC); + SupportModAlphaAddClr = (Caps.TextureOpCaps & D3DTEXOPCAPS_MODULATEALPHA_ADDCOLOR) != 0; + SupportDot3 = (Caps.TextureOpCaps & D3DTEXOPCAPS_DOTPRODUCT3) != 0; + SupportPointSprites = (Caps.MaxPointSize > 1.0f); + SupportCubemaps = (Caps.TextureCaps & D3DPTEXTURECAPS_CUBEMAP) != 0; CanDoMultiPass = true; IsFogAllowed = true; - MaxTexturesPerPass = 8; - VertexShaderVersion = 0; - PixelShaderVersion = 0; - MaxSimultaneousTextures = 8; + MaxTexturesPerPass = Caps.MaxSimultaneousTextures; + VertexShaderVersion = Caps.VertexShaderVersion; + PixelShaderVersion = Caps.PixelShaderVersion; + MaxSimultaneousTextures = Caps.MaxSimultaneousTextures; DeviceId = 0; DriverBuildVersion = 0; DriverVersionStatus = DRIVER_STATUS_GOOD; @@ -1248,24 +1239,26 @@ DX8Caps::DX8Caps( MaxDisplayWidth(0), MaxDisplayHeight(0) { - SupportTnL = true; + SupportTnL = (Caps.DevCaps & D3DDEVCAPS_HWTRANSFORMANDLIGHT) != 0; CanDoMultiPass = true; IsFogAllowed = true; SupportDXTC = true; - supportGamma = true; + supportGamma = (Caps.Caps2 & D3DCAPS2_FULLSCREENGAMMA) != 0; SupportNPatches = false; - SupportBumpEnvmap = true; - SupportBumpEnvmapLuminance = true; - SupportZBias = true; - SupportAnisotropicFiltering = true; - SupportModAlphaAddClr = true; - SupportDot3 = true; - SupportPointSprites = true; - SupportCubemaps = true; - MaxTexturesPerPass = 8; - VertexShaderVersion = 0; - PixelShaderVersion = 0; - MaxSimultaneousTextures = 8; + SupportBumpEnvmap = (Caps.TextureOpCaps & D3DTEXOPCAPS_BUMPENVMAP) != 0; + SupportBumpEnvmapLuminance = (Caps.TextureOpCaps & D3DTEXOPCAPS_BUMPENVMAPLUMINANCE) != 0; + SupportZBias = (Caps.RasterCaps & D3DPRASTERCAPS_ZBIAS) != 0; + SupportAnisotropicFiltering = + (Caps.TextureFilterCaps & D3DPTFILTERCAPS_MAGFANISOTROPIC) && + (Caps.TextureFilterCaps & D3DPTFILTERCAPS_MINFANISOTROPIC); + SupportModAlphaAddClr = (Caps.TextureOpCaps & D3DTEXOPCAPS_MODULATEALPHA_ADDCOLOR) != 0; + SupportDot3 = (Caps.TextureOpCaps & D3DTEXOPCAPS_DOTPRODUCT3) != 0; + SupportPointSprites = (Caps.MaxPointSize > 1.0f); + SupportCubemaps = (Caps.TextureCaps & D3DPTEXTURECAPS_CUBEMAP) != 0; + MaxTexturesPerPass = Caps.MaxSimultaneousTextures; + VertexShaderVersion = Caps.VertexShaderVersion; + PixelShaderVersion = Caps.PixelShaderVersion; + MaxSimultaneousTextures = Caps.MaxSimultaneousTextures; DeviceId = 0; DriverBuildVersion = 0; DriverVersionStatus = DRIVER_STATUS_GOOD; diff --git a/GeneralsMD/Code/Libraries/Source/WWVegas/WW3D2/part_emt.cpp b/GeneralsMD/Code/Libraries/Source/WWVegas/WW3D2/part_emt.cpp index 8e72c0b017e..76566a64e10 100644 --- a/GeneralsMD/Code/Libraries/Source/WWVegas/WW3D2/part_emt.cpp +++ b/GeneralsMD/Code/Libraries/Source/WWVegas/WW3D2/part_emt.cpp @@ -144,8 +144,13 @@ ParticleEmitterClass::ParticleEmitterClass(const ParticleEmitterClass & src) : ParticlesLeft(src.ParticlesLeft), MaxParticles(src.MaxParticles), IsComplete(false), +#ifdef __APPLE__ + NameString(src.NameString ? ::_strdup (src.NameString) : nullptr), + UserString(src.UserString ? ::_strdup (src.UserString) : nullptr), +#else NameString(::_strdup (src.NameString)), UserString(::_strdup (src.UserString)), +#endif RemoveOnComplete(src.RemoveOnComplete), IsInScene(false), GroupID(0), diff --git a/Platform/MacOS/Launcher/Sources/AboutWindow.swift b/Platform/MacOS/Launcher/Sources/AboutWindow.swift new file mode 100644 index 00000000000..841031bbe4f --- /dev/null +++ b/Platform/MacOS/Launcher/Sources/AboutWindow.swift @@ -0,0 +1,147 @@ +import SwiftUI +import AppKit + +struct AboutView: View { + private let neonBlue = Color(red: 0.1, green: 0.5, blue: 1.0) + private let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + + var body: some View { + VStack(spacing: 20) { + _buildLogo() + _buildTitle() + _buildCredits() + Divider().background(Color.white.opacity(0.2)) + _buildLinks() + _buildLegal() + } + .padding(30) + .frame(width: 420, height: 480) + .background(Color(white: 0.1)) + } + + private func _buildLogo() -> some View { + Group { + if let imgPath = Bundle.main.path(forResource: "AppIcon", ofType: "png"), + let nsImg = NSImage(contentsOfFile: imgPath) { + Image(nsImage: nsImg) + .resizable() + .scaledToFit() + .frame(width: 80, height: 80) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: neonBlue.opacity(0.4), radius: 8) + } else { + Image(systemName: "gamecontroller.fill") + .font(.system(size: 50)) + .foregroundColor(neonBlue) + } + } + } + + private func _buildTitle() -> some View { + VStack(spacing: 4) { + Text("Generals Online") + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.white) + + Text("macOS Native Port") + .font(.system(size: 14, weight: .medium, design: .monospaced)) + .foregroundColor(neonBlue) + + Text("Version \(version)") + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.white.opacity(0.5)) + } + } + + private func _buildCredits() -> some View { + VStack(spacing: 8) { + HStack(spacing: 12) { + if let imgPath = Bundle.main.path(forResource: "author_logo", ofType: "png"), + let nsImg = NSImage(contentsOfFile: imgPath) { + Image(nsImage: nsImg) + .resizable() + .scaledToFit() + .frame(width: 36, height: 36) + .clipShape(Circle()) + .overlay(Circle().stroke(neonBlue.opacity(0.5), lineWidth: 1)) + } + + VStack(alignment: .leading, spacing: 2) { + Text("macOS Port by Dima Ok (OKJI)") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.white) + + Text("Metal Renderer • Apple Silicon") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.white.opacity(0.5)) + } + } + + Text("Built on top of the EA GPLv3 open-source release") + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.4)) + .multilineTextAlignment(.center) + } + } + + private func _buildLinks() -> some View { + HStack(spacing: 20) { + _buildLink(title: "Website", url: "https://general-online-zh.web.app") + _buildLink(title: "Telegram", url: "https://t.me/GeneralsOnlineMacOSChannel") + _buildLink(title: "GitHub", url: "https://github.com/GeneralsOnlineDevelopmentTeam/GameClient") + } + } + + private func _buildLink(title: String, url: String) -> some View { + Button(action: { + if let link = URL(string: url) { + NSWorkspace.shared.open(link) + } + }) { + Text(title) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundColor(neonBlue) + .underline() + } + .buttonStyle(PlainButtonStyle()) + .onHover { inside in + if inside { NSCursor.pointingHand.push() } else { NSCursor.pop() } + } + } + + private func _buildLegal() -> some View { + Text("C&C: Generals Zero Hour™ is a trademark of Electronic Arts.\nGame engine source code licensed under GPLv3.") + .font(.system(size: 10)) + .foregroundColor(.white.opacity(0.3)) + .multilineTextAlignment(.center) + .lineSpacing(2) + } +} + +class AboutWindowController { + private static var window: NSWindow? + + static func show() { + if let existing = window { + existing.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let aboutView = NSHostingView(rootView: AboutView()) + let win = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + win.contentView = aboutView + win.title = "About Generals Online" + win.center() + win.isReleasedWhenClosed = false + win.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + + window = win + } +} diff --git a/Platform/MacOS/Launcher/Sources/LauncherApp.swift b/Platform/MacOS/Launcher/Sources/LauncherApp.swift index 16246ead02f..02a11cc81f4 100644 --- a/Platform/MacOS/Launcher/Sources/LauncherApp.swift +++ b/Platform/MacOS/Launcher/Sources/LauncherApp.swift @@ -5,9 +5,16 @@ struct GeneralsLauncherApp: App { var body: some Scene { WindowGroup { MainView() - .frame(minWidth: 800, idealWidth: 800, minHeight: 500, idealHeight: 500) + .frame(minWidth: 860, idealWidth: 860, minHeight: 580, idealHeight: 580) .edgesIgnoringSafeArea(.all) } .windowStyle(.hiddenTitleBar) + .commands { + CommandGroup(replacing: .appInfo) { + Button("About Generals Online") { + AboutWindowController.show() + } + } + } } } diff --git a/Platform/MacOS/Launcher/Sources/MainView.swift b/Platform/MacOS/Launcher/Sources/MainView.swift index 730173901a3..6c2a2b6edb3 100644 --- a/Platform/MacOS/Launcher/Sources/MainView.swift +++ b/Platform/MacOS/Launcher/Sources/MainView.swift @@ -1,47 +1,78 @@ import SwiftUI import AppKit +import Combine + +// MARK: - View Model class LauncherViewModel: ObservableObject { + enum Tab: String, CaseIterable { + case steam = "Steam" + case local = "Local Archive" + } + + @Published var activeTab: Tab = .steam @Published var installPath: String = UserDefaults.standard.string(forKey: "GENERALS_INSTALL_PATH") ?? "" @Published var isLaunching: Bool = false @Published var alertMessage: String? = nil - + @Published var steamUsername: String = "" + @Published var steamPassword: String = "" + @Published var isUpdateDismissed: Bool = false + + var steamCMD = SteamCMDManager() + var updateChecker = UpdateChecker() + private var cancellables = Set() + + init() { + steamCMD.objectWillChange.sink { [weak self] _ in + self?.objectWillChange.send() + }.store(in: &cancellables) + + updateChecker.$availableUpdate + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + + if let username = KeychainHelper.savedUsername() { + steamUsername = username + steamPassword = KeychainHelper.load(account: username) ?? "" + } + + updateChecker.startPeriodicChecks() + } + + func saveCredentials() { + guard !steamUsername.isEmpty, !steamPassword.isEmpty else { return } + KeychainHelper.save(account: steamUsername, password: steamPassword) + } + var isPathValid: Bool { guard !installPath.isEmpty else { return false } - let fm = FileManager.default - let url = URL(fileURLWithPath: installPath) - - guard let items = try? fm.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: .skipsHiddenFiles) else { return false } - - var hasZH = false - var hasBase = false - - for itemURL in items { - guard let isDir = try? itemURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory, isDir else { continue } - guard let subItems = try? fm.contentsOfDirectory(atPath: itemURL.path) else { continue } - - let containsZH = subItems.contains { $0.lowercased() == "inizh.big" } - let containsBase = subItems.contains { $0.lowercased() == "ini.big" } - - if containsZH { - hasZH = true - } else if containsBase { - hasBase = true - } - - if hasZH && hasBase { return true } + return _validateGameFolder(at: URL(fileURLWithPath: installPath)) + } + + var canLaunch: Bool { + switch activeTab { + case .steam: return steamCMD.areAssetsValid && !steamCMD.state.isRunning + case .local: return isPathValid + } + } + + var effectiveInstallPath: String { + switch activeTab { + case .steam: return steamCMD.installDir.path + case .local: return installPath } - - return false } - + func chooseFolder() { let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false panel.message = NSLocalizedString("Select the Windows Game Folder (containing .big files)", comment: "") - + if panel.runModal() == .OK, let url = panel.url { DispatchQueue.main.async { self.installPath = url.path @@ -49,33 +80,33 @@ class LauncherViewModel: ObservableObject { } } } - + func launchGame() { - guard isPathValid else { return } + guard canLaunch else { return } isLaunching = true - + guard let executableURL = Bundle.main.executableURL?.deletingLastPathComponent().appendingPathComponent("GeneralsOnlineZH") else { isLaunching = false return } - + guard FileManager.default.fileExists(atPath: executableURL.path) else { alertMessage = "Engine binary not found at \(executableURL.path)" isLaunching = false return } - + let task = Process() task.executableURL = executableURL task.currentDirectoryURL = executableURL.deletingLastPathComponent() - + var env = ProcessInfo.processInfo.environment - env["GENERALS_INSTALL_PATH"] = installPath + env["GENERALS_INSTALL_PATH"] = effectiveInstallPath task.environment = env - + do { try task.run() - + DispatchQueue.global().async { Thread.sleep(forTimeInterval: 0.5) DispatchQueue.main.async { @@ -90,8 +121,32 @@ class LauncherViewModel: ObservableObject { isLaunching = false } } + + private func _validateGameFolder(at url: URL) -> Bool { + let fm = FileManager.default + guard let items = try? fm.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: .skipsHiddenFiles) else { + return false + } + + var hasZH = false + var hasBase = false + + for itemURL in items { + guard let isDir = try? itemURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory, isDir else { continue } + guard let subItems = try? fm.contentsOfDirectory(atPath: itemURL.path) else { continue } + + if subItems.contains(where: { $0.lowercased() == "inizh.big" }) { hasZH = true } + if subItems.contains(where: { $0.lowercased() == "ini.big" }) { hasBase = true } + + if hasZH && hasBase { return true } + } + + return false + } } +// MARK: - Window Accessor + struct WindowAccessor: NSViewRepresentable { func makeNSView(context: Context) -> NSView { let view = NSView() @@ -106,32 +161,53 @@ struct WindowAccessor: NSViewRepresentable { func updateNSView(_ nsView: NSView, context: Context) {} } +// MARK: - Main View + struct MainView: View { @StateObject private var viewModel = LauncherViewModel() - private let neonBlue = Color(red: 0.1, green: 0.5, blue: 1.0) + private let neonGreen = Color(red: 0.1, green: 0.9, blue: 0.4) private let darkPanel = Color.black.opacity(0.85) - + private let steamGradient = LinearGradient( + colors: [Color(red: 0.06, green: 0.12, blue: 0.24), Color(red: 0.02, green: 0.06, blue: 0.14)], + startPoint: .top, + endPoint: .bottom + ) + var body: some View { GeometryReader { geometry in ZStack { WindowAccessor().frame(width: 0, height: 0) - + _buildBackground(size: geometry.size) - - VStack(spacing: 35) { - Spacer() + + VStack(spacing: 0) { _buildHeader() + .padding(.top, 30) + + if let update = viewModel.updateChecker.availableUpdate, !viewModel.isUpdateDismissed { + _buildUpdateBanner(update) + .padding(.horizontal, 40) + .padding(.top, 12) + } + + _buildTabBar() + .padding(.top, 20) + + _buildActiveTab() + .padding(.horizontal, 40) + .padding(.top, 16) + Spacer() - _buildPathSelector() - Spacer() + _buildBottomAction() - Spacer() + .padding(.bottom, 12) + + _buildFooter() + .padding(.bottom, 20) } - .padding(30) - - _buildFooter() + .padding(.horizontal, 20) } .frame(width: geometry.size.width, height: geometry.size.height) .edgesIgnoringSafeArea(.all) @@ -142,74 +218,22 @@ struct MainView: View { )) { alert in Alert(title: Text("Launch Error"), message: Text(alert.message), dismissButton: .default(Text("OK"))) } - } - - private func _buildFooter() -> some View { - VStack { - Spacer() - HStack(spacing: 12) { - if let imgPath = Bundle.main.path(forResource: "author_logo", ofType: "png"), - let nsImg = NSImage(contentsOfFile: imgPath) { - Image(nsImage: nsImg) - .resizable() - .scaledToFit() - .frame(width: 42, height: 42) - .clipShape(Circle()) - .overlay(Circle().stroke(Color.white.opacity(0.3), lineWidth: 1)) - .shadow(color: .black, radius: 2) - } - - VStack(alignment: .leading, spacing: 4) { - Text("Ported by OKJI (Okladnoj)") - .font(.system(size: 13, weight: .bold, design: .monospaced)) - .foregroundColor(Color.white.opacity(0.85)) - .shadow(color: .black, radius: 1, x: 1, y: 1) - - HStack(spacing: 8) { - Button(action: { - if let url = URL(string: "https://okladnoj-bio.web.app/") { - NSWorkspace.shared.open(url) - } - }) { - Text("Website") - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundColor(neonBlue) - .underline() - .shadow(color: .black, radius: 1) - } - .buttonStyle(PlainButtonStyle()) - .onHover { inside in - if inside { NSCursor.pointingHand.push() } else { NSCursor.pop() } - } - - Text("|") - .foregroundColor(.white.opacity(0.5)) - .font(.system(size: 11)) - - Button(action: { - if let url = URL(string: "https://t.me/GeneralsOnlineMacOS") { - NSWorkspace.shared.open(url) - } - }) { - Text("Telegram") - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundColor(neonBlue) - .underline() - .shadow(color: .black, radius: 1) - } - .buttonStyle(PlainButtonStyle()) - .onHover { inside in - if inside { NSCursor.pointingHand.push() } else { NSCursor.pop() } - } + .alert(isPresented: $viewModel.steamCMD.showPurchaseAlert) { + Alert( + title: Text("Game Not Found"), + message: Text("The account \"\(viewModel.steamCMD.lastUsername)\" does not own Command & Conquer™ Generals — Zero Hour.\n\nPurchase the game on Steam, then press \"Download Assets\" again."), + primaryButton: .default(Text("Open Steam Store")) { + if let url = URL(string: SteamCMDManager.storeURL) { + NSWorkspace.shared.open(url) } - } - Spacer() - } - .padding(.horizontal, 25) - .padding(.bottom, 25) + }, + secondaryButton: .cancel(Text("Close")) + ) } } - + + // MARK: - Background + @ViewBuilder private func _buildBackground(size: CGSize) -> some View { if let bgPath = Bundle.main.path(forResource: "background", ofType: "png"), @@ -219,116 +243,509 @@ struct MainView: View { .scaledToFill() .frame(width: size.width, height: size.height) .clipped() - .overlay(Color.black.opacity(0.4)) + .overlay(Color.black.opacity(0.55)) } } - + + // MARK: - Header + private func _buildHeader() -> some View { - VStack(spacing: 5) { + VStack(spacing: 4) { Text("GENERALS ONLINE") - .font(.system(size: 56, weight: .black, design: .default)) - .foregroundColor(.white) - .shadow(color: neonBlue, radius: 15, x: 0, y: 0) - - Text("COMMUNITY MAC PORT (ALPHA)") - .font(.system(size: 16, weight: .bold, design: .monospaced)) + .font(.system(size: 48, weight: .black)) .foregroundColor(.white) + .shadow(color: neonBlue, radius: 15) + + Text("COMMUNITY MAC PORT") + .font(.system(size: 14, weight: .bold, design: .monospaced)) + .foregroundColor(.white.opacity(0.7)) .shadow(color: .black, radius: 2, x: 0, y: 2) } } - - private func _buildPathSelector() -> some View { - VStack(alignment: .leading, spacing: 12) { + + // MARK: - Tab Bar + + private func _buildTabBar() -> some View { + HStack(spacing: 0) { + ForEach(LauncherViewModel.Tab.allCases, id: \.self) { tab in + _buildTabButton(tab) + } + } + .background(Color.black.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(neonBlue.opacity(0.3), lineWidth: 1)) + .padding(.horizontal, 40) + } + + private func _buildTabButton(_ tab: LauncherViewModel.Tab) -> some View { + let isActive = viewModel.activeTab == tab + let label: String + let icon: String + + switch tab { + case .steam: + label = "STEAM (RECOMMENDED)" + icon = "arrow.down.circle.fill" + case .local: + label = "LOCAL ARCHIVE" + icon = "folder.fill" + } + + return Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.activeTab = tab + } + }) { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 13)) + Text(label) + .font(.system(size: 12, weight: .bold, design: .monospaced)) + } + .foregroundColor(isActive ? .white : .white.opacity(0.4)) + .padding(.horizontal, 24) + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background(isActive ? neonBlue.opacity(0.25) : Color.clear) + } + .buttonStyle(PlainButtonStyle()) + } + + // MARK: - Active Tab Content + + @ViewBuilder + private func _buildActiveTab() -> some View { + switch viewModel.activeTab { + case .steam: _buildSteamTab() + case .local: _buildLocalTab() + } + } + + // MARK: - Steam Tab + + private func _buildSteamTab() -> some View { + VStack(spacing: 14) { + _buildSteamCredentials() + _buildSteamConsole() + _buildSteamStatus() + } + .padding(20) + .background(Color.black.opacity(0.4)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(neonBlue.opacity(0.2), lineWidth: 1)) + } + + private func _buildSteamCredentials() -> some View { + VStack(alignment: .leading, spacing: 10) { + Text("STEAM CREDENTIALS") + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .foregroundColor(.white.opacity(0.6)) + + HStack(spacing: 12) { + _buildTextField(placeholder: "Username", text: $viewModel.steamUsername, isSecure: false) + _buildTextField(placeholder: "Password", text: $viewModel.steamPassword, isSecure: true) + + if case .waitingSteamGuard = viewModel.steamCMD.state { + _buildTextField(placeholder: "Guard Code", text: $viewModel.steamCMD.steamGuardCode, isSecure: false) + .frame(width: 110) + + Button(action: { viewModel.steamCMD.submitSteamGuard() }) { + Text("SUBMIT") + .font(.system(size: 11, weight: .bold, design: .monospaced)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(neonGreen.opacity(0.3)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(neonGreen, lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + .buttonStyle(PlainButtonStyle()) + } + } + + HStack(spacing: 12) { + _buildSteamActionButton() + + if viewModel.steamCMD.state.isRunning { + Button(action: { viewModel.steamCMD.cancel() }) { + Text("CANCEL") + .font(.system(size: 11, weight: .bold, design: .monospaced)) + .foregroundColor(.red) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.red.opacity(0.1)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.red.opacity(0.5), lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + .buttonStyle(PlainButtonStyle()) + } + } + } + } + + @ViewBuilder + private func _buildSteamActionButton() -> some View { + let canStart = !viewModel.steamUsername.isEmpty + && !viewModel.steamPassword.isEmpty + && !viewModel.steamCMD.state.isRunning + + Button(action: { + viewModel.saveCredentials() + viewModel.steamCMD.startDownload( + username: viewModel.steamUsername, + password: viewModel.steamPassword + ) + }) { + HStack(spacing: 6) { + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 13)) + Text("DOWNLOAD ASSETS") + .font(.system(size: 12, weight: .bold, design: .monospaced)) + } + .foregroundColor(canStart ? .white : .white.opacity(0.3)) + .padding(.horizontal, 20) + .padding(.vertical, 8) + .background(canStart ? neonBlue.opacity(0.25) : Color.white.opacity(0.05)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(canStart ? neonBlue : Color.white.opacity(0.1), lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + .buttonStyle(PlainButtonStyle()) + .disabled(!canStart) + } + + private func _buildTextField(placeholder: String, text: Binding, isSecure: Bool) -> some View { + Group { + if isSecure { + SecureField(placeholder, text: text) + } else { + TextField(placeholder, text: text) + } + } + .textFieldStyle(PlainTextFieldStyle()) + .font(.system(size: 13, design: .monospaced)) + .foregroundColor(.white) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(Color.black.opacity(0.5)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(neonBlue.opacity(0.3), lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + + private func _buildSteamConsole() -> some View { + ScrollViewReader { proxy in + ScrollView { + Text(viewModel.steamCMD.consoleLog.isEmpty ? "Awaiting command..." : viewModel.steamCMD.consoleLog) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(neonGreen.opacity(0.8)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .id("consoleBottom") + } + .frame(height: 120) + .background(Color.black.opacity(0.7)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(neonGreen.opacity(0.15), lineWidth: 1)) + .onChange(of: viewModel.steamCMD.consoleLog) { _ in + withAnimation { + proxy.scrollTo("consoleBottom", anchor: .bottom) + } + } + } + } + + private func _buildSteamStatus() -> some View { + HStack(spacing: 8) { + Circle() + .fill(_statusColor()) + .frame(width: 8, height: 8) + .shadow(color: _statusColor(), radius: 4) + + Text(viewModel.steamCMD.state.statusText) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundColor(.white.opacity(0.7)) + .lineLimit(1) + + Spacer() + } + } + + private func _statusColor() -> Color { + switch viewModel.steamCMD.state { + case .idle, .waitingForCredentials: return .gray + case .downloadingSteamCMD, .authenticating, .downloading, .validating, .waitingSteamGuard, .downloadingPatch, .unpackingPatch: return .orange + case .completed: return neonGreen + case .failed: return .red + } + } + + // MARK: - Local Tab + + private func _buildLocalTab() -> some View { + VStack(alignment: .leading, spacing: 14) { Text("TACTICAL DATA PATH:") - .font(.system(size: 14, weight: .bold, design: .monospaced)) - .foregroundColor(.white) - .shadow(color: .black, radius: 2, x: 1, y: 1) - + .font(.system(size: 13, weight: .bold, design: .monospaced)) + .foregroundColor(.white.opacity(0.7)) + HStack(spacing: 12) { - Text(viewModel.installPath.isEmpty ? "NO SIGNAL - AWAITING FOLDER TARGET" : viewModel.installPath) - .font(.system(size: 14, weight: .medium, design: .monospaced)) + Text(viewModel.installPath.isEmpty ? "NO SIGNAL — AWAITING FOLDER TARGET" : viewModel.installPath) + .font(.system(size: 13, weight: .medium, design: .monospaced)) .foregroundColor(.white) .lineLimit(1) .truncationMode(.middle) - .padding(.horizontal, 15) - .padding(.vertical, 12) + .padding(.horizontal, 14) + .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) .background(darkPanel) - .overlay(Rectangle().stroke(neonBlue, lineWidth: 2)) - - Button(action: { - viewModel.chooseFolder() - }) { + .overlay(RoundedRectangle(cornerRadius: 4).stroke(neonBlue.opacity(0.5), lineWidth: 1.5)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + + Button(action: { viewModel.chooseFolder() }) { Text("LOCATE") - .font(.system(size: 14, weight: .bold, design: .monospaced)) - .padding(.horizontal, 20) - .padding(.vertical, 12) + .font(.system(size: 13, weight: .bold, design: .monospaced)) + .foregroundColor(.white) + .padding(.horizontal, 18) + .padding(.vertical, 10) .background(darkPanel) - .overlay(Rectangle().stroke(neonBlue, lineWidth: 2)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(neonBlue, lineWidth: 1.5)) + .clipShape(RoundedRectangle(cornerRadius: 4)) } .buttonStyle(PlainButtonStyle()) - .foregroundColor(.white) + } + + if !viewModel.installPath.isEmpty && !viewModel.isPathValid { + _buildLocalValidationWarning() } } - .padding(25) - .background(Color.black.opacity(0.3)) - .padding(.horizontal, 40) + .padding(20) + .background(Color.black.opacity(0.4)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(neonBlue.opacity(0.2), lineWidth: 1)) + } + + private func _buildLocalValidationWarning() -> some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red.opacity(0.8)) + .font(.system(size: 14)) + + Text("INVALID TARGET — No ini.big / inizh.big detected in subdirectories") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundColor(.red.opacity(0.8)) + } + .padding(10) + .background(Color.red.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.red.opacity(0.3), lineWidth: 1)) } - + + // MARK: - Bottom Action + @ViewBuilder private func _buildBottomAction() -> some View { - if viewModel.isPathValid { + if viewModel.canLaunch { _buildLaunchButton() } else { - _buildTargetRequiredAlert() + _buildTargetRequiredHint() } } - + private func _buildLaunchButton() -> some View { - Button(action: { - viewModel.launchGame() - }) { - Group { + Button(action: { viewModel.launchGame() }) { + HStack(spacing: 10) { if viewModel.isLaunching { + ProgressView() + .scaleEffect(0.7) + .progressViewStyle(CircularProgressViewStyle(tint: .white)) Text("INITIALIZING...") } else { + Image(systemName: "play.fill") Text("LAUNCH") } } - .font(.system(size: 28, weight: .bold, design: .monospaced)) - .padding(.horizontal, 50) - .padding(.vertical, 15) - .background(darkPanel) + .font(.system(size: 24, weight: .bold, design: .monospaced)) .foregroundColor(.white) - .overlay(Rectangle().stroke(neonBlue, lineWidth: 2)) - .shadow(color: neonBlue.opacity(0.6), radius: 10) + .padding(.horizontal, 50) + .padding(.vertical, 14) + .background( + LinearGradient( + colors: [neonBlue.opacity(0.3), neonBlue.opacity(0.15)], + startPoint: .leading, + endPoint: .trailing + ) + ) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(neonBlue, lineWidth: 2)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .shadow(color: neonBlue.opacity(0.5), radius: 12) } .buttonStyle(PlainButtonStyle()) .disabled(viewModel.isLaunching) } - - private func _buildTargetRequiredAlert() -> some View { - VStack(spacing: 10) { - if let imgPath = Bundle.main.path(forResource: "dir_image", ofType: "png"), + + private func _buildTargetRequiredHint() -> some View { + HStack(spacing: 8) { + if viewModel.activeTab == .local { + if let imgPath = Bundle.main.path(forResource: "dir_image", ofType: "png"), + let nsImg = NSImage(contentsOfFile: imgPath) { + Image(nsImage: nsImg) + .resizable() + .scaledToFit() + .frame(maxHeight: 60) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.orange.opacity(0.4), lineWidth: 1)) + } + } + + Text(viewModel.activeTab == .steam + ? "DOWNLOAD GAME ASSETS VIA STEAM TO ENABLE LAUNCH" + : "SELECT THE PARENT DIRECTORY CONTAINING BOTH GAME VERSIONS") + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .foregroundColor(.orange.opacity(0.8)) + .multilineTextAlignment(.center) + } + .padding(14) + .background(Color.orange.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.orange.opacity(0.3), lineWidth: 1)) + } + + // MARK: - Update Banner + + private func _buildUpdateBanner(_ update: AppUpdate) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "arrow.down.circle.fill") + .foregroundColor(neonGreen) + .font(.system(size: 16)) + + Text("UPDATE AVAILABLE: v\(update.version)") + .font(.system(size: 13, weight: .bold, design: .monospaced)) + .foregroundColor(.white) + + Spacer() + + Button(action: { viewModel.isUpdateDismissed = true }) { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(.white.opacity(0.5)) + } + .buttonStyle(PlainButtonStyle()) + } + + Text(update.releaseNotes) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.white.opacity(0.7)) + .lineLimit(3) + + HStack(spacing: 10) { + Button(action: { + if let url = URL(string: update.downloadURL) { + NSWorkspace.shared.open(url) + } + }) { + HStack(spacing: 6) { + Image(systemName: "arrow.down.to.line") + .font(.system(size: 11)) + Text("DOWNLOAD UPDATE") + .font(.system(size: 11, weight: .bold, design: .monospaced)) + } + .foregroundColor(.white) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(neonGreen.opacity(0.2)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(neonGreen.opacity(0.6), lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + .buttonStyle(PlainButtonStyle()) + + Button(action: { + if let url = URL(string: "https://general-online-zh.web.app") { + NSWorkspace.shared.open(url) + } + }) { + HStack(spacing: 6) { + Image(systemName: "safari") + .font(.system(size: 11)) + Text("SEE DETAILS") + .font(.system(size: 11, weight: .bold, design: .monospaced)) + } + .foregroundColor(.white.opacity(0.7)) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(.white.opacity(0.2), lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(14) + .background(neonGreen.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(neonGreen.opacity(0.3), lineWidth: 1)) + } + + // MARK: - Footer + + private func _buildFooter() -> some View { + HStack(spacing: 12) { + if let imgPath = Bundle.main.path(forResource: "author_logo", ofType: "png"), let nsImg = NSImage(contentsOfFile: imgPath) { Image(nsImage: nsImg) .resizable() .scaledToFit() - .frame(maxHeight: 90) - .overlay(Rectangle().stroke(Color.red.opacity(0.6), lineWidth: 1)) + .frame(width: 36, height: 36) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) + .shadow(color: .black, radius: 2) + } + + VStack(alignment: .leading, spacing: 3) { + Text("Ported by OKJI (Okladnoj)") + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .foregroundColor(.white.opacity(0.75)) + .shadow(color: .black, radius: 1, x: 1, y: 1) + + HStack(spacing: 8) { + _buildFooterLink(title: "Website", url: "https://general-online-zh.web.app") + Text("|").foregroundColor(.white.opacity(0.3)).font(.system(size: 10)) + _buildFooterLink(title: "Telegram", url: "https://t.me/GeneralsOnlineMacOSChannel") + } + } + Spacer() + + Button(action: { + AboutWindowController.show() + }) { + Image(systemName: "info.circle") + .font(.system(size: 18)) + .foregroundColor(.white.opacity(0.6)) + } + .buttonStyle(PlainButtonStyle()) + .onHover { inside in + if inside { NSCursor.pointingHand.push() } else { NSCursor.pop() } } - - Text("TARGET REQUIRED: SELECT THE PARENT DIRECTORY CONTAINING BOTH GAME VERSIONS") - .font(.system(size: 13, weight: .bold, design: .monospaced)) - .foregroundColor(Color.red.opacity(0.9)) } - .padding(15) - .background(darkPanel) - .overlay(Rectangle().stroke(Color.red.opacity(0.5), lineWidth: 2)) - .shadow(color: Color.red.opacity(0.3), radius: 10) + .padding(.horizontal, 20) + } + + private func _buildFooterLink(title: String, url: String) -> some View { + Button(action: { + if let link = URL(string: url) { NSWorkspace.shared.open(link) } + }) { + Text(title) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundColor(neonBlue) + .underline() + .shadow(color: .black, radius: 1) + } + .buttonStyle(PlainButtonStyle()) + .onHover { inside in + if inside { NSCursor.pointingHand.push() } else { NSCursor.pop() } + } } } +// MARK: - Alert Helper + struct AlertItem: Identifiable { let id = UUID() let message: String diff --git a/Platform/MacOS/Launcher/Sources/SteamCMDManager.swift b/Platform/MacOS/Launcher/Sources/SteamCMDManager.swift new file mode 100644 index 00000000000..8fe0c91e95a --- /dev/null +++ b/Platform/MacOS/Launcher/Sources/SteamCMDManager.swift @@ -0,0 +1,509 @@ +import Foundation +import AppKit +import Security + +enum SteamCMDState: Equatable { + case idle + case downloadingSteamCMD + case waitingForCredentials + case authenticating + case waitingSteamGuard + case downloading(progress: String) + case validating + case downloadingPatch(progress: Double) + case unpackingPatch + case completed + case failed(String) + + var isRunning: Bool { + switch self { + case .downloadingSteamCMD, .authenticating, .downloading, .validating, .downloadingPatch, .unpackingPatch: + return true + default: + return false + } + } + + var statusText: String { + switch self { + case .idle: return "READY" + case .downloadingSteamCMD: return "INSTALLING STEAMCMD..." + case .waitingForCredentials: return "AWAITING CREDENTIALS" + case .authenticating: return "AUTHENTICATING..." + case .waitingSteamGuard: return "STEAM GUARD CODE REQUIRED" + case .downloading(let progress): return "DOWNLOADING ASSETS... \(progress)" + case .validating: return "VALIDATING FILES..." + case .downloadingPatch(let progress): return String(format: "DOWNLOADING PATCH... %.0f%%", progress * 100) + case .unpackingPatch: return "UNPACKING PATCH..." + case .completed: return "ASSETS READY" + case .failed(let msg): return "ERROR: \(msg)" + } + } +} + +class SteamCMDManager: ObservableObject { + @Published var state: SteamCMDState = .idle + @Published var consoleLog: String = "" + @Published var steamGuardCode: String = "" + @Published var showPurchaseAlert: Bool = false + + var lastUsername: String = "" + + static let appID = "2732960" + + private var process: Process? + private var inputPipe: Pipe? + private var downloadObservation: NSKeyValueObservation? + + var supportDir: URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return base.appendingPathComponent("Generals Online") + } + + var steamCMDDir: URL { supportDir.appendingPathComponent("steamcmd") } + var steamCMDBinary: URL { steamCMDDir.appendingPathComponent("steamcmd") } + var installDir: URL { + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + return docs.appendingPathComponent("Generals Online") + } + + var assetsDir: URL { installDir.appendingPathComponent("Assets") } + var baseGameDir: URL { installDir.appendingPathComponent("ZH_Generals") } + + var isSteamCMDInstalled: Bool { + FileManager.default.fileExists(atPath: steamCMDBinary.path) + } + + var areAssetsValid: Bool { + let fm = FileManager.default + let zhFiles = (try? fm.contentsOfDirectory(atPath: assetsDir.path)) ?? [] + let baseFiles = (try? fm.contentsOfDirectory(atPath: baseGameDir.path)) ?? [] + + let hasZH = zhFiles.contains { $0.lowercased() == "inizh.big" } + let hasBase = baseFiles.contains { $0.lowercased() == "ini.big" } || zhFiles.contains { $0.lowercased() == "ini.big" } + + return hasZH && hasBase + } + + private func reorganizeAssets() { + let fm = FileManager.default + let source = assetsDir.appendingPathComponent("ZH_Generals") + let destination = baseGameDir + + guard fm.fileExists(atPath: source.path) else { + appendLog("[*] ZH_Generals already in correct location\n") + return + } + + if fm.fileExists(atPath: destination.path) { + try? fm.removeItem(at: destination) + } + + do { + try fm.moveItem(at: source, to: destination) + appendLog("[✓] Moved ZH_Generals/ alongside Assets/\n") + } catch { + appendLog("[!] Failed to move ZH_Generals: \(error.localizedDescription)\n") + } + } + + private func cleanEAPatchFiles() { + let filesToRemove = ["PatchData.big", "PatchINI.big", "PatchWindow.big", "PatchZH.big"] + let fm = FileManager.default + + for file in filesToRemove { + let path = assetsDir.appendingPathComponent(file) + if fm.fileExists(atPath: path.path) { + try? fm.removeItem(at: path) + appendLog("[*] Removed EA modern patch file: \(file)\n") + } + + // Also check root just in case + let rootPath = installDir.appendingPathComponent(file) + if fm.fileExists(atPath: rootPath.path) { + try? fm.removeItem(at: rootPath) + appendLog("[*] Removed EA modern patch file from root: \(file)\n") + } + } + } + + func appendLog(_ text: String) { + print(text, terminator: "") + DispatchQueue.main.async { + self.consoleLog += text + } + } + + func startDownload(username: String, password: String) { + guard !state.isRunning else { return } + + consoleLog = "" + lastUsername = username + + if !isSteamCMDInstalled { + installSteamCMD { [weak self] success in + guard let self, success else { return } + self.runSteamCMD(username: username, password: password) + } + return + } + + runSteamCMD(username: username, password: password) + } + + func submitSteamGuard() { + guard case .waitingSteamGuard = state, !steamGuardCode.isEmpty else { return } + + let code = steamGuardCode.trimmingCharacters(in: .whitespacesAndNewlines) + appendLog("> Steam Guard code submitted\n") + writeToProcess(code + "\n") + steamGuardCode = "" + DispatchQueue.main.async { self.state = .authenticating } + } + + func cancel() { + process?.terminate() + process = nil + inputPipe = nil + DispatchQueue.main.async { + self.state = .idle + self.appendLog("\n--- CANCELLED ---\n") + } + } + + // MARK: - SteamCMD Installation + + private func installSteamCMD(completion: @escaping (Bool) -> Void) { + DispatchQueue.main.async { self.state = .downloadingSteamCMD } + appendLog("[*] Downloading SteamCMD for macOS...\n") + + let tarballURL = "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_osx.tar.gz" + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self else { return } + + do { + try FileManager.default.createDirectory(at: self.steamCMDDir, withIntermediateDirectories: true) + + let tarPath = self.steamCMDDir.appendingPathComponent("steamcmd_osx.tar.gz") + + guard let url = URL(string: tarballURL), + let data = try? Data(contentsOf: url) else { + self.fail("Failed to download SteamCMD tarball") + completion(false) + return + } + + try data.write(to: tarPath) + self.appendLog("[*] Extracting SteamCMD...\n") + + let tar = Process() + tar.executableURL = URL(fileURLWithPath: "/usr/bin/tar") + tar.arguments = ["-xzf", tarPath.path, "-C", self.steamCMDDir.path] + try tar.run() + tar.waitUntilExit() + + guard tar.terminationStatus == 0 else { + self.fail("Failed to extract SteamCMD (exit \(tar.terminationStatus))") + completion(false) + return + } + + try? FileManager.default.removeItem(at: tarPath) + + self.appendLog("[*] Removing quarantine attributes...\n") + let xattr = Process() + xattr.executableURL = URL(fileURLWithPath: "/usr/bin/xattr") + xattr.arguments = ["-cr", self.steamCMDDir.path] + try xattr.run() + xattr.waitUntilExit() + + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: self.steamCMDBinary.path + ) + + self.appendLog("[✓] SteamCMD installed successfully\n\n") + completion(true) + } catch { + self.fail("Installation error: \(error.localizedDescription)") + completion(false) + } + } + } + + // MARK: - SteamCMD Execution + + private func runSteamCMD(username: String, password: String) { + DispatchQueue.main.async { self.state = .authenticating } + appendLog("[*] Launching SteamCMD...\n") + + do { + try FileManager.default.createDirectory(at: assetsDir, withIntermediateDirectories: true) + } catch { + fail("Cannot create assets directory: \(error.localizedDescription)") + return + } + + let task = Process() + task.executableURL = steamCMDBinary + + task.arguments = [ + "+@sSteamCmdForcePlatformType", "windows", + "+force_install_dir", assetsDir.path, + "+login", username, password, + "+app_update", Self.appID, "validate", + "+quit" + ] + + task.currentDirectoryURL = steamCMDDir + + let outputPipe = Pipe() + let errorPipe = Pipe() + let input = Pipe() + + task.standardOutput = outputPipe + task.standardError = errorPipe + task.standardInput = input + + self.inputPipe = input + self.process = task + + outputPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return } + self?.processOutput(text) + } + + errorPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return } + self?.processOutput(text) + } + + task.terminationHandler = { [weak self] proc in + DispatchQueue.main.async { + guard let self else { return } + outputPipe.fileHandleForReading.readabilityHandler = nil + errorPipe.fileHandleForReading.readabilityHandler = nil + self.process = nil + self.inputPipe = nil + + if proc.terminationStatus == 0 { + self.appendLog("\n[✓] Vanilla download completed successfully!\n") + self.reorganizeAssets() + self.cleanEAPatchFiles() + self.downloadCommunityPatch() + } else if proc.terminationStatus == 42 { + self.appendLog("\n[*] SteamCMD updated itself. Restarting process...\n") + self.runSteamCMD(username: username, password: password) + } else if case .failed = self.state { + // already set + } else { + self.fail("SteamCMD exited with code \(proc.terminationStatus)") + } + } + } + + do { + try task.run() + } catch { + fail("Failed to launch SteamCMD: \(error.localizedDescription)") + } + } + + static let storeURL = "https://store.steampowered.com/app/2732960" + + private func processOutput(_ text: String) { + appendLog(text) + + let lower = text.lowercased() + + if lower.contains("steam guard") || lower.contains("two-factor") || lower.contains("enter the current code") { + DispatchQueue.main.async { self.state = .waitingSteamGuard } + return + } + + if lower.contains("no subscription") || lower.contains("no license") { + fail("Game not owned — purchase required") + DispatchQueue.main.async { self.showPurchaseAlert = true } + return + } + + if lower.contains("update state") { + let lines = text.components(separatedBy: "\n") + for line in lines where line.lowercased().contains("update state") { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + DispatchQueue.main.async { self.state = .downloading(progress: trimmed) } + } + return + } + + if lower.contains("validating") { + DispatchQueue.main.async { self.state = .validating } + return + } + + if lower.contains("login failure") || lower.contains("invalid password") { + fail("Authentication failed — check credentials") + process?.terminate() + return + } + } + + private func writeToProcess(_ text: String) { + guard let data = text.data(using: .utf8) else { return } + inputPipe?.fileHandleForWriting.write(data) + } + + private func fail(_ message: String) { + DispatchQueue.main.async { + self.state = .failed(message) + self.appendLog("\n[✗] \(message)\n") + } + } + + // MARK: - Community Patch + + private func downloadCommunityPatch() { + DispatchQueue.main.async { + self.state = .downloadingPatch(progress: 0.0) + self.appendLog("\n[⭳] Downloading Community Patch & Maps...\n") + } + + // Hardcoded URL pointing to your new public GitHub repo + guard let patchURL = URL(string: "https://github.com/Okladnoj/GeneralsOnline-MacPatch/releases/latest/download/GO_Mac_Patch.zip") else { + fail("Invalid patch URL.") + return + } + + let task = URLSession.shared.downloadTask(with: patchURL) { [weak self] localURL, response, error in + guard let self = self else { return } + + if let error = error { + self.fail("Failed to download patch: \(error.localizedDescription)") + return + } + + guard let localURL = localURL else { + self.fail("Patch download failed: No file URL returned.") + return + } + + // Move the downloaded temp file so it doesn't get automatically deleted + let tempZipURL = self.installDir.appendingPathComponent("temp_patch.zip") + let fm = FileManager.default + try? fm.removeItem(at: tempZipURL) + do { + try fm.moveItem(at: localURL, to: tempZipURL) + DispatchQueue.main.async { + self.downloadObservation?.invalidate() + self.downloadObservation = nil + self.applyPatch(from: tempZipURL) + } + } catch { + self.fail("Failed to prepare patch file: \(error.localizedDescription)") + } + } + + downloadObservation = task.progress.observe(\.fractionCompleted) { [weak self] progress, _ in + let fraction = progress.fractionCompleted + DispatchQueue.main.async { + self?.state = .downloadingPatch(progress: fraction) + } + } + + task.resume() + } + + private func applyPatch(from zipURL: URL) { + state = .unpackingPatch + appendLog("[*] Unpacking patch...\n") + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + // -o: overwrite without prompting. -d: extract to installDir (where Assets/ is) + process.arguments = ["-o", zipURL.path, "-d", installDir.path] + + process.terminationHandler = { [weak self] proc in + DispatchQueue.main.async { + try? FileManager.default.removeItem(at: zipURL) // Clean up zip + + if proc.terminationStatus == 0 { + self?.appendLog("[✓] Community Patch successfully applied!\n") + self?.state = .completed + } else { + self?.fail("Failed to unpack patch (unzip exited with code \(proc.terminationStatus))") + } + } + } + + do { + try process.run() + } catch { + fail("Failed to execute unzip: \(error.localizedDescription)") + } + } +} + +// MARK: - Keychain + +struct KeychainHelper { + private static let service = "com.generals-online.launcher" + + static func save(account: String, password: String) { + guard let data = password.data(using: .utf8) else { return } + + delete(account: account) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: data + ] + + SecItemAdd(query as CFDictionary, nil) + } + + static func load(account: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func delete(account: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + + SecItemDelete(query as CFDictionary) + } + + static func savedUsername() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, let attrs = result as? [String: Any] else { return nil } + return attrs[kSecAttrAccount as String] as? String + } +} diff --git a/Platform/MacOS/Launcher/Sources/UpdateChecker.swift b/Platform/MacOS/Launcher/Sources/UpdateChecker.swift new file mode 100644 index 00000000000..09e57342fd0 --- /dev/null +++ b/Platform/MacOS/Launcher/Sources/UpdateChecker.swift @@ -0,0 +1,80 @@ +import Foundation +import Combine + +struct AppUpdate: Codable { + let version: String + let build: Int + let downloadURL: String + let releaseNotes: String + let releaseDate: String + let minOSVersion: String +} + +class UpdateChecker: ObservableObject { + static var currentVersion: String { + Bundle.main.infoDictionary?["GOLauncherVersion"] as? String ?? "0.0.0" + } + static var currentBuild: Int { + Int(Bundle.main.infoDictionary?["GOLauncherBuild"] as? String ?? "0") ?? 0 + } + + private static let updateURL = URL(string: "https://general-online-zh.web.app/api/update.json")! + private static let checkInterval: TimeInterval = 5 * 60 + + @Published var availableUpdate: AppUpdate? = nil + @Published var isChecking: Bool = false + @Published var lastCheckDate: Date? = nil + + private var timer: Timer? + + func startPeriodicChecks() { + checkForUpdate() + + timer = Timer.scheduledTimer(withTimeInterval: Self.checkInterval, repeats: true) { [weak self] _ in + self?.checkForUpdate() + } + } + + func stopPeriodicChecks() { + timer?.invalidate() + timer = nil + } + + func checkForUpdate() { + guard !isChecking else { return } + isChecking = true + + var request = URLRequest(url: Self.updateURL) + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + request.timeoutInterval = 10 + + URLSession.shared.dataTask(with: request) { [weak self] data, response, error in + DispatchQueue.main.async { + guard let self else { return } + self.isChecking = false + self.lastCheckDate = Date() + + guard let data, error == nil else { return } + guard let update = try? JSONDecoder().decode(AppUpdate.self, from: data) else { return } + + if self.isNewerVersion(update.version, than: Self.currentVersion) + || update.build > Self.currentBuild { + self.availableUpdate = update + } + } + }.resume() + } + + private func isNewerVersion(_ remote: String, than local: String) -> Bool { + let r = remote.split(separator: ".").compactMap { Int($0) } + let l = local.split(separator: ".").compactMap { Int($0) } + + for i in 0.. lv { return true } + if rv < lv { return false } + } + return false + } +} diff --git a/Platform/MacOS/Launcher/assemble_distribution.sh b/Platform/MacOS/Launcher/assemble_distribution.sh index 2ed4f5a1b20..507fa0662b4 100644 --- a/Platform/MacOS/Launcher/assemble_distribution.sh +++ b/Platform/MacOS/Launcher/assemble_distribution.sh @@ -1,5 +1,22 @@ #!/bin/bash +# Assemble the final macOS distribution package (.zip + .dmg) +# Requires a successful CMake build of the game first. +# +# Run standalone (from Platform/MacOS/Launcher/): +# sh assemble_distribution.sh +# +# Or via the root build script: +# sh build_mac.sh --launcher # build + assemble +# sh build_mac.sh --launcher --clean # clean build + assemble +# +# Prerequisites: +# - dylibbundler (brew install dylibbundler) +# - create-dmg (brew install create-dmg) — optional, for premium DMG + +VERSION="1.1.1" +BUILD="3" + LAUNCHER_NAME="GeneralsLauncher" FINAL_APP_NAME="Generals Online" CMAKE_APP_DIR="../../../build/macos/GeneralsMD/GeneralsOnlineZH.app" @@ -7,7 +24,8 @@ DIST_DIR="build/dist" OUTPUTS_DIR="outputs" FINAL_APP_DIR="$DIST_DIR/$FINAL_APP_NAME.app" ZIP_NAME="Generals_Online_Mac_Alpha.zip" -README_NAME="README_INSTALL.md" +DMG_NAME="Generals_Online_Mac_Alpha.dmg" +INSTRUCTIONS_NAME="Instructions.html" echo "==========================================" echo "📦 Assembling Final Distribution Package" @@ -35,7 +53,7 @@ FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks" GAME_BINARY="$MACOS_DIR/GeneralsOnlineZH" GNS_SEARCH_PATH="../../../build/macos/bin" -echo "📦 [1/6] Bundling third-party dynamic libraries..." +echo "📦 [1/7] Bundling third-party dynamic libraries..." export PATH="/opt/homebrew/bin:$PATH" if ! command -v dylibbundler &>/dev/null; then @@ -54,7 +72,7 @@ if [ $? -ne 0 ]; then exit 1 fi -echo "🔒 [2/6] Cleaning RPATHs and re-signing..." +echo "🔒 [2/7] Cleaning RPATHs and re-signing..." EXISTING_RPATHS=$(otool -l "$GAME_BINARY" | grep -A 2 LC_RPATH | awk '/path / {print $2}') for rp in $EXISTING_RPATHS; do while install_name_tool -delete_rpath "$rp" "$GAME_BINARY" 2>/dev/null; do true; done @@ -63,8 +81,10 @@ install_name_tool -add_rpath "@executable_path/../Frameworks/" "$GAME_BINARY" codesign --force --deep -s - "$FINAL_APP_DIR" -echo "🔨 [3/6] Compiling Swift Launcher into the package..." +echo "🔨 [3/7] Compiling Swift Launcher into the package..." swiftc Sources/LauncherApp.swift Sources/MainView.swift \ + Sources/SteamCMDManager.swift Sources/AboutWindow.swift \ + Sources/UpdateChecker.swift \ -o "$MACOS_DIR/$LAUNCHER_NAME" \ -target arm64-apple-macosx11.0 @@ -73,7 +93,7 @@ if [ $? -ne 0 ]; then exit 1 fi -echo "🎨 [4/6] Injecting Launcher UI assets and patching..." +echo "🎨 [4/7] Injecting Launcher UI assets and patching..." cp assets/background.png "$RESOURCES_DIR/background.png" 2>/dev/null || true cp assets/dir_image.png "$RESOURCES_DIR/dir_image.png" 2>/dev/null || true cp assets/author_logo.png "$RESOURCES_DIR/author_logo.png" 2>/dev/null || true @@ -85,63 +105,37 @@ PLIST_FILE="$CONTENTS_DIR/Info.plist" /usr/libexec/PlistBuddy -c "Delete :CFBundleIconFile" "$PLIST_FILE" 2>/dev/null || true /usr/libexec/PlistBuddy -c "Add :CFBundleIconFile string AppIcon.png" "$PLIST_FILE" -echo "📝 [5/6] Generating README instruction..." -cat << 'EOF' > "$OUTPUTS_DIR/$README_NAME" -# Command and Conquer Generals – Mac OS Port 🍏 - -### ⚠️ Installation & Launch Instructions - -Since this application is a free community port and lacks an official paid Apple Developer certificate, the macOS security system (Gatekeeper) will place the downloaded archive and the app into "quarantine". It may say the file is damaged or cannot be opened. - -To remove this restriction, you need to run **one** simple command in the Terminal: - -1. Unzip the downloaded ZIP archive. -2. Open the system application **"Terminal"** (Terminal.app). -3. Enter the following command (it will ask for your Mac administrator password): - -```bash -sudo xattr -cr "Path to/Generals Online.app" -``` - -> **Tip:** You can just type `sudo xattr -cr ` (make sure there is a space at the end) and drag the unzipped game app directly into the Terminal window. The path will be inserted automatically! - -4. Press **Enter** and type your password (characters will be hidden while typing). - -After that, you will be able to launch **Generals Online** with a regular double-click. - ---- - -### 🚀 Terminal Quick-Start (For Advanced Users) - -If you prefer using the Terminal for the entire process, navigate to the folder where you downloaded the ZIP file and run this one-liner: - -```bash -unzip -d Generals_Online_Mac_Alpha Generals_Online_Mac_Alpha.zip && cd Generals_Online_Mac_Alpha && sudo xattr -cr "Generals Online.app" && open "Generals Online.app" -``` - ---- - -### 📂 Connecting Game Data - -When the Launcher opens, you **MUST** select the parent folder of your Windows version game files. -*(Inside the folder you select, there must be two subdirectories: the Vanilla version and Zero Hour).* - -Have a great game, General! 🫡 -EOF +/usr/libexec/PlistBuddy -c "Delete :GOLauncherVersion" "$PLIST_FILE" 2>/dev/null || true +/usr/libexec/PlistBuddy -c "Add :GOLauncherVersion string $VERSION" "$PLIST_FILE" +/usr/libexec/PlistBuddy -c "Delete :GOLauncherBuild" "$PLIST_FILE" 2>/dev/null || true +/usr/libexec/PlistBuddy -c "Add :GOLauncherBuild string $BUILD" "$PLIST_FILE" +echo " Launcher version: v$VERSION (build $BUILD)" + +echo "📝 [5/7] Copying HTML instructions..." +if [ -f "www/instructions.html" ]; then + cp "www/instructions.html" "$OUTPUTS_DIR/$INSTRUCTIONS_NAME" +else + echo "⚠️ Warning: www/instructions.html not found, skipping HTML instructions." +fi -echo "🗜️ [6/6] Creating final deployment ZIP..." +echo "🗜️ [6/7] Creating final deployment ZIP..." # Идем в dist, чтобы в архиве корневым элементом была сама app, без папок build/dist cd "$DIST_DIR" || exit zip -qry "../../$OUTPUTS_DIR/$ZIP_NAME" "$FINAL_APP_NAME.app" cd ../.. -# Идем в outputs и добавляем ридми внутрь готового зипа +# Идем в outputs и добавляем инструкции внутрь готового зипа cd "$OUTPUTS_DIR" || exit -zip -rq "$ZIP_NAME" "$README_NAME" +if [ -f "$INSTRUCTIONS_NAME" ]; then + zip -rq "$ZIP_NAME" "$INSTRUCTIONS_NAME" +fi +cd .. + +# echo "💿 [7/7] Creating DMG installer image..." +# sh build_dmg.sh echo "✅ Distribution package successfully created in: $OUTPUTS_DIR" -ls -lah -cd .. +ls -lah "$OUTPUTS_DIR" # Удаляем build_launcher.sh раз мы все объединили rm -f build_launcher.sh 2>/dev/null || true diff --git a/Platform/MacOS/Launcher/assets/dmg_background.png b/Platform/MacOS/Launcher/assets/dmg_background.png new file mode 100644 index 00000000000..88db3194cef Binary files /dev/null and b/Platform/MacOS/Launcher/assets/dmg_background.png differ diff --git a/Platform/MacOS/Launcher/build_dmg.sh b/Platform/MacOS/Launcher/build_dmg.sh new file mode 100644 index 00000000000..0271213f9e9 --- /dev/null +++ b/Platform/MacOS/Launcher/build_dmg.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +# Build a styled DMG installer image from an existing distribution. +# +# Run standalone (from Platform/MacOS/Launcher/): +# sh build_dmg.sh +# +# Prerequisites: +# - create-dmg (brew install create-dmg) — optional, for premium DMG + +set -e + +FINAL_APP_NAME="Generals Online" +DIST_DIR="build/dist" +OUTPUTS_DIR="outputs" +DMG_NAME="Generals_Online_Mac_Alpha.dmg" +DMG_OUTPUT="$OUTPUTS_DIR/$DMG_NAME" + +if [ ! -d "$DIST_DIR/$FINAL_APP_NAME.app" ]; then + echo "🚨 ERROR: Distribution not found at $DIST_DIR/$FINAL_APP_NAME.app" + echo "Run assemble_distribution.sh first." + exit 1 +fi + +mkdir -p "$OUTPUTS_DIR" +rm -f "$DMG_OUTPUT" + +echo "💿 Creating DMG installer image..." + +if command -v create-dmg &>/dev/null; then + create-dmg \ + --volname "Generals Online" \ + --volicon "Generals.png" \ + --background "assets/dmg_background.png" \ + --window-pos 200 120 \ + --window-size 660 400 \ + --icon-size 80 \ + --icon "$FINAL_APP_NAME.app" 170 190 \ + --app-drop-link 490 190 \ + --hide-extension "$FINAL_APP_NAME.app" \ + --no-internet-enable \ + "$DMG_OUTPUT" \ + "$DIST_DIR/" +else + echo "⚠️ create-dmg not found, falling back to basic hdiutil (install with: brew install create-dmg)" + DMG_STAGING="build/dmg_staging" + rm -rf "$DMG_STAGING" + mkdir -p "$DMG_STAGING" + cp -R "$DIST_DIR/$FINAL_APP_NAME.app" "$DMG_STAGING/" + ln -s /Applications "$DMG_STAGING/Applications" + + hdiutil create \ + -volname "Generals Online" \ + -srcfolder "$DMG_STAGING" \ + -ov \ + -format UDZO \ + "$DMG_OUTPUT" + + rm -rf "$DMG_STAGING" +fi + +echo "✅ DMG created: $DMG_OUTPUT" +ls -lh "$DMG_OUTPUT" diff --git a/Platform/MacOS/Launcher/run_launcher.sh b/Platform/MacOS/Launcher/run_launcher.sh new file mode 100644 index 00000000000..37419b66932 --- /dev/null +++ b/Platform/MacOS/Launcher/run_launcher.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# Run the assembled Generals Online.app with logs piped to terminal. +# +# Run: +# sh run_launcher.sh +# sh run_launcher.sh 2>&1 | tee launcher.log # also save to file + +APP_PATH="build/dist/Generals Online.app/Contents/MacOS/GeneralsLauncher" + +if [ ! -f "$APP_PATH" ]; then + echo "🚨 ERROR: Launcher not found at: $APP_PATH" + echo "Run 'sh assemble_distribution.sh' first." + exit 1 +fi + +killall GeneralsLauncher 2>/dev/null || true + +echo "🚀 Launching from distribution bundle..." +echo "===========================================" +"$APP_PATH" diff --git a/Platform/MacOS/Launcher/www/instructions.html b/Platform/MacOS/Launcher/www/instructions.html new file mode 100644 index 00000000000..d5991613508 --- /dev/null +++ b/Platform/MacOS/Launcher/www/instructions.html @@ -0,0 +1,992 @@ + + + + + + Generals Online - Installation + + + + + +
+
+
+
Generals Online
+

macOS Installation

+
+ +
+ +
+ + macOS Gatekeeper will block this app because it is a free community port without a paid Apple Developer certificate. You need to remove the quarantine flag using Terminal before playing. + + +
+ +
+

1 Open Terminal

+

Open the Terminal app from Launchpad or Spotlight Search.

+
+ +
+

2 Type the command

+

Copy the following command. Make sure there is a space at the end, but do not press Enter yet:

+
+ sudo xattr -cr + +
+
+ +
+

3 Drag and Drop

+

Drag the unzipped Generals Online.app into the Terminal window. The path will be inserted automatically. Then press Enter.

+
+ +
+

4 Enter your Mac Password

+

The Terminal will ask for your Mac administrator password (the PIN/password you use to log in to your computer).

+
+
+ ⚠️ Wait! Your typing will be invisible! +
+

When you type your password, no characters or asterisks will appear on the screen. This is a normal macOS security feature. Just type your password blindly and press Enter.

+
+
+ +
+

5 Open the Launcher

+

You can now double-click the app to launch it! In the launcher, select the folder containing your original Windows game data.

+ +
+
+ + +
+ +
+
    +
    + + +
    + +
    +
    Game Cache:
    +
    main folder ~/Command and Conquer Generals Zero Hour Data/
    +
    custom maps ~/Command and Conquer Generals Zero Hour Data/maps/
    +
    replays ~/Command and Conquer Generals Zero Hour Data/Replays/
    +
    +
    + + +
    + + + + + diff --git a/Platform/MacOS/Source/Audio/AVAudioBridge.h b/Platform/MacOS/Source/Audio/AVAudioBridge.h index 24c1477022c..bf84015cdfc 100644 --- a/Platform/MacOS/Source/Audio/AVAudioBridge.h +++ b/Platform/MacOS/Source/Audio/AVAudioBridge.h @@ -17,6 +17,7 @@ void avbridge_unloadBuffer(int bufferID); int avbridge_play(int bufferID, float gain, float pitch, bool loop); int avbridge_play3D(int bufferID, float gain, float pitch, float x, float y, float z, float maxDist, float refDist); +int avbridge_playStream(const char* filepath, float gain, float pitch, bool loop); void avbridge_stop(int playerID); void avbridge_stopAll(void); bool avbridge_isPlaying(int playerID); diff --git a/Platform/MacOS/Source/Audio/AVAudioBridge.mm b/Platform/MacOS/Source/Audio/AVAudioBridge.mm index cb998238ee1..8fe69dc6280 100644 --- a/Platform/MacOS/Source/Audio/AVAudioBridge.mm +++ b/Platform/MacOS/Source/Audio/AVAudioBridge.mm @@ -1,4 +1,5 @@ #import "AVAudioBridge.h" +#import "../Utils/MacDebug.h" // Restore Byte typedef required by AudioToolbox, which was undefined by metal_prefix.h #include @@ -19,6 +20,7 @@ int bufferID; bool active; bool is3D; + uint32_t generation; }; struct AVBridgeBufferEntry { @@ -172,6 +174,17 @@ static void detachAndReattach(int slotIdx, bool to3D, AVAudioFormat *format) { } } +static void ensure_engine_running(void) { + if (gEngine && !gEngine.isRunning) { + NSError *err = nil; + if ([gEngine startAndReturnError:&err]) { + DEBUG_AUDIO_MAC(("ensure_engine_running: Engine was stopped/paused. Restarted successfully.")); + } else { + DEBUG_AUDIO_MAC(("ensure_engine_running: Failed to restart engine: %s", err.localizedDescription.UTF8String)); + } + } +} + static void ensure_engine_inited(void) { if (gEngine) return; @@ -203,6 +216,7 @@ static void ensure_engine_inited(void) { gSlots[i].active = false; gSlots[i].bufferID = 0; gSlots[i].is3D = false; + gSlots[i].generation = 0; } NSError *error = nil; @@ -306,11 +320,14 @@ int avbridge_play(int bufferID, float gain, float pitch, bool loop) { int idx = findFreeSlot(); if (idx < 0) { os_unfair_lock_unlock(&gLock); + DEBUG_AUDIO_MAC(("avbridge_play: NO FREE SLOT for buf=%d", bufferID)); return -1; } gSlots[idx].active = true; gSlots[idx].bufferID = bufferID; gSlots[idx].is3D = false; + gSlots[idx].generation++; + uint32_t capturedGen = gSlots[idx].generation; os_unfair_lock_unlock(&gLock); detachAndReattach(idx, false, entry->format); @@ -322,10 +339,17 @@ int avbridge_play(int bufferID, float gain, float pitch, bool loop) { AVAudioPlayerNodeBufferOptions opts = loop ? AVAudioPlayerNodeBufferLoops : 0; [node scheduleBuffer:entry->buffer atTime:nil options:opts completionHandler:^{ os_unfair_lock_lock(&gLock); + if (gSlots[idx].generation != capturedGen) { + DEBUG_AUDIO_MAC(("avbridge_play COMPLETION: STALE callback slot=%d gen=%u vs current=%u -> IGNORED", + idx, capturedGen, gSlots[idx].generation)); + os_unfair_lock_unlock(&gLock); + return; + } gSlots[idx].active = false; gSlots[idx].bufferID = 0; os_unfair_lock_unlock(&gLock); }]; + ensure_engine_running(); [node play]; return idx; @@ -343,11 +367,14 @@ int avbridge_play3D(int bufferID, float gain, float pitch, int idx = findFreeSlot(); if (idx < 0) { os_unfair_lock_unlock(&gLock); + DEBUG_AUDIO_MAC(("avbridge_play3D: NO FREE SLOT for buf=%d", bufferID)); return -1; } gSlots[idx].active = true; gSlots[idx].bufferID = bufferID; gSlots[idx].is3D = true; + gSlots[idx].generation++; + uint32_t capturedGen = gSlots[idx].generation; os_unfair_lock_unlock(&gLock); detachAndReattach(idx, true, entry->format); @@ -362,10 +389,67 @@ int avbridge_play3D(int bufferID, float gain, float pitch, [node scheduleBuffer:entry->buffer atTime:nil options:0 completionHandler:^{ os_unfair_lock_lock(&gLock); + if (gSlots[idx].generation != capturedGen) { + DEBUG_AUDIO_MAC(("avbridge_play3D COMPLETION: STALE callback slot=%d gen=%u vs current=%u -> IGNORED", + idx, capturedGen, gSlots[idx].generation)); + os_unfair_lock_unlock(&gLock); + return; + } + gSlots[idx].active = false; + gSlots[idx].bufferID = 0; + os_unfair_lock_unlock(&gLock); + }]; + ensure_engine_running(); + [node play]; + + return idx; +} + +int avbridge_playStream(const char* filepath, float gain, float pitch, bool loop) { + ensure_engine_inited(); + if (!gEngineStarted) return -1; + + NSURL *url = [NSURL fileURLWithPath:[NSString stringWithUTF8String:filepath]]; + NSError *err = nil; + AVAudioFile *file = [[AVAudioFile alloc] initForReading:url error:&err]; + if (!file) { + DEBUG_AUDIO_MAC(("avbridge_playStream: failed to open file %s: %s", filepath, [[err localizedDescription] UTF8String])); + return -1; + } + + os_unfair_lock_lock(&gLock); + int idx = findFreeSlot(); + if (idx < 0) { + os_unfair_lock_unlock(&gLock); + DEBUG_AUDIO_MAC(("avbridge_playStream: NO FREE SLOT for file=%s", filepath)); + return -1; + } + gSlots[idx].active = true; + gSlots[idx].bufferID = 0; // Not a buffer + gSlots[idx].is3D = false; + gSlots[idx].generation++; + uint32_t capturedGen = gSlots[idx].generation; + os_unfair_lock_unlock(&gLock); + + detachAndReattach(idx, false, file.processingFormat); + + AVAudioPlayerNode *node = gSlots[idx].node; + node.volume = gain; + node.rate = pitch; + + [node scheduleFile:file atTime:nil completionHandler:^{ + os_unfair_lock_lock(&gLock); + if (gSlots[idx].generation != capturedGen) { + DEBUG_AUDIO_MAC(("avbridge_playStream COMPLETION: STALE callback slot=%d gen=%u vs current=%u -> IGNORED", + idx, capturedGen, gSlots[idx].generation)); + os_unfair_lock_unlock(&gLock); + return; + } gSlots[idx].active = false; gSlots[idx].bufferID = 0; os_unfair_lock_unlock(&gLock); }]; + ensure_engine_running(); [node play]; return idx; @@ -377,27 +461,34 @@ void avbridge_stop(int playerID) { if (playerID < 0 || playerID >= gMaxNodes) return; if (!gSlots[playerID].active) return; - [gSlots[playerID].node stop]; - os_unfair_lock_lock(&gLock); + gSlots[playerID].generation++; gSlots[playerID].active = false; gSlots[playerID].bufferID = 0; os_unfair_lock_unlock(&gLock); + + [gSlots[playerID].node stop]; } void avbridge_stopAll(void) { + DEBUG_AUDIO_MAC(("avbridge_stopAll: stopping all %d nodes", gMaxNodes)); + os_unfair_lock_lock(&gLock); for (int i = 0; i < gMaxNodes; i++) { if (gSlots[i].active) { - [gSlots[i].node stop]; + gSlots[i].generation++; gSlots[i].active = false; gSlots[i].bufferID = 0; } } + os_unfair_lock_unlock(&gLock); + for (int i = 0; i < gMaxNodes; i++) { + [gSlots[i].node stop]; + } } bool avbridge_isPlaying(int playerID) { if (playerID < 0 || playerID >= gMaxNodes) return false; - return gSlots[playerID].active && gSlots[playerID].node.isPlaying; + return gSlots[playerID].active; } void avbridge_setVolume(int playerID, float gain) { diff --git a/Platform/MacOS/Source/Audio/MacOSAudioManager.cpp b/Platform/MacOS/Source/Audio/MacOSAudioManager.cpp index a944d52fe94..43fb10258a6 100644 --- a/Platform/MacOS/Source/Audio/MacOSAudioManager.cpp +++ b/Platform/MacOS/Source/Audio/MacOSAudioManager.cpp @@ -2,16 +2,21 @@ #include "Common/AudioAffect.h" #include "Common/AudioEventInfo.h" #include "Common/AudioEventRTS.h" +#include "Common/AudioHandleSpecialValues.h" #include "Common/AudioRequest.h" #include "Common/Debug.h" #include "Common/GameMemory.h" #include "Common/FileSystem.h" #include "Common/file.h" +#include "Common/System/NativeFileSystem.h" +#include "Common/GlobalData.h" #include "../Utils/MacDebug.h" #include extern FileSystem *TheFileSystem; +static const char* AUDIO_CACHE_DIR_FORMAT = "%sAudioCache/"; + #pragma mark - WAV Loading from Engine FileSystem struct WavParseResult { @@ -150,6 +155,12 @@ void MacOSAudioManager::init() { } void MacOSAudioManager::reset() { + int activeCount = 0; + for (auto &pa : m_sources) { + if (pa.isPlaying) activeCount++; + } + DEBUG_AUDIO_MAC(("MacOSAudioManager::reset() called. %d sources were active.", activeCount)); + AudioManager::reset(); avbridge_stopAll(); for (auto &pa : m_sources) { @@ -158,6 +169,8 @@ void MacOSAudioManager::reset() { if (pa.eventRTS) { delete pa.eventRTS; pa.eventRTS = nullptr; } pa.handle = 0; } + + DEBUG_AUDIO_MAC(("MacOSAudioManager::reset() completed. Buffer cache has %zu entries.", m_bufferCache.size())); } void MacOSAudioManager::update() { @@ -219,10 +232,7 @@ PlayingAudio* MacOSAudioManager::findFreeSource(int priorityToDemand) { int MacOSAudioManager::loadAudioBuffer(const AsciiString& path, bool forceMono) { std::string originalPath = path.str(); - std::string pathStr = originalPath; - for (size_t i = 0; i < pathStr.length(); ++i) { - if (pathStr[i] == '\\') pathStr[i] = '/'; - } + std::string pathStr = NativeFileSystem::get_safe_path(originalPath); std::string cacheKey = originalPath + (forceMono ? "_mono" : "_stereo"); auto hit = m_bufferCache.find(cacheKey); @@ -288,6 +298,58 @@ int MacOSAudioManager::loadAudioBuffer(const AsciiString& path, bool forceMono) return bufID; } +std::string MacOSAudioManager::getPhysicalPathForStream(const std::string& vfsPath) { + std::string safePath = NativeFileSystem::get_safe_path(vfsPath); + if (NativeFileSystem::exists(safePath)) { + return safePath; + } + + if (!TheFileSystem || !TheFileSystem->doesFileExist(vfsPath.c_str())) { + return ""; + } + + File *f = TheFileSystem->openFile(vfsPath.c_str(), File::READ); + if (!f) return ""; + + size_t fileSize = f->size(); + if (fileSize == 0 || fileSize > 50 * 1024 * 1024) { f->close(); return ""; } + + uint8_t *buf = (uint8_t*)malloc(fileSize); + if (!buf) { f->close(); return ""; } + + Int bytesRead = f->read(buf, (Int)fileSize); + f->close(); + + if (bytesRead <= 0 || (size_t)bytesRead != fileSize) { free(buf); return ""; } + + AsciiString cachePath; + cachePath.format(AUDIO_CACHE_DIR_FORMAT, TheGlobalData->getPath_UserData().str()); + cachePath.concat(vfsPath.c_str()); + + std::string safeMacPath = NativeFileSystem::get_safe_path(cachePath.str()); + + bool writeNeeded = true; + if (NativeFileSystem::exists(safeMacPath)) { + if (NativeFileSystem::file_size(safeMacPath) == fileSize) { + writeNeeded = false; + } + } + + if (writeNeeded) { + File *outFile = TheFileSystem->openFile(cachePath.str(), File::WRITE); + if (outFile) { + outFile->write(buf, (Int)fileSize); + outFile->close(); + } else { + free(buf); + return ""; + } + } + + free(buf); + return safeMacPath; +} + #pragma mark - Request Processing void MacOSAudioManager::processRequestList() { @@ -337,19 +399,22 @@ void MacOSAudioManager::playAudioEvent(AudioEventRTS *eventToPlay) { return; } - bool isPos = (event->getPosition() != nullptr && event->isPositionalAudio()); - int bufID = loadAudioBuffer(filename, isPos); - if (bufID <= 0) { - // Suppress this log because it will spam every time a missing/ADPCM sound is triggered, nuking FPS. - // DEBUG_AUDIO_MAC(("playAudioEvent: buffer failed for %s. Deleting event.", filename.str())); - delete event; - return; - } - int priority = 50; const AudioEventInfo *info = event->getAudioEventInfo(); if (info) priority = info->m_priority; + bool isStream = (info && (info->m_soundType == AT_Music || info->m_soundType == AT_Streaming)); + int bufID = 0; + + if (!isStream) { + bool isPos = (event->getPosition() != nullptr && event->isPositionalAudio()); + bufID = loadAudioBuffer(filename, isPos); + if (bufID <= 0) { + delete event; + return; + } + } + PlayingAudio *pa = findFreeSource(priority); if (!pa) { DEBUG_AUDIO_MAC(("playAudioEvent: No free source for %s (pri %d). Deleting event.", filename.str(), priority)); @@ -370,13 +435,22 @@ void MacOSAudioManager::playAudioEvent(AudioEventRTS *eventToPlay) { float pitch = event->getPitchShift() > 0 ? event->getPitchShift() : 1.0f; int playerID = -1; - const Coord3D *pos = event->getPosition(); - if (pos && event->isPositionalAudio()) { - playerID = avbridge_play3D(bufID, gain, pitch, - pos->x, pos->y, pos->z, - 500.0f, 50.0f); + if (isStream) { + std::string physicalPath = getPhysicalPathForStream(filename.str()); + if (!physicalPath.empty()) { + playerID = avbridge_playStream(physicalPath.c_str(), gain, pitch, false); + } else { + DEBUG_AUDIO_MAC(("playAudioEvent: Failed to extract stream %s", filename.str())); + } } else { - playerID = avbridge_play(bufID, gain, pitch, false); + const Coord3D *pos = event->getPosition(); + if (pos && event->isPositionalAudio()) { + playerID = avbridge_play3D(bufID, gain, pitch, + pos->x, pos->y, pos->z, + 500.0f, 50.0f); + } else { + playerID = avbridge_play(bufID, gain, pitch, false); + } } if (playerID < 0) { @@ -404,11 +478,16 @@ void MacOSAudioManager::friend_forcePlayAudioEventRTS(const AudioEventRTS *event AsciiString filename = eventCopy.getFilename(); if (filename.isEmpty()) return; - int bufID = loadAudioBuffer(filename, false); - if (bufID <= 0) return; - float baseVol = 1.0f; const AudioEventInfo *info = eventCopy.getAudioEventInfo(); + + bool isStream = (info && (info->m_soundType == AT_Music || info->m_soundType == AT_Streaming)); + int bufID = 0; + + if (!isStream) { + bufID = loadAudioBuffer(filename, false); + if (bufID <= 0) return; + } if (info) { if (info->m_soundType == AT_Music) baseVol = getVolume(AudioAffect_Music); else if (info->m_soundType == AT_Streaming) baseVol = getVolume(AudioAffect_Speech); @@ -420,7 +499,12 @@ void MacOSAudioManager::friend_forcePlayAudioEventRTS(const AudioEventRTS *event float gain = eventCopy.getVolume() * baseVol; float pitch = eventCopy.getPitchShift() > 0 ? eventCopy.getPitchShift() : 1.0f; - avbridge_play(bufID, gain, pitch, false); + if (isStream) { + std::string pathStr = NativeFileSystem::get_safe_path(filename.str()); + avbridge_playStream(pathStr.c_str(), gain, pitch, false); + } else { + avbridge_play(bufID, gain, pitch, false); + } } #pragma mark - Listener @@ -473,12 +557,40 @@ void MacOSAudioManager::killAudioEventImmediately(AudioHandle audioEvent) { #pragma mark - Stubs -void MacOSAudioManager::nextMusicTrack() {} -void MacOSAudioManager::prevMusicTrack() {} -Bool MacOSAudioManager::isMusicPlaying() const { return FALSE; } +void MacOSAudioManager::nextMusicTrack() { + AsciiString trackName = getMusicTrackName(); + TheAudio->removeAudioEvent(AHSV_StopTheMusic); + trackName = TheAudio->nextTrackName(trackName); + AudioEventRTS newTrack(trackName); + TheAudio->addAudioEvent(&newTrack); +} +void MacOSAudioManager::prevMusicTrack() { + AsciiString trackName = getMusicTrackName(); + TheAudio->removeAudioEvent(AHSV_StopTheMusic); + trackName = TheAudio->prevTrackName(trackName); + AudioEventRTS newTrack(trackName); + TheAudio->addAudioEvent(&newTrack); +} +Bool MacOSAudioManager::isMusicPlaying() const { + for (auto &pa : m_sources) { + if (pa.isPlaying && pa.eventRTS && pa.eventRTS->getAudioEventInfo()) { + if (pa.eventRTS->getAudioEventInfo()->m_soundType == AT_Music) return TRUE; + } + } + return FALSE; +} Bool MacOSAudioManager::isMusicAlreadyLoaded() const { return TRUE; } Bool MacOSAudioManager::hasMusicTrackCompleted(const AsciiString &trackName, Int numberOfTimes) const { return FALSE; } -AsciiString MacOSAudioManager::getMusicTrackName() const { return ""; } +AsciiString MacOSAudioManager::getMusicTrackName() const { + for (auto &pa : m_sources) { + if (pa.isPlaying && pa.eventRTS && pa.eventRTS->getAudioEventInfo()) { + if (pa.eventRTS->getAudioEventInfo()->m_soundType == AT_Music) { + return pa.eventRTS->getEventName(); + } + } + } + return AsciiString(""); +} void MacOSAudioManager::openDevice() {} void MacOSAudioManager::closeDevice() {} void *MacOSAudioManager::getDevice() { return nullptr; } diff --git a/Platform/MacOS/Source/Audio/MacOSAudioManager.h b/Platform/MacOS/Source/Audio/MacOSAudioManager.h index 5c7559b9c04..aa63d209274 100644 --- a/Platform/MacOS/Source/Audio/MacOSAudioManager.h +++ b/Platform/MacOS/Source/Audio/MacOSAudioManager.h @@ -89,6 +89,7 @@ class MacOSAudioManager : public AudioManager { void playAudioEvent(AudioEventRTS *eventToPlay); int loadAudioBuffer(const AsciiString& path, bool forceMono = false); + std::string getPhysicalPathForStream(const std::string& vfsPath); void stopSourceAndFree(PlayingAudio &pa); PlayingAudio* findFreeSource(int priorityToDemand); diff --git a/Platform/MacOS/Source/GeneralsOnlineStubs.cpp b/Platform/MacOS/Source/GeneralsOnlineStubs.cpp index 8cc0b8d9780..4f7a2627db5 100644 --- a/Platform/MacOS/Source/GeneralsOnlineStubs.cpp +++ b/Platform/MacOS/Source/GeneralsOnlineStubs.cpp @@ -1,4 +1,4 @@ -#ifdef __APPLE__ + #include #include #include @@ -30,7 +30,7 @@ extern "C" { void sentry_capture_event(sentry_value_t) {} } -#endif + #include bool SetStringInRegistry(std::string path, std::string key, std::string val) { return true; } diff --git a/Platform/MacOS/Source/Main/MacOSGameEngine.mm b/Platform/MacOS/Source/Main/MacOSGameEngine.mm index 9b906ebe1a5..492637291fc 100644 --- a/Platform/MacOS/Source/Main/MacOSGameEngine.mm +++ b/Platform/MacOS/Source/Main/MacOSGameEngine.mm @@ -74,7 +74,8 @@ static bool DetectGameModes(const std::string& rootPath, std::string& outZH, std if (hasINIZH && outZH.empty()) { outZH = subdir; - } else if (hasINI && !hasINIZH && outBase.empty()) { + } + if (hasINI && outBase.empty()) { outBase = subdir; } } diff --git a/Platform/MacOS/Source/Main/MacOSMain.mm b/Platform/MacOS/Source/Main/MacOSMain.mm index 1054d71917a..8a813d32d8e 100644 --- a/Platform/MacOS/Source/Main/MacOSMain.mm +++ b/Platform/MacOS/Source/Main/MacOSMain.mm @@ -20,6 +20,7 @@ #include #include +#include #include #include #include @@ -87,7 +88,7 @@ static void macosSignalHandler(int sig) { // ── External engine bridges ── -extern "C" void MacOS_ApplyDisplayResolution(int w, int h); +extern "C" void MacOS_ApplyDisplayResolution(int w, int h, bool isWindowed); extern "C" void MacOS_UpdateMetalDeviceScreenSize(int width, int height); #include @@ -146,21 +147,12 @@ - (void)windowDidEndLiveResize:(NSNotification *)notification { int newW = (int)newSize.width; int newH = (int)newSize.height; - printf("[MacOS] windowDidEndLiveResize: %dx%d\n", newW, newH); + printf("[MacOS] windowDidEndLiveResize: logical=%dx%d (bsf=%.1f)\n", + newW, newH, self.window.backingScaleFactor); fflush(stdout); - // Update CAMetalLayer drawable size - if (contentView.layer && [contentView.layer isKindOfClass:[CAMetalLayer class]]) { - CAMetalLayer* layer = (CAMetalLayer*)contentView.layer; - layer.contentsScale = 1.0; - layer.drawableSize = CGSizeMake(newW, newH); - } - - // Update MetalDevice8 (viewport, depth texture, screen dimensions) - MacOS_UpdateMetalDeviceScreenSize(newW, newH); - - // Apply through the full engine path (mirrors OptionsMenu Accept) - MacOS_ApplyDisplayResolution(newW, newH); + bool isWindowed = TheGlobalData ? TheGlobalData->m_windowed : false; + MacOS_ApplyDisplayResolution(newW, newH, isWindowed); } - (void)runGame { @@ -245,12 +237,16 @@ - (void)runGame { [NSApp terminate:nil]; } +extern "C" void MacOS_InitWindowedState(bool isWindowed, int xRes, int yRes); + - (void)createWindow { int width = TheGlobalData ? TheGlobalData->m_xResolution : 800; int height = TheGlobalData ? TheGlobalData->m_yResolution : 600; - printf("[DIAG] createWindow: %dx%d xRes=%d yRes=%d\n", width, height, - TheGlobalData ? TheGlobalData->m_xResolution : -1, - TheGlobalData ? TheGlobalData->m_yResolution : -1); + bool isWindowed = true; // TheSuperHackers @tweak macOS: Always start in windowed mode. + + MacOS_InitWindowedState(isWindowed, width, height); + + printf("[DIAG] createWindow: %dx%d windowed=%d\n", width, height, isWindowed); fflush(stdout); NSRect frame = NSMakeRect(0, 0, width, height); @@ -263,8 +259,26 @@ - (void)createWindow { styleMask:style backing:NSBackingStoreBuffered defer:NO]; + [self.window setCollectionBehavior:NSWindowCollectionBehaviorFullScreenNone]; [self.window setTitle:@"Command and Conquer Generals"]; - [self.window center]; + + if (!isWindowed) { + NSApp.presentationOptions = NSApplicationPresentationHideDock | NSApplicationPresentationHideMenuBar; + [self.window setStyleMask:NSWindowStyleMaskBorderless]; + + NSScreen* screen = [NSScreen mainScreen]; + NSRect screenFrame = screen.frame; + [self.window setFrame:screenFrame display:YES]; + + TheWritableGlobalData->m_xResolution = (int)screenFrame.size.width; + TheWritableGlobalData->m_yResolution = (int)screenFrame.size.height; + + // Notify metal wrapper about immediate resize + MacOS_UpdateMetalDeviceScreenSize(screenFrame.size.width, screenFrame.size.height); + } else { + [self.window center]; + } + [self.window makeKeyAndOrderFront:nil]; [self.window setDelegate:self]; @@ -275,6 +289,14 @@ - (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication*)app { return YES; } +extern "C" bool MacOS_ToggleFullscreen(); + +- (BOOL)windowShouldZoom:(NSWindow *)window toFrame:(NSRect)newFrame { + // Intercept green "zoom" button to trigger borderless fullscreen + MacOS_ToggleFullscreen(); + return NO; // Prevent default macOS zoom +} + @end // ── CreateGameEngine (mirrors WinMain.cpp lines 978-989) ── @@ -292,6 +314,7 @@ - (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication*)app { char MacOSCommandLineString[4096] = ""; int main(int argc, char* argv[]) { + std::setlocale(LC_CTYPE, "en_US.UTF-8"); MacOSCommandLineString[0] = '\0'; for (int i=0; i0) strncat(MacOSCommandLineString, " ", sizeof(MacOSCommandLineString)-1 - strlen(MacOSCommandLineString)); diff --git a/Platform/MacOS/Source/Metal/MacOSDisplayManager.mm b/Platform/MacOS/Source/Metal/MacOSDisplayManager.mm index bc64acb3a0a..15b48f3559c 100644 --- a/Platform/MacOS/Source/Metal/MacOSDisplayManager.mm +++ b/Platform/MacOS/Source/Metal/MacOSDisplayManager.mm @@ -9,7 +9,6 @@ * standard DX8Wrapper path (Resize_And_Position_Window / Set_Device_Resolution) * to mirror the tested Windows flow. */ -#ifdef __APPLE__ #import #import @@ -116,4 +115,3 @@ return { 800, 600, 60 }; } -#endif // __APPLE__ diff --git a/Platform/MacOS/Source/Metal/MetalDevice8.h b/Platform/MacOS/Source/Metal/MetalDevice8.h index a917dbb6e59..220f369cf2e 100644 --- a/Platform/MacOS/Source/Metal/MetalDevice8.h +++ b/Platform/MacOS/Source/Metal/MetalDevice8.h @@ -1,6 +1,5 @@ #pragma once -#ifdef __APPLE__ #include #include @@ -98,6 +97,7 @@ class MetalDevice8 : public IDirect3DDevice8 { HRESULT CopyRects(IDirect3DSurface8 *s, const void *r, UINT c, IDirect3DSurface8 *d, const void *p) override; HRESULT Reset(D3DPRESENT_PARAMETERS *p) override; HRESULT GetDeviceCaps(D3DCAPS8 *c) override; + static void FillDeviceCaps(D3DCAPS8 *pCaps); HRESULT GetAdapterIdentifier(UINT a, DWORD f, D3DADAPTER_IDENTIFIER8 *i) override; HRESULT SetMaterial(const D3DMATERIAL8 *m) override; HRESULT SetClipPlane(DWORD i, const float *p) override; @@ -137,4 +137,3 @@ class MetalDevice8 : public IDirect3DDevice8 { #include "MetalDevice8_state.h" }; -#endif // __APPLE__ diff --git a/Platform/MacOS/Source/Metal/MetalDevice8.mm b/Platform/MacOS/Source/Metal/MetalDevice8.mm index 5821598913d..f71b3802b46 100644 --- a/Platform/MacOS/Source/Metal/MetalDevice8.mm +++ b/Platform/MacOS/Source/Metal/MetalDevice8.mm @@ -4,7 +4,6 @@ * Stage 0: Skeleton — all methods log and return D3D_OK. * BeginScene/EndScene/Present/Clear have real Metal frame lifecycle code. */ -#ifdef __APPLE__ // Import ObjC/Metal frameworks FIRST, before win_compat.h #import @@ -1158,26 +1157,44 @@ static uint32_t GetTextureGeneration(IDirect3DBaseTexture8* t) { return D3D_OK; } -STDMETHODIMP MetalDevice8::GetDeviceCaps(D3DCAPS8 *pCaps) { - if (!pCaps) - return E_POINTER; +void MetalDevice8::FillDeviceCaps(D3DCAPS8 *pCaps) { memset(pCaps, 0, sizeof(*pCaps)); pCaps->DeviceType = D3DDEVTYPE_HAL; pCaps->DevCaps = D3DDEVCAPS_HWTRANSFORMANDLIGHT; pCaps->MaxSimultaneousTextures = 8; pCaps->MaxTextureBlendStages = 8; - pCaps->VertexShaderVersion = 0x0101; - pCaps->PixelShaderVersion = 0x0101; + pCaps->VertexShaderVersion = 0; // Fixed function fallback + pCaps->PixelShaderVersion = 0; // Fixed function fallback pCaps->MaxPrimitiveCount = 0xFFFFFF; pCaps->MaxVertexIndex = 0xFFFFFF; pCaps->MaxStreams = 8; pCaps->MaxActiveLights = 4; - pCaps->MaxTextureWidth = 4096; - pCaps->MaxTextureHeight = 4096; + pCaps->MaxTextureWidth = 8192; + pCaps->MaxTextureHeight = 8192; + pCaps->RasterCaps = - D3DPRASTERCAPS_FOGRANGE | 0x00000100 | 0x00000200 | D3DPRASTERCAPS_ZBIAS; - pCaps->TextureCaps = 0x00000001 | 0x00000002 | 0x00000004 | D3DPTEXTURECAPS_MIPMAP | - D3DPTEXTURECAPS_CUBEMAP | D3DPTEXTURECAPS_MIPCUBEMAP; + D3DPRASTERCAPS_FOGRANGE | D3DPRASTERCAPS_FOGTABLE | + D3DPRASTERCAPS_FOGVERTEX | D3DPRASTERCAPS_ZBIAS | + D3DPRASTERCAPS_MIPMAPLODBIAS | D3DPRASTERCAPS_ZTEST; + + pCaps->TextureCaps = + D3DPTEXTURECAPS_ALPHA | D3DPTEXTURECAPS_PROJECTED | + D3DPTEXTURECAPS_CUBEMAP | D3DPTEXTURECAPS_MIPMAP | + D3DPTEXTURECAPS_MIPCUBEMAP; + + pCaps->TextureFilterCaps = + D3DPTFILTERCAPS_MINFPOINT | D3DPTFILTERCAPS_MINFLINEAR | + D3DPTFILTERCAPS_MINFANISOTROPIC | + D3DPTFILTERCAPS_MAGFPOINT | D3DPTFILTERCAPS_MAGFLINEAR | + D3DPTFILTERCAPS_MIPFPOINT | D3DPTFILTERCAPS_MIPFLINEAR; + + pCaps->CubeTextureFilterCaps = pCaps->TextureFilterCaps; + + pCaps->TextureAddressCaps = + D3DPTADDRESSCAPS_WRAP | D3DPTADDRESSCAPS_MIRROR | + D3DPTADDRESSCAPS_CLAMP | D3DPTADDRESSCAPS_BORDER | + D3DPTADDRESSCAPS_MIRRORONCE; + pCaps->TextureOpCaps = D3DTEXOPCAPS_DISABLE | D3DTEXOPCAPS_SELECTARG1 | D3DTEXOPCAPS_SELECTARG2 | D3DTEXOPCAPS_MODULATE | D3DTEXOPCAPS_MODULATE2X | D3DTEXOPCAPS_MODULATE4X | @@ -1187,22 +1204,32 @@ static uint32_t GetTextureGeneration(IDirect3DBaseTexture8* t) { D3DTEXOPCAPS_BLENDFACTORALPHA | D3DTEXOPCAPS_BLENDCURRENTALPHA | D3DTEXOPCAPS_MODULATEALPHA_ADDCOLOR | D3DTEXOPCAPS_MODULATECOLOR_ADDALPHA | D3DTEXOPCAPS_MODULATEINVALPHA_ADDCOLOR | D3DTEXOPCAPS_MODULATEINVCOLOR_ADDALPHA | + D3DTEXOPCAPS_BUMPENVMAP | D3DTEXOPCAPS_BUMPENVMAPLUMINANCE | D3DTEXOPCAPS_DOTPRODUCT3 | D3DTEXOPCAPS_MULTIPLYADD | D3DTEXOPCAPS_LERP; - pCaps->PrimitiveMiscCaps = D3DPMISCCAPS_COLORWRITEENABLE; + + pCaps->PrimitiveMiscCaps = + D3DPMISCCAPS_COLORWRITEENABLE | D3DPMISCCAPS_CULLNONE | + D3DPMISCCAPS_CULLCW | D3DPMISCCAPS_CULLCCW | + D3DPMISCCAPS_BLENDOP | D3DPMISCCAPS_MASKZ; + pCaps->Caps2 = D3DCAPS2_FULLSCREENGAMMA; pCaps->SrcBlendCaps = 0x1FFF; pCaps->DestBlendCaps = 0x1FFF; pCaps->ZCmpCaps = 0xFF; pCaps->AlphaCmpCaps = 0xFF; pCaps->StencilCaps = 0xFF; - // TextureFilterCaps: lets _Init_Filters set FILTER_TYPE_BEST=LINEAR. - // FILTER_TYPE_DEFAULT is then overridden back to POINT in texturefilter.cpp (#ifdef __APPLE__) - // to prevent DXT1 BC1-block boundary artifacts on UI buttons drawn via Render2DClass. - pCaps->TextureFilterCaps = - D3DPTFILTERCAPS_MINFPOINT | D3DPTFILTERCAPS_MINFLINEAR | - D3DPTFILTERCAPS_MINFANISOTROPIC | - D3DPTFILTERCAPS_MAGFPOINT | D3DPTFILTERCAPS_MAGFLINEAR | - D3DPTFILTERCAPS_MIPFPOINT | D3DPTFILTERCAPS_MIPFLINEAR; + + pCaps->MaxTextureRepeat = 8192; + pCaps->MaxAnisotropy = 16; + pCaps->MaxPointSize = 256.0f; + pCaps->MaxUserClipPlanes = 6; + pCaps->MaxVertexBlendMatrices = 4; +} + +STDMETHODIMP MetalDevice8::GetDeviceCaps(D3DCAPS8 *pCaps) { + if (!pCaps) + return E_POINTER; + FillDeviceCaps(pCaps); return D3D_OK; } @@ -3415,4 +3442,3 @@ static uint32_t GetTextureGeneration(IDirect3DBaseTexture8* t) { } } -#endif // __APPLE__ diff --git a/Platform/MacOS/Source/Metal/MetalInterface8.h b/Platform/MacOS/Source/Metal/MetalInterface8.h index 24b8d841d7c..f5ec41f91d3 100644 --- a/Platform/MacOS/Source/Metal/MetalInterface8.h +++ b/Platform/MacOS/Source/Metal/MetalInterface8.h @@ -1,6 +1,5 @@ #pragma once -#ifdef __APPLE__ #include #include @@ -32,4 +31,4 @@ class MetalInterface8 : public IDirect3D8 { ULONG m_RefCount; }; -#endif // __APPLE__ + diff --git a/Platform/MacOS/Source/Metal/MetalInterface8.mm b/Platform/MacOS/Source/Metal/MetalInterface8.mm index 0c4527c630a..ba0d661d4af 100644 --- a/Platform/MacOS/Source/Metal/MetalInterface8.mm +++ b/Platform/MacOS/Source/Metal/MetalInterface8.mm @@ -1,7 +1,6 @@ /** * MetalInterface8.mm — IDirect3D8 implementation on Apple Metal */ -#ifdef __APPLE__ #import "MetalInterface8.h" #import "MetalDevice8.h" @@ -152,83 +151,7 @@ static void queryDisplayModes() { if (!pCaps) return E_POINTER; - // Fill caps directly — no need to create a temporary MetalDevice8. - // These are static capabilities and don't depend on device state. - memset(pCaps, 0, sizeof(*pCaps)); - pCaps->DeviceType = D3DDEVTYPE_HAL; - pCaps->DevCaps = D3DDEVCAPS_HWTRANSFORMANDLIGHT; - pCaps->MaxSimultaneousTextures = 8; - pCaps->MaxTextureBlendStages = 8; - - // Architecture adaptation - pCaps->VertexShaderVersion = 0; - pCaps->PixelShaderVersion = 0; - pCaps->MaxPrimitiveCount = 0xFFFFFF; - pCaps->MaxVertexIndex = 0xFFFFFF; - pCaps->MaxStreams = 8; - pCaps->MaxActiveLights = 4; - pCaps->MaxTextureWidth = 8192; - pCaps->MaxTextureHeight = 8192; - - // RasterCaps: fog range + fog table + fog vertex + zbias + mipmap LOD bias - pCaps->RasterCaps = - D3DPRASTERCAPS_FOGRANGE | D3DPRASTERCAPS_FOGTABLE | - D3DPRASTERCAPS_FOGVERTEX | D3DPRASTERCAPS_ZBIAS | - D3DPRASTERCAPS_MIPMAPLODBIAS | D3DPRASTERCAPS_ZTEST; - - // TextureCaps: power-of-two not required, alpha, projective, cubemap, volumemap - pCaps->TextureCaps = - D3DPTEXTURECAPS_ALPHA | D3DPTEXTURECAPS_PROJECTED | - D3DPTEXTURECAPS_CUBEMAP | D3DPTEXTURECAPS_MIPMAP | - D3DPTEXTURECAPS_MIPCUBEMAP; - - // TextureFilterCaps: all filtering modes Metal supports - pCaps->TextureFilterCaps = - D3DPTFILTERCAPS_MINFPOINT | D3DPTFILTERCAPS_MINFLINEAR | - D3DPTFILTERCAPS_MINFANISOTROPIC | - D3DPTFILTERCAPS_MAGFPOINT | D3DPTFILTERCAPS_MAGFLINEAR | - D3DPTFILTERCAPS_MIPFPOINT | D3DPTFILTERCAPS_MIPFLINEAR; - - // CubeTextureFilterCaps: same as TextureFilterCaps - pCaps->CubeTextureFilterCaps = pCaps->TextureFilterCaps; - - // TextureAddressCaps: wrap, mirror, clamp, border, mirroronce - pCaps->TextureAddressCaps = - D3DPTADDRESSCAPS_WRAP | D3DPTADDRESSCAPS_MIRROR | - D3DPTADDRESSCAPS_CLAMP | D3DPTADDRESSCAPS_BORDER | - D3DPTADDRESSCAPS_MIRRORONCE; - - // TextureOpCaps: all operations the W3D ShaderClass::Apply() checks for - pCaps->TextureOpCaps = - D3DTEXOPCAPS_DISABLE | D3DTEXOPCAPS_SELECTARG1 | D3DTEXOPCAPS_SELECTARG2 | - D3DTEXOPCAPS_MODULATE | D3DTEXOPCAPS_MODULATE2X | D3DTEXOPCAPS_MODULATE4X | - D3DTEXOPCAPS_ADD | D3DTEXOPCAPS_ADDSIGNED | D3DTEXOPCAPS_ADDSIGNED2X | - D3DTEXOPCAPS_SUBTRACT | D3DTEXOPCAPS_ADDSMOOTH | - D3DTEXOPCAPS_BLENDDIFFUSEALPHA | D3DTEXOPCAPS_BLENDTEXTUREALPHA | - D3DTEXOPCAPS_BLENDFACTORALPHA | D3DTEXOPCAPS_BLENDCURRENTALPHA | - D3DTEXOPCAPS_MODULATEALPHA_ADDCOLOR | - D3DTEXOPCAPS_BUMPENVMAP | D3DTEXOPCAPS_BUMPENVMAPLUMINANCE | - D3DTEXOPCAPS_DOTPRODUCT3; - - // PrimitiveMiscCaps - pCaps->PrimitiveMiscCaps = - D3DPMISCCAPS_COLORWRITEENABLE | D3DPMISCCAPS_CULLNONE | - D3DPMISCCAPS_CULLCW | D3DPMISCCAPS_CULLCCW | - D3DPMISCCAPS_BLENDOP | D3DPMISCCAPS_MASKZ; - - pCaps->Caps2 = D3DCAPS2_FULLSCREENGAMMA; - pCaps->SrcBlendCaps = 0x1FFF; - pCaps->DestBlendCaps = 0x1FFF; - pCaps->ZCmpCaps = 0xFF; - pCaps->AlphaCmpCaps = 0xFF; - pCaps->StencilCaps = 0xFF; - - pCaps->MaxTextureRepeat = 8192; - pCaps->MaxAnisotropy = 16; - pCaps->MaxPointSize = 256.0f; - pCaps->MaxUserClipPlanes = 6; - pCaps->MaxVertexBlendMatrices = 4; - + MetalDevice8::FillDeviceCaps(pCaps); return D3D_OK; } @@ -270,4 +193,4 @@ static void queryDisplayModes() { return static_cast(CreateMetalInterface8()); } -#endif // __APPLE__ + diff --git a/Platform/MacOS/Source/Metal/dx8wrapper_metal.mm b/Platform/MacOS/Source/Metal/dx8wrapper_metal.mm index f6bf68821b7..9baa3fd3c58 100644 --- a/Platform/MacOS/Source/Metal/dx8wrapper_metal.mm +++ b/Platform/MacOS/Source/Metal/dx8wrapper_metal.mm @@ -592,7 +592,66 @@ return Set_Render_Device(new_dev); } -bool DX8Wrapper::Toggle_Windowed() { return false; } +extern "C" void MacOS_UpdateMetalDeviceScreenSize(int width, int height); +extern "C" void MacOS_ApplyDisplayResolution(int w, int h, bool isWindowed); + +static int s_windowedWidth = 800; +static int s_windowedHeight = 600; + +class DX8WrapperHack : public DX8Wrapper { +public: + static void SetWindowedState(bool w) { + IsWindowed = w; + } +}; + +extern "C" void MacOS_InitWindowedState(bool isWindowed, int xRes, int yRes) { + DX8WrapperHack::SetWindowedState(isWindowed); + DX8Wrapper_IsWindowed = isWindowed; + s_windowedWidth = xRes; + s_windowedHeight = yRes; +} + +bool DX8Wrapper::Toggle_Windowed() { + if (!_Hwnd) return false; + + NSWindow* win = (__bridge NSWindow*)_Hwnd; + + IsWindowed = !IsWindowed; + DX8Wrapper_IsWindowed = IsWindowed; + + if (!IsWindowed) { + // Switch to Fullscreen + s_windowedWidth = ResolutionWidth; + s_windowedHeight = ResolutionHeight; + + NSApp.presentationOptions = NSApplicationPresentationHideDock | NSApplicationPresentationHideMenuBar; + [win setStyleMask:NSWindowStyleMaskBorderless]; + + NSScreen* screen = [win screen] ?: [NSScreen mainScreen]; + ResolutionWidth = (int)screen.frame.size.width; + ResolutionHeight = (int)screen.frame.size.height; + } else { + // Switch to Windowed + NSApp.presentationOptions = NSApplicationPresentationDefault; + [win setStyleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable]; + + ResolutionWidth = s_windowedWidth; + ResolutionHeight = s_windowedHeight; + } + + Resize_And_Position_Window(); + Reset_Device(); + + MacOS_ApplyDisplayResolution(ResolutionWidth, ResolutionHeight, IsWindowed); + + return true; +} + +extern "C" bool MacOS_ToggleFullscreen() { + WW3D::Toggle_Windowed(); + return true; +} bool DX8Wrapper::Set_Device_Resolution(int width, int height, int bits, int windowed, bool resize_window) { @@ -617,64 +676,55 @@ // Windows version: GetClientRect → AdjustWindowRect → SetWindowPos. // macOS equivalent: resize NSWindow content area → update CAMetalLayer → update MetalDevice8. -extern "C" void MacOS_UpdateMetalDeviceScreenSize(int width, int height); - void DX8Wrapper::Resize_And_Position_Window() { if (!_Hwnd) return; NSWindow* win = (__bridge NSWindow*)_Hwnd; NSView* contentView = win.contentView; - CGSize currentSize = contentView.bounds.size; - - if ((int)currentSize.width == ResolutionWidth && - (int)currentSize.height == ResolutionHeight) { - return; - } + CGFloat bsf = win.backingScaleFactor; - printf("[DX8Wrapper] Resize_And_Position_Window: %dx%d -> %dx%d\n", - (int)currentSize.width, (int)currentSize.height, - ResolutionWidth, ResolutionHeight); + printf("[DX8Wrapper] Resize_And_Position_Window: IsWindowed=%d, requested %dx%d\n", + IsWindowed, ResolutionWidth, ResolutionHeight); fflush(stdout); - DEBUG_RENDERING_MAC(("Resize_And_Position_Window: %dx%d -> %dx%d", - (int)currentSize.width, (int)currentSize.height, - ResolutionWidth, ResolutionHeight)); - - // 1. Resize NSWindow (mirrors AdjustWindowRect + SetWindowPos) - NSRect contentRect = NSMakeRect(0, 0, ResolutionWidth, ResolutionHeight); - NSRect newFrame = [win frameRectForContentRect:contentRect]; NSScreen* screen = [win screen] ?: [NSScreen mainScreen]; - NSRect visibleFrame = screen.visibleFrame; - // Clamp newFrame to visibleFrame to prevent it from going under the dock - // or off the top of the screen when Resolution is larger than usable area. - if (newFrame.size.width > visibleFrame.size.width) { - newFrame.size.width = visibleFrame.size.width; - } - if (newFrame.size.height > visibleFrame.size.height) { - newFrame.size.height = visibleFrame.size.height; - } + if (!IsWindowed) { + [win setFrame:screen.frame display:YES animate:NO]; + [win setLevel:NSMainMenuWindowLevel + 1]; + } else { + [win setLevel:NSNormalWindowLevel]; + NSRect contentRect = NSMakeRect(0, 0, ResolutionWidth, ResolutionHeight); + NSRect newFrame = [win frameRectForContentRect:contentRect]; + NSRect visibleFrame = screen.visibleFrame; + + // Clamp newFrame to visibleFrame to prevent it from going under the dock + if (newFrame.size.width > visibleFrame.size.width) { + newFrame.size.width = visibleFrame.size.width; + } + if (newFrame.size.height > visibleFrame.size.height) { + newFrame.size.height = visibleFrame.size.height; + } - newFrame.origin.x = (visibleFrame.size.width - newFrame.size.width) / 2 + visibleFrame.origin.x; - newFrame.origin.y = NSMaxY(visibleFrame) - newFrame.size.height; + newFrame.origin.x = (visibleFrame.size.width - newFrame.size.width) / 2 + visibleFrame.origin.x; + newFrame.origin.y = NSMaxY(visibleFrame) - newFrame.size.height; - [win setFrame:newFrame display:YES animate:NO]; + [win setFrame:newFrame display:YES animate:NO]; + } - // 2. Update CAMetalLayer drawable size - CGFloat bsf = win.backingScaleFactor; + // 2. Update CAMetalLayer drawable size (logical points — contentsScale handles Retina) if (contentView.layer && [contentView.layer isKindOfClass:[CAMetalLayer class]]) { CAMetalLayer* layer = (CAMetalLayer*)contentView.layer; layer.contentsScale = bsf; - // Ensure aspect ratio is maintained and layer is letterboxed if window was clamped layer.contentsGravity = kCAGravityResizeAspect; - layer.drawableSize = CGSizeMake(ResolutionWidth * bsf, ResolutionHeight * bsf); + CGSize logicalSize = win.contentView.bounds.size; + layer.drawableSize = CGSizeMake(logicalSize.width, logicalSize.height); } - // 3. Update MetalDevice8 screen dimensions + depth texture + viewport - MacOS_UpdateMetalDeviceScreenSize(ResolutionWidth * bsf, ResolutionHeight * bsf); - DEBUG_RENDERING_MAC(("Resize_And_Position_Window: after UpdateMetalDeviceScreenSize(%.1f, %.1f)", - ResolutionWidth * bsf, ResolutionHeight * bsf)); + // 3. Update MetalDevice8 screen dimensions + depth texture + viewport (logical points) + CGSize logicalSize = win.contentView.bounds.size; + MacOS_UpdateMetalDeviceScreenSize((int)logicalSize.width, (int)logicalSize.height); } // ── Scene / Frame (copied from dx8wrapper.cpp lines 1816-1984, DX8WebBrowser removed) ── diff --git a/build_run_mac.sh b/build_run_mac.sh index 2b7817a61ae..321bc1a80dd 100755 --- a/build_run_mac.sh +++ b/build_run_mac.sh @@ -128,6 +128,7 @@ killall -9 lldb 2>/dev/null sleep 1 export GENERALS_INSTALL_PATH="/Users/okji/dev/games/General Online Common" +# export GENERALS_INSTALL_PATH="/Users/okji/Documents/Generals Online" # Metal frame rate control: # 60 = VSync (default)