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(); +}