diff --git a/AGENTS.md b/AGENTS.md index 58cfbc1db..0eb9c8dea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,7 @@ This codebase is inspired by **Quake 2** (id Software). The developer is deeply | WC3 JASS native coverage, callback contracts, state ownership | [games/warcraft-3/jass-native-coverage.md](games/warcraft-3/jass-native-coverage.md) | | WC3 AI next-upgrade cost queries and `ShiftTownSpot` construction placement state | [docs/games/warcraft-3/ai-upgrade-costs-and-town-spot.md](docs/games/warcraft-3/ai-upgrade-costs-and-town-spot.md) | | WC3 event-trigger queueing, synchronous `TriggerExecute`, coroutine context | [docs/games/warcraft-3/trigger-events.md](docs/games/warcraft-3/trigger-events.md) | +| WC3 JASS choice dialogs, modal input, event context, remaining retail gaps | [docs/games/warcraft-3/jass-dialogs.md](docs/games/warcraft-3/jass-dialogs.md) | | WC3 timer-dialog mission countdowns, stock FDF HUD, local visibility, save/load identity | [docs/games/warcraft-3/timer-dialogs.md](docs/games/warcraft-3/timer-dialogs.md) | | WC3 leaderboards, counted-objective HUDs, per-player assignment, save/load identity | [docs/games/warcraft-3/leaderboards.md](docs/games/warcraft-3/leaderboards.md) | | WC3 multiboard/texttag JASS registries, local display bits, presentation gaps | [docs/games/warcraft-3/multiboard-and-texttag.md](docs/games/warcraft-3/multiboard-and-texttag.md) | diff --git a/client/cl_parse.c b/client/cl_parse.c index 7982894a5..02e837462 100644 --- a/client/cl_parse.c +++ b/client/cl_parse.c @@ -1043,6 +1043,10 @@ static void CL_ParseWindow(sizeBuf_t *msg) { cstring_t text; def.id = MSG_ReadLong(msg); + if (op == UI_WINDOW_CLOSE) { + CL_WindowClose(def.id); + return; + } if (op != UI_WINDOW_OPEN) { fprintf(stderr, "CL_ParseWindow: bad operation %u\n", (unsigned)op); msg->readcount = msg->cursize; diff --git a/client/cl_scrn.c b/client/cl_scrn.c index 54c5eec34..0a44a1b71 100644 --- a/client/cl_scrn.c +++ b/client/cl_scrn.c @@ -502,7 +502,8 @@ void SCR_LayoutDrawBackdrop2(uiFrame_t const *frame, rect_t const *screen, uiBac .insets.bottom = bd->BackgroundInsets[2], .insets.left = bd->BackgroundInsets[3], .flags = (bd->TileBackground ? DRAW_TILE : 0) - | (bd->Mirrored ? DRAW_MIRRORED : 0))); + | (bd->Mirrored ? DRAW_MIRRORED : 0) + | (bd->BlendAll ? DRAW_BLEND_ALL : 0))); } void SCR_LayoutDrawBackdrop(uiFrame_t const *frame, rect_t const *screen) { diff --git a/client/tr_public.h b/client/tr_public.h index 1e327bb84..2292c62c0 100644 --- a/client/tr_public.h +++ b/client/tr_public.h @@ -39,6 +39,7 @@ enum { DRAW_TILE = 1 << 2, DRAW_MIRRORED = 1 << 3, DRAW_EDGE_2X2 = 1 << 4, /* edge texture uses WoW 2×2 quadrant UV layout */ + DRAW_BLEND_ALL = 1 << 5, /* backdrop background uses texture alpha */ }; /* Text drawing parameters */ diff --git a/common/shared.h b/common/shared.h index 53bf60aa5..00f3e9948 100644 --- a/common/shared.h +++ b/common/shared.h @@ -508,6 +508,7 @@ typedef enum { typedef enum { UI_WINDOW_OPEN, + UI_WINDOW_CLOSE, /* generic server-authored close: opcode and window ID only */ } uiWindowOp_t; #define UI_WINDOW_MOVABLE (1u << 0) // flag bit; permits client-local pointer dragging; used by server-authored windows diff --git a/docs/games/warcraft-3/jass-dialogs.md b/docs/games/warcraft-3/jass-dialogs.md new file mode 100644 index 000000000..4f440c45c --- /dev/null +++ b/docs/games/warcraft-3/jass-dialogs.md @@ -0,0 +1,79 @@ +# JASS choice dialogs + +The Warcraft game module owns `DialogCreate`, `DialogDestroy`, `DialogClear`, +`DialogSetMessage`, `DialogAddButton`, `DialogAddQuitButton`, `DialogDisplay`, +`TriggerRegisterDialogEvent`, `TriggerRegisterDialogButtonEvent`, +`GetClickedButton`, and `GetClickedDialog`. + +## Ownership and input + +`level.dialogs` and `level.dialog_buttons` contain bounded, map-lifetime, +one-based handle IDs. Destroyed and cleared handles are not recycled within a +map: a late click cannot be mistaken for a newly created button. The server +retains the message, labels, hotkeys, visibility, and ownership; the universal +client receives an ordinary server-authored `svc_window` and returns +`jassdialog `. The game validates both IDs, parentage, +client identity, and player-specific visibility, then consumes that visibility +before emitting an event. Duplicated network commands are ignored. + +`UI_WINDOW_CLOSE` is a generic server-to-client `svc_window` operation carrying +only the window ID. It is not a Warcraft-specific UI protocol. It lets +`DialogDisplay(false)`, `DialogClear`, and `DialogDestroy` release modal focus +without synthesizing an answer. The client's `close_window_command` handles a +selected button's local closure without changing simulation state. + +The generic interactive window uses `UI_WINDOW_MODAL | UI_WINDOW_UNIQUE | +UI_WINDOW_NO_PAUSE | UI_WINDOW_NO_ESCAPE`; it captures gameplay input without +inventing a simulation pause. Text is resolved through `G_LevelString` and the +existing FDF UI layer. + +## Events and save/load + +The event registration records a dialog ID and optional button ID. A click +queues the matching `EVENT_DIALOG_CLICK` and/or `EVENT_DIALOG_BUTTON_CLICK` +registration through the normal `GAMEEVENT` pipeline. `JASSCONTEXT` stores +both IDs and the clicking player's `playerState`, including across coroutine +suspension. The response survives game saves via the level event serializer, +JASS snapshots, and stable dialog/button handle indexes. The game save version +is **66**, and the JASS snapshot version is **7**; earlier layouts are rejected. + +A choice is one-shot per `DialogDisplay` call. Re-display is allowed and does not +recreate the JASS handles. A new dialog shown to the same player replaces the +previous active choice and clears its authoritative visible bit. + +## Deliberate remaining compatibility work + +* `DialogAddQuitButton` creates an ordinary selectable button carrying the + quit and score-screen metadata. The retail-specific quit/end-game transition + and score-screen policy are not implemented here. +* Button hotkeys are stored, but the generic `svc_window` keyboard shortcut + routing is not implemented. Mouse input is supported. +* UI emits at most 12 buttons in a window; omitted choices are reported to + stderr. Unusually large custom-map dialogs need scrolling/pagination and + retail scaling research. +* ScriptDialog and ScriptDialogButton use generated bindings to stock FDF. + The retail FDF has no variable-height choice rows, so row anchors and dialog + height are generated from the runtime button count. Final visual alignment, + actual clickable rendering, and exact retail dialog layout require in-game + verification at 640×480 and widescreen resolutions. +* Save restores dialog identities and visibility. The load and client-begin + paths republish the active choice, though manual save/reconnect testing is + still required. +* Nested simultaneous dialogs, priority and per-button hotkey collision rules + need further retail verification. + +The NightElfX06 fork requires both the global dialog event and specific-button +event pathways. It must not use a map-specific event or client UI hook. + +### Regression tests + +`wc3_dialog.*` covers creation, distinct handles, clear/destroy lifecycle, +JASS native calls, a choice event, correct `GetClickedButton` / +`GetClickedDialog` / `GetTriggerPlayer` context, registration selection, and +duplicate click rejection. It also verifies player-number identity when a +client slot differs from its Warcraft player number, verifies clear hides the +matching client's window, and checks that repeated serialization reclaims +temporary FDF frames. The disabled-label +serialization path and save-version rejection have focused regressions. +`make test` passed with 46,715 assertions after the audit fixes. Retail visual +alignment and campaign integration still require user-side game verification. diff --git a/docs/games/warcraft-3/trigger-events.md b/docs/games/warcraft-3/trigger-events.md index 7c9b861bd..477c2278e 100644 --- a/docs/games/warcraft-3/trigger-events.md +++ b/docs/games/warcraft-3/trigger-events.md @@ -203,3 +203,12 @@ make test-wc3-engine WC3_PATTERN='wc3_api.*' make test-wc3-engine WC3_PATTERN='wc3_spell.*' make test-wc3-engine WC3_PATTERN='wc3_save.round_trip_jass_timers' ``` + +## JASS interactive dialog choices + +`DialogDisplay` creates a player-scoped modal `svc_window` without pausing the +simulation. A validated click publishes events targeted at the registered +dialog and button, with the authoritative player ID and one-based clicked +handle IDs. This response context is copied to a coroutine, so +`GetClickedButton` / `GetClickedDialog` continue to work after a trigger wait. +See [JASS choice dialogs](jass-dialogs.md) for lifecycle and limitations. diff --git a/games/warcraft-3/common/stb_fdf.h b/games/warcraft-3/common/stb_fdf.h index d6115a059..c5d2321d7 100644 --- a/games/warcraft-3/common/stb_fdf.h +++ b/games/warcraft-3/common/stb_fdf.h @@ -413,6 +413,7 @@ void UI_ClearTextures(void); /* -------------------------------------------------------------------------- */ frameDef_t *UI_Spawn(FRAMETYPE type, frameDef_t *parent); frameDef_t *UI_CloneFrameTree(frameDef_t const *source, frameDef_t *parent); +void UI_FreeFrameTree(frameDef_t *root); uint32_t UI_FindFrameNumber(cstring_t name); void UI_SetText(frameDef_t *frame, cstring_t format, ...); void UI_SetTextPointer(frameDef_t *frame, cstring_t text); @@ -1141,6 +1142,31 @@ frameDef_t *UI_Spawn(FRAMETYPE type, frameDef_t *parent) { return NULL; } +/* Release one pooled frame and the dynamic strings, lists, and camera token it owns. */ +static void UI_ReleaseFrame(frameDef_t *frame) { + if (frame->camera_event_instance_id && ui_release_camera_event_instance) + ui_release_camera_event_instance(frame->camera_event_instance_id); + UI_FreeFrameDynamicText(frame); + UI_FreeFrameMenuItems(frame); + memset(frame, 0, sizeof(*frame)); +} + +/* Reclaim a serialized temporary tree so repeated server-authored windows do not exhaust the shared FDF frame pool. */ +void UI_FreeFrameTree(frameDef_t *root) { + bool release[MAX_UI_CLASSES] = { false }; + uintptr_t address = (uintptr_t)root, base = (uintptr_t)frames; + + if (!root || address < base || address >= base + sizeof(frames) || + (address - base) % sizeof(frames[0])) return; + FOR_LOOP(i, MAX_UI_CLASSES) { + frameDef_t *frame = frames + i; + for (frameDef_t const *parent = frame; parent; parent = parent->Parent) { + if (parent == root) { release[i] = true; break; } + } + } + FOR_LOOP(i, MAX_UI_CLASSES) if (release[i]) UI_ReleaseFrame(frames + i); +} + typedef struct { cstring_t name; uint32_t fofs; @@ -1878,6 +1904,7 @@ frameDef_t *UI_CloneFrameTree(frameDef_t const *source, frameDef_t *parent) { FOR_LOOP(i, count) { copies[i] = UI_Spawn(sources[i]->Type, parent); if (!copies[i]) { + while (i) UI_ReleaseFrame(copies[--i]); return NULL; } uintptr_t camera_event_instance_id = copies[i]->camera_event_instance_id; diff --git a/games/warcraft-3/game/api/api_misc.h b/games/warcraft-3/game/api/api_misc.h index 9979557b6..7952d3beb 100644 --- a/games/warcraft-3/game/api/api_misc.h +++ b/games/warcraft-3/game/api/api_misc.h @@ -572,10 +572,10 @@ uint32_t GetTriggeringTrackable(jass_t *j) { return jass_pushnullhandle(j, "trackable"); } uint32_t GetClickedButton(jass_t *j) { - return jass_pushnullhandle(j, "button"); + return jass_pushlighthandle(j, G_JassDialogButtonById(jass_getcontext(j)->dialog_button_id), "button"); } uint32_t GetClickedDialog(jass_t *j) { - return jass_pushnullhandle(j, "dialog"); + return jass_pushlighthandle(j, G_JassDialogById(jass_getcontext(j)->dialog_id), "dialog"); } uint32_t GetLevelingUnit(jass_t *j) { return jass_pushlighthandle(j, jass_getcontext(j)->unit, "unit"); @@ -1046,35 +1046,46 @@ uint32_t SetDefaultDifficulty(jass_t *j) { return 0; } uint32_t DialogCreate(jass_t *j) { - return jass_pushnullhandle(j, "dialog"); + return jass_pushlighthandle(j, G_JassDialogCreate(), "dialog"); } uint32_t DialogDestroy(jass_t *j) { - //handle_t whichDialog = jass_checkhandle(j, 1, "dialog"); + G_JassDialogDestroy(G_JassDialog(jass_checkhandle(j, 1, "dialog"))); return 0; } uint32_t DialogSetAsync(jass_t *j) { - //handle_t whichDialog = jass_checkhandle(j, 1, "dialog"); + /* Compatibility extension. Standard dialogs remain authoritative. */ return 0; } uint32_t DialogClear(jass_t *j) { - //handle_t whichDialog = jass_checkhandle(j, 1, "dialog"); + G_JassDialogClear(G_JassDialog(jass_checkhandle(j, 1, "dialog"))); return 0; } uint32_t DialogSetMessage(jass_t *j) { - //handle_t whichDialog = jass_checkhandle(j, 1, "dialog"); - //cstring_t messageText = jass_checkstring(j, 2); + jassDialog_t *dialog = G_JassDialog(jass_checkhandle(j, 1, "dialog")); + cstring_t message = jass_checkstring(j, 2); + if (dialog) snprintf(dialog->message, sizeof(dialog->message), "%s", message ? G_LevelString(message) : ""); return 0; } uint32_t DialogAddButton(jass_t *j) { - //handle_t whichDialog = jass_checkhandle(j, 1, "dialog"); - //cstring_t buttonText = jass_checkstring(j, 2); - //int32_t hotkey = jass_checkinteger(j, 3); - return jass_pushnullhandle(j, "button"); + jassDialog_t *dialog = G_JassDialog(jass_checkhandle(j, 1, "dialog")); + cstring_t text = jass_checkstring(j, 2); + int32_t hotkey = jass_checkinteger(j, 3); + jassDialogButtonOptions_t options = { .hotkey = hotkey }; + return jass_pushlighthandle(j, G_JassDialogAddButton(dialog, text, &options), "button"); +} +uint32_t DialogAddQuitButton(jass_t *j) { + jassDialog_t *dialog = G_JassDialog(jass_checkhandle(j, 1, "dialog")); + bool score = jass_checkboolean(j, 2); + cstring_t text = jass_checkstring(j, 3); + int32_t hotkey = jass_checkinteger(j, 4); + jassDialogButtonOptions_t options = { .hotkey = hotkey, .quit = true, .score_screen = score }; + return jass_pushlighthandle(j, G_JassDialogAddButton(dialog, text, &options), "button"); } uint32_t DialogDisplay(jass_t *j) { - //player_t *whichPlayer = jass_checkhandle(j, 1, "player"); - //handle_t whichDialog = jass_checkhandle(j, 2, "dialog"); - //bool flag = jass_checkboolean(j, 3); + player_t *player = jass_checkhandle(j, 1, "player"); + jassDialog_t *dialog = G_JassDialog(jass_checkhandle(j, 2, "dialog")); + bool visible = jass_checkboolean(j, 3); + G_JassDialogDisplay(player, dialog, visible); return 0; } uint32_t InitGameCache(jass_t *j) { diff --git a/games/warcraft-3/game/api/api_module.c b/games/warcraft-3/game/api/api_module.c index a065d0115..7121cc418 100644 --- a/games/warcraft-3/game/api/api_module.c +++ b/games/warcraft-3/game/api/api_module.c @@ -741,6 +741,7 @@ jassModule_t jass_funcs[] = { { "DialogClear", DialogClear }, { "DialogSetMessage", DialogSetMessage }, { "DialogAddButton", DialogAddButton }, + { "DialogAddQuitButton", DialogAddQuitButton }, { "DialogDisplay", DialogDisplay }, { "InitGameCache", InitGameCache }, { "SaveGameCache", SaveGameCache }, diff --git a/games/warcraft-3/game/api/api_trigger.h b/games/warcraft-3/game/api/api_trigger.h index 2461e82a4..a38aaf2d3 100644 --- a/games/warcraft-3/game/api/api_trigger.h +++ b/games/warcraft-3/game/api/api_trigger.h @@ -238,14 +238,25 @@ uint32_t TriggerRegisterGameStateEvent(jass_t *j) { return jass_pushlighthandle(j, evt, "event"); } uint32_t TriggerRegisterDialogEvent(jass_t *j) { - //trigger_t *whichTrigger = jass_checkhandle(j, 1, "trigger"); - //handle_t whichDialog = jass_checkhandle(j, 2, "dialog"); - return jass_pushnullhandle(j, "event"); + trigger_t *trigger = jass_checkhandle(j, 1, "trigger"); + jassDialog_t *dialog = G_JassDialog(jass_checkhandle(j, 2, "dialog")); + event_t *event; + if (!trigger || !dialog || !(event = G_MakeEvent(EVENT_DIALOG_CLICK))) + return jass_pushnullhandle(j, "event"); + event->trigger = trigger; + event->dialog_id = dialog->id; + return jass_pushlighthandle(j, event, "event"); } uint32_t TriggerRegisterDialogButtonEvent(jass_t *j) { - //trigger_t *whichTrigger = jass_checkhandle(j, 1, "trigger"); - //handle_t whichButton = jass_checkhandle(j, 2, "button"); - return jass_pushnullhandle(j, "event"); + trigger_t *trigger = jass_checkhandle(j, 1, "trigger"); + jassDialogButton_t *button = G_JassDialogButton(jass_checkhandle(j, 2, "button")); + event_t *event; + if (!trigger || !button || !(event = G_MakeEvent(EVENT_DIALOG_BUTTON_CLICK))) + return jass_pushnullhandle(j, "event"); + event->trigger = trigger; + event->dialog_id = button->dialog_id; + event->button_id = button->id; + return jass_pushlighthandle(j, event, "event"); } uint32_t TriggerRegisterGameEvent(jass_t *j) { //trigger_t *whichTrigger = jass_checkhandle(j, 1, "trigger"); diff --git a/games/warcraft-3/game/g_commands.c b/games/warcraft-3/game/g_commands.c index 554488f0f..9e6b77a29 100644 --- a/games/warcraft-3/game/g_commands.c +++ b/games/warcraft-3/game/g_commands.c @@ -2898,6 +2898,18 @@ static void CMD_UICanvas(edict_t *ent, uint32_t argc, cstring_t argv[]) { ent->client->canvas = (UICANVASCLASS)value; } +/* The client sends opaque map-lifetime IDs; the game validates ownership. */ +static void CMD_JassDialogChoice(edict_t *ent, uint32_t argc, cstring_t argv[]) { + char *end_dialog, *end_button; + unsigned long dialog, button; + if (!ent || !ent->client || argc != 3) return; + dialog = strtoul(argv[1], &end_dialog, 10); + button = strtoul(argv[2], &end_button, 10); + if (!argv[1][0] || *end_dialog || !argv[2][0] || *end_button || + dialog > MAX_JASS_DIALOGS || button > MAX_JASS_DIALOG_BUTTONS) return; + G_JassDialogClick(ent, (uint32_t)dialog, (uint32_t)button); +} + clientCommand_t clientCommands[] = { { "give", CMD_Give }, { "god", CMD_God }, @@ -2948,6 +2960,7 @@ clientCommand_t clientCommands[] = { { "gameresult_restart", CMD_GameResultRestart }, { "gameresult_load", CMD_GameResultLoad }, { "gameresult_quit", CMD_GameResultQuit }, + { "jassdialog", CMD_JassDialogChoice }, { "debugspawn", CMD_DebugSpawn }, { "enemiesclear", CMD_EnemiesClear }, { "eclear", CMD_EnemiesClear }, diff --git a/games/warcraft-3/game/g_dialog.c b/games/warcraft-3/game/g_dialog.c new file mode 100644 index 000000000..041cd10e1 --- /dev/null +++ b/games/warcraft-3/game/g_dialog.c @@ -0,0 +1,296 @@ +/* Generic Warcraft III JASS choice dialogs. The universal client only renders + * svc_window frames and returns an opaque choice; ownership remains in game.dll. */ +#include "g_local.h" +#include "hud/hud_local.h" +#include "generated/script_dialog.h" +#include "generated/script_dialog_button.h" + +static bool dialog_fdf_warning_printed, button_fdf_warning_printed; + +static bool DialogIsPlayer(player_t const *player, uint32_t *number) { + if (!player || !number || !game.clients) return false; + FOR_LOOP(i, game.max_clients) if (player == &game.clients[i].ps && i < 32) { + if (player->number >= 32) return false; + *number = player->number; + return true; + } + return false; +} + +jassDialog_t *G_JassDialogById(uint32_t id) { + if (!id || id > level.dialog_count || id > MAX_JASS_DIALOGS) return NULL; + jassDialog_t *dialog = &level.dialogs[id - 1]; + return dialog->inuse && dialog->id == id ? dialog : NULL; +} + +jassDialogButton_t *G_JassDialogButtonById(uint32_t id) { + if (!id || id > level.dialog_button_count || id > MAX_JASS_DIALOG_BUTTONS) return NULL; + jassDialogButton_t *button = &level.dialog_buttons[id - 1]; + return button->inuse && button->id == id ? button : NULL; +} + +jassDialog_t *G_JassDialog(handle_t value) { + uintptr_t address = (uintptr_t)value, base = (uintptr_t)level.dialogs; + if (!value || address < base || address >= base + sizeof(level.dialogs) || + (address - base) % sizeof(level.dialogs[0])) return NULL; + jassDialog_t *dialog = value; + return G_JassDialogById(dialog->id) == dialog ? dialog : NULL; +} + +jassDialogButton_t *G_JassDialogButton(handle_t value) { + uintptr_t address = (uintptr_t)value, base = (uintptr_t)level.dialog_buttons; + if (!value || address < base || address >= base + sizeof(level.dialog_buttons) || + (address - base) % sizeof(level.dialog_buttons[0])) return NULL; + jassDialogButton_t *button = value; + return G_JassDialogButtonById(button->id) == button ? button : NULL; +} + +jassDialog_t *G_JassDialogCreate(void) { + if (level.dialog_count >= MAX_JASS_DIALOGS) return NULL; + jassDialog_t *dialog = &level.dialogs[level.dialog_count++]; + memset(dialog, 0, sizeof(*dialog)); + dialog->id = level.dialog_count; + dialog->inuse = true; + return dialog; +} + +/* Store optional presentation metadata without growing this native's argument list. */ +jassDialogButton_t *G_JassDialogAddButton(jassDialog_t *dialog, cstring_t label, + jassDialogButtonOptions_t const *options) { + if (!G_JassDialog(dialog) || level.dialog_button_count >= MAX_JASS_DIALOG_BUTTONS) return NULL; + jassDialogButton_t *button = &level.dialog_buttons[level.dialog_button_count++]; + memset(button, 0, sizeof(*button)); + button->inuse = true; + button->id = level.dialog_button_count; + button->dialog_id = dialog->id; + if (options) { + button->hotkey = options->hotkey; + button->quit = options->quit; + button->score_screen = options->score_screen; + } + snprintf(button->text, sizeof(button->text), "%s", label ? G_LevelString(label) : ""); + return button; +} + +void G_JassDialogClear(jassDialog_t *dialog) { + if (!G_JassDialog(dialog)) return; + FOR_LOOP(i, game.max_clients) { + uint32_t number = game.clients[i].ps.number; + if (number < 32 && (dialog->visible_players & (1u << number))) { + edict_t *ent = G_GetPlayerEntityByNumber(number); + if (ent && ent->client) UI_JassDialogHide(ent); + } + } + dialog->visible_players = 0; + FOR_LOOP(i, level.dialog_button_count) { + jassDialogButton_t *button = &level.dialog_buttons[i]; + if (button->dialog_id == dialog->id) button->inuse = false; + } + dialog->message[0] = '\0'; + /* A displayed dialog is rebuilt only on a subsequent DialogDisplay call. */ +} + +void G_JassDialogDestroy(jassDialog_t *dialog) { + if (!G_JassDialog(dialog)) return; + G_JassDialogClear(dialog); + dialog->inuse = false; + dialog->visible_players = 0; +} + +void G_JassDialogDisplay(player_t *player, jassDialog_t *dialog, bool visible) { + uint32_t index; + if (!G_JassDialog(dialog) || !DialogIsPlayer(player, &index)) return; + if (visible) { + /* The client has a single authoritative choice window per player. */ + FOR_LOOP(i, level.dialog_count) if (level.dialogs[i].id != dialog->id) + level.dialogs[i].visible_players &= ~(1u << index); + dialog->visible_players |= 1u << index; + } else { + bool was_visible = (dialog->visible_players & (1u << index)) != 0; + dialog->visible_players &= ~(1u << index); + if (was_visible) { + edict_t *ent = G_GetPlayerEntityByNumber(index); + if (ent && ent->client) UI_JassDialogHide(ent); + } + } + if (visible) { + edict_t *ent = G_GetPlayerEntityByNumber(player->number); + if (ent && ent->client) UI_JassDialogShow(ent, dialog); + } +} + +void G_JassDialogClick(edict_t *ent, uint32_t dialog_id, uint32_t button_id) { + jassDialog_t *dialog = G_JassDialogById(dialog_id); + jassDialogButton_t *button = G_JassDialogButtonById(button_id); + uint32_t player; + if (!ent || !ent->client || !dialog || !button || button->dialog_id != dialog_id || + !DialogIsPlayer(&ent->client->ps, &player) || + !(dialog->visible_players & (1u << player))) return; + dialog->visible_players &= ~(1u << player); /* reject duplicate commands */ + FOR_EACH_EVENT(registration) { + if (!registration->trigger || registration->dialog_id != dialog_id) continue; + if (registration->type != EVENT_DIALOG_CLICK && + registration->type != EVENT_DIALOG_BUTTON_CLICK) continue; + if (registration->button_id && registration->button_id != button_id) continue; + gameEvent_t *event = G_PublishEvent(NULL, registration->type); + if (!event) break; + event->responseTo = registration; + event->dialog_id = dialog_id; + event->button_id = button_id; + event->dialog_player = player + 1; + } + /* Quit buttons are tracked as distinct choices; retail end-game policy + * must be implemented separately, not guessed from a client UI callback. */ +} + +static void DialogStyleText(frameDef_t *frame) { + if (!frame) return; + if (!frame->Font.Index) + frame->Font.Index = gi.FontIndex("Fonts\\FRIZQT__.TTF", HUD_FONT_SIZE); + if (!frame->Font.Color.a) frame->Font.Color = COLOR32_WHITE; +} + +/* Supply race-skinned chrome and bounds for the dialog's backdrop. */ +static bool DialogApplyBackdrop(frameDef_t *root, frameDef_t *backdrop) { + if (!root) return false; + + if (!backdrop) { + backdrop = UI_Spawn(FT_BACKDROP, root); + if (!backdrop) return false; + snprintf(backdrop->Name, sizeof(backdrop->Name), "JassChoiceDialogBackdrop"); + } + + /* ScriptDialog is not present in every Warcraft data set. Always author + * the same race-skinned chrome used by the in-game menu, including when + * its FDF template exists but leaves the backdrop unskinned. */ + backdrop->Type = FT_BACKDROP; + backdrop->Backdrop.Background = UI_LoadTexture("EscMenuBackground", true); + backdrop->Backdrop.EdgeFile = UI_LoadTexture("EscMenuBorder", true); + backdrop->DecorateFileNames = true; + /* HACK: retail ScriptDialog FDF omits backdrop anchors; the client requires explicit bounds to fill the dialog. */ + UI_SetPoint(backdrop, FRAMEPOINT_TOPLEFT, root, FRAMEPOINT_TOPLEFT, 0, 0); + UI_SetPoint(backdrop, FRAMEPOINT_BOTTOMRIGHT, root, FRAMEPOINT_BOTTOMRIGHT, 0, 0); + root->DialogBackdrop = backdrop; + snprintf(root->DialogBackdropName, sizeof(root->DialogBackdropName), "%s", backdrop->Name); + return true; +} + +/* Client windows accept a server-authored FDF frame tree. The ScriptDialog + * template provides Warcraft styling when it is present in the game archives. */ +void UI_JassDialogShow(edict_t *ent, jassDialog_t const *dialog) { + frameDef_t *root = NULL, *message, *source, *previous = NULL; + ScriptDialog_t dialog_frames, clone_frames; + ScriptDialogButton_t button_template, button_frames; + bool stock_dialog, stock_button; + float content_height = 0.10f; + uint32_t n = 0, omitted = 0; + if (!ent || !ent->client || !dialog) return; + stock_dialog = UI_EnsureFDF("UI\\FrameDef\\UI\\ScriptDialog.fdf") && ScriptDialog_Load(&dialog_frames); + stock_button = stock_dialog && ScriptDialogButton_Load(&button_template); + if (!stock_dialog && !dialog_fdf_warning_printed) { + fprintf(stderr, "WC3 JASS dialog: ScriptDialog FDF unavailable; using native frame types\n"); + dialog_fdf_warning_printed = true; + } + if (stock_dialog && !stock_button && !button_fdf_warning_printed) { + fprintf(stderr, "WC3 JASS dialog: ScriptDialogButton FDF unavailable; using native button frames\n"); + button_fdf_warning_printed = true; + } + source = stock_dialog ? dialog_frames.ScriptDialog : NULL; + root = source ? UI_CloneFrameTree(source, NULL) : UI_Spawn(FT_DIALOG, NULL); + if (!root) { + fprintf(stderr, "WC3 JASS dialog %u: failed to allocate a dialog frame\n", dialog->id); + return; + } + snprintf(root->Name, sizeof(root->Name), "JassChoiceDialog"); + if (source && !ScriptDialog_Bind(&clone_frames, root)) { + fprintf(stderr, "WC3 JASS dialog %u: cloned ScriptDialog binding is incomplete\n", dialog->id); + goto cleanup; + } + if (!root->Width || !root->Height) { + /* TODO: retail ScriptDialog dimensions are absent in some data sets; retain a stock-shaped native fallback. */ + UI_SetSize(root, 0.288f, 0.17f); + } + if (!DialogApplyBackdrop(root, source ? clone_frames.ScriptDialogBackdrop : NULL)) { + fprintf(stderr, "WC3 JASS dialog %u: failed to allocate backdrop\n", dialog->id); + goto cleanup; + } + UI_CenterFrame(root); + message = source ? clone_frames.ScriptDialogText : NULL; + if (!message) message = UI_Spawn(FT_TEXT, root); + if (!message) { + fprintf(stderr, "WC3 JASS dialog %u: failed to allocate message text\n", dialog->id); + goto cleanup; + } + snprintf(message->Name, sizeof(message->Name), "JassChoiceMessage"); + DialogStyleText(message); + UI_SetText(message, "%s", dialog->message); + FOR_LOOP(i, level.dialog_button_count) { + jassDialogButton_t const *entry = &level.dialog_buttons[i]; + frameDef_t *button, *text; + if (!entry->inuse || entry->dialog_id != dialog->id) continue; + if (n >= MAX_JASS_DIALOG_UI_BUTTONS) { ++omitted; continue; } + if (stock_button) button = UI_CloneFrameTree(button_template.ScriptDialogButton, root); + else button = UI_Spawn(FT_GLUETEXTBUTTON, root); + if (!button) { + fprintf(stderr, "WC3 JASS dialog %u: failed to allocate button %u\n", dialog->id, entry->id); + goto cleanup; + } + if (stock_button && !ScriptDialogButton_Bind(&button_frames, button)) { + fprintf(stderr, "WC3 JASS dialog %u: cloned button %u binding is incomplete\n", dialog->id, entry->id); + goto cleanup; + } + snprintf(button->Name, sizeof(button->Name), "JassChoiceButton%u", n); + if (!button->Width || !button->Height) { + /* TODO: this fallback applies only when no button dimensions exist in the loaded stock FDF. */ + UI_SetSize(button, 0.159f, 0.031f); + } + /* HACK: ScriptDialog FDF supplies a button template but no row container or dynamic anchors. */ + if (previous) UI_SetPoint(button, FRAMEPOINT_TOP, previous, FRAMEPOINT_BOTTOM, 0, -0.004f); + else UI_SetPoint(button, FRAMEPOINT_TOP, root, FRAMEPOINT_TOP, 0, -0.10f); + content_height += button->Height + 0.004f; + previous = button; + text = stock_button ? button_frames.ScriptDialogButtonText : NULL; + if (!text) text = UI_Spawn(FT_TEXT, button); + if (!text) { + fprintf(stderr, "WC3 JASS dialog %u: failed to allocate label for button %u\n", dialog->id, entry->id); + goto cleanup; + } + snprintf(text->Name, sizeof(text->Name), "JassChoiceButtonText%u", n); + UI_SetPoint(text, FRAMEPOINT_CENTER, button, FRAMEPOINT_CENTER, 0, 0); + DialogStyleText(text); + UI_SetText(text, "%s", entry->text); + snprintf(button->Button.NormalText.frame, sizeof(button->Button.NormalText.frame), "%s", text->Name); + UI_SetOnClick(button, UI_WINDOW_CLOSE_COMMAND_PREFIX "jassdialog %u %u", dialog->id, entry->id); + ++n; + } + if (omitted) fprintf(stderr, "WC3 JASS dialog %u: omitted %u button(s); UI capacity is 12\n", dialog->id, omitted); + /* HACK: expand the template around its generated rows; stock FDF has no variable-height choice layout. */ + UI_SetSize(root, root->Width, MAX(root->Height, content_height + 0.012f)); + UI_SetCurrentClient(ent->client); + UI_WriteWindow(ent, root, &MAKE(uiWindowDef_t, .id = WC3_JASS_DIALOG_WINDOW, + .class_id = WC3_JASS_DIALOG_WINDOW, + .flags = UI_WINDOW_MODAL | UI_WINDOW_UNIQUE | UI_WINDOW_NO_PAUSE | UI_WINDOW_NO_ESCAPE)); + UI_SetCurrentClient(NULL); +cleanup: + UI_FreeFrameTree(root); +} + +void UI_JassDialogHide(edict_t *ent) { + if (!ent || !ent->client) return; + gi.Write(PF_BYTE, &(int32_t){svc_window}); + gi.Write(PF_BYTE, &(int32_t){UI_WINDOW_CLOSE}); + gi.Write(PF_LONG, &(uint32_t){WC3_JASS_DIALOG_WINDOW}); + gi.unicast(ent); +} + +void UI_JassDialogRestore(edict_t *ent) { + uint32_t player; + if (!ent || !ent->client || !DialogIsPlayer(&ent->client->ps, &player)) return; + FOR_LOOP(i, level.dialog_count) { + jassDialog_t *dialog = &level.dialogs[i]; + if (dialog->inuse && (dialog->visible_players & (1u << player))) { + UI_JassDialogShow(ent, dialog); + return; + } + } +} diff --git a/games/warcraft-3/game/g_events.c b/games/warcraft-3/game/g_events.c index a06ee8055..52030fbc8 100644 --- a/games/warcraft-3/game/g_events.c +++ b/games/warcraft-3/game/g_events.c @@ -119,6 +119,12 @@ static void G_ExecuteEvent(gameEvent_t *evt) { FOR_EACH_EVENT(e) { if (!G_EventSubjectIsCurrent(e)) continue; switch (e->type) { + case EVENT_DIALOG_CLICK: + case EVENT_DIALOG_BUTTON_CLICK: + if (evt->responseTo == e && evt->dialog_id == e->dialog_id && + (!e->button_id || evt->button_id == e->button_id)) + jass_calltriggerevent(level.vm, e->trigger, evt); + break; case EVENT_GAME_VICTORY: break; case EVENT_GAME_END_LEVEL: diff --git a/games/warcraft-3/game/g_local.h b/games/warcraft-3/game/g_local.h index 3b8098fd3..2d8683106 100644 --- a/games/warcraft-3/game/g_local.h +++ b/games/warcraft-3/game/g_local.h @@ -24,6 +24,12 @@ #define SEL_SCALE 72 #define MAX_BUILD_QUEUE 7 #define MAX_EVENT_QUEUE 1024 +#define MAX_JASS_DIALOGS 64 // dialogs; bounds stable map-lifetime handles; used by the JASS registry +#define MAX_JASS_DIALOG_BUTTONS 256 // buttons; bounds stable map-lifetime handles; used by the JASS registry +#define MAX_JASS_DIALOG_TEXT 512 // bytes; bounds retained dialog messages; used by save and UI serialization +#define MAX_JASS_DIALOG_BUTTON_TEXT 192 // bytes; bounds retained choice labels; used by save and UI serialization +#define MAX_JASS_DIALOG_UI_BUTTONS 12 // buttons; stock template has no scrolling container; excess choices are logged +#define WC3_JASS_DIALOG_WINDOW 0x4A444C47u // window ID; JDLG tag identifies the modal JASS choice window #define MAX_MESSAGE_SUBSCRIBERS 8 // callbacks; bounded because messages are synchronous and game-local #define MAX_UNIT_SELECT_SOUNDS 6 // sounds; largest UnitAckSounds *What variant list in ROC/TFT data #define BZ_STRINGIFY_INNER(value) #value @@ -911,6 +917,29 @@ typedef struct { float AcquireRange; } unitInfo_t; +typedef struct { + bool inuse; + uint32_t id; /* one-based and never recycled during the map */ + uint32_t dialog_id; + int32_t hotkey; + bool quit; + bool score_screen; + char text[MAX_JASS_DIALOG_BUTTON_TEXT]; +} jassDialogButton_t; + +typedef struct { + bool inuse; + uint32_t id; /* one-based and never recycled during the map */ + uint32_t visible_players; /* client player numbers */ + char message[MAX_JASS_DIALOG_TEXT]; +} jassDialog_t; + +typedef struct { + int32_t hotkey; + bool quit; + bool score_screen; +} jassDialogButtonOptions_t; + typedef struct gameevent_s { EVENTTYPE type; edict_t *edict; @@ -923,6 +952,8 @@ typedef struct gameevent_s { vec2_t point; bool has_point; event_t *responseTo; + uint32_t dialog_id, button_id; + uint32_t dialog_player; /* one-based player index; zero means no override */ } gameEvent_t; typedef struct { @@ -1905,6 +1936,7 @@ struct gevent_s { gtimer_t *timer; struct jass_function const *filter; handle_t region; + uint32_t dialog_id, button_id; /* registration target, one-based */ float range; uint32_t state; uint32_t limitop; @@ -2197,6 +2229,9 @@ struct level_locals { bool script_paused; bool quest_paused; bool modal_paused; + jassDialog_t dialogs[MAX_JASS_DIALOGS]; + jassDialogButton_t dialog_buttons[MAX_JASS_DIALOG_BUTTONS]; + uint32_t dialog_count, dialog_button_count; timeOfDay_t timeofday; wc3EnvironmentFog_t environment_fog; box2_t camera_bounds; /* map-global camera target rectangle; W3I default, SetCameraBounds may replace it */ @@ -2981,6 +3016,20 @@ void UI_ClearLayer(edict_t *, uint32_t); void UI_ShowGameResult(edict_t *, uint32_t); void UI_FlushPendingGameResults(void); void UI_HideGameResult(edict_t *); +/* JASS interactive choice dialogs: the registry and click authority live in the game. */ +jassDialog_t *G_JassDialog(handle_t); +jassDialogButton_t *G_JassDialogButton(handle_t); +jassDialog_t *G_JassDialogById(uint32_t); +jassDialogButton_t *G_JassDialogButtonById(uint32_t); +jassDialog_t *G_JassDialogCreate(void); +jassDialogButton_t *G_JassDialogAddButton(jassDialog_t *, cstring_t, jassDialogButtonOptions_t const *); +void G_JassDialogDestroy(jassDialog_t *); +void G_JassDialogClear(jassDialog_t *); +void G_JassDialogDisplay(player_t *, jassDialog_t *, bool); +void G_JassDialogClick(edict_t *, uint32_t, uint32_t); +void UI_JassDialogShow(edict_t *, jassDialog_t const *); +void UI_JassDialogHide(edict_t *); +void UI_JassDialogRestore(edict_t *); void UI_ShowQuests(edict_t *); void UI_HideQuests(edict_t *); void UI_ShowAllies(edict_t *); diff --git a/games/warcraft-3/game/g_main.c b/games/warcraft-3/game/g_main.c index 8c99de6b1..e20298d02 100644 --- a/games/warcraft-3/game/g_main.c +++ b/games/warcraft-3/game/g_main.c @@ -1419,6 +1419,7 @@ static void G_ClientBegin(edict_t *edict) { UI_WriteHoverLayout(edict); UI_WriteTimerDialogs(edict); UI_WriteLeaderboard(edict); + UI_JassDialogRestore(edict); G_AccumulatePlayerFood(client); /* Invalidate cache so the initial resource bar write always fires. */ diff --git a/games/warcraft-3/game/g_save.c b/games/warcraft-3/game/g_save.c index a0419e30e..0a15a2125 100644 --- a/games/warcraft-3/game/g_save.c +++ b/games/warcraft-3/game/g_save.c @@ -78,8 +78,8 @@ enum { static uint32_t const save_magic = MAKEFOURCC('W', '3', 'S', 'V'); static uint32_t const save_commit = MAKEFOURCC('W', '3', 'O', 'K'); -/* Version 65 makes delayed ability owner/target incarnation snapshots mandatory. */ -static uint32_t const save_version = 65; +/* Version 66 adds persistent JASS choice-dialog state, button handles, and event payloads. */ +static uint32_t const save_version = 66; #define MAX_SAVE_STRING (1u << 20) // bytes; bounds quest-string allocations from corrupt saves #define MAX_SAVE_GROUP_HANDLES 65536u // corrupt-save bound only; runtime group registry itself grows dynamically #define UMOVE_RELOC_RANGE (64 << 20) // bytes; every umove_t is static data in libgame, so a valid offset from the anchor stays well inside one module image @@ -180,6 +180,8 @@ typedef enum { JASS_HANDLE_GROUP, JASS_HANDLE_TIMER, JASS_HANDLE_TIMERDIALOG, + JASS_HANDLE_DIALOG, + JASS_HANDLE_BUTTON, JASS_HANDLE_LEADERBOARD, JASS_HANDLE_MULTIBOARD, JASS_HANDLE_MULTIBOARDITEM, @@ -204,6 +206,8 @@ static struct { cstring_t type; jassHandleDomain_t domain; } const jass_handle_d { "group", JASS_HANDLE_GROUP }, { "timer", JASS_HANDLE_TIMER }, { "timerdialog", JASS_HANDLE_TIMERDIALOG }, + { "dialog", JASS_HANDLE_DIALOG }, + { "button", JASS_HANDLE_BUTTON }, { "leaderboard", JASS_HANDLE_LEADERBOARD }, { "multiboard", JASS_HANDLE_MULTIBOARD }, { "multiboarditem", JASS_HANDLE_MULTIBOARDITEM }, @@ -214,6 +218,25 @@ static struct { cstring_t type; jassHandleDomain_t domain; } const jass_handle_d { "region", JASS_HANDLE_REGION }, }; +static field_t const jass_dialog_fields[] = { + TF(jassDialog_t, inuse, F_INT), + TF(jassDialog_t, id, F_INT), + TF(jassDialog_t, visible_players, F_INT), + TF(jassDialog_t, message, F_INT), + { NULL, 0, 0, 0, 0, 0 } +}; + +static field_t const jass_dialog_button_fields[] = { + TF(jassDialogButton_t, inuse, F_INT), + TF(jassDialogButton_t, id, F_INT), + TF(jassDialogButton_t, dialog_id, F_INT), + TF(jassDialogButton_t, hotkey, F_INT), + TF(jassDialogButton_t, quit, F_INT), + TF(jassDialogButton_t, score_screen, F_INT), + TF(jassDialogButton_t, text, F_INT), + { NULL, 0, 0, 0, 0, 0 } +}; + static field_t const timer_dialog_fields[] = { F(gtimerdialog_s, timer, F_TIMER, 0, FIELD_NONE), F(gtimerdialog_s, inuse, F_INT), @@ -267,6 +290,8 @@ static field_t const save_event_fields[] = { F(gevent_s, timer, F_TIMER, 0, FIELD_NONE), F(gevent_s, filter, F_FUNCTION), F(gevent_s, region, F_REGION), + F(gevent_s, dialog_id, F_INT), + F(gevent_s, button_id, F_INT), F(gevent_s, range, F_FLOAT), F(gevent_s, state, F_INT), F(gevent_s, limitop, F_INT), @@ -302,6 +327,9 @@ static field_t const save_game_event_fields[] = { F(gameevent_s, point, F_VECTOR), F(gameevent_s, has_point, F_INT), F(gameevent_s, responseTo, F_EVENT, 0, FIELD_NONE), + F(gameevent_s, dialog_id, F_INT), + F(gameevent_s, button_id, F_INT), + F(gameevent_s, dialog_player, F_INT), { NULL, 0, 0, 0, 0, 0 } }; @@ -486,6 +514,10 @@ static field_t const level_fields[] = { FC(level_locals, triggers, F_STRUCT, MAX_TRIGGERS, trigger_fields, num_triggers), FC(level_locals, timers, F_STRUCT, MAX_TIMERS, timer_fields, num_timers), F(level_locals, timer_dialogs, F_STRUCT, MAX_TIMERDIALOGS, timer_dialog_fields), + F(level_locals, dialog_count, F_INT), + F(level_locals, dialog_button_count, F_INT), + F(level_locals, dialogs, F_STRUCT, MAX_JASS_DIALOGS, jass_dialog_fields), + F(level_locals, dialog_buttons, F_STRUCT, MAX_JASS_DIALOG_BUTTONS, jass_dialog_button_fields), F(level_locals, leaderboards, F_STRUCT, MAX_LEADERBOARDS, leaderboard_fields), F(level_locals, player_leaderboards, F_INT), F(level_locals, multiboards, F_STRUCT, MAX_MULTIBOARDS, multiboard_fields), @@ -1162,6 +1194,16 @@ bool G_SaveJassHandle(cstring_t type, handle_t value, uint32_t *id) { if (domain == JASS_HANDLE_TIMER) { return TimerIndex(value, id); } + if (domain == JASS_HANDLE_DIALOG) { + jassDialog_t *dialog = G_JassDialog(value); + if (!dialog) return false; + *id = dialog->id; return true; + } + if (domain == JASS_HANDLE_BUTTON) { + jassDialogButton_t *button = G_JassDialogButton(value); + if (!button) return false; + *id = button->id; return true; + } if (domain == JASS_HANDLE_TIMERDIALOG) { timerdialog_t *dialog = value; if (dialog < level.timer_dialogs || dialog >= level.timer_dialogs + MAX_TIMERDIALOGS || !dialog->inuse) @@ -1258,6 +1300,8 @@ handle_t G_LoadJassHandle(cstring_t type, uint32_t id) { return group && group->inuse ? group : NULL; } if (domain == JASS_HANDLE_TIMER) return id < level.num_timers ? &level.timers[id] : NULL; + if (domain == JASS_HANDLE_DIALOG) return G_JassDialogById(id); + if (domain == JASS_HANDLE_BUTTON) return G_JassDialogButtonById(id); if (domain == JASS_HANDLE_TIMERDIALOG) return id < MAX_TIMERDIALOGS && level.timer_dialogs[id].inuse ? &level.timer_dialogs[id] : NULL; if (domain == JASS_HANDLE_LEADERBOARD) @@ -2209,6 +2253,8 @@ bool ReadGame(cstring_t filename) { FOR_LOOP(i, game.max_clients) if (game.clients[i].connected) { G_MusicSyncClient(game.clients + i); UI_UpdateCursorPresentation(game.clients + i); + edict_t *player_ent = G_GetPlayerEntityByNumber(game.clients[i].ps.number); + if (player_ent) UI_JassDialogRestore(player_ent); } /* svc_layout layers are client presentation state and are not serialized. * Force the restored timer-dialog model to republish on the next frame. */ @@ -2356,8 +2402,9 @@ TEST(wc3_save, rejects_prior_save_versions) { "/tmp/openwarcraft3-wc3-save-version-62.bin", "/tmp/openwarcraft3-wc3-save-version-63.bin", "/tmp/openwarcraft3-wc3-save-version-64.bin", + "/tmp/openwarcraft3-wc3-save-version-65.bin", }; - uint32_t const old_versions[] = { 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64 }; + uint32_t const old_versions[] = { 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65 }; reset_entities(); setup_test_world(); diff --git a/games/warcraft-3/game/generated/script_dialog.h b/games/warcraft-3/game/generated/script_dialog.h new file mode 100644 index 000000000..70976ea77 --- /dev/null +++ b/games/warcraft-3/game/generated/script_dialog.h @@ -0,0 +1,36 @@ +/* Generated by fdfbindgen. Do not edit by hand. */ +#ifndef SCRIPTDIALOG_H +#define SCRIPTDIALOG_H + +#include "../g_local.h" + +typedef struct ScriptDialog_s { + frameDef_t * ScriptDialog; + frameDef_t * ScriptDialogBackdrop; + frameDef_t * ScriptDialogText; +} ScriptDialog_t; + + +static inline bool ScriptDialog_Bind(ScriptDialog_t *out, frameDef_t *bind_root) { + bool ok = true; + if (!out) { + return false; + } + memset(out, 0, sizeof(*out)); + out->ScriptDialog = bind_root; + if (!out->ScriptDialog) { + BZ_FDF_REPORT_MISSING("ScriptDialog"); + ok = false; + } + BZ_FDF_BIND_CHILD(out, ScriptDialogBackdrop, bind_root, "ScriptDialogBackdrop"); + BZ_FDF_BIND_CHILD(out, ScriptDialogText, bind_root, "ScriptDialogText"); + return ok; +} + +static inline bool ScriptDialog_Load(ScriptDialog_t *out) { + return out && + UI_EnsureFDF("UI\\FrameDef\\UI\\EscMenuTemplates.fdf") && + ScriptDialog_Bind(out, UI_FindFrame("ScriptDialog")); +} + +#endif /* SCRIPTDIALOG_H */ diff --git a/games/warcraft-3/game/generated/script_dialog_button.h b/games/warcraft-3/game/generated/script_dialog_button.h new file mode 100644 index 000000000..099a7629d --- /dev/null +++ b/games/warcraft-3/game/generated/script_dialog_button.h @@ -0,0 +1,34 @@ +/* Generated by fdfbindgen. Do not edit by hand. */ +#ifndef SCRIPTDIALOGBUTTON_H +#define SCRIPTDIALOGBUTTON_H + +#include "../g_local.h" + +typedef struct ScriptDialogButton_s { + frameDef_t * ScriptDialogButton; + frameDef_t * ScriptDialogButtonText; +} ScriptDialogButton_t; + + +static inline bool ScriptDialogButton_Bind(ScriptDialogButton_t *out, frameDef_t *bind_root) { + bool ok = true; + if (!out) { + return false; + } + memset(out, 0, sizeof(*out)); + out->ScriptDialogButton = bind_root; + if (!out->ScriptDialogButton) { + BZ_FDF_REPORT_MISSING("ScriptDialogButton"); + ok = false; + } + BZ_FDF_BIND_CHILD(out, ScriptDialogButtonText, bind_root, "ScriptDialogButtonText"); + return ok; +} + +static inline bool ScriptDialogButton_Load(ScriptDialogButton_t *out) { + return out && + UI_EnsureFDF("UI\\FrameDef\\UI\\EscMenuTemplates.fdf") && + ScriptDialogButton_Bind(out, UI_FindFrame("ScriptDialogButton")); +} + +#endif /* SCRIPTDIALOGBUTTON_H */ diff --git a/games/warcraft-3/game/hud/hud.c b/games/warcraft-3/game/hud/hud.c index 32f82bd1d..221467ab4 100644 --- a/games/warcraft-3/game/hud/hud.c +++ b/games/warcraft-3/game/hud/hud.c @@ -521,6 +521,11 @@ bool UI_BuildFrameForWrite(frameDef_t const *frame, out->points.y[anchor].used = 1; } out->color = frame->Font.Color; + if (frame->Parent && frame->Parent->disabled && + (frame->Font.DisabledColor.a || frame->Font.DisabledColor.r || + frame->Font.DisabledColor.g || frame->Font.DisabledColor.b)) { + out->color = frame->Font.DisabledColor; + } if (buf.cursize + sizeof(data) <= buf.maxsize) { memcpy(buf.data + buf.cursize, &data, sizeof(data)); buf.cursize += sizeof(data); diff --git a/games/warcraft-3/game/hud/hud_local.h b/games/warcraft-3/game/hud/hud_local.h index d2d68412b..6531b13c2 100644 --- a/games/warcraft-3/game/hud/hud_local.h +++ b/games/warcraft-3/game/hud/hud_local.h @@ -42,7 +42,7 @@ #define WC3_MESSAGE_LOG_TEXT_SIZE \ (WC3_MESSAGE_LOG_MAX_ENTRIES * (WC3_MESSAGE_LOG_ENTRY_SIZE + 4) + 1) #define HUD_CONSOLE_WIDE_MAX 8 // frames; retail ConsoleUI.fdf authors four widescreen tiles; headroom for custom skins -#define HUD_DEFERRED_IMAGES 8 // symbolic skin keys registered at write time, not parse time; class-gated chrome only +#define HUD_DEFERRED_IMAGES 16 // handles; fits deferred console chrome and EscMenu background/state keys #define HUD_DEFERRED_IMAGE_BASE MAX_IMAGES // handle base; deferred handles never alias a live CS_IMAGES slot typedef struct { diff --git a/games/warcraft-3/game/hud/hud_write.c b/games/warcraft-3/game/hud/hud_write.c index b5ffd09e9..6dc7ebef0 100644 --- a/games/warcraft-3/game/hud/hud_write.c +++ b/games/warcraft-3/game/hud/hud_write.c @@ -298,6 +298,28 @@ bool UI_IsWideChromeKey(cstring_t key) { return false; } +static bool UI_IsDeferredImageKey(cstring_t key) { + /* The stock Default skin maps EscMenuBackground and EscMenuButtonBackground + * to the same Human texture. Registering both by path aliases their live + * image slot and loses the key needed to select race art at write time. */ + static cstring_t const keys[] = { + "EscMenuBackground", + "EscMenuButtonBackground", + "EscMenuButtonPushedBackground", + "EscMenuButtonDisabledBackground", + "EscMenuButtonDisabledPushedBackground", + "EscMenuButtonBorder", + "EscMenuButtonPushedBorder", + "EscMenuButtonDisabledBorder", + "EscMenuButtonDisabledPushedBorder", + "EscMenuButtonMouseOverHighlight", + }; + if (UI_IsWideChromeKey(key)) return true; + FOR_LOOP(i, sizeof(keys) / sizeof(keys[0])) + if (key && !strcmp(key, keys[i])) return true; + return false; +} + static uint32_t UI_DeferredImage(cstring_t key) { FOR_LOOP(i, HUD_DEFERRED_IMAGES) { if (!hud.deferred_key[i][0]) snprintf(hud.deferred_key[i], sizeof(hud.deferred_key[i]), "%s", key); @@ -404,7 +426,7 @@ BZ_HOST_HIDDEN uint32_t UI_LoadTexture(cstring_t path, bool decorate) { uint32_t index; if (!path || !*path) return 0; - if (UI_IsWideChromeKey(path) && (index = UI_DeferredImage(path))) return index; + if (UI_IsDeferredImageKey(path) && (index = UI_DeferredImage(path))) return index; cstring_t resolved = UI_ThemeImagePath(path); index = gi.ImageIndex(resolved); diff --git a/games/warcraft-3/game/tests/t_canvas.c b/games/warcraft-3/game/tests/t_canvas.c index 84ef64f0f..d40ef42dd 100644 --- a/games/warcraft-3/game/tests/t_canvas.c +++ b/games/warcraft-3/game/tests/t_canvas.c @@ -20,6 +20,18 @@ typedef struct { cstring_t (*configstring)(uint32_t); } canvasCap_t; static canvasCap_t cap; +static cstring_t const canvas_esc_menu_keys[] = { + "EscMenuBackground", + "EscMenuButtonBackground", + "EscMenuButtonPushedBackground", + "EscMenuButtonDisabledBackground", + "EscMenuButtonDisabledPushedBackground", + "EscMenuButtonBorder", + "EscMenuButtonPushedBorder", + "EscMenuButtonDisabledBorder", + "EscMenuButtonDisabledPushedBorder", + "EscMenuButtonMouseOverHighlight", +}; static int canvas_image_index(cstring_t name) { if (!name || !*name) return 0; @@ -112,6 +124,57 @@ TEST(wc3_canvas, console_load_defers_widescreen_tiles_and_collects_their_frames) canvas_teardown(); } +TEST(wc3_canvas, esc_menu_art_resolves_for_each_race_and_fits_deferred_table) { + gameClient_t client = { 0 }; + gameClient_t *saved_client = ui_current_client; + char skin_text[4096]; + size_t skin_size = 0; + uint32_t image[sizeof(canvas_esc_menu_keys) / sizeof(canvas_esc_menu_keys[0])]; + + canvas_setup(); + + Stb_IniCacheFree(&game.config.theme); + memset(&game.config.theme, 0, sizeof(game.config.theme)); + skin_size += (size_t)snprintf(skin_text + skin_size, sizeof(skin_text) - skin_size, "[NightElf]\n"); + FOR_LOOP(i, sizeof(canvas_esc_menu_keys) / sizeof(canvas_esc_menu_keys[0])) + skin_size += (size_t)snprintf(skin_text + skin_size, sizeof(skin_text) - skin_size, + "%s=TestUI\\Textures\\nightelf-%u.blp\n", canvas_esc_menu_keys[i], (unsigned)i); + skin_size += (size_t)snprintf(skin_text + skin_size, sizeof(skin_text) - skin_size, "\n[Undead]\n"); + FOR_LOOP(i, sizeof(canvas_esc_menu_keys) / sizeof(canvas_esc_menu_keys[0])) + skin_size += (size_t)snprintf(skin_text + skin_size, sizeof(skin_text) - skin_size, + "%s=TestUI\\Textures\\undead-%u.blp\n", canvas_esc_menu_keys[i], (unsigned)i); + T_ASSERT(Stb_IniCacheLoadBuffer(&game.config.theme, skin_text)); + + FOR_LOOP(i, sizeof(canvas_esc_menu_keys) / sizeof(canvas_esc_menu_keys[0])) { + image[i] = UI_LoadTexture(canvas_esc_menu_keys[i], true); + T_ASSERT(image[i] >= HUD_DEFERRED_IMAGE_BASE); + T_ASSERT(image[i] < HUD_DEFERRED_IMAGE_BASE + HUD_DEFERRED_IMAGES); + T_STREQ(UI_ImageKey(image[i]), canvas_esc_menu_keys[i]); + FOR_LOOP(j, i) T_ASSERT(image[i] != image[j]); + } + /* ConsoleUI.fdf already occupies two deferred slots; all ten EscMenu keys must still fit. */ + T_STREQ(UI_ImageKey(HUD_DEFERRED_IMAGE_BASE + 12), ""); + + UI_SetCurrentClient(&client); + client.ps.race = kPlayerRaceNightElf; + FOR_LOOP(i, sizeof(canvas_esc_menu_keys) / sizeof(canvas_esc_menu_keys[0])) { + uint32_t live = UI_LiveImage(image[i]); + T_ASSERT(live > 0 && live < MAX_IMAGES); + T_STREQ(cap.images[live], UI_ThemeImagePath(canvas_esc_menu_keys[i])); + T_ASSERT(strstr(cap.images[live], "nightelf-") != NULL); + } + client.ps.race = kPlayerRaceUndead; + FOR_LOOP(i, sizeof(canvas_esc_menu_keys) / sizeof(canvas_esc_menu_keys[0])) { + uint32_t live = UI_LiveImage(image[i]); + T_ASSERT(live > 0 && live < MAX_IMAGES); + T_STREQ(cap.images[live], UI_ThemeImagePath(canvas_esc_menu_keys[i])); + T_ASSERT(strstr(cap.images[live], "undead-") != NULL); + } + UI_SetCurrentClient(saved_client); + + canvas_teardown(); +} + TEST(wc3_canvas, console_write_authors_extension_tiles_for_wide_clients_only) { edict_t *ent = &g_edicts[0]; gameClient_t *client = ent->client; diff --git a/games/warcraft-3/game/tests/t_dialog.c b/games/warcraft-3/game/tests/t_dialog.c new file mode 100644 index 000000000..62d24d013 --- /dev/null +++ b/games/warcraft-3/game/tests/t_dialog.c @@ -0,0 +1,262 @@ +#ifdef BZ_TESTS +#include "shared/test.h" +#include "../g_local.h" +#include "jass/jass.h" +#include + +void setup_test_world(void); +bool run_test_jass(cstring_t script); + +static void dialog_test_write(pfWriteType_t type, void const *data) { (void)type; (void)data; } +static edict_t *dialog_unicast_target; +static uint32_t dialog_unicast_count; +static void dialog_test_unicast(edict_t *ent) { dialog_unicast_target = ent; ++dialog_unicast_count; } +static int dialog_test_font(cstring_t name, uint32_t size) { (void)name; (void)size; return 1; } +static int dialog_test_image(cstring_t path) { return path && *path ? 1 : 0; } +static uint32_t dialog_test_frame_count(void) { + uint32_t count = 0; + FOR_LOOP(i, MAX_UI_CLASSES) if (frames[i].inuse) ++count; + return count; +} + +TEST(wc3_dialog, create_add_clear_destroy_retains_stable_handles) { + jassDialog_t *dialog; + jassDialogButton_t *first, *second; + setup_test_world(); + dialog = G_JassDialogCreate(); + T_NOT_NULL(dialog); + T_EQ(G_JassDialogById(dialog->id), dialog); + first = G_JassDialogAddButton(dialog, "Long Route", NULL); + second = G_JassDialogAddButton(dialog, "Short Route", NULL); + T_NOT_NULL(first); T_NOT_NULL(second); + T_NE(first, second); + T_EQ(first->dialog_id, dialog->id); + T_STREQ(first->text, "Long Route"); + G_JassDialogClear(dialog); + T_NULL(G_JassDialogButton(first)); + T_NULL(G_JassDialogButton(second)); + T_EQ(G_JassDialog(dialog), dialog); + second = G_JassDialogAddButton(dialog, "New Route", NULL); + T_NOT_NULL(second); + T_NE(first, second); + G_JassDialogDestroy(dialog); + T_NULL(G_JassDialog(dialog)); + T_NULL(G_JassDialogButton(second)); +} + +TEST(wc3_dialog, save_round_trip_restores_dialog_handles_and_visibility) { + cstring_t filename = "/tmp/openrealm-wc3-jass-dialog-save.bin"; + jassDialog_t *dialog; + jassDialogButton_t *button; + setup_test_world(); + T_ASSERT(run_test_jass("function main takes nothing returns nothing\nendfunction\n")); + dialog = G_JassDialogCreate(); + T_NOT_NULL(dialog); + snprintf(dialog->message, sizeof(dialog->message), "Choose a route"); + button = G_JassDialogAddButton(dialog, "Long", NULL); + T_NOT_NULL(button); + dialog->visible_players = 1; + T_ASSERT(WriteGame(filename)); + G_JassDialogClear(dialog); + T_NULL(G_JassDialogButtonById(1)); + T_ASSERT(ReadGame(filename)); + dialog = G_JassDialogById(1); + button = G_JassDialogButtonById(1); + T_NOT_NULL(dialog); T_NOT_NULL(button); + T_STREQ(dialog->message, "Choose a route"); + T_STREQ(button->text, "Long"); + T_EQ(dialog->visible_players, 1u); + remove(filename); +} + +TEST(wc3_dialog, jass_natives_allocate_real_dialog_and_button_handles) { + setup_test_world(); + T_ASSERT(run_test_jass( + "function main takes nothing returns nothing\n" + " local dialog d = DialogCreate()\n" + " local button a\n" + " local button b\n" + " local button q\n" + " call BJassAssert(d != null, \"dialog must be allocated\")\n" + " call DialogSetMessage(d, \"Choose route\")\n" + " set a = DialogAddButton(d, \"Long\", 0)\n" + " set b = DialogAddButton(d, \"Short\", 0)\n" + " set q = DialogAddQuitButton(d, true, \"Quit\", 0)\n" + " call BJassAssert(a != null and b != null and q != null and a != b, \"distinct buttons\")\n" + "endfunction\n")); + T_EQ(level.dialog_count, 1u); + T_EQ(level.dialog_button_count, 3u); + T_ASSERT(G_JassDialogButtonById(3)->quit); + T_ASSERT(G_JassDialogButtonById(3)->score_screen); + G_JassDialogClear(G_JassDialogById(1)); + G_JassDialogDestroy(G_JassDialogById(1)); + T_NULL(G_JassDialogById(1)); +} + +TEST(wc3_dialog, clicking_publishes_only_matching_registrations) { + jassDialog_t *dialog; + jassDialogButton_t *long_button, *short_button; + uint32_t n; + setup_test_world(); + T_ASSERT(run_test_jass( + "globals\n" + " dialog route = null\n" + " button longer = null\n" + " button shorter = null\n" + " integer selection = 0\n" + " integer dialogEvents = 0\n" + " integer buttonEvents = 0\n" + "endglobals\n" + "function onDialog takes nothing returns nothing\n" + " set dialogEvents = dialogEvents + 1\n" + " if GetClickedDialog() == route and GetTriggerPlayer() == Player(0) then\n" + " if GetClickedButton() == longer then\n" + " set selection = 1\n" + " elseif GetClickedButton() == shorter then\n" + " set selection = 2\n" + " endif\n" + " endif\n" + "endfunction\n" + "function onButton takes nothing returns nothing\n" + " set buttonEvents = buttonEvents + 1\n" + "endfunction\n" + "function main takes nothing returns nothing\n" + " local trigger t = CreateTrigger()\n" + " local trigger b = CreateTrigger()\n" + " set route = DialogCreate()\n" + " set longer = DialogAddButton(route, \"Long\", 0)\n" + " set shorter = DialogAddButton(route, \"Short\", 0)\n" + " call TriggerRegisterDialogEvent(t, route)\n" + " call TriggerAddAction(t, function onDialog)\n" + " call TriggerRegisterDialogButtonEvent(b, longer)\n" + " call TriggerAddAction(b, function onButton)\n" + "endfunction\n" + "function verify takes nothing returns nothing\n" + " call BJassAssert(selection == 2, \"short branch\")\n" + " call BJassAssert(dialogEvents == 1, \"one dialog event\")\n" + " call BJassAssert(buttonEvents == 0, \"long-only button handler\")\n" + "endfunction\n")); + dialog = G_JassDialogById(1); + long_button = G_JassDialogButtonById(1); + short_button = G_JassDialogButtonById(2); + T_NOT_NULL(dialog); T_NOT_NULL(long_button); T_NOT_NULL(short_button); + game.clients[0].ps.number = 0; + g_edicts[0].client = game.clients; + dialog->visible_players = 1; + n = level.events.write; + G_JassDialogClick(g_edicts, dialog->id, short_button->id); + T_EQ(level.events.write, n + 1); + G_JassDialogClick(g_edicts, dialog->id, long_button->id); /* duplicate rejected */ + T_EQ(level.events.write, n + 1); + G_RunEvents(); + jass_runevents(level.vm); + jass_callbyname(level.vm, "verify", false); + T_ASSERT(!jass_rterror_pending(level.vm)); +} + +TEST(wc3_dialog, click_visibility_uses_player_number_not_client_slot) { + jassDialog_t *dialog; + jassDialogButton_t *button; + uint32_t const player_number = 5; + setup_test_world(); + T_ASSERT(run_test_jass( + "globals\n" + " dialog route = null\n" + " button choice = null\n" + " integer selection = 0\n" + "endglobals\n" + "function onDialog takes nothing returns nothing\n" + " if GetClickedDialog() == route and GetClickedButton() == choice and GetTriggerPlayer() == Player(5) then\n" + " set selection = 1\n" + " endif\n" + "endfunction\n" + "function main takes nothing returns nothing\n" + " local trigger t = CreateTrigger()\n" + " set route = DialogCreate()\n" + " set choice = DialogAddButton(route, \"Continue\", 0)\n" + " call TriggerRegisterDialogEvent(t, route)\n" + " call TriggerAddAction(t, function onDialog)\n" + "endfunction\n" + "function verify takes nothing returns nothing\n" + " call BJassAssert(selection == 1, \"player number is preserved\")\n" + "endfunction\n")); + dialog = G_JassDialogById(1); + button = G_JassDialogButtonById(1); + T_NOT_NULL(dialog); T_NOT_NULL(button); + game.clients[0].ps.number = player_number; + g_edicts[0].client = game.clients; + dialog->visible_players = 1u << player_number; + G_JassDialogClick(g_edicts, dialog->id, button->id); + G_RunEvents(); + jass_runevents(level.vm); + jass_callbyname(level.vm, "verify", false); + T_ASSERT(!jass_rterror_pending(level.vm)); +} + +TEST(wc3_dialog, clear_hides_client_by_warcraft_player_number) { + void (*old_write)(pfWriteType_t, void const *) = gi.Write; + void (*old_unicast)(edict_t *) = gi.unicast; + jassDialog_t *dialog; + + setup_test_world(); + dialog = G_JassDialogCreate(); + T_NOT_NULL(dialog); + game.clients[0].ps.number = 5; + game.clients[5].ps.number = 0; + g_edicts[0].client = game.clients; + dialog->visible_players = 1u << 5; + dialog_unicast_target = NULL; + dialog_unicast_count = 0; + gi.Write = dialog_test_write; + gi.unicast = dialog_test_unicast; + + G_JassDialogClear(dialog); + + gi.Write = old_write; + gi.unicast = old_unicast; + T_EQ(dialog_unicast_count, 1u); + T_EQ(dialog_unicast_target, g_edicts); + T_EQ(dialog->visible_players, 0u); +} + +TEST(wc3_dialog, repeated_display_reclaims_temporary_fdf_frames) { + void (*old_write)(pfWriteType_t, void const *) = gi.Write; + void (*old_unicast)(edict_t *) = gi.unicast; + int (*old_font)(cstring_t, uint32_t) = gi.FontIndex; + int (*old_image)(cstring_t) = gi.ImageIndex; + stbIniCache_t old_theme = game.config.theme, theme = { 0 }; + cstring_t theme_text = "[Default]\nEscMenuBackground=DialogBackground.blp\nEscMenuBorder=DialogBorder.blp\n"; + jassDialog_t *dialog; + uint32_t baseline; + + setup_test_world(); + UI_ClearTemplates(); + dialog = G_JassDialogCreate(); + T_NOT_NULL(dialog); + snprintf(dialog->message, sizeof(dialog->message), "Choose a route"); + T_NOT_NULL(G_JassDialogAddButton(dialog, "Continue", NULL)); + game.clients[0].ps.number = 0; + g_edicts[0].client = game.clients; + T_ASSERT(Stb_IniCacheLoadBuffer(&theme, theme_text)); + game.config.theme = theme; + gi.Write = dialog_test_write; + gi.unicast = dialog_test_unicast; + gi.FontIndex = dialog_test_font; + gi.ImageIndex = dialog_test_image; + + UI_JassDialogShow(g_edicts, dialog); + baseline = dialog_test_frame_count(); + FOR_LOOP(i, 8) { + UI_JassDialogShow(g_edicts, dialog); + T_EQ(dialog_test_frame_count(), baseline); + } + + gi.Write = old_write; + gi.unicast = old_unicast; + gi.FontIndex = old_font; + gi.ImageIndex = old_image; + UI_ClearTemplates(); + Stb_IniCacheFree(&game.config.theme); + game.config.theme = old_theme; +} +#endif diff --git a/games/warcraft-3/game/tests/t_game.c b/games/warcraft-3/game/tests/t_game.c index 4d51fddae..4524df4e1 100644 --- a/games/warcraft-3/game/tests/t_game.c +++ b/games/warcraft-3/game/tests/t_game.c @@ -2200,6 +2200,23 @@ TEST(wc3_game, hud_simple_button_serializes_button_state) { UI_ClearTemplates(); } +TEST(wc3_game, hud_disabled_button_label_uses_authored_disabled_color) { + uint8_t typedata[128]; + char textbuf[128]; + uiFrame_t out; + frameDef_t button = { .Type = FT_GLUETEXTBUTTON, .disabled = true }; + frameDef_t label = { .Type = FT_TEXT, .Parent = &button, .Text = "Disabled choice" }; + + label.Font.Color = COLOR32_WHITE; + /* Retail disables WC3 button labels with a light gray FontDisabledColor. */ + label.Font.DisabledColor = (color32_t){ .r = 170, .g = 170, .b = 170, .a = 255 }; + T_ASSERT(UI_BuildFrameForWrite(&label, &out, typedata, sizeof(typedata), textbuf, sizeof(textbuf))); + T_EQ(out.color.r, label.Font.DisabledColor.r); + T_EQ(out.color.g, label.Font.DisabledColor.g); + T_EQ(out.color.b, label.Font.DisabledColor.b); + T_EQ(out.color.a, label.Font.DisabledColor.a); +} + static PATHSTR hud_test_images[MAX_IMAGES]; static cstring_t hud_test_get_configstring(uint32_t index) { if (index >= CS_IMAGES && index < CS_IMAGES + MAX_IMAGES) diff --git a/games/warcraft-3/jass-native-coverage.md b/games/warcraft-3/jass-native-coverage.md index 51982a951..5dc0b15c8 100644 --- a/games/warcraft-3/jass-native-coverage.md +++ b/games/warcraft-3/jass-native-coverage.md @@ -202,7 +202,7 @@ movie unlock persistence and camera-button rows remain follow-up work. See [pre- `GetGameDifficulty()` state. Campaign map startup seeds both values from `wc3_campaign_difficulty`; scripts may then change current and default difficulty independently. -Still incomplete: the generic `Dialog*` / dialog-button event natives, `DialogAddQuitButton`, actual score-screen +The generic `Dialog*` and dialog/button event natives now have bounded registries, server-authoritative click dispatch, and local modal-window presentation. `DialogAddQuitButton` creates handles but its quit policy remains incomplete; see [JASS dialogs](../../docs/games/warcraft-3/jass-dialogs.md). Still incomplete: actual score-screen presentation, Reduce Difficulty/observer-on-death result policy, and result-dialog ownership of single-player modal pausing. The existing `PauseGame` / modal path should be reused for that work rather than adding a second pause model. diff --git a/games/warcraft-3/jass/jass.h b/games/warcraft-3/jass/jass.h index a512b6b82..9e0b092b6 100644 --- a/games/warcraft-3/jass/jass.h +++ b/games/warcraft-3/jass/jass.h @@ -75,6 +75,7 @@ struct jass_context { edict_t *unit; edict_t *source; int32_t eventValue; + uint32_t dialog_id, dialog_button_id; vec2_t point; uint8_t hasPoint; player_t *playerState; diff --git a/games/warcraft-3/jass/jdo.c b/games/warcraft-3/jass/jdo.c index 778b650b3..c59b6e8f3 100644 --- a/games/warcraft-3/jass/jdo.c +++ b/games/warcraft-3/jass/jdo.c @@ -22,7 +22,7 @@ #define INF_LOOP_PROTECTION 1000000 /* SC2 Galaxy scripts have large but legitimate loops */ #define SYNTAX_C_OPERATORS 1 // bitmask; enables Galaxy symbolic logic and shift operators #define SYNTAX_INCLUDES 2 // bitmask; enables Galaxy include preprocessing -#define BZ_JASS_SNAPSHOT_VERSION 6 // format version; persists region trigger context +#define BZ_JASS_SNAPSHOT_VERSION 7 // format version; persists region trigger context #define BZ_JASS_SNAPSHOT_MAX_COUNT (1u << 20) // records; bounds allocations and list walks from corrupt snapshots #define BZ_JASS_SNAPSHOT_MAX_STRING (1u << 20) // bytes; bounds strings from corrupt snapshots @@ -31,6 +31,8 @@ typedef struct { edict_t *unit; edict_t *source; int32_t value; + uint32_t dialog_id, dialog_button_id; + player_t *event_player; vec2_t const *point; bool has_point; handle_t timer; @@ -1088,7 +1090,7 @@ void jass_runevents(jass_t *j) { * ========================================================================= */ static bool jass_evaluatetriggercontext(jass_t *j, jassTriggerContextParams_t const *params) { - player_t *player = jass_eventplayer(params->unit); + player_t *player = params->event_player ? params->event_player : jass_eventplayer(params->unit); if (params->trigger->disabled) { return false; @@ -1102,6 +1104,8 @@ static bool jass_evaluatetriggercontext(jass_t *j, jassTriggerContextParams_t co tmp_state.context.unit = params->unit; tmp_state.context.source = params->source; tmp_state.context.eventValue = params->value; + tmp_state.context.dialog_id = params->dialog_id; + tmp_state.context.dialog_button_id = params->dialog_button_id; tmp_state.context.point = params->point ? *params->point : (vec2_t){ 0.0f, 0.0f }; tmp_state.context.hasPoint = params->has_point; tmp_state.context.playerState = player; @@ -1186,13 +1190,15 @@ bool jass_evaluateplayerinteger(jass_t *j, jassFunc_t const *expr, player_t *pla static void jass_executetriggercontext(jass_t *j, jassTriggerContextParams_t const *params, bool immediate) { jasscoroutine_t *first = NULL, *last = NULL; FOR_EACH_LIST(gTriggerAction_t, action, params->trigger->actions) { - player_t *player = jass_eventplayer(params->unit); + player_t *player = params->event_player ? params->event_player : jass_eventplayer(params->unit); jasscoroutine_t *co = jass_startcoroutine(j, &MAKE(jassContext_t, .trigger = params->trigger, .func = action->func, .unit = params->unit, .source = params->source, .eventValue = params->value, + .dialog_id = params->dialog_id, + .dialog_button_id = params->dialog_button_id, .point = params->point ? *params->point : (vec2_t){ 0.0f, 0.0f }, .hasPoint = params->has_point, .playerState = player, @@ -1250,6 +1256,9 @@ bool jass_calltriggerevent(jass_t *j, trigger_t *trigger, gameEvent_t const *eve if (!event) return false; return jass_calltriggercontext(j, &(jassTriggerContextParams_t){ .trigger = trigger, .unit = event->edict, .source = event->source, .value = event->value, + .dialog_id = event->dialog_id, .dialog_button_id = event->button_id, + .event_player = event->dialog_player + ? jass_getplayerbyindex(event->dialog_player - 1) : NULL, .point = event->has_point ? &event->point : NULL, .has_point = event->has_point, .region = event->responseTo && (event->type == EVENT_GAME_ENTER_REGION || event->type == EVENT_GAME_LEAVE_REGION) ? event->responseTo->region : NULL }); @@ -2684,6 +2693,8 @@ static bool jass_snapshot_writecontext(jassSnapshot_t *snapshot, jassContext_t c }; if (!jass_snapshot_writestr(snapshot, jass_functionname(context->func)) || !jass_snapshot_io(snapshot, (void *)&context->eventValue, sizeof(context->eventValue)) || + !jass_snapshot_io(snapshot, (void *)&context->dialog_id, sizeof(context->dialog_id)) || + !jass_snapshot_io(snapshot, (void *)&context->dialog_button_id, sizeof(context->dialog_button_id)) || !jass_snapshot_io(snapshot, (void *)&context->point, sizeof(context->point)) || !jass_snapshot_io(snapshot, (void *)&context->hasPoint, sizeof(context->hasPoint)) || !jass_snapshot_io(snapshot, (void *)&context->timer_generation, sizeof(context->timer_generation)) || @@ -2708,6 +2719,8 @@ static bool jass_snapshot_readcontext(jass_t *j, jassSnapshot_t *snapshot, jassC SAFE_DELETE(func, jass_free); if (has_func && !context->func) return false; if (!jass_snapshot_io(snapshot, &context->eventValue, sizeof(context->eventValue)) || + !jass_snapshot_io(snapshot, &context->dialog_id, sizeof(context->dialog_id)) || + !jass_snapshot_io(snapshot, &context->dialog_button_id, sizeof(context->dialog_button_id)) || !jass_snapshot_io(snapshot, &context->point, sizeof(context->point)) || !jass_snapshot_io(snapshot, &context->hasPoint, sizeof(context->hasPoint)) || !jass_snapshot_io(snapshot, &context->timer_generation, sizeof(context->timer_generation)) || diff --git a/games/warcraft-3/menu/controls/menu_control_backdrop.h b/games/warcraft-3/menu/controls/menu_control_backdrop.h index ba65cc99e..b5eb5d849 100644 --- a/games/warcraft-3/menu/controls/menu_control_backdrop.h +++ b/games/warcraft-3/menu/controls/menu_control_backdrop.h @@ -36,7 +36,8 @@ static void UI_DrawBackdropWithColor(frameDef_t const *frame, rect_t const *rect .insets.bottom = frame->Backdrop.BackgroundInsets[BACKDROPINSET_BOTTOM], .insets.left = frame->Backdrop.BackgroundInsets[BACKDROPINSET_LEFT], .flags = (frame->Backdrop.TileBackground ? DRAW_TILE : 0) - | (frame->Backdrop.Mirrored ? DRAW_MIRRORED : 0))); + | (frame->Backdrop.Mirrored ? DRAW_MIRRORED : 0) + | (frame->Backdrop.BlendAll ? DRAW_BLEND_ALL : 0))); } static void UI_DrawBackdrop(frameDef_t const *frame, rect_t const *rect) { diff --git a/games/warcraft-3/tests/test_menu_fdf.c b/games/warcraft-3/tests/test_menu_fdf.c index e9edc6751..babbb80f3 100644 --- a/games/warcraft-3/tests/test_menu_fdf.c +++ b/games/warcraft-3/tests/test_menu_fdf.c @@ -476,6 +476,27 @@ TEST(menu_fdf, compact_pool_preserves_capacity_and_reports_exhaustion) { T_ASSERT(sizeof(framePoint_t) <= sizeof(void *) + 8); } +TEST(menu_fdf, failed_tree_clone_reclaims_partial_pool_allocations) { + frameDef_t *root, *child, *last = NULL, *frame; + uint32_t before = 0, after = 0; + + reset_ui_state(); + root = UI_Spawn(FT_FRAME, NULL); + child = UI_Spawn(FT_TEXT, root); + T_NOT_NULL(root); T_NOT_NULL(child); + while ((frame = UI_Spawn(FT_FRAME, NULL))) last = frame; + T_NOT_NULL(last); + UI_FreeFrameTree(last); + FOR_LOOP(i, MAX_UI_CLASSES) if (frames[i].inuse) ++before; + + T_NULL(UI_CloneFrameTree(root, NULL)); + FOR_LOOP(i, MAX_UI_CLASSES) if (frames[i].inuse) ++after; + T_EQ(after, before); + T_NOT_NULL(UI_Spawn(FT_FRAME, NULL)); + T_NULL(UI_Spawn(FT_FRAME, NULL)); + reset_ui_state(); +} + TEST(menu_fdf, parse_nested_parent_child_relationship) { frameDef_t *root; frameDef_t *child; @@ -2689,7 +2710,7 @@ static void sp_row_icon_geometry(cstring_t box) { T_FEQ(captured_backdrop.screen.w, .032f, .00001f); T_FEQ(captured_backdrop.screen.h, .032f, .00001f); T_FEQ(captured_backdrop.insets.left, j == 2 ? .004f : .007f, .00001f); - T_EQ(captured_backdrop.flags, DRAW_TILE); + T_EQ(captured_backdrop.flags, DRAW_TILE | DRAW_BLEND_ALL); } button->hidden = hidden; button->ui_flags = flags; diff --git a/renderer/r_backdrop.c b/renderer/r_backdrop.c index 196c2f320..f20c591bb 100644 --- a/renderer/r_backdrop.c +++ b/renderer/r_backdrop.c @@ -117,9 +117,19 @@ void R_DrawBackdrop(drawBackdrop_t const *db) { R_AddQuad(vertices + num_vertices, &background, &bg_uv, db->bg.color, 0); num_vertices += 6; - R_DrawImageBatch(db->bg.texture, SHADER_UI, BLEND_MODE_BLEND, - 0, 0, false, NULL, vertices, num_vertices, - (db->flags & DRAW_TILE) && (fabsf(bg_uv.w) > 1 || bg_uv.h > 1)); + drawImageBatchParams_t const image = { + .texture = db->bg.texture, + .shader = SHADER_UI, + .alphamode = BLEND_MODE_BLEND, + .vertices = vertices, + .vertexCount = num_vertices, + .repeat = (db->flags & DRAW_TILE) && (fabsf(bg_uv.w) > 1 || bg_uv.h > 1), + }; + if (db->flags & DRAW_BLEND_ALL) + R_DrawImageBatch(db->bg.texture, SHADER_UI, BLEND_MODE_BLEND, + 0, 0, false, NULL, vertices, num_vertices, image.repeat); + else + R_DrawImageBatchOpaque(&image); } /* --- edge/corner quads (batched into one drawcall) --- */ diff --git a/renderer/r_draw.c b/renderer/r_draw.c index fc8487235..8ace3b761 100644 --- a/renderer/r_draw.c +++ b/renderer/r_draw.c @@ -125,22 +125,13 @@ static void R_ResetUIScissor(void) { R_Call(glScissor, 0, 0, tr.drawableSize.width, tr.drawableSize.height); } -void R_DrawImageBatch(texture_t const *texture, - SHADERTYPE shaderType, - BLEND_MODE alphamode, - float uActiveGlow, - float uRadialShade, - bool hasClip, - rect_t const *clip, - vertex_t const *vertices, - uint32_t num_vertices, - bool repeat) +static void R_DrawImageBatchInternal(drawImageBatchParams_t const *params, bool opaque) { - if (!vertices || !num_vertices) { + if (!params || !params->vertices || !params->vertexCount) { return; } - spriteProg_t *shader = R_SpriteShader(shaderType); + spriteProg_t *shader = R_SpriteShader(params->shader); mat4_t ui_matrix, model_matrix; rect_t const scene = R_UISceneRect(); @@ -151,21 +142,25 @@ void R_DrawImageBatch(texture_t const *texture, shader->state.viewProjection = ui_matrix; shader->state.model = model_matrix; - shader->state.activeGlow = uActiveGlow; - shader->state.radialShade = uRadialShade; + shader->state.activeGlow = params->activeGlow; + shader->state.radialShade = params->radialShade; R_Call(glBindVertexArray, tr.buffer[RBUF_TEMP1]->vao); R_Call(glBindBuffer, GL_ARRAY_BUFFER, tr.buffer[RBUF_TEMP1]->vbo); - R_Call(glBufferData, GL_ARRAY_BUFFER, sizeof(vertex_t) * num_vertices, vertices, GL_DYNAMIC_DRAW); + R_Call(glBufferData, GL_ARRAY_BUFFER, sizeof(vertex_t) * params->vertexCount, params->vertices, GL_DYNAMIC_DRAW); R_Call(glDisable, GL_DEPTH_TEST); R_Call(glDepthMask, GL_FALSE); - R_Call(glEnable, GL_BLEND); - - R_SetBlending(alphamode); - R_BindTexture(texture, 0); + if (opaque) { + R_Call(glDisable, GL_BLEND); + } else { + R_Call(glEnable, GL_BLEND); + /* BLEND_MODE_NONE historically still uses source alpha for UI textures. */ + R_SetBlending(params->alphamode); + } + R_BindTexture(params->texture, 0); // R_Call(glTexParameteri, GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); // R_Call(glTexParameteri, GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); - if (repeat) { + if (params->repeat) { R_Call(glTexParameteri, GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); R_Call(glTexParameteri, GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); } else { @@ -175,20 +170,51 @@ void R_DrawImageBatch(texture_t const *texture, R_Call(glTexParameteri, GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); R_Call(glTexParameteri, GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); R_Call(glDisable, GL_CULL_FACE); - R_Call(glEnable, GL_BLEND); - if (hasClip) { - R_SetUIClipScissor(clip); + if (params->hasClip) { + R_SetUIClipScissor(params->clip); } - R_StatsDraw(GL_TRIANGLES, num_vertices, 1); + R_StatsDraw(GL_TRIANGLES, params->vertexCount, 1); R_ApplyShader(shader); - R_Call(glDrawArrays, GL_TRIANGLES, 0, num_vertices); - if (hasClip) { + R_Call(glDrawArrays, GL_TRIANGLES, 0, params->vertexCount); + if (params->hasClip) { R_ResetUIScissor(); } + if (opaque) { + R_Call(glEnable, GL_BLEND); + } R_Call(glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); } +void R_DrawImageBatch(texture_t const *texture, + SHADERTYPE shaderType, + BLEND_MODE alphamode, + float uActiveGlow, + float uRadialShade, + bool hasClip, + rect_t const *clip, + vertex_t const *vertices, + uint32_t num_vertices, + bool repeat) +{ + R_DrawImageBatchInternal(&(drawImageBatchParams_t){ + .texture = texture, + .shader = shaderType, + .alphamode = alphamode, + .activeGlow = uActiveGlow, + .radialShade = uRadialShade, + .hasClip = hasClip, + .clip = clip, + .vertices = vertices, + .vertexCount = num_vertices, + .repeat = repeat, + }, false); +} + +void R_DrawImageBatchOpaque(drawImageBatchParams_t const *params) { + R_DrawImageBatchInternal(params, true); +} + void R_DrawImageEx(drawImage_t const *drawImage) { vertex_t simp[6]; R_AddQuad(simp, &drawImage->screen, &drawImage->uv, drawImage->color, 0); diff --git a/renderer/r_local.h b/renderer/r_local.h index bec116d3d..7c26f85b9 100644 --- a/renderer/r_local.h +++ b/renderer/r_local.h @@ -458,7 +458,19 @@ void R_DrawCharScaled(float x, float y, int c, float scale); void R_DrawFill(rect_t const *rect, color32_t color); void R_DrawImage(texture_t const *texture, rect_t const *screen, rect_t const *uv, color32_t color); void R_DrawImageEx(drawImage_t const *drawImage); +typedef struct { + texture_t const *texture; + SHADERTYPE shader; + BLEND_MODE alphamode; + float activeGlow, radialShade; + bool hasClip; + rect_t const *clip; + vertex_t const *vertices; + uint32_t vertexCount; + bool repeat; +} drawImageBatchParams_t; void R_DrawImageBatch(texture_t const *texture, SHADERTYPE shaderType, BLEND_MODE alphamode, float uActiveGlow, float uRadialShade, bool hasClip, rect_t const *clip, vertex_t const *vertices, uint32_t num_vertices, bool repeat); +void R_DrawImageBatchOpaque(drawImageBatchParams_t const *params); void R_DrawMinimapScene(rect_t const *screen, cstring_t map); bool R_TraceMinimap(float x, float y, vec2_t *outWorld); bool R_WorldToMinimap(vec2_t const *world, vec2_t *outScreen); diff --git a/tests/test_renderer_model.c b/tests/test_renderer_model.c index 76ab2513b..5b4349a76 100644 --- a/tests/test_renderer_model.c +++ b/tests/test_renderer_model.c @@ -24,7 +24,7 @@ void MDLX_ReleaseSprites(mdxModel_t *model) { (void)model; } static char shader_src[16384]; static rect_t backdrop_uv, backdrop_rect; static bool backdrop_repeat; -static uint32_t backdrop_calls; +static uint32_t backdrop_calls, backdrop_opaque_calls; static size2_t backdrop_size = {256, 64}; /* Capture the real shader source submission without requiring a window in the unit suite. */ @@ -2883,13 +2883,30 @@ static void test_backdrop_batch(texture_t const *tex, SHADERTYPE shader, BLEND_M (void)tex; (void)shader; (void)blend; (void)glow; (void)radialShade; (void)hasclip; (void)clip; (void)verts; T_EQ(count, 6); backdrop_repeat = repeat; backdrop_calls++; } +static void test_backdrop_opaque(drawImageBatchParams_t const *params) { + T_NOT_NULL(params); T_EQ(params->vertexCount, 6); + backdrop_repeat = params->repeat; backdrop_opaque_calls++; +} #define R_GetTextureSize test_backdrop_size #define R_AddQuad test_backdrop_quad #define R_DrawImageBatch test_backdrop_batch +#define R_DrawImageBatchOpaque test_backdrop_opaque #include "renderer/r_backdrop.c" #undef R_GetTextureSize #undef R_AddQuad #undef R_DrawImageBatch +#undef R_DrawImageBatchOpaque + +TEST(renderer_backdrop, alpha_blend_flag_only_selects_opaque_background_path) { + drawBackdrop_t draw = { .screen = {0, 0, .2f, .1f}, .bg.texture = (texture_t const *)1 }; + backdrop_calls = backdrop_opaque_calls = 0; + + R_DrawBackdrop(&draw); + T_EQ(backdrop_calls, 0); T_EQ(backdrop_opaque_calls, 1); + draw.flags = DRAW_BLEND_ALL; + R_DrawBackdrop(&draw); + T_EQ(backdrop_calls, 1); T_EQ(backdrop_opaque_calls, 1); +} TEST(renderer_backdrop, mirrored_background_is_independent_of_tiling) { drawBackdrop_t draw = { .screen = {0, 0, .512f, .032f}, .bg.texture = (texture_t const *)1 };