From 9d917e44f97ab3fc682710d2e056f9c4658afa20 Mon Sep 17 00:00:00 2001 From: corepunch Date: Sun, 4 Oct 2026 11:06:02 +0200 Subject: [PATCH] wc3: Support 1.00 CD data and retail campaign selector and UI sounds A RoC CD install ships only the 1.00 War3.mpq, whose CampaignStrings.txt predates the Background/CampaignList schema, so the campaign screen loaded no backdrop and stayed black. Parse that legacy layout (TitleN headers, name-only cinematics with [Label] headers, Backdrop skin fallback) and warn in the console whenever a fallback or a blank backdrop happens. Rebuild the campaign and mission selectors the way patched clients do: runtime rows of arrow/camera buttons in fixed 0.0315625 slots, a blank after each campaign and after an Introduction, centred in a ten-slot box and wheel-scrolled when longer. The static 1.00 FDF rows stay hidden. Show the MainMenuLogo, and restore the selector backdrop on Back. Add interface sounds through the imported PlaySoundByName path: register UISounds.slk rows as named kits when no SoundEntries.dbc exists, play GlueScreenClick on glue clicks, pick kit file variants per play, and fire glue panel SND events by giving UI sprites per-instance event state and non-positional playback. Co-Authored-By: Claude Opus 5.5 --- architecture/sound.md | 6 + client/cl_main.c | 4 +- client/cl_movie.c | 3 +- client/tr_public.h | 2 +- common/shared.h | 1 + docs/games/warcraft-3/campaign-progress.md | 10 + games/warcraft-3/menu/menu_local.h | 1 + games/warcraft-3/menu/menu_main.c | 5 + games/warcraft-3/menu/menu_render.c | 14 + games/warcraft-3/menu/menu_screen.h | 2 + games/warcraft-3/menu/screens/single_player.c | 402 +++++++++++++----- games/warcraft-3/renderer/mdx/r_mdx_render.c | 3 +- games/warcraft-3/renderer/r_game.c | 31 +- .../resources-src/TestUI/CampaignStrings.txt | 29 ++ .../FrameDef/Glue/StandardTemplates.fdf | 10 + games/warcraft-3/tests/test_menu_fdf.c | 209 +++++---- sound/s_local.h | 9 +- sound/s_sound.c | 132 ++++-- tests/test_sound.c | 68 ++- 19 files changed, 691 insertions(+), 250 deletions(-) diff --git a/architecture/sound.md b/architecture/sound.md index 7dabea204..6ad3fc38b 100644 --- a/architecture/sound.md +++ b/architecture/sound.md @@ -110,6 +110,12 @@ Model animation sounds are client presentation, not server simulation state. The WC3 evaluates these event tracks before frustum culling so a client-visible model may still be audible while off-screen. The renderer owns event-key de-duplication per entity/model and resolves the animated event node to world space through the existing MDX node matrices. This local path currently passes authored volume; pitch variance and authored panner distances require a future generic mixer/API extension rather than WC3-specific client branches. +UI sprites (`DrawSprite`: glue panels, button particles) render with `RDF_UI_SPRITE`. They all carry entity number 0, so their event state is keyed by `renderEntity_t.instance_id` (the sprite's owner id), exactly like entity-camera views; only `SND` events are dispatched, and they are passed to `PlaySoundAt` with a NULL origin, which the client plays non-positionally. A sprite's first sighting counts as entering its sequence, so keys on the first frame of a glue `Birth`/`Morph` (e.g. `SNDXALPD`, `LeftGlueScreenPopDown`) still play. + +## Named interface sounds + +Menus play named kits through the imported `PlaySoundByName`. World of Warcraft fills the kit table from `DBFilesClient\SoundEntries.dbc`; when that DBC is absent (Warcraft III), `S_LoadSoundEntries` registers `UI\SoundInfo\UISounds.slk` rows as kits through a `slkField_t` schema (label, `FileNames` variants, `DirectoryBase`, `Volume` on the SLK's 0..127 scale). The WC3 glue menu plays `GlueScreenClick` on every completed button/checkbox click. Kit lookup is case-insensitive, and an unknown name is reported once on stderr. Each play picks one file variant (DBC `freq` weights when authored, otherwise uniform over the SLK's comma-separated `FileNames`) and loads it through the path-keyed `sfx_t` table, so kit variants share the normal registration/eviction lifetime. + ## Assets diff --git a/client/cl_main.c b/client/cl_main.c index 8c2d1ea2c..ebdd7aec5 100644 --- a/client/cl_main.c +++ b/client/cl_main.c @@ -911,9 +911,9 @@ TEST(client_session, menu_rebuild_clears_world_scope_before_returning_to_menu) { #endif +/* A NULL origin is a screen-space (UI) presentation sound and plays without distance attenuation. */ static void CL_RendererPlaySoundAt(cstring_t path, vec3_t const *origin, float volume) { - if (!origin) return; - S_PlaySoundPacket(path, origin, true, CHAN_AUTO, volume, 1.0f, 0.0f); + S_PlaySoundPacket(path, origin, origin != NULL, CHAN_AUTO, volume, 1.0f, 0.0f); } void CL_Init(void) { diff --git a/client/cl_movie.c b/client/cl_movie.c index cd53d38ff..3752a092d 100644 --- a/client/cl_movie.c +++ b/client/cl_movie.c @@ -349,8 +349,7 @@ void CL_MovieShutdown(void) { bool CL_PlayMovie(cstring_t path) { #ifndef BZ_FFMPEG - (void)path; - CON_printf("Movie playback is disabled in this build (rebuild with FFMPEG=1)."); + CON_printf("Skipping movie %s: playback is disabled in this build (rebuild with FFMPEG=1).", path ? path : ""); return false; #else AVCodec const *video_decoder; diff --git a/client/tr_public.h b/client/tr_public.h index d137e6665..6b920f886 100644 --- a/client/tr_public.h +++ b/client/tr_public.h @@ -108,7 +108,7 @@ typedef struct { void (*MemFree)(handle_t); uint32_t (*LoadSlk)(cstring_t filename, slkField_t const *schema, void **dest, uint32_t row_stride); cstring_t (*CvarString)(cstring_t name, cstring_t fallback); - void (*PlaySoundAt)(cstring_t path, vec3_t const *origin, float volume); + void (*PlaySoundAt)(cstring_t path, vec3_t const *origin, float volume); /* NULL origin: non-positional UI sound */ void (*error)(cstring_t fmt, ...); } refImport_t; diff --git a/common/shared.h b/common/shared.h index 787fffa39..53bf60aa5 100644 --- a/common/shared.h +++ b/common/shared.h @@ -256,6 +256,7 @@ enum { FLAG(RDF_NOPARTICLES, 4), FLAG(RDF_USE_ENTITY_CAMERA, 5), FLAG(RDF_ISOLATED_PARTICLES, 6), + FLAG(RDF_UI_SPRITE, 7), /* screen-space UI model: events keyed by instance_id, sounds non-positional */ }; #define MAX_COMMANDS 12 diff --git a/docs/games/warcraft-3/campaign-progress.md b/docs/games/warcraft-3/campaign-progress.md index ed476a81e..fdf272e95 100644 --- a/docs/games/warcraft-3/campaign-progress.md +++ b/docs/games/warcraft-3/campaign-progress.md @@ -156,6 +156,16 @@ hard-coded by race. Campaign and mission rows are rebuilt/reloaded when entering the campaign selector, so progress written by the preceding map is visible after the session returns to the frontend. +### Selector rows + +Patched clients build both selector columns at runtime from `CampaignStrings` (Warsmash's `CampaignMenuUI` does the same); the static `TutorialFrame` … `NightElfFrame` and `Mission0Frame` … `Mission13Frame` rows authored by the 1.00 `CampaignMenu.fdf` are always hidden. Each row clones `CampaignArrowButtonTemplate` (maps, campaigns) or `CampaignCameraButtonTemplate` (movies), `StandardSmallTextTemplate` for the gold header and `StandardTitleTextTemplate` (grey 0.764) for the name, laid out like the 1.00 rows. + +Rows occupy fixed 0.0315625 slots; ten slots (the height of TFT `CampaignListBox.fdf`'s scroll bar, 0.315625) make the box, whose top-left sits at `(-0.287, -0.1274)` from `CampaignMenu`'s top-right. Every campaign is followed by a blank slot, and an Introduction cinematic by one blank slot. A column that fits is centred in the box; a longer one starts at the top and scrolls with the mouse wheel (the screen's `scroll` hook). These constants were fitted to retail 1.2x captures: two campaigns centre at 0.236/0.301 below the top edge, and the Prologue mission column (Intro, blank, Opening, two chapters) centres at 0.285 with 0.032 spacing, both within about a pixel. The earlier static-row layout sat ~0.03 higher because the 1.00 FDF rows are not what patched clients draw. The `WarCraftIIILogo` sprite's FDF offset leaves it off-screen, so the menu loads `MainMenuLogo` and anchors it at `TOPRIGHT (-0.13, -0.08)`, mirroring the main menu (and Warsmash). + +Choosing a campaign swaps the backdrop to that campaign's scene and plays its `Birth` camera move; Back from the mission column restores the selector's default-campaign scene with the same move. + +The 1.00 `War3.mpq` `CampaignStrings.txt` also predates the later schema: it has no `[Index]`, `Background`, `Cursor`, or `DefaultOpen`, splits missions into `TitleN`/`MissionN`/`FileN`, and names cinematics only by title (`InCinematic`/`OpCinematic`/`EdCinematic`, headers in `[Label]`). The parser maps those to `In/Op/Ed` movies and the `Backdrop` skin, printing a console warning for each campaign that falls back. Without `DefaultOpen`, `unlocked` visibility shows nothing until progress exists; the default `all` shows every campaign. + The older `wc3_campaign_mission_visibility=played` and `wc3_campaign_played__` frontend bridge is removed. Merely launching a map is not campaign progression; authored JASS availability natives diff --git a/games/warcraft-3/menu/menu_local.h b/games/warcraft-3/menu/menu_local.h index 0cf273c05..592f40ef4 100644 --- a/games/warcraft-3/menu/menu_local.h +++ b/games/warcraft-3/menu/menu_local.h @@ -179,6 +179,7 @@ void UI_DrawFrame(frameDef_t const *frame); void UI_DrawFrames(frameDef_t const *const *roots, uint32_t num_roots); bool M_EditKey(int key); bool M_MouseEvent(menuMouseEvent_t event, int x, int y, int32_t param); +bool UI_FrameContainsPoint(frameDef_t const *frame, float fdf_x, float fdf_y); void M_TextInput(cstring_t text); void UI_EditTextInput(cstring_t text); bool UI_EditHasFocus(frameDef_t const *frame); diff --git a/games/warcraft-3/menu/menu_main.c b/games/warcraft-3/menu/menu_main.c index e588f2cd4..ab46400a3 100644 --- a/games/warcraft-3/menu/menu_main.c +++ b/games/warcraft-3/menu/menu_main.c @@ -502,6 +502,11 @@ bool M_MouseEvent(menuMouseEvent_t event, int x, int y, int32_t param) { frameDef_t const *hit = UI_HitTest(fdf.x, fdf.y); UI_UpdateMouseFrameFlags(hit, up && left); + if (wheel_y && UI_GetCurrentScreen() && UI_GetCurrentScreen()->scroll && + UI_GetCurrentScreen()->scroll(fdf.x, fdf.y, wheel_y)) { + return true; + } + /* Dispatch to per-type event handler */ if (hit && hit->event_handler) { hit->event_handler((frameDef_t *)hit, event, fdf.x, fdf.y, param); diff --git a/games/warcraft-3/menu/menu_render.c b/games/warcraft-3/menu/menu_render.c index 2dc0eee4f..9e4e1bd5c 100644 --- a/games/warcraft-3/menu/menu_render.c +++ b/games/warcraft-3/menu/menu_render.c @@ -100,6 +100,13 @@ static bool UI_PointInRect(float x, float y, rect_t const *rect) { y >= rect->y && y < rect->y + rect->h; } +/* Uses the rectangle from the last laid-out frame, the same space UI_HitTest reads. */ +bool UI_FrameContainsPoint(frameDef_t const *frame, float fdf_x, float fdf_y) { + uint32_t index; + return frame && !frame->hidden && UI_FrameIndex(frame, &index) && runtimes[index].calculated && + UI_PointInRect(fdf_x, fdf_y, &runtimes[index].rect); +} + static bool UI_FrameIsInteractive(frameDef_t const *frame) { if (!frame || frame->hidden || frame->disabled || (frame->ui_flags & UIFLAG_PASSTHROUGH)) { return false; @@ -476,6 +483,11 @@ static void UI_DrawHighlightFrame(frameDef_t const *frame, rect_t const *rect); * PER-TYPE EVENT HANDLERS — called from UI_MouseEventLocal * ======================================================================== */ +/* Glue controls share UISounds.slk's GlueScreenClick on a completed click, as retail/Warsmash do. */ +static void UI_PlayGlueClick(void) { + if (mi.PlaySoundByName) mi.PlaySoundByName("GlueScreenClick"); +} + static void UI_ButtonEventHandler(frameDef_t *frame, menuMouseEvent_t event, float fdf_x, float fdf_y, int32_t param) { (void)fdf_x; (void)fdf_y; if (param != 1) { @@ -486,6 +498,7 @@ static void UI_ButtonEventHandler(frameDef_t *frame, menuMouseEvent_t event, flo } else if (event == MENU_MOUSE_UP) { frame->ui_flags &= ~UIFLAG_PRESSED; if (frame->OnClick[0]) { + UI_PlayGlueClick(); UI_QueueCommand(frame->OnClick); } } @@ -502,6 +515,7 @@ static void UI_CheckBoxEventHandler(frameDef_t *frame, menuMouseEvent_t event, f frame->ui_flags &= ~UIFLAG_PRESSED; frame->ui_flags ^= UIFLAG_CHECKED; ((frameDef_t *)frame)->CheckBox.Checked = (frame->ui_flags & UIFLAG_CHECKED) != 0; + UI_PlayGlueClick(); if (frame->OnClick[0]) { UI_QueueCommand(frame->OnClick); } diff --git a/games/warcraft-3/menu/menu_screen.h b/games/warcraft-3/menu/menu_screen.h index 170b793e1..0913c90e1 100644 --- a/games/warcraft-3/menu/menu_screen.h +++ b/games/warcraft-3/menu/menu_screen.h @@ -20,6 +20,8 @@ typedef struct uiScreen_s { void (*refresh)(int msec); void (*draw)(void); void (*key_event)(int key, bool down); + /* Mouse wheel over screen-owned content; return true when consumed so it does not reach the hit frame. */ + bool (*scroll)(float fdf_x, float fdf_y, int delta); } uiScreen_t; /* Screen implementations */ diff --git a/games/warcraft-3/menu/screens/single_player.c b/games/warcraft-3/menu/screens/single_player.c index cb7219530..8efb57b06 100644 --- a/games/warcraft-3/menu/screens/single_player.c +++ b/games/warcraft-3/menu/screens/single_player.c @@ -7,6 +7,7 @@ #include "../generated/single_player_menu.h" #include "common/campaign_progress.h" #include +#include #include #ifndef _WIN32 #include @@ -14,9 +15,12 @@ #define SINGLE_PLAYER_MAX_CAMPAIGNS 16 // campaigns; UI parse/storage capacity; bounds authored campaign entries #define SINGLE_PLAYER_MAX_MISSIONS 128 // missions; UI parse/storage capacity; bounds authored mission entries -#define SINGLE_PLAYER_CAMPAIGN_VISIBLE_ROWS 5 // rows; keeps five campaign entries visible before scrolling -#define SINGLE_PLAYER_CAMPAIGN_LIST_HEIGHT 0.13f // FDF units; five rows plus list insets -#define SINGLE_PLAYER_MISSION_VISIBLE_ROWS 14 // rows; visible mission list capacity; controls listbox pagination +#define SINGLE_PLAYER_LIST_ROWS 10 // rows; TFT CampaignListBox.fdf scroll bar (0.315625) holds ten list slots +#define SINGLE_PLAYER_LIST_ROW_HEIGHT 0.0315625f // FDF units; one slot; retail rows measure 0.032 apart +#define SINGLE_PLAYER_LIST_WIDTH 0.27f // FDF units; arrow, header and name column +#define SINGLE_PLAYER_LIST_X -0.287f // FDF units from CampaignMenu TOPRIGHT; retail arrows start at x=0.513 +#define SINGLE_PLAYER_LIST_Y -0.1274f // FDF units from CampaignMenu TOPRIGHT; centres the box 0.285 below the top +#define SINGLE_PLAYER_LIST_MAX_ENTRIES (UI_MAX_MAP_LIST_ITEMS * 2) // entries; every campaign row is followed by a blank #define SINGLE_PLAYER_CAMPAIGN_VISIBILITY_CVAR "wc3_campaign_visibility" #define SINGLE_PLAYER_LIST_FLAG_CINEMATIC 0x80000000u // bit; marks cinematic list items; separates them from mission indices #define SINGLE_PLAYER_LIST_INDEX_MASK 0x7fffffffu // bitmask; retains the 31-bit item index; strips the cinematic marker @@ -80,15 +84,51 @@ static cstring_t const solo_lft[] = { NULL, }; +/* Data fallbacks and failures that blank the campaign screen must be visible in the in-game console, not only stderr. */ +static void SinglePlayer_Warn(cstring_t fmt, ...) { + char message[512]; + va_list args; + + va_start(args, fmt); + vsnprintf(message, sizeof(message), fmt, args); + va_end(args); + fprintf(stderr, "UI: warning: %s\n", message); + if (mi.Printf) mi.Printf("Warning: %s", message); +} + static SinglePlayerMenu_t single_player; + +/* Retail builds both selectors from CampaignStrings at runtime (Warsmash's CampaignMenuUI does the same): rows of + * an arrow (map/campaign) or camera (movie) button with a gold header and a grey name, laid out in fixed slots. + * Campaigns are separated by a blank slot and the Introduction cinematic is followed by one; a column that fits is + * centred in the ten-slot box, a longer one scrolls. The static TutorialFrame/Mission0Frame rows authored by the + * 1.00 CampaignMenu.fdf are never shown by patched clients, so they stay hidden. */ +typedef struct { + frameDef_t *slot, *arrow, *camera, *label, *desc; +} singlePlayerListRow_t; + +typedef struct { + uint32_t item; // index into the list's uiMapListState_t; UINT32_MAX for a blank slot + bool camera; + cstring_t header, name; +} singlePlayerListEntry_t; + +typedef struct { + frameDef_t *box; + singlePlayerListRow_t rows[SINGLE_PLAYER_LIST_ROWS]; + singlePlayerListEntry_t entries[SINGLE_PLAYER_LIST_MAX_ENTRIES]; + uint32_t count, scroll; + cstring_t command; +} singlePlayerList_t; + +static singlePlayerList_t campaign_rows = { .command = "menu_single_player_campaign_select %u" }; +static singlePlayerList_t mission_rows = { .command = "menu_single_player_mission_select %u" }; static singlePlayerCampaign_t campaigns[SINGLE_PLAYER_MAX_CAMPAIGNS]; static uint32_t campaign_count; static uint32_t campaign_order[SINGLE_PLAYER_MAX_CAMPAIGNS]; static uint32_t campaign_order_count; static uiMapListState_t campaign_list; -static frameDef_t *campaign_list_frame; static uiMapListState_t mission_list; -static frameDef_t *mission_list_frame; static uint32_t campaign_background_model = 0; static uiBirthSequence_t campaign_background_birth; static bool campaign_background_has_render_time; @@ -274,6 +314,17 @@ static void SinglePlayer_ParseFileValue(singlePlayerCampaign_t *campaign, uint32 SinglePlayer_SetMissionCount(campaign, index); } +static void SinglePlayer_ParseTitleValue(singlePlayerCampaign_t *campaign, uint32_t index, char *value) { + char *cursor = value; + UINAME header; + + if (!campaign || index >= SINGLE_PLAYER_MAX_MISSIONS || !SinglePlayer_ReadQuoted(&cursor, header, sizeof(header))) { + return; + } + snprintf(campaign->missions[index].header, sizeof(campaign->missions[index].header), "%s", header); + SinglePlayer_SetMissionCount(campaign, index); +} + static void SinglePlayer_ParseCinematicValue(singlePlayerCinematic_t *cinematic, char *value) { char *cursor = value; UINAME header; @@ -317,7 +368,53 @@ static bool SinglePlayer_ParseCampaignField(singlePlayerCampaign_t *campaign, cs return false; } +/* Retail 1.00 War3.mpq predates the CampaignList schema: missions are split into TitleN/MissionN/FileN, + * cinematics carry only a display name, and their shared headers live in a [Label] section. */ +static cstring_t const legacy_cinematic_keys[SINGLE_PLAYER_CINEMATIC_COUNT] = { "InCinematic", "OpCinematic", "EdCinematic" }; +static cstring_t const legacy_cinematic_suffixes[SINGLE_PLAYER_CINEMATIC_COUNT] = { "In", "Op", "Ed" }; +static UINAME legacy_cinematic_labels[SINGLE_PLAYER_CINEMATIC_COUNT]; + +static bool SinglePlayer_ParseLegacyCinematic(singlePlayerCampaign_t *campaign, cstring_t key, char *value) { + FOR_LOOP(i, SINGLE_PLAYER_CINEMATIC_COUNT) { + singlePlayerCinematic_t *cinematic; + char *cursor = value; + UINAME name; + + if (strcasecmp(key, legacy_cinematic_keys[i])) continue; + if (!campaign) { + if (SinglePlayer_ReadQuoted(&cursor, name, sizeof(name))) + snprintf(legacy_cinematic_labels[i], sizeof(legacy_cinematic_labels[i]), "%s", name); + return true; + } + cinematic = &campaign->cinematics[i]; + if (!SinglePlayer_ReadQuoted(&cursor, name, sizeof(name)) || !name[0]) return true; + snprintf(cinematic->name, sizeof(cinematic->name), "%s", name); + snprintf(cinematic->movie_path, sizeof(cinematic->movie_path), "%s%s", campaign->key, legacy_cinematic_suffixes[i]); + return true; + } + return false; +} + +/* Later CampaignStrings name the war3skins backdrop explicitly; 1.00 relies on the Backdrop skin entry. */ +static void SinglePlayer_FinalizeLegacyCampaigns(void) { + FOR_LOOP(i, campaign_count) { + singlePlayerCampaign_t *campaign = &campaigns[i]; + if (!campaign->background[0]) { + snprintf(campaign->background, sizeof(campaign->background), "%.*sBackdrop", + (int)(sizeof(campaign->background) - 9), campaign->key); + SinglePlayer_Warn("CampaignStrings: campaign '%s' has no Background key (pre-1.01 data?); using skin '%s'", + campaign->key, campaign->background); + } + FOR_LOOP(j, SINGLE_PLAYER_CINEMATIC_COUNT) { + singlePlayerCinematic_t *cinematic = &campaign->cinematics[j]; + if (cinematic->movie_path[0] && !cinematic->header[0]) + snprintf(cinematic->header, sizeof(cinematic->header), "%s", legacy_cinematic_labels[j]); + } + } +} + static void SinglePlayer_ParseCampaignLine(singlePlayerCampaign_t *campaign, char *key, char *value) { + if (SinglePlayer_ParseLegacyCinematic(campaign, key, value)) return; if (!campaign) return; if (SinglePlayer_ParseCampaignField(campaign, key, value)) return; if (!strcasecmp(key, "IntroCinematic")) { @@ -330,6 +427,7 @@ static void SinglePlayer_ParseCampaignLine(singlePlayerCampaign_t *campaign, cha uint32_t index; if (SinglePlayer_ParseIndexedKey(key, "Mission", &index)) SinglePlayer_ParseMissionValue(campaign, index, value); else if (SinglePlayer_ParseIndexedKey(key, "File", &index)) SinglePlayer_ParseFileValue(campaign, index, value); + else if (SinglePlayer_ParseIndexedKey(key, "Title", &index)) SinglePlayer_ParseTitleValue(campaign, index, value); } } @@ -381,7 +479,8 @@ static bool SinglePlayer_LoadCampaignFile(cstring_t file_name) { } *end = '\0'; snprintf(section, sizeof(section), "%s", SinglePlayer_Trim(key + 1)); - campaign = strcasecmp(section, "Index") ? SinglePlayer_EnsureCampaign(section) : NULL; + campaign = strcasecmp(section, "Index") && strcasecmp(section, "Label") + ? SinglePlayer_EnsureCampaign(section) : NULL; continue; } char *eq = strchr(key, '='); @@ -400,6 +499,7 @@ static bool SinglePlayer_LoadCampaignFile(cstring_t file_name) { } mi.MemFree(text); + SinglePlayer_FinalizeLegacyCampaigns(); return campaign_count > 0; } @@ -425,6 +525,7 @@ static void SinglePlayer_LoadCampaignData(void) { memset(campaigns, 0, sizeof(campaigns)); memset(campaign_order, 0, sizeof(campaign_order)); + memset(legacy_cinematic_labels, 0, sizeof(legacy_cinematic_labels)); campaign_count = 0; campaign_order_count = 0; @@ -477,6 +578,22 @@ static void SinglePlayer_SetHidden(frameDef_t *frame, bool hidden) { } } +static void SinglePlayer_HideAuthoredRows(void) { + frameDef_t *const frames[] = { + single_player.TutorialFrame, single_player.HumanFrame, single_player.UndeadFrame, + single_player.OrcFrame, single_player.NightElfFrame, + }; + char name[32]; + + FOR_LOOP(i, sizeof(frames) / sizeof(frames[0])) SinglePlayer_SetHidden(frames[i], true); + for (uint32_t i = 0; single_player.MissionSelectFrame; i++) { + frameDef_t *row; + snprintf(name, sizeof(name), "Mission%uFrame", (unsigned)i); + if (!(row = UI_FindChildFrame(single_player.MissionSelectFrame, name))) break; + SinglePlayer_SetHidden(row, true); + } +} + static void SinglePlayer_SetView(singlePlayerView_t view) { bool const show_campaign = view == SINGLE_PLAYER_VIEW_CAMPAIGN_SELECT || view == SINGLE_PLAYER_VIEW_MISSION_SELECT; @@ -491,18 +608,13 @@ static void SinglePlayer_SetView(singlePlayerView_t view) { SinglePlayer_SetHidden(single_player.CampaignBackdrop_2, true); SinglePlayer_SetHidden(single_player.CampaignSelectFrame, view != SINGLE_PLAYER_VIEW_CAMPAIGN_SELECT); SinglePlayer_SetHidden(single_player.MissionSelectFrame, view != SINGLE_PLAYER_VIEW_MISSION_SELECT); - SinglePlayer_SetHidden(single_player.TutorialFrame, true); - SinglePlayer_SetHidden(single_player.HumanFrame, true); - SinglePlayer_SetHidden(single_player.TutorialButton, true); - SinglePlayer_SetHidden(single_player.HumanButton, true); + SinglePlayer_HideAuthoredRows(); SinglePlayer_SetHidden(single_player.SlidingDoors, true); - SinglePlayer_SetHidden(campaign_list_frame, - view != SINGLE_PLAYER_VIEW_CAMPAIGN_SELECT); - SinglePlayer_SetHidden(mission_list_frame, - view != SINGLE_PLAYER_VIEW_MISSION_SELECT); } static void SinglePlayer_SetCampaignBackdrop(singlePlayerCampaign_t const *campaign) { + if (campaign && !campaign->background[0]) + SinglePlayer_Warn("campaign '%s' has no backdrop model; the campaign screen will be black", campaign->key); if (single_player.CampaignBackdrop_2 && campaign && campaign->background[0]) { SinglePlayer_ReleaseCampaignBackdropState(); campaign_background_model = UI_LoadModel(campaign->background, true); @@ -510,8 +622,8 @@ static void SinglePlayer_SetCampaignBackdrop(singlePlayerCampaign_t const *campa fprintf(stderr, "[UI] Campaign backdrop: skin=\"%s\" model_idx=%u\n", campaign->background, (unsigned)campaign_background_model); if (!campaign_background_model || !UI_GetModel(campaign_background_model)) { - fprintf(stderr, "UI: campaign backdrop '%s' did not resolve to a loaded model\n", - campaign->background); + SinglePlayer_Warn("campaign backdrop '%s' did not resolve to a loaded model; the campaign screen will be black", + campaign->background); } } } @@ -735,6 +847,131 @@ static void SinglePlayer_PopulateMissionList(singlePlayerCampaign_t const *campa #endif } +static frameDef_t *SinglePlayer_CloneTemplate(cstring_t template_name, frameDef_t *parent, cstring_t suffix) { + frameDef_t const *source = UI_FindFrame(template_name); + frameDef_t *frame = source ? UI_CloneFrameTree(source, parent) : NULL; + + if (!frame) { + SinglePlayer_Warn("campaign list template '%s' is missing; selector rows cannot be built", template_name); + return NULL; + } + snprintf(frame->Name, sizeof(frame->Name), "%s%s", parent->Name, suffix); + return frame; +} + +/* The FDF tree outlives screen re-inits, so a box built by an earlier init is re-bound by name, not respawned. */ +static bool SinglePlayer_BindList(singlePlayerList_t *list, frameDef_t *parent, cstring_t name) { + char row_name[sizeof(UINAME)]; + + if (!(list->box = UI_FindChildFrame(parent, name))) return false; + FOR_LOOP(i, SINGLE_PLAYER_LIST_ROWS) { + singlePlayerListRow_t *row = &list->rows[i]; + cstring_t const parts[] = { "Button", "CameraButton", "Label", "Desc" }; + frameDef_t **frames[] = { &row->arrow, &row->camera, &row->label, &row->desc }; + + snprintf(row_name, sizeof(row_name), "%sRow%u", name, (unsigned)i); + row->slot = UI_FindChildFrame(list->box, row_name); + FOR_LOOP(j, sizeof(parts) / sizeof(parts[0])) { + char part_name[sizeof(UINAME) * 2]; + snprintf(part_name, sizeof(part_name), "%s%s", row_name, parts[j]); + *frames[j] = row->slot ? UI_FindChildFrame(row->slot, part_name) : NULL; + } + if (!row->arrow || !row->camera || !row->label || !row->desc) memset(row, 0, sizeof(*row)); + } + return true; +} + +/* Build the fixed row widgets once; entries are mapped onto them on every fill or scroll. */ +static void SinglePlayer_CreateList(singlePlayerList_t *list, frameDef_t *parent, cstring_t name) { + color32_t const name_color = { .r = 195, .g = 195, .b = 195, .a = 255 }; // 0.764, as the 1.00 static rows + + memset(list->rows, 0, sizeof(list->rows)); + list->box = NULL; + if (!parent || !single_player.CampaignMenu || SinglePlayer_BindList(list, parent, name)) return; + list->box = UI_Spawn(FT_FRAME, parent); + if (!list->box) return; + snprintf(list->box->Name, sizeof(list->box->Name), "%s", name); + UI_SetSize(list->box, SINGLE_PLAYER_LIST_WIDTH, SINGLE_PLAYER_LIST_ROWS * SINGLE_PLAYER_LIST_ROW_HEIGHT); + UI_SetPoint(list->box, FRAMEPOINT_TOPLEFT, single_player.CampaignMenu, FRAMEPOINT_TOPRIGHT, + SINGLE_PLAYER_LIST_X, SINGLE_PLAYER_LIST_Y); + FOR_LOOP(i, SINGLE_PLAYER_LIST_ROWS) { + singlePlayerListRow_t *row = &list->rows[i]; + + row->slot = UI_Spawn(FT_FRAME, list->box); + if (!row->slot) return; + snprintf(row->slot->Name, sizeof(row->slot->Name), "%sRow%u", name, (unsigned)i); + UI_SetSize(row->slot, SINGLE_PLAYER_LIST_WIDTH, SINGLE_PLAYER_LIST_ROW_HEIGHT); + row->arrow = SinglePlayer_CloneTemplate("CampaignArrowButtonTemplate", row->slot, "Button"); + row->camera = SinglePlayer_CloneTemplate("CampaignCameraButtonTemplate", row->slot, "CameraButton"); + row->label = SinglePlayer_CloneTemplate("StandardSmallTextTemplate", row->slot, "Label"); + row->desc = SinglePlayer_CloneTemplate("StandardTitleTextTemplate", row->slot, "Desc"); + if (!row->arrow || !row->camera || !row->label || !row->desc) { + memset(row, 0, sizeof(*row)); + return; + } + /* Same geometry as the authored 1.00 rows: label beside the button, name under the label. */ + UI_SetPoint(row->arrow, FRAMEPOINT_LEFT, row->slot, FRAMEPOINT_LEFT, 0, 0); + UI_SetPoint(row->camera, FRAMEPOINT_LEFT, row->slot, FRAMEPOINT_LEFT, 0, 0); + UI_SetPoint(row->label, FRAMEPOINT_TOPLEFT, row->arrow, FRAMEPOINT_TOPRIGHT, 0.004f, -0.005f); + UI_SetPoint(row->desc, FRAMEPOINT_TOPLEFT, row->label, FRAMEPOINT_BOTTOMLEFT, 0, 0); + row->desc->Font.Color = name_color; + } +} + +static void SinglePlayer_LayoutList(singlePlayerList_t *list) { + uint32_t const max_scroll = list->count > SINGLE_PLAYER_LIST_ROWS ? list->count - SINGLE_PLAYER_LIST_ROWS : 0; + float const top = list->count < SINGLE_PLAYER_LIST_ROWS + ? (float)(SINGLE_PLAYER_LIST_ROWS - list->count) * SINGLE_PLAYER_LIST_ROW_HEIGHT * 0.5f : 0.0f; + + list->scroll = MIN(list->scroll, max_scroll); + FOR_LOOP(i, SINGLE_PLAYER_LIST_ROWS) { + singlePlayerListRow_t const *row = &list->rows[i]; + uint32_t const index = list->scroll + i; + singlePlayerListEntry_t const *entry = index < list->count ? &list->entries[index] : NULL; + bool const blank = !entry || entry->item == UINT32_MAX; + + if (!row->slot) continue; + UI_SetPoint(row->slot, FRAMEPOINT_TOPLEFT, list->box, FRAMEPOINT_TOPLEFT, + 0, -(top + (float)i * SINGLE_PLAYER_LIST_ROW_HEIGHT)); + SinglePlayer_SetHidden(row->slot, blank); + if (blank) continue; + UI_SetText(row->label, "%s", entry->header ? entry->header : ""); + UI_SetText(row->desc, "%s", entry->name ? entry->name : ""); + UI_SetOnClick(row->arrow, list->command, (unsigned)entry->item); + UI_SetOnClick(row->camera, list->command, (unsigned)entry->item); + SinglePlayer_SetHidden(row->arrow, entry->camera); + SinglePlayer_SetHidden(row->camera, !entry->camera); + } +} + +static void SinglePlayer_AddListEntry(singlePlayerList_t *list, uint32_t item, bool camera, + cstring_t header, cstring_t name) { + if (list->count >= SINGLE_PLAYER_LIST_MAX_ENTRIES) return; + list->entries[list->count++] = (singlePlayerListEntry_t){ item, camera, header, name }; +} + +static void SinglePlayer_FillMissionRows(singlePlayerCampaign_t const *campaign) { + mission_rows.count = mission_rows.scroll = 0; + FOR_LOOP(i, mission_list.count) { + uint32_t const flags = mission_list.items[i].flags; + uint32_t const index = flags & SINGLE_PLAYER_LIST_INDEX_MASK; + bool const cinematic = (flags & SINGLE_PLAYER_LIST_FLAG_CINEMATIC) != 0; + cstring_t header = "", name = mission_list.items[i].name; + + if (cinematic && index < SINGLE_PLAYER_CINEMATIC_COUNT) { + header = campaign->cinematics[index].header; + name = campaign->cinematics[index].name; + } else if (!cinematic && index < campaign->num_missions) { + header = campaign->missions[index].header; + name = campaign->missions[index].name[0] ? campaign->missions[index].name : name; + } + SinglePlayer_AddListEntry(&mission_rows, i, cinematic, header, name); + if (cinematic && index == SINGLE_PLAYER_CINEMATIC_INTRO && i + 1 < mission_list.count) + SinglePlayer_AddListEntry(&mission_rows, UINT32_MAX, false, NULL, NULL); + } + SinglePlayer_LayoutList(&mission_rows); +} + static void SinglePlayer_PopulateMissionSelect(singlePlayerCampaign_t const *campaign) { if (!campaign) { return; @@ -746,6 +983,7 @@ static void SinglePlayer_PopulateMissionSelect(singlePlayerCampaign_t const *cam UI_SetText(single_player.MissionNameHeader, "%s", campaign->header); } SinglePlayer_PopulateMissionList(campaign); + SinglePlayer_FillMissionRows(campaign); } static void SinglePlayer_SelectCampaign(singlePlayerCampaign_t const *campaign) { @@ -785,6 +1023,13 @@ static void SinglePlayer_PopulateCampaignList(void) { item->flags = campaign_index; } + campaign_rows.count = campaign_rows.scroll = 0; + FOR_LOOP(i, campaign_list.count) { + singlePlayerCampaign_t const *campaign = &campaigns[campaign_list.items[i].flags]; + SinglePlayer_AddListEntry(&campaign_rows, i, false, campaign->header, campaign->name); + SinglePlayer_AddListEntry(&campaign_rows, UINT32_MAX, false, NULL, NULL); + } + SinglePlayer_LayoutList(&campaign_rows); fprintf(stderr, "Campaign screen: %u campaign(s) listed\n", (unsigned)campaign_list.count); FOR_LOOP(i, campaign_list.count) { uiMapListItem_t const *item = &campaign_list.items[i]; @@ -793,69 +1038,6 @@ static void SinglePlayer_PopulateCampaignList(void) { } } -static void SinglePlayer_CreateCampaignList(void) { - frameDef_t *template_frame; - - if (campaign_list_frame || !single_player.CampaignSelectFrame) { - return; - } - - template_frame = UI_FindFrame("MapListBox"); - if (!template_frame) { - return; - } - - campaign_list_frame = UI_CloneFrameTree(template_frame, single_player.CampaignSelectFrame); - if (!campaign_list_frame) { - return; - } - - SinglePlayer_PopulateCampaignList(); - UI_SetSize(campaign_list_frame, 0.34f, SINGLE_PLAYER_CAMPAIGN_LIST_HEIGHT); - UI_SetPoint(campaign_list_frame, - FRAMEPOINT_BOTTOMLEFT, - single_player.BackButton, - FRAMEPOINT_TOPLEFT, - -0.14f, - 0.04f); - UI_BindMapList(campaign_list_frame, - &campaign_list, - single_player.DifficultySelectLabel, - SINGLE_PLAYER_CAMPAIGN_VISIBLE_ROWS, - "menu_single_player_campaign_select %u"); -} - -static void SinglePlayer_CreateMissionList(void) { - frameDef_t *template_frame; - - if (mission_list_frame || !single_player.MissionSelectFrame) { - return; - } - - template_frame = UI_FindFrame("MapListBox"); - if (!template_frame) { - return; - } - - mission_list_frame = UI_CloneFrameTree(template_frame, single_player.MissionSelectFrame); - if (!mission_list_frame) { - return; - } - - UI_SetSize(mission_list_frame, 0.34f, 0.28f); - UI_SetPoint(mission_list_frame, - FRAMEPOINT_BOTTOMLEFT, - single_player.BackButton, - FRAMEPOINT_TOPLEFT, - -0.14f, - 0.04f); - UI_BindMapList(mission_list_frame, - &mission_list, - single_player.DifficultySelectLabel, - SINGLE_PLAYER_MISSION_VISIBLE_ROWS, - "menu_single_player_mission_select %u"); -} - static void SinglePlayer_BindMainMenu(void) { if (!single_player.SinglePlayerMenu) { return; @@ -933,18 +1115,22 @@ static void SinglePlayerMenu_Init(void) { mi.Printf("SinglePlayerMenu_Init\n"); SinglePlayer_LoadCampaignData(); SinglePlayer_LoadCampaignProgress(); - campaign_list_frame = NULL; - mission_list_frame = NULL; memset(&campaign_list, 0, sizeof(campaign_list)); memset(&mission_list, 0, sizeof(mission_list)); + /* The FDF offset leaves the sprite origin off-screen; like the main menu (and Warsmash), retail places the + * MainMenuLogo model explicitly, mirrored to the right edge. */ if (single_player.WarCraftIIILogo) { - single_player.WarCraftIIILogo->Portrait.model = UI_LoadModel("CampaignLogo", true); + uint32_t logo_model = UI_LoadModel("MainMenuLogo", true); + if (logo_model) single_player.WarCraftIIILogo->Portrait.model = logo_model; + else SinglePlayer_Warn("campaign screen logo 'MainMenuLogo' did not load"); + UI_SetPoint(single_player.WarCraftIIILogo, FRAMEPOINT_TOPRIGHT, single_player.CampaignMenu, + FRAMEPOINT_TOPRIGHT, -0.13f, -0.08f); } SinglePlayer_BindMainMenu(); SinglePlayer_BindCampaignMenu(); - SinglePlayer_CreateCampaignList(); - SinglePlayer_CreateMissionList(); + SinglePlayer_CreateList(&campaign_rows, single_player.CampaignSelectFrame, "CampaignListBox"); + SinglePlayer_CreateList(&mission_rows, single_player.MissionSelectFrame, "MissionListBox"); SinglePlayer_SetCampaignBackdrop(SinglePlayer_DefaultCampaign()); selected_campaign_index = SINGLE_PLAYER_MAX_CAMPAIGNS; SinglePlayer_SetView(SINGLE_PLAYER_VIEW_MAIN); @@ -954,38 +1140,16 @@ static void SinglePlayerMenu_Shutdown(void) { SinglePlayer_ReleaseCampaignBackdropState(); } -static void SinglePlayerMenu_Refresh(int msec) { - static uint32_t logged_scroll = UINT32_MAX; - float target; - float diff; - float alpha; - - if (current_view != SINGLE_PLAYER_VIEW_CAMPAIGN_SELECT || campaign_list.count == 0) { - return; - } +/* The wheel scrolls whichever selector column is under the pointer, including over its buttons. */ +static bool SinglePlayerMenu_Scroll(float fdf_x, float fdf_y, int delta) { + singlePlayerList_t *list = current_view == SINGLE_PLAYER_VIEW_CAMPAIGN_SELECT ? &campaign_rows + : current_view == SINGLE_PLAYER_VIEW_MISSION_SELECT ? &mission_rows : NULL; - target = (float)campaign_list.scroll; - if (logged_scroll != campaign_list.scroll) { - uint32_t const first_row = (uint32_t)campaign_list.visualScroll; - fprintf(stderr, "Campaign screen: scroll=%u visualScroll=%.2f; visible rows [%u,%u) of %u (%u-row viewport)\n", - (unsigned)campaign_list.scroll, - campaign_list.visualScroll, - (unsigned)first_row, - (unsigned)MIN(first_row + SINGLE_PLAYER_CAMPAIGN_VISIBLE_ROWS, campaign_list.count), - (unsigned)campaign_list.count, - (unsigned)SINGLE_PLAYER_CAMPAIGN_VISIBLE_ROWS); - logged_scroll = campaign_list.scroll; - } - diff = target - campaign_list.visualScroll; - alpha = (float)msec / 90.0f; - if (alpha > 1.0f) { - alpha = 1.0f; - } - if (diff > -0.001f && diff < 0.001f) { - campaign_list.visualScroll = target; - } else { - campaign_list.visualScroll += diff * alpha; - } + if (!list || !list->box || !delta || !UI_FrameContainsPoint(list->box, fdf_x, fdf_y)) return false; + if (delta > 0 && list->scroll > 0) list->scroll--; + else if (delta < 0) list->scroll++; + SinglePlayer_LayoutList(list); + return true; } static void SinglePlayerMenu_Draw(void) { @@ -1028,6 +1192,12 @@ void SinglePlayerMenu_ShowCampaign(void) { void SinglePlayerMenu_BackCampaign(void) { if (current_view == SINGLE_PLAYER_VIEW_MISSION_SELECT) { + /* Picking a campaign moved the camera into that campaign's scene; returning restores the selector's + * scene through the same Birth camera move that first entering the campaign screen plays. */ + singlePlayerCampaign_t const *campaign = SinglePlayer_DefaultCampaign(); + SinglePlayer_SetCampaignBackdrop(campaign); + SinglePlayer_BeginCampaignBackdropBirth(campaign); + selected_campaign_index = SINGLE_PLAYER_MAX_CAMPAIGNS; SinglePlayer_SetView(SINGLE_PLAYER_VIEW_CAMPAIGN_SELECT); return; } @@ -1093,7 +1263,7 @@ uiScreen_t singlePlayerMenuScreen = { .load = SinglePlayerMenu_LoadScreen, .init = SinglePlayerMenu_Init, .shutdown = SinglePlayerMenu_Shutdown, - .refresh = SinglePlayerMenu_Refresh, + .scroll = SinglePlayerMenu_Scroll, .draw = SinglePlayerMenu_Draw, .key_event = SinglePlayerMenu_KeyEvent, }; diff --git a/games/warcraft-3/renderer/mdx/r_mdx_render.c b/games/warcraft-3/renderer/mdx/r_mdx_render.c index a66cc84e9..75d390744 100644 --- a/games/warcraft-3/renderer/mdx/r_mdx_render.c +++ b/games/warcraft-3/renderer/mdx/r_mdx_render.c @@ -308,10 +308,11 @@ void MDLX_DrawSpriteInstance(drawSprite_t const *sprite, color32_t tint) { entity.tint = tint.a ? tint : COLOR32_WHITE; entity.frame = R_UISequenceFrame(seq, anim, tr.viewDef.time - sprite->start_time); entity.oldframe = entity.frame; + entity.instance_id = (uintptr_t)(sprite->id ? sprite->id : model); viewdef.scissor = (rect_t) { 0, 0, 1, 1 }; viewdef.num_entities = 1; viewdef.entities = &entity; - viewdef.rdflags |= RDF_NOWORLDMODEL | RDF_NOFRUSTUMCULL; + viewdef.rdflags |= RDF_NOWORLDMODEL | RDF_NOFRUSTUMCULL | RDF_UI_SPRITE; viewdef.viewport = (struct rect) {0,0,1,1}; entity.flags |= RF_NO_FOGOFWAR | RF_NO_SHADOW | RF_NO_LIGHTING; diff --git a/games/warcraft-3/renderer/r_game.c b/games/warcraft-3/renderer/r_game.c index 6883e834f..bce045373 100644 --- a/games/warcraft-3/renderer/r_game.c +++ b/games/warcraft-3/renderer/r_game.c @@ -278,6 +278,7 @@ typedef struct { wc3EventFamily_t const *family; uint32_t depth; bool entity_camera; + bool ui_sprite; model_t const *source_model; uintptr_t source_instance_id; } wc3EventParams_t; @@ -288,6 +289,7 @@ typedef struct { mat4_t const *transform; uint32_t depth; bool entity_camera; + bool ui_sprite; model_t const *source_model; uintptr_t source_instance_id; } wc3EventDispatchParams_t; @@ -1426,6 +1428,11 @@ static void R_W3EmitSoundEvent(wc3EventParams_t const *params, uint32_t key) { fprintf(stderr, "WC3 renderer: MDX SND event '%s' has an invalid AnimSounds path\n", id); return; } + /* Glue panels and other UI sprites live in screen space, so their SND keys are interface sounds. */ + if (params->ui_sprite) { + ri.PlaySoundAt(path, NULL, MAX(0.0f, MIN(1.0f, row->volume / 127.0f))); + return; + } { mat4_t event_transform; if (!MDLX_EventWorldTransform(params->model, params->event, params->entity, @@ -1811,7 +1818,10 @@ static void R_W3DispatchModelEvents(wc3EventDispatchParams_t const *params) { params->state->generation != params->entity->generation) { *params->state = (wc3EventState_t){ .model = params->entity->model, .frame = params->entity->frame, .render_time = tr.viewDef.time, .generation = params->entity->generation, .valid = true }; - return; + /* A glue panel appears on the first frame of its Birth/Morph sequence, where its slide sound is keyed. + * Treat the first sighting as entering the sequence (no previous sequence) instead of skipping it. */ + if (!params->ui_sprite) return; + params->state->frame = UINT32_MAX; } if (params->state->frame == params->entity->frame && params->state->render_time == tr.viewDef.time) return; @@ -1828,10 +1838,12 @@ static void R_W3DispatchModelEvents(wc3EventDispatchParams_t const *params) { prefix, event->node.name); continue; } + /* UI sprites have no world to spawn into or splat onto; only their sounds are presentation. */ + if (params->ui_sprite && family->kind != WC3_EVENT_SOUND) continue; event_params = MAKE(wc3EventParams_t, .entity = params->entity, .model = params->model, .event = event, .transform = params->transform, .family = family, .depth = params->depth, - .entity_camera = params->entity_camera, .source_model = params->source_model, - .source_instance_id = params->source_instance_id); + .entity_camera = params->entity_camera, .ui_sprite = params->ui_sprite, + .source_model = params->source_model, .source_instance_id = params->source_instance_id); FOR_LOOP(i, event->num_keys) { uint32_t key = event->keys[i]; if (!MDLX_EventKeyCrossed(params->model, event, key, params->state->frame, @@ -1880,18 +1892,23 @@ static void R_W3UpdateModelEvents(renderEntity_t const *entity) { /* Presentation events belong to the color pass, not the shadow-map pass. */ if (tr.render_phase == RENDER_PHASE_LIGHTS) return; bool entity_camera = (tr.viewDef.rdflags & RDF_USE_ENTITY_CAMERA) != 0; + bool ui_sprite = (tr.viewDef.rdflags & RDF_UI_SPRITE) != 0; + /* Sprites share entity number 0, so like entity-camera views they need per-instance event state. */ + bool instanced = entity_camera || ui_sprite; if (!entity) return; - wc3EventState_t *state = entity_camera ? R_W3CameraEventState(entity) : NULL; - if (entity_camera && !state) return; + /* Look the instance state up first: that lookup is what invalidates it when the model changes. */ + wc3EventState_t *state = instanced ? R_W3CameraEventState(entity) : NULL; + if (instanced && !state) return; if ((entity->flags & RF_HIDDEN) || !entity->model || entity->model->modeltype != ID_MDLX || - !entity->model->mdx || (!entity_camera && entity->number >= MAX_GAME_ENTITIES)) return; + !entity->model->mdx || (!instanced && entity->number >= MAX_GAME_ENTITIES)) return; model = entity->model->mdx; if (!model->events) return; R_GetEntityMatrix(entity, &transform); - if (!entity_camera) state = event_state + entity->number; + if (!instanced) state = event_state + entity->number; R_W3DispatchModelEvents(&MAKE(wc3EventDispatchParams_t, .entity = entity, .model = model, .state = state, .transform = &transform, .depth = 0, .entity_camera = entity_camera, + .ui_sprite = ui_sprite, .source_model = entity_camera ? entity->model : NULL, .source_instance_id = entity_camera ? entity->instance_id : 0)); } diff --git a/games/warcraft-3/tests/resources-src/TestUI/CampaignStrings.txt b/games/warcraft-3/tests/resources-src/TestUI/CampaignStrings.txt index 096e74d21..09121c535 100644 --- a/games/warcraft-3/tests/resources-src/TestUI/CampaignStrings.txt +++ b/games/warcraft-3/tests/resources-src/TestUI/CampaignStrings.txt @@ -45,6 +45,34 @@ Entries=1 Mission0="Enemies at the Gate" File0="NightElf01" +[Scroll] +Header="Scroll Campaign" +Name="Twelve Chapters" +Mission0="Scroll 0" +File0="Scroll00" +Mission1="Scroll 1" +File1="Scroll01" +Mission2="Scroll 2" +File2="Scroll02" +Mission3="Scroll 3" +File3="Scroll03" +Mission4="Scroll 4" +File4="Scroll04" +Mission5="Scroll 5" +File5="Scroll05" +Mission6="Scroll 6" +File6="Scroll06" +Mission7="Scroll 7" +File7="Scroll07" +Mission8="Scroll 8" +File8="Scroll08" +Mission9="Scroll 9" +File9="Scroll09" +Mission10="Scroll 10" +File10="Scroll10" +Mission11="Scroll 11" +File11="Scroll11" + [Tutorial] Header="Prologue Campaign" Name="Exodus of the Horde" @@ -58,3 +86,4 @@ Mission0="Chasing Visions" Mission1="Departures" File0="Prologue01" File1="Prologue02" + diff --git a/games/warcraft-3/tests/resources-src/TestUI/FrameDef/Glue/StandardTemplates.fdf b/games/warcraft-3/tests/resources-src/TestUI/FrameDef/Glue/StandardTemplates.fdf index ea58b8451..7faeea47c 100644 --- a/games/warcraft-3/tests/resources-src/TestUI/FrameDef/Glue/StandardTemplates.fdf +++ b/games/warcraft-3/tests/resources-src/TestUI/FrameDef/Glue/StandardTemplates.fdf @@ -176,3 +176,13 @@ Frame "MENU" "StandardPopupMenuMenuTemplate" { Frame "BACKDROP" "StandardPopupMenuMenuBackdropTemplate" INHERITS "StandardBorderedButtonBackdropTemplate" { } } + +// Campaign selector row buttons; retail StandardTemplates.fdf adds backdrop/highlight children. +Frame "TEXTBUTTON" "CampaignArrowButtonTemplate" { + Height 0.032, + Width 0.032, +} +Frame "TEXTBUTTON" "CampaignCameraButtonTemplate" { + Height 0.032, + Width 0.032, +} diff --git a/games/warcraft-3/tests/test_menu_fdf.c b/games/warcraft-3/tests/test_menu_fdf.c index 35b2e8455..42478e11b 100644 --- a/games/warcraft-3/tests/test_menu_fdf.c +++ b/games/warcraft-3/tests/test_menu_fdf.c @@ -2639,6 +2639,34 @@ TEST(menu_fdf, console_commands_reject_malformed_numeric_arguments) { mi = saved; } +/* Selector rows are named Row; a blank or unused slot hides the whole row. */ +static frameDef_t *sp_row(cstring_t box, uint32_t row, cstring_t part) { + char name[64]; + snprintf(name, sizeof(name), "%sRow%u%s", box, (unsigned)row, part); + return UI_FindFrame(name); +} + +static bool sp_row_shows(cstring_t box, uint32_t row, cstring_t header, cstring_t name, bool camera, + uint32_t item) { + frameDef_t *slot = sp_row(box, row, ""); + frameDef_t *label = sp_row(box, row, "Label"), *desc = sp_row(box, row, "Desc"); + frameDef_t *arrow = sp_row(box, row, "Button"), *cam = sp_row(box, row, "CameraButton"); + frameDef_t *button = camera ? cam : arrow; + char command[64]; + + snprintf(command, sizeof(command), "%s %u", + strcmp(box, "CampaignListBox") ? "menu_single_player_mission_select" : "menu_single_player_campaign_select", + (unsigned)item); + return slot && label && desc && arrow && cam && !slot->hidden && !strcmp(label->Text, header) && + !strcmp(desc->Text, name) && !button->hidden && (camera ? arrow : cam)->hidden && + !strcmp(button->OnClick, command); +} + +static bool sp_row_blank(cstring_t box, uint32_t row) { + frameDef_t *slot = sp_row(box, row, ""); + return slot && slot->hidden; +} + TEST(menu_fdf, console_screen_commands_and_campaign_shortcuts) { menuImport_t saved = mi; test_glue_setup(); @@ -2687,12 +2715,31 @@ TEST(menu_fdf, console_screen_commands_and_campaign_shortcuts) { { "menu_single_player_campaign_tutorial", "Exodus of the Horde" }, }; FOR_LOOP(i, sizeof(races) / sizeof(races[0])) { + captured_model_path = NULL; Cmd_ExecuteString(races[i].cmd); T_STREQ(UI_FindFrame("MissionName")->Text, races[i].title); + /* The 1.00 War3.mpq Tutorial layout has no Background key; its skin-name fallback must still load. */ + if (strstr(races[i].cmd, "tutorial")) T_STREQ(captured_model_path, "TutorialBackdrop"); T_ASSERT(!UI_FindFrame("MissionSelectFrame")->hidden); Cmd_ExecuteString("menu_single_player_campaign_back"); T_ASSERT(!UI_FindFrame("CampaignSelectFrame")->hidden); } + /* The 1.00 Tutorial layout splits mission headers into TitleN and names cinematics without files. */ + { + Cmd_ExecuteString("menu_single_player_campaign_tutorial"); +#ifdef BZ_FFMPEG + /* Intro, blank, Opening, two chapters: five slots centred 2.5 down; the Intro header comes from [Label]. */ + T_ASSERT(sp_row_shows("MissionListBox", 0, "", "The Prophecy", true, 0)); + T_ASSERT(sp_row_blank("MissionListBox", 1)); + T_ASSERT(sp_row_shows("MissionListBox", 2, "", "Thrall's Vision", true, 1)); + T_ASSERT(sp_row_shows("MissionListBox", 3, "Chapter One", "Chasing Visions", false, 2)); + T_STREQ(sp_row("MissionListBox", 2, "CameraButton")->OnClick, "menu_single_player_mission_select 1"); +#else + T_ASSERT(sp_row_shows("MissionListBox", 0, "Chapter One", "Chasing Visions", false, 0)); + T_ASSERT(sp_row_shows("MissionListBox", 1, "Chapter Two", "Departures", false, 1)); +#endif + Cmd_ExecuteString("menu_single_player_campaign_back"); + } Cmd_ExecuteString("menu_ingame"); UI_ResetGlueSceneModels(); mi = saved; @@ -3179,7 +3226,9 @@ static void test_single_player_campaign_profile(bool tft) { mi = saved; return; } + captured_printf[0] = '\0'; singlePlayerMenuScreen.init(); + if (!tft) T_ASSERT(strstr(captured_printf, "campaign 'Tutorial' has no Background key") != NULL); root = UI_FindFrame("SinglePlayerMenu"); campaign_button = UI_FindFrame("CampaignButton"); @@ -3213,34 +3262,30 @@ static void test_single_player_campaign_profile(bool tft) { SinglePlayerMenu_ShowCampaign(); campaign_select_frame = UI_FindFrame("CampaignSelectFrame"); human_button = UI_FindFrame("HumanButton"); - campaign_list_box = campaign_select_frame - ? UI_FindChildFrame(campaign_select_frame, "MapListBox") - : NULL; + campaign_list_box = UI_FindFrame("CampaignListBox"); if (!require_not_null(human_button) || !require_not_null(campaign_list_box)) { mi = saved; return; } - T_ASSERT(human_button->hidden); + /* Retail-patched clients build the selector at runtime; the static 1.00 rows stay hidden. */ + T_ASSERT(human_button->Parent && human_button->Parent->hidden); + T_ASSERT(UI_FindFrame("TutorialFrame") && UI_FindFrame("TutorialFrame")->hidden); T_ASSERT(!campaign_list_box->hidden); - T_FEQ(campaign_list_box->Width, 0.34f, 0.001f); - T_FEQ(campaign_list_box->Height, 0.13f, 0.001f); - T_FEQ(campaign_list_box->Points.x[FPP_MIN].offset, -0.14f, 0.001f); - T_FEQ(campaign_list_box->Points.y[FPP_MAX].offset, 0.04f, 0.001f); - T_ASSERT(campaign_list_box->Points.x[FPP_MIN].relativeTo == back_button); - T_ASSERT(campaign_list_box->Points.y[FPP_MAX].relativeTo == back_button); - T_ASSERT(campaign_list_box->MapListControl.State != NULL); - T_EQ((int)campaign_list_box->MapListControl.VisibleRows, 5); - T_NOT_NULL(campaign_list_box->event_handler); - T_EQ((int)campaign_list_box->MapListControl.State->count, 4); - T_STREQ(campaign_list_box->MapListControl.State->items[0].name, - tft - ? "Sentinels Campaign: Terror of the Tides" - : "Human Campaign: The Scourge of Lordaeron"); - T_STREQ(campaign_list_box->MapListControl.State->items[1].path, - tft ? "Human" : "Undead"); - T_STREQ(campaign_list_box->MapListControl.SelectCommand, - "menu_single_player_campaign_select %u"); + T_ASSERT(campaign_list_box->Parent == campaign_select_frame); + T_FEQ(campaign_list_box->Width, 0.27f, 0.001f); + T_FEQ(campaign_list_box->Height, 0.315625f, 0.0001f); + T_FEQ(campaign_list_box->Points.x[FPP_MIN].offset, -0.287f, 0.001f); + T_FEQ(campaign_list_box->Points.y[FPP_MIN].offset, -0.1274f, 0.0001f); + /* Four campaigns, each followed by a blank slot: eight of ten slots, centred one slot down. */ + T_FEQ(sp_row("CampaignListBox", 0, "")->Points.y[FPP_MIN].offset, -0.0315625f, 0.0001f); + T_ASSERT(tft ? sp_row_shows("CampaignListBox", 0, "Sentinels Campaign", "Terror of the Tides", false, 0) + : sp_row_shows("CampaignListBox", 0, "Human Campaign", "The Scourge of Lordaeron", false, 0)); + T_ASSERT(sp_row_blank("CampaignListBox", 1)); + T_ASSERT(tft ? sp_row_shows("CampaignListBox", 2, "Alliance Campaign", "Curse of the Blood Elves", false, 1) + : sp_row_shows("CampaignListBox", 2, "Undead Campaign", "Path of the Damned", false, 1)); + T_ASSERT(sp_row_blank("CampaignListBox", 7)); + T_ASSERT(sp_row_blank("CampaignListBox", 8)); captured_command[0] = '\0'; Cmd_ExecuteString(tft ? "menu_single_player_campaign_select 1" : "menu_single_player_campaign_select 0"); @@ -3249,9 +3294,7 @@ static void test_single_player_campaign_profile(bool tft) { mission_select_frame = UI_FindFrame("MissionSelectFrame"); mission_name = UI_FindFrame("MissionName"); mission_name_header = UI_FindFrame("MissionNameHeader"); - mission_list_box = mission_select_frame - ? UI_FindChildFrame(mission_select_frame, "MapListBox") - : NULL; + mission_list_box = UI_FindFrame("MissionListBox"); if (!require_not_null(mission_select_frame) || !require_not_null(mission_name) || !require_not_null(mission_name_header) || @@ -3267,46 +3310,62 @@ static void test_single_player_campaign_profile(bool tft) { T_STREQ(mission_name_header->Text, tft ? "Alliance Campaign" : "Human Campaign"); #ifdef BZ_FFMPEG - T_EQ((int)mission_list_box->MapListControl.State->count, 6); - T_STREQ(mission_list_box->MapListControl.State->items[0].name, - tft - ? "Cinematic: Introduction: Alliance Introduction" - : "Cinematic: Introduction: Human Introduction"); - T_STREQ(mission_list_box->MapListControl.State->items[0].path, - tft ? "Movies\\HumanXIntro.mpq" : "Movies\\HumanIntro.mpq"); - T_STREQ(mission_list_box->MapListControl.State->items[1].name, - tft - ? "Cinematic: Cinematic: Alliance Opening" - : "Cinematic: Cinematic: Human Opening"); - T_STREQ(mission_list_box->MapListControl.State->items[2].name, - tft ? "Chapter One: Misconceptions" : "The Defense of Strahnbrad"); - T_STREQ(mission_list_box->MapListControl.State->items[3].name, - tft ? "Chapter Two: A Dark Covenant" : "Blackrock & Roll"); - T_STREQ(mission_list_box->MapListControl.State->items[5].name, - tft - ? "Cinematic: Cinematic: Alliance Ending" - : "Cinematic: Cinematic: Human Ending"); + /* Intro, blank, Opening, three missions, Ending: seven slots centred 1.5 down. */ + T_FEQ(sp_row("MissionListBox", 0, "")->Points.y[FPP_MIN].offset, -1.5f * 0.0315625f, 0.0001f); + T_ASSERT(sp_row_shows("MissionListBox", 0, "Introduction", + tft ? "Alliance Introduction" : "Human Introduction", true, 0)); + T_ASSERT(sp_row_blank("MissionListBox", 1)); + T_ASSERT(sp_row_shows("MissionListBox", 2, "Cinematic", tft ? "Alliance Opening" : "Human Opening", true, 1)); + T_ASSERT(tft ? sp_row_shows("MissionListBox", 3, "Chapter One", "Misconceptions", false, 2) + : sp_row_shows("MissionListBox", 3, "", "The Defense of Strahnbrad", false, 2)); + T_ASSERT(sp_row_shows("MissionListBox", 6, "Cinematic", tft ? "Alliance Ending" : "Human Ending", true, 5)); captured_movie_path[0] = '\0'; captured_command[0] = '\0'; - Cmd_ExecuteString("menu_single_player_mission_select 0"); + Cmd_ExecuteString(sp_row("MissionListBox", 0, "CameraButton")->OnClick); T_STREQ(captured_movie_path, tft ? "Movies\\HumanXIntro.mpq" : "Movies\\HumanIntro.mpq"); T_STREQ(captured_command, ""); #else - T_EQ((int)mission_list_box->MapListControl.State->count, 3); - T_STREQ(mission_list_box->MapListControl.State->items[0].name, - tft ? "Chapter One: Misconceptions" : "The Defense of Strahnbrad"); - T_STREQ(mission_list_box->MapListControl.State->items[1].name, - tft ? "Chapter Two: A Dark Covenant" : "Blackrock & Roll"); + /* Three missions centred in ten slots: the column starts 3.5 slots down. */ + T_FEQ(sp_row("MissionListBox", 0, "")->Points.y[FPP_MIN].offset, -3.5f * 0.0315625f, 0.0001f); + T_ASSERT(tft ? sp_row_shows("MissionListBox", 0, "Chapter One", "Misconceptions", false, 0) + : sp_row_shows("MissionListBox", 0, "", "The Defense of Strahnbrad", false, 0)); + T_ASSERT(tft ? sp_row_shows("MissionListBox", 1, "Chapter Two", "A Dark Covenant", false, 1) + : sp_row_shows("MissionListBox", 1, "", "Blackrock & Roll", false, 1)); + T_ASSERT(sp_row_blank("MissionListBox", 3)); #endif - T_STREQ(mission_list_box->MapListControl.SelectCommand, - "menu_single_player_mission_select %u"); - T_NOT_NULL(mission_list_box->event_handler); Cmd_ExecuteString(back_button->OnClick); T_ASSERT(!campaign_select_frame->hidden); T_ASSERT(mission_select_frame->hidden); + if (!tft) { + /* Twelve missions overflow the ten slots: the column starts at the top and the wheel scrolls it. */ + float x = 0, y = 0; + bool found = false; + + SinglePlayerMenu_LaunchCampaign("Scroll"); + singlePlayerMenuScreen.draw(); + T_FEQ(sp_row("MissionListBox", 0, "")->Points.y[FPP_MIN].offset, 0.0f, 0.0001f); + T_ASSERT(sp_row_shows("MissionListBox", 0, "", "Scroll 0", false, 0)); + T_ASSERT(sp_row_shows("MissionListBox", 9, "", "Scroll 9", false, 9)); + /* The fixture CampaignMenu has no size, so the box may hang off its corner at negative coordinates. */ + for (float fy = -1.0f; fy < 1.0f && !found; fy += 0.005f) + for (float fx = -1.0f; fx < 1.0f && !found; fx += 0.005f) + if (UI_FrameContainsPoint(mission_list_box, fx, fy)) { x = fx; y = fy; found = true; } + T_ASSERT(found); + T_ASSERT(!singlePlayerMenuScreen.scroll(x, y, 0)); + T_ASSERT(singlePlayerMenuScreen.scroll(x, y, -1)); + T_ASSERT(sp_row_shows("MissionListBox", 0, "", "Scroll 1", false, 1)); + FOR_LOOP(i, 5) singlePlayerMenuScreen.scroll(x, y, -1); + T_ASSERT(sp_row_shows("MissionListBox", 0, "", "Scroll 2", false, 2)); + T_ASSERT(sp_row_shows("MissionListBox", 9, "", "Scroll 11", false, 11)); + FOR_LOOP(i, 5) singlePlayerMenuScreen.scroll(x, y, 1); + T_ASSERT(sp_row_shows("MissionListBox", 0, "", "Scroll 0", false, 0)); + Cmd_ExecuteString(back_button->OnClick); + T_ASSERT(!campaign_select_frame->hidden); + } + captured_cvar_name[0] = '\0'; captured_cvar_value[0] = '\0'; Cmd_ExecuteString("menu_single_player_difficulty 2"); @@ -3335,39 +3394,25 @@ static void test_single_player_campaign_profile(bool tft) { } test_campaign_visibility = "unlocked"; SinglePlayerMenu_ShowCampaign(); - T_EQ((int)campaign_list_box->MapListControl.State->count, 2); - T_STREQ(campaign_list_box->MapListControl.State->items[0].path, "Human"); - T_STREQ(campaign_list_box->MapListControl.State->items[1].path, "Undead"); + /* Two campaigns plus their blanks: four slots centred three down, as in the retail fresh-profile capture. */ + T_FEQ(sp_row("CampaignListBox", 0, "")->Points.y[FPP_MIN].offset, -3.0f * 0.0315625f, 0.0001f); + T_ASSERT(tft ? sp_row_shows("CampaignListBox", 0, "Alliance Campaign", "Curse of the Blood Elves", false, 0) + : sp_row_shows("CampaignListBox", 0, "Human Campaign", "The Scourge of Lordaeron", false, 0)); + T_ASSERT(sp_row_blank("CampaignListBox", 1)); + T_ASSERT(tft ? sp_row_shows("CampaignListBox", 2, "Scourge Campaign", "Legacy of the Damned", false, 1) + : sp_row_shows("CampaignListBox", 2, "Undead Campaign", "Path of the Damned", false, 1)); + T_ASSERT(sp_row_blank("CampaignListBox", 4)); Cmd_ExecuteString("menu_single_player_campaign_select 0"); #ifdef BZ_FFMPEG - T_EQ((int)mission_list_box->MapListControl.State->count, 5); - T_STREQ(mission_list_box->MapListControl.State->items[0].name, - tft - ? "Cinematic: Introduction: Alliance Introduction" - : "Cinematic: Introduction: Human Introduction"); - T_STREQ(mission_list_box->MapListControl.State->items[1].name, - tft - ? "Cinematic: Cinematic: Alliance Opening" - : "Cinematic: Cinematic: Human Opening"); - T_EQ((int)mission_list_box->MapListControl.State->items[2].flags, 0); - T_STREQ(mission_list_box->MapListControl.State->items[2].name, - tft ? "Chapter One: Misconceptions" : "The Defense of Strahnbrad"); - T_EQ((int)mission_list_box->MapListControl.State->items[3].flags, 1); - T_STREQ(mission_list_box->MapListControl.State->items[3].name, - tft ? "Chapter Two: A Dark Covenant" : "Blackrock & Roll"); - T_STREQ(mission_list_box->MapListControl.State->items[4].name, - tft - ? "Cinematic: Cinematic: Alliance Ending" - : "Cinematic: Cinematic: Human Ending"); + T_ASSERT(tft ? sp_row_shows("MissionListBox", 4, "Chapter Two", "A Dark Covenant", false, 3) + : sp_row_shows("MissionListBox", 4, "", "Blackrock & Roll", false, 3)); #else - T_EQ((int)mission_list_box->MapListControl.State->count, 2); - T_EQ((int)mission_list_box->MapListControl.State->items[0].flags, 0); - T_STREQ(mission_list_box->MapListControl.State->items[0].name, - tft ? "Chapter One: Misconceptions" : "The Defense of Strahnbrad"); - T_EQ((int)mission_list_box->MapListControl.State->items[1].flags, 1); - T_STREQ(mission_list_box->MapListControl.State->items[1].name, - tft ? "Chapter Two: A Dark Covenant" : "Blackrock & Roll"); + T_ASSERT(tft ? sp_row_shows("MissionListBox", 0, "Chapter One", "Misconceptions", false, 0) + : sp_row_shows("MissionListBox", 0, "", "The Defense of Strahnbrad", false, 0)); + T_ASSERT(tft ? sp_row_shows("MissionListBox", 1, "Chapter Two", "A Dark Covenant", false, 1) + : sp_row_shows("MissionListBox", 1, "", "Blackrock & Roll", false, 1)); + T_ASSERT(sp_row_blank("MissionListBox", 2)); #endif captured_command[0] = '\0'; diff --git a/sound/s_local.h b/sound/s_local.h index c87882433..a6981f116 100644 --- a/sound/s_local.h +++ b/sound/s_local.h @@ -52,20 +52,17 @@ typedef struct { bool load_attempted; } sfx_t; -/* DBC kit entry — cache pointer added so the decoded PCM lives on the handle. */ +/* Named kit entry. Each play picks one file variant; decoded PCM lives on that variant's path-keyed sfx_t. */ typedef struct { uint32_t id; uint32_t type; cstring_t name; cstring_t files[SENTRY_MAX_FILES]; - uint32_t freq[SENTRY_MAX_FILES]; + uint32_t freq[SENTRY_MAX_FILES]; /* relative variant weights; all zero means uniform */ cstring_t directoryBase; float volume; uint32_t flags; - sfxcache_t *cache; int registration_sequence; - int load_attempt_sequence; - bool load_attempted; } sSoundKit_t; typedef struct sHashNode_s { @@ -128,6 +125,8 @@ typedef struct { float user_volume; bool initialized; uint8_t *dbc_data; + void *slk_rows; /* UISounds.slk rows backing named kits when no SoundEntries.dbc exists */ + uint32_t slk_row_count; } sState_t; extern sState_t s; diff --git a/sound/s_sound.c b/sound/s_sound.c index 89416f74f..71646c413 100644 --- a/sound/s_sound.c +++ b/sound/s_sound.c @@ -7,7 +7,9 @@ * decodes MP3 dialogue into the same S16, 44100 Hz, mono cache format. */ #include "s_local.h" +#include #include "common/stb_dbc.h" +#include "common/stb_slk.h" sState_t s; @@ -234,7 +236,8 @@ static sfxcache_t *S_ResampleLoad(char const *path) { static uint32_t S_HashString(cstring_t str) { uint32_t hash = 5381; - for (; *str; str++) hash = ((hash << 5) + hash) + (unsigned char)*str; + /* Fold case to match S_FindByName's strcasecmp; otherwise differently cased names miss their bucket. */ + for (; *str; str++) hash = ((hash << 5) + hash) + (unsigned char)tolower((unsigned char)*str); return hash & (S_HASH_BUCKETS - 1); } @@ -259,24 +262,6 @@ static void S_InsertHash(uint32_t kit_id, cstring_t name) { * Load cache — mirrors Q2's S_LoadSound * ========================================================================= */ -/* Load (or return cached) PCM for a kit entry. */ -static sfxcache_t *S_LoadKit(sSoundKit_t *k) { - if (!k || k->id == 0 || !k->files[0] || !*k->files[0]) return NULL; - if (k->cache) return k->cache; - if (k->load_attempted && k->load_attempt_sequence == s.registration_sequence) return NULL; - k->load_attempted = true; - k->load_attempt_sequence = s.registration_sequence; - - char path[512]; - if (k->directoryBase && *k->directoryBase && *k->directoryBase != '(') - snprintf(path, sizeof(path), "%s\\%s", k->directoryBase, k->files[0]); - else - snprintf(path, sizeof(path), "%s", k->files[0]); - - k->cache = S_ResampleLoad(path); - return k->cache; -} - /* Load (or return cached) PCM for a path-keyed sfx handle. */ static sfxcache_t *S_LoadSfx(sfx_t *sfx) { if (!sfx || !sfx->path[0]) return NULL; @@ -288,6 +273,39 @@ static sfxcache_t *S_LoadSfx(sfx_t *sfx) { return sfx->cache; } +static sfx_t *S_FindSfx(cstring_t path, bool create); + +/* Choose a kit variant: SoundEntries freq weights when authored, otherwise uniform (UISounds FileNames). */ +static cstring_t S_PickKitFile(sSoundKit_t const *k) { + uint32_t count = 0, total = 0, pick; + + while (count < SENTRY_MAX_FILES && k->files[count] && *k->files[count]) total += k->freq[count++]; + if (!count) return NULL; + if (!total) return k->files[(uint32_t)rand() % count]; + pick = (uint32_t)rand() % total; + FOR_LOOP(i, count) { + if (pick < k->freq[i]) return k->files[i]; + pick -= k->freq[i]; + } + return k->files[count - 1]; +} + +/* Load (or return cached) PCM for one randomly chosen variant of a kit. */ +static sfxcache_t *S_LoadKit(sSoundKit_t *k) { + cstring_t file = k && k->id ? S_PickKitFile(k) : NULL; + char path[512]; + sfx_t *sfx; + + if (!file) return NULL; + if (k->directoryBase && *k->directoryBase && *k->directoryBase != '(') + snprintf(path, sizeof(path), "%s\\%s", k->directoryBase, file); + else + snprintf(path, sizeof(path), "%s", file); + if (!(sfx = S_FindSfx(path, true))) return NULL; + sfx->registration_sequence = s.registration_sequence; + return S_LoadSfx(sfx); +} + /* Find or create a path-keyed sfx handle (mirrors Q2 S_FindName). */ static sfx_t *S_FindSfx(cstring_t path, bool create) { for (int i = 0; i < s.num_sfx; i++) @@ -309,6 +327,58 @@ static sfx_t *S_FindSfx(cstring_t path, bool create) { * DBC SoundEntries loader * ========================================================================= */ +/* Warcraft III names its interface sounds in UI\SoundInfo\UISounds.slk rather than a DBC. Registering + * those rows as kits lets every menu use the same imported PlaySoundByName("GlueScreenClick") path. */ +typedef struct { + cstring_t name, FileNames, DirectoryBase; + float Volume; +} sSlkSound_t; + +static slkField_t const s_slk_sound_schema[] = { + { "", offsetof(sSlkSound_t, name), STB_SLK_STR }, + { "FileNames", offsetof(sSlkSound_t, FileNames), STB_SLK_STR }, + { "DirectoryBase", offsetof(sSlkSound_t, DirectoryBase), STB_SLK_STR }, + { "Volume", offsetof(sSlkSound_t, Volume), STB_SLK_FLOAT }, + { NULL }, +}; + +static void S_LoadUISoundsSlk(void) { + sSlkSound_t *rows = NULL; + uint32_t count = Stb_SlkLoad("UI\\SoundInfo\\UISounds.slk", s_slk_sound_schema, (void **)&rows, + sizeof(*rows)); + + if (!count || !rows) return; + s.slk_rows = rows; + s.slk_row_count = count; + FOR_LOOP(i, count) { + sSlkSound_t *row = rows + i; + uint32_t id = s.kit_count ? s.kit_count : 1; + sSoundKit_t *k; + string_t files = (string_t)row->FileNames, dir = (string_t)row->DirectoryBase; + uint32_t nfiles = 0; + + if (!row->name || !*row->name || !files || !*files || id >= S_MAX_KITS) continue; + /* S_LoadKit inserts its own separator between DirectoryBase and the file. */ + for (size_t len = dir ? strlen(dir) : 0; len && (dir[len - 1] == '\\' || dir[len - 1] == '/'); ) + dir[--len] = '\0'; + k = &s.kits[id]; + memset(k, 0, sizeof(*k)); + k->id = id; + k->name = row->name; + for (string_t file = files; file && nfiles < SENTRY_MAX_FILES; ) { + string_t comma = strchr(file, ','); + if (comma) *comma = '\0'; + if (*file) k->files[nfiles++] = file; + file = comma ? comma + 1 : NULL; + } + k->directoryBase = dir; + k->volume = MAX(0.0f, MIN(1.0f, row->Volume / 127.0f)); /* SLK Volume is 0..127 */ + k->registration_sequence = s.registration_sequence; + s.kit_count = id + 1; + S_InsertHash(id, k->name); + } +} + void S_LoadSoundEntries(void) { stbDbc_t h; uint32_t size = 0; @@ -316,6 +386,7 @@ void S_LoadSoundEntries(void) { if (!Stb_DbcValid(data, (uint32_t)size, &h) || h.fields != SENTRY_FIELDS || h.record_size != SENTRY_RECORD_SIZE) { FS_FreeFile(data); + S_LoadUISoundsSlk(); return; } uint8_t *records = data + 20; @@ -336,8 +407,6 @@ void S_LoadSoundEntries(void) { k->directoryBase = Stb_DbcString(strings, h.string_size, Stb_DbcField(&h, rec, 23)); k->volume = Stb_DbcReadFloat(rec + 24 * sizeof(uint32_t)); k->flags = Stb_DbcField(&h, rec, 25); - k->cache = NULL; - k->load_attempted = false; k->registration_sequence = s.registration_sequence; if (k->id >= s.kit_count) s.kit_count = k->id + 1; S_InsertHash(id, k->name); @@ -371,15 +440,6 @@ void S_EndRegistration(void) { } s.num_sfx = dst; - /* Free kit caches not touched this sequence */ - for (uint32_t i = 1; i < s.kit_count; i++) { - sSoundKit_t *k = &s.kits[i]; - if (k->id != i) continue; - if (k->registration_sequence != s.registration_sequence && k->cache) { - free(k->cache); - k->cache = NULL; - } - } } /* ========================================================================= @@ -546,9 +606,8 @@ void S_Shutdown(void) { SDL_CloseAudioDevice(s.device); for (int i = 0; i < s.num_sfx; i++) free(s.known_sfx[i].cache); - for (uint32_t i = 1; i < s.kit_count; i++) - if (s.kits[i].id == i) free(s.kits[i].cache); FS_FreeFile(s.dbc_data); + FS_SLKFreeRows(s_slk_sound_schema, s.slk_rows, s.slk_row_count, sizeof(sSlkSound_t)); FOR_LOOP(stream_id, S_STREAM_COUNT) free(s.streams[stream_id].data); memset(&s, 0, sizeof(s)); } @@ -676,9 +735,16 @@ void S_PlaySound(uint32_t kit_id) { } void S_PlaySoundByName(cstring_t name) { + static char last_missing[64]; if (!s.initialized || !name || !*name) return; sHashNode_t *n = S_FindByName(name); - if (n) S_PlaySound(n->kit_id); + if (n) { + S_PlaySound(n->kit_id); + } else if (strcmp(last_missing, name)) { + /* Name each unresolved sound once so missing kit data is visible without flooding stderr. */ + snprintf(last_missing, sizeof(last_missing), "%s", name); + fprintf(stderr, "[sound] warning: no sound kit named '%s'\n", name); + } } /* Preload a server-configstring sound so playback never blocks on archive I/O. */ diff --git a/tests/test_sound.c b/tests/test_sound.c index 6f0474172..4a2a749f0 100644 --- a/tests/test_sound.c +++ b/tests/test_sound.c @@ -1,10 +1,12 @@ #include "sound/s_local.h" #include "shared/test.h" +#include "common/stb_slk.h" #include /* Generated 0.5-s stereo sine fixture; no retail audio is used. */ static uint32_t sound_test_reads, sound_test_ticks; +static char sound_test_last_read[256]; Uint32 SDL_GetTicks(void) { return sound_test_ticks; } handle_t FS_ReadFile(cstring_t filename, uint32_t * size) { @@ -13,7 +15,8 @@ handle_t FS_ReadFile(cstring_t filename, uint32_t * size) { uint8_t *data; sound_test_reads++; - if (!strcmp(filename, "stereo.wav")) { + snprintf(sound_test_last_read, sizeof(sound_test_last_read), "%s", filename); + if (!strcmp(filename, "stereo.wav") || !strncmp(filename, "Sound\\Interface\\", 16)) { static uint8_t const wav[] = { 'R','I','F','F',40,0,0,0,'W','A','V','E', 'f','m','t',' ',16,0,0,0,1,0,2,0,0x44,0xac,0,0,0x10,0xb1,2,0,2,0,8,0, @@ -52,6 +55,29 @@ handle_t FS_ReadFile(cstring_t filename, uint32_t * size) { void FS_FreeFile(void *data) { free(data); } +/* Warcraft III data has no SoundEntries.dbc; S_LoadSoundEntries falls back to these UISounds.slk rows. */ +typedef struct { cstring_t name, FileNames, DirectoryBase; float Volume; } soundTestSlkRow_t; +static slkField_t const sound_test_slk_schema[] = { + { "", offsetof(soundTestSlkRow_t, name), STB_SLK_STR }, + { "FileNames", offsetof(soundTestSlkRow_t, FileNames), STB_SLK_STR }, + { "DirectoryBase", offsetof(soundTestSlkRow_t, DirectoryBase), STB_SLK_STR }, + { "Volume", offsetof(soundTestSlkRow_t, Volume), STB_SLK_FLOAT }, + { NULL }, +}; + +uint32_t Stb_SlkLoad(cstring_t filename, slkField_t const *schema, void **dest, uint32_t row_stride) { + soundTestSlkRow_t *rows; + (void)schema; + if (strcmp(filename, "UI\\SoundInfo\\UISounds.slk") || row_stride != sizeof(*rows)) return 0; + rows = calloc(2, sizeof(*rows)); + if (!rows) return 0; + rows[0] = (soundTestSlkRow_t){ strdup("GlueScreenClick"), strdup("BigButtonClick.wav,Alt.wav"), + strdup("Sound\\Interface\\"), 100 }; + rows[1] = (soundTestSlkRow_t){ strdup("NoFiles"), strdup(""), strdup(""), 127 }; + *dest = rows; + return 2; +} + static void sound_test_reset(void) { S_ClearSoundEvents(); for (int i = 0; i < s.num_sfx; i++) free(s.known_sfx[i].cache); @@ -367,3 +393,43 @@ TEST(sound, feedback_rejects_missing_audio_and_survives_backpressure_and_stop) { T_ASSERT(!S_PollSoundEvent(&event)); sound_test_reset(); } + +static bool sound_test_has_sfx(cstring_t path) { + for (int i = 0; i < s.num_sfx; i++) + if (!strcmp(s.known_sfx[i].path, path) && s.known_sfx[i].cache) return true; + return false; +} + +TEST(sound, uisounds_slk_rows_become_named_kits_without_soundentries_dbc) { + sSoundKit_t const *kit; + + sound_test_reset(); + S_LoadSoundEntries(); + T_EQ(s.slk_row_count, 2); + T_EQ(s.kit_count, 2); /* NoFiles has nothing to play and is not registered */ + kit = &s.kits[1]; + T_STREQ(kit->name, "GlueScreenClick"); + T_STREQ(kit->directoryBase, "Sound\\Interface"); + T_STREQ(kit->files[0], "BigButtonClick.wav"); + T_STREQ(kit->files[1], "Alt.wav"); + T_FEQ(kit->volume, 100.0f / 127.0f, 0.0001f); + + /* FileNames variants are picked per play; each variant loads once through its own path-keyed sfx. */ + srand(1); + sound_test_reads = 0; + bool saw_first = false, saw_second = false; + FOR_LOOP(i, 32) { + S_PlaySoundByName(i ? "GlueScreenClick" : "glueScreenClick"); + saw_first |= sound_test_has_sfx("Sound\\Interface\\BigButtonClick.wav"); + saw_second |= sound_test_has_sfx("Sound\\Interface\\Alt.wav"); + } + T_ASSERT(saw_first); + T_ASSERT(saw_second); + T_EQ(sound_test_reads, 2); + S_PlaySoundByName("NoFiles"); + T_EQ(sound_test_reads, 2); + + S_StopAllSounds(); + FS_SLKFreeRows(sound_test_slk_schema, s.slk_rows, s.slk_row_count, sizeof(soundTestSlkRow_t)); + sound_test_reset(); +}