diff --git a/.claude/skills/add-slot-menu-item/SKILL.md b/.claude/skills/add-slot-menu-item/SKILL.md new file mode 100644 index 00000000000..7b3ef937424 --- /dev/null +++ b/.claude/skills/add-slot-menu-item/SKILL.md @@ -0,0 +1,41 @@ +--- +name: add-slot-menu-item +description: Add an entry to the CustomLobby slot right-click context menu (e.g. Kick, Move to observers, Close slot). Use when the user asks to add, gate, or scaffold a slot context-menu action in the custom lobby. Edits the declarative list in lua/ui/lobby/customlobby/CustomLobbyMenus.lua and, for a new operation, adds a CustomLobbyController intent. +--- + +# Adding a slot context-menu item + +One edit in most cases: append an entry to the `SlotMenu` list in [CustomLobbyMenus.lua](/lua/ui/lobby/customlobby/CustomLobbyMenus.lua). + +```lua +{ + label = "Close slot", -- example of a NEW action (RequestCloseSlot doesn't exist yet — add it) + when = function(ctx) return ctx.isHost and ctx.isOpen end, + action = function(ctx) CustomLobbyController.RequestCloseSlot(ctx.slot) end, + -- enabled = function(ctx) ... end, -- optional; omit for always-enabled. false = greyed, not hidden +}, +``` + +(Already wired, for reference: `Move to observers` → `RequestMoveToObserver`, `Eject` → `RequestEject`, ready toggles, `Take this slot` → `RequestTakeSlot`.) + +`ctx` (see `SlotContext`): `slot`, `player|false`, `isHost`, `isYou`, `isOpen`. + +## Do / Don't + +| | Rule | +|---|------| +| ✅ | Gate visibility with `when(ctx)` — that's how host vs player / open vs occupied menus differ. Use `enabled` only when you want the item shown-but-greyed. | +| ✅ | Have `action(ctx)` call a **`CustomLobbyController` intent** (`RequestX`). Keep it one line. | +| ✅ | If the action is a new operation, add the intent to [CustomLobbyController.lua](/lua/ui/lobby/customlobby/CustomLobbyController.lua) first: host applies + `BroadcastPlayers`; a client `SendData`s a request the host validates (add a typed message in [CustomLobbyMessages.lua](/lua/ui/lobby/customlobby/CustomLobbyMessages.lua) + a `ProcessX` handler). The host stays authoritative. | +| 🚫 | Write to the model from `action` — read it, mutate via a controller intent. | +| 🚫 | Touch [CustomLobbyContextMenu.lua](/lua/ui/lobby/customlobby/CustomLobbyContextMenu.lua) — it's a generic renderer and knows nothing about the lobby. | +| 🚫 | Build the menu in the slot row — the row only calls `BuildSlotMenu(slot)`; definitions live in `CustomLobbyMenus`. | +| 🚫 | Use the `%` operator (FAF is Lua 5.0 — use `math.mod`). | + +## A whole new menu surface (not just a slot item) + +The renderer [CustomLobbyContextMenu.lua](/lua/ui/lobby/customlobby/CustomLobbyContextMenu.lua) is generic — slots are just its first caller. To add another surface (lobby background, observer list, options…): add a new entry list + `BuildXMenu(...)` in `CustomLobbyMenus.lua` (its own `ctx` shape), then have that control's right-click call `CustomLobbyContextMenu.Show(CustomLobbyMenus.BuildXMenu(...), event.MouseX, event.MouseY)`. Don't edit the renderer. + +## Verify + +An empty result simply doesn't show. Right-click a slot in each relevant state (your own / another's / open; as host and as client) and confirm the item appears only `when` it should and its intent round-trips. UI-only check: `import("/lua/ui/lobby/customlobby/customlobbyinterface.lua").OpenDebug()`. diff --git a/.claude/skills/customlobby-model-choice/SKILL.md b/.claude/skills/customlobby-model-choice/SKILL.md new file mode 100644 index 00000000000..9ddcf2ce585 --- /dev/null +++ b/.claude/skills/customlobby-model-choice/SKILL.md @@ -0,0 +1,59 @@ +--- +name: customlobby-model-choice +description: Decide which of the three CustomLobby models new state belongs in — Launch (shared + launched), Session (shared, lobby-room only), or Local (per-peer, never synced) — and keep hot-reload working. Use when adding a field/state to the custom lobby, or unsure where lobby state should live. +--- + +# Which CustomLobby model does this state go in? + +Three reactive singletons, chosen by two questions: **is it shared (host-dictated → broadcast to everyone)?** and **does it get launched (becomes part of the game)?** + +| Put it in… | Shared? | Launched? | Examples | +|---|---|---|---| +| **[CustomLobbyLaunchModel.lua](/lua/ui/lobby/customlobby/CustomLobbyLaunchModel.lua)** | ✅ | ✅ | players (per-slot), observers, scenario, game options, mods, auto-teams, spawn-mex. | +| **[CustomLobbySessionModel.lua](/lua/ui/lobby/customlobby/CustomLobbySessionModel.lua)** | ✅ | ❌ | slot count, closed slots (later: lobby title, password, kick log). | +| **[CustomLobbyLocalModel.lua](/lua/ui/lobby/customlobby/CustomLobbyLocalModel.lua)** | ❌ | — | identity (`LocalPeerId` / `HostID` / `IsHost`), CPU benchmarks (later: ping). | + +**Decide with two litmus questions:** +1. *Does the launched game consume this?* → **Launch**. (A closed slot is just empty at launch and slot count is map-derived presentation — neither reaches the scenario, so they're **Session**, not Launch.) +2. *Is it this client's own state?* → **Local**. Identity is per-peer (the host's `IsHost` is true, a client's is false); broadcasting it would corrupt the receiver — it never goes on the wire. + +Everything else host-dictated that isn't launched is **Session**. + +**Not all state is a model.** *Reference data* — identical on every peer and derived from disk +or computed from synced inputs — belongs in **neither**. It's a cached module, not a LazyVar +singleton on the wire. Examples: the **map catalog** ([CustomLobbyMapCatalog.lua](/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua), +enumerated scenarios), and the upcoming **option schema** (derived from `ScenarioFile` + `GameMods`). +Only the host's *choice* (the `ScenarioFile` / `GameMods` / `GameOptions` values in Launch) syncs; +each peer recomputes the catalog/schema locally. Filter/search state in a picker is per-peer UI +preference (component-local + Prefs), not a model either. + +## Sync mapping (host → clients) + +| Model | Message(s) | Broadcast by | +|---|---|---| +| Launch | `SetPlayers` (players + observers) and `SentLaunchInfo` (scenario / options / mods / teams / spawn) | `BroadcastPlayers` / `BroadcastLaunchInfo` | +| Session | `SetSessionState` | `BroadcastSessionState` | +| Local | — (never synced; set on the connection handshake) | — | + +The host pushes **whole snapshots** on join and on change. Adding a synced field means adding it to the relevant broadcast *and* its `Process*` handler in the controller. + +## Adding a field — also touch OnReload + +Each model hand-copies its LazyVars in `__moduleinfo.OnReload` so a hot-reload doesn't reset live state. When you add a field: + +1. add it to that model's `SetupSingleton` (a `Create(...)` with its default), and +2. **add a matching copy line in that model's `OnReload`** — miss this and the field silently resets on every save-reload (exactly when you're iterating). + +## Gotchas — do / don't + +| | Rule | +|---|------| +| ✅ | Route every write through the owning model's helpers (`SetPlayer`, `SetGameOption`, `AddObserver`, `SetClosed`, `SetCpuBenchmark`, …) — they keep the copy-then-`Set` discipline. | +| ✅ | Add the matching `OnReload` copy line whenever you add a field (see above). | +| ✅ | Write the shared (Launch / Session) models **only from the controller**, after the host validates; clients send a request message. | +| 🚫 | Put ping / CPU / connection churn in **Launch** or **Session** — its dirtying would ride along with a synced snapshot. It's **Local**. | +| 🚫 | Put launch-affecting state in **Session** (it won't reach the game) or **Local** (it won't be shared). If the game needs it and all peers must agree → **Launch**. | +| 🚫 | Broadcast anything from **Local** — identity/connectivity is per-peer; sending it corrupts the receiver. | +| 🚫 | Mutate a LazyVar's held table in place — always `table.copy` → mutate → `:Set` (a new table), or dependents never go dirty. | +| 🚫 | Write any model from a view/component — views read via `Derive` only; the controller is the sole writer. | +| 🚫 | Use the `%` operator (FAF is Lua 5.0 — use `math.mod`). | diff --git a/init_faf.lua b/init_faf.lua index 8ad09ae6d8c..d82067fe191 100644 --- a/init_faf.lua +++ b/init_faf.lua @@ -656,4 +656,7 @@ MountDirectory(SHGetFolderPath('LOCAL_APPDATA') .. 'Gas Powered Games/Supreme Co MountDirectory(fa_path .. "/movies", '/movies') MountDirectory(fa_path .. "/sounds", '/sounds') MountDirectory(fa_path .. "/maps", '/maps') -MountDirectory(fa_path .. "/fonts", '/fonts') \ No newline at end of file +MountDirectory(fa_path .. "/fonts", '/fonts') + +-- support for custom backgrounds in the lobby +MountDirectory(InitFileDir .. '/../gamedata/custom-lobby-backgrounds', '/textures/ui/common/lobby/custom-backgrounds') diff --git a/init_fafbeta.lua b/init_fafbeta.lua index 26d83cce288..d2d6195fa94 100644 --- a/init_fafbeta.lua +++ b/init_fafbeta.lua @@ -642,5 +642,8 @@ MountDirectory(fa_path .. "/sounds", '/sounds') MountDirectory(fa_path .. "/maps", '/maps') MountDirectory(fa_path .. "/fonts", '/fonts') +-- support for custom backgrounds in the lobby +MountDirectory(InitFileDir .. '/../gamedata/custom-lobby-backgrounds', '/textures/ui/common/lobby/custom-backgrounds') + -- Allows developers to embed code to debug a replay table.insert(path, 1, { dir = InitFileDir .. '\\..\\Debug', mountpoint = '/' }) diff --git a/init_fafdevelop.lua b/init_fafdevelop.lua index 3d4569c530f..37ba1b0d053 100644 --- a/init_fafdevelop.lua +++ b/init_fafdevelop.lua @@ -642,5 +642,8 @@ MountDirectory(fa_path .. "/sounds", '/sounds') MountDirectory(fa_path .. "/maps", '/maps') MountDirectory(fa_path .. "/fonts", '/fonts') +-- support for custom backgrounds in the lobby +MountDirectory(InitFileDir .. '/../gamedata/custom-lobby-backgrounds', '/textures/ui/common/lobby/custom-backgrounds') + -- Allows developers to embed code to debug a replay table.insert(path, 1, { dir = InitFileDir .. '\\..\\Debug', mountpoint = '/' }) diff --git a/lua/lazyvar.lua b/lua/lazyvar.lua index ca7e1fefab3..dae7a9963a7 100644 --- a/lua/lazyvar.lua +++ b/lua/lazyvar.lua @@ -9,7 +9,7 @@ local setmetatable = setmetatable -- Set this true to get tracebacks in error messages. It slows down lazyvars a lot, -- so don't use except when debugging. -local ExtendedErrorMessages = false +local ExtendedErrorMessages = true local EvalContext = nil local WeakKeyMeta = { __mode = 'k' } diff --git a/lua/ui/CLAUDE.md b/lua/ui/CLAUDE.md index 4abd43add8a..8505b57360a 100644 --- a/lua/ui/CLAUDE.md +++ b/lua/ui/CLAUDE.md @@ -184,6 +184,32 @@ end, `TrashBag` is a weak table for values, so an item already destroyed elsewhere drops out automatically — `Destroy()` should always be idempotent. See [trashbag.lua:30-50](../system/trashbag.lua#L1-L50) for the contract. +### ⚠️ A `Trash:Add(Derive(...))` with no strong reference gets garbage-collected + +This is the load-bearing reason the canonical pattern assigns to `self.Observer` **and** passes through `Trash:Add` — the two references do different jobs: + +- `self.Observer = …` is the **strong** reference that keeps the derived LazyVar alive. +- `self.Trash:Add(…)` registers it for **teardown**, nothing more. + +`Trash:Add` does **not** keep its contents alive: `TrashBag` is `__mode = 'v'` (weak *values*, [trashbag.lua:40](../system/trashbag.lua#L40)), and a LazyVar's `used_by` (the subscriber list a source walks on `:Set`) is `__mode = 'k'` (weak *keys*, [lazyvar.lua:306](../lazyvar.lua#L306)). So a derived observer reachable **only** through those two tables has no strong referrer and is eligible for collection. + +```lua +-- ❌ BUG: nothing holds the derived LazyVar strongly. It fires once on creation, +-- then a GC pass collects it and it silently stops reacting to source changes. +self.Trash:Add(LazyVarDerive(model.IsHost, function(isHostLazy) + self:OnHostChanged(isHostLazy()) +end)) + +-- ✅ CORRECT: the self field is the strong ref; Trash:Add still handles teardown. +self.IsHostObserver = self.Trash:Add(LazyVarDerive(model.IsHost, function(isHostLazy) + self:OnHostChanged(isHostLazy()) +end)) +``` + +The failure mode is nasty because it's **timing-dependent and silent**: the observer's first synchronous fire works (before any GC), so initial render looks correct; only a *later* `source:Set` — after a GC cycle — fails to propagate, with no error. (Real example: a tab's host-only gear that stayed hidden because its visibility observer was collected before `IsHost` flipped to `true` at hosting time. The source's own `OnDirty` still fired — the source was strongly held by its model singleton — but the orphaned derived observer was already gone.) + +If you build observers in a loop (per row / per tab) and have no natural `self.X` name for each, keep them in a strong list field — `self.Observers = self.Observers or {}; table.insert(self.Observers, self.Trash:Add(Derive(...)))` — so the list holds them and the `Trash` still tears them down. + --- ## 4. Layout — Fluent Layouter @@ -224,6 +250,46 @@ self.SomeLine.Top:Set(scaled(20)) If you find yourself reaching for `ScaleNumber` a lot, that's usually a sign you should be using `Layouter` instead. +### Standard asset sizes — look them up, don't guess + +A `Button` (and most textured controls) sizes to its art by default, so the texture's width *is* the +control width unless you override it. Don't eyeball these. Most textures' native (unscaled) sizes are +dumped in [`/textures/texture-dimensions.csv`](/textures/texture-dimensions.csv) +(`RelativePath, Width, Height, Format`) — grep it for the resolved path. **The `/BUTTON/` tree is not +in that CSV**; read those from the DDS header instead (`od -An -t u4 -j 12 -N 8 ` → `height width`). +`SkinnableFile` resolves `/BUTTON/`, `/scx_menu/`, `/dialogs/` to the active **faction** skin (`common` +is only the fallback, and loads differently); `CreateButtonStd`/`CreateButtonWithDropshadow` append +`_btn_{up,…}.dds`. Verified standard buttons (unscaled W × H): `/BUTTON/large/` **296 × 80**, +`/BUTTON/medium/` **132 × 44** (these two are the lobby's Launch/Leave buttons — **there is no +`/BUTTON/small/`**, referencing it makes the button invisible), `/scx_menu/small-btn/small` **200 × 72** +(the map/mod dialogs' button), `/dialogs/close_btn/close` and gear menu-btns **24 × 24**. Don't confuse +`/BUTTON//` with the separate `widgets/_btn` family in the CSV (392/276/152 wide). Pre-scale — +multiply by the UI scale. + +### Scrollbars — reserve the gutter on the content, not the bar + +`UIUtil.CreateVertScrollbarFor(content, offset_right)` sets `bar.Left = content.Right + offset_right` (`AnchorToRight`). The convention here: **inset the content's right edge by the gutter and attach the bar at offset 0** — the bar then sits in that reserved strip. + +```lua +Layouter(self.Content):AtLeftIn(self, 6):AtRightIn(self, 32):End() -- 32px gutter on the right +self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Content) -- offset 0 → bar sits in it +``` + +The standard gutter is **32px** (`/lua/ui/lobby/customlobby` is aligned to this). For a fixed-width grid the same reservation goes into the width math (`width = panelW - leftInset - 32`); right-aligned cell content (a value dropdown) then clears the bar automatically. + +- **Don't double-reserve.** Use *either* a content inset *or* a negative `offset_right` (e.g. `-32` on a full-width control), never both — combined they push the bar over the content. +- **Pooled/virtualised lists** that attach the bar to themselves (`CreateVertScrollbarFor(self)`, rows full-width) put the bar just *outside* the list's right edge instead — a deliberate different idiom; a negative `offset_right` is the only way to inset those. +- **TextArea**: bind `Width` in `Initialize` (post-mount), not `__post_init` — see the TextArea gotcha in the lobby CLAUDE.md. + +**The wheel only scrolls over the bar by default.** The engine routes `WheelRotation` to the scrollbar itself, so spinning the wheel over the *content* does nothing unless the content forwards it. Use `UIUtil.ForwardWheelToScroll(content, scrollable)` (chains any existing `HandleEvent`) right after creating the bar: + +```lua +self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Grid) +UIUtil.ForwardWheelToScroll(self.Grid, self.Grid) -- wheel over the rows now scrolls too +``` + +It forwards with the explicit `"Vert"` axis: a native `Grid` indexes its scroll state by axis (a `nil` axis errors in [grid.lua](../maui/grid.lua) `ScrollLines`); single-axis scrollables (`ItemList`/`TextArea`, custom lists) ignore the argument. Wheel events bubble from rows/cells to the parent `Grid`/`ItemList`, so attaching to the scrollable itself is enough — and a child that consumes the wheel (e.g. a `Combo` adjusting its value) still wins, which is what you want. + ### Reusing layout files `LayoutHelpers.*RelativeTo` reads positions from a layout `.lua` table — used by older skinned screens. New code generally prefers fluent anchors against siblings; reach for layout files only when matching an existing skinned design. diff --git a/lua/ui/lobby/FEATURES.md b/lua/ui/lobby/FEATURES.md new file mode 100644 index 00000000000..88d21160a77 --- /dev/null +++ b/lua/ui/lobby/FEATURES.md @@ -0,0 +1,328 @@ +# Custom-Game Lobby — Feature Inventory (parity yardstick) + +This catalogs **everything the current custom-game lobby does**, so a ground-up +MVC rebuild has a measurable parity target. It is a behaviour spec, not a design. + +> Line references are **approximate** (gathered by code exploration; the files +> drift). Treat them as "look near here", and re-confirm against current source. +> Primary sources: [`/lua/ui/lobby/lobby.lua`](/lua/ui/lobby/lobby.lua) (~7400 lines), +> [`/lua/ui/lobby/lobbyComm.lua`](/lua/ui/lobby/lobbyComm.lua), +> [`/lua/ui/lobby/lobbyOptions.lua`](/lua/ui/lobby/lobbyOptions.lua), +> [`/lua/ui/lobby/ModsManager.lua`](/lua/ui/lobby/ModsManager.lua), +> [`/lua/ui/lobby/UnitsManager.lua`](/lua/ui/lobby/UnitsManager.lua), +> [`/lua/ui/lobby/presets.lua`](/lua/ui/lobby/presets.lua), +> [`/lua/ui/lobby/gameselect.lua`](/lua/ui/lobby/gameselect.lua), +> [`/lua/ui/dialogs/mapselect.lua`](/lua/ui/dialogs/mapselect.lua), +> [`/lua/ui/lobby/data/gamedata.lua`](/lua/ui/lobby/data/gamedata.lua), +> [`/lua/ui/lobby/data/playerdata.lua`](/lua/ui/lobby/data/playerdata.lua). + +## What we're replacing (scale) + +- `lobby.lua` alone is ~7,400 lines; the lobby ecosystem ~10,700 lines. +- Central state `gameInfo` is a hybrid: plain tables (`GameOptions`, `ClosedSlots`, + `SpawnMex`, `GameMods`, `AutoTeams`) + reactive `WatchedValueArray` for + `PlayerOptions` / `Observers`. **The reactivity primitive already exists** and + there's a standing TODO to replace `SetSlotInfo` sledgehammers with lazy callbacks. +- Networking: **host-authority**. Non-hosts send requests; host validates and + broadcasts authoritative state. ~30 message types. Full `GameInfo` dump on join. +- UI is driven by ~10 "refresh-everything" functions (`SetSlotInfo`, + `RefreshOptionDisplayData`, `refreshObserverList`, …) called after each change. + +--- + +## A. Lifecycle & entry + +| Feature | Who | Trigger / function | Notes | +|---|---|---|---| +| Game discovery list | all | `gameselect.lua` LAN/IP discovery | shows map, name, players | +| Create game | host | `gameselect.lua` → `gamecreate` | nickname validation 2–32 chars | +| Direct IP connect | all | IP+port input, stored in prefs | | +| Host a lobby | host | `HostGame` (lobby.lua ~780); `lobbyComm.Hosting` (~5706) | loads last scenario/options from prefs, host into slot 1, keep-alive thread (30s) | +| Join a lobby | client | `JoinGame` (~788); `ConnectionToHostEstablished` (~5596) | sends `SetAvailableMods` + `AddPlayer`; host replies with full `GameInfo` | +| Leave / abort | all | `ReturnToMenu` (~1878), Escape handler (~669) | confirm dialog; destroys `lobbyComm`/`GUI`; quit-to-desktop if `/gpgnet` | +| Rehost | host | `/rehost` flag (~179, ~5812) | restores last-game preset, reassigns players/AIs to prior slots | +| Launch transition | host | `GameLaunched` (~5677) | saves loading faction, kills threads, destroys UI + comm | + +--- + +## B. Slots & players + +**Slot states & context menu** (`GetSlotMenuData` ~246, `GetSlotMenuTables` ~341, `DoSlotBehavior` ~548): + +| Slot state | Host menu entries | Client menu entries | +|---|---|---| +| Open | Close, Close-spawn-mex (adaptive), Occupy | Occupy | +| Closed | Open, Close-spawn-mex (adaptive) | — | +| Human-occupied | Whisper, Move to Observer, Kick, Move to slot N | Whisper | +| AI-occupied | Remove (Kick), AI personality list | — | + +**Actions:** + +| Feature | Who | Function | Message | State | +|---|---|---|---|---| +| Open/close slot | host | `HostUtils.SetSlotClosed` (~6839) | `SlotClosed` | `ClosedSlots[slot]` | +| Close + spawn-mex (adaptive) | host | `SetSlotClosedSpawnMex` (~6859) | `SlotClosedSpawnMex` | `ClosedSlots`+`SpawnMex` | +| Occupy empty slot | player | host: `MovePlayerToEmptySlot` (~7039); client → host | `MovePlayer`→`SlotMove` | move player, force unready | +| Move player to slot | host | `SwapPlayers` (~7078) | `SlotMove`/`SwapPlayers` | relocate, unready | +| Swap two players | host | `SwapPlayers`/`DoSlotSwap` (~6762) | `SwapPlayers` | swap incl. team, unready both | +| Kick player | host | `EjectPeer` + confirm dialog (~590) | engine eject + `SystemMessage` | slot cleared; kick msg saved to prefs | + +**Slot row display** (`CreateSlotsUI` ~2718, `SetSlotInfo` ~1032, `ClearSlotInfo` ~1245): +slot #, country flag, rating (R: mean±3·dev tooltip), games (G:), name (context-menu +combo, colour-coded host/player/you/AI), colour combo, faction combo, team combo, +CPU bar, ping bar, ready checkbox. `SetSlotInfo` recomputes the whole row on any change. + +--- + +## C. Per-player settings + +| Feature | Who | Function | Message | Notes | +|---|---|---|---|---| +| Faction | player (own) / host (AI) | faction combo + large selector `CreateUI_Faction_Selector` (~6375) | `PlayerOptions{Faction}` | per-map availability; random=5 resolved at launch; sets skin; saved to prefs | +| Colour | player (own) / host | colour combo; `Check_Availaible_Color` (~6706), `IsColorFree` (~1304) | client `RequestColor` → host `SetColor` | **scarcity**: taken colours hidden; host validates/reverts | +| Team | player (own) / host | team combo | `PlayerOptions{Team}` | disabled when AutoTeams active or ready; backend team = UI team+1 | +| Ready | player (own) | ready checkbox (~2991) | `PlayerOptions{Ready}` | disables own controls; gates launch; host can force-unready (`SetPlayerNotReady`) | +| Start position | player/host | map-preview click/drag `ConfigureMapListeners` (~4931) | `MovePlayer`/`SlotMove`/`AutoTeams` | fixed-spawn: move self; host swap mode; manual AutoTeams via map clicks | + +--- + +## D. AI management (host-only) + +| Feature | Function | Message | Notes | +|---|---|---|---| +| Add AI | `HostUtils.AddAI` → `TryAddPlayer` (~7186) | `SlotAssigned` | personality list from `aitypes.lua` (`GetAItypes`); AI auto-ready, `Human=false` | +| Remove AI | `HostUtils.RemoveAI` (~6988) | `ClearSlot` | | +| AI rating | `ComputeAIRating` (~456), `GetAIPlayerData` (~503) | — | from cheat/build/map multipliers + omni bonus (per `AILobbyProperties`) | +| AI faction/colour/team | via PlayerData | `PlayerOptions` | colour from `GetAvailableColor`/inherited; team via AutoTeams or explicit | +| Move/swap AI | `SwapPlayers`/`MovePlayerToEmptySlot` | `SlotMove`/`SwapPlayers` | same path as players | +| Fill slots | "Fill Slots" + AI combo (~3210) | `SlotAssigned` ×N | | + +--- + +## E. Observers + +| Feature | Who | Function | Message | +|---|---|---|---| +| Join as observer | auto (slots full / requested) | `TryAddObserver` (~7113) | `ObserverAdded` | +| Player → observer | host or player-request | `ConvertPlayerToObserver` (~6879) / `RequestConvertToObserver` | `ConvertPlayerToObserver` | +| Observer → player | host or observer-request | `ConvertObserverToPlayer` (~6923) / `RequestConvertToPlayer` (opt. slot) | `ConvertObserverToPlayer` | +| Observer list UI | all | `refreshObserverList` (~926) | name + rating + ping + CPU; team ratings if >2 teams | +| Kick observer | host | `KickObservers` (~7379) | engine eject; also auto-kick at launch if observers disallowed | +| Observer chat | observer | `AddChatText` greys observer names | `PublicChat`/`PrivateChat` | +| Rehost slot negotiation | host | `FindRehostSlotForID` (~841) | observers reclaim prior slots on rehost | + +--- + +## F. Host authority & validation + +- **Pattern**: client mutates own slot → broadcasts `PlayerOptions` (host `Accept` + checks `SenderID == OwnerID`); non-owned mutations go request→host-validate→broadcast. + Host-only gate: `AmHost` (~5067) on relevant handlers. +- **Optimistic UI**: client applies colour locally, host may revert (~5357). +- **Ready-gating**: `RefreshButtonEnabledness` (~7248) disables host option buttons + while any human is not ready; launch enabled only when all ready (or single-player / host-observes). +- **Force unready**: `SetPlayerNotReady` (one), `SetAllPlayerNotReady` (all, on option reset). +- **Race guards**: e.g. ignore a move request if the player became ready meanwhile (~5323). + +--- + +## G. Game options (complete enumeration — `lobbyOptions.lua`) + +### Team options (~72–226) +| Key | Values | Default | +|---|---|---| +| `TeamSpawn` | fixed, penguin_autobalance, random, balanced, balanced_flex, random_reveal, balanced_reveal, balanced_reveal_mirrored, balanced_flex_reveal | fixed | +| `TeamLock` | locked, unlocked | locked | +| `AutoTeams` | none, tvsb, lvsr, pvsi, manual | none | +| `CommonArmy` | Off, UnionWhenDisconnected, Union, Common | Off | +| `TeamShareOverflow` | enabled, disabled | enabled | + +### Global options (~229–706) +| Key | Values | Default | +|---|---|---| +| `Share` | FullShare, ShareUntilDeath, PartialShare, TransferToKiller, Defectors, CivilianDeserter | FullShare | +| `DisconnectShare` | SameAsShare | SameAsShare | +| `DisconnectShareCommanders` | Explode | Explode | +| `ShareUnitCap` | none, allies, all | allies | +| `ManualUnitShare` | all, no_builders, none | all | +| `RestrictedCategories` | set (Unit Manager) | none | +| `Victory` | demoralization, decapitation, domination, eradication, sandbox | demoralization | +| `FogOfWar` | explored, none | explored | +| `GameSpeed` | normal, fast, adjustable | normal | +| `CheatsEnabled` | false, true | false | +| `CivilianAlliance` | enemy, neutral, removed | enemy | +| `RevealCivilians` | Yes, No | Yes | +| `PrebuiltUnits` | Off, On | Off | +| `NoRushOption` | Off, 1..5,10,15,…,60 | Off | +| `RandomMap` | Off, Official, All | Off | +| `Score` | yes, no | yes | +| `UnitCap` | 125,250,…,1000,1250,1500 | 1000 | +| `AllowObservers` | true/false | true | +| `Timeouts` | 0, 3, -1 (MP only) | 3 | +| `DisconnectionDelay02` | 10, 30, 90 (MP only) | 90 | +| `Unranked` | No, Yes | No | + +### AI options (~709–814) +| Key | Values | Default | +|---|---|---| +| `CheatMult` | 0.5–5.9 | 1.0 | +| `BuildMult` | 0.5–5.9 | 1.0 | +| `TMLRandom` | 0–20% | 0 | +| `LandExpansionsAllowed` | 0–8, 99999 | 6 | +| `NavalExpansionsAllowed` | 0–8, 99999 | 5 | +| `OmniCheat` | on, off | on | + +Plus **map-specific options** (`mapname_options.lua`, validated by `maputil`) and +**custom AI options** from mods (`/lua/AI/LobbyOptions/`). Storage: `gameInfo.GameOptions[key]` ++ `Prefs LobbyOpt_`; host writes via `SetGameOption(s)` (~5938) → `GameOptions` broadcast ++ GpgNet (`RestrictedCategories`→bool, `ScenarioFile`, `Slots`). Reset via `OptionUtils.SetDefaults` (~2592). +Display: `RefreshOptionDisplayData` (~4543), scrollable `GUI.OptionContainer`, optional "show changed only". + +--- + +## H. Auto-teams & spawn + +- **AutoTeams** (`AssignAutoTeams` ~1802): none / tvsb / lvsr / pvsi / manual (host clicks map markers, requires random spawn). +- **Spawn variants** (`TeamSpawn`): fixed, random, balanced (+flex, +reveal, +mirrored), penguin_autobalance. +- **Spawn-mex** (adaptive maps): per-slot `SpawnMex` toggle; embedded into GameOptions at launch. +- **Launch-time** (`TryLaunch` ~2077): `AssignAutoTeams`, `AssignRandomFactions`, `FixFactionIndexes`, + `AssignRandomStartSpots`, `AssignAINames`, flatten gameInfo, build Ratings/ClanTags tables. + +--- + +## I. Map selection (`mapselect.lua`) + +- Map browser dialog `CreateDialog` (~453): list + preview + filters + options panel + Mods/Unit-Manager buttons. +- **Filters** (~117): supported players (2–16), size (5–81 km), type (official/custom), AI markers, hide-obsolete; persisted in prefs. Name search. +- **Preview**: `ResourceMapPreview` (terrain, water/cliff/buildable masks, mass/hydro markers, start spots). +- **Metadata** (`maputil` `LoadScenario` ~53): name, map/save/script/preview, size, reclaim, type, version, AdaptiveMap, Configurations (teams/armies). +- **Enumeration**: `EnumerateSkirmishScenarios` (~312) over `/maps/*` + mod map dirs; playable skirmish only. +- Hardcoded official-maps list for the "official" filter. + +--- + +## J. Mods manager (`ModsManager.lua`) + +- Dialog `CreateDialog(parent, isHost, availableMods, saveBehaviour)` (~160). +- **Types**: GAME (sim, network-replicated, disables rating), UI (local only), BLACKLISTED, LOCAL (host has, peers don't), NO_DEPENDENCY. +- **Browse**: grid (expand/collapse), sort (status/type/name/author/version), free-text search; prefs-persisted. +- **Favorites**: per-mod star; "Activate Favorites" (host: sim+UI, client: UI only). +- **Dependencies**: forward auto-activate, backward auto-deactivate, conflict prompts (`Mods.GetDependencies`). +- **Peer availability**: `availableMods[peerID]`; `EveryoneHasMod`; sim mods auto-disabled / peers kicked if missing. +- **Apply**: split sim/UI, `saveBehaviour(simMods, uiMods)` → host `UpdateMods` → `ModsChanged` broadcast; `Mods.SetSelectedMods`. + +--- + +## K. Unit restrictions manager (`UnitsManager.lua` + `UnitsRestrictions.lua`) + +- Dialog `CreateDialog(parent, initial, OnOk, OnCancel, isHost)` (~216); scales to ~90% screen. +- **150+ presets** in ~15 groups (tech levels, unit types, spam, snipes, anti-air, torpedo, direct-fire, defensive/intel, SCU/ACU, economy, missiles, engineers). +- **Expression system**: category algebra (`*` AND, `+` OR, `-` NOT, `()`), UPPERCASE categories + lowercase unit ids + enhancement names. +- Grid: presets + per-faction × category unit matrix; host editable, client read-only. +- Stored in `GameOptions.RestrictedCategories`; broadcast + GpgNet bool; saved in presets. + +--- + +## L. Chat + +| Feature | Trigger / function | Message | +|---|---|---| +| Public chat | `GUI.chatEdit` Enter → `PublicChat` (~1924) | `PublicChat` | +| Whisper | `/whisper`,`/pm`,`/w`,`/private` → `ParseWhisper` (~192) → `PrivateChat` (~1934) | `PrivateChat` | +| Unknown command | "Command Not Known" | — | +| Join/leave & connection notices | `AddPlayer`, `Peer_Really_Disconnected`, `CalcConnectionStatus` | `SystemMessage` | +| Rating in chat/list | `refreshObserverList` | — | +| Clickable names | `chatarea.lua` author click → prefill `/w name` | — | +| Input history | up/down over `commandQueue` | — | +| Scroll / auto-bottom | `AddChatText` (~4819) | — | + +--- + +## M. Connectivity & health + +| Feature | Function | Message | Notes | +|---|---|---|---| +| CPU benchmark | `UpdateBenchmark`/`StressCPU` (~6226) | `CPUBenchmark` | auto on connect + manual button; 0–450; cached in prefs; host re-broadcasts | +| CPU bar | `SetSlotCPUBar` (~6300) | — | green/yellow/red thresholds | +| Ping bar | ping thread (~4406) | — | shown if >500ms; status texture 1–4 | +| Connection tracking | `CalcConnectionStatus` (~4742) | — | ConnectionEstablished/CurrentConnection/ConnectedWithProxy | +| Pre-launch connectivity | `EveryoneHasEstablishedConnections` (~4785) | — | every important peer must reach every other | +| Peer disconnect | `PeerDisconnected` (~5833) | `Peer_Really_Disconnected` | clears slot/observer; chat notice | +| Host keep-alive/timeout | thread (~5616), `quietTimeout` 30s | — | "Keep Trying / Give Up" dialog | + +--- + +## N. Compatibility & launch validation + +- **Version check**: host `AddPlayer.Accept` (~5272) compares Version/GameType/Commit → reject + eject on mismatch. +- **Missing map**: `MissingMap` → `PlayerMissingMapAlert` (~7190) sets `BadMap`, system message; local fallback to default scenario; `ClearBadMapFlags` on map change. +- **Mods exchange**: `SetAvailableMods` → `availableMods[peer]`; `IsModAvailable` requires all peers; missing → kick with list. +- **Launch blockers** (`TryLaunch` ~2077): no players; host-as-observer when observers disallowed; observers present → confirm-kick; missing connections; map missing (warn, proceeds with fallback). + +--- + +## O. Presets & rehost (`presets.lua`) + +- Save preset (map, options, mods, restrictions, player options) to profile; load/apply (`ApplyGameSettings` ~6683); delete; rename. +- Auto-save "lastGame" preset at launch (`SaveLastGamePreset`). +- Rehost (`/rehost`): restore last-game settings; reassign returning players to prior slots (evicting AIs / displacing to observer), restore AIs, relocate host. +- Presets dialog UI: list + detail + Load/Create/Save/Delete/Help/Quit. + +--- + +## P. Lobby preferences dialog (`ShowLobbyOptionsDialog` ~6519) + +Local, non-game-affecting: background mode (factions/concept/screenshot/map/none), +chat font size, snowflake count, windowed lobby, stretch background, faction font colour, +player-colour-in-chat. Applied immediately; stored in prefs. + +## Q. Other dialogs + +- Briefing dialog (campaign/scenario), large map preview (water/cliff/buildable masks), + save/load dialog (skirmish), kick/reset confirm popups. + +--- + +## Network message catalog (~30) + +`AddPlayer`, `PlayerOptions`, `MovePlayer`, `RequestColor`, `SetColor`, +`RequestConvertToObserver`, `RequestConvertToPlayer`, `ConvertPlayerToObserver`, +`ConvertObserverToPlayer`, `ObserverAdded`, `SlotAssigned`, `SlotMove`, `SwapPlayers`, +`ClearSlot`, `SlotClosed`, `SlotClosedSpawnMex`, `AutoTeams`, `GameOptions`, +`ModsChanged`, `SetAvailableMods`, `MissingMap`, `GameInfo` (full sync on join), +`Launch`, `PublicChat`, `PrivateChat`, `SystemMessage`, `CPUBenchmark`, +`SetPlayerNotReady`, `SetAllPlayerNotReady`, `Peer_Really_Disconnected`. + +Engine callbacks: `Hosting`, `ConnectionToHostEstablished`, `PeerDisconnected`, +`DataReceived`, `GameLaunched`, `Ejected`, `ConnectionFailed`, `GameConfigRequested`. + +--- + +## Data model + +- **`gameInfo`** (`gamedata.lua`): `GameOptions` (table), `PlayerOptions` / + `Observers` (`WatchedValueArray`), `ClosedSlots`, `SpawnMex`, `GameMods`, `AutoTeams`, `AdaptiveMap`. +- **`PlayerData`** (`playerdata.lua`): OwnerID, PlayerName, Human, Faction, PlayerColor, + ArmyColor, Team, StartSpot, Ready, PL/MEAN/DEV/NG, PlayerClan, Country, AIPersonality, + AILobbyProperties, Version/GameType/Commit, Civilian, BadMap, ObserverListIndex. +- Module globals: `gameInfo`, `lobbyComm`, `GUI`, `localPlayerID`, `hostID`, + `availableMods`, `selectedSimMods`/`selectedUIMods`, `CPU_Benchmarks`, `rehostPlayerOptions`. + +## "Refresh-everything" functions to replace with reactive subscriptions + +`SetSlotInfo` (~1032), `ClearSlotInfo` (~1245), `UpdateGame` (~2302), +`refreshObserverList` (~926), `RefreshOptionDisplayData` (~4543), +`UpdateFactionSelector` (~6445), `RefreshButtonEnabledness` (~7248), +`Check_Availaible_Color` (~6706), `RefreshLobbyBackground`, `RefreshMapPositionForAllControls`. + +--- + +## Reuse vs rebuild + +**Relatively portable** (self-contained): map preview (`ResourceMapPreview`), mods +manager dialog, unit manager dialog, options definitions (`lobbyOptions.lua` data), +faction selector, CPU benchmark, chat (the new chat-MVC), presets storage. + +**Deeply tangled core** (rebuild against the model): slot/player sync (`SetSlotInfo` ++ `HostUtils`), the 30-message dispatch, host-authority request/validate/broadcast, +connection tracking. These are where the MVC rebuild's value — and risk — concentrate. diff --git a/lua/ui/lobby/TARGET_ARCHITECTURE.md b/lua/ui/lobby/TARGET_ARCHITECTURE.md new file mode 100644 index 00000000000..366be61a111 --- /dev/null +++ b/lua/ui/lobby/TARGET_ARCHITECTURE.md @@ -0,0 +1,303 @@ +# Custom-Game Lobby — Target Architecture (rebuild sketch) + +A design sketch for rebuilding the custom-game lobby on the reactive-MVC pattern +proven in [`autolobby/`](autolobby/CLAUDE.md) and [`game/chat/`](/lua/ui/game/chat/CLAUDE.md). +Read alongside [`FEATURES.md`](FEATURES.md) (what it must do) and +[`USER_STORIES.md`](USER_STORIES.md) (from whose perspective). + +This is a sketch, not final code. Representative code shapes are shown only for the +two load-bearing parts: the model and the host-authority flow. + +--- + +## 1. Principles (carried from autolobby / chat) + +- **Reactive model = single source of truth.** Replaces `gameInfo` + the ~10 + `SetSlotInfo`/`Refresh*` sledgehammers. Views subscribe via `Derive`; nobody pushes. +- **Thin `moho` Instance, logic in a free-function Controller.** The engine owns the + `moho.lobby_methods` object; it forwards callbacks to a hot-reloadable controller + (exactly the autolobby `AutolobbyInstance` ↔ `AutolobbyController` split). +- **Components own their reactivity and visibility.** A slot row subscribes to its + own slot; the options panel to the options; etc. No parent push-hub. +- **Write discipline in the model.** All mutations go through model write-helpers + (copy-then-`Set`), so in-place mutation can't silently break reactivity. +- **Standardize on `LazyVar`.** Drop `WatchedValueArray`/`WatchedValueTable`; use a + per-slot array of `LazyVar`s for players/observers (per-slot granularity replaces + per-field — a slot row is small enough to rebuild on any of its fields changing). + +**The one thing the autolobby does NOT teach: host authority.** The autolobby is +single-writer (host computes everything). The custom lobby is request→validate→ +broadcast. That shapes the Controller (§5) and is the main new design work. + +--- + +## 2. Layer overview + +``` + engine callbacks + │ + ┌───────────▼────────────┐ + │ LobbyInstance │ moho.lobby_methods, thin shell + │ (engine ABI + dispatch)│ validation via LobbyMessages + └───────────┬────────────┘ + forwards (passing self) ▲ BroadcastData / SendData + │ │ + ┌───────────▼────────────┐ │ + │ LobbyController │──────┘ the only writer to the model + │ intents · host-ops · │ and the only one talking to peers + │ wire-handlers │ + └───────────┬────────────┘ + writes via helpers + │ + ┌───────────▼────────────┐ + │ LobbyModel (+ submodels)│ reactive LazyVars + derived + write helpers + └───────────┬────────────┘ + OnDirty / Derive + │ + ┌───────────────┼───────────────────────────┐ + ▼ ▼ ▼ + Components Sub-dialogs (mini-MVC) Reused widgets + (slots, options, (map select, mods, units, (map preview, chat-MVC, + observers, …) presets, lobby prefs) faction selector, …) +``` + +UI never writes the model. UI calls **controller intents**; the controller writes +the model (host) or sends a request (client); wire-handlers apply authoritative state. + +--- + +## 3. Model decomposition + +`gameInfo` becomes a small package of focused reactive models (like chat's +`ChatModel` + `ChatConfigModel`). Suggested split: + +### `LobbyModel` — the synced game state (the gameInfo replacement) + +| LazyVar | Replaces | Notes | +|---|---|---| +| `Players[1..N]` (array of LazyVars) | `gameInfo.PlayerOptions` | each slot a LazyVar of `PlayerData\|false` | +| `Observers[1..M]` (array of LazyVars) | `gameInfo.Observers` | | +| `ClosedSlots`, `SpawnMex` | same | slot-flag tables | +| `GameOptions` | `gameInfo.GameOptions` | one table; write via helper | +| `GameMods` | `gameInfo.GameMods` | selected sim/ui set | +| `AutoTeams` | `gameInfo.AutoTeams` | slot→team | +| `ScenarioFile` | `GameOptions.ScenarioFile` | split out so the preview observes it directly | +| `LocalPeerId`, `HostID`, `IsHost`, `LocalPlayerName` | globals | identity | + +**Derived** (computed, never written): `Scenario` (bundle `{ScenarioFile, Players}`), +`AvailableColors[slot]`, `PlayersNotReady`, `CanLaunch`, `GameQuality`, +`SlotMenu[slot]` (the right-click model), `PlayerCount`. These replace +`Check_Availaible_Color`, `GetPlayersNotReady`, `RefreshButtonEnabledness`, +`ShowGameQuality`, `GetSlotMenuTables`. + +### `LobbyConnectivityModel` — local, high-frequency, NOT part of the synced game state + +`ConnectionStatus[peer]`, `Ping[peer]`, `CPUBenchmarks[name]`, `AvailableMods[peer]`, +`BadMap[peer]`. Kept separate so frequent ping/CPU churn doesn't dirty game state. + +### `LobbyPrefsModel` — local UI preferences + +`Committed` / `Pending` like [`ChatConfigModel`](/lua/ui/game/chat/config/ChatConfigModel.lua): +background mode, chat font, snowflakes, windowed, stretch, chat colours. + +### Chat — reuse the chat MVC + +Lobby chat is the same shape as in-game chat; reuse the model/controller, or a thin +lobby-scoped mirror. Do not reinvent. + +Model write-helpers (mirroring autolobby's `SetPlayer`/`SetPeerStatus`/…): +`SetPlayer(slot, data)`, `ClearPlayer(slot)`, `SetObserver`, `SetPlayerField(slot, key, value)`, +`SetGameOption(key, value)`, `SetClosed(slot, …)`, `SetMods(…)`, `SetScenario(file)`. + +```lua +-- shape (per autolobby AutolobbyModel) +function SetPlayerField(model, slot, key, value) + local p = table.copy(model.Players[slot]() or {}) + p[key] = value + model.Players[slot]:Set(p) -- only slot `slot`'s subscribers re-fire +end +``` + +--- + +## 4. `LobbyInstance` — the thin `moho` shell + +Same role as [`AutolobbyInstance`](autolobby/AutolobbyInstance.lua): the C-bound +object the engine instantiates. Keeps engine-ABI wrappers (`BroadcastData`, +`SendData`, `EjectPeer`, `LaunchGame`, `GetPeers`, …, with their `LobbyMessages` +validation), the command-line/identity creators, and `DataReceived`'s +validate→accept→dispatch. Every callback with behaviour forwards to the controller: + +```lua +Hosting = function(self) LobbyController.OnHosting(self) end, +DataReceived = function(self, data) ... validate ... LobbyMessages[data.Type].Handler(self, data) end, +PeerDisconnected = function(self, name, id) LobbyController.OnPeerDisconnected(self, name, id) end, +GameLaunched = function(self) LobbyController.OnGameLaunched(self) end, +``` + +Does **not** hot-reload (live C object) — keep it thin; behaviour lives in the controller. + +--- + +## 5. `LobbyController` — the logic (and host authority) + +A package of free-function modules (split by domain, like chat's `commands/` and +`config/`). Each function takes the `instance` as its first arg (for +`BroadcastData`/`SendData`/`IsHost`). Three faces: + +### 5a. Local intents (called by the UI) +What the local user asks for. Each decides host vs client: + +```lua +-- PlayerController +function RequestSetColor(instance, slot, color) + if instance:IsHost() then + HostSetColor(instance, slot, color) -- authoritative path + else + instance:SendData(LobbyModel.GetSingleton().HostID(), + { Type = 'RequestColor', Slot = slot, Color = color }) + end +end +``` + +### 5b. Host operations (host-only authority: validate → apply → broadcast) + +```lua +function HostSetColor(instance, slot, color) + if not LobbyModel.IsColorFree(LobbyModel.GetSingleton(), color, slot) then return end + LobbyModel.SetPlayerField(LobbyModel.GetSingleton(), slot, 'PlayerColor', color) + instance:BroadcastData({ Type = 'SetColor', Slot = slot, Color = color }) +end +``` + +### 5c. Wire handlers (apply authoritative state arriving from the network) + +```lua +function OnSetColor(instance, data) -- registered as LobbyMessages.SetColor.Handler + LobbyModel.SetPlayerField(LobbyModel.GetSingleton(), data.Slot, 'PlayerColor', data.Color) +end +``` + +### The host-authority data flow (the defining difference) + +``` +client UI ── intent ──► RequestX ──(not host)── SendData ─► host + │ HostX: validate +host UI ── intent ──► RequestX ──(host)──► HostX ◄────────────┘ + │ apply to model + BroadcastData + ▼ + every client: OnX wire-handler ─► apply to model ─► views react +``` + +Validation lives in the **message registry** `LobbyMessages.lua` (mirroring +[`AutolobbyMessages`](autolobby/AutolobbyMessages.lua)): a table of +`{ Validate, Accept, Handler }` per type, `Handler → LobbyController.OnX`. `Accept` +encodes `IsFromHost` / `AmHost` / ownership checks. ~30 entries (the full set in +[`FEATURES.md`](FEATURES.md) § "Network message catalog"). + +Suggested controller modules: `LobbyController` (lifecycle, dispatch, launch), +`PlayerController` (slots/players/AI/observers — intents + host-ops + handlers), +`OptionsController`, `ModsController`, `ConnectivityController`. + +--- + +## 6. Components (views) + +Each subscribes to its slice and feeds dumb children — replacing the refresh sledgehammers. + +| Component | Subscribes to | Calls (intents) | Replaces | +|---|---|---|---| +| `LobbySlotInterface` (per slot) | `Players[slot]`, `AvailableColors[slot]`, `SlotMenu[slot]`, connectivity `Ping/CPU` | faction/colour/team/ready/move/kick/AI intents | per-slot part of `SetSlotInfo` | +| `LobbySlotsInterface` | `PlayerCount`, slot structure | — | `CreateSlotsUI` | +| `LobbyObserverListInterface` | `Observers`, connectivity | become-player / kick | `refreshObserverList` | +| `LobbyOptionsPanelInterface` | `GameOptions` (+ derived display) | open options/map/mods dialogs (host) | `RefreshOptionDisplayData` | +| `LobbyMapPreviewInterface` | `Scenario` | start-position intents | map preview wiring | +| `LobbyFooterInterface` | `CanLaunch`, `PlayersNotReady`, `IsHost` | launch, ready, leave | `RefreshButtonEnabledness` | +| `LobbyChatInterface` | chat model | chat controller | chat area | + +`LobbyInterface` is the **composition root** (build + lay out children, no +subscriptions) — exactly like `AutolobbyInterface`. + +--- + +## 7. Sub-dialogs as mini-MVC + +Each heavy dialog is its own small MVC, reading the model and calling controller +intents on apply (like chat's `config/` trio): + +| Dialog | Reads | On apply → controller | Reuse | +|---|---|---|---| +| Map select | map list, `Scenario` | `HostSetScenario` | `ResourceMapPreview` | +| Mods manager | `AvailableMods`, `GameMods` | `HostSetMods` | `ModsManager.lua` (port) | +| Unit manager | `GameOptions.RestrictedCategories` | `HostSetRestrictedCategories` | `UnitsManager.lua` (port) | +| Presets | preset storage | `ApplyGameSettings` via controller | `presets.lua` storage | +| Lobby prefs | `LobbyPrefsModel.Pending` | `LobbyPrefsController` Apply/Set | pattern from `ChatConfig` | + +--- + +## 8. Reuse vs rebuild + +- **Port mostly as-is**: `ResourceMapPreview`, `ModsManager`, `UnitsManager`, + `lobbyOptions.lua` (option *data*), faction selector, CPU benchmark, presets + storage, chat-MVC. Give each a model-backed input + a controller callback. +- **Rebuild against the model**: slot/player sync (`SetSlotInfo` + `HostUtils`), + the message dispatch, host-authority validation, connection tracking. This is + where the value and the risk concentrate. + +--- + +## 9. Bootstrap & lifecycle + +Order (from autolobby's lesson): in the entry wrapper (`lobby.lua`'s `CreateLobby` +equivalent) set up **models → views → instance**, so views subscribe to a live +model and the instance's `__init` seeds an existing model. On the connecting client, +the host's full `GameInfo` message seeds the model (one wire-handler that bulk-sets). + +Hot-reload: model + views + controller reload (own `__moduleinfo` hooks); the +`LobbyInstance` C object does not — keep it thin. Long-running threads (ping/CPU +loops) reload only on the next lobby, as in autolobby. + +A debug `OpenDebug()` (fake-populated model + views, no networking) lets the whole +lobby be inspected without a real game — the standalone-invocation principle. + +--- + +## 10. Domain → architecture mapping (A–Q from FEATURES.md) + +| Domain | Model | Controller | View / Dialog | +|---|---|---|---| +| A Lifecycle/entry | identity fields | `LobbyController` lifecycle + `OnHosting`/`OnConnect`/`OnGameLaunched` | entry wrapper, composition root | +| B Slots & players | `Players`, `ClosedSlots`, `SpawnMex`, derived `SlotMenu` | `PlayerController` (move/swap/kick/close) | `LobbySlotInterface` | +| C Per-player settings | `Players[slot]` fields, `AvailableColors` | `PlayerController` intents + host-ops | slot row controls | +| D AI | `Players[slot]` (AI) | `PlayerController` AddAI/RemoveAI | slot menu | +| E Observers | `Observers` | `PlayerController` convert/kick | `LobbyObserverListInterface` | +| F Host authority | `IsHost`, derived gates | all host-ops + `LobbyMessages.Accept` | footer enable/disable | +| G Game options | `GameOptions` | `OptionsController` | `LobbyOptionsPanelInterface`, options dialog | +| H Auto-teams/spawn | `AutoTeams`, `GameOptions.TeamSpawn` | `OptionsController` + launch-time resolves | options panel, map preview | +| I Map selection | `ScenarioFile`, `Scenario` | `HostSetScenario` | map select dialog + preview | +| J Mods | `GameMods`, conn `AvailableMods` | `ModsController` | mods manager dialog | +| K Unit restrictions | `GameOptions.RestrictedCategories` | `OptionsController` | unit manager dialog | +| L Chat | chat model | chat controller | `LobbyChatInterface` (reuse) | +| M Connectivity | `LobbyConnectivityModel` | `ConnectivityController` (CPU/ping/connection) | ping/CPU bars, observer/slot rows | +| N Compatibility/validation | conn `BadMap`, identity | `LobbyMessages.Accept`, launch checks | launch blockers, system chat | +| O Presets/rehost | `GameOptions`/`Players` snapshot | `LobbyController` apply/rehost | presets dialog | +| P Lobby prefs | `LobbyPrefsModel` | `LobbyPrefsController` | lobby prefs dialog | +| Q Other dialogs | (various) | host-ops | briefing, big preview, save/load | + +--- + +## 11. Open decisions & risks + +- **Migration**: parallel package (e.g. `/lua/ui/lobby/custom/`) behind a toggle vs + in-place strangler of `lobby.lua`. A parallel rebuild needs a parity period measured + against `FEATURES.md`/`USER_STORIES.md`; the strangler keeps it shippable throughout. +- **Reactivity granularity**: per-slot LazyVars (proposed) vs per-field. Per-slot is + simpler and enough; revisit only if a row's rebuild cost ever shows up. +- **Connectivity model boundary**: keep ping/CPU out of the synced game state (proposed) + to avoid churn dirtying game state — confirm no view needs them coupled. +- **Host migration / reconnect**: the current lobby's reconnect/keep-alive and + rehost edge-cases are the least-understood surface; inventory them deeply before + committing (the highest parity risk). +- **One model vs submodels**: proposed split is `LobbyModel` + `Connectivity` + + `Prefs` + reused chat. Resist over-splitting the core game state — it's one + consistent snapshot the host broadcasts. diff --git a/lua/ui/lobby/USER_STORIES.md b/lua/ui/lobby/USER_STORIES.md new file mode 100644 index 00000000000..f07c0788334 --- /dev/null +++ b/lua/ui/lobby/USER_STORIES.md @@ -0,0 +1,190 @@ +# Custom-Game Lobby — User Stories + +Companion to [`FEATURES.md`](FEATURES.md). Same A–Q structure, so each story +traces back to a catalogued feature. Format: **As a ``, I want ``, so +that ``** — with *when/given* conditions where they matter. + +Roles: **Host**, **Player** (human in a slot), **Observer**, **Joining client** +(connecting peer), **System** (automatic / engine-driven). + +> **Progress** (updated as the [`customlobby/`](customlobby/CLAUDE.md) rebuild lands slices): +> ✅ done · 🟡 partial / works with gaps · ⬜ not started. Markers reflect the reactive-MVC +> rebuild, not the legacy `lobby.lua`. Launch itself isn't wired yet, so anything that only +> resolves *at launch* is ⬜ even when its inputs are configurable. + +--- + +## A. Lifecycle & entry + +- ⬜ As a **player**, I want to browse discovered games (map, name, player count), so that I can pick one to join. +- 🟡 As a **host**, I want to create a game with a name and a starting map, so that others can find and join it. *(Given a valid nickname, 2–32 non-space chars.)* — *host/client connect + sync work; the create-game entry UI (name, nickname validation) isn't rebuilt.* +- 🟡 As a **player**, I want to join by direct IP:port, so that I can connect when discovery doesn't list the game. *(Engine join path works in testing; no direct-IP entry UI yet.)* +- ✅ As a **joining client**, I want to receive the full lobby state on connect, so that I immediately see the current map, slots, options and mods. *(`BroadcastPlayers` + `BroadcastLaunchInfo` + `BroadcastSessionState` on join.)* +- 🟡 As a **player**, I want to leave the lobby with a confirmation prompt, so that I don't exit by accident; *and when launched via `/gpgnet`*, leaving quits to desktop instead of the menu. — *Leave/Esc → menu works; no confirm prompt, no gpgnet quit-to-desktop.* +- ⬜ As a **host**, I want to rehost my last game, so that the previous map/options/mods and player slots are restored automatically. +- ⬜ As a **player**, I want my lobby UI to tear down cleanly when the game launches, so that no stale threads or windows linger. + +## B. Slots & players + +- ✅ As a **host**, I want to open or close an empty slot, so that I control how many players can join. *(Per-seat **Close**/**Open** button on each empty/closed slot + a header **Close empty slots** button → `RequestSetSlotClosed` / `RequestCloseEmptySlots`; closing is rejected on an occupied seat.)* +- ⬜ As a **host on an adaptive map**, I want to close a slot as "spawn-mex", so that the position yields mass instead of an ACU. +- ✅ As a **player**, I want to occupy an empty open slot, so that I can join the game; *when* I'm not already marked ready. +- ✅ As a **host**, I want to move a player to another slot or swap two players, so that I can arrange start positions and teams. *(Drag a row onto another → `RequestSwapSlots`.)* +- 🟡 As a **host**, I want to kick a player with an optional message, so that I can remove someone; *and* the message is remembered for next time. *(Eject works; no message / recall.)* +- 🟡 As a **player**, I want each slot row to show name, rating, country, faction, colour, team, ping, CPU and ready state, so that I can assess the lobby at a glance. *(Name, colour, faction, team, rating, games, country flag, CPU/headroom, ready shown + a reserved avatar box; ping not yet.)* +- ✅ As a **player**, I want a slot's controls to reflect who owns it (me / another player / host / AI) via colour and an appropriate right-click menu, so that I only see actions I'm allowed to take. + +## C. Per-player settings + +- 🟡 As a **player**, I want to choose my faction (including Random), so that I play what I want; *given* the map allows it and I'm not ready. *(Click the faction → a **multi-toggle** picker: tick a subset of factions, more than one = random among them, resolved to one at launch. Gated to your own seat while not ready, or any AI seat for the host. Per-map faction availability isn't enforced yet.)* +- 🟡 As a **player**, I want to pick a colour, so that I'm visually distinct; *when* the colour is free — taken colours are hidden and the host reverts conflicts. *(Click the swatch → a colour grid; colours taken by another seated player are greyed, and the host rejects a taken colour if a race slips through → `RequestSetColor`.)* +- 🟡 As a **player**, I want to set my team, so that I'm allied correctly; *when* AutoTeams is off and I'm not ready. *(Click the team tag → a team picker (No team / Team 1..8); hidden under a binary AutoTeams mode, where the start position decides the team → `RequestSetTeam`.)* +- ✅ As a **player**, I want to toggle Ready, so that I signal I'm set; *and when* ready, my own controls lock until I unready. +- ⬜ As a **host**, I want to force a player not-ready, so that I can change settings that affect them. +- ⬜ As a **player**, I want to pick my start position on the map preview, so that I control where I spawn; *when* spawn is fixed (host gets swap/assign tools otherwise). +- ⬜ As a **player**, I want my last faction/colour remembered, so that I don't re-pick every lobby. + +## D. AI management (host) + +- ⬜ As a **host**, I want to add an AI of a chosen personality to a slot, so that I can fill the game; *and* the AI is auto-ready. +- ⬜ As a **host**, I want the AI personality list to include built-in and mod-provided AIs, so that all installed AIs are selectable. +- ⬜ As a **host**, I want to remove an AI from a slot, so that I can free it. +- ⬜ As a **host**, I want AIs to get a computed rating from cheat/build/map multipliers, so that game quality estimates stay meaningful. +- ⬜ As a **host**, I want to set an AI's faction/colour/team and move/swap it like a player, so that I can arrange AI-inclusive setups. +- ⬜ As a **host**, I want a quick "fill slots" action, so that I can populate remaining slots with AIs at once. + +## E. Observers + +- 🟡 As a **joining client**, I want to enter as an observer when no slot is free or when I request it, so that I can still watch. *(Observers are modelled/synced; auto-enter-as-observer on a full lobby isn't wired.)* +- 🟡 As a **player**, I want to become an observer (or request it from the host), so that I can step out of the game without leaving. *(Host moves a player to observers via the context menu, and an **"Observe" button** moves the local player out of their slot — host-side; a non-host's request-to-host path isn't wired yet.)* +- ✅ As an **observer**, I want to become a player / request a specific slot, so that I can join in; *given* a slot is available (host may evict an AI). *(Right-click → "Play this slot".)* +- 🟡 As a **player**, I want to see the observer list with rating, ping and CPU, so that I know who's spectating. *(Observer strip shows count + names; no rating/ping/CPU.)* +- ⬜ As a **host**, I want to kick an observer, so that I can manage spectators; *and* observers are auto-kicked at launch if observers are disallowed. +- ⬜ As an **observer**, I want to use public and private chat (shown greyed), so that I can still communicate. + +## F. Host authority & validation + +- ✅ As a **host**, I want to be the single source of truth, so that all clients converge on one authoritative state. *(Host-authoritative: request → validate → broadcast snapshot.)* +- 🟡 As a **player**, I want my own-slot changes applied optimistically and then confirmed by the host, so that the UI feels responsive but stays consistent. *(Round-trips through the host; not yet optimistic.)* +- ⬜ As a **host**, I want option-changing buttons disabled while any human is not ready, so that settings can't shift after people commit. +- 🟡 As a **host**, I want the launch button enabled only when everyone is ready (or single-player / I'm observing), so that I can't start prematurely. *(Launch works — `RequestLaunch` builds the config, broadcasts a `LaunchGame` message and calls the engine; it's validated on click (host + a map + ≥1 seat + every other human ready, the host exempt). Not yet reactively enabled/disabled, and the block reason is only logged.)* +- ⬜ As a **system**, I want to ignore a move request if the player became ready in the meantime, so that races don't corrupt slot state. + +## G. Game options (host) + +- ✅ As a **host**, I want to set the victory condition (assassination / decapitation / supremacy / annihilation / sandbox), so that the win rule fits the game. *(Options dialog.)* +- ✅ As a **host**, I want to configure share conditions (full / until-death / partial / transfer-to-killer / defectors / civilian-deserter) and share-unit-cap, so that resource/unit transfer on death matches our preference. +- ✅ As a **host**, I want to set unit cap, fog of war, game speed, no-rush timer, prebuilt units, civilians, score and cheats, so that the match rules are tuned. +- ✅ As a **host**, I want to mark the game unranked, so that it doesn't affect ratings. +- ✅ As a **host (multiplayer)**, I want to set timeouts and disconnection delay, so that disconnect handling fits the context (tournament/quick/regular). +- ✅ As a **host**, I want to set AI multipliers (cheat/build), omni, TML randomization and expansion limits, so that AIx difficulty is tuned. *(AI column of the options dialog.)* +- 🟡 As a **host**, I want a "reset to defaults" action, so that I can clear all options at once (which also unreadies everyone). *(Reset button clears to defaults; the auto-unready side-effect isn't wired.)* +- ✅ As a **host**, I want map-specific and mod-provided options surfaced alongside the standard ones, so that nothing is hidden. *(Scenario + Mods columns.)* +- ✅ As a **player**, I want to see the active (and optionally only the changed) options, so that I understand the ruleset. *(The config panel's **Options tab** shows the current values read-only to everyone, grouped Lobby / Scenario / Mods, with a hide-defaults toggle and origin markers; the host edits them via the Options dialog.)* + +## H. Auto-teams & spawn + +- 🟡 As a **host**, I want AutoTeams modes (top/bottom, left/right, odd/even, manual), so that teams are assigned by position without manual fiddling. *(Selectable as a lobby option; the **binary positional modes** (top/bottom, left/right, odd/even) now resolve team-from-position at launch in `BuildGameConfiguration` — and drive the two-column display + auto-balance. The `manual` marker mode isn't wired.)* +- ⬜ As a **host**, I want manual AutoTeams by clicking map markers, so that I can hand-place teams on random spawn. +- 🟡 As a **host**, I want spawn variants (fixed, random, balanced/flex/reveal, penguin-autobalance), so that start placement matches the desired fairness/secrecy. *(Selectable as a lobby option; placement is resolved at launch, which isn't wired.)* +- ⬜ As a **host on an adaptive map**, I want per-slot spawn-mex, so that closed positions still contribute economy. +- 🟡 As a **system**, I want random factions/start spots/AI names and the ratings/clan tables resolved at launch, so that the final config is complete and fair. *(At launch `BuildGameConfiguration` resolves random factions to a concrete one, assigns army numbers in slot order, stamps the ratings/clan-tag tables, and resolves team-from-position for the binary AutoTeams modes; random start-spot assignment and AI names aren't done yet.)* + +## I. Map selection (host) + +- ✅ As a **host**, I want to browse maps with a preview, so that I can choose a map informedly. +- 🟡 As a **host**, I want to filter maps by player count, size, type (official/custom), AI markers and hide-obsolete, and search by name, so that I find the right map quickly; *and* my filters persist. *(Size, player count, name search + persistence done; type/AI-markers/hide-obsolete filters not.)* +- 🟡 As a **host**, I want a resource-aware preview (mass/hydro, water/cliff/buildable masks, start spots), so that I understand the map layout. *(Preview shows start spots, mass/hydro, wrecks; the lobby's Map tab also shows reclaim totals + description / author / url / version; terrain masks not.)* +- ✅ As a **host**, I want to pick a random map, so that I can play something fresh. +- ✅ As a **player**, I want clear warnings when a map's files are missing, so that I'm not stuck on an unplayable map. *(File-health check disables Select.)* + +## J. Mods manager + +- 🟡 As a **host**, I want to enable/disable sim mods, so that gameplay-affecting mods apply to everyone; *and* doing so disables ranking. *(Enable/disable + sync done via `RequestSetGameMods`; the ranking-disable side-effect isn't wired.)* +- ✅ As a **player**, I want to enable UI mods locally, so that my client UI changes without affecting others. +- 🟡 As a **player**, I want to browse, sort, search, expand/collapse and favorite mods, so that I can manage a large mod list; *and* my preferences persist. *(Browse, search, type filters, persistence done; sort + expand/collapse not; favorites replaced by presets — see next.)* +- 🟡 As a **player**, I want "activate favorites" in one click, so that I quickly enable my usual set (host: sim+UI, client: UI only). *(Replaced by named **presets** — load a saved selection; the host/client sim-vs-UI split is honoured.)* +- ✅ As a **player**, I want mod dependencies and conflicts handled automatically, so that I don't end up with a broken set. *(`ResolveEnable`/`ResolveDisable`.)* +- ⬜ As a **host**, I want to see which peers lack a sim mod, so that missing-mod peers are flagged/auto-disabled or kicked rather than causing desyncs. + +## K. Unit restrictions (host) + +- ⬜ As a **host**, I want to apply unit-restriction presets (tech levels, types, spam/snipe/anti-air/etc.), so that I can shape allowed units fast. +- ⬜ As a **host**, I want to toggle individual units per faction, so that I can fine-tune beyond presets. +- ⬜ As a **player**, I want a read-only view of the restrictions, so that I know what's banned. +- ⬜ As a **host**, I want restrictions saved with presets and reflected in rating eligibility, so that custom rulesets are reusable and correctly rated. + +## L. Chat + +- ⬜ As a **player/observer**, I want to send public chat, so that everyone in the lobby sees it. +- ⬜ As a **player/observer**, I want to whisper via `/whisper` `/pm` `/w` `/private`, so that I can message one person privately. +- ⬜ As a **player**, I want clear feedback on an unknown command or an invalid whisper target, so that I know it didn't send. +- ⬜ As a **player**, I want join/leave/connection notices in chat, so that I'm aware of lobby changes. +- ⬜ As a **player**, I want to click a name in chat to prefill a whisper, so that replying is quick. +- ⬜ As a **player**, I want command history (up/down) and auto-scroll with a "new message" indicator, so that chatting is comfortable. + +## M. Connectivity & health + +- 🟡 As a **player**, I want my CPU benchmarked (auto on connect, and on demand), so that others can see my performance; *and* the result is cached and re-broadcast by the host. *(Stored benchmark is shared + re-broadcast; no live/on-demand stress test.)* +- 🟡 As a **player**, I want CPU and ping bars per slot (ping shown when poor), so that I can spot weak links. *(CPU column with cap-headroom done; ping not.)* +- 🟡 As a **system**, I want to track per-peer connection status, so that the UI reflects who is fully connected. *(`EstablishedPeers`/`OnPeerDisconnected` tracked; not fully surfaced.)* +- ⬜ As a **host**, I want launch blocked until every important peer is connected to every other, so that the game doesn't start with broken links. +- 🟡 As a **player**, I want a peer's disconnect to clear its slot and notify chat, so that the lobby stays accurate. *(Disconnect frees the slot for everyone; no chat notice — chat isn't in the lobby yet.)* +- ⬜ As a **client**, I want a "host timed out — keep trying / give up" prompt, so that I can decide what to do on a stalled connection. + +## N. Compatibility & launch validation + +- ⬜ As a **host**, I want to reject peers on a different game version/type/commit, so that desyncs are prevented (with a clear ejection reason). +- 🟡 As a **player**, I want missing-map situations flagged (and a fallback used), so that the lobby communicates the problem instead of silently breaking. *(The map dialog flags missing files; in-lobby fallback not.)* +- ⬜ As a **host**, I want peers' available mods exchanged and validated, so that everyone has the required sim mods before launch. +- ⬜ As a **host**, I want launch blocked with a clear reason (no players, host-as-observer when disallowed, observers present, missing connections), so that I know exactly what to fix. + +## O. Presets & rehost + +- 🟡 As a **host**, I want to save the current setup (map, options, mods, restrictions, players) as a named preset, so that I can reuse it. *(Save/load of map, options, mods and restrictions works via the action-bar **Presets** dialog; presets are **setup-only by design** — players/observers are not stored.)* +- ✅ As a **host**, I want to load/delete/rename presets, so that I can manage reusable configurations. *(The Presets dialog loads/deletes/renames; loading applies the setup host-authoritatively and reconciles options to the current map+mods.)* +- 🟡 As a **system**, I want the last game auto-saved as a preset at launch, so that rehost can restore it. *(Launch auto-saves the reserved `lastGame` setup; the rehost **restore** (in-lobby button + `/rehost` arg) is deferred. Note the auto-save carries no roster — reseating needs a separate player capture.)* +- ⬜ As a **host**, I want rehost to reseat returning players to their prior slots (displacing AIs/others as needed), so that a replay setup reconstitutes quickly. *(Blocked on AI-add + per-player slot intents; also needs its own roster capture — setup presets deliberately don't store players.)* + +## P. Lobby preferences (per user) + +- ⬜ As a **player**, I want to choose the lobby background mode, chat font size, snowflake count, windowed mode, background stretch and chat colours, so that the lobby looks how I like; *and* changes apply immediately and persist. + +## Q. Other dialogs + +- ⬜ As a **host**, I want a briefing dialog for scenarios that have one, so that players see the operation context. +- ⬜ As a **player**, I want a large map preview (with terrain/resource masks), so that I can study the map closely. +- ⬜ As a **host (skirmish)**, I want a save/load dialog, so that I can resume saved games. +- ⬜ As a **host**, I want confirmation prompts for destructive actions (kick, reset options), so that I don't trigger them by mistake. + +## R. New requests + +- ✅ As a **host**, I want the ability to lock players in-place when autobalance is applied so that players that want to play together stay in the same team. *(Slot context menu "Lock in slot"; a locked seat is pinned (synced session state) and the balancer rearranges only the unlocked players around it — see `CustomLobbyBalancer`.)* +- ⬜ As a **player**, I want the observer bug that can freeze/crash the lobby fixed — or at least a warning when it's hit — so that observing doesn't break the lobby. *(Reliable repro needs a debuggable lobby setup.)* +- ✅ As a **player**, I want to randomise among a chosen subset of factions (multi-choice), so that I get variety without pure random. *(The faction picker is a multi-toggle; the allowed set is stored as `player.Factions` and `BuildGameConfiguration` resolves it to one concrete faction at launch.)* +- ⬜ As a **player**, I want my FAF avatar shown in the lobby, so that players are recognisable. +- ⬜ As a **host**, I want a "players vs AI" AutoTeams option, so that all humans are teamed against the AIs automatically. +- ⬜ As a **host**, I want closing a slot to auto-move its player to a free slot (in opti), so that rearranging doesn't drop anyone. +- ✅ As a **host**, I want an opti team preview, so that I can see the balanced teams before committing. *(The auto-balance button opens a preview modal — `CustomLobbyBalancePreview` — showing the proposed two sides + match quality, with Apply / Cancel; nothing is re-seated until Apply.)* +- ⬜ As a **host**, I want flexible mirror balancing, so that mirror match-ups can be balanced with some give. +- 🟡 As a **host**, I want a system for balancing premades, so that pre-made groups are distributed fairly across teams. *(The mechanism exists — lock the premade's seats and the balancer holds them together while balancing the rest — but there is no automatic premade detection / grouping yet.)* +- ⬜ As a **player**, I want easier rehosting — a dedicated in-game/lobby rehost button that works with the client, including rehosting someone else's lobby (when they're AFK, or just to duplicate it), so that rehosting is quick. *(Extends the "rehost my last game" stories in A/O.)* +- ⬜ As a **non-host player**, I want to save presets as a client, so that I keep my setups even when I'm not hosting. +- ⬜ As a **host**, I want to load presets without switching maps, so that applying a preset doesn't force a map change. +- ⬜ As a **player**, I want lobby options categorised by mod, so that mod-provided options are grouped clearly. +- ⬜ As a **player**, I want to collapse lobby-option categories, so that I can focus on the ones I care about. +- ⬜ As a **non-host player**, I want to read each option's description, so that I understand what every setting does. +- ⬜ As a **host**, I want the unit-manager bug fixed where presets stop appearing after you disable units manually, so that presets keep working. +- ⬜ As a **host**, I want better unit stats in the unit manager, so that I can judge units when restricting them. +- ⬜ As a **host**, I want faster map/option loading when many maps are installed, so that the lobby isn't slow to open. +- ⬜ As a **player**, I want faster mod-manager loading, so that opening it isn't slow. +- ⬜ As a **modder**, I want a moddable lobby UI, so that the lobby can be extended/customised by mods. +- ⬜ As a **player**, I want mod presets kept separate from lobby-option presets, so that the two don't get entangled. +- ⬜ As a **host**, I want map-specific lobby options to persist between sessions when I rehost the same map, so that I don't reconfigure them each time. +- ⬜ As a **host**, I want last game's mods NOT auto-enabled when the game didn't launch (but kept when it did, for rehosting), so that a failed launch doesn't silently carry mods forward. +- ⬜ As a **player**, I want "disable all sim / UI / all mods" buttons, so that I can clear mods in one click. +- ⬜ As a **player**, I want the mod manager's dependency-related UI updates fixed, so that enabling/disabling reflects dependencies correctly. +- ⬜ As a **developer**, I want mod-dependency management reworked in the lobby frontend so it no longer keys off singular version UUIDs, so that the dependency system is fixed at the source. +- ⬜ As a **player**, I want a button to show where civilians are on map preview. +- ⬜ As a **player**, I want a small team number (and slot?) on the map preview. to show where civilians are on map. +- ⬜ As a **player**, I want the logs of the custom lobby traffic to go to disk. \ No newline at end of file diff --git a/lua/ui/lobby/autolobby.lua b/lua/ui/lobby/autolobby.lua index 407ae7b237a..40428409fb9 100644 --- a/lua/ui/lobby/autolobby.lua +++ b/lua/ui/lobby/autolobby.lua @@ -31,8 +31,8 @@ -- the provided functionality. It now acts as a wrapper for the autolobby controller -- that can be found at: lua\ui\lobby\autolobby\AutolobbyController.lua ----@type UIAutolobbyCommunications | false -local AutolobbyCommunicationsInstance = false +---@type UIAutolobbyInstance | false +local LobbyInstance = false --- Creates the lobby communications, called (indirectly) by the engine to setup the module state. ---@param protocol UILobbyProtocol @@ -40,32 +40,35 @@ local AutolobbyCommunicationsInstance = false ---@param desiredPlayerName string ---@param localPlayerUID UILobbyPeerId ---@param natTraversalProvider any ----@return UIAutolobbyCommunications +---@return UIAutolobbyInstance function CreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider) -- we intentionally do not log the 'natTraversalProvider' parameter as it can cause issues due to being an uninitialized C object LOG("CreateLobby", protocol, localPort, desiredPlayerName, localPlayerUID) - -- create the interface, needs to be done before the lobby is + -- create the model and interface, needs to be done before the lobby is. + -- the model is set up first so the interface subscribes against it, and so + -- a rejoin (which re-runs CreateLobby) starts from fresh, non-stale state. local playerCount = tonumber(GetCommandLineArg("/players", 1)[1]) or 8 + import("/lua/ui/lobby/autolobby/autolobbymodel.lua").SetupSingleton(playerCount) local interface = import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").SetupSingleton(playerCount) -- create the lobby local maxConnections = 16 - AutolobbyCommunicationsInstance = InternalCreateLobby( - import("/lua/ui/lobby/autolobby/autolobbycontroller.lua").AutolobbyCommunications, + LobbyInstance = InternalCreateLobby( + import("/lua/ui/lobby/autolobby/autolobbyinstance.lua").AutolobbyInstance, protocol, localPort, maxConnections, desiredPlayerName, localPlayerUID, natTraversalProvider ) - AutolobbyCommunicationsInstance.LobbyParameters = AutolobbyCommunicationsInstance.LobbyParameters or {} - AutolobbyCommunicationsInstance.LobbyParameters.Protocol = protocol - AutolobbyCommunicationsInstance.LobbyParameters.LocalPort = localPort - AutolobbyCommunicationsInstance.LobbyParameters.MaxConnections = maxConnections - AutolobbyCommunicationsInstance.LobbyParameters.DesiredPlayerName = desiredPlayerName - AutolobbyCommunicationsInstance.LobbyParameters.LocalPlayerPeerId = localPlayerUID - AutolobbyCommunicationsInstance.LobbyParameters.NatTraversalProvider = natTraversalProvider + LobbyInstance.LobbyParameters = LobbyInstance.LobbyParameters or {} + LobbyInstance.LobbyParameters.Protocol = protocol + LobbyInstance.LobbyParameters.LocalPort = localPort + LobbyInstance.LobbyParameters.MaxConnections = maxConnections + LobbyInstance.LobbyParameters.DesiredPlayerName = desiredPlayerName + LobbyInstance.LobbyParameters.LocalPlayerPeerId = localPlayerUID + LobbyInstance.LobbyParameters.NatTraversalProvider = natTraversalProvider - return AutolobbyCommunicationsInstance + return LobbyInstance end --- Instantiates a lobby instance by hosting one. @@ -77,17 +80,19 @@ end function HostGame(gameName, scenarioFileName, singlePlayer) LOG("HostGame", gameName, scenarioFileName, singlePlayer) - if AutolobbyCommunicationsInstance then + if LobbyInstance then - AutolobbyCommunicationsInstance.HostParameters = AutolobbyCommunicationsInstance.HostParameters or {} - AutolobbyCommunicationsInstance.HostParameters.GameName = gameName - AutolobbyCommunicationsInstance.HostParameters.ScenarioFile = scenarioFileName - AutolobbyCommunicationsInstance.HostParameters.SinglePlayer = singlePlayer + LobbyInstance.HostParameters = LobbyInstance.HostParameters or {} + LobbyInstance.HostParameters.GameName = gameName + LobbyInstance.HostParameters.ScenarioFile = scenarioFileName + LobbyInstance.HostParameters.SinglePlayer = singlePlayer - AutolobbyCommunicationsInstance.GameOptions.ScenarioFile = string.gsub(scenarioFileName, - ".v%d%d%d%d_scenario.lua", - "_scenario.lua") - AutolobbyCommunicationsInstance:HostGame() + -- the synced game options live on the model; the scenario observer + -- reacts and the host sees the map preview + local AutolobbyModel = import("/lua/ui/lobby/autolobby/autolobbymodel.lua") + local scenarioFile = string.gsub(scenarioFileName, ".v%d%d%d%d_scenario.lua", "_scenario.lua") --[[@as FileName]] + AutolobbyModel.SetScenarioFile(AutolobbyModel.GetSingleton(), scenarioFile) + LobbyInstance:HostGame() end end @@ -103,13 +108,13 @@ local rejoinTest = false function JoinGame(address, asObserver, playerName, uid) LOG("JoinGame", address, asObserver, playerName, uid) - if AutolobbyCommunicationsInstance then - AutolobbyCommunicationsInstance.JoinParameters = AutolobbyCommunicationsInstance.JoinParameters or {} - AutolobbyCommunicationsInstance.JoinParameters.Address = address - AutolobbyCommunicationsInstance.JoinParameters.AsObserver = asObserver - AutolobbyCommunicationsInstance.JoinParameters.DesiredPlayerName = playerName - AutolobbyCommunicationsInstance.JoinParameters.DesiredPeerId = uid - AutolobbyCommunicationsInstance:JoinGame(address, playerName, uid) + if LobbyInstance then + LobbyInstance.JoinParameters = LobbyInstance.JoinParameters or {} + LobbyInstance.JoinParameters.Address = address + LobbyInstance.JoinParameters.AsObserver = asObserver + LobbyInstance.JoinParameters.DesiredPlayerName = playerName + LobbyInstance.JoinParameters.DesiredPeerId = uid + LobbyInstance:JoinGame(address, playerName, uid) end end @@ -120,8 +125,8 @@ end function ConnectToPeer(addressAndPort, name, uid) LOG("ConnectToPeer", addressAndPort, name, uid) - if AutolobbyCommunicationsInstance then - AutolobbyCommunicationsInstance:ConnectToPeer(addressAndPort, name, uid) + if LobbyInstance then + LobbyInstance:ConnectToPeer(addressAndPort, name, uid) end end @@ -131,7 +136,7 @@ end function DisconnectFromPeer(uid, doNotUpdateView) LOG("DisconnectFromPeer", uid, doNotUpdateView) - if AutolobbyCommunicationsInstance then - AutolobbyCommunicationsInstance:DisconnectFromPeer(uid) + if LobbyInstance then + LobbyInstance:DisconnectFromPeer(uid) end end diff --git a/lua/ui/lobby/autolobby/AutolobbyConnectionMatrix.lua b/lua/ui/lobby/autolobby/AutolobbyConnectionMatrix.lua index 620c6051b81..edbf4c49d96 100644 --- a/lua/ui/lobby/autolobby/AutolobbyConnectionMatrix.lua +++ b/lua/ui/lobby/autolobby/AutolobbyConnectionMatrix.lua @@ -26,18 +26,31 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group local AutolobbyConnectionMatrixDot = import("/lua/ui/lobby/autolobby/autolobbyconnectionmatrixdot.lua") +local AutolobbyModel = import("/lua/ui/lobby/autolobby/autolobbymodel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive ---@class UIAutolobbyConnectionMatrix : Group +---@field Trash TrashBag ---@field PlayerCount number +---@field Border any +---@field Background Bitmap ---@field Elements UIAutolobbyConnectionMatrixDot[][] +---@field ConnectionsObserver LazyVar +---@field StatusesObserver LazyVar +---@field OwnershipObserver LazyVar +---@field IsAliveObserver LazyVar local AutolobbyConnectionMatrix = Class(Group) { ---@param self UIAutolobbyConnectionMatrix ---@param parent Control - __init = function(self, parent, playerCount) + __init = function(self, parent) Group.__init(self, parent, "AutolobbyConnectionMatrix") - self.PlayerCount = playerCount + self.Trash = TrashBag() + + local model = AutolobbyModel.GetSingleton() + self.PlayerCount = model.PlayerCount() self.Border = UIUtil.SurroundWithBorder(self, '/scx_menu/lan-game-lobby/frame/') self.Background = UIUtil.CreateBitmapColor(self, '99000000') @@ -50,6 +63,34 @@ local AutolobbyConnectionMatrix = Class(Group) { self.Elements[y][x] = AutolobbyConnectionMatrixDot.Create(self) end end + + -- hidden until we know of a peer; the observers below reveal it once + -- there is something to show + self:Hide() + + -- subscribe to the model directly: each handler reads its LazyVar + -- (establishing the dependency edge) and feeds the dot grid + self.ConnectionsObserver = self.Trash:Add( + LazyVarDerive(model.Connections, function(connectionsLazy) + self:OnConnectionsChanged(connectionsLazy()) + end)) + self.StatusesObserver = self.Trash:Add( + LazyVarDerive(model.Statuses, function(statusesLazy) + self:OnStatusesChanged(statusesLazy()) + end)) + self.OwnershipObserver = self.Trash:Add( + LazyVarDerive(model.Ownership, function(ownershipLazy) + self:OnOwnershipChanged(ownershipLazy()) + end)) + self.IsAliveObserver = self.Trash:Add( + LazyVarDerive(model.IsAliveStamp, function(stampLazy) + self:OnIsAliveChanged(stampLazy()) + end)) + end, + + ---@param self UIAutolobbyConnectionMatrix + OnDestroy = function(self) + self.Trash:Destroy() end, ---@param self UIAutolobbyConnectionMatrix @@ -118,17 +159,74 @@ local AutolobbyConnectionMatrix = Class(Group) { ---@param self UIAutolobbyConnectionMatrix ---@param id number UpdateIsAliveTimestamp = function(self, id) + -- a StartSpot can fall outside the grid; guard the row lookup + if not self.Elements[id] then + return + end ---@type UIAutolobbyConnectionMatrixDot local dot = self.Elements[id][id] if dot then dot:SetIsAliveTimestamp(GetSystemTimeSeconds()) end end, + + --------------------------------------------------------------------------- + --#region Model observers + + ---@param self UIAutolobbyConnectionMatrix + ---@param connections UIAutolobbyConnections + OnConnectionsChanged = function(self, connections) + if not connections then + return + end + + -- reveal the matrix only once we actually know of a peer; the initial + -- (empty) derivation should not flash an empty grid on screen + if next(AutolobbyModel.GetSingleton().ConnectionMatrix()) then + self:Show() + end + self:UpdateConnections(connections) + end, + + ---@param self UIAutolobbyConnectionMatrix + ---@param statuses UIAutolobbyStatus + OnStatusesChanged = function(self, statuses) + if not statuses then + return + end + + if next(statuses) then + self:Show() + end + self:UpdateStatuses(statuses) + end, + + ---@param self UIAutolobbyConnectionMatrix + ---@param ownership boolean[][] | false + OnOwnershipChanged = function(self, ownership) + if not ownership then + return + end + + self:Show() + self:UpdateOwnership(ownership) + end, + + ---@param self UIAutolobbyConnectionMatrix + ---@param stamp UIAutolobbyAliveStamp | false + OnIsAliveChanged = function(self, stamp) + if not stamp then + return + end + + self:UpdateIsAliveTimestamp(stamp.Index) + end, + + --#endregion } ---@param parent Control ----@param count number ---@return UIAutolobbyConnectionMatrix -Create = function(parent, count) - return AutolobbyConnectionMatrix(parent, count) +Create = function(parent) + return AutolobbyConnectionMatrix(parent) end diff --git a/lua/ui/lobby/autolobby/AutolobbyController.lua b/lua/ui/lobby/autolobby/AutolobbyController.lua index d4d03c19e63..f5ab7888ff7 100644 --- a/lua/ui/lobby/autolobby/AutolobbyController.lua +++ b/lua/ui/lobby/autolobby/AutolobbyController.lua @@ -20,1107 +20,460 @@ --** SOFTWARE. --****************************************************************************************************** -local Utils = import("/lua/system/utils.lua") -local MapUtil = import("/lua/ui/maputil.lua") -local GameColors = import("/lua/gamecolors.lua") - -local MohoLobbyMethods = moho.lobby_methods -local DebugComponent = import("/lua/shared/components/debugcomponent.lua").DebugComponent -local AutolobbyServerCommunicationsComponent = import("/lua/ui/lobby/autolobby/components/autolobbyservercommunicationscomponent.lua") - .AutolobbyServerCommunicationsComponent - -local AutolobbyArgumentsComponent = import("/lua/ui/lobby/autolobby/components/autolobbyarguments.lua").AutolobbyArgumentsComponent - -local AutolobbyMessages = import("/lua/ui/lobby/autolobby/autolobbymessages.lua").AutolobbyMessages - -local AutolobbyEngineStrings = { - -- General info strings - ['Connecting'] = "Connecting to Game", - ['AbortConnect'] = "Abort Connect", - ['TryingToConnect'] = "Connecting...", - ['TimedOut'] = "%s timed out.", - ['TimedOutToHost'] = "Timed out to host.", - ['Ejected'] = "You have been ejected: %s", - ['ConnectionFailed'] = "Connection failed: %s", - ['LaunchFailed'] = "Launch failed: %s", - ['LobbyFull'] = "The game lobby is full.", - - -- Error reasons - ['StartSpots'] = "The map does not support this number of players.", - ['NoConfig'] = "No valid game configurations found.", - ['NoObservers'] = "Observers not allowed.", - ['KickedByHost'] = "Kicked by host.", - ['GameLaunched'] = "Game was launched.", - ['NoLaunchLimbo'] = "No clients allowed in limbo at launch", - ['HostLeft'] = "Host abandoned lobby", - ['LaunchRejected'] = "Some players are using an incompatible client version.", -} - --- associated textures are in `/textures/divisions/ .png` --- Make note of the space, which isn't there for "grandmaster" and "unlisted" divisions - ----@alias Division ----| "bronze" ----| "silver" ----| "gold" ----| "diamond" ----| "master" ----| "grandmaster" ----| "unlisted" - ----@alias Subdivision ----| "I" ----| "II" ----| "III" ----| "IV" ----| "V" ----| "" # when Division is grandmaster or unlisted - ----@class UIAutolobbyPlayer: UILobbyLaunchPlayerConfiguration ----@field StartSpot number ----@field DEV number # Related to rating/divisions ----@field MEAN number # Related to rating/divisions ----@field NG number # Related to rating/divisions ----@field DIV Division # Related to rating/divisions ----@field SUBDIV Subdivision # Related to rating/divisions ----@field PL number # Related to rating/divisions ----@field PlayerClan string - ----@alias UIAutolobbyConnections boolean[][] ----@alias UIAutolobbyStatus UIPeerLaunchStatus[] - ----@class UIAutolobbyParameters ----@field Protocol UILobbyProtocol ----@field LocalPort number ----@field MaxConnections number ----@field DesiredPlayerName string ----@field LocalPlayerPeerId UILobbyPeerId ----@field NatTraversalProvider any - ----@class UIAutolobbyHostParameters ----@field GameName string ----@field ScenarioFile string # path to the _scenario.lua file ----@field SinglePlayer boolean - ----@class UIAutolobbyJoinParameters ----@field Address GPGNetAddress ----@field AsObserver boolean ----@field DesiredPlayerName string ----@field DesiredPeerId UILobbyPeerId - ---- Responsible for the behavior of the automated lobby. ----@class UIAutolobbyCommunications : moho.lobby_methods, DebugComponent, UIAutolobbyServerCommunicationsComponent, UIAutolobbyArgumentsComponent ----@field Trash TrashBag ----@field LocalPeerId UILobbyPeerId # a number that is stringified ----@field LocalPlayerName string # nickname ----@field HostID UILobbyPeerId ----@field PlayerCount number # Originates from the command line ----@field GameMods UILobbyLaunchGameModsConfiguration[] ----@field GameOptions UILobbyLaunchGameOptionsConfiguration # Is synced from the host via `SendData` or `BroadcastData`. ----@field PlayerOptions UIAutolobbyPlayer[] # Is synced from the host via `SendData` or `BroadcastData`. ----@field ConnectionMatrix table # Is synced between players via `EstablishedPeers` ----@field LaunchStatutes table # Is synced between players via `BroadcastData` ----@field LobbyParameters? UIAutolobbyParameters # Used for rejoining functionality ----@field HostParameters? UIAutolobbyHostParameters # Used for rejoining functionality ----@field JoinParameters? UIAutolobbyJoinParameters # Used for rejoining functionality -AutolobbyCommunications = Class(MohoLobbyMethods, AutolobbyServerCommunicationsComponent, AutolobbyArgumentsComponent, DebugComponent) { - - ---@param self UIAutolobbyCommunications - __init = function(self) - self.Trash = TrashBag() - - self.LocalPeerId = "-2" - self.LocalPlayerName = "Charlie" - self.PlayerCount = self:GetCommandLineArgumentNumber("/players", 2) - self.HostID = "-2" - - self.GameMods = {} - self.GameOptions = self:CreateLocalGameOptions() - self.PlayerOptions = {} - self.LaunchStatutes = {} - self.ConnectionMatrix = {} - end, - - ---@param self UIAutolobbyCommunications - __post_init = function(self) - - end, - - --- Creates a table that represents the local player settings. This represents the initial player. It can be edited by the host accordingly. - ---@param self UIAutolobbyCommunications - ---@return UIAutolobbyPlayer - CreateLocalPlayer = function(self) - ---@type UIAutolobbyPlayer - local info = {} - - info.Human = true - info.Civilian = false - - -- determine player name - info.PlayerName = self.LocalPlayerName or self:GetLocalPlayerName() or "player" - - -- retrieve faction - info.Faction = 1 - local factionData = import("/lua/factions.lua") - for index, tbl in factionData.Factions do - if HasCommandLineArg("/" .. tbl.Key) then - info.Faction = index - break - end - end - - -- retrieve team and start spot - info.Team = self:GetCommandLineArgumentNumber("/team", -1) - info.StartSpot = self:GetCommandLineArgumentNumber("/startspot", -1) - - -- determine army color based on start location - info.PlayerColor = GameColors.MapToWarmCold(info.StartSpot) - info.ArmyColor = GameColors.MapToWarmCold(info.StartSpot) - - -- retrieve rating - info.DEV = self:GetCommandLineArgumentNumber("/deviation", 500) - info.MEAN = self:GetCommandLineArgumentNumber("/mean", 1500) - info.NG = self:GetCommandLineArgumentNumber("/numgames", 0) - info.DIV = self:GetCommandLineArgumentString("/division", "") - info.SUBDIV = self:GetCommandLineArgumentString("/subdivision", "") - info.PL = math.floor(info.MEAN - 3 * info.DEV) - info.PlayerClan = self:GetCommandLineArgumentString("/clan", "") - - return info - end, - - --- Creates a table that represents the local game options. - ---@param self UIAutolobbyCommunications - ---@return UILobbyLaunchGameOptionsConfiguration - CreateLocalGameOptions = function(self) - ---@type UILobbyLaunchGameOptionsConfiguration - local options = { - Score = 'no', - TeamSpawn = 'fixed', - TeamLock = 'locked', - Victory = 'demoralization', - Timeouts = '3', - CheatsEnabled = 'false', - CivilianAlliance = 'enemy', - RevealCivilians = 'Yes', - GameSpeed = 'normal', - FogOfWar = 'explored', - UnitCap = '1500', - PrebuiltUnits = 'Off', - Share = 'FullShare', - ShareUnitCap = 'allies', - DisconnectionDelay02 = '90', - DisconnectShare = 'SameAsShare', - DisconnectShareCommanders = 'Explode', - TeamShareOverflow = "enabled", - - -- yep, great - Ranked = true, - Unranked = 'No', - } - - -- process game options from the command line - for name, value in self:GetCommandLineArgumentArray("/gameoptions") do - if name and value then - options[name] = value - else - LOG("Malformed gameoption. ignoring name: " .. repr(name) .. " and value: " .. repr(value)) - end - end +-- Lobby logic for the automated lobby, as a module of free functions. +-- +-- The engine instantiates a `moho.lobby_methods` object — `AutolobbyInstance` +-- (see AutolobbyInstance.lua) — and calls its callbacks. That instance is a thin +-- shell: it forwards the callbacks with real behaviour to the functions here and +-- passes itself as the first argument. This split keeps the engine-ABI surface +-- tiny and makes the logic a plain module that **hot-reloads** (the instance's +-- forwarders resolve through the live module table) and is testable with a mock +-- instance. The running threads are the exception — a forked thread holds the +-- function it started with, so thread edits only take effect on the next lobby. +-- +-- All writes go to `AutolobbyModel` (the reactive singleton the UI observes), +-- through its write helpers so the copy-then-`Set` discipline stays in one place. - return options - end, - - --------------------------------------------------------------------------- - --#region Utilities - - ---@param self UIAutolobbyCommunications - ---@param playerOptions UIAutolobbyPlayer[] - ---@param connectionMatrix table - ---@return UIAutolobbyConnections - CreateConnectionsMatrix = function(self, playerOptions, connectionMatrix) - ---@type UIAutolobbyConnections - local connections = {} - - -- initial setup - for y = 1, self.PlayerCount do - connections[y] = {} - for x = 1, self.PlayerCount do - connections[y][x] = false - end - end +local MapUtil = import("/lua/ui/maputil.lua") +local AutolobbyModel = import("/lua/ui/lobby/autolobby/autolobbymodel.lua") + +------------------------------------------------------------------------------- +--#region Launch-flow computations +-- +-- Pure helpers used by the launch / status threads. They moved here from the +-- model because they are controller logic, not part of the reactive model's +-- derivations (which keep `PeerIdToIndex` / `Create*Matrix` in the model). + +--- Determines the launch status of the local peer. +---@param connectionMatrix table +---@param playerCount number +---@return UIPeerLaunchStatus +function CreateLaunchStatus(connectionMatrix, playerCount) + -- check number of peers + local validPeerCount = playerCount - 1 + if table.getsize(connectionMatrix) < validPeerCount then + return 'Missing local peers' + end + + return 'Ready' +end + +--- Verifies whether we can launch the game. +---@param peerStatus UIAutolobbyStatus +---@param playerCount number +---@return boolean +function CanLaunch(peerStatus, playerCount) + -- check if we know of all peers + if table.getsize(peerStatus) ~= playerCount then + return false + end - -- populate the matrix - for peerId, establishedPeers in connectionMatrix do - for _, peerConnectedToId in establishedPeers do - local peerIdNumber = self:PeerIdToIndex(playerOptions, peerId) - local peerConnectedToIdNumber = self:PeerIdToIndex(playerOptions, peerConnectedToId) - - -- connection works both ways - if peerIdNumber and peerConnectedToIdNumber then - if peerIdNumber > self.PlayerCount or peerConnectedToIdNumber > self.PlayerCount then - self:DebugWarn("Invalid peer id", peerIdNumber, peerConnectedToIdNumber) - else - connections[peerIdNumber][peerConnectedToIdNumber] = true - connections[peerConnectedToIdNumber][peerIdNumber] = true - end - end - end + -- check if all peers are ready for launch + for k, launchStatus in peerStatus do + if launchStatus ~= 'Ready' then + return false end + end - return connections - end, - - ---@param self UIAutolobbyCommunications - ---@param playerOptions UIAutolobbyPlayer[] - ---@param statuses table - ---@return UIAutolobbyStatus - CreateConnectionStatuses = function(self, playerOptions, statuses) - local output = {} - for peerId, launchStatus in statuses do - local peerIdNumber = self:PeerIdToIndex(playerOptions, peerId) - if peerIdNumber then - output[peerIdNumber] = launchStatus - end - end + return true +end - return output - end, - - ---@param self UIAutolobbyCommunications - ---@param playerCount number - ---@param localIndex number - ---@return boolean[][] - CreateOwnershipMatrix = function(self, playerCount, localIndex) - local output = {} - for y = 1, playerCount do - output[y] = {} - for x = 1, playerCount do - output[y][x] = false - end - end +---@param playerOptions UIAutolobbyPlayer[] +---@return table +function CreateRatingsTable(playerOptions) + ---@type table + local allRatings = {} - for k = 1, playerCount do - output[localIndex][k] = true - output[k][localIndex] = true - end - return output - end, - - --- Determines the launch status of the local peer. - ---@param self UIAutolobbyCommunications - ---@param connectionMatrix table - ---@return UIPeerLaunchStatus - CreateLaunchStatus = function(self, connectionMatrix) - -- check number of peers - local validPeerCount = self.PlayerCount - 1 - if table.getsize(connectionMatrix) < validPeerCount then - return 'Missing local peers' + for slot, options in pairs(playerOptions) do + if options.Human and options.PL then + allRatings[options.PlayerName] = options.PL end + end - return 'Ready' - end, - - ---@param self UIAutolobbyCommunications - ---@param playerOptions UIAutolobbyPlayer[] - ---@return table - CreateRatingsTable = function(self, playerOptions) - ---@type table - local allRatings = {} + return allRatings +end - for slot, options in pairs(playerOptions) do - if options.Human and options.PL then - allRatings[options.PlayerName] = options.PL - end - end +---@param playerOptions UIAutolobbyPlayer[] +---@return table +function CreateDivisionsTable(playerOptions) + ---@type table + local allDivisions = {} - return allRatings - end, - - ---@param self UIAutolobbyCommunications - ---@param playerOptions UIAutolobbyPlayer[] - ---@return table - CreateDivisionsTable = function(self, playerOptions) - ---@type table - local allDivisions = {} - - for slot, options in pairs(playerOptions) do - if options.Human and options.PL then - if options.DIV ~= "unlisted" then - local division = options.DIV - if options.SUBDIV and options.SUBDIV ~= "" then - division = division .. ' ' .. options.SUBDIV - end - allDivisions[options.PlayerName] = division + for slot, options in pairs(playerOptions) do + if options.Human and options.PL then + if options.DIV ~= "unlisted" then + local division = options.DIV + if options.SUBDIV and options.SUBDIV ~= "" then + division = division .. ' ' .. options.SUBDIV end + allDivisions[options.PlayerName] = division end end + end - return allDivisions - end, + return allDivisions +end - ---@param self UIAutolobbyCommunications - ---@param playerOptions UIAutolobbyPlayer[] - ---@return table - CreateClanTagsTable = function(self, playerOptions) - local allClanTags = {} +---@param playerOptions UIAutolobbyPlayer[] +---@return table +function CreateClanTagsTable(playerOptions) + local allClanTags = {} - for slot, options in pairs(playerOptions) do - if options.PlayerClan then - allClanTags[options.PlayerName] = options.PlayerClan - end - end - - return allClanTags - end, - - --- Verifies whether we can launch the game. - ---@param self UIAutolobbyCommunications - ---@param peerStatus UIAutolobbyStatus - ---@return boolean - CanLaunch = function(self, peerStatus) - -- check if we know of all peers - if table.getsize(peerStatus) ~= self.PlayerCount then - return false - end - - -- check if all peers are ready for launch - for k, launchStatus in peerStatus do - if launchStatus ~= 'Ready' then - return false - end - end - - return true - end, - - --- Maps a peer id to an index that can be used in the interface. In - --- practice the peer id can be all over the place, ranging from -1 - --- to numbers such as 35240. With this function we map it to a sane - --- index that we can use in the interface. - ---@param self UIAutolobbyCommunications - ---@param playerOptions UIAutolobbyPlayer[] - ---@param peerId UILobbyPeerId - ---@return number | false - PeerIdToIndex = function(self, playerOptions, peerId) - if type(peerId) ~= 'string' then - self:DebugWarn("Invalid peer id", peerId) - return false + for slot, options in pairs(playerOptions) do + if options.PlayerClan then + allClanTags[options.PlayerName] = options.PlayerClan end + end - -- try to find matching player options - if playerOptions then - for k, options in playerOptions do - if options.OwnerID == peerId then - if options.StartSpot then - return options.StartSpot - end - end - end - end + return allClanTags +end - return false - end, - - --- Prefetches a scenario to try and reduce the loading screen time. - ---@param self UIAutolobbyCommunications - ---@param gameOptions UILobbyLaunchGameOptionsConfiguration - ---@param gameMods UILobbyLaunchGameModsConfiguration[] - Prefetch = function(self, gameOptions, gameMods) - local scenarioPath = gameOptions.ScenarioFile - if not scenarioPath then - return - end +--- Prefetches a scenario to try and reduce the loading screen time. +---@param gameOptions UILobbyLaunchGameOptionsConfiguration +---@param gameMods UILobbyLaunchGameModsConfiguration[] +function Prefetch(gameOptions, gameMods) + local scenarioPath = gameOptions.ScenarioFile + if not scenarioPath then + return + end - local scenarioFile = MapUtil.LoadScenario(gameOptions.ScenarioFile) - if not scenarioFile then - -- ??? - return - end + local scenarioFile = MapUtil.LoadScenario(gameOptions.ScenarioFile) + if not scenarioFile then + -- ??? + return + end - PrefetchSession(scenarioFile.map, gameMods, true) - end, + PrefetchSession(scenarioFile.map, gameMods, true) +end - ---@param self UIAutolobbyCommunications - ---@param lobbyParameters UIAutolobbyParameters - ---@param joinParameters UIAutolobbyJoinParameters - Rejoin = function(self, lobbyParameters, joinParameters) - local autolobbyModule = import("/lua/ui/lobby/autolobby.lua") +--#endregion - -- start disposing threads to prevent race conditions - self.Trash:Destroy() +------------------------------------------------------------------------------- +--#region Rejoin - ForkThread( - function() - self:SendLaunchStatusToServer('Rejoining') +---@param instance UIAutolobbyInstance +---@param lobbyParameters UIAutolobbyParameters +---@param joinParameters UIAutolobbyJoinParameters +function Rejoin(instance, lobbyParameters, joinParameters) + local autolobbyModule = import("/lua/ui/lobby/autolobby.lua") - -- prevent race condition on network - WaitSeconds(1.0) + -- start disposing threads to prevent race conditions + instance.Trash:Destroy() - -- inform peers and server that we're rejoining - self:BroadcastData({ Type = "UpdateLaunchStatus", LaunchStatus = 'Rejoining' }) + ForkThread( + function() + instance:SendLaunchStatusToServer('Rejoining') - -- prevent race condition on network - WaitSeconds(1.0) + -- prevent race condition on network + WaitSeconds(1.0) - -- create a new lobby - self:Destroy() + -- inform peers and server that we're rejoining + instance:BroadcastData({ Type = "UpdateLaunchStatus", LaunchStatus = 'Rejoining' }) - -- prevent race conditions - WaitSeconds(1.0) - local newLobby = autolobbyModule.CreateLobby( - lobbyParameters.Protocol, - lobbyParameters.LocalPort, - lobbyParameters.DesiredPlayerName, - lobbyParameters.LocalPlayerPeerId, - lobbyParameters.NatTraversalProvider - ) + -- prevent race condition on network + WaitSeconds(1.0) - -- wait a bit before we join - WaitSeconds(1.0) + -- create a new lobby + instance:Destroy() - autolobbyModule.JoinGame(joinParameters.Address, joinParameters.AsObserver, - joinParameters.DesiredPlayerName, - joinParameters.DesiredPeerId) - end - ) - end, + -- prevent race conditions + WaitSeconds(1.0) + autolobbyModule.CreateLobby( + lobbyParameters.Protocol, + lobbyParameters.LocalPort, + lobbyParameters.DesiredPlayerName, + lobbyParameters.LocalPlayerPeerId, + lobbyParameters.NatTraversalProvider + ) + + -- wait a bit before we join + WaitSeconds(1.0) + autolobbyModule.JoinGame(joinParameters.Address, joinParameters.AsObserver, + joinParameters.DesiredPlayerName, + joinParameters.DesiredPeerId) + end + ) +end - --------------------------------------------------------------------------- - --#region Threads +--#endregion - ---@param self UIAutolobbyCommunications - CheckForRejoinThread = function(self) +------------------------------------------------------------------------------- +--#region Threads - local rejoinThreshold = 3 - local rejoinCount = 0 +---@param instance UIAutolobbyInstance +function CheckForRejoinThread(instance) - while not IsDestroyed(self) do + local rejoinThreshold = 3 + local rejoinCount = 0 - -- check if we're ready to launch - if self.LaunchStatutes[self.LocalPeerId] ~= 'Ready' then + while not IsDestroyed(instance) do - -- if we're not, check the status of peers - local onePeerIsRejoining = false - local onePeerIsReady = false - for k, launchStatus in self.LaunchStatutes do - onePeerIsReady = onePeerIsReady or (launchStatus == 'Ready') - onePeerIsRejoining = onePeerIsRejoining or (launchStatus == 'Rejoining') - end + local model = AutolobbyModel.GetSingleton() + local launchStatutes = model.LaunchStatutes() - if onePeerIsReady then - rejoinCount = rejoinCount + 1 - end + -- check if we're ready to launch + if launchStatutes[model.LocalPeerId()] ~= 'Ready' then - -- try to not rejoin at the same time - if onePeerIsRejoining then - rejoinCount = 0 - end - else - rejoinCount = 0 + -- if we're not, check the status of peers + local onePeerIsRejoining = false + local onePeerIsReady = false + for k, launchStatus in launchStatutes do + onePeerIsReady = onePeerIsReady or (launchStatus == 'Ready') + onePeerIsRejoining = onePeerIsRejoining or (launchStatus == 'Rejoining') end - -- if we reached the threshold, time to rejoin! - if rejoinCount > rejoinThreshold then - self:Rejoin(self.LobbyParameters, self.JoinParameters) + if onePeerIsReady then + rejoinCount = rejoinCount + 1 end - WaitSeconds(1.0 + 1 * Random()) - end - end, - - --- Passes the local launch status to all peers. - ---@param self UIAutolobbyCommunications - ShareLaunchStatusThread = function(self) - while not IsDestroyed(self) do - local launchStatus = self:CreateLaunchStatus(self.ConnectionMatrix) - self.LaunchStatutes[self.LocalPeerId] = launchStatus - - -- update peers - self:BroadcastData({ Type = "UpdateLaunchStatus", LaunchStatus = launchStatus }) - - -- update server - self:SendLaunchStatusToServer(launchStatus) - - -- update UI for launch statuses - import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").GetSingleton() - :UpdateLaunchStatuses(self:CreateConnectionStatuses(self.PlayerOptions, self.LaunchStatutes)) - - WaitSeconds(2.0) - end - end, - - ---@param self UIAutolobbyCommunications - LaunchThread = function(self) - while not IsDestroyed(self) do - if self:CanLaunch(self.LaunchStatutes) then - - WaitSeconds(5.0) - if (not IsDestroyed(self)) and self:CanLaunch(self.LaunchStatutes) then - - -- Army numbers need to be calculated: they are numbered incrementally in slot order. - local slots = {} - for slotIndex, _ in pairs(self.PlayerOptions) do - table.insert(slots, slotIndex) - end - table.sort(slots) - - -- send player options to the server - for armyIndex, slotIndex in ipairs(slots) do - local playerOptions = self.PlayerOptions[slotIndex] - local ownerId = playerOptions.OwnerID - self:SendPlayerOptionToServer(ownerId, 'Team', playerOptions.Team) - self:SendPlayerOptionToServer(ownerId, 'Army', armyIndex) - self:SendPlayerOptionToServer(ownerId, 'StartSpot', playerOptions.StartSpot) - self:SendPlayerOptionToServer(ownerId, 'Faction', playerOptions.Faction) - end - - -- tuck them into the game options. By all means a hack, but - -- this way they are available in both the sim and the UI - self.GameOptions.Ratings = self:CreateRatingsTable(self.PlayerOptions) - self.GameOptions.Divisions = self:CreateDivisionsTable(self.PlayerOptions) - self.GameOptions.ClanTags = self:CreateClanTagsTable(self.PlayerOptions) - - -- create game configuration - local gameConfiguration = { - GameMods = self.GameMods, - GameOptions = self.GameOptions, - PlayerOptions = self.PlayerOptions, - Observers = {}, - } - - -- send it to all players and tell them to launch with the configuration - self:BroadcastData({ Type = "Launch", GameConfig = gameConfiguration }) - self:LaunchGame(gameConfiguration) - end + -- try to not rejoin at the same time + if onePeerIsRejoining then + rejoinCount = 0 end - - WaitSeconds(1.0) - end - end, - - --#endregion - - --------------------------------------------------------------------------- - --#region Message Handlers - -- - -- All the message functions in this section run asynchroniously on each - -- client. They are responsible for processing the data received from - -- other peers. Validation is done in `AutolobbyMessages` before the message - -- processed. - - ---@param self UIAutolobbyCommunications - ---@param data UIAutolobbyAddPlayerMessage - ProcessAddPlayerMessage = function(self, data) - ---@type UIAutolobbyPlayer - local playerOptions = data.PlayerOptions - - -- override some data - playerOptions.OwnerID = data.SenderID - playerOptions.PlayerName = self:MakeValidPlayerName(playerOptions.OwnerID, playerOptions.PlayerName) - - -- TODO: verify that the StartSpot is not occupied - -- put the player where it belongs - self.PlayerOptions[playerOptions.StartSpot] = playerOptions - - -- sync game options with the connected peer - self:SendData(data.SenderID, { Type = "UpdateGameOptions", GameOptions = self.GameOptions }) - - -- sync player options to all connected peers - self:BroadcastData({ Type = "UpdatePlayerOptions", PlayerOptions = self.PlayerOptions }) - - -- update UI for player options - import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").GetSingleton() - :UpdateScenario(self.GameOptions.ScenarioFile, self.PlayerOptions) - - local localIndex = self:PeerIdToIndex(self.PlayerOptions, self.LocalPeerId) - if localIndex then - local ownershipMatrix = self:CreateOwnershipMatrix(self.PlayerCount, localIndex) - import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").GetSingleton() - :UpdateOwnership(ownershipMatrix) - end - end, - - ---@param self UIAutolobbyCommunications - ---@param data UIAutolobbyUpdatePlayerOptionsMessage - ProcessUpdatePlayerOptionsMessage = function(self, data) - self.PlayerOptions = data.PlayerOptions - - -- update UI for player options - import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").GetSingleton() - :UpdateScenario(self.GameOptions.ScenarioFile, self.PlayerOptions) - - local localIndex = self:PeerIdToIndex(self.PlayerOptions, self.LocalPeerId) - if localIndex then - local ownershipMatrix = self:CreateOwnershipMatrix(self.PlayerCount, localIndex) - -- update UI for player options - import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").GetSingleton() - :UpdateOwnership(ownershipMatrix) - end - end, - - ---@param self UIAutolobbyCommunications - ---@param data UIAutolobbyUpdateGameOptionsMessage - ProcessUpdateGameOptionsMessage = function(self, data) - self.GameOptions = data.GameOptions - - self:Prefetch(self.GameOptions, self.GameMods) - - -- update UI for game options - import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").GetSingleton() - :UpdateScenario(self.GameOptions.ScenarioFile, self.PlayerOptions) - end, - - ---@param self UIAutolobbyCommunications - ---@param data UIAutolobbyLaunchMessage - ProcessLaunchMessage = function(self, data) - self:LaunchGame(data.GameConfig) - end, - - ---@param self UIAutolobbyCommunications - ---@param data UIAutolobbyUpdateLaunchStatusMessage - ProcessUpdateLaunchStatusMessage = function(self, data) - self.LaunchStatutes[data.SenderID] = data.LaunchStatus - - -- update UI for launch statuses - import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").GetSingleton() - :UpdateLaunchStatuses(self:CreateConnectionStatuses(self.PlayerOptions, self.LaunchStatutes)) - end, - - --#endregion - - --------------------------------------------------------------------------- - --#region Engine interface - - --- Broadcasts data to all (connected) peers. - ---@param self UIAutolobbyCommunications - ---@param data UILobbyData - BroadcastData = function(self, data) - self:DebugSpew("BroadcastData", data.Type) - - -- validate message type - local message = AutolobbyMessages[data.Type] - if not message then - self:DebugWarn("Blocked broadcasting unknown message type", data.Type) - return - end - - -- validate message format - if not message.Validate(self, data) then - self:DebugWarn("Blocked broadcasting malformed message of type", data.Type) - return - end - - return MohoLobbyMethods.BroadcastData(self, data) - end, - - --- (Re)Connects to a peer. - ---@param self any - ---@param address any - ---@param name any - ---@param peerId UILobbyPeerId - ---@return nil - ConnectToPeer = function(self, address, name, peerId) - self:DebugSpew("ConnectToPeer", address, name, peerId) - return MohoLobbyMethods.ConnectToPeer(self, address, name, peerId) - end, - - --- ??? - ---@param self UIAutolobbyCommunications - ---@return nil - DebugDump = function(self) - self:DebugSpew("DebugDump") - return MohoLobbyMethods.DebugDump(self) - end, - - --- Destroys the C-object and all the (UI) entities in the trash bag. - ---@param self UIAutolobbyCommunications - ---@return nil - Destroy = function(self) - self:DebugSpew("Destroy") - - self.Trash:Destroy() - return MohoLobbyMethods.Destroy(self) - end, - - --- Disconnects from a peer. - --- See also `ConnectToPeer` to connect - ---@param self UIAutolobbyCommunications - ---@param peerId UILobbyPeerId - ---@return nil - DisconnectFromPeer = function(self, peerId) - self:DebugSpew("DisconnectFromPeer", peerId) - - return MohoLobbyMethods.DisconnectFromPeer(self, peerId) - end, - - --- Ejects a peer from the lobby. - ---@param self UIAutolobbyCommunications - ---@param peerId UILobbyPeerId - ---@param reason string - ---@return nil - EjectPeer = function(self, peerId, reason) - self:DebugSpew("EjectPeer", peerId, reason) - return MohoLobbyMethods.EjectPeer(self, peerId, reason) - end, - - --- Retrieves the local identifier. - ---@param self UIAutolobbyCommunications - ---@return UILobbyPeerId - GetLocalPlayerID = function(self) - self:DebugSpew("GetLocalPlayerID") - return MohoLobbyMethods.GetLocalPlayerID(self) - end, - - --- Retrieves the local name. Note that this name can be overwritten by the host via `MakeValidPlayerName` - ---@param self UIAutolobbyCommunications - ---@return string - GetLocalPlayerName = function(self) - self:DebugSpew("GetLocalPlayerName") - return MohoLobbyMethods.GetLocalPlayerName(self) - end, - - --- Retrieves the local port. - ---@param self any - ---@return number|nil - GetLocalPort = function(self) - self:DebugSpew("GetLocalPort") - return MohoLobbyMethods.GetLocalPort(self) - end, - - --- Retrieves information about a peer. See `GetPeers` to get the same information for all connected peers. - ---@param self UIAutolobbyCommunications - ---@param peerId UILobbyPeerId - ---@return Peer - GetPeer = function(self, peerId) - self:DebugSpew("GetPeer", peerId) - return MohoLobbyMethods.GetPeer(self, peerId) - end, - - --- Retrieves information about all connected peers. See `GetPeer` to get information for a specific peer. - ---@param self UIAutolobbyCommunications - GetPeers = function(self) - -- self:DebugSpew("GetPeers") - return MohoLobbyMethods.GetPeers(self) - end, - - --- Transforms the lobby to be discoveryable and joinable for other players. - ---@param self UIAutolobbyCommunications - ---@return nil - HostGame = function(self) - self:DebugSpew("HostGame") - return MohoLobbyMethods.HostGame(self) - end, - - --- Retrieves whether the local client is the host. - ---@param self any - ---@return boolean - IsHost = function(self) - self:DebugSpew("IsHost") - return MohoLobbyMethods.IsHost(self) - end, - - --- Join a lobby that is set to be a host. - ---@param self UIAutolobbyCommunications - ---@param address GPGNetAddress - ---@param remotePlayerName string - ---@param remotePlayerPeerId UILobbyPeerId - ---@return nil - JoinGame = function(self, address, remotePlayerName, remotePlayerPeerId) - self:DebugSpew("JoinGame", address, remotePlayerName, remotePlayerPeerId) - return MohoLobbyMethods.JoinGame(self, address, remotePlayerName, remotePlayerPeerId) - end, - - --- Launches the game for the local client. The game configuration that is passed in should originate from the host. - ---@param self UIAutolobbyCommunications - ---@param gameConfig UILobbyLaunchConfiguration - ---@return nil - LaunchGame = function(self, gameConfig) - self:DebugSpew("LaunchGame") - self:DebugSpew(reprs(gameConfig, { depth = 10 })) - - return MohoLobbyMethods.LaunchGame(self, gameConfig) - end, - - --- Returns a valid game name. - ---@param self UIAutolobbyCommunications - ---@param name string - ---@return string - MakeValidGameName = function(self, name) - - self:DebugSpew("MakeValidGameName", name) - return MohoLobbyMethods.MakeValidGameName(self, name) - end, - - --- Returns a valid player name. - ---@param self UIAutolobbyCommunications - ---@param peerId UILobbyPeerId - ---@param name string - ---@return string - MakeValidPlayerName = function(self, peerId, name) - self:DebugSpew("MakeValidPlayerName", peerId, name) - return MohoLobbyMethods.MakeValidPlayerName(self, peerId, name) - end, - - ---@param self UIAutolobbyCommunications - ---@param peerId UILobbyPeerId - ---@param data UILobbyData - ---@return nil - SendData = function(self, peerId, data) - self:DebugSpew("SendData", peerId, data.Type) - - -- validate message type - local message = AutolobbyMessages[data.Type] - if not message then - self:DebugWarn("Blocked sending unknown message type", data.Type, "to", peerId) - return + else + rejoinCount = 0 end - -- validate message type - if not message.Validate(self, data) then - self:DebugWarn("Blocked sending malformed message of type", data.Type, "to", peerId) - return + -- if we reached the threshold, time to rejoin! + if rejoinCount > rejoinThreshold then + Rejoin(instance, instance.LobbyParameters, instance.JoinParameters) end - return MohoLobbyMethods.SendData(self, peerId, data) - end, - - --#endregion - - --------------------------------------------------------------------------- - --#region Connection events - - --- Called by the engine as we're trying to host a lobby. - ---@param self UIAutolobbyCommunications - Hosting = function(self) - self:DebugSpew("Hosting") - - self.LocalPeerId = self:GetLocalPlayerID() - self.LocalPlayerName = self:GetLocalPlayerName() - self.HostID = self:GetLocalPlayerID() - - -- give ourself a seat at the table - local hostPlayerOptions = self:CreateLocalPlayer() - hostPlayerOptions.OwnerID = self.LocalPeerId - hostPlayerOptions.PlayerName = self:MakeValidPlayerName(self.LocalPeerId, self.LocalPlayerName) - self.PlayerOptions[hostPlayerOptions.StartSpot] = hostPlayerOptions - - -- occasionally send data over the network to create pings on screen - self.Trash:Add(ForkThread(self.ShareLaunchStatusThread, self)) - self.Trash:Add(ForkThread(self.LaunchThread, self)) - - -- start prefetching the scenario - self:Prefetch(self.GameOptions, self.GameMods) - - self:SendLaunchStatusToServer('Hosting') - - -- update UI for game options - import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").GetSingleton() - :UpdateScenario(self.GameOptions.ScenarioFile, self.PlayerOptions) - end, - - --- Called by the engine as we're trying to join a lobby. - ---@param self UIAutolobbyCommunications - Connecting = function(self) - self:DebugSpew("Connecting") - self:SendLaunchStatusToServer('Connecting') - end, - - --- Called by the engine when the connection fails. - ---@param self UIAutolobbyCommunications - ---@param reason string # reason for connection failure, populated by the engine - ConnectionFailed = function(self, reason) - self:DebugSpew("ConnectionFailed", reason) - - -- try to rejoin - self:Rejoin(self.LobbyParameters, self.JoinParameters) - end, - - --- Called by the engine when the connection succeeds with the host. - ---@param self UIAutolobbyCommunications - ---@param localPeerId UILobbyPeerId - ---@param hostPeerId string - ConnectionToHostEstablished = function(self, localPeerId, newLocalName, hostPeerId) - self:DebugSpew("ConnectionToHostEstablished", localPeerId, newLocalName, hostPeerId) - self.LocalPlayerName = newLocalName - self.LocalPeerId = localPeerId - self.HostID = hostPeerId - - -- occasionally send data over the network to create pings on screen - self.Trash:Add(ForkThread(self.ShareLaunchStatusThread, self)) - -- self.Trash:Add(ForkThread(self.CheckForRejoinThread, self)) -- disabled, for now - - self:SendData(self.HostID, { Type = "AddPlayer", PlayerOptions = self:CreateLocalPlayer() }) - end, - - --- Called by the engine when a peer establishes a connection. - ---@param self UIAutolobbyCommunications - ---@param peerId UILobbyPeerId - ---@param peerConnectedTo UILobbyPeerId[] # all established conenctions for the given player - EstablishedPeers = function(self, peerId, peerConnectedTo) - self:DebugSpew("EstablishedPeers", peerId, reprs(peerConnectedTo)) + WaitSeconds(1.0 + 1 * Random()) + end +end - -- update server - self:SendEstablishedPeer(peerId) - - self.LaunchStatutes[peerId] = self.LaunchStatutes[peerId] or 'Unknown' - -- update UI for launch statuses - import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").GetSingleton() - :UpdateLaunchStatuses(self:CreateConnectionStatuses(self.PlayerOptions, self.LaunchStatutes)) - - -- update the matrix and the UI - self.ConnectionMatrix[peerId] = peerConnectedTo - local connections = self:CreateConnectionsMatrix(self.PlayerOptions, self.ConnectionMatrix) - import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").GetSingleton() - :UpdateConnections(connections) - end, - - --#endregion - - --------------------------------------------------------------------------- - --#region Lobby events - - --- Called by the engine when you are ejected from a lobby. - ---@param self UIAutolobbyCommunications - ---@param reason string # reason for disconnection, populated by the host - Ejected = function(self, reason) - self:DebugSpew("Ejected", reason) - self:SendLaunchStatusToServer('Ejected') - end, - - --- ??? - ---@param self UIAutolobbyCommunications - ---@param text string - SystemMessage = function(self, text) - self:DebugSpew("SystemMessage", text) - end, - - --- Called by the engine when we receive data from other players. There is no checking to see if the data is legitimate, these need to be done in Lua. - --- - --- Data can be send via `BroadcastData` and/or `SendData`. - ---@param self UIAutolobbyCommunications - ---@param data UILobbyReceivedMessage - DataReceived = function(self, data) - -- make it more convenient to debug malicious traffic - SPEW(string.format("Received data of type %s from %s (%s)", tostring(data.Type), tostring(data.SenderID), tostring(data.SenderName))) - - -- signal UI that we received something - local peerIndex = self:PeerIdToIndex(self.PlayerOptions, data.SenderID) - if peerIndex then - import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").GetSingleton() - :UpdateIsAliveStamp(peerIndex) - end +--- Passes the local launch status to all peers. +---@param instance UIAutolobbyInstance +function ShareLaunchStatusThread(instance) + while not IsDestroyed(instance) do + local model = AutolobbyModel.GetSingleton() + local launchStatus = CreateLaunchStatus(model.ConnectionMatrix(), model.PlayerCount()) - -- validate message type - local message = AutolobbyMessages[data.Type] - if not message then - self:DebugWarn("Ignoring unknown message type", data.Type, "from", data.SenderID) - return - end - - -- validate message data - if not message.Validate(self, data) then - self:DebugWarn("Ignoring malformed message of type", data.Type, "from", data.SenderID) - return - end + AutolobbyModel.SetPeerStatus(model, model.LocalPeerId(), launchStatus) - -- validate message source - if not message.Accept(self, data) then - self:DebugWarn("Message rejected: ", data.Type) - return - end + -- update peers + instance:BroadcastData({ Type = "UpdateLaunchStatus", LaunchStatus = launchStatus }) - -- handle the message - message.Handler(self, data) - end, - - --- Called by the engine when the game configuration is requested by the discovery service. - ---@param self UIAutolobbyCommunications - GameConfigRequested = function(self) - self:DebugSpew("GameConfigRequested") - end, - - --- Called by the engine when a peer disconnects. - ---@param self UIAutolobbyCommunications - ---@param peerName string - ---@param peerId UILobbyPeerId - PeerDisconnected = function(self, peerName, peerId) - self:DebugSpew("PeerDisconnected", peerName, peerId) - self:SendDisconnectedPeer(peerId) - end, - - --- Called by the engine when the game is launched. - ---@param self UIAutolobbyCommunications - GameLaunched = function(self) - self:DebugSpew("GameLaunched") - - -- clear out the interface - import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").GetSingleton():Destroy() - - -- destroy ourselves, the game takes over the management of peers - self:Destroy() - - self:SendGameStateToServer('Launching') - end, - - --- Called by the engine when the launch failed. - ---@param self UIAutolobbyCommunications - ---@param reasonKey string - LaunchFailed = function(self, reasonKey) - self:DebugSpew("LaunchFailed", LOC(reasonKey)) - self:SendLaunchStatusToServer('Failed') - end, - - --#endregion - - --#region Debugging - - ---@param self UIAutolobbyCommunications - ---@param ... any - DebugSpew = function(self, ...) - if not self.EnabledSpewing then - return - end + -- update server + instance:SendLaunchStatusToServer(launchStatus) - SPEW("Autolobby communications", unpack(arg)) - end, + WaitSeconds(2.0) + end +end +---@param instance UIAutolobbyInstance +function LaunchThread(instance) + while not IsDestroyed(instance) do + local model = AutolobbyModel.GetSingleton() + if CanLaunch(model.LaunchStatutes(), model.PlayerCount()) then - ---@param self UIAutolobbyCommunications - ---@param ... any - DebugLog = function(self, ...) - if not self.EnabledLogging then - return - end + WaitSeconds(5.0) + if (not IsDestroyed(instance)) and CanLaunch(model.LaunchStatutes(), model.PlayerCount()) then - LOG("Autolobby communications", unpack(arg)) - end, + local playerOptions = model.PlayerOptions() - ---@param self UIAutolobbyCommunications - ---@param ... any - DebugWarn = function(self, ...) - if not self.EnabledWarnings then - return - end + -- Army numbers need to be calculated: they are numbered incrementally in slot order. + local slots = {} + for slotIndex, _ in pairs(playerOptions) do + table.insert(slots, slotIndex) + end + table.sort(slots) + + -- send player options to the server + for armyIndex, slotIndex in ipairs(slots) do + local options = playerOptions[slotIndex] + local ownerId = options.OwnerID + instance:SendPlayerOptionToServer(ownerId, 'Team', options.Team) + instance:SendPlayerOptionToServer(ownerId, 'Army', armyIndex) + instance:SendPlayerOptionToServer(ownerId, 'StartSpot', options.StartSpot) + instance:SendPlayerOptionToServer(ownerId, 'Faction', options.Faction) + end - WARN("Autolobby communications", unpack(arg)) - end, + -- tuck the rating / division / clan tables into the game + -- options. By all means a hack, but this way they are + -- available in both the sim and the UI + local gameOptions = AutolobbyModel.StampLaunchTables( + model, + CreateRatingsTable(playerOptions), + CreateDivisionsTable(playerOptions), + CreateClanTagsTable(playerOptions) + ) - ---@param self UIAutolobbyCommunications - ---@param ... any - DebugError = function(self, ...) - if not self.EnabledErrors then - return + -- create game configuration + local gameConfiguration = { + GameMods = model.GameMods(), + GameOptions = gameOptions, + PlayerOptions = playerOptions, + Observers = {}, + } + + -- send it to all players and tell them to launch with the configuration + instance:BroadcastData({ Type = "Launch", GameConfig = gameConfiguration }) + instance:LaunchGame(gameConfiguration) + end end - local message = "Autolobby communications" - for _, arg in ipairs(arg) do - message = message .. "\t" .. tostring(arg) - end + WaitSeconds(1.0) + end +end - error(message) - end, +--#endregion - --#endregion -} +------------------------------------------------------------------------------- +--#region Message handlers +-- +-- Invoked by `AutolobbyMessages..Handler` after the message has been +-- validated and accepted. They run asynchronously on each client. + +---@param instance UIAutolobbyInstance +---@param data UIAutolobbyAddPlayerMessage +function ProcessAddPlayerMessage(instance, data) + ---@type UIAutolobbyPlayer + local playerOptions = data.PlayerOptions + + -- override some data + playerOptions.OwnerID = data.SenderID + playerOptions.PlayerName = instance:MakeValidPlayerName(playerOptions.OwnerID, playerOptions.PlayerName) + + local model = AutolobbyModel.GetSingleton() + + -- TODO: verify that the StartSpot is not occupied + -- put the player where it belongs + AutolobbyModel.SetPlayer(model, playerOptions.StartSpot, playerOptions) + + -- sync game options with the connected peer + instance:SendData(data.SenderID, { Type = "UpdateGameOptions", GameOptions = model.GameOptions() }) + + -- sync player options to all connected peers + instance:BroadcastData({ Type = "UpdatePlayerOptions", PlayerOptions = model.PlayerOptions() }) + + -- the scenario + ownership observers react to the PlayerOptions change +end + +---@param instance UIAutolobbyInstance +---@param data UIAutolobbyUpdatePlayerOptionsMessage +function ProcessUpdatePlayerOptionsMessage(instance, data) + -- a fresh table straight off the network; replace wholesale. The scenario + + -- ownership observers react to the change. + AutolobbyModel.GetSingleton().PlayerOptions:Set(data.PlayerOptions) +end + +---@param instance UIAutolobbyInstance +---@param data UIAutolobbyUpdateGameOptionsMessage +function ProcessUpdateGameOptionsMessage(instance, data) + local model = AutolobbyModel.GetSingleton() + model.GameOptions:Set(data.GameOptions) + + Prefetch(model.GameOptions(), model.GameMods()) + + -- the scenario observer reacts to the GameOptions change +end + +---@param instance UIAutolobbyInstance +---@param data UIAutolobbyLaunchMessage +function ProcessLaunchMessage(instance, data) + instance:LaunchGame(data.GameConfig) +end + +---@param instance UIAutolobbyInstance +---@param data UIAutolobbyUpdateLaunchStatusMessage +function ProcessUpdateLaunchStatusMessage(instance, data) + -- the matrix' Statuses observer reacts to the LaunchStatutes change + AutolobbyModel.SetPeerStatus(AutolobbyModel.GetSingleton(), data.SenderID, data.LaunchStatus) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Connection events +-- +-- The behaviour behind the engine callbacks of the same name. The instance's +-- callbacks forward here, passing themselves as `instance`. + +--- Called as we're trying to host a lobby. +---@param instance UIAutolobbyInstance +function OnHosting(instance) + local model = AutolobbyModel.GetSingleton() + + local localPeerId = instance:GetLocalPlayerID() + model.LocalPeerId:Set(localPeerId) + instance.LocalPlayerName = instance:GetLocalPlayerName() + instance.HostID = localPeerId + + -- give ourself a seat at the table + local hostPlayerOptions = instance:CreateLocalPlayer() + hostPlayerOptions.OwnerID = localPeerId + hostPlayerOptions.PlayerName = instance:MakeValidPlayerName(localPeerId, instance.LocalPlayerName) + AutolobbyModel.SetPlayer(model, hostPlayerOptions.StartSpot, hostPlayerOptions) + + -- occasionally send data over the network to create pings on screen + instance.Trash:Add(ForkThread(ShareLaunchStatusThread, instance)) + instance.Trash:Add(ForkThread(LaunchThread, instance)) + + -- start prefetching the scenario + Prefetch(model.GameOptions(), model.GameMods()) + + instance:SendLaunchStatusToServer('Hosting') + + -- the scenario observer reacts to the PlayerOptions change +end + +--- Called when the connection succeeds with the host. +---@param instance UIAutolobbyInstance +---@param localPeerId UILobbyPeerId +---@param newLocalName string +---@param hostPeerId string +function OnConnectionToHostEstablished(instance, localPeerId, newLocalName, hostPeerId) + instance.LocalPlayerName = newLocalName + AutolobbyModel.GetSingleton().LocalPeerId:Set(localPeerId) + instance.HostID = hostPeerId + + -- occasionally send data over the network to create pings on screen + instance.Trash:Add(ForkThread(ShareLaunchStatusThread, instance)) + -- instance.Trash:Add(ForkThread(CheckForRejoinThread, instance)) -- disabled, for now + + instance:SendData(instance.HostID, { Type = "AddPlayer", PlayerOptions = instance:CreateLocalPlayer() }) +end + +--- Called when a peer establishes a connection. +---@param instance UIAutolobbyInstance +---@param peerId UILobbyPeerId +---@param peerConnectedTo UILobbyPeerId[] # all established connections for the given player +function OnEstablishedPeers(instance, peerId, peerConnectedTo) + -- update server + instance:SendEstablishedPeer(peerId) + + local model = AutolobbyModel.GetSingleton() + + -- seed an initial status for the peer and record its connections; the + -- matrix' Statuses / Connections observers react to the changes + AutolobbyModel.EnsurePeerStatus(model, peerId, 'Unknown') + AutolobbyModel.SetPeerConnections(model, peerId, peerConnectedTo) +end + +--- Called when the connection fails. +---@param instance UIAutolobbyInstance +function OnConnectionFailed(instance) + -- try to rejoin + Rejoin(instance, instance.LobbyParameters, instance.JoinParameters) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-imports this module after a couple of frames so the +--- instance's forwarders resolve to the fresh functions. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/autolobby/AutolobbyInstance.lua b/lua/ui/lobby/autolobby/AutolobbyInstance.lua new file mode 100644 index 00000000000..5b4de09810a --- /dev/null +++ b/lua/ui/lobby/autolobby/AutolobbyInstance.lua @@ -0,0 +1,617 @@ +--****************************************************************************************************** +--** Copyright (c) 2024 Willem 'Jip' Wijnia +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The `moho.lobby_methods` object the engine instantiates (via `InternalCreateLobby` +-- in autolobby.lua) and whose callbacks it calls. It is a thin shell: +-- +-- - Engine-ABI wrappers (BroadcastData / SendData / ConnectToPeer / ...) stay here +-- because they override `moho` methods and add validation / debug spew. +-- - Command-line / engine-coupled creators (CreateLocalPlayer / CreateLocalGameOptions) +-- stay here because they lean on the mixed-in argument component. +-- - Callbacks that carry real behaviour forward to `AutolobbyController`, passing +-- `self`. That keeps the logic in a hot-reloadable, testable free-function module. +-- +-- This object does NOT hot-reload — the live C object keeps its bound methods and +-- threads across a reload of this file, so edits here only take effect on the next +-- `CreateLobby`. Keep it thin; put behaviour in the controller. + +local GameColors = import("/lua/gamecolors.lua") + +local MohoLobbyMethods = moho.lobby_methods +local DebugComponent = import("/lua/shared/components/debugcomponent.lua").DebugComponent +local AutolobbyServerCommunicationsComponent = import("/lua/ui/lobby/autolobby/components/autolobbyservercommunicationscomponent.lua") + .AutolobbyServerCommunicationsComponent + +local AutolobbyArgumentsComponent = import("/lua/ui/lobby/autolobby/components/autolobbyarguments.lua").AutolobbyArgumentsComponent + +local AutolobbyMessages = import("/lua/ui/lobby/autolobby/autolobbymessages.lua").AutolobbyMessages + +local AutolobbyModel = import("/lua/ui/lobby/autolobby/autolobbymodel.lua") +local AutolobbyController = import("/lua/ui/lobby/autolobby/autolobbycontroller.lua") + +-- associated textures are in `/textures/divisions/ .png` +-- Make note of the space, which isn't there for "grandmaster" and "unlisted" divisions + +---@alias Division +---| "bronze" +---| "silver" +---| "gold" +---| "diamond" +---| "master" +---| "grandmaster" +---| "unlisted" + +---@alias Subdivision +---| "I" +---| "II" +---| "III" +---| "IV" +---| "V" +---| "" # when Division is grandmaster or unlisted + +---@class UIAutolobbyPlayer: UILobbyLaunchPlayerConfiguration +---@field StartSpot number +---@field DEV number # Related to rating/divisions +---@field MEAN number # Related to rating/divisions +---@field NG number # Related to rating/divisions +---@field DIV Division # Related to rating/divisions +---@field SUBDIV Subdivision # Related to rating/divisions +---@field PL number # Related to rating/divisions +---@field PlayerClan string + +---@alias UIAutolobbyConnections boolean[][] +---@alias UIAutolobbyStatus UIPeerLaunchStatus[] + +---@class UIAutolobbyParameters +---@field Protocol UILobbyProtocol +---@field LocalPort number +---@field MaxConnections number +---@field DesiredPlayerName string +---@field LocalPlayerPeerId UILobbyPeerId +---@field NatTraversalProvider any + +---@class UIAutolobbyHostParameters +---@field GameName string +---@field ScenarioFile string # path to the _scenario.lua file +---@field SinglePlayer boolean + +---@class UIAutolobbyJoinParameters +---@field Address GPGNetAddress +---@field AsObserver boolean +---@field DesiredPlayerName string +---@field DesiredPeerId UILobbyPeerId + +--- The engine's lobby object. Owns the engine ABI; forwards behaviour to +--- `AutolobbyController`. The synced state lives in `AutolobbyModel`; the fields +--- kept here are controller-internal (identity + rejoin parameters). +---@class UIAutolobbyInstance : moho.lobby_methods, DebugComponent, UIAutolobbyServerCommunicationsComponent, UIAutolobbyArgumentsComponent +---@field Trash TrashBag +---@field LocalPlayerName string # nickname +---@field HostID UILobbyPeerId +---@field LobbyParameters? UIAutolobbyParameters # Used for rejoining functionality +---@field HostParameters? UIAutolobbyHostParameters # Used for rejoining functionality +---@field JoinParameters? UIAutolobbyJoinParameters # Used for rejoining functionality +AutolobbyInstance = Class(MohoLobbyMethods, AutolobbyServerCommunicationsComponent, AutolobbyArgumentsComponent, DebugComponent) { + + ---@param self UIAutolobbyInstance + __init = function(self) + self.Trash = TrashBag() + + self.LocalPlayerName = "Charlie" + self.HostID = "-2" + + -- The model singleton is created in `autolobby.lua > CreateLobby` + -- before the lobby (and thus this instance) is instantiated. Seed + -- the initial state here. + local model = AutolobbyModel.GetSingleton() + model.LocalPeerId:Set("-2") + model.GameMods:Set({}) + model.GameOptions:Set(self:CreateLocalGameOptions()) + model.PlayerOptions:Set({}) + model.LaunchStatutes:Set({}) + model.ConnectionMatrix:Set({}) + end, + + ---@param self UIAutolobbyInstance + __post_init = function(self) + + end, + + --- Creates a table that represents the local player settings. This represents the initial player. It can be edited by the host accordingly. + ---@param self UIAutolobbyInstance + ---@return UIAutolobbyPlayer + CreateLocalPlayer = function(self) + ---@type UIAutolobbyPlayer + local info = {} + + info.Human = true + info.Civilian = false + + -- determine player name + info.PlayerName = self.LocalPlayerName or self:GetLocalPlayerName() or "player" + + -- retrieve faction + info.Faction = 1 + local factionData = import("/lua/factions.lua") + for index, tbl in factionData.Factions do + if HasCommandLineArg("/" .. tbl.Key) then + info.Faction = index + break + end + end + + -- retrieve team and start spot + info.Team = self:GetCommandLineArgumentNumber("/team", -1) + info.StartSpot = self:GetCommandLineArgumentNumber("/startspot", -1) + + -- determine army color based on start location + info.PlayerColor = GameColors.MapToWarmCold(info.StartSpot) + info.ArmyColor = GameColors.MapToWarmCold(info.StartSpot) + + -- retrieve rating + info.DEV = self:GetCommandLineArgumentNumber("/deviation", 500) + info.MEAN = self:GetCommandLineArgumentNumber("/mean", 1500) + info.NG = self:GetCommandLineArgumentNumber("/numgames", 0) + info.DIV = self:GetCommandLineArgumentString("/division", "") + info.SUBDIV = self:GetCommandLineArgumentString("/subdivision", "") + info.PL = math.floor(info.MEAN - 3 * info.DEV) + info.PlayerClan = self:GetCommandLineArgumentString("/clan", "") + + return info + end, + + --- Creates a table that represents the local game options. + ---@param self UIAutolobbyInstance + ---@return UILobbyLaunchGameOptionsConfiguration + CreateLocalGameOptions = function(self) + ---@type UILobbyLaunchGameOptionsConfiguration + local options = { + Score = 'no', + TeamSpawn = 'fixed', + TeamLock = 'locked', + Victory = 'demoralization', + Timeouts = '3', + CheatsEnabled = 'false', + CivilianAlliance = 'enemy', + RevealCivilians = 'Yes', + GameSpeed = 'normal', + FogOfWar = 'explored', + UnitCap = '1500', + PrebuiltUnits = 'Off', + Share = 'FullShare', + ShareUnitCap = 'allies', + DisconnectionDelay02 = '90', + DisconnectShare = 'SameAsShare', + DisconnectShareCommanders = 'Explode', + TeamShareOverflow = "enabled", + + -- yep, great + Ranked = true, + Unranked = 'No', + } + + -- process game options from the command line + for name, value in self:GetCommandLineArgumentArray("/gameoptions") do + if name and value then + options[name] = value + else + LOG("Malformed gameoption. ignoring name: " .. repr(name) .. " and value: " .. repr(value)) + end + end + + return options + end, + + --------------------------------------------------------------------------- + --#region Engine interface + + --- Broadcasts data to all (connected) peers. + ---@param self UIAutolobbyInstance + ---@param data UILobbyData + BroadcastData = function(self, data) + self:DebugSpew("BroadcastData", data.Type) + + -- validate message type + local message = AutolobbyMessages[data.Type] + if not message then + self:DebugWarn("Blocked broadcasting unknown message type", data.Type) + return + end + + -- validate message format + if not message.Validate(self, data) then + self:DebugWarn("Blocked broadcasting malformed message of type", data.Type) + return + end + + return MohoLobbyMethods.BroadcastData(self, data) + end, + + --- (Re)Connects to a peer. + ---@param self any + ---@param address any + ---@param name any + ---@param peerId UILobbyPeerId + ---@return nil + ConnectToPeer = function(self, address, name, peerId) + self:DebugSpew("ConnectToPeer", address, name, peerId) + return MohoLobbyMethods.ConnectToPeer(self, address, name, peerId) + end, + + --- ??? + ---@param self UIAutolobbyInstance + ---@return nil + DebugDump = function(self) + self:DebugSpew("DebugDump") + return MohoLobbyMethods.DebugDump(self) + end, + + --- Destroys the C-object and all the (UI) entities in the trash bag. + ---@param self UIAutolobbyInstance + ---@return nil + Destroy = function(self) + self:DebugSpew("Destroy") + + self.Trash:Destroy() + return MohoLobbyMethods.Destroy(self) + end, + + --- Disconnects from a peer. + --- See also `ConnectToPeer` to connect + ---@param self UIAutolobbyInstance + ---@param peerId UILobbyPeerId + ---@return nil + DisconnectFromPeer = function(self, peerId) + self:DebugSpew("DisconnectFromPeer", peerId) + + return MohoLobbyMethods.DisconnectFromPeer(self, peerId) + end, + + --- Ejects a peer from the lobby. + ---@param self UIAutolobbyInstance + ---@param peerId UILobbyPeerId + ---@param reason string + ---@return nil + EjectPeer = function(self, peerId, reason) + self:DebugSpew("EjectPeer", peerId, reason) + return MohoLobbyMethods.EjectPeer(self, peerId, reason) + end, + + --- Retrieves the local identifier. + ---@param self UIAutolobbyInstance + ---@return UILobbyPeerId + GetLocalPlayerID = function(self) + self:DebugSpew("GetLocalPlayerID") + return MohoLobbyMethods.GetLocalPlayerID(self) + end, + + --- Retrieves the local name. Note that this name can be overwritten by the host via `MakeValidPlayerName` + ---@param self UIAutolobbyInstance + ---@return string + GetLocalPlayerName = function(self) + self:DebugSpew("GetLocalPlayerName") + return MohoLobbyMethods.GetLocalPlayerName(self) + end, + + --- Retrieves the local port. + ---@param self any + ---@return number|nil + GetLocalPort = function(self) + self:DebugSpew("GetLocalPort") + return MohoLobbyMethods.GetLocalPort(self) + end, + + --- Retrieves information about a peer. See `GetPeers` to get the same information for all connected peers. + ---@param self UIAutolobbyInstance + ---@param peerId UILobbyPeerId + ---@return Peer + GetPeer = function(self, peerId) + self:DebugSpew("GetPeer", peerId) + return MohoLobbyMethods.GetPeer(self, peerId) + end, + + --- Retrieves information about all connected peers. See `GetPeer` to get information for a specific peer. + ---@param self UIAutolobbyInstance + GetPeers = function(self) + -- self:DebugSpew("GetPeers") + return MohoLobbyMethods.GetPeers(self) + end, + + --- Transforms the lobby to be discoveryable and joinable for other players. + ---@param self UIAutolobbyInstance + ---@return nil + HostGame = function(self) + self:DebugSpew("HostGame") + return MohoLobbyMethods.HostGame(self) + end, + + --- Retrieves whether the local client is the host. + ---@param self any + ---@return boolean + IsHost = function(self) + self:DebugSpew("IsHost") + return MohoLobbyMethods.IsHost(self) + end, + + --- Join a lobby that is set to be a host. + ---@param self UIAutolobbyInstance + ---@param address GPGNetAddress + ---@param remotePlayerName string + ---@param remotePlayerPeerId UILobbyPeerId + ---@return nil + JoinGame = function(self, address, remotePlayerName, remotePlayerPeerId) + self:DebugSpew("JoinGame", address, remotePlayerName, remotePlayerPeerId) + return MohoLobbyMethods.JoinGame(self, address, remotePlayerName, remotePlayerPeerId) + end, + + --- Launches the game for the local client. The game configuration that is passed in should originate from the host. + ---@param self UIAutolobbyInstance + ---@param gameConfig UILobbyLaunchConfiguration + ---@return nil + LaunchGame = function(self, gameConfig) + self:DebugSpew("LaunchGame") + self:DebugSpew(reprs(gameConfig, { depth = 10 })) + + return MohoLobbyMethods.LaunchGame(self, gameConfig) + end, + + --- Returns a valid game name. + ---@param self UIAutolobbyInstance + ---@param name string + ---@return string + MakeValidGameName = function(self, name) + + self:DebugSpew("MakeValidGameName", name) + return MohoLobbyMethods.MakeValidGameName(self, name) + end, + + --- Returns a valid player name. + ---@param self UIAutolobbyInstance + ---@param peerId UILobbyPeerId + ---@param name string + ---@return string + MakeValidPlayerName = function(self, peerId, name) + self:DebugSpew("MakeValidPlayerName", peerId, name) + return MohoLobbyMethods.MakeValidPlayerName(self, peerId, name) + end, + + ---@param self UIAutolobbyInstance + ---@param peerId UILobbyPeerId + ---@param data UILobbyData + ---@return nil + SendData = function(self, peerId, data) + self:DebugSpew("SendData", peerId, data.Type) + + -- validate message type + local message = AutolobbyMessages[data.Type] + if not message then + self:DebugWarn("Blocked sending unknown message type", data.Type, "to", peerId) + return + end + + -- validate message type + if not message.Validate(self, data) then + self:DebugWarn("Blocked sending malformed message of type", data.Type, "to", peerId) + return + end + + return MohoLobbyMethods.SendData(self, peerId, data) + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Connection events + -- + -- Callbacks with real behaviour forward to `AutolobbyController`; the + -- trivial ones (just a server status update) stay inline. + + --- Called by the engine as we're trying to host a lobby. + ---@param self UIAutolobbyInstance + Hosting = function(self) + self:DebugSpew("Hosting") + AutolobbyController.OnHosting(self) + end, + + --- Called by the engine as we're trying to join a lobby. + ---@param self UIAutolobbyInstance + Connecting = function(self) + self:DebugSpew("Connecting") + self:SendLaunchStatusToServer('Connecting') + end, + + --- Called by the engine when the connection fails. + ---@param self UIAutolobbyInstance + ---@param reason string # reason for connection failure, populated by the engine + ConnectionFailed = function(self, reason) + self:DebugSpew("ConnectionFailed", reason) + AutolobbyController.OnConnectionFailed(self) + end, + + --- Called by the engine when the connection succeeds with the host. + ---@param self UIAutolobbyInstance + ---@param localPeerId UILobbyPeerId + ---@param newLocalName string + ---@param hostPeerId string + ConnectionToHostEstablished = function(self, localPeerId, newLocalName, hostPeerId) + self:DebugSpew("ConnectionToHostEstablished", localPeerId, newLocalName, hostPeerId) + AutolobbyController.OnConnectionToHostEstablished(self, localPeerId, newLocalName, hostPeerId) + end, + + --- Called by the engine when a peer establishes a connection. + ---@param self UIAutolobbyInstance + ---@param peerId UILobbyPeerId + ---@param peerConnectedTo UILobbyPeerId[] # all established conenctions for the given player + EstablishedPeers = function(self, peerId, peerConnectedTo) + self:DebugSpew("EstablishedPeers", peerId, reprs(peerConnectedTo)) + AutolobbyController.OnEstablishedPeers(self, peerId, peerConnectedTo) + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Lobby events + + --- Called by the engine when you are ejected from a lobby. + ---@param self UIAutolobbyInstance + ---@param reason string # reason for disconnection, populated by the host + Ejected = function(self, reason) + self:DebugSpew("Ejected", reason) + self:SendLaunchStatusToServer('Ejected') + end, + + --- ??? + ---@param self UIAutolobbyInstance + ---@param text string + SystemMessage = function(self, text) + self:DebugSpew("SystemMessage", text) + end, + + --- Called by the engine when we receive data from other players. There is no checking to see if the data is legitimate, these need to be done in Lua. + --- + --- Data can be send via `BroadcastData` and/or `SendData`. + ---@param self UIAutolobbyInstance + ---@param data UILobbyReceivedMessage + DataReceived = function(self, data) + -- make it more convenient to debug malicious traffic + SPEW(string.format("Received data of type %s from %s (%s)", tostring(data.Type), tostring(data.SenderID), tostring(data.SenderName))) + + -- signal UI that we received something; a fresh stamp table fires the + -- view's IsAlive observer even for repeated pulses from the same peer + local model = AutolobbyModel.GetSingleton() + local peerIndex = AutolobbyModel.PeerIdToIndex(model.PlayerOptions(), data.SenderID) + if peerIndex then + model.IsAliveStamp:Set({ Index = peerIndex, Time = GetSystemTimeSeconds() }) + end + + -- validate message type + local message = AutolobbyMessages[data.Type] + if not message then + self:DebugWarn("Ignoring unknown message type", data.Type, "from", data.SenderID) + return + end + + -- validate message data + if not message.Validate(self, data) then + self:DebugWarn("Ignoring malformed message of type", data.Type, "from", data.SenderID) + return + end + + -- validate message source + if not message.Accept(self, data) then + self:DebugWarn("Message rejected: ", data.Type) + return + end + + -- handle the message (the handler routes to `AutolobbyController`) + message.Handler(self, data) + end, + + --- Called by the engine when the game configuration is requested by the discovery service. + ---@param self UIAutolobbyInstance + GameConfigRequested = function(self) + self:DebugSpew("GameConfigRequested") + end, + + --- Called by the engine when a peer disconnects. + ---@param self UIAutolobbyInstance + ---@param peerName string + ---@param peerId UILobbyPeerId + PeerDisconnected = function(self, peerName, peerId) + self:DebugSpew("PeerDisconnected", peerName, peerId) + self:SendDisconnectedPeer(peerId) + end, + + --- Called by the engine when the game is launched. + ---@param self UIAutolobbyInstance + GameLaunched = function(self) + self:DebugSpew("GameLaunched") + + -- clear out the interface + import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").GetSingleton():Destroy() + + -- destroy ourselves, the game takes over the management of peers + self:Destroy() + + self:SendGameStateToServer('Launching') + end, + + --- Called by the engine when the launch failed. + ---@param self UIAutolobbyInstance + ---@param reasonKey string + LaunchFailed = function(self, reasonKey) + self:DebugSpew("LaunchFailed", LOC(reasonKey)) + self:SendLaunchStatusToServer('Failed') + end, + + --#endregion + + --#region Debugging + + ---@param self UIAutolobbyInstance + ---@param ... any + DebugSpew = function(self, ...) + if not self.EnabledSpewing then + return + end + + SPEW("Autolobby instance", unpack(arg)) + end, + + + ---@param self UIAutolobbyInstance + ---@param ... any + DebugLog = function(self, ...) + if not self.EnabledLogging then + return + end + + LOG("Autolobby instance", unpack(arg)) + end, + + ---@param self UIAutolobbyInstance + ---@param ... any + DebugWarn = function(self, ...) + if not self.EnabledWarnings then + return + end + + WARN("Autolobby instance", unpack(arg)) + end, + + ---@param self UIAutolobbyInstance + ---@param ... any + DebugError = function(self, ...) + if not self.EnabledErrors then + return + end + + local message = "Autolobby instance" + for _, arg in ipairs(arg) do + message = message .. "\t" .. tostring(arg) + end + + error(message) + end, + + --#endregion +} diff --git a/lua/ui/lobby/autolobby/AutolobbyInterface.lua b/lua/ui/lobby/autolobby/AutolobbyInterface.lua index de46cf0d5ce..5c70d5544fd 100644 --- a/lua/ui/lobby/autolobby/AutolobbyInterface.lua +++ b/lua/ui/lobby/autolobby/AutolobbyInterface.lua @@ -34,17 +34,9 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group local AutolobbyMapPreview = import("/lua/ui/lobby/autolobby/autolobbymappreview.lua") local AutolobbyConnectionMatrix = import("/lua/ui/lobby/autolobby/autolobbyconnectionmatrix.lua") - ----@class UIAutolobbyInterfaceState ----@field PlayerCount number ----@field PlayerOptions? table ----@field PathToScenarioFile? FileName ----@field GameOptions? UILobbyLaunchGameOptionsConfiguration ----@field Connections? UIAutolobbyConnections ----@field Statuses? UIAutolobbyStatus +local AutolobbyModel = import("/lua/ui/lobby/autolobby/autolobbymodel.lua") ---@class UIAutolobbyInterface : Group ----@field State UIAutolobbyInterfaceState ---@field BackgroundTextures string[] ---@field Background Bitmap ---@field Preview UIAutolobbyMapPreview @@ -59,20 +51,18 @@ local AutolobbyInterface = Class(Group) { "/menus02/background-paint05_bmp.dds", }, + -- Pure composition root: it builds and lays out the background, the map + -- preview and the connection matrix. It holds no model subscriptions — + -- each child subscribes to the model itself and owns its own visibility. ---@param self UIAutolobbyInterface ---@param parent Control - __init = function(self, parent, playerCount) + __init = function(self, parent) Group.__init(self, parent, "AutolobbyInterface") - -- initial, empty state - self.State = { - PlayerCount = playerCount - } - local backgroundTexture = self.BackgroundTextures[math.random(1, 5)] --[[@as FileName]] self.Background = UIUtil.CreateBitmap(self, backgroundTexture) self.Preview = AutolobbyMapPreview.GetInstance(self) - self.ConnectionMatrix = AutolobbyConnectionMatrix.Create(self, playerCount) + self.ConnectionMatrix = AutolobbyConnectionMatrix.Create(self) end, ---@param self UIAutolobbyInterface @@ -86,96 +76,18 @@ local AutolobbyInterface = Class(Group) { :Fill(self) :End() + -- position / size only; the preview and matrix manage their own + -- visibility from the model LayoutHelpers.ReusedLayoutFor(self.Preview) :AtCenterIn(self, -100, 0) :Width(400) :Height(400) - :Hide() :End() LayoutHelpers.ReusedLayoutFor(self.ConnectionMatrix) :CenteredBelow(self.Preview, 20) - :Hide() :End() end, - - ---@param self UIAutolobbyInterface - ---@param ownership boolean[][] - UpdateOwnership = function(self, ownership) - self.State.OwnerShip = ownership - - self.ConnectionMatrix:Show() - self.ConnectionMatrix:UpdateOwnership(ownership) - end, - - ---@param self UIAutolobbyInterface - ---@param connections UIAutolobbyConnections - UpdateConnections = function(self, connections) - self.State.Connections = connections - - self.ConnectionMatrix:Show() - self.ConnectionMatrix:UpdateConnections(connections) - end, - - ---@param self UIAutolobbyInterface - ---@param statuses UIAutolobbyStatus - UpdateLaunchStatuses = function(self, statuses) - self.State.Statuses = statuses - - self.ConnectionMatrix:Show() - self.ConnectionMatrix:UpdateStatuses(statuses) - end, - - ---@param self UIAutolobbyInterface - ---@param pathToScenarioInfo FileName - ---@param playerOptions UIAutolobbyPlayer[] - UpdateScenario = function(self, pathToScenarioInfo, playerOptions) - self.State.PathToScenarioFile = pathToScenarioInfo - self.State.PlayerOptions = playerOptions - - if pathToScenarioInfo and playerOptions then - -- hide it for now until we have a better way to decipher its possible (negative) impact - self.Preview:Show() - self.Preview:UpdateScenario(pathToScenarioInfo, playerOptions) - end - end, - - ---@param self UIAutolobbyInterface - ---@param id number - UpdateIsAliveStamp = function(self, id) - self.ConnectionMatrix:UpdateIsAliveTimestamp(id) - end, - - --#region Debugging - - ---@param self UIAutolobbyInterface - ---@param state UIAutolobbyInterfaceState - RestoreState = function(self, state) - self.State = state - - if state.PathToScenarioFile and state.PlayerOptions then - local ok, msg = pcall(self.UpdateScenario, self, state.PathToScenarioFile, state.PlayerOptions) - if not ok then - WARN(msg) - end - end - - if state.Connections then - local ok, msg = pcall(self.UpdateConnections, self, state.Connections) - if not ok then - WARN(msg) - end - end - - if state.Statuses then - local ok, msg = pcall(self.UpdateLaunchStatuses, self, state.Statuses) - if not ok then - WARN(msg) - end - end - end, - - --#endregion } --- A trashbag that should be destroyed upon reload. @@ -221,8 +133,10 @@ end ---@param newModule any function __moduleinfo.OnReload(newModule) if AutolobbyInterfaceInstance then - local handle = newModule.SetupSingleton(AutolobbyInterfaceInstance.State.PlayerCount) - handle:RestoreState(AutolobbyInterfaceInstance.State) + -- the model survives the reload (it is its own singleton), so a fresh + -- interface restores itself: its observers read the current model + -- values on their first fire. No manual state replay is needed. + newModule.SetupSingleton(AutolobbyModel.GetSingleton().PlayerCount()) end end diff --git a/lua/ui/lobby/autolobby/AutolobbyMapPreview.lua b/lua/ui/lobby/autolobby/AutolobbyMapPreview.lua index eaadee5eae3..32897d0e687 100644 --- a/lua/ui/lobby/autolobby/AutolobbyMapPreview.lua +++ b/lua/ui/lobby/autolobby/AutolobbyMapPreview.lua @@ -27,8 +27,12 @@ local LayoutHelpers = import("/lua/maui/layouthelpers.lua") local Group = import("/lua/maui/group.lua").Group local MapPreview = import("/lua/ui/controls/mappreview.lua").MapPreview local AutolobbyMapPreviewSpawn = import("/lua/ui/lobby/autolobby/autolobbymappreviewspawn.lua") +local AutolobbyModel = import("/lua/ui/lobby/autolobby/autolobbymodel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive ---@class UIAutolobbyMapPreview : Group +---@field Trash TrashBag ---@field Preview MapPreview ---@field Overlay Bitmap ---@field PathToScenarioFile? FileName @@ -39,6 +43,7 @@ local AutolobbyMapPreviewSpawn = import("/lua/ui/lobby/autolobby/autolobbymappre ---@field WreckageIcon Bitmap # Acts as a pool ---@field IconTrash TrashBag # Trashbag that contains all icons ---@field SpawnIcons UIAutolobbyMapPreviewSpawn[] +---@field ScenarioObserver LazyVar local AutolobbyMapPreview = ClassUI(Group) { ---@param self UIAutolobbyMapPreview @@ -46,6 +51,8 @@ local AutolobbyMapPreview = ClassUI(Group) { __init = function(self, parent) Group.__init(self, parent) + self.Trash = TrashBag() + self.Preview = MapPreview(self) -- D:\SteamLibrary\steamapps\common\Supreme Commander Forged Alliance\gamedata\textures\textures\ui\common\game\mini-map-glow-brd ? @@ -59,6 +66,31 @@ local AutolobbyMapPreview = ClassUI(Group) { UIUtil.CreateDialogBrackets(self, 30, 24, 30, 24) self.IconTrash = TrashBag() + + -- subscribe to the model's scenario bundle directly; reading the lazy + -- establishes the dependency edge so later changes re-fire + self.ScenarioObserver = self.Trash:Add( + LazyVarDerive(AutolobbyModel.GetSingleton().Scenario, function(scenarioLazy) + self:OnScenarioChanged(scenarioLazy()) + end)) + end, + + ---@param self UIAutolobbyMapPreview + OnDestroy = function(self) + self.Trash:Destroy() + end, + + --- Reacts to the model's scenario bundle: show + render the preview once a + --- scenario file is known, hide it otherwise. + ---@param self UIAutolobbyMapPreview + ---@param scenario UIAutolobbyScenario + OnScenarioChanged = function(self, scenario) + if scenario.ScenarioFile and scenario.PlayerOptions then + self:Show() + self:UpdateScenario(scenario.ScenarioFile, scenario.PlayerOptions) + else + self:Hide() + end end, ---@param self UIAutolobbyMapPreview diff --git a/lua/ui/lobby/autolobby/AutolobbyMessages.lua b/lua/ui/lobby/autolobby/AutolobbyMessages.lua index 597daa97d81..b3853719383 100644 --- a/lua/ui/lobby/autolobby/AutolobbyMessages.lua +++ b/lua/ui/lobby/autolobby/AutolobbyMessages.lua @@ -26,18 +26,21 @@ -- function. If the message is accepted the handler is called, which is just a -- wrapper to another function in the autolobby. +local AutolobbyModel = import("/lua/ui/lobby/autolobby/autolobbymodel.lua") +local AutolobbyController = import("/lua/ui/lobby/autolobby/autolobbycontroller.lua") + ---@class UIAutolobbyMessageHandler ----@field Validate fun(lobby: UIAutolobbyCommunications, data: UILobbyReceivedMessage): boolean # Responsible for filtering out non-sense ----@field Accept fun(lobby: UIAutolobbyCommunications, data: UILobbyReceivedMessage): boolean # Responsible for filtering out malicous messages ----@field Handler fun(lobby: UIAutolobbyCommunications, data: UILobbyReceivedMessage) # Responsible for handling the message +---@field Validate fun(lobby: UIAutolobbyInstance, data: UILobbyReceivedMessage): boolean # Responsible for filtering out non-sense +---@field Accept fun(lobby: UIAutolobbyInstance, data: UILobbyReceivedMessage): boolean # Responsible for filtering out malicous messages +---@field Handler fun(lobby: UIAutolobbyInstance, data: UILobbyReceivedMessage) # Responsible for handling the message ----@param lobby UIAutolobbyCommunications +---@param lobby UIAutolobbyInstance ---@param data UILobbyReceivedMessage local function IsFromHost(lobby, data) return data.SenderID == lobby.HostID end ----@param lobby UIAutolobbyCommunications +---@param lobby UIAutolobbyInstance ---@param data UILobbyReceivedMessage local function IsHost(lobby, data) return lobby:IsHost() @@ -50,7 +53,7 @@ AutolobbyMessages = { ---@class UIAutolobbyUpdateLaunchStatusMessage : UILobbyReceivedMessage ---@field LaunchStatus UIPeerLaunchStatus - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyUpdateLaunchStatusMessage ---@return boolean Validate = function(lobby, data) @@ -61,17 +64,17 @@ AutolobbyMessages = { return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyUpdateLaunchStatusMessage ---@return boolean Accept = function(lobby, data) return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyUpdateLaunchStatusMessage Handler = function(lobby, data) - lobby:ProcessUpdateLaunchStatusMessage(data) + AutolobbyController.ProcessUpdateLaunchStatusMessage(lobby, data) end }, @@ -81,7 +84,7 @@ AutolobbyMessages = { ---@class UIAutolobbyAddPlayerMessage : UILobbyReceivedMessage ---@field PlayerOptions UIAutolobbyPlayer - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyAddPlayerMessage ---@return boolean Validate = function(lobby, data) @@ -92,7 +95,7 @@ AutolobbyMessages = { return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyAddPlayerMessage ---@return boolean Accept = function(lobby, data) @@ -110,7 +113,7 @@ AutolobbyMessages = { end -- verify that the player is not already in the lobby - for _, otherPlayerOptions in lobby.PlayerOptions do + for _, otherPlayerOptions in AutolobbyModel.GetSingleton().PlayerOptions() do if otherPlayerOptions.OwnerID == data.SenderID then lobby:DebugWarn("Received duplicate message of type ", data.Type) return false @@ -120,10 +123,10 @@ AutolobbyMessages = { return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyAddPlayerMessage Handler = function(lobby, data) - lobby:ProcessAddPlayerMessage(data) + AutolobbyController.ProcessAddPlayerMessage(lobby, data) end }, @@ -132,7 +135,7 @@ AutolobbyMessages = { ---@class UIAutolobbyUpdatePlayerOptionsMessage : UILobbyReceivedMessage ---@field PlayerOptions UIAutolobbyPlayer[] - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyUpdatePlayerOptionsMessage ---@return boolean Validate = function(lobby, data) @@ -143,7 +146,7 @@ AutolobbyMessages = { return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyUpdatePlayerOptionsMessage ---@return boolean Accept = function(lobby, data) @@ -155,10 +158,10 @@ AutolobbyMessages = { return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyUpdatePlayerOptionsMessage Handler = function(lobby, data) - lobby:ProcessUpdatePlayerOptionsMessage(data) + AutolobbyController.ProcessUpdatePlayerOptionsMessage(lobby, data) end }, @@ -167,7 +170,7 @@ AutolobbyMessages = { ---@class UIAutolobbyUpdateGameOptionsMessage : UILobbyReceivedMessage ---@field GameOptions UILobbyLaunchGameOptionsConfiguration - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyUpdateGameOptionsMessage ---@return boolean Validate = function(lobby, data) @@ -178,7 +181,7 @@ AutolobbyMessages = { return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyUpdateGameOptionsMessage ---@return boolean Accept = function(lobby, data) @@ -192,10 +195,10 @@ AutolobbyMessages = { return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyUpdateGameOptionsMessage Handler = function(lobby, data) - lobby:ProcessUpdateGameOptionsMessage(data) + AutolobbyController.ProcessUpdateGameOptionsMessage(lobby, data) end }, @@ -204,7 +207,7 @@ AutolobbyMessages = { ---@class UIAutolobbyLaunchMessage : UILobbyReceivedMessage ---@field GameConfig UILobbyLaunchConfiguration - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyLaunchMessage ---@return boolean Validate = function(lobby, data) @@ -222,7 +225,7 @@ AutolobbyMessages = { return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyLaunchMessage ---@return boolean Accept = function(lobby, data) @@ -236,10 +239,10 @@ AutolobbyMessages = { return true end, - ---@param lobby UIAutolobbyCommunications + ---@param lobby UIAutolobbyInstance ---@param data UIAutolobbyLaunchMessage Handler = function(lobby, data) - lobby:ProcessLaunchMessage(data) + AutolobbyController.ProcessLaunchMessage(lobby, data) end } } diff --git a/lua/ui/lobby/autolobby/AutolobbyModel.lua b/lua/ui/lobby/autolobby/AutolobbyModel.lua new file mode 100644 index 00000000000..dade3313528 --- /dev/null +++ b/lua/ui/lobby/autolobby/AutolobbyModel.lua @@ -0,0 +1,357 @@ +--****************************************************************************************************** +--** Copyright (c) 2024 Willem 'Jip' Wijnia +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- Reactive state singleton for the automated lobby. This is the single source +-- of truth that the controller (`AutolobbyController.lua`) writes to and the +-- view (`AutolobbyInterface.lua`) observes via `Derive`. It holds no UI +-- references and no networking; it can be constructed with neither present. +-- +-- See `/lua/ui/game/chat/ChatModel.lua` for the pattern this mirrors, and +-- `/lua/ui/CLAUDE.md` for the reactivity rules (notably: never mutate a held +-- table in place — build a new table and `:Set` it). + +local Create = import("/lua/lazyvar.lua").Create + +------------------------------------------------------------------------------- +--#region Pure derivation helpers +-- +-- These were methods on the controller class; they are stateless (they operate +-- only on their arguments), so they live here as free functions. They are used +-- both by the derived LazyVars below and by the controller's networking threads. + +--- Maps a peer id to an index that can be used in the interface. In practice +--- the peer id can be all over the place, ranging from -1 to numbers such as +--- 35240. With this function we map it to a sane index. +---@param playerOptions UIAutolobbyPlayer[] +---@param peerId UILobbyPeerId +---@return number | false +function PeerIdToIndex(playerOptions, peerId) + if type(peerId) ~= 'string' then + WARN("Autolobby model: invalid peer id ", tostring(peerId)) + return false + end + + if playerOptions then + for k, options in playerOptions do + if options.OwnerID == peerId then + if options.StartSpot then + return options.StartSpot + end + end + end + end + + return false +end + +---@param playerOptions UIAutolobbyPlayer[] +---@param connectionMatrix table +---@param playerCount number +---@return UIAutolobbyConnections +function CreateConnectionsMatrix(playerOptions, connectionMatrix, playerCount) + ---@type UIAutolobbyConnections + local connections = {} + + -- initial setup + for y = 1, playerCount do + connections[y] = {} + for x = 1, playerCount do + connections[y][x] = false + end + end + + -- populate the matrix + for peerId, establishedPeers in connectionMatrix do + for _, peerConnectedToId in establishedPeers do + local peerIdNumber = PeerIdToIndex(playerOptions, peerId) + local peerConnectedToIdNumber = PeerIdToIndex(playerOptions, peerConnectedToId) + + -- connection works both ways + if peerIdNumber and peerConnectedToIdNumber then + if peerIdNumber > playerCount or peerConnectedToIdNumber > playerCount then + WARN("Autolobby model: invalid peer id ", peerIdNumber, peerConnectedToIdNumber) + else + connections[peerIdNumber][peerConnectedToIdNumber] = true + connections[peerConnectedToIdNumber][peerIdNumber] = true + end + end + end + end + + return connections +end + +---@param playerOptions UIAutolobbyPlayer[] +---@param statuses table +---@return UIAutolobbyStatus +function CreateConnectionStatuses(playerOptions, statuses) + local output = {} + for peerId, launchStatus in statuses do + local peerIdNumber = PeerIdToIndex(playerOptions, peerId) + if peerIdNumber then + output[peerIdNumber] = launchStatus + end + end + + return output +end + +---@param playerCount number +---@param localIndex number +---@return boolean[][] +function CreateOwnershipMatrix(playerCount, localIndex) + local output = {} + for y = 1, playerCount do + output[y] = {} + for x = 1, playerCount do + output[y][x] = false + end + end + + -- a StartSpot can in theory fall outside the grid; guard against indexing a + -- non-existent row/column rather than erroring out the whole derivation + if localIndex < 1 or localIndex > playerCount then + WARN("Autolobby model: local index out of range ", localIndex) + return output + end + + for k = 1, playerCount do + output[localIndex][k] = true + output[k][localIndex] = true + end + return output +end + +-- Launch-flow computations (CreateLaunchStatus, CanLaunch, CreateRatingsTable, +-- CreateDivisionsTable, CreateClanTagsTable) live in `AutolobbyController` — they +-- are controller logic, not part of the reactive model's derivations. + +--#endregion + +------------------------------------------------------------------------------- +--#region Reactive model + +--- Lightweight "we just heard from this peer" pulse. A fresh table is set on +--- every receive so the held value's identity always changes, which fires the +--- view observer even for repeated pulses from the same peer index. +---@class UIAutolobbyAliveStamp +---@field Index number +---@field Time number + +--- Reactive autolobby-state singleton: the single source of truth shared by +--- the controller (writer) and the interface (reader). +---@class UIAutolobbyModel +---@field PlayerCount LazyVar # originates from the command line; sizes the grid and every derivation +---@field LocalPeerId LazyVar # local peer id; feeds the Ownership derivation +---@field PlayerOptions LazyVar # synced player slots +---@field GameOptions LazyVar # synced game options (carries ScenarioFile) +---@field GameMods LazyVar # synced game mods +---@field ConnectionMatrix LazyVar> # raw established-peers map +---@field LaunchStatutes LazyVar> # raw per-peer launch status +---@field IsAliveStamp LazyVar # alive pulse (see UIAutolobbyAliveStamp) +---@field Connections LazyVar # derived from PlayerOptions + ConnectionMatrix + PlayerCount +---@field Statuses LazyVar # derived from PlayerOptions + LaunchStatutes +---@field Ownership LazyVar # derived from PlayerCount + PeerIdToIndex(PlayerOptions, LocalPeerId) +---@field Scenario LazyVar # derived bundle of { ScenarioFile, PlayerOptions } for the map preview + +--- The scenario preview depends on two raw vars; bundling them into one derived +--- var lets the preview subscribe with a single observer (one LazyVar to read). +---@class UIAutolobbyScenario +---@field ScenarioFile? FileName +---@field PlayerOptions UIAutolobbyPlayer[] + +--- Singleton handle; nil until `SetupSingleton` (or `GetSingleton`) builds the model. +---@type UIAutolobbyModel | nil +local ModelInstance = nil + +--- Allocates a fresh model singleton, replacing any existing instance. Rejoin +--- relies on this resetting the state: a new lobby must not inherit stale +--- connection / launch state from the previous one. +---@param playerCount? number +---@return UIAutolobbyModel +function SetupSingleton(playerCount) + playerCount = playerCount or 8 + + ---@type UIAutolobbyModel + local model = { + PlayerCount = Create(playerCount), + LocalPeerId = Create("-2"), + PlayerOptions = Create({}), + GameOptions = Create({}), + GameMods = Create({}), + ConnectionMatrix = Create({}), + LaunchStatutes = Create({}), + IsAliveStamp = Create(false), + Connections = Create(), + Statuses = Create(), + Ownership = Create(), + Scenario = Create(), + } + + -- Derived values. Reading the raw LazyVars inside these compute functions + -- registers the dependency edges, so any `:Set` on a raw var re-fires the + -- view observers subscribed to these derived vars. + model.Connections:Set(function() + return CreateConnectionsMatrix(model.PlayerOptions(), model.ConnectionMatrix(), model.PlayerCount()) + end) + + model.Statuses:Set(function() + return CreateConnectionStatuses(model.PlayerOptions(), model.LaunchStatutes()) + end) + + model.Ownership:Set(function() + local localIndex = PeerIdToIndex(model.PlayerOptions(), model.LocalPeerId()) + if not localIndex then + return false + end + return CreateOwnershipMatrix(model.PlayerCount(), localIndex) + end) + + model.Scenario:Set(function() + return { ScenarioFile = model.GameOptions().ScenarioFile, PlayerOptions = model.PlayerOptions() } + end) + + ModelInstance = model + return model +end + +--- Returns the model singleton, creating it on first access. +---@return UIAutolobbyModel +function GetSingleton() + if not ModelInstance then + SetupSingleton() + end + return ModelInstance --[[@as UIAutolobbyModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Write helpers +-- +-- The synced tables are LazyVar values, so a write must build a NEW table and +-- `:Set` it — mutating the held table in place never marks dependents dirty +-- (see /lua/ui/CLAUDE.md § 2). These helpers encapsulate that copy-then-Set +-- discipline so call sites in the controller can't get it wrong. + +--- Places (or replaces) a player at a start spot. +---@param model UIAutolobbyModel +---@param startSpot number +---@param options UIAutolobbyPlayer +function SetPlayer(model, startSpot, options) + local players = table.copy(model.PlayerOptions()) + players[startSpot] = options + model.PlayerOptions:Set(players) +end + +--- Sets the launch status of a peer. +---@param model UIAutolobbyModel +---@param peerId UILobbyPeerId +---@param status UIPeerLaunchStatus +function SetPeerStatus(model, peerId, status) + local statuses = table.copy(model.LaunchStatutes()) + statuses[peerId] = status + model.LaunchStatutes:Set(statuses) +end + +--- Seeds a launch status for a peer only if we don't have one yet. +---@param model UIAutolobbyModel +---@param peerId UILobbyPeerId +---@param status UIPeerLaunchStatus +function EnsurePeerStatus(model, peerId, status) + if model.LaunchStatutes()[peerId] then + return + end + SetPeerStatus(model, peerId, status) +end + +--- Records the peers a peer is connected to. +---@param model UIAutolobbyModel +---@param peerId UILobbyPeerId +---@param peers UILobbyPeerId[] +function SetPeerConnections(model, peerId, peers) + local connectionMatrix = table.copy(model.ConnectionMatrix()) + connectionMatrix[peerId] = peers + model.ConnectionMatrix:Set(connectionMatrix) +end + +--- Sets the scenario file on the game options. +---@param model UIAutolobbyModel +---@param scenarioFile FileName +function SetScenarioFile(model, scenarioFile) + local gameOptions = table.copy(model.GameOptions()) + gameOptions.ScenarioFile = scenarioFile + model.GameOptions:Set(gameOptions) +end + +--- Tucks the (pre-computed) rating / division / clan tables into a copy of the +--- game options and sets it. The tables are computed by the controller (the +--- launch-flow logic lives there); this helper only keeps the copy-then-`Set` +--- discipline. Returns the new game options so the caller can reuse them. +---@param model UIAutolobbyModel +---@param ratings table +---@param divisions table +---@param clanTags table +---@return UILobbyLaunchGameOptionsConfiguration +function StampLaunchTables(model, ratings, divisions, clanTags) + local gameOptions = table.copy(model.GameOptions()) + gameOptions.Ratings = ratings + gameOptions.Divisions = divisions + gameOptions.ClanTags = clanTags + model.GameOptions:Set(gameOptions) + return gameOptions +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: rebuilds the singleton on the new module and copies the +--- current raw LazyVar values across so observers don't see a state reset. The +--- derived vars (Connections / Statuses / Ownership) are rebuilt with their +--- compute functions by `SetupSingleton` and must not be copied. `IsAliveStamp` +--- is a transient pulse and is intentionally not carried over. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if ModelInstance then + local handle = newModule.SetupSingleton(ModelInstance.PlayerCount()) + handle.LocalPeerId:Set(ModelInstance.LocalPeerId()) + handle.PlayerOptions:Set(ModelInstance.PlayerOptions()) + handle.GameOptions:Set(ModelInstance.GameOptions()) + handle.GameMods:Set(ModelInstance.GameMods()) + handle.ConnectionMatrix:Set(ModelInstance.ConnectionMatrix()) + handle.LaunchStatutes:Set(ModelInstance.LaunchStatutes()) + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/autolobby/CLAUDE.md b/lua/ui/lobby/autolobby/CLAUDE.md new file mode 100644 index 00000000000..1bde8456910 --- /dev/null +++ b/lua/ui/lobby/autolobby/CLAUDE.md @@ -0,0 +1,165 @@ +# Autolobby — Architecture Guide + +The automated lobby (used for matchmaking / automatch games) follows the same +reactive MVC structure as the in-game chat: a reactive **model**, a dumb +**view** that observes it, and a **controller** that is the only writer. + +> **Read first:** [`/lua/ui/CLAUDE.md`](/lua/ui/CLAUDE.md) for project-wide UI +> patterns (`__init` vs `__post_init`, LazyVars and `Derive`, `TrashBag`) and +> [`/lua/ui/game/chat/CLAUDE.md`](/lua/ui/game/chat/CLAUDE.md) for the chat +> refactor this mirrors. This doc only covers what is autolobby-specific. + +--- + +## Architecture + +``` +engine ──callbacks──► AutolobbyInstance (moho.lobby_methods, thin shell) + │ forwards (passing self) + ▼ + AutolobbyController (logic, free functions) + │ writes via model helpers + ▼ + AutolobbyModel (LazyVars, singleton) + │ OnDirty / Derive + ▼ + AutolobbyInterface (composition) → matrix / preview (read) +``` + +| Role | File | Responsibility | +|------|------|----------------| +| Model | [AutolobbyModel.lua](AutolobbyModel.lua) | Reactive singleton: raw synced state + derived view-models (`Connections`, `Statuses`, `Ownership`, `Scenario`) + the pure derivation helpers + the copy-then-`Set` write helpers. No UI, no networking. | +| Instance | [AutolobbyInstance.lua](AutolobbyInstance.lua) | The `moho.lobby_methods` object the engine instantiates. Thin shell: engine-ABI wrappers + validation/dispatch + command-line creators stay here; callbacks with behaviour forward to the controller. | +| Controller | [AutolobbyController.lua](AutolobbyController.lua) | The lobby logic as **free functions** (`OnHosting`, `OnEstablishedPeers`, `Process*`, threads, `Rejoin`, launch-flow). The only writer to the model. Hot-reloadable. | +| Composition root | [AutolobbyInterface.lua](AutolobbyInterface.lua) | Builds and lays out the children; holds **no** model subscriptions. | +| Components | [AutolobbyConnectionMatrix.lua](AutolobbyConnectionMatrix.lua), [AutolobbyMapPreview.lua](AutolobbyMapPreview.lua) | Each subscribes to the model via `Derive`, feeds its dot grid / preview, and owns its own visibility. Never write the model. | +| Entry point | [/lua/ui/lobby/autolobby.lua](/lua/ui/lobby/autolobby.lua) | Engine-facing wrapper: `CreateLobby` / `HostGame` / `JoinGame` / `ConnectToPeer` / `DisconnectFromPeer`. Bootstraps model + view + instance in that order. | + +Like the chat (`ChatLinesInterface`, `ChatEditInterface`, … each subscribe to the +model themselves), the components own their reactivity. `AutolobbyInterface` is a +pure composition root, not a push hub. + +--- + +## Instance vs Controller + +The chat controller is a module of free functions; the engine doesn't own it. The +autolobby's engine object (`AutolobbyInstance`) *is* owned by the engine — it +instantiates it via `InternalCreateLobby` and calls its callbacks. So the +autolobby uses a **humble-object** split: + +- **`AutolobbyInstance`** is the C-bound shell. It keeps what is genuinely + engine-adjacent: the `moho` overrides (`BroadcastData` / `SendData` / …, with + their `AutolobbyMessages` validation), `DataReceived`'s validate→accept→dispatch, + and the command-line creators (`CreateLocalPlayer` / `CreateLocalGameOptions`, + which lean on the mixed-in argument component). Trivial callbacks (a single + `SendXToServer`) stay inline; callbacks with behaviour forward to the controller. +- **`AutolobbyController`** holds the behaviour as free functions taking the + instance as their first argument (`OnHosting(instance)`, etc.). `AutolobbyMessages` + routes each validated message straight to `AutolobbyController.Process*`. + +**Why:** the logic becomes **hot-reloadable** — the instance's forwarders resolve +through the live controller module table, so editing a handler takes effect +without re-hosting. It is also testable with a mock instance. Two caveats: + +- The **instance does not hot-reload** (the live C object keeps its bound methods + across a reload of `AutolobbyInstance.lua`). Keep it thin; edits there need a new + `CreateLobby`. The view and model hot-reload on their own. +- The **threads don't hot-reload** either — a forked `ShareLaunchStatusThread` / + `LaunchThread` holds the function it started with, so thread edits take effect + on the next lobby. The message/event handlers do reload. + +--- + +## Model + +`UIAutolobbyModel` ([AutolobbyModel.lua](AutolobbyModel.lua)) is a flat set of +`LazyVar`s plus a few derived ones. + +**Raw** (written by the controller): `PlayerCount`, `LocalPeerId`, +`PlayerOptions`, `GameOptions`, `GameMods`, `ConnectionMatrix`, `LaunchStatutes`, +`IsAliveStamp`. + +**Derived** (computed via `:Set(function() … end)`, never written directly): + +| Derived | From | Consumed by | +|---------|------|-------------| +| `Connections` | `PlayerOptions`, `ConnectionMatrix`, `PlayerCount` | matrix | +| `Statuses` | `PlayerOptions`, `LaunchStatutes` | matrix | +| `Ownership` | `PlayerCount`, `PeerIdToIndex(PlayerOptions, LocalPeerId)`; `false` until the local index is known | matrix | +| `Scenario` | `{ ScenarioFile = GameOptions().ScenarioFile, PlayerOptions }` | preview | + +`Scenario` is a bundle so the preview can subscribe with **one** observer that +reads **one** LazyVar — the same shape as every other observer. (An earlier +two-observer version that read the model directly instead of its LazyVar never +formed the dependency edge and silently stopped firing; bundling removes that +footgun.) + +The pure derivation helpers (`PeerIdToIndex`, `CreateConnectionsMatrix`, +`CreateConnectionStatuses`, `CreateOwnershipMatrix`, `CreateLaunchStatus`, +`CanLaunch`, `CreateRatingsTable`, `CreateDivisionsTable`, +`CreateClanTagsTable`) are free functions in the model module. The model uses +them for its derived vars; the controller calls the launch-flow / alive-stamp +ones via `AutolobbyModel.`. + +### Write helpers + +Writes to the synced tables go through helpers that encapsulate the copy-then-`Set` +discipline, so call sites can't accidentally mutate in place: `SetPlayer`, +`SetPeerStatus`, `EnsurePeerStatus`, `SetPeerConnections`, `SetScenarioFile`, +`StampLaunchTables`. The controller calls these instead of building tables by hand. + +`LocalPlayerName`, `HostID` and the rejoin parameters +(`LobbyParameters` / `HostParameters` / `JoinParameters`) live on the +**instance** — no view reads them and no derivation depends on them. + +--- + +## Rules + +- **The controller is the only writer to the model.** Components only read + (subscribe via `Derive`) and render. +- **Write through the model helpers, never mutate a held table in place.** The + synced tables (`PlayerOptions`, `ConnectionMatrix`, `LaunchStatutes`, + `GameOptions`) are LazyVar values — mutating in place never marks dependents + dirty (see [`/lua/ui/CLAUDE.md § 2`](/lua/ui/CLAUDE.md)). The write helpers + encapsulate the `table.copy` → mutate → `:Set` dance; use them. +- **A `Derive` handler must read its own LazyVar.** `Derive` only forms the + dependency edge when the handler calls `lv()`. A handler that reads the model + some other way fires once at construction and then never again. Always + `function(fooLazy) self:OnFoo(fooLazy()) end`. +- **`IsAliveStamp` is a pulse, not state.** Set a fresh `{Index, Time}` table on + every receive so the value identity always changes and the observer fires even + for repeated pulses from the same peer. +- **Bootstrap order matters.** `CreateLobby` sets up the model *before* the view + so the view subscribes against it, and *before* the instance so the instance's + `__init` seeds an existing model. Rejoin re-runs `CreateLobby`, + which calls `SetupSingleton` and thus resets the model to fresh state — a new + lobby must not inherit stale connection / launch state. + +--- + +## Verifying changes + +For a quick visual check of the UI without any networking, mount it against a +fake-populated model from the console: + +``` +UI_Lua import("/lua/ui/lobby/autolobby/autolobbyinterface.lua").OpenDebug() +``` + +`OpenDebug` builds a 4-player model with statuses and a fully-connected matrix; +`CloseDebug()` tears it down. (The map preview stays hidden unless you also +`AutolobbyModel.SetScenarioFile` with an installed scenario.) + +For the real flow, lobby ↔ server traffic can't be exercised with the local +launch script. Use the two-client procedure documented in +[components/AutolobbyServerCommunicationsComponent.lua](components/AutolobbyServerCommunicationsComponent.lua) +(two FAF clients against the test server, command-line format +`"%s" /init init_local_development.lua`, both on the same PR, both searching). + +Smoke checklist: the connection matrix fills as peers connect; launch statuses +progress (`Connecting` → `Missing local peers` → `Ready`); the map preview +appears; alive pings blink on incoming traffic; the game launches ~5s after all +peers are `Ready` with correct army numbering; rejoin rebuilds the lobby with +clean state. diff --git a/lua/ui/lobby/autolobby/components/AutolobbyArguments.lua b/lua/ui/lobby/autolobby/components/AutolobbyArguments.lua index 19aff423ca0..0a94e052b4f 100644 --- a/lua/ui/lobby/autolobby/components/AutolobbyArguments.lua +++ b/lua/ui/lobby/autolobby/components/AutolobbyArguments.lua @@ -57,7 +57,7 @@ AutolobbyArgumentsComponent = ClassSimple { }, --- Verifies that it is an expected command line argument - ---@param self UIAutolobbyArgumentsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyArgumentsComponent | UIAutolobbyInstance ---@param option string ---@return boolean ValidCommandLineKey = function(self, option) @@ -70,7 +70,7 @@ AutolobbyArgumentsComponent = ClassSimple { end, --- Attempts to retrieve a string-like command line argument - ---@param self UIAutolobbyArgumentsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyArgumentsComponent | UIAutolobbyInstance ---@param option string ---@param default string ---@return string @@ -89,7 +89,7 @@ AutolobbyArgumentsComponent = ClassSimple { end, --- Attempts to retrieve a number-like command line argument - ---@param self UIAutolobbyArgumentsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyArgumentsComponent | UIAutolobbyInstance ---@param option string ---@param default number ---@return number @@ -114,7 +114,7 @@ AutolobbyArgumentsComponent = ClassSimple { end, --- Attempts to retrieve a table-like command line argument - ---@param self UIAutolobbyArgumentsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyArgumentsComponent | UIAutolobbyInstance ---@param option string ---@return table GetCommandLineArgumentArray = function(self, option) diff --git a/lua/ui/lobby/autolobby/components/AutolobbyServerCommunicationsComponent.lua b/lua/ui/lobby/autolobby/components/AutolobbyServerCommunicationsComponent.lua index 95e3b62beb8..c6eb27714ab 100644 --- a/lua/ui/lobby/autolobby/components/AutolobbyServerCommunicationsComponent.lua +++ b/lua/ui/lobby/autolobby/components/AutolobbyServerCommunicationsComponent.lua @@ -90,7 +90,7 @@ local GpgNetSend = GpgNetSend AutolobbyServerCommunicationsComponent = ClassSimple { --- Sends a message to the server to update relevant army options of a player. - ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyInstance ---@param peerId UILobbyPeerId ---@param key 'Team' | 'Army' | 'StartSpot' | 'Faction' ---@param value any @@ -105,7 +105,7 @@ AutolobbyServerCommunicationsComponent = ClassSimple { end, --- Sends a message to the server to update relevant army options of an AI. - ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyInstance ---@param aiName string ---@param key 'Team' | 'Army' | 'StartSpot' | 'Faction' ---@param value any @@ -120,7 +120,7 @@ AutolobbyServerCommunicationsComponent = ClassSimple { end, --- Sends a message to the server to update relevant game options. - ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyInstance ---@param key 'Slots' | any ---@param value any SendGameOptionToServer = function(self, key, value) @@ -134,7 +134,7 @@ AutolobbyServerCommunicationsComponent = ClassSimple { end, --- Sends a message to the server indicating what the status of the lobby as a whole. - ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyInstance ---@param value UILobbyState SendGameStateToServer = function(self, value) -- any other value seriously messes with the lobby protocol on the server @@ -146,21 +146,21 @@ AutolobbyServerCommunicationsComponent = ClassSimple { end, --- sends a message to the server about the status of the local peer. - ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyInstance ---@param value UIPeerLaunchStatus SendLaunchStatusToServer = function(self, value) GpgNetSend('LaunchStatus', value) end, --- Sends a message to the server that we established a connection to a peer. This message can be send multiple times for the same peer and the server should be idempotent to it. - ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyInstance ---@param peerId UILobbyPeerId SendEstablishedPeer = function(self, peerId) GpgNetSend('EstablishedPeer', peerId) end, --- Sends a message to the server that we disconnected from a peer. Note that a peer may be trying to rejoin. See also the launch status of the given peer. - ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyCommunications + ---@param self UIAutolobbyServerCommunicationsComponent | UIAutolobbyInstance ---@param peerId UILobbyPeerId SendDisconnectedPeer = function(self, peerId) GpgNetSend('DisconnectedPeer', peerId) diff --git a/lua/ui/lobby/customlobby/CLAUDE.md b/lua/ui/lobby/customlobby/CLAUDE.md new file mode 100644 index 00000000000..ad9371df7c0 --- /dev/null +++ b/lua/ui/lobby/customlobby/CLAUDE.md @@ -0,0 +1,187 @@ +# Custom Lobby — rebuild (work in progress) + +A ground-up, reactive-MVC rebuild of the custom-games lobby (the player-driven +counterpart to the matchmaker [`autolobby/`](../autolobby/CLAUDE.md)). It replaces +the organically-grown [`/lua/ui/lobby/lobby.lua`](../lobby.lua). + +> Deferred / parked work is tracked in [TODO.md](TODO.md). + +> **Read first**, in order: +> - [`/lua/ui/CLAUDE.md`](/lua/ui/CLAUDE.md) — UI patterns (LazyVar/`Derive`, `__init`/`__post_init`, `TrashBag`). +> - [`/lua/ui/lobby/autolobby/CLAUDE.md`](../autolobby/CLAUDE.md) — the proven Model / Instance / Controller / components split this mirrors. +> - [`/lua/ui/lobby/TARGET_ARCHITECTURE.md`](../TARGET_ARCHITECTURE.md) — the design for this rebuild. +> - [`/lua/ui/lobby/FEATURES.md`](../FEATURES.md) / [`USER_STORIES.md`](../USER_STORIES.md) — the parity target. + +## Naming + +Folder `customlobby/`, class/module prefix `CustomLobby` — pairs with `autolobby/` / +`Autolobby*`. "Custom" is FAF's own term for non-matchmaker games (the autolobby is +the automated/matchmaker path). + +## Three models + +State is split by two questions — *is it shared (host-dictated → everyone)?* and *does +it get launched (becomes part of the game)?* See the `customlobby-model-choice` skill. + +- **[CustomLobbyLaunchModel.lua](models/CustomLobbyLaunchModel.lua)** — shared **and** launched: + the launch payload (players, observers, scenario, game options, mods, auto-teams, spawn + mex, unit restrictions) + `MaxSlots` + the `UICustomLobbyPlayer` shape (incl. the faction multi-select `Factions` + its representative `Faction`; `RealFactionCount` / `RepresentativeFaction` / `NormalizeFactions` / the multi-field `SetPlayerFields` helpers). `Players` is an **array of per-slot + LazyVars** so one slot's change re-fires only that row. Write helpers (`SetPlayer`, + `SetPlayerField`, `AddObserver`, …) keep the copy-then-`Set` discipline. +- **[CustomLobbySessionModel.lua](models/CustomLobbySessionModel.lua)** — shared but **not** + launched: lobby-room management (slot count, closed slots, `LockedSlots`, `SlotsPinned`). A closed + slot is just empty at launch and slot count is map-derived presentation — neither reaches the + scenario. `LockedSlots` pins individual seats so auto-balance holds those players in place (only + the unlocked players are rearranged); the lock is cleared when its seat empties. `SlotsPinned` + locks *all* seating so the host rejects client slot-takes (only the host may move players), + enforced in `ProcessTakeSlot`. +- **[CustomLobbyLocalModel.lua](models/CustomLobbyLocalModel.lua)** — **per-peer, never synced**: + identity (`LocalPeerId` / `HostID` / `IsHost`, set on the connection handshake) + + connectivity (CPU benchmarks now; ping later). Broadcasting identity would corrupt the + receiver's sense of itself, so it never goes on the wire. + +## Status + +| File | Role | +|------|------| +| [CustomLobbyLaunchModel.lua](models/CustomLobbyLaunchModel.lua) | shared + launched state — the launch payload (see above). | +| [CustomLobbySessionModel.lua](models/CustomLobbySessionModel.lua) | shared, lobby-room-only state (slot count, closed slots, locked slots, pinned seating). | +| [CustomLobbyLocalModel.lua](models/CustomLobbyLocalModel.lua) | per-peer state, never synced: identity + CPU benchmarks. | +| [models/derived/](models/derived/CLAUDE.md) | **derived models** — read-only reactive projections of the authoritative state, the "fishing" layer between the compact synced fields and the views (never written by the controller). The first is [CustomLobbyScenarioDerivedModel](models/derived/CustomLobbyScenarioDerivedModel.lua): resolves the launch model's compact `ScenarioFile` into a rich `Scenario` bundle — info (for the map texture) + extracted save markers (`Spawns` / `MassPoints` / `HydroPoints` / `Wrecks`, never the raw save) + `MaxDimension` / `ArmyCount` / `Name` / `Size` / `Version`. **Does the fishing once so consumers just read the field they need** (the map preview, the config facts line, `CustomLobbyRules`); an internal observer **dedups by file** so a launch-info rebroadcast of the same map is a no-op (no preview reload). [CustomLobbyOptionsDerivedModel](models/derived/CustomLobbyOptionsDerivedModel.lua) splits `GameOptions` into lobby / scenario / mods categories and enriches each option (label, help, chosen value, is-default), caching the disk-loaded schema; [CustomLobbyRestrictionsDerivedModel](models/derived/CustomLobbyRestrictionsDerivedModel.lua) maps the active restriction preset keys to their name / icon / tooltip; [CustomLobbyModsDerivedModel](models/derived/CustomLobbyModsDerivedModel.lua) splits the enabled mods into game / ui groups enriched with name / icon / author / version; [CustomLobbySlotsDerivedModel](models/derived/CustomLobbySlotsDerivedModel.lua) is the `Slots` **lookup table** (one entry per slot) merging each seat's player + scenario placement + closed flag + locked flag + CPU benchmark into a ready-to-paint entry (the slot/faction/CPU formatting moved out of the slot controls into here). See [models/derived/CLAUDE.md](models/derived/CLAUDE.md). | +| [CustomLobbyPerformancePopover.lua](CustomLobbyPerformancePopover.lua) | hover popover over the CPU column; hand-built bitmap bar chart of a peer's `PerformanceTrackingV2` history, with a yellow recommended-unit-cap line. | +| [CustomLobbyInstance.lua](CustomLobbyInstance.lua) | thin `moho.lobby_methods` shell; validates/dispatches traffic, forwards callbacks to the controller. Also feeds [`CustomLobbyLog`](CustomLobbyLog.lua) from its three network choke points (BroadcastData / SendData / DataReceived). | +| [CustomLobbyLog.lua](CustomLobbyLog.lua) | the **network traffic log** — a reactive, per-peer, never-synced ring buffer (`Entries` LazyVar, capped) of every message this peer broadcasts / sends / receives, fed by the instance and rendered by the **Logs** tab. Each peer logs only its own traffic, so host and client views differ naturally. Not one of the three models (no game state); a diagnostic feed. | +| [CustomLobbyController.lua](CustomLobbyController.lua) | host-authority logic (free functions): seating, `Process*` handlers, intents (`RequestSetReady`, `RequestTakeSlot`, `RequestSwapSlots`, `RequestEject`, `RequestMoveToObserver`, `RequestSetFactions`/`RequestSetColor`/`RequestSetTeam` (the seated player's own faction multi-select / colour / team — host applies to the slot, a client asks the host for its own seat; colour is scarcity-validated, faction normalised + a representative `Faction` kept), `RequestSetSlotClosed` (open/close one seat — closing rejected on an occupied seat) / `RequestCloseEmptySlots` (close every open empty seat), `RequestSetScenario`, `RequestSetGameMods/Options`, `RequestResetGameOptions`, `RequestSetSlotsPinned`, `RequestSetSlotLocked`, `RequestApplyBalance` (applies a previewed balance), `RequestReopenClosedSlots`, `RequestLaunch`, `RequestSaveSetupPreset`/`RequestLoadSetupPreset`, `RequestChat` — all keyed by slot/bool/file/name so a chat command can call them too; permission is gated separately). The **faction multi-select**: `player.Factions` (allowed real-faction indices) is the source of truth, `player.Faction` its representative (one index, or the Random sentinel when >1); `BuildGameConfiguration` resolves it to one concrete faction at launch (uniform pick from the set). **Chat relay:** `RequestChat` (host short-circuits, client sends to host) → `ProcessRequestChat` is the **single chat chokepoint** (resolves the sender name authoritatively via `FindNameForOwner`, broadcasts `ChatMessage`, and shows it locally since a broadcast doesn't loop back; future mute/rate-limit lives here) → `ProcessChatMessage` appends/reconciles into the [chat model](social/CustomLobbyChatModel.lua). **Lobby notices:** the host emits a `SystemNotice` (senderless system line shown in chat) via `BroadcastSystemNotice` — host-broadcast, not per-peer diffing, so every peer (incl. the host, via local loopback) shows the same line. Wired at the host-authoritative change sites: join (`ProcessAddPlayer`) / leave (`OnPeerDisconnected`) / kick (`RequestEject` adds "Host removed X."; a human kick also yields a "left" line from the disconnect — two lines is fine) / map / mods / options / restrictions changes / seat swap (`SwapSlots`) / move-to-observers / auto-balance applied. The controller also shares each peer's stored CPU benchmark. **Launch:** `RequestLaunch` (host-only, readiness-validated) → auto-saves the `lastGame` preset (`BuildSetupSnapshot`) → `BuildGameConfiguration` (seed option defaults + scenario, resolve random factions, assign army numbers + push to server, stamp ratings/clan tags, resolve team-from-position for the binary AutoTeams modes, resolve sim mods via `Mods.GetGameMods`) → broadcast `LaunchGame` + `instance:LaunchGame`; clients run `ProcessLaunchGame`. **Presets:** `BuildSetupSnapshot` (read the launch state → serializable **setup-only** snapshot: scenario / options / mods / restrictions) + `ApplySetup` (host-only: write scenario/mods/restrictions, reconcile options to the current schema, one broadcast); players, observers and teams/spawn-mex are not stored (see [presetselect/](presetselect/CLAUDE.md)). | +| [CustomLobbyRules.lua](CustomLobbyRules.lua) | the game-rule **pure kernel** (not view, not networking, **reads no models**): `RecommendedUnitCap(seatedCount, maxDimension)` (per-player cap by map size), `AutoTeamMode(gameOptions)` / `SideLabels(mode)` / `BuildSideResolver(mode, scenario)` (binary auto-team split). Every input is passed in, so the same functions serve the reactive [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) (which applies them to publish `Side` / `Teams`) and, later, the host's launch-time team assignment — neither path makes this module read state. | +| [CustomLobbyBalancer.lua](CustomLobbyBalancer.lua) | the auto-balance **pure kernel** (**reads no models**) — **positional only** (the feature is gated to a binary AutoTeams mode, so there are always exactly two sides and the **position decides the team**; the kernel never writes `Team`). Takes the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua)'s **already-resolved** snapshot — its `Slots` table (per-seat player / side / locked / closed) + `Teams` aggregate (mode / labels / resolved) — so it never re-resolves sides. **Solve and score are split** so the preview can hand-edit a balance. **`BuildCandidates(slots, teams, arrangement, locks, count?)`** returns the top-N **distinct** team splits (best-first by match quality, mirror-equivalent splits dropped, each a complete scored plan with its own random position shuffle) — the preview browses them. **`Rebalance(slots, teams, arrangement, locks)`** is the single-best wrapper (`BuildCandidates(...)[1]`); it re-solves the *unlocked* players in three phases (a port of the legacy `PenguinAutoBalance`): **(1) Teams** — split into two even sides minimising the cheap rating heuristic; **(2) Pairs** — rank-match across the teams (strongest of A faces strongest of B; by rating, never random); **(3) Positions** — `ShufflePairsIntoSeats` (its own exported function) scatters the pairs across the free mirror rows. Locked players (and an odd roster's lowest-rated unlocked player) are **pinned at their seat in `arrangement`** — not their lobby seat — so locking a player the balance already moved keeps them put. **`ScoreArrangement(slots, teams, arrangement, locks)`** scores a concrete arrangement *without* re-solving (for a manual swap / lock toggle). Both run `ScorePlan`, which fills the plan: two rating-sorted `sides` (used for `totals` / quality), the ordered mirror **`positions`** (the k-th side-1 seat vs the k-th side-2 seat, sorted by slot so the list is stable across re-scores — this is what the preview renders + drag-reorders), per-side total-mean `totals`, `quality` + `currentQuality` (`trueskill.computeQuality` proposed vs. current, for a "before → after"), `winChance` (predicted win % per side — a local normal-CDF over the side mean/variance sums, the directional counterpart to the symmetric quality), and a per-player `moved` flag. `locks` (ownerId→true) is an optional **override** of the seats' own Locked flags — the preview passes its working, preview-local lock set; nil falls back to `entry.Locked`. **`BuildPlan(slots, teams, locks?, count?)`** is the convenience entry — `BuildCandidates` from the lobby's current seating (returns the candidate **list**). `lockedOwners` flags locked players; `ToArrangement(plan)` returns the lobby update — **slot → ownerId**, no team. The controller's `RequestApplyBalance` re-seats from it; the team falls out of the position, resolved at launch by `BuildGameConfiguration` (binary AutoTeams → team-from-`StartSpot`). | +| [CustomLobbyPresets.lua](CustomLobbyPresets.lua) | **named setup presets** (setup-only: scenario / options / mods / restrictions — no players/observers) — pure prefs CRUD (`GetPresets`/`GetPreset`/`SavePreset`/`DeletePreset`/`RenamePreset`) over one key (`customlobby_setup_presets`), an ordered `{ Name, Setup }` array mirroring the mod presets in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua). Holds no models, no network. A reserved `LastGamePresetName` entry is auto-saved at launch (the rehost source). Capturing/applying a setup lives in the controller (`BuildSetupSnapshot`/`ApplySetup`); see [presetselect/](presetselect/CLAUDE.md). | +| [CustomLobbySession.lua](CustomLobbySession.lua) | the lobby session's **main trash bag**. The lobby lives in the persistent front-end Lua state, which is *not* reset when the game launches in its own state — so anything left reachable (a running thread, a cache, a singleton) leaks for the whole match. This module owns one session-lifetime `TrashBag` (`GetTrash()`); every lobby-scoped `Destroyable` registers in it, and one `Teardown()` (clean-slate in `CreateLobby`, on leave, on `OnGameLaunched`) frees the lot. Works despite the bag being weak-valued because each singleton is pinned by its own module `Instance` local. **Rollout in progress** — converted: the map catalog, all five [derived models](models/derived/CLAUDE.md), and the three authoritative [models/](models/) (Launch/Session/Local, with thin `Destroy` per the design's decision #3). Still to do: the mod catalog, the interface + performance popover (pending the teardown-ordering decision), and the lobby instance. See [design/session-trashbag-teardown.md](design/session-trashbag-teardown.md). | +| [CustomLobbyMessages.lua](CustomLobbyMessages.lua) | message registry: `AddPlayer`, `SetPlayers` (launch model: players + observers), `SentLaunchInfo` (launch model: scenario / options / mods / teams / spawn mex), `SetSessionState` (session model: slot count / closed slots / locked slots / pinned), `SetReady`, `SetFactions`/`SetColor`/`SetTeam` (a client → host: change its own seat's faction set / colour / team), `TakeSlot`, `RequestChat` (any peer → host: a chat line to relay) / `ChatMessage` (host → everyone: the accepted line) / `SystemNotice` (host → everyone: a senderless lobby notice — join/leave), `DisconnectPeer`, `LaunchGame` (host's final game config → `instance:LaunchGame`), `ReportCpuBenchmark`, `SetCpuBenchmarks`. | +| [social/CustomLobbyChatModel.lua](social/CustomLobbyChatModel.lua) | the **chat feed** — a per-peer, never-synced reactive `Entries` ring buffer (`UICustomLobbyChatEntry`: `Id` / `SenderId` / `SenderName` / `Text` / `Status` / `Kind`), the chat equivalent of [`CustomLobbyLog`](CustomLobbyLog.lua) but **written by the controller**, so it is a `ClassSimple : Destroyable` in the session trash (freed on `Teardown()`). `Append` adds an optimistic `Pending` line / system notice; `Receive` reconciles the sender's `Pending` line to `Confirmed` by the echoed `Id` (or appends others' lines). Not one of the three models — no game state. See [social/chat-design.md](social/chat-design.md). | +| [social/CustomLobbyChatController.lua](social/CustomLobbyChatController.lua) | the chat **send pipeline** (writes the chat model, never the wire): `Send` decides command-vs-chat (a `/` line is handled locally and never broadcast — registry lands in slice 2), echoes a plain line optimistically (`Pending`) and hands it to the controller's `RequestChat`; `AppendLocalSystem` for local notices. Mirrors the in-game [`ChatController.Send`](/lua/ui/game/chat/ChatController.lua) split. | +| [CustomLobbyBalancePreview.lua](CustomLobbyBalancePreview.lua) | the auto-balance **preview + tuner** (host-only `Popup`): the slots header's balance button opens it instead of re-seating directly. It owns a **working state** nothing synced sees until Apply — `Arrangement` (slot→ownerId) + `Locks` (a preview-local lock set, **seeded** from the lobby's real locks but never written back). Renders the proposal as **mirror-position rows** (row k = the k-th seat on each side, who face off, in start-position order with a position label; the right column **mirrored** so the teams face each other) — each row puts the **name on the outer edge** and the **mean rating in a fixed column hugging the centre** (so the two columns line up across rows, flanking the centre gap), with the **deviation** as a muted `(±N)` just outside it; the centre shows the pair's mean gap (magnitude) with a **`<` / `>` arrow pointing to the higher-rated side**; **moved** players are gold, **locked** players blue. The host **browses the candidate balances** with a "< i / N >" browser (the kernel's top-N distinct splits via `BuildCandidates`); stepping adopts each. Three gestures tune the shown one: **drag a row** onto another → swap those two positions' pairs (both players move together — "D → A"; same feel as the lobby slot rows — press-vs-drag threshold, a floating pair ghost beside the cursor, and a drop-target highlight); **click two players** → swap just those two (`ScoreArrangement` re-scores, no re-solve); **click a player's lock dot** → pin/unpin them (preview-local) which **regenerates** the candidates around that lock. The **header is a two-line summary** on the same columns as the rows: top line — team label (outer) + each team's predicted **win %** (column) + the match **quality** % in the centre; bottom line — total mean (column) with the summed deviation muted `(±N)`, and the **gap total** (direction arrow) in the centre. The status line carries only situational notes (the odd one out, or why it can't balance). Every value + control has a hover **tooltip** explaining it (win / quality / gap / total / deviation / player / lock / candidate browser). While open it **pins the lobby** (`SlotsPinned`) so the roster can't shift under the proposal (it would go stale); it remembers the prior pin state — **Cancel** restores it, **Apply** leaves it pinned so the balance holds. **Apply** commits only `Arrangement` (→ `RequestApplyBalance`); **Cancel** discards everything (locks included). A transient picker, owns no synced state (beyond toggling the shared pin while open). | +| [CustomLobbyContextMenu.lua](CustomLobbyContextMenu.lua) | generic framed floating menu; `Show(entries, x, y)` renders any `{label, action, enabled}` list, dismisses on item click / click-outside / Esc. Knows nothing about the lobby. | +| [slots/CustomLobbySlotPickers.lua](slots/CustomLobbySlotPickers.lua) | the three per-player edit **pickers** a card opens (same singleton + click-outside-cover + Esc framing as the context menu): `ShowFactionPicker` (a faction **multi-toggle** — tick a subset, applies live, never empties the set, >1 = random), `ShowColorPicker` (a colour grid; colours taken by another seated player greyed + inert, current colour framed), `ShowTeamPicker` (No team / Team 1..8). Each only proposes — it calls `RequestSetFactions` / `RequestSetColor` / `RequestSetTeam` (host-authoritative) and the seat re-renders from the synced model; it writes no model itself. | +| [CustomLobbyMenus.lua](CustomLobbyMenus.lua) | declarative menu **definitions**: entry lists with `when(ctx)`/`action(ctx)` filtered by lobby state (`BuildSlotMenu`). Adding/state-gating an item is a one-liner here. | +| [slots/](slots/) | the **slot subsystem** (its own folder, a sub-folder per layout). [`CustomLobbySlotsInterface`](slots/CustomLobbySlotsInterface.lua) is the entry the composition root mounts: a "Players" header over the **active layout body**, picked by the AutoTeams mode — one-column for the non-team modes, the two-column team layout for the binary modes (left/right, top/bottom, even/odd). It observes `GameOptions` and swaps the body when the kind flips (create-on-mode / destroy-on-switch); a change *within* the binary modes is handled by the two-column body's own re-layout. It is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`: hit-test which row a point is in, drop-highlight, drag ghost → `RequestSwapSlots`) for *every* layout — it alone needs to hit-test across rows — so the layout bodies stay pure build/place/reveal and never duplicate the drag logic; it also exposes `PreferredHeight()` (computed from the mode + model, swap-order-independent) so the root sizes the slot area. The header band carries a **host-only tool strip** (right-aligned: pin seating · auto teams · auto-balance · close empty slots · reopen closed slots) of small icon buttons (local `SlotTool`, mirroring the config column's `PreviewTool`); the **close empty slots** button (`RequestCloseEmptySlots`) closes every open seat with no player in one go (pairs with reopen); the pin button is lit from the synced `SlotsPinned` and the strip is hidden for clients. The **auto-teams** button is a quick-cycle that steps `AutoTeams` through none → top/bottom → left/right → odd/even → manual (`RequestSetGameOptions`); its glyph is the per-mode `/BUTTON/autoteam//` art (kept in sync from the `GameOptions` observer), so it shows the current mode and the slot layout follows. The **auto-balance** button is gated (`SlotTool:SetEnabled`, greyed when off) to a **resolved binary AutoTeams mode** (the derived `Teams` aggregate's `Mode` + `Resolved` — two well-defined sides, map loaded for the positional ones) and opens the [balance preview](CustomLobbyBalancePreview.lua) rather than re-seating on click. A **"Locked" notice** (lock glyph + label) sits right of the "Players" label and is shown to **everyone** while `SlotsPinned` is on, so a client can see seating is host-controlled before clicking an open slot to no effect. [`CustomLobbySlotBase`](slots/CustomLobbySlotBase.lua) holds **all slot behaviour** (one subscription to the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — which already merged this seat's player / placement / closed / CPU benchmark and did the faction/CPU formatting — click/right-click/drag-to-swap + intents, the background/highlight/click overlays + a gold left-edge **lock stripe** shown when the seat's `Locked` flag is set) and the default `RenderPlayer`/`RenderCpu`/`RenderExtras` that paint the standard + optional named controls (colour, name, team, ready, CPU + the optional rating / games / country flag / reserved avatar box + a **faction icon strip** — `CreateFactionIcons`/`RenderFactionIcons` show the selected factions as small icons, a single Random icon when the whole set is allowed) from the entry's resolved views; a presentation subclasses it and implements just `CreateContents` / `LayoutContents`. The base also owns the **per-player editing**: `CreateEditZone(kind)` builds a click zone (laid over the colour swatch / faction / team, above the ClickArea) that — when the seat is editable (your own seat while not ready, or any AI seat for the host; team gated off under a binary AutoTeams mode) — opens the matching picker from [`CustomLobbySlotPickers`](slots/CustomLobbySlotPickers.lua), else falls through to the normal take/ready/drag press. And a host-only per-seat **close/open button** (`CreateSlotButton` — shown by `RenderSlotButton` only on an empty seat → "Close", or a closed seat → "Open" → `RequestSetSlotClosed`; a closed empty seat reads "- closed -"). [`onecolumn/`](slots/onecolumn/): [`CustomLobbyOneColumnSlots`](slots/onecolumn/CustomLobbyOneColumnSlots.lua) (stacks thin rows, reveals 1..count) + [`CustomLobbySlotRow`](slots/onecolumn/CustomLobbySlotRow.lua) (thin one-line presentation). [`twocolumn/`](slots/twocolumn/): [`CustomLobbyTwoColumnSlots`](slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (splits slots into two team columns by each seat's `Side` from the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua) — no longer re-resolving the split itself — with a [`CustomLobbyTeamScore`](CustomLobbyTeamScore.lua) strip across the top as the Left/Right indicator, and the "two columns, unresolved" fallback — parity fill + the score self-hides until a positional map's start positions load) + [`CustomLobbySlotCard`](slots/twocolumn/CustomLobbySlotCard.lua) (fat half-width / two-line presentation; `SetMirrored` flips the right column's cards so the two teams face each other). Each body exports `HeightForCount`. | +| [CustomLobbyObserversInterface.lua](CustomLobbyObserversInterface.lua) | observer strip; subscribes to the model's `Observers` list and shows the count + names (read-only). | +| [CustomLobbyScenarioPreview.lua](CustomLobbyScenarioPreview.lua) | **shared** map-preview *surface*: the scenario's map texture + overlays (start spots, resource/wreck markers, plus a **dummy translucent `WaterMask`** placeholder until a real mask exists) with aspect-correct positioning, texture-leak-safe icon sharing, per-group visibility (`SetOverlayVisible('spawns'\|'resources'\|'wrecks'\|'water', …)`), and three-phase init. A **pure renderer**: `SetScenario(info, markers)` takes already-extracted markers (`UICustomLobbyScenarioMarkers`) and never touches the raw save. Chrome-free; `CustomLobbyMapPreview` wraps it with the frame. Spawn appearance is the owner's via a `CreateSpawnIcon` factory. | +| [CustomLobbyMapPreview.lua](CustomLobbyMapPreview.lua) | the map preview **as one whole** — the chrome (glow border on top + dark backdrop, surface inset by `Padding`), the surface, and the faction spawn icon (local `MapPreviewSpawn`). Used by **both** consumers: created `Bound = true` it subscribes to [`CustomLobbyScenarioDerivedModel`](models/derived/CustomLobbyScenarioDerivedModel.lua) and renders the resolved scenario with per-slot faction spawns (no reload on take/swap, and no reload on a same-map rebroadcast — the model dedups); created unbound (the map-select dialog) it does no model wiring and the owner drives `preview.Surface` directly (numbered-dot spawns), feeding it markers from the catalog's `LoadMarkers`. Exposes `.Surface` for owners to drive / anchor overlays to. | +| [mapselect/](mapselect/CLAUDE.md) | the **map-select dialog** + its catalog and list, in their own folder (host-only `Popup`: searchable, filterable scenario list → `RequestSetScenario`). Self-contained sub-MVC; see [mapselect/CLAUDE.md](mapselect/CLAUDE.md) — including the **`MapPreview` texture-leak** writeup that shaped its design. | +| [modselect/](modselect/CLAUDE.md) | the **mod-select dialog** + its catalog and list, built to the map-select shape (checkbox list + type filters + detail panel + **presets**). Returns a uid set; the opener routes it — sim mods → `RequestSetGameMods` (synced), UI mods → local prefs — or persists the lot standalone. Mod domain logic lives in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) (the `maputil.lua` sibling, fronting `/lua/mods.lua`). See [modselect/CLAUDE.md](modselect/CLAUDE.md). | +| [presetselect/](presetselect/CLAUDE.md) | the **setup-presets dialog** (host-only `Popup`): a list of named setup snapshots (map / options / mods / restrictions) with **Load / Save / Rename / Delete**, opened by the action-bar **Presets** button. Owns no synced state — Save/Load route through the controller's host-authoritative `RequestSaveSetupPreset` / `RequestLoadSetupPreset` intents; the persistence is [`CustomLobbyPresets`](CustomLobbyPresets.lua). Setup-only — players/observers are **not** part of presets (see presetselect/CLAUDE.md). | +| [optionselect/](optionselect/CLAUDE.md) | the **options dialog**: three columns (lobby / scenario / mod options) over the selected scenario + mods, with search + hide-defaults filters; non-default options are marked. Derives the option *schema* per-peer (reference data) via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua); edits a working copy of the *values* and on OK routes the reconciled set through `RequestSetGameOptions` (synced via `GameOptions`). Host-only. See [optionselect/CLAUDE.md](optionselect/CLAUDE.md). | +| [CustomLobbyInterface.lua](CustomLobbyInterface.lua) | composition root, laid out in **areas** (flip the module `Debug` flag to tint them), sized for the **1024×768** floor. A **one-column** layout (the two-column variant was reverted by community request): a **title bar** (title · background picker) over a **left column** split vertically — the **slots** on top (a single column of rows, up to 16, height tracking the slot count) and the **Chat / Observers** tabs below — beside a fixed-width **right column** (the config component: a pinned map preview + facts line over Options / Mods / Restrictions tabs). A full-width **action bar** at the bottom holds the global actions: **Leave** + status on the left, the host-only **Presets** button (opens the setup-presets dialog — see [presetselect/](presetselect/CLAUDE.md)) + the host-only **Launch** on the right. Reads IsHost (action-bar buttons) + SlotCount (to size the slot area via `CustomLobbySlotsInterface.HeightForSlots`); Leave (Esc handler) + **Become observer** (`RequestMoveToObserver`, in the Observers tab). The slot rows + their drag coordination now live in [`CustomLobbySlotsInterface`](CustomLobbySlotsInterface.lua), which fills the slot area. `OpenDebug()` / hot-reload. | +| [CustomLobbyBackgrounds.lua](CustomLobbyBackgrounds.lua) | **background discovery + selection** — pure data/prefs (no models, no network). `Discover()` scans for `*.png` → `{ Path, Name }`: the **custom override** folder `textures/ui/common/lobby/custom-backgrounds` (mounted from `gamedata/custom-lobby-backgrounds` by `init_fafdevelop.lua`) takes precedence — if it holds any image, only those are offered; otherwise the stock `textures/ui/common/lobby/backgrounds` set is used. Holds the per-peer chosen path in a reactive `Selected` LazyVar persisted to prefs (`GetSelectedLazy`/`GetSelected`/`Select`). A purely cosmetic, never-synced local choice (like a skin); defaults to the first image found when nothing is stored. | +| [CustomLobbyBackground.lua](CustomLobbyBackground.lua) | the **full-window background surface** — a single `Bitmap` that paints the selected image so it **covers** the whole parent while keeping aspect ratio (scale to the larger axis ratio, centre, crop the overflow at the screen edge), with a solid backdrop fallback when none is chosen / the file can't be read. Subscribes to [`CustomLobbyBackgrounds`](CustomLobbyBackgrounds.lua) itself; `Width`/`Height` are bound to the parent rect so a resize re-covers for free. | +| [CustomLobbyTeamScore.lua](CustomLobbyTeamScore.lua) | the **accumulated team rating** side indicator — `Side A N · M Side B`. Hosted in the strip atop the two-column slot layout (it doubles as the columns' Left/Right header). Shown only for the binary auto-team formations; **hidden** for `none`/`manual` or until a positional map's start positions load. A **single subscription** to the [slots derived model](models/derived/CustomLobbySlotsDerivedModel.lua)'s `Teams` aggregate (mode / labels / resolved / per-side rating totals — all computed once there); no side/rating logic of its own. Reference data; never writes. | +| [CustomLobbyTabs.lua](CustomLobbyTabs.lua) | a **generic tabbed panel** (strip + content; one panel alive, created on select / destroyed on switch). Tabs **divide the strip evenly** across its width. Construct with a `{ Label, Create, Badge?, Action?, Icon?, Compact? }` list + optional `OnSelect`. A tab's optional `Badge` LazyVar drives a grey **count pill** to the right of the label; its optional `Action` (`{ Create, Visible? }`) is a small button the owner builds **inside the tab, left of the label** (e.g. a config gear), whose `Visible` LazyVar hides it (collapsing it from the layout) when it doesn't apply. The action, label and pill are centred together as one cluster; any absent/hidden/empty piece contributes 0 width so the rest re-centres. A tab can instead be **`Compact`** (a fixed narrow width, excluded from the even division — the flexible tabs share what's left) and/or show an **`Icon`** centred instead of its label (an icon-only utility tab); the default active tab is the first non-compact one. The container just mirrors the LazyVars, the owner decides what they mean. Used for the bottom-left (Logs / Chat / Observers) and the config interface's Options / Mods / Restrictions. | +| [social/](social/) | the lobby's **bottom-left** column (the `CustomLobbyTabs` content): [`CustomLobbyChatPanel`](social/CustomLobbyChatPanel.lua) (the **Chat** tab — a scrollable feed over an edit box, built to the Logs-tab shape; reads [`CustomLobbyChatModel`](social/CustomLobbyChatModel.lua) and sends through [`CustomLobbyChatController`](social/CustomLobbyChatController.lua)) and [`CustomLobbyObserversPanel`](social/CustomLobbyObserversPanel.lua) (the **Observers** tab — the shared observer list + a host-authoritative **Become observer** button → `RequestMoveToObserver`). Each is a tab content component (`Create(parent)`, created on select / destroyed on switch). The Chat / Observers tabs mirror the config column's shape — a per-tab **config gear** (`CustomLobbyInterface`'s local `GearAction`; both no-ops with a "coming soon" tooltip for now) + a right-side **count pill**: Observers shows the live observer count, Chat a dummy until the chat slice lands. A third **compact, icon-only [`CustomLobbyLogsPanel`](social/CustomLobbyLogsPanel.lua)** sits left of Chat (the **Logs** tab — a live **tail view** of this peer's network traffic from [`CustomLobbyLog`](CustomLobbyLog.lua): the most recent entries that fit, in columns `time · kind · ⚠ · name`, with a malformed/unauthorised message tinted + a ⚠ icon whose tooltip is the failure reason). | +| [config/](config/) | the lobby's **right** column. [`CustomLobbyConfigInterface.lua`](config/CustomLobbyConfigInterface.lua) is the column **composition**: a bound square `CustomLobbyMapPreview` **pinned** at the top with a vertical **preview tool strip** to its right (local `PreviewTool` icon buttons — toggles for army/start icons, mass+hydro deposits and the dummy water mask, driving `preview.Surface:SetOverlayVisible`, + a host-only **change-map** config icon at the bottom → the map-select dialog), a name + size/players/version facts line under it, and a [`CustomLobbyTabs`](CustomLobbyTabs.lua) (**Options / Mods / Restrictions**) filling the rest. Each tab carries its own **config gear** (`CustomLobbyTabs`' per-tab `Action`, built by the interface's `GearAction` helper) — a skinned button **inside the tab, left of the label** — that opens that tab's editor: Options → `CustomLobbyOptionSelect`, Mods → `CustomLobbyModSelect`, Restrictions → `CustomLobbyUnitSelect` (see [unitselect/](unitselect/)). The Options + Restrictions gears are **host-only**: their `Visible` LazyVar is the `IsHost` field, so they're **hidden** for clients and the label re-centres; the Mods gear shows for everyone (UI mods are local; the sim portion is host-gated inside the dialog). The interface also owns the tabs' **count badges** as computed LazyVars over the launch model — Options shows the non-default-option count (the options derived model's `NonDefaultCount`), Mods shows `sim / ui` (synced sim mods / this peer's UI-mod prefs), Restrictions shows the active restriction count (preset keys in the launch model's `Restrictions`). All three tab panels are now **read-only**: their per-domain action buttons (open editor / reset / manage mods) are removed — the grid/content fills the whole panel — and the action-bar **Settings** button (now joined by this gear) is the edit entry point. [`CustomLobbyOptionsPanel`](config/CustomLobbyOptionsPanel.lua) (options grouped **Lobby / Scenario / Mods** + hide-defaults toggle; map/mod options gold-flagged with an origin tooltip, option help as a label tooltip — all read from the [options derived model](models/derived/CustomLobbyOptionsDerivedModel.lua), the panel does no schema gathering), [`CustomLobbyModsPanel`](config/CustomLobbyModsPanel.lua) (enabled mods in **Game / UI** sections, each an **icon + name** row with author/version on hover, from the [mods derived model](models/derived/CustomLobbyModsDerivedModel.lua)), [`CustomLobbyUnitsPanel`](config/CustomLobbyUnitsPanel.lua) (the **Restrictions** read-only list — each active restriction's **preset icon + name** in taller rows, from the [restrictions derived model](models/derived/CustomLobbyRestrictionsDerivedModel.lua); the icon's tooltip is the preset description). Each self-subscribes to the model and exposes `Initialize()` + `Create(parent)`. **Parked** (built, but unwired): [`CustomLobbyMapPanel`](config/CustomLobbyMapPanel.lua) (the full Map tab — preview + label/value details + Change-map), now superseded by the pinned preview. Churning a preview is safe here because the lobby shows only **one** current map and the engine caches map textures by name (the texture-leak rule only bites the *map-select dialog* — see [mapselect/CLAUDE.md](mapselect/CLAUDE.md)). | +| [/lua/ui/lobby/lobby.lua](../lobby.lua) | engine entry wrapper (`CreateLobby`/`HostGame`/`JoinGame`) → CustomLobby. Old lobby preserved at `lobby-old.lua`. | + +Working today: host + clients see each other (host-authoritative player sync), the +host's launch config (scenario / options / mods) and session state (slot count / closed +slots) are pushed to clients as whole snapshots (`SentLaunchInfo` + `SetSessionState`, on +join and on change) so the map preview / slot grid render, +ready toggles round-trip, players can **take an open slot** (click it) and the host can +**swap** (drag a row onto another), **eject**, and **move a player to observers** (right-click +→ context menu); observers are synced in the `SetPlayers` snapshot, shown in an observer +strip, and an observer rejoins via right-click → **Play this slot**. Each peer's +stored sim-performance benchmark is shared (no live stress test), and a **Leave** button +(or Esc) disconnects and returns to the menu — a leaving client frees its slot for +everyone via `OnPeerDisconnected`. The host can edit the **game options** via the action-bar +**Settings** button → the options dialog (synced through `RequestSetGameOptions`). (The per-domain +**Change-map** and **mod-select** entry points are temporarily removed during the layout rework — +the dialogs themselves still exist and will be rewired once it lands; `RequestSetScenario` / +`RequestSetGameMods` remain callable, e.g. from chat commands.) The host can +**launch the game** (Launch button → `RequestLaunch`): once a map is picked, ≥1 slot is seated +and every other human is ready, it builds the game config, broadcasts `LaunchGame` and hands it to +the engine, so all peers start together. (Lobby-UI teardown on launch and reactive enable/disable +of the button are still TODO.) The host can **save / load named setup presets** (the action-bar +**Presets** button → the presets dialog): a preset stores map / options / mods / restrictions only +(no players/observers) and loading applies them host-authoritatively (options reconciled to the +current map+mods); launch auto-saves the `lastGame` preset. (Rehost player-reseating is a separate +later slice that needs its own roster capture — see [presetselect/](presetselect/CLAUDE.md).) Launched via +`scripts/LaunchCustomLobby.ps1`, or inspect UI only with +`UI_Lua import("/lua/ui/lobby/customlobby/customlobbyinterface.lua").OpenDebug()`. + +## Next slices (per TARGET_ARCHITECTURE.md) + +1. **Options panel** — done; see [optionselect/](optionselect/CLAUDE.md). The schema is derived + per-peer from `ScenarioFile` + `GameMods` (lobby ∪ scenario `_options.lua` ∪ mod options) via + [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua); the dialog edits the synced `GameOptions` + *values* and reconciles on confirm (seed defaults, drop stale keys) → `RequestSetGameOptions`. + Still TODO: reconcile in the **controller** on scenario/mod change too (the `TODO` in + `RequestSetScenario`), so values stay sane even without opening the dialog. +2. Remaining sub-dialogs (units, prefs) as mini-MVC, following the map-select shape. **Mods** and + **options** are done — see [modselect/](modselect/CLAUDE.md) (carries its own presets, replacing + the legacy per-mod "favorites") and [optionselect/](optionselect/CLAUDE.md). +3. ~~Make slot controls interactive (faction/colour/team → controller **intents**).~~ **Done** — click the swatch / faction / team on a card → a picker → `RequestSetColor` / `RequestSetFactions` (multi-select) / `RequestSetTeam`; the host also opens/closes seats (per-seat button + header "close empty"). Cards also show rating / games / country flag + a reserved avatar box. +4. Map-derived `SlotCount`; the GPGNet (`localPort == -1`) FAF-client path. + +## Rules (same as autolobby) + +- Components subscribe via `Derive`; they never write the model. +- The controller is the only writer, through the model write-helpers (never mutate a held table in place). +- A `Derive` handler must read its own LazyVar (`function(xLazy) self:OnX(xLazy()) end`). +- FAF is Lua 5.0 — no `%` operator; use `math.mod`. + +## The label/value detail format + +How we present a single entity's metadata (a map, a mod). Used by the in-lobby **Map tab** +([config/CustomLobbyMapPanel.lua](config/CustomLobbyMapPanel.lua)), the **map-select dialog** +([mapselect/CustomLobbyMapSelect.lua](mapselect/CustomLobbyMapSelect.lua)) and the **mod-select +dialog** ([modselect/CustomLobbyModSelect.lua](modselect/CustomLobbyModSelect.lua)) — they read +identically. + +**Shape (top → bottom):** + +- An optional **header** — the title (16–18pt `titleFont`) over a single **quick-facts line** of + the short, always-present facts joined by `" · "` (`20km · 8 players · v3`; `v3 · Game mod`), + centred. The map dialog/tab put the preview here; the mod dialog puts the icon. +- A stack of **labelled sections**, each a **dim label above its value**: + - **Label** — `UIUtil.CreateText(parent, text, 12, UIUtil.titleFont)`, colour `LabelColor` = + `'ff8a909a'`, hit-test off. Built by a local `CreateSectionLabel`. + - **Value** — 13pt `bodyFont` in `ValueColor` = `'ffc8ccd0'` for one-liners (author, reclaim), or + a `TextArea` (same `ValueColor`) for long text (description, dependencies). + - Examples: `Author` / `Description` / `Reclaim` (mass·energy icons) / `Dependencies` / `Details`. + +**Collapse + stacking.** A `LayoutSections` / `LayoutDetail` method stacks the *visible* sections +top-to-bottom, anchoring each under the previous and **skipping (hiding) the ones the entity +doesn't declare** so the rest floats up; the long text area (description / deps) fills the +remaining space. Anchor the value's left/right statically in `__post_init` but set the *tops* +in this method — and call it from the clear path too, so an empty open doesn't leave a control +unanchored (→ circular dependency at render). + +**TextArea width** still follows the gotcha below: bind `Width` to the laid-out span in +`Initialize` (post-mount), never `__post_init`. + +**On sharing.** Each consumer keeps its **own local copy** of these helpers (`CreateSectionLabel`, +`LayoutSections`/`LayoutDetail`, `FormatAmount`, the `LabelColor`/`ValueColor` constants) rather +than a shared component — a deliberate *drift-is-fine* call, so the three can diverge as each +context needs. If they start drifting in ways that matter, that's the signal to extract a shared +`CustomLobbyMapDetails`-style view. + +## Layout / init gotchas (learned building the map-select dialog) + +These are the recurring footguns when writing a custom control here — all variations of +"layout isn't a value you can read yet." Read `/lua/ui/CLAUDE.md` § 1–2 first; this is the +lobby-specific checklist. + +| Symptom | Cause | Fix | +|---|---|---| +| `attempt to call method 'SetFunction' (a nil value)` while laying a control out | A `self.X` field name **collides with a Control edge** — `Left` / `Right` / `Top` / `Bottom` / `Width` / `Height` / `Depth` are reserved LazyVars; assigning `self.Top = 0` clobbers the edge. | Name custom fields anything else (`ScrollTop`, not `Top`). | +| `attempt to call method 'AtLeftRightIn' (a nil value)` (or similar) in a layouter chain | Not every `LayoutHelpers.*` function is exposed on the fluent `ReusedLayoutFor` builder. `AtLeftRightIn` is bare-only. | Use the methods other components use — `:AtLeftIn(p):AtRightIn(p)`, `:AnchorToBottom`, `:Fill`, … — or call `LayoutHelpers.Foo(control, …)` directly. | +| `circular dependency in lazy evaluation` when an `Edit`'s font is set, or a list/preview reads its size | Reading a **concrete** layout value (`SetFont`, `ShowItem`, `Width()`) before the control is anchored into a settled parent rect. | Three-phase init: build in `__init`, anchor in `__post_init`, and read geometry only in an `Initialize()` the **opener calls after mounting** (e.g. after `Popup` centres the dialog). For an `Edit`, give it placeholder `:Left(0):Top(0):Width():Height()` before `SetFont`. | +| Cascade of errors after one layout failure (half-built pools, observers firing into a broken control) | A throw mid-`Initialize` left partial state (e.g. `PoolCount` set before the rows existed), and a streaming `Derive`/thread kept calling into it. | Set "ready" counters **after** the thing they describe is fully built; guard paint/refresh paths against nil rows; gate model-observer work on a `self.Ready` flag set in `Initialize`. | +| `circular dependency in lazy evaluation` with **no frame from your files** (fires during the render pass) | A control you created in `__init` is **never anchored** in `__post_init` (or you renamed/moved its layout and forgot it). Its `Left`/`Right`/`Top`/`Bottom` stay at the circular defaults, and it errors the moment it's rendered. | Every visible control needs a layout. To find which one, set `import("/lua/lazyvar.lua").ExtendedErrorMessages = true` — the error then appends the offending control's **creation stack** (file + line). Then anchor it (or hide it). | +| `circular dependency` only **on hot-reload** (or when the model already has state), not on a fresh open | A `Derive` observer fires **immediately on creation** in `__init`, and its handler reads layout geometry (e.g. positions icons via `self.Preview.Width()`). Fresh open: the model field is empty, handler no-ops. Reload: the field already has a value, so it renders before the parent has laid the component out. | Gate the observer handlers behind a `self.Ready` flag (default false). Set `Ready = true` and do the first render **deferred** (`ForkThread` + `WaitFrames(1)` from `__post_init`, or an `Initialize()` the parent calls) — once the parent has actually sized you. See `CustomLobbyMapPreview`. | +| Controls in a hidden container still render — a hidden tab's list/buttons bleed over the active one | MAUI's `Hide()`/`Show()` cascade the hidden flag to the children that exist **at call time**; there's no render-time ancestor check (the `OnHide` "return true to stop the cascade" contract in [`/lua/maui/grid.lua`](/lua/maui/grid.lua) confirms it). So anything created or `Show()`-n in a panel *after* it was hidden (a rebuilt grid row, a button you re-showed) renders, because it missed the cascade. | Don't keep hidden-but-alive panels at all: **create the active panel and destroy it on switch** so only one is ever live (the [`CustomLobbyTabs`](CustomLobbyTabs.lua) model — strip + create-on-select / destroy-on-switch). If you must keep a panel alive and toggle it, re-assert visibility *after* any rebuild (show the active, re-`Hide()` the inactive) rather than trusting an earlier `Hide()`. | +| A `TextArea`'s text wraps far too early (~50–60% of the visible box) | `TextArea.__init` pins `Width` to its **constructor** `width` arg via `SetDimensions`, and `ReflowText` wraps to `Width()` — but the fluent `:AtLeftIn():AtRightIn()` set only `Left`/`Right`, never `Width`. So the box renders `Left..Right` wide while the text still wraps at the stale constructor width. | Bind `Width` to the span: `self.X.Width:Set(function() return self.X.Right() - self.X.Left() end)`. **Do this in `Initialize()` (post-mount), not `__post_init`:** `TextArea` hooks `Width.OnDirty → ReflowText`, so the bind *eagerly* reads `Right()/Left()` → the parent geometry, which is still circular until `Popup` mounts the dialog. (Left/Right anchor to the parent, so no self-cycle once mounted.) | +| Memory climbs ~30 MB and never drops — not on re-texture, `ClearTexture`, `Destroy`, or dialog close | `MapPreview:SetTexture` / `SetTextureFromMap` allocate textures the engine **never frees** (no release API on the control or globally). Loading one per list row × hundreds of maps leaks memory the game needs in-match. | Don't put a `MapPreview` per list row. Render at most a **few** previews total (the map-select list is text-only; only the single candidate preview loads a texture, once per selection). The same applies to per-row `Bitmap` thumbnails via `SetNewTexture`. | + +Reference implementation for all four: [mapselect/CustomLobbyMapSelect.lua](mapselect/CustomLobbyMapSelect.lua) ++ [mapselect/CustomLobbyMapList.lua](mapselect/CustomLobbyMapList.lua) (the pooled list's +`ScrollTop`, three-phase `Initialize`, post-loop `PoolCount`, nil-guarded `PaintRow`). The +texture-leak case is documented in depth in [mapselect/CLAUDE.md](mapselect/CLAUDE.md). diff --git a/lua/ui/lobby/customlobby/CustomLobbyBackground.lua b/lua/ui/lobby/customlobby/CustomLobbyBackground.lua new file mode 100644 index 00000000000..3a9fc93a753 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyBackground.lua @@ -0,0 +1,127 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The lobby's full-window background surface: a single Bitmap that paints the selected background +-- image (CustomLobbyBackgrounds) so it **covers** the whole parent while keeping the texture's +-- aspect ratio — scaled to the larger of the two axis ratios, centred, with the overflow cropped by +-- the screen edge. Falls back to a solid backdrop when no image is selected or the file can't be +-- read. +-- +-- It subscribes to the reactive selection itself (the controller never touches it — it's a local +-- cosmetic choice) and re-covers automatically when the window resizes, because Width/Height are +-- bound as functions of the parent rect. + +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Backgrounds = import("/lua/ui/lobby/customlobby/customlobbybackgrounds.lua") + +local LazyVarCreate = import("/lua/lazyvar.lua").Create +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +local Layouter = LayoutHelpers.ReusedLayoutFor + +--- The backdrop shown when no image is selected, or a selected file can't be read. +local FallbackColor = 'ff0a0a0a' + +---@class UICustomLobbyBackground : Bitmap +---@field Trash TrashBag +---@field AspectLazy LazyVar # { Width, Height } of the current texture in pixels (0 when none) +---@field SelectedObserver LazyVar +local CustomLobbyBackground = ClassUI(Bitmap) { + + ---@param self UICustomLobbyBackground + ---@param parent Control + __init = function(self, parent) + Bitmap.__init(self, parent) + self:DisableHitTest() + self:SetSolidColor(FallbackColor) + + self.Trash = TrashBag() + -- texture pixel size drives the cover math; 0 means "no texture -> solid fallback" + self.AspectLazy = self.Trash:Add(LazyVarCreate({ Width = 0, Height = 0 })) + + -- react to the per-peer background choice (fires once on creation, then on each change) + self.SelectedObserver = self.Trash:Add( + LazyVarDerive(Backgrounds.GetSelectedLazy(), function(pathLazy) + self:SetBackground(pathLazy()) + end)) + end, + + ---@param self UICustomLobbyBackground + ---@param parent Control + __post_init = function(self, parent) + -- Cover the parent while keeping the texture's aspect ratio: scale to the LARGER of the two + -- axis ratios so the image fills the window and overflows (is cropped) on the other axis, + -- then centre it. Raw pixels throughout — we fill the physical frame, so no ui_scale here. + -- Both edges are bound as functions of the parent rect, so a window resize re-covers for + -- free. + self.Width:Set(function() + local aspect = self.AspectLazy() + local parentWidth, parentHeight = parent.Width(), parent.Height() + if aspect.Width <= 0 or aspect.Height <= 0 then + return parentWidth + end + local scale = math.max(parentWidth / aspect.Width, parentHeight / aspect.Height) + return aspect.Width * scale + end) + self.Height:Set(function() + local aspect = self.AspectLazy() + local parentWidth, parentHeight = parent.Width(), parent.Height() + if aspect.Width <= 0 or aspect.Height <= 0 then + return parentHeight + end + local scale = math.max(parentWidth / aspect.Width, parentHeight / aspect.Height) + return aspect.Height * scale + end) + -- AtCenterIn only sets Left/Top (reading the Width/Height bound above) — no conflict. + Layouter(self):AtCenterIn(parent):End() + end, + + --- Paints `path` as a cover-fit background. Falls back to the solid backdrop when `path` is + --- false or the file can't be read (a stale prefs entry pointing at a deleted image). + ---@param self UICustomLobbyBackground + ---@param path FileName | false + SetBackground = function(self, path) + if path then + local width, height = GetTextureDimensions(path) + if width and height and width > 0 and height > 0 then + self:SetTexture(path) + self.AspectLazy:Set({ Width = width, Height = height }) + return + end + WARN("CustomLobby: background image could not be read: " .. tostring(path)) + end + self:SetSolidColor(FallbackColor) + self.AspectLazy:Set({ Width = 0, Height = 0 }) + end, + + ---@param self UICustomLobbyBackground + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyBackground +Create = function(parent) + return CustomLobbyBackground(parent) +end diff --git a/lua/ui/lobby/customlobby/CustomLobbyBackgrounds.lua b/lua/ui/lobby/customlobby/CustomLobbyBackgrounds.lua new file mode 100644 index 00000000000..65cfd3549d6 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyBackgrounds.lua @@ -0,0 +1,119 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- Lobby background discovery + the per-peer background selection. Purely cosmetic and never synced +-- (like a skin choice): it scans textures/ui/common/lobby/backgrounds for *.png, exposes the list, +-- and persists the chosen path in this machine's prefs. The reactive `Selected` handle lets the +-- background surface (CustomLobbyBackground) and the picker (CustomLobbyInterface's combo) stay in +-- sync without polling. +-- +-- Pure data + prefs: no models, no network. The chosen image is drawn to cover the whole window +-- while keeping its aspect ratio — that cover math lives in CustomLobbyBackground. + +local Prefs = import("/lua/user/prefs.lua") +local LazyVarCreate = import("/lua/lazyvar.lua").Create + +--- The directory of the stock background images shipped with the game. +BackgroundsDir = '/textures/ui/common/lobby/backgrounds' + +--- An optional override directory (mounted from gamedata/custom-lobby-backgrounds — see +--- init_fafdevelop.lua). When it holds any image, it *replaces* the stock set entirely; otherwise +--- the stock set is used. +CustomBackgroundsDir = '/textures/ui/common/lobby/custom-backgrounds' + +--- The prefs key the chosen background path is stored under (this machine only). +local PrefsKey = "customlobby_background" + +--- Reactive handle to the selected background path, or false for "no background" (the solid +--- backdrop). Lazily created + seeded from prefs on first access (see GetSelectedLazy). +---@type LazyVar | false +local SelectedLazy = false + +--- A human label for a background file: the bare filename without dir/extension, separators turned +--- to spaces and Title Cased (`aeon-omen.png` -> `Aeon Omen`). +---@param path string +---@return string +local function DisplayName(path) + local name = string.gsub(path, "^.*/", "") -- strip directory + name = string.gsub(name, "%.%w+$", "") -- strip extension + name = string.gsub(name, "[-_]", " ") -- separators -> spaces + name = string.gsub(name, "(%a)([%w]*)", function(first, rest) + return string.upper(first) .. rest + end) + return name +end + +--- All `*.png` in `dir` as `{ Path, Name }`, sorted by path. +---@param dir FileName +---@return { Path: FileName, Name: string }[] +local function ScanDir(dir) + local backgrounds = {} + for _, path in DiskFindFiles(dir, '*.png') do + table.insert(backgrounds, { Path = path, Name = DisplayName(path) }) + end + table.sort(backgrounds, function(a, b) return a.Path < b.Path end) + return backgrounds +end + +--- All background images on disk as `{ Path, Name }`, sorted by path. The custom override folder +--- takes precedence: if it holds any image, only those are offered; otherwise the stock set is used. +--- Empty only when neither folder has any. Re-scanned on each call (cheap — a handful of files). +---@return { Path: FileName, Name: string }[] +function Discover() + local custom = ScanDir(CustomBackgroundsDir) + if next(custom) then + return custom + end + return ScanDir(BackgroundsDir) +end + +--- The selected-path LazyVar, created + seeded on first call. Subscribe with `Derive` to react to +--- background changes. Seeding rule: an explicit stored value (a path, or `false` for "None") is +--- honoured; with nothing stored yet we default to the first discovered image so a fresh lobby +--- shows a backdrop rather than a black frame. +---@return LazyVar +function GetSelectedLazy() + if not SelectedLazy then + local stored = Prefs.GetFromCurrentProfile(PrefsKey) + if stored == nil then + local all = Discover() + stored = all[1] and all[1].Path or false + end + SelectedLazy = LazyVarCreate(stored or false) + end + return SelectedLazy +end + +--- The currently selected background path, or false when none is chosen. +---@return FileName | false +function GetSelected() + return GetSelectedLazy()() +end + +--- Chooses `path` as the background (pass false to clear it): updates the reactive handle and +--- persists the choice to prefs. +---@param path FileName | false +function Select(path) + GetSelectedLazy():Set(path or false) + Prefs.SetToCurrentProfile(PrefsKey, path or false) + SavePreferences() +end diff --git a/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua b/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua new file mode 100644 index 00000000000..ccb768d2aff --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyBalancePreview.lua @@ -0,0 +1,1132 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The auto-balance **preview**: a host-only modal that proposes a two-team split (and its match +-- quality) BEFORE anything is re-seated, with Apply / Cancel — so the host commits a balance on +-- purpose (USER_STORIES § R). It is also where the host *tunes* the balance with a few clicks. +-- +-- While it is open the lobby is **pinned** (`SlotsPinned`), so the roster can't shift under the +-- proposal and leave it stale. The prior pin state is remembered: Cancel restores it, while Apply +-- leaves the lobby pinned so the balanced seating holds until the host unpins. +-- +-- It owns a **working state** that nothing synced ever sees until Apply: +-- * `Arrangement` — the proposed seating (slot -> ownerId). +-- * `Locks` — a working lock set (ownerId -> true), SEEDED from the lobby's real locks but +-- kept preview-local: toggling one here is just a balancing constraint, never +-- written back (Cancel discards everything; Apply commits only the seating). +-- +-- The host first **browses the candidate balances** — the kernel returns the top-N distinct splits +-- (`BuildCandidates`), shown one at a time with a "< i / N >" browser; stepping through adopts each. +-- +-- The body renders as **mirror positions** (row k = the k-th seat on each side, who face off; the +-- right column is mirrored so the teams face each other): each row puts the name on the outer edge and +-- the **mean** rating in a column hugging the centre (with the deviation muted as "(±N)" beside it) + +-- the pair's mean gap in the centre, a "<" / ">" arrow pointing to the higher-rated side. The header is +-- a two-line summary on the same columns: TOP — team label (outer) + each team's predicted win % (column) +-- + the match quality in the centre; BOTTOM — total mean (column, "(±N)" summed deviation muted) + the +-- gap total (direction arrow) in the centre. Three gestures tune the shown candidate: +-- * **Drag a row** onto another → swap those two positions' pairs (both players move together), +-- so the host arranges which pair spawns where ("D -> A"). +-- * **Click a player, then another** → swap just those two (`ScoreArrangement` re-scores, no solve). +-- * **Click a player's lock** → pin / unpin them, which **regenerates** the candidates around that +-- constraint (the locked player held at its seat, the rest re-balanced). +-- Moved players are gold, locked players blue. The status line carries only situational notes (the odd +-- one left in place, or why it can't balance). Every value / control has a hover tooltip (see `Help`). +-- +-- Built to the preset dialog's shape (areas layout, three-phase init, Popup singleton). + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Dragger = import("/lua/maui/dragger.lua").Dragger +local Popup = import("/lua/ui/controls/popups/popup.lua").Popup +local Tooltip = import("/lua/ui/game/tooltip.lua") + +local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyslotsderivedmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") +local CustomLobbyBalancer = import("/lua/ui/lobby/customlobby/customlobbybalancer.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor + +local Debug = false + +local DialogWidth = 540 +local DialogHeight = 470 +local Pad = 12 +local TitleHeight = 28 +local HintHeight = 16 +local HeaderHeight = 40 -- two lines: total (+dev) / win% per team, gap / quality in the centre +local StatusHeight = 24 +local ActionHeight = 52 +local RowHeight = 24 +local MaxRows = 8 -- binary AutoTeams on a 16-spawn map is at most 8 per side +local LockSize = 12 +local PosLabelWidth = 16 +local CenterWidth = 60 -- fixed centre band (the gap number + direction arrow) so the rating columns line up across rows +local NavSize = 22 +local ButtonGap = 8 +local DragThreshold = 5 -- cursor travel (screen px) before a press becomes a drag, not a click + +local LabelColor = 'ffc8ccd0' -- a player that stays where it is +local MoveColor = 'ffe6c64f' -- a player the balance moves (gold) +local LockColor = 'ff7fb2ff' -- a host-locked player, pinned in place (blue) +local HeaderColor = 'ffd9c97a' +local MutedColor = 'ff8c9096' +local SelectColor = 'ffffffff' -- swap-selection + drop-target highlight (low alpha) + +local GapLowColor = 'ff66bb66' -- a close pair +local GapMidColor = 'ffd9c97a' -- a noticeable gap +local GapHighColor = 'ffcc6666' -- a lopsided pair + +-- hover tooltips explaining each value / control { title, body } +local Help = { + Win = { "Win chance", + "The chance this team wins. It comes from the player ratings. The two teams add up to 100%." }, + Quality = { "Match quality", + "How even the two teams are. A higher number means a closer match." }, + Gap = { "Rating gap", + "How far apart the two teams are in rating. The arrow points to the stronger team. A smaller number is more balanced." }, + Total = { "Team rating", + "The team's total rating. It is the sum of the players' ratings." }, + Deviation = { "Rating uncertainty", + "How unsure we are about the team's rating. It is the sum of the players' deviations. A higher number means the ratings are less certain." }, + Player = { "Player rating", + "The player's rating. The number in brackets is the deviation, which is how unsure we are about that rating. Click two players to swap them. Drag the row to move the pair to another spot." }, + Lock = { "Lock in place", + "Pin this player to their seat. Auto-balance keeps locked players where they are. Only the other players get moved." }, + Candidate = { "Other balances", + "Step through the other balanced line-ups. The best one is shown first." }, +} + +--- Attaches an explanatory hover tooltip to a control (re-enabling hit testing, since the value texts +--- are hit-disabled so the row's click / drag passes through to the catcher beneath them). +---@param control Control +---@param help string[] # { title, body } +local function AddHelp(control, help) + control:EnableHitTest() + Tooltip.AddControlTooltipManual(control, help[1], help[2]) +end + +--- Creates an invisible layout area (tinted while Debug is on). +---@param parent Control +---@param name string +---@param color string +---@return Group +local function CreateArea(parent, name, color) + local area = Group(parent, name) + local bg = Bitmap(area) + bg:SetSolidColor(color) + bg:SetAlpha(Debug and 0.18 or 0.0) + bg:DisableHitTest() + Layouter(bg):Fill(area):End() + return area +end + +--- The current lobby snapshot the kernel reads (slots + teams aggregate). +---@return UICustomLobbySlot[] slots +---@return UICustomLobbyTeams teams +local function CurrentSnapshot() + return CustomLobbySlotsDerivedModel.GetSlots(), CustomLobbySlotsDerivedModel.GetTeams() +end + +--- The display colour for a player: blue = host-locked (pinned), gold = moved by the balance, grey = +--- unchanged. Locked wins (a locked player never moves, so it can't also read as a mover). +---@param player UICustomLobbyBalancePlayer +---@return string +local function PlayerColor(player) + if player.locked then + return LockColor + elseif player.moved then + return MoveColor + end + return LabelColor +end + +--- Colour for a pair's rating gap: green close, amber noticeable, red lopsided. +---@param gap number +---@return string +local function GapColor(gap) + if gap < 100 then + return GapLowColor + elseif gap < 250 then + return GapMidColor + end + return GapHighColor +end + +---@class UICustomLobbyBalanceRow +---@field Group Group +---@field DropHighlight Bitmap # full-row tint while it's a drag drop-target +---@field LeftSelect Bitmap # left half: swap-click / drag target + selection highlight +---@field RightSelect Bitmap +---@field PosLabel Text # the position index (1..N) +---@field LeftName Text # outer edge +---@field LeftDev Text # muted deviation "(±N)", just outside the rating +---@field LeftRating Text # mean rating, aligned in a column beside the centre +---@field CenterArea Group # fixed-width centre band (holds the gap number) +---@field Center Text # the pair's rating gap (magnitude, centred) +---@field CenterArrowL Text # "<" shown when the left player is higher-rated +---@field CenterArrowR Text # ">" shown when the right player is higher-rated +---@field RightRating Text +---@field RightDev Text +---@field RightName Text +---@field LeftLock Bitmap # left player's lock toggle +---@field RightLock Bitmap +---@field LeftOwner UILobbyPeerId | nil # who is on each half right now (set by Render) +---@field LeftSlot number | nil +---@field RightOwner UILobbyPeerId | nil +---@field RightSlot number | nil + +---@class UICustomLobbyBalancePreview : Group +---@field Trash TrashBag +---@field OnCloseCb fun() +---@field Result UICustomLobbyBalancePlan +---@field Candidates UICustomLobbyBalancePlan[] # the browsable top-N balances (best-first) +---@field CandidateIndex number # which candidate is shown (1-based) +---@field Arrangement table # working seating (slot -> ownerId) +---@field Locks table # working lock set (preview-local) +---@field SelectedSlot number | nil # first half clicked for a swap +---@field SelectedOwner UILobbyPeerId | nil +---@field DragGhost Group | false # floating pair label following the cursor mid-drag +---@field TitleArea Group +---@field HintArea Group +---@field HeaderArea Group +---@field HeaderTop Group # top line of the header (win % + quality) — vertical reference +---@field HeaderBottom Group # bottom line of the header (totals + gap) — vertical reference +---@field RowsArea Group +---@field StatusArea Group +---@field ActionArea Group +---@field Title Text +---@field Hint Text +---@field TeamLabelA Text # team label, outer edge (like a player name) +---@field TeamLabelB Text +---@field TeamTotalA Text # team total (sum of means), centre column (aligned above the player ratings) +---@field TeamTotalB Text +---@field TeamDevA Text # team total deviation "(±N)", muted, just outside the total +---@field TeamDevB Text +---@field TeamWinA Text # team A predicted win %, top line of its column +---@field TeamWinB Text +---@field HeaderCenterArea Group # centre band (holds the gap total + quality), aligned above the rows' centre band +---@field HeaderGap Text # the total rating gap between the teams (magnitude, centred, bottom line) +---@field HeaderArrowL Text # "<" shown when the left team has the higher total +---@field HeaderArrowR Text # ">" shown when the right team has the higher total +---@field HeaderQuality Text # match quality %, centred on the top line (above the gap) +---@field Rows UICustomLobbyBalanceRow[] +---@field Status Text +---@field NavPrev Bitmap # browse to the previous candidate ("<") +---@field NavNext Bitmap # browse to the next candidate (">") +---@field NavLabel Text # "i / N" +---@field ApplyButton Button +---@field CancelButton Button +---@field Ready boolean +---@field Applied boolean # Apply committed — closing then leaves the lobby pinned +---@field PriorPinned boolean # SlotsPinned before the preview pinned it (restored on cancel) +local CustomLobbyBalancePreview = ClassUI(Group) { + + ---@param self UICustomLobbyBalancePreview + ---@param parent Control + ---@param options { onClose: fun() } + __init = function(self, parent, options) + Group.__init(self, parent, "CustomLobbyBalancePreview") + + self.Trash = TrashBag() + self.OnCloseCb = options.onClose + self.Ready = false + self.SelectedSlot = nil + self.SelectedOwner = nil + self.DragGhost = false + + -- pin the lobby for as long as the preview is open, so the roster can't shift under the proposal + -- (a client slot-take while balancing would make the arrangement stale). Remember the prior pin + -- state: cancelling restores it, while Apply leaves the lobby pinned so the balance holds. + self.Applied = false + self.PriorPinned = CustomLobbySessionModel.GetSingleton().SlotsPinned() and true or false + CustomLobbyController.RequestSetSlotsPinned(true) + + -- seed the working state from the lobby: the candidate balances honouring the lobby's existing + -- locks (locks nil -> the kernel reads each seat's own Locked flag), then keep those locks local + local slots, teams = CurrentSnapshot() + local candidates = CustomLobbyBalancer.BuildPlan(slots, teams) + self.Candidates = candidates + self.CandidateIndex = 1 + self.Result = candidates[1] + self.Arrangement = self.Result.arrangement + self.Locks = table.copy(self.Result.lockedOwners) + + self.TitleArea = CreateArea(self, "TitleArea", 'ffcc4040') + self.HintArea = CreateArea(self, "HintArea", 'ff404040') + self.HeaderArea = CreateArea(self, "HeaderArea", 'ff4060cc') + self.RowsArea = CreateArea(self, "RowsArea", 'ff406060') + self.StatusArea = CreateArea(self, "StatusArea", 'ffcc40cc') + self.ActionArea = CreateArea(self, "ActionArea", 'ff808080') + + self.Title = UIUtil.CreateText(self.TitleArea, "Balance preview", 22, UIUtil.titleFont) + self.Title:DisableHitTest() + + self.Hint = UIUtil.CreateText(self.HintArea, + "Drag a row to move a pair · click two players to swap · click a lock to pin", 11, UIUtil.bodyFont) + self.Hint:SetColor(MutedColor) + self.Hint:DisableHitTest() + + -- the team summary mirrors a player row: label (outer) + total (centre column, above the player + -- ratings), and the gap total in the centre band above the per-row gaps + local labels = self.Result.labels or { "Team 1", "Team 2" } + self.TeamLabelA = UIUtil.CreateText(self.HeaderArea, labels[1], 14, UIUtil.titleFont) + self.TeamLabelA:SetColor(HeaderColor) + self.TeamLabelA:DisableHitTest() + self.TeamLabelB = UIUtil.CreateText(self.HeaderArea, labels[2], 14, UIUtil.titleFont) + self.TeamLabelB:SetColor(HeaderColor) + self.TeamLabelB:DisableHitTest() + + self.TeamTotalA = UIUtil.CreateText(self.HeaderArea, "", 14, UIUtil.titleFont) + self.TeamTotalA:SetColor(HeaderColor) + self.TeamTotalA:DisableHitTest() + self.TeamTotalB = UIUtil.CreateText(self.HeaderArea, "", 14, UIUtil.titleFont) + self.TeamTotalB:SetColor(HeaderColor) + self.TeamTotalB:DisableHitTest() + + self.TeamDevA = UIUtil.CreateText(self.HeaderArea, "", 11, UIUtil.bodyFont) + self.TeamDevA:SetColor(MutedColor) + self.TeamDevA:DisableHitTest() + self.TeamDevB = UIUtil.CreateText(self.HeaderArea, "", 11, UIUtil.bodyFont) + self.TeamDevB:SetColor(MutedColor) + self.TeamDevB:DisableHitTest() + + self.HeaderCenterArea = Group(self.HeaderArea, "BalanceHeaderCenter") + self.HeaderGap = UIUtil.CreateText(self.HeaderCenterArea, "", 12, UIUtil.bodyFont) + self.HeaderGap:SetColor(HeaderColor) + self.HeaderGap:DisableHitTest() + self.HeaderArrowL = UIUtil.CreateText(self.HeaderCenterArea, "", 12, UIUtil.bodyFont) + self.HeaderArrowL:SetColor(HeaderColor) + self.HeaderArrowL:DisableHitTest() + self.HeaderArrowR = UIUtil.CreateText(self.HeaderCenterArea, "", 12, UIUtil.bodyFont) + self.HeaderArrowR:SetColor(HeaderColor) + self.HeaderArrowR:DisableHitTest() + + -- the bottom line of the header: each team's predicted win % (under its total) + the match + -- quality (under the gap). HeaderTop / HeaderBottom are vertical-reference bands only. + self.HeaderTop = Group(self.HeaderArea, "BalanceHeaderTop") + self.HeaderTop:DisableHitTest() + self.HeaderBottom = Group(self.HeaderArea, "BalanceHeaderBottom") + self.HeaderBottom:DisableHitTest() + self.TeamWinA = UIUtil.CreateText(self.HeaderArea, "", 14, UIUtil.titleFont) + self.TeamWinA:SetColor(HeaderColor) + self.TeamWinA:DisableHitTest() + self.TeamWinB = UIUtil.CreateText(self.HeaderArea, "", 14, UIUtil.titleFont) + self.TeamWinB:SetColor(HeaderColor) + self.TeamWinB:DisableHitTest() + self.HeaderQuality = UIUtil.CreateText(self.HeaderCenterArea, "", 12, UIUtil.bodyFont) + self.HeaderQuality:SetColor(HeaderColor) + self.HeaderQuality:DisableHitTest() + + -- explanatory hover tooltips on the header values + AddHelp(self.TeamWinA, Help.Win) + AddHelp(self.TeamWinB, Help.Win) + AddHelp(self.HeaderQuality, Help.Quality) + AddHelp(self.HeaderGap, Help.Gap) + AddHelp(self.TeamTotalA, Help.Total) + AddHelp(self.TeamTotalB, Help.Total) + AddHelp(self.TeamDevA, Help.Deviation) + AddHelp(self.TeamDevB, Help.Deviation) + + -- a fixed pool of interactive position rows; Render shows/hides + fills them from the proposal + self.Rows = {} + for i = 1, MaxRows do + self.Rows[i] = self:CreateRow(self.RowsArea, i) + end + + self.Status = UIUtil.CreateText(self.StatusArea, "", 14, UIUtil.bodyFont) + self.Status:SetColor(LabelColor) + self.Status:DisableHitTest() + + -- candidate browser (left of the action buttons): "< i / N >" to step through the top balances + self.NavPrev = self:CreateNavArrow("<", function() self:ShowCandidate(self.CandidateIndex - 1) end) + self.NavLabel = UIUtil.CreateText(self.ActionArea, "", 13, UIUtil.bodyFont) + self.NavLabel:SetColor(LabelColor) + self.NavLabel:DisableHitTest() + self.NavNext = self:CreateNavArrow(">", function() self:ShowCandidate(self.CandidateIndex + 1) end) + + self.ApplyButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Apply") + self.ApplyButton.OnClick = function() + self:ApplyAndClose() + end + + self.CancelButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Cancel") + self.CancelButton.OnClick = function() + self.OnCloseCb() + end + end, + + --- Builds one interactive position row: a drop-target tint, two clickable halves (swap-select / + --- drag start + selection highlight), the position label, the two player texts + the centre gap, + --- and a lock toggle per side. Handlers read the row's current owner/slot (set by Render), so the + --- closures stay valid across re-renders. + ---@param self UICustomLobbyBalancePreview + ---@param parent Control + ---@param index number + ---@return UICustomLobbyBalanceRow + CreateRow = function(self, parent, index) + local row = {} + row.Group = Group(parent, "BalanceRow" .. tostring(index)) + + row.DropHighlight = Bitmap(row.Group) + row.DropHighlight:SetSolidColor(SelectColor) + row.DropHighlight:SetAlpha(0.0) + row.DropHighlight:DisableHitTest() + + row.LeftSelect = Bitmap(row.Group) + row.LeftSelect:SetSolidColor(SelectColor) + row.LeftSelect:SetAlpha(0.0) + row.LeftSelect.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' and not event.Modifiers.Right then + self:BeginRowGesture(index, row.LeftSlot, row.LeftOwner, event) + return true + end + return false + end + + row.RightSelect = Bitmap(row.Group) + row.RightSelect:SetSolidColor(SelectColor) + row.RightSelect:SetAlpha(0.0) + row.RightSelect.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' and not event.Modifiers.Right then + self:BeginRowGesture(index, row.RightSlot, row.RightOwner, event) + return true + end + return false + end + + row.PosLabel = UIUtil.CreateText(row.Group, "", 11, UIUtil.bodyFont) + row.PosLabel:SetColor(MutedColor) + row.PosLabel:DisableHitTest() + + -- per half: the name on the outer edge, then the muted mean, then the rating in a fixed column + -- beside the centre band; the gap number lives in the centre band so the rating columns line up + row.LeftName = UIUtil.CreateText(row.Group, "", 14, UIUtil.bodyFont) + row.LeftName:DisableHitTest() + row.LeftDev = UIUtil.CreateText(row.Group, "", 11, UIUtil.bodyFont) + row.LeftDev:SetColor(MutedColor) + row.LeftDev:DisableHitTest() + row.LeftRating = UIUtil.CreateText(row.Group, "", 14, UIUtil.bodyFont) + row.LeftRating:DisableHitTest() + + row.CenterArea = Group(row.Group, "BalanceRowCenter") + row.Center = UIUtil.CreateText(row.CenterArea, "", 12, UIUtil.bodyFont) + row.Center:SetColor(MutedColor) + row.Center:DisableHitTest() + row.CenterArrowL = UIUtil.CreateText(row.CenterArea, "", 12, UIUtil.bodyFont) + row.CenterArrowL:DisableHitTest() + row.CenterArrowR = UIUtil.CreateText(row.CenterArea, "", 12, UIUtil.bodyFont) + row.CenterArrowR:DisableHitTest() + + row.RightRating = UIUtil.CreateText(row.Group, "", 14, UIUtil.bodyFont) + row.RightRating:DisableHitTest() + row.RightDev = UIUtil.CreateText(row.Group, "", 11, UIUtil.bodyFont) + row.RightDev:SetColor(MutedColor) + row.RightDev:DisableHitTest() + row.RightName = UIUtil.CreateText(row.Group, "", 14, UIUtil.bodyFont) + row.RightName:DisableHitTest() + + row.LeftLock = Bitmap(row.Group) + row.LeftLock:SetSolidColor(LockColor) + row.LeftLock:SetAlpha(0.0) + row.LeftLock.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' and not event.Modifiers.Right then + self:OnLockToggled(row.LeftOwner) + return true + end + return false + end + + row.RightLock = Bitmap(row.Group) + row.RightLock:SetSolidColor(LockColor) + row.RightLock:SetAlpha(0.0) + row.RightLock.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' and not event.Modifiers.Right then + self:OnLockToggled(row.RightOwner) + return true + end + return false + end + + -- hover tooltips: the click/drag halves explain the player values + interaction, the dots the lock + -- (AddControlTooltipManual chains the existing handlers, so click / drag / toggle still fire) + AddHelp(row.LeftSelect, Help.Player) + AddHelp(row.RightSelect, Help.Player) + AddHelp(row.LeftLock, Help.Lock) + AddHelp(row.RightLock, Help.Lock) + + return row + end, + + --- Builds a candidate-browser arrow ("<" / ">") — a faint clickable chip with a centred glyph. + --- `SetNavArrow` lights/dims it; clicking runs `onClick`. + ---@param self UICustomLobbyBalancePreview + ---@param glyph string + ---@param onClick fun() + ---@return Bitmap + CreateNavArrow = function(self, glyph, onClick) + local arrow = Bitmap(self.ActionArea) + arrow:SetSolidColor(SelectColor) + arrow:SetAlpha(0.0) + arrow.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' and not event.Modifiers.Right then + onClick() + return true + end + return false + end + local label = UIUtil.CreateText(arrow, glyph, 16, UIUtil.bodyFont) + label:SetColor(LabelColor) + label:DisableHitTest() + Layouter(label):AtHorizontalCenterIn(arrow):AtVerticalCenterIn(arrow):End() + arrow.Label = label + AddHelp(arrow, Help.Candidate) + return arrow + end, + + ---@param self UICustomLobbyBalancePreview + __post_init = function(self) + self.Width:Set(LayoutHelpers.ScaleNumber(DialogWidth)) + self.Height:Set(LayoutHelpers.ScaleNumber(DialogHeight)) + + Layouter(self.TitleArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtTopIn(self, Pad):Height(TitleHeight):End() + Layouter(self.HintArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AnchorToBottom(self.TitleArea, 2):Height(HintHeight):End() + Layouter(self.HeaderArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AnchorToBottom(self.HintArea, Pad):Height(HeaderHeight):End() + Layouter(self.ActionArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtBottomIn(self, Pad):Height(ActionHeight):End() + Layouter(self.StatusArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AnchorToTop(self.ActionArea, Pad):Height(StatusHeight):End() + Layouter(self.RowsArea) + :AtLeftIn(self, Pad):AtRightIn(self, Pad) + :AnchorToBottom(self.HeaderArea, Pad):AnchorToTop(self.StatusArea, Pad) + :End() + + Layouter(self.Title):AtHorizontalCenterIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() + Layouter(self.Hint):AtHorizontalCenterIn(self.HintArea):AtVerticalCenterIn(self.HintArea):End() + + -- team summary, on the same columns as the player rows: two stacked lines (HeaderTop = totals + + -- gap, HeaderBottom = win % + quality). The centre band spans both lines. + Layouter(self.HeaderTop):AtLeftIn(self.HeaderArea):AtRightIn(self.HeaderArea) + :AtTopIn(self.HeaderArea):Height(HeaderHeight / 2):End() + Layouter(self.HeaderBottom):AtLeftIn(self.HeaderArea):AtRightIn(self.HeaderArea) + :AnchorToBottom(self.HeaderTop, 0):AtBottomIn(self.HeaderArea):End() + Layouter(self.HeaderCenterArea):AtHorizontalCenterIn(self.HeaderArea):AtTopIn(self.HeaderArea) + :AtBottomIn(self.HeaderArea):Width(CenterWidth):End() + + -- top line: team label (outer) + per-team win % (column) and the match quality in the centre + Layouter(self.HeaderQuality):AtHorizontalCenterIn(self.HeaderCenterArea):AtVerticalCenterIn(self.HeaderTop):End() + Layouter(self.TeamLabelA):AtLeftIn(self.HeaderArea):AtVerticalCenterIn(self.HeaderTop):End() + Layouter(self.TeamWinA):AnchorToLeft(self.HeaderCenterArea, 8):AtVerticalCenterIn(self.HeaderTop):End() + Layouter(self.TeamLabelB):AtRightIn(self.HeaderArea):AtVerticalCenterIn(self.HeaderTop):End() + Layouter(self.TeamWinB):AnchorToRight(self.HeaderCenterArea, 8):AtVerticalCenterIn(self.HeaderTop):End() + + -- bottom line: per-team total (+dev, under the win %) and the gap total in the centre + Layouter(self.HeaderGap):AtHorizontalCenterIn(self.HeaderCenterArea):AtVerticalCenterIn(self.HeaderBottom):End() + Layouter(self.HeaderArrowL):AnchorToLeft(self.HeaderGap, 3):AtVerticalCenterIn(self.HeaderBottom):End() + Layouter(self.HeaderArrowR):AnchorToRight(self.HeaderGap, 3):AtVerticalCenterIn(self.HeaderBottom):End() + Layouter(self.TeamTotalA):AnchorToLeft(self.HeaderCenterArea, 8):AtVerticalCenterIn(self.HeaderBottom):End() + Layouter(self.TeamDevA):AnchorToLeft(self.TeamTotalA, 5):AtVerticalCenterIn(self.HeaderBottom):End() + Layouter(self.TeamTotalB):AnchorToRight(self.HeaderCenterArea, 8):AtVerticalCenterIn(self.HeaderBottom):End() + Layouter(self.TeamDevB):AnchorToRight(self.TeamTotalB, 5):AtVerticalCenterIn(self.HeaderBottom):End() + + -- stack the position rows from the top of the rows area + for i = 1, MaxRows do + local row = self.Rows[i] + Layouter(row.Group):AtLeftIn(self.RowsArea):AtRightIn(self.RowsArea):Height(RowHeight):End() + if i == 1 then + Layouter(row.Group):AtTopIn(self.RowsArea):End() + else + Layouter(row.Group):AnchorToBottom(self.Rows[i - 1].Group, 0):End() + end + + Layouter(row.DropHighlight):Fill(row.Group):End() + + Layouter(row.PosLabel):AtLeftIn(row.Group):AtVerticalCenterIn(row.Group):Width(PosLabelWidth):End() + Layouter(row.LeftLock):AnchorToRight(row.PosLabel, 2):AtVerticalCenterIn(row.Group) + :Width(LockSize):Height(LockSize):Over(row.Group, 10):End() + Layouter(row.RightLock):AtRightIn(row.Group):AtVerticalCenterIn(row.Group) + :Width(LockSize):Height(LockSize):Over(row.Group, 10):End() + + -- fixed-width centre band (so the rating columns either side line up across every row) + Layouter(row.CenterArea):AtHorizontalCenterIn(row.Group):AtTopIn(row.Group):AtBottomIn(row.Group) + :Width(CenterWidth):End() + Layouter(row.Center):AtHorizontalCenterIn(row.CenterArea):AtVerticalCenterIn(row.CenterArea):End() + Layouter(row.CenterArrowL):AnchorToLeft(row.Center, 3):AtVerticalCenterIn(row.CenterArea):End() + Layouter(row.CenterArrowR):AnchorToRight(row.Center, 3):AtVerticalCenterIn(row.CenterArea):End() + + -- ratings: a column hugging the centre band (left right-aligned, right left-aligned); the + -- muted mean sits just outside each rating; names run from the outer edge + Layouter(row.LeftRating):AnchorToLeft(row.CenterArea, 8):AtVerticalCenterIn(row.Group):End() + Layouter(row.LeftDev):AnchorToLeft(row.LeftRating, 5):AtVerticalCenterIn(row.Group):End() + Layouter(row.LeftName):AnchorToRight(row.LeftLock, 6):AtVerticalCenterIn(row.Group):End() + Layouter(row.RightRating):AnchorToRight(row.CenterArea, 8):AtVerticalCenterIn(row.Group):End() + Layouter(row.RightDev):AnchorToRight(row.RightRating, 5):AtVerticalCenterIn(row.Group):End() + Layouter(row.RightName):AnchorToLeft(row.RightLock, 6):AtVerticalCenterIn(row.Group):End() + + -- the swap-click / drag halves sit above the drop tint but below the texts (which ignore + -- hits) and stop short of the centre band; the lock bitmaps sit above them (Over) + Layouter(row.LeftSelect):AtTopIn(row.Group):AtBottomIn(row.Group) + :AtLeftIn(row.Group):AnchorToLeft(row.CenterArea, 2):End() + Layouter(row.RightSelect):AtTopIn(row.Group):AtBottomIn(row.Group) + :AtRightIn(row.Group):AnchorToRight(row.CenterArea, 2):End() + end + + Layouter(self.Status):AtLeftIn(self.StatusArea):AtVerticalCenterIn(self.StatusArea):End() + + -- candidate browser on the left of the action row + Layouter(self.NavPrev):AtLeftIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea) + :Width(NavSize):Height(NavSize):End() + Layouter(self.NavLabel):AnchorToRight(self.NavPrev, 6):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.NavNext):AnchorToRight(self.NavLabel, 6):AtVerticalCenterIn(self.ActionArea) + :Width(NavSize):Height(NavSize):End() + + Layouter(self.ApplyButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.CancelButton):AnchorToLeft(self.ApplyButton, ButtonGap):AtVerticalCenterIn(self.ActionArea):End() + end, + + --- Post-mount render (the opener calls this after Popup centres the dialog, so the rows read + --- concrete geometry). + ---@param self UICustomLobbyBalancePreview + Initialize = function(self) + self.Ready = true + self:Render() + end, + + --------------------------------------------------------------------------- + --#region Interaction + + --- A press on a row half: it becomes a **drag** (move this whole pair to another position) once the + --- cursor travels past the threshold, otherwise the release is a **click** (select this player for a + --- swap). Mirrors the lobby slot rows' press-vs-drag handling. + ---@param self UICustomLobbyBalancePreview + ---@param rowIndex number + ---@param slot number | nil + ---@param ownerId UILobbyPeerId | nil + ---@param event KeyEvent + BeginRowGesture = function(self, rowIndex, slot, ownerId, event) + local startX, startY = event.MouseX, event.MouseY + local moved = false + + local drag = Dragger() + drag.OnMove = function(dragSelf, x, y) + if not moved and (math.abs(x - startX) > DragThreshold or math.abs(y - startY) > DragThreshold) then + moved = true + end + if moved then + -- float the pair ghost beside the cursor + highlight the row under it (lobby feel) + if not self.DragGhost then + self.DragGhost = self:CreateDragGhost(rowIndex) + end + self.DragGhost.Left:Set(x + LayoutHelpers.ScaleNumber(12)) + self.DragGhost.Top:Set(y + LayoutHelpers.ScaleNumber(8)) + self:SetDropTarget(self:RowIndexAt(y), rowIndex) + end + end + drag.OnRelease = function(dragSelf, x, y) + if moved then + local target = self:RowIndexAt(y) + if target and target ~= rowIndex then + self:SwapPositions(rowIndex, target) + end + else + self:OnPlayerClicked(slot, ownerId) + end + self:EndDrag() + drag:Destroy() + end + drag.OnCancel = function(dragSelf) + self:EndDrag() + drag:Destroy() + end + PostDragger(self:GetRootFrame(), event.KeyCode, drag) + end, + + --- Clears the transient drag visuals (drop highlight + floating ghost). + ---@param self UICustomLobbyBalancePreview + EndDrag = function(self) + self:ClearDropHighlight() + if self.DragGhost then + self.DragGhost:Destroy() + self.DragGhost = false + end + end, + + --- Builds the floating drag ghost for a position's pair (both names, the lobby's dark chip), drawn + --- above the rows and offset from the cursor. Destroyed in EndDrag. + ---@param self UICustomLobbyBalancePreview + ---@param index number + ---@return Group + CreateDragGhost = function(self, index) + local position = (self.Result.positions or {})[index] + local a = position and position.a + local b = position and position.b + local name + if a and b then + name = a.name .. " · " .. b.name + elseif a then + name = a.name + elseif b then + name = b.name + else + name = "Position " .. tostring(index) + end + + local ghost = Group(self, "CustomLobbyBalanceDragGhost") + ghost:DisableHitTest() + + local bg = Bitmap(ghost) + bg:SetSolidColor('cc101418') + bg:DisableHitTest() + + local label = UIUtil.CreateText(ghost, name, 14, UIUtil.bodyFont) + label:DisableHitTest() + + Layouter(label):AtLeftTopIn(ghost, 6, 3):End() + Layouter(bg):Fill(ghost):End() + ghost.Width:Set(function() return label.Width() + LayoutHelpers.ScaleNumber(12) end) + ghost.Height:Set(function() return label.Height() + LayoutHelpers.ScaleNumber(6) end) + return ghost + end, + + --- A player half was clicked: select it for a swap, deselect it if it was the selection, or — if + --- another player is already selected — swap their seats and re-score. Empty halves and locked + --- players are inert (a locked player is pinned; unlock it first to move it). + ---@param self UICustomLobbyBalancePreview + ---@param slot number | nil + ---@param ownerId UILobbyPeerId | nil + OnPlayerClicked = function(self, slot, ownerId) + if not (slot and ownerId) or self.Locks[ownerId] then + return + end + if self.SelectedSlot == slot then + self.SelectedSlot = nil + self.SelectedOwner = nil + self:Render() + elseif self.SelectedSlot then + local arrangement = table.copy(self.Arrangement) + local other = self.SelectedSlot + arrangement[other], arrangement[slot] = arrangement[slot], arrangement[other] + self.Arrangement = arrangement + self.SelectedSlot = nil + self.SelectedOwner = nil + self:Rescore() + else + self.SelectedSlot = slot + self.SelectedOwner = ownerId + self:Render() + end + end, + + --- True if either player at position `index` is locked (so that whole row is pinned — it can't be + --- the source or target of a pair drag; unlock first to move it). + ---@param self UICustomLobbyBalancePreview + ---@param index number + ---@return boolean + PositionLocked = function(self, index) + local position = (self.Result.positions or {})[index] + if not position then + return false + end + return (position.a and position.a.locked or position.b and position.b.locked) and true or false + end, + + --- Swaps two positions' pairs in the working arrangement: both players of position `i` move to + --- position `j` and vice versa (sides preserved, so each pair stays together). Refuses if either + --- row holds a locked player. Re-scores. + ---@param self UICustomLobbyBalancePreview + ---@param i number + ---@param j number + SwapPositions = function(self, i, j) + local positions = self.Result.positions + local pi, pj = positions[i], positions[j] + if not (pi and pj) or self:PositionLocked(i) or self:PositionLocked(j) then + return + end + local arrangement = table.copy(self.Arrangement) + if pi.slotA and pj.slotA then + arrangement[pi.slotA], arrangement[pj.slotA] = arrangement[pj.slotA], arrangement[pi.slotA] + end + if pi.slotB and pj.slotB then + arrangement[pi.slotB], arrangement[pj.slotB] = arrangement[pj.slotB], arrangement[pi.slotB] + end + self.Arrangement = arrangement + self.SelectedSlot = nil + self.SelectedOwner = nil + self:Rescore() + end, + + --- A player's lock was clicked: toggle it in the working set and regenerate the candidates around + --- the new constraint (the locked player is pinned at its current seat; the rest re-balance). Blue + --- marks a locked player. + ---@param self UICustomLobbyBalancePreview + ---@param ownerId UILobbyPeerId | nil + OnLockToggled = function(self, ownerId) + if not ownerId then + return + end + local locks = table.copy(self.Locks) + locks[ownerId] = (not locks[ownerId]) or nil + self.Locks = locks + self:Regenerate(self.Arrangement) + end, + + --- Regenerates the candidate balances around the current locks, pinning locked players at their + --- seats in `arrangement`, and shows the best one. Used by open / Retry / a lock toggle. + ---@param self UICustomLobbyBalancePreview + ---@param arrangement table + Regenerate = function(self, arrangement) + local slots, teams = CurrentSnapshot() + self.Candidates = CustomLobbyBalancer.BuildCandidates(slots, teams, arrangement, self.Locks) + self.CandidateIndex = 1 + self:Adopt(self.Candidates[1]) + end, + + --- Browses to candidate `index` (clamped), discarding any unsaved manual swap on the shown one. + ---@param self UICustomLobbyBalancePreview + ---@param index number + ShowCandidate = function(self, index) + local count = table.getn(self.Candidates) + if index < 1 then + index = 1 + elseif index > count then + index = count + end + self.CandidateIndex = index + self:Adopt(self.Candidates[index]) + end, + + --- Re-scores the current working arrangement in place (after a swap / drag / lock toggle) — no + --- re-solve, so the hand-made arrangement is preserved; only the metrics and colours refresh. + ---@param self UICustomLobbyBalancePreview + Rescore = function(self) + local slots, teams = CurrentSnapshot() + self.Result = CustomLobbyBalancer.ScoreArrangement(slots, teams, self.Arrangement, self.Locks) + if self.Ready then + self:Render() + end + end, + + --- Adopts a freshly-solved plan as the new working state (clears the swap selection). + ---@param self UICustomLobbyBalancePreview + ---@param plan UICustomLobbyBalancePlan + Adopt = function(self, plan) + self.Result = plan + self.Arrangement = plan.arrangement + self.SelectedSlot = nil + self.SelectedOwner = nil + if self.Ready then + self:Render() + end + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Drag drop-target + + --- The visible row whose vertical bounds contain screen-y `y`, or nil. + ---@param self UICustomLobbyBalancePreview + ---@param y number + ---@return number | nil + RowIndexAt = function(self, y) + local count = table.getn(self.Result.positions or {}) + for i = 1, math.min(count, MaxRows) do + local group = self.Rows[i].Group + if y >= group.Top() and y <= group.Bottom() then + return i + end + end + return nil + end, + + --- Highlights `target` as the drop row during a drag — nothing if it's the source, invalid, or + --- either the source or target row is locked (a pinned pair can't move). + ---@param self UICustomLobbyBalancePreview + ---@param target number | nil + ---@param source number + SetDropTarget = function(self, target, source) + local droppable = target and target ~= source + and not self:PositionLocked(source) and not self:PositionLocked(target) + for i = 1, MaxRows do + self.Rows[i].DropHighlight:SetAlpha((droppable and target == i) and 0.15 or 0.0) + end + end, + + ---@param self UICustomLobbyBalancePreview + ClearDropHighlight = function(self) + for i = 1, MaxRows do + self.Rows[i].DropHighlight:SetAlpha(0.0) + end + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Rendering + + --- Fills one position row from a `UICustomLobbyBalancePosition` (either player may be absent on an + --- uneven split), and records its slots/owners for the click + drag handlers. + ---@param self UICustomLobbyBalancePreview + ---@param row UICustomLobbyBalanceRow + ---@param position UICustomLobbyBalancePosition + ---@param index number + FillRow = function(self, row, position, index) + row.Group:Show() + row.PosLabel:SetText(tostring(index)) + local a, b = position.a, position.b + self:FillHalf(row.LeftName, row.LeftRating, row.LeftDev, row.LeftLock, row.LeftSelect, a, position.slotA) + self:FillHalf(row.RightName, row.RightRating, row.RightDev, row.RightLock, row.RightSelect, b, position.slotB) + row.LeftOwner = a and a.ownerId or nil + row.LeftSlot = position.slotA + row.RightOwner = b and b.ownerId or nil + row.RightSlot = position.slotB + + -- the pair's mean gap, only when both players are rated — the magnitude is centred and a + -- "<" / ">" arrow points to the higher-rated side + if a and b and a.pl > 0 and b.pl > 0 then + local meanA = math.floor(a.mean + 0.5) + local meanB = math.floor(b.mean + 0.5) + self:SetDirectionalGap(row.Center, row.CenterArrowL, row.CenterArrowR, meanA, meanB, + GapColor(math.abs(meanA - meanB))) + else + row.Center:SetText("") + row.CenterArrowL:SetText("") + row.CenterArrowR:SetText("") + end + end, + + --- Shows a directional rating gap: the magnitude on `numberText` (coloured `color`) with `arrowL` + --- ("<") or `arrowR` (">") lit to point at the higher of `valueA` (left) / `valueB` (right). Equal + --- values show no arrow. + ---@param self UICustomLobbyBalancePreview + ---@param numberText Text + ---@param arrowL Text + ---@param arrowR Text + ---@param valueA number + ---@param valueB number + ---@param color string + SetDirectionalGap = function(self, numberText, arrowL, arrowR, valueA, valueB, color) + local diff = valueA - valueB + numberText:SetText(tostring(math.abs(diff))) + numberText:SetColor(color) + arrowL:SetText(diff > 0 and "<" or "") + arrowL:SetColor(color) + arrowR:SetText(diff < 0 and ">" or "") + arrowR:SetColor(color) + end, + + --- Paints one half of a row — name (outer), mean rating (centre column) + muted deviation "(±N)", + --- the lock dot and the selection highlight — for a player, or clears it when the half is empty. + ---@param self UICustomLobbyBalancePreview + ---@param name Text + ---@param rating Text + ---@param dev Text + ---@param lock Bitmap + ---@param select Bitmap + ---@param player UICustomLobbyBalancePlayer | nil + ---@param slot number | nil + FillHalf = function(self, name, rating, dev, lock, select, player, slot) + if not player then + name:SetText("") + rating:SetText("") + dev:SetText("") + lock:SetAlpha(0.0) + select:SetAlpha(0.0) + return + end + local color = PlayerColor(player) + name:SetText(player.name) + name:SetColor(color) + -- mean + deviation only for rated players (an unrated / AI player's mean is a placeholder) + if player.pl > 0 then + rating:SetText(tostring(math.floor(player.mean + 0.5))) + rating:SetColor(color) + dev:SetText("(±" .. tostring(math.floor(player.dev + 0.5)) .. ")") + else + rating:SetText("") + dev:SetText("") + end + -- the lock dot: solid blue when pinned, faint when not (a click toggles it) + lock:SetSolidColor(player.locked and LockColor or SelectColor) + lock:SetAlpha(player.locked and 1.0 or 0.25) + -- highlight the half that is the pending swap selection + select:SetAlpha(slot == self.SelectedSlot and 0.12 or 0.0) + end, + + --- Paints the position rows + the summary line from the working plan, and enables Apply only when + --- there is something to apply. + ---@param self UICustomLobbyBalancePreview + Render = function(self) + local result = self.Result + + self.TeamTotalA:SetText(tostring(math.floor(result.totals[1] + 0.5))) + self.TeamTotalB:SetText(tostring(math.floor(result.totals[2] + 0.5))) + self.TeamDevA:SetText("(±" .. tostring(math.floor(result.devTotals[1] + 0.5)) .. ")") + self.TeamDevB:SetText("(±" .. tostring(math.floor(result.devTotals[2] + 0.5)) .. ")") + self:SetDirectionalGap(self.HeaderGap, self.HeaderArrowL, self.HeaderArrowR, + math.floor(result.totals[1] + 0.5), math.floor(result.totals[2] + 0.5), HeaderColor) + + -- bottom line: each team's predicted win % (under its total) + the match quality (under the gap) + if result.winChance then + self.TeamWinA:SetText(tostring(result.winChance[1]) .. "%") + self.TeamWinB:SetText(tostring(result.winChance[2]) .. "%") + else + self.TeamWinA:SetText("") + self.TeamWinB:SetText("") + end + self.HeaderQuality:SetText(result.quality and (tostring(math.floor(result.quality + 0.5)) .. "%") or "n/a") + + local positions = result.positions or {} + local rowCount = table.getn(positions) + for i = 1, MaxRows do + local row = self.Rows[i] + if i <= rowCount then + self:FillRow(row, positions[i], i) + else + row.Group:Hide() + end + end + + self.Status:SetText(self:StatusLine()) + self:UpdateNav() + + if result.feasible then + self.ApplyButton:Enable() + else + self.ApplyButton:Disable() + end + end, + + --- Lights or dims a nav arrow (a dim arrow reads as unavailable — at the first / last candidate). + ---@param self UICustomLobbyBalancePreview + ---@param arrow Bitmap + ---@param enabled boolean + SetNavArrow = function(self, arrow, enabled) + arrow:SetAlpha(enabled and 0.12 or 0.0) + arrow.Label:SetColor(enabled and LabelColor or '00000000') + end, + + --- Updates the candidate browser: "i / N" + arrow availability. Hidden entirely with one candidate. + ---@param self UICustomLobbyBalancePreview + UpdateNav = function(self) + local count = table.getn(self.Candidates) + local multiple = count > 1 + self.NavLabel:SetText(multiple and (tostring(self.CandidateIndex) .. " / " .. tostring(count)) or "") + self:SetNavArrow(self.NavPrev, multiple and self.CandidateIndex > 1) + self:SetNavArrow(self.NavNext, multiple and self.CandidateIndex < count) + end, + + --- The status line now carries only situational notes — why it can't balance, or the odd one left + --- in place. Quality / win / totals live in the header. + ---@param self UICustomLobbyBalancePreview + ---@return string + StatusLine = function(self) + local result = self.Result + if result.reason then + return result.reason + end + if result.unassigned then + return result.unassigned.name .. " stays put (odd count)" + end + return "" + end, + + --#endregion + + --- Commits the working arrangement (host-authoritative) and closes. Locks toggled here are NOT + --- written back — they were only a balancing constraint for this session. The lobby stays pinned + --- (see OnDestroy) so the applied balance holds until the host unpins. + ---@param self UICustomLobbyBalancePreview + ApplyAndClose = function(self) + if self.Result.feasible then + CustomLobbyController.RequestApplyBalance(self.Arrangement) + self.Applied = true + end + self.OnCloseCb() + end, + + ---@param self UICustomLobbyBalancePreview + OnDestroy = function(self) + -- restore the pin state on cancel (anything but a committed Apply); Apply leaves it pinned + if not self.Applied then + CustomLobbyController.RequestSetSlotsPinned(self.PriorPinned) + end + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +--#region Singleton + open / close + +---@type Popup | false +local Instance = false + +--- Opens the balance preview over `parent` (host-only entry point — the slots header's balance button). +---@param parent? Control +function Open(parent) + parent = parent or GetFrame(0) + + if Instance then + Instance:Close() + end + + local popup + local content = CustomLobbyBalancePreview(parent, { + onClose = function() + if popup then + popup:Close() + end + end, + }) + + popup = Popup(parent, content) + local baseOnClosed = popup.OnClosed + popup.OnClosed = function(self) + baseOnClosed(self) + Instance = false + end + Instance = popup + + -- Popup has mounted + centred the content; now it's safe to populate the rows + content:Initialize() +end + +--- Closes the dialog if open. +function Close() + if Instance then + Instance:Close() + Instance = false + end +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + Close() +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua b/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua new file mode 100644 index 00000000000..9989f465151 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyBalancer.lua @@ -0,0 +1,667 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The auto-balance **kernel**: a pure function that builds a balance *plan* from the lobby snapshot. +-- It reads no models — the caller passes the slots derived model's already-resolved state — so it can +-- be reasoned about and tested in isolation, like CustomLobbyRules. +-- +-- **Positional only.** Auto-balance is offered only for binary AutoTeams modes (top/bottom, +-- left/right, odd/even), which guarantees exactly two sides. Under AutoTeams the **position decides +-- the team**, so this kernel never touches `Team`: it decides the two balanced teams + the pairing +-- and expresses the result as a **player -> slot arrangement**. The lobby applies the arrangement by +-- re-seating, and position -> team is resolved at launch (BuildGameConfiguration). +-- +-- The plan is built in three phases (USER_STORIES § R): +-- 1. TEAMS — split the free players into two even sides minimising a cheap rating-imbalance +-- heuristic (|Σdev−goal|·1.2 + |Σmean−goal|, as the legacy did — trueskill is too costly per +-- combination). Locked players are held on their position's side; an odd roster leaves the +-- lowest-rated unlocked player in place (never ejected). +-- 2. PAIRS — rank-match across the teams: the strongest of side A faces the strongest of side B, +-- etc. Who faces whom is decided by rating, never at random. +-- 3. POSITIONS — `ShufflePairsIntoSeats` scatters the pairs across the free mirror rows, so each +-- pair's *position* is random while the *pairing* stays fixed. Locked players keep their seat. +-- Then the chosen split is scored once with trueskill `computeQuality` for the preview. +-- +-- Ratings: the search + quality use MEAN / DEV; the preview shows MEAN +/- DEV per player, and the +-- per-side display `totals` are the sum of the players' MEAN. + +local Trueskill = import("/lua/ui/lobby/trueskill.lua") + +-- safety cap on the combination search (C(16,8) = 12870 worst case, comfortably under this) +local MaxBalanceEvaluations = 20000 + +--- A player reduced to just what balancing needs (projected from a slots-derived-model entry). +---@class UICustomLobbyBalancePlayer +---@field ownerId UILobbyPeerId +---@field name string +---@field pl number # display rating (per-side totals + rank sort) +---@field mean number # trueskill mean (search + quality) +---@field dev number # trueskill deviation +---@field locked boolean # the host pinned this seat +---@field side 1 | 2 # the seat's resolved auto-team side (current) +---@field slot number # current seat +---@field moved boolean # this player's proposed seat differs from its current seat (preview highlight) + +--- One mirror row of the layout: the k-th seat on side A faces the k-th seat on side B. The preview +--- renders the plan as an ordered list of these (a player may be absent on an uneven split), and a +--- pair drag-and-drop swaps two positions' occupants. +---@class UICustomLobbyBalancePosition +---@field slotA number | nil +---@field slotB number | nil +---@field a UICustomLobbyBalancePlayer | nil +---@field b UICustomLobbyBalancePlayer | nil + +--- A proposed balance. +---@class UICustomLobbyBalancePlan +---@field labels string[] # the two side labels, for the preview +---@field sides UICustomLobbyBalancePlayer[][] # the two teams, rank-sorted (totals / quality) +---@field positions UICustomLobbyBalancePosition[] # ordered mirror rows (k-th seat each side), for the preview +---@field totals number[] # per-side total mean (sum of the players' means) +---@field devTotals number[] # per-side total deviation (sum of the players' deviations) +---@field lockedOwners table # user-locked players (the preview marks them) +---@field unassigned UICustomLobbyBalancePlayer | false # the odd one left in place, if any +---@field feasible boolean # false = nothing to apply (Apply disabled) +---@field reason string | nil # why it can't / didn't balance, for the preview +---@field quality number | false # trueskill match quality % of the proposal, or false +---@field currentQuality number | false # trueskill match quality % of the CURRENT seating, for "before -> after" +---@field winChance number[] | false # predicted win % per side {a, b} for the proposal, or false +---@field arrangement table # the lobby update: slot -> ownerId (no team) + +--- Sorts players strongest-first by display rating, so two rating-sorted sides line up rank for rank. +---@param a UICustomLobbyBalancePlayer +---@param b UICustomLobbyBalancePlayer +---@return boolean +local function ByRatingDesc(a, b) + return a.pl > b.pl +end + +--- Scores a proposed two-side split with trueskill match quality. Returns false for AI / unrated +--- players (mean 0) or a degenerate matrix — the preview then shows "—". +---@param sides UICustomLobbyBalancePlayer[][] +---@return number | false +local function ComputeQuality(sides) + local teams = Trueskill.Teams.create() + for side = 1, 2 do + for _, bp in ipairs(sides[side]) do + if not bp.mean or bp.mean == 0 then + return false + end + teams:addPlayer(side, Trueskill.Player.create(bp.name, Trueskill.Rating.create(bp.mean, bp.dev))) + end + end + if table.getn(teams:getTeams()) ~= 2 then + return false + end + + local quality = Trueskill.computeQuality(teams) + if not quality or quality <= 0 then + return false + end + return quality +end + +-- trueskill's per-player performance variance (beta); matches the 250 baked into computeQuality. +local Beta = 250 + +--- Gauss error function (Abramowitz & Stegun 7.1.26) — max abs error ~1.5e-7, plenty for a display %. +---@param x number +---@return number +local function Erf(x) + local sign = x < 0 and -1 or 1 + x = math.abs(x) + local t = 1 / (1 + 0.3275911 * x) + local y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) + * t * math.exp(-x * x) + return sign * y +end + +--- Predicted win split for a two-side proposal, as integer percentages {winA, winB} summing to 100. +--- This is the probabilistic counterpart to the symmetric match quality: quality says "how close", +--- this says "which way". P(A beats B) = Phi(Δμ / sqrt(N·β² + Σσ²)) over both sides' players. Returns +--- false for AI / unrated players (mean 0) or an empty side — the preview then shows "—". +---@param sides UICustomLobbyBalancePlayer[][] +---@return number[] | false +local function ComputeWinChance(sides) + if table.getn(sides[1]) == 0 or table.getn(sides[2]) == 0 then + return false + end + local sumMean = { 0, 0 } + local sumVar = { 0, 0 } + local count = 0 + for side = 1, 2 do + for _, bp in ipairs(sides[side]) do + if not bp.mean or bp.mean == 0 then + return false + end + sumMean[side] = sumMean[side] + bp.mean + sumVar[side] = sumVar[side] + bp.dev * bp.dev + count = count + 1 + end + end + local denom = math.sqrt(count * Beta * Beta + sumVar[1] + sumVar[2]) + if denom <= 0 then + return false + end + local pA = 0.5 * (1 + Erf((sumMean[1] - sumMean[2]) / (denom * math.sqrt(2)))) + local a = math.floor(pA * 100 + 0.5) + return { a, 100 - a } +end + +--- Phase 3 — seats already-formed rank-matched pairs at randomly-chosen mirror rows: `pairsA[i]` vs +--- `pairsB[i]` is the i-th pair (formed by rating in phase 2), and it lands at the same free row on +--- both sides, so WHO faces whom stays fixed while the POSITION is random. `seatsA`/`seatsB` are the +--- free seats per side, paired by order into mirror rows (locked seats are excluded by the caller, so +--- locked players don't move). Invokes `place(slot, player)` for each seated player; any leftover +--- players — when the two sides have unequal free counts (asymmetric locks) — fill the remaining +--- seats so none are dropped. +---@param pairsA UICustomLobbyBalancePlayer[] +---@param pairsB UICustomLobbyBalancePlayer[] +---@param seatsA number[] +---@param seatsB number[] +---@param place fun(slot: number, player: UICustomLobbyBalancePlayer) +function ShufflePairsIntoSeats(pairsA, pairsB, seatsA, seatsB, place) + local rowCount = math.min(table.getn(seatsA), table.getn(seatsB)) + local rowOrder = {} + for k = 1, rowCount do + rowOrder[k] = k + end + for k = rowCount, 2, -1 do + local j = Random(1, k) + rowOrder[k], rowOrder[j] = rowOrder[j], rowOrder[k] + end + + local usedA, usedB = {}, {} + local pairCount = math.min(table.getn(pairsA), table.getn(pairsB), rowCount) + for i = 1, pairCount do + local row = rowOrder[i] + place(seatsA[row], pairsA[i]) + place(seatsB[row], pairsB[i]) + usedA[row], usedB[row] = true, true + end + + local function seatRest(players, seats, used, fromIndex) + local free = {} + for k = 1, table.getn(seats) do + if not used[k] then + table.insert(free, k) + end + end + local r = 1 + for i = fromIndex, table.getn(players) do + if not free[r] then + break + end + place(seats[free[r]], players[i]) + r = r + 1 + end + end + seatRest(pairsA, seatsA, usedA, pairCount + 1) + seatRest(pairsB, seatsB, usedB, pairCount + 1) +end + +--- Projects the slots snapshot into the minimal records balancing needs. `locks` (ownerId -> true), +--- when given, OVERRIDES each seat's own Locked flag — the preview passes its working lock set so a +--- lock toggled in the dialog is honoured without touching the synced lobby; nil falls back to +--- `entry.Locked`. +---@param slots UICustomLobbySlot[] +---@param locks table | nil +---@return UICustomLobbyBalancePlayer[] players # one per occupied side-1/2 seat (bp.locked from `locks`) +---@return table seatSide # slot -> side, for every side-1/2 seat +---@return table seatClosed # slot -> closed, for every side-1/2 seat +local function Project(slots, locks) + local players = {} + local seatSide = {} + local seatClosed = {} + for _, entry in ipairs(slots) do + local side = entry.Side + if side == 1 or side == 2 then + seatSide[entry.Slot] = side + seatClosed[entry.Slot] = entry.Closed and true or false + if entry.Player then + local locked + if locks then + locked = locks[entry.Player.OwnerID] and true or false + else + locked = entry.Locked and true or false + end + table.insert(players, { + ownerId = entry.Player.OwnerID, + name = entry.Player.PlayerName or "?", + pl = entry.Player.PL or 0, + mean = entry.Player.MEAN or 1500, + dev = entry.Player.DEV or 500, + locked = locked, + side = side, + slot = entry.Slot, + }) + end + end + end + return players, seatSide, seatClosed +end + +--- The identity arrangement (every seated player on its current seat) — the starting point for a fresh +--- solve from the lobby's current seating. +---@param slots UICustomLobbySlot[] +---@return table +local function IdentityArrangement(slots) + local arrangement = {} + for _, entry in ipairs(slots) do + if (entry.Side == 1 or entry.Side == 2) and entry.Player then + arrangement[entry.Slot] = entry.Player.OwnerID + end + end + return arrangement +end + +--- A fresh, empty plan; the gated fields are filled in by the scorer / solver. +---@param teams UICustomLobbyTeams +---@return UICustomLobbyBalancePlan +local function NewPlan(teams) + ---@type UICustomLobbyBalancePlan + return { + labels = (teams and teams.Labels) or { "Team 1", "Team 2" }, + sides = { {}, {} }, + positions = {}, + totals = { 0, 0 }, + devTotals = { 0, 0 }, + lockedOwners = {}, + unassigned = false, + feasible = false, + reason = nil, + quality = false, + currentQuality = false, + winChance = false, + arrangement = {}, + } +end + +--- Scores a concrete `arrangement` (slot -> ownerId) into `plan`: the two rank-sorted sides, the +--- per-side total mean, the moved flags (vs. each player's current lobby seat), the current-seating +--- quality, and the proposed quality + win split. Pure display computation — it moves no one. +--- `players` / `seatSide` come from Project. +---@param plan UICustomLobbyBalancePlan +---@param players UICustomLobbyBalancePlayer[] +---@param seatSide table +---@param arrangement table +local function ScorePlan(plan, players, seatSide, arrangement) + local byId = {} + for _, bp in ipairs(players) do + byId[bp.ownerId] = bp + if bp.locked then + plan.lockedOwners[bp.ownerId] = true + end + end + + -- the current seating's quality, for the "before -> after" readout + local currentSides = { {}, {} } + for _, bp in ipairs(players) do + table.insert(currentSides[bp.side], bp) + end + plan.currentQuality = ComputeQuality(currentSides) + + -- the proposed sides, from the arrangement; a player's side is its seat's side + plan.arrangement = arrangement + for slot, ownerId in arrangement do + local bp = byId[ownerId] + local side = seatSide[slot] + if bp and side then + bp.moved = slot ~= bp.slot + table.insert(plan.sides[side], bp) + end + end + table.sort(plan.sides[1], ByRatingDesc) + table.sort(plan.sides[2], ByRatingDesc) + for side = 1, 2 do + local total = 0 + local devTotal = 0 + for _, bp in ipairs(plan.sides[side]) do + total = total + bp.mean + devTotal = devTotal + bp.dev + end + plan.totals[side] = total + plan.devTotals[side] = devTotal + end + + plan.quality = ComputeQuality(plan.sides) + plan.winChance = ComputeWinChance(plan.sides) + + -- ordered mirror positions for the preview: the k-th side-1 seat faces the k-th side-2 seat (both + -- sorted by slot, so the list is stable across re-scores — a position swap moves the players + -- between seats, not the row order) + local slotsA, slotsB = {}, {} + for slot, _ in arrangement do + local side = seatSide[slot] + if side == 1 then + table.insert(slotsA, slot) + elseif side == 2 then + table.insert(slotsB, slot) + end + end + table.sort(slotsA) + table.sort(slotsB) + local posCount = math.max(table.getn(slotsA), table.getn(slotsB)) + for k = 1, posCount do + local slotA, slotB = slotsA[k], slotsB[k] + plan.positions[k] = { + slotA = slotA, + slotB = slotB, + a = slotA and byId[arrangement[slotA]] or nil, + b = slotB and byId[arrangement[slotB]] or nil, + } + end +end + +--- Scores an arrangement the host hand-edited (a manual swap, or after a lock toggle) WITHOUT +--- re-solving — everyone stays exactly where `arrangement` puts them, only the metrics refresh. `locks` +--- is the preview's working lock set (see Project). Apply is enabled whenever both sides are non-empty. +---@param slots UICustomLobbySlot[] +---@param teams UICustomLobbyTeams +---@param arrangement table +---@param locks table | nil +---@return UICustomLobbyBalancePlan +function ScoreArrangement(slots, teams, arrangement, locks) + local plan = NewPlan(teams) + if not (teams and teams.Mode) or not teams.Resolved then + plan.reason = "Auto-balance needs an AutoTeams mode with a loaded map." + return plan + end + local players, seatSide = Project(slots, locks) + if table.getn(players) < 2 then + plan.reason = "Need at least two players to balance." + return plan + end + ScorePlan(plan, players, seatSide, arrangement) + plan.feasible = table.getn(plan.sides[1]) > 0 and table.getn(plan.sides[2]) > 0 + return plan +end + +-- how many candidate splits the search keeps (caller asks for `count`; we collect a wider pool so that +-- after discarding mirror-equivalent splits there are still enough genuinely-different ones) +local DefaultCandidateCount = 5 + +--- Builds up to `count` candidate balance plans — the best distinct team splits, each a complete, +--- scored plan — best-first by match quality. The host browses them in the preview. Re-solves the +--- UNLOCKED players, keeping locked players (and, for an odd roster, the lowest-rated unlocked player) +--- pinned at their seat in `arrangement` — so a balance honours where the host has already locked +--- people, even when those seats differ from the lobby. Each candidate gets its own random position +--- shuffle. Always returns a non-empty list; an infeasible case is a single plan carrying the reason. +---@param slots UICustomLobbySlot[] +---@param teams UICustomLobbyTeams +---@param arrangement table # current seats (pinned players are read from here) +---@param locks table | nil +---@param count? number # how many candidates to return (default 5) +---@return UICustomLobbyBalancePlan[] +function BuildCandidates(slots, teams, arrangement, locks, count) + count = count or DefaultCandidateCount + + -- the feature is gated to a binary AutoTeams mode with a resolved split; bail defensively otherwise + if not (teams and teams.Mode) or not teams.Resolved then + local plan = NewPlan(teams) + plan.reason = "Auto-balance needs an AutoTeams mode with a loaded map." + return { plan } + end + + local players, seatSide, seatClosed = Project(slots, locks) + local occupiedCount = table.getn(players) + if occupiedCount < 2 then + local plan = NewPlan(teams) + plan.reason = "Need at least two players to balance." + return { plan } + end + + -- where each player currently sits (its seat in the working arrangement, or its lobby seat if it + -- isn't in one). Pinned players are held here rather than at their lobby seat, so locking a player + -- the balance already moved keeps them where the host sees them. + local slotOf = {} + for slot, ownerId in arrangement do + slotOf[ownerId] = slot + end + + -- pinned = locked players + (odd roster) the lowest-rated unlocked player, left in place so the rest + -- pair up (never ejected) + local pinned = {} + ---@type UICustomLobbyBalancePlayer | false + local unassigned = false + for _, bp in ipairs(players) do + if bp.locked then + pinned[bp.ownerId] = true + end + end + if math.mod(occupiedCount, 2) == 1 then + local odd + for _, bp in ipairs(players) do + if not pinned[bp.ownerId] and (not odd or (bp.mean - bp.dev * 2.2) < (odd.mean - odd.dev * 2.2)) then + odd = bp + end + end + if odd then + pinned[odd.ownerId] = true + unassigned = odd + end + end + + -- pinned players hold their current seat (off-limits to the search); everyone else is the free pool + local solvedBase = {} + local pinnedSlots = {} + local lockedMean = { 0, 0 } + local lockedDev = { 0, 0 } + local lockedCount = { 0, 0 } + local freePool = {} + local totalMean, totalDev = 0, 0 + for _, bp in ipairs(players) do + totalMean = totalMean + bp.mean + totalDev = totalDev + bp.dev + if pinned[bp.ownerId] then + local seat = slotOf[bp.ownerId] or bp.slot + local side = seatSide[seat] or bp.side + solvedBase[seat] = bp.ownerId + pinnedSlots[seat] = true + lockedMean[side] = lockedMean[side] + bp.mean + lockedDev[side] = lockedDev[side] + bp.dev + lockedCount[side] = lockedCount[side] + 1 + else + table.insert(freePool, bp) + end + end + + -- the seats the search can fill: every side-1/2 seat that isn't closed or pinned (the seats freed by + -- the unlocked players + any open seats) + local freeSeats = { {}, {} } + for slot, side in seatSide do + if not seatClosed[slot] and not pinnedSlots[slot] then + table.insert(freeSeats[side], slot) + end + end + table.sort(freeSeats[1]) + table.sort(freeSeats[2]) + + local freeCount = table.getn(freePool) + + -- shuffle the free pool so re-running varies which equally-good split / seating is picked + for i = freeCount, 2, -1 do + local j = Random(1, i) + freePool[i], freePool[j] = freePool[j], freePool[i] + end + + --- Builds + scores one plan from a chosen side-A index set (a position shuffle per call). + ---@param chosenList number[] + ---@return UICustomLobbyBalancePlan + local function PlanFor(chosenList) + local chosen = {} + for _, index in ipairs(chosenList) do + chosen[index] = true + end + local freeA, freeB = {}, {} + for i = 1, freeCount do + table.insert(chosen[i] and freeA or freeB, freePool[i]) + end + table.sort(freeA, ByRatingDesc) + table.sort(freeB, ByRatingDesc) + local arr = table.copy(solvedBase) + ShufflePairsIntoSeats(freeA, freeB, freeSeats[1], freeSeats[2], + function(slot, bp) arr[slot] = bp.ownerId end) + local plan = NewPlan(teams) + plan.unassigned = unassigned + ScorePlan(plan, players, seatSide, arr) + plan.feasible = freeCount > 0 + return plan + end + + -- nothing to rearrange (everyone pinned): one plan showing the pinned board + if freeCount == 0 then + local plan = PlanFor({}) + plan.feasible = false + plan.reason = "Every player is locked in place — nothing to rearrange." + return { plan } + end + + -- how many free players go to side A: aim for equal final sizes, clamped to each side's free seats + local capA, capB = table.getn(freeSeats[1]), table.getn(freeSeats[2]) + local placedA = math.floor((freeCount + lockedCount[2] - lockedCount[1]) / 2 + 0.5) + local minA = math.max(0, freeCount - capB) + local maxA = math.min(freeCount, capA) + if minA > maxA then + local plan = NewPlan(teams) + plan.reason = "The locked players don't fit the team layout." + ScorePlan(plan, players, seatSide, solvedBase) -- still show the pinned players + return { plan } + end + if placedA < minA then placedA = minA end + if placedA > maxA then placedA = maxA end + + -- search: enumerate side-A choices, keeping the top `poolSize` by the cheap rating-imbalance + -- heuristic (a wider pool than `count`, so mirror-equivalent splits can be dropped below) + local goalMean, goalDev = totalMean / 2, totalDev / 2 + local poolSize = math.max(count * 4, 12) + local kept = {} -- {value, chosen}, sorted ascending by value + local evaluations = 0 + local current = {} + local function consider(value) + local n = table.getn(kept) + if n >= poolSize and value >= kept[n].value then + return + end + local pos = n + 1 + for i = 1, n do + if value < kept[i].value then + pos = i + break + end + end + table.insert(kept, pos, { value = value, chosen = table.copy(current) }) + if table.getn(kept) > poolSize then + table.remove(kept) + end + end + local function evaluate() + local meanA, devA = lockedMean[1], lockedDev[1] + for i = 1, table.getn(current) do + local bp = freePool[current[i]] + meanA = meanA + bp.mean + devA = devA + bp.dev + end + consider(math.abs(devA - goalDev) * 1.2 + math.abs(meanA - goalMean)) + evaluations = evaluations + 1 + end + local function choose(startIndex, remaining) + if evaluations >= MaxBalanceEvaluations then + return + end + if remaining == 0 then + evaluate() + return + end + for i = startIndex, freeCount - remaining + 1 do + table.insert(current, i) + choose(i + 1, remaining - 1) + table.remove(current) + if evaluations >= MaxBalanceEvaluations then + return + end + end + end + choose(1, placedA) + + -- build the best distinct splits, skipping mirror-equivalent ones (side A = the complement of an + -- already-taken split is the same matchup with the teams flipped) + local plans = {} + local seen = {} + for _, entry in ipairs(kept) do + if table.getn(plans) >= count then + break + end + local inA = {} + for _, index in ipairs(entry.chosen) do + inA[index] = true + end + local sigA, sigB = "", "" + for i = 1, freeCount do + if inA[i] then + sigA = sigA .. tostring(i) .. "," + else + sigB = sigB .. tostring(i) .. "," + end + end + local signature = (sigA < sigB) and (sigA .. "|" .. sigB) or (sigB .. "|" .. sigA) + if not seen[signature] then + seen[signature] = true + table.insert(plans, PlanFor(entry.chosen)) + end + end + + -- present the most balanced first (by the displayed metric — match quality) + table.sort(plans, function(p, q) + return (p.quality or 0) > (q.quality or 0) + end) + return plans +end + +--- Re-solves into a single best balance plan — the first of BuildCandidates. Used where one plan is +--- enough (Retry's re-roll picks among the candidate set in the preview). +---@param slots UICustomLobbySlot[] +---@param teams UICustomLobbyTeams +---@param arrangement table +---@param locks table | nil +---@return UICustomLobbyBalancePlan +function Rebalance(slots, teams, arrangement, locks) + return BuildCandidates(slots, teams, arrangement, locks, 1)[1] +end + +--- Builds the candidate balance plans from the lobby's current seating — a fresh solve honouring +--- `locks` (the preview's working lock set; nil = the seats' own Locked flags). +---@param slots UICustomLobbySlot[] +---@param teams UICustomLobbyTeams +---@param locks? table +---@param count? number +---@return UICustomLobbyBalancePlan[] +function BuildPlan(slots, teams, locks, count) + return BuildCandidates(slots, teams, IdentityArrangement(slots), locks, count) +end + +--- The lobby update for a plan: the player -> slot arrangement to apply. `Team` is intentionally +--- absent — under AutoTeams the position decides the team (resolved at launch). +---@param plan UICustomLobbyBalancePlan +---@return table +function ToArrangement(plan) + return plan.arrangement +end diff --git a/lua/ui/lobby/customlobby/CustomLobbyContextMenu.lua b/lua/ui/lobby/customlobby/CustomLobbyContextMenu.lua new file mode 100644 index 00000000000..041a74b9a38 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyContextMenu.lua @@ -0,0 +1,244 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- A generic, content-agnostic context menu: hand it a list of entries and a screen +-- position and it draws a framed vertical list, runs the chosen entry's action, and +-- dismisses itself (item click, click-outside, or Esc). It knows nothing about the +-- lobby — what the entries are and when they apply lives in CustomLobbyMenus.lua, so +-- adding or state-gating an item never touches this file. +-- +-- Framed like the rest of the lobby (chat-config border + dark fill). Singleton on +-- GetFrame(0); each Show rebuilds it for the given entries. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local EscapeHandler = import("/lua/ui/dialogs/eschandler.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap + +local Layouter = LayoutHelpers.ReusedLayoutFor + +local ItemHeight = 22 +local MenuPad = 4 +local LabelPadX = 10 +local MinWidth = 130 +local FontSize = 13 + +--- One row in a context menu. +---@class UICustomLobbyContextMenuItem +---@field label string +---@field action fun() # run on click +---@field enabled? boolean # default true; a disabled item is greyed and inert + +------------------------------------------------------------------------------- + +---@class UICustomLobbyContextMenuRow : Group +---@field Surface Bitmap +---@field Label Text + +---@class UICustomLobbyContextMenu : Group +---@field Border Bitmap +---@field Background Bitmap +---@field Rows UICustomLobbyContextMenuRow[] +local CustomLobbyContextMenu = ClassUI(Group) { + + ---@param self UICustomLobbyContextMenu + ---@param parent Control + ---@param entries UICustomLobbyContextMenuItem[] + __init = function(self, parent, entries) + Group.__init(self, parent, "CustomLobbyContextMenu") + + self.Border = Bitmap(self) + self.Border:SetSolidColor('ff415055') + self.Border:DisableHitTest() + + self.Background = Bitmap(self) + self.Background:SetSolidColor('f0101418') + self.Background:DisableHitTest() + + self.Rows = {} + for i = 1, table.getn(entries) do + self.Rows[i] = self:CreateRow(entries[i]) + end + end, + + --- Builds one clickable row. The surface bitmap both catches the mouse and is the + --- hover highlight; the label draws on top with hit-testing off. + ---@param self UICustomLobbyContextMenu + ---@param entry UICustomLobbyContextMenuItem + ---@return UICustomLobbyContextMenuRow + CreateRow = function(self, entry) + local enabled = entry.enabled ~= false + + ---@type UICustomLobbyContextMenuRow + local row = Group(self) + + row.Surface = Bitmap(row) + row.Surface:SetSolidColor('ffffffff') + row.Surface:SetAlpha(0.0) + + row.Label = UIUtil.CreateText(row, entry.label, FontSize, UIUtil.bodyFont) + row.Label:SetColor(enabled and 'ffffffff' or 'ff666666') + row.Label:DisableHitTest() + + if enabled then + row.Surface.HandleEvent = function(control, event) + if event.Type == 'MouseEnter' then + control:SetAlpha(0.12) + return true + elseif event.Type == 'MouseExit' then + control:SetAlpha(0.0) + return true + elseif event.Type == 'ButtonPress' then + -- close first, so an action that opens another dialog isn't undone + Hide() + entry.action() + return true + end + return false + end + else + row.Surface:DisableHitTest() + end + + return row + end, + + ---@param self UICustomLobbyContextMenu + __post_init = function(self) + local rowCount = table.getn(self.Rows) + + -- width = widest label (+ padding), floored at a minimum + local widest = 0 + for i = 1, rowCount do + local w = self.Rows[i].Label.Width() + if w > widest then + widest = w + end + end + self.Width:Set(math.max(LayoutHelpers.ScaleNumber(MinWidth), widest + LayoutHelpers.ScaleNumber(LabelPadX * 2))) + self.Height:Set(LayoutHelpers.ScaleNumber(MenuPad * 2 + rowCount * ItemHeight)) + + Layouter(self.Border):Fill(self):End() + Layouter(self.Background) + :AtLeftIn(self, 1):AtRightIn(self, 1):AtTopIn(self, 1):AtBottomIn(self, 1) + :End() + + for i = 1, rowCount do + local row = self.Rows[i] + local top = MenuPad + (i - 1) * ItemHeight + Layouter(row) + :AtLeftIn(self, MenuPad):AtRightIn(self, MenuPad):Height(ItemHeight) + :Top(function() return self.Top() + LayoutHelpers.ScaleNumber(top) end) + :End() + Layouter(row.Surface):Fill(row):End() + Layouter(row.Label):AtLeftIn(row, LabelPadX - MenuPad):AtVerticalCenterIn(row):End() + end + end, +} + +------------------------------------------------------------------------------- +-- Singleton + show / hide + +local ModuleTrash = TrashBag() + +---@type UICustomLobbyContextMenu | false +local Instance = false +---@type Bitmap | false +local Cover = false + +--- A full-screen invisible catcher behind the menu: a click anywhere off the menu +--- dismisses it. +---@return Bitmap +local function CreateCover() + local cover = Bitmap(GetFrame(0)) + cover:SetSolidColor('00000000') + cover.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + Hide() + return true + end + return false + end + Layouter(cover):Fill(GetFrame(0)):End() + return cover +end + +--- Opens a context menu at the screen point, populated with `entries` (no-op for an +--- empty list). Replaces any menu already open. +---@param entries UICustomLobbyContextMenuItem[] +---@param x number +---@param y number +function Show(entries, x, y) + Hide() + if not entries or table.empty(entries) then + return + end + + local frame = GetFrame(0) + + -- The slot rows raise their hit area with `Over(self, ...)`, so a default-depth + -- overlay sits *under* them and never catches the click. Pin the cover and the + -- menu above everything (the popup.lua pattern) so click-outside actually fires. + local baseDepth = frame:GetTopmostDepth() + + Cover = CreateCover() + Cover.Depth:Set(baseDepth + 10) + + Instance = CustomLobbyContextMenu(frame, entries) + Instance.Depth:Set(baseDepth + 20) + ModuleTrash:Add(Instance) + + -- keep the menu fully on-screen + local left = math.min(x, frame.Right() - Instance.Width()) + local top = math.min(y, frame.Bottom() - Instance.Height()) + Instance.Left:Set(math.max(0, left)) + Instance.Top:Set(math.max(0, top)) + + -- Esc closes the menu before it would reach the lobby's leave handler + EscapeHandler.PushEscapeHandler(function() Hide() end) +end + +--- Closes the open menu (if any). +function Hide() + if not Instance then + return + end + EscapeHandler.PopEscapeHandler() + Instance:Destroy() + Instance = false + if Cover then + Cover:Destroy() + Cover = false + end +end + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + Hide() + ModuleTrash:Destroy() +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbyController.lua b/lua/ui/lobby/customlobby/CustomLobbyController.lua new file mode 100644 index 00000000000..e02c8b1954c --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyController.lua @@ -0,0 +1,1740 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- Lobby logic for the custom lobby, as free functions (the autolobby pattern). The +-- engine instantiates the thin CustomLobbyInstance and forwards its callbacks here, +-- passing itself as `instance`. The host is authoritative; state goes to the three +-- models via their write helpers: +-- * CustomLobbyLaunchModel — shared, launched (players, scenario, options, mods). +-- * CustomLobbySessionModel — shared, lobby-room only (slot count, closed slots). +-- * CustomLobbyLocalModel — per-peer, never synced (identity, CPU benchmarks). +-- +-- Barebones host-authority flow: +-- host : OnHosting -> seats itself in slot 1 +-- client : OnConnectionToHostEstablished -> SendData(host, AddPlayer) +-- host : ProcessAddPlayer -> seats the peer -> broadcasts players + launch + session +-- everyone: Process* -> applies the snapshot -> components react +-- ready : RequestSetReady (intent) -> host applies + broadcasts SetPlayers +-- +-- See /lua/ui/lobby/TARGET_ARCHITECTURE.md § 5. + +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") +local CustomLobbyPresets = import("/lua/ui/lobby/customlobby/customlobbypresets.lua") +local CustomLobbyChatModel = import("/lua/ui/lobby/customlobby/social/customlobbychatmodel.lua") + +--- The live lobby object, set by the first engine callback. UI-triggered intents +--- (RequestSetReady, …) reach the network through it without threading it everywhere. +---@type UICustomLobbyInstance | false +local LobbyInstance = false + +-- delay between the close-assert and the open broadcast in RequestReopenClosedSlots +local ReopenClosedSlotsDelay = 0.5 + +------------------------------------------------------------------------------- +--#region Helpers + +--- First empty player slot within the active slot count, or nil. +---@return number | nil +local function FindFreeSlot() + local launch = CustomLobbyLaunchModel.GetSingleton() + local session = CustomLobbySessionModel.GetSingleton() + for slot = 1, session.SlotCount() do + if not launch.Players[slot]() then + return slot + end + end + return nil +end + +--- The slot a peer occupies, or nil. +---@param ownerId UILobbyPeerId +---@return number | nil +local function FindSlotForOwner(ownerId) + local launch = CustomLobbyLaunchModel.GetSingleton() + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local player = launch.Players[slot]() + if player and player.OwnerID == ownerId then + return slot + end + end + return nil +end + +--- The player occupying `slot` within the player array, or nil. Tolerant of the +--- arbitrary slot values a chat command might pass. +---@param slot any +---@return UICustomLobbyPlayer | nil +local function PlayerInSlot(slot) + if type(slot) ~= 'number' or slot < 1 or slot > CustomLobbyLaunchModel.MaxSlots then + return nil + end + return CustomLobbyLaunchModel.GetSingleton().Players[slot]() or nil +end + +--- The display name of the peer that owns `ownerId` — its seated player's `PlayerName`, else its +--- observer entry's, else the raw peer id. The host uses this to stamp chat lines authoritatively +--- (clients don't get to spoof a name); the chat send pipeline uses it for the optimistic echo. +---@param ownerId UILobbyPeerId +---@return string +function FindNameForOwner(ownerId) + local slot = FindSlotForOwner(ownerId) + local player = slot and PlayerInSlot(slot) + if player then + return player.PlayerName + end + for _, observer in CustomLobbyLaunchModel.GetSingleton().Observers() do + if observer.OwnerID == ownerId then + return observer.PlayerName + end + end + return tostring(ownerId) +end + +--- A set (`key -> true`) from a list of keys, so an order-independent `table.equal` can compare two +--- key lists (the restrictions list is order-independent, like the mods/options keyed tables already are). +---@param keys string[] +---@return table +local function KeySet(keys) + local set = {} + for _, key in keys do + set[key] = true + end + return set +end +---@return table +local function GatherPlayers() + local launch = CustomLobbyLaunchModel.GetSingleton() + local players = {} + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + players[slot] = launch.Players[slot]() or false + end + return players +end + +--- Host broadcasts the player + observer snapshot (part of the launch state) to everyone. +---@param instance UICustomLobbyInstance +local function BroadcastPlayers(instance) + local launch = CustomLobbyLaunchModel.GetSingleton() + instance:BroadcastData({ + Type = 'SetPlayers', + Players = GatherPlayers(), + Observers = launch.Observers(), + }) +end + +--- Whether `slot` is a real, currently-open seat (in range, empty, not closed). +---@param slot any +---@return boolean +local function IsOpenSlot(slot) + local session = CustomLobbySessionModel.GetSingleton() + if type(slot) ~= 'number' or slot < 1 or slot > session.SlotCount() then + return false + end + return not CustomLobbyLaunchModel.GetSingleton().Players[slot]() and not session.ClosedSlots()[slot] +end + +--- Host-side: drops any auto-balance lock pinning `slot`. A lock belongs to the player seated there, +--- so it must not outlive them and pin whoever takes the seat next (see CustomLobbyBalancer). Does +--- NOT broadcast — returns whether a lock was actually cleared so the caller can fold a session-state +--- snapshot into its existing broadcast only when something changed. +---@param slot number +---@return boolean # true if a lock was cleared +local function ClearSlotLock(slot) + local session = CustomLobbySessionModel.GetSingleton() + if not session.LockedSlots()[slot] then + return false + end + CustomLobbySessionModel.SetLocked(session, slot, false) + return true +end + +--- Host-side: moves the player owned by `ownerId` into an open `slot` (or seats it from +--- the observer list), forcing it unready and keeping StartSpot mirrored to the seat. +--- Broadcasts the new snapshot. A no-op if the move isn't valid — the host is the gate. +---@param instance UICustomLobbyInstance +---@param ownerId UILobbyPeerId +---@param slot number +local function TakeSlot(instance, ownerId, slot) + if not IsOpenSlot(slot) then + return + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + local from = FindSlotForOwner(ownerId) + local player + if from then + if from == slot then + return + end + player = table.copy(launch.Players[from]()) + CustomLobbyLaunchModel.ClearPlayer(launch, from) + -- the player relocated; the seat it left must not stay locked (the lock was theirs) + if ClearSlotLock(from) then + BroadcastSessionState(instance) + end + else + -- not in a slot: an observer joining a slot (the reverse of Move to observers) + local observer = CustomLobbyLaunchModel.RemoveObserver(launch, ownerId) + if not observer then + return + end + player = table.copy(observer) + end + + player.StartSpot = slot + player.Ready = false -- (re)seating resets readiness + CustomLobbyLaunchModel.SetPlayer(launch, slot, player) + BroadcastPlayers(instance) +end + +--- Host-side: swaps the contents of two slots (players and/or empties), forcing any +--- moved player unready and keeping StartSpot mirrored to the seat. Broadcasts. +---@param instance UICustomLobbyInstance +---@param slotA number +---@param slotB number +local function SwapSlots(instance, slotA, slotB) + local count = CustomLobbySessionModel.GetSingleton().SlotCount() + if type(slotA) ~= 'number' or type(slotB) ~= 'number' then + return + end + if slotA == slotB or slotA < 1 or slotA > count or slotB < 1 or slotB > count then + return + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + local a = launch.Players[slotA]() + local b = launch.Players[slotB]() + if a then + a = table.copy(a) + a.StartSpot = slotB + a.Ready = false + end + if b then + b = table.copy(b) + b.StartSpot = slotA + b.Ready = false + end + + -- a seat that ends up empty must shed its lock (the lock belonged to the player who left it), + -- so a later occupant isn't silently pinned + local lockChanged = false + if b then + CustomLobbyLaunchModel.SetPlayer(launch, slotA, b) + else + CustomLobbyLaunchModel.ClearPlayer(launch, slotA) + lockChanged = ClearSlotLock(slotA) or lockChanged + end + if a then + CustomLobbyLaunchModel.SetPlayer(launch, slotB, a) + else + CustomLobbyLaunchModel.ClearPlayer(launch, slotB) + lockChanged = ClearSlotLock(slotB) or lockChanged + end + BroadcastPlayers(instance) + if lockChanged then + BroadcastSessionState(instance) + end + + -- announce the swap/move (the host performs every swap, whether it started the drag or a client did) + local aName = a and a.PlayerName + local bName = b and b.PlayerName + if aName and bName then + BroadcastSystemNotice(instance, aName .. " and " .. bName .. " swapped seats.") + elseif aName then + BroadcastSystemNotice(instance, aName .. " moved to seat " .. slotB .. ".") + elseif bName then + BroadcastSystemNotice(instance, bName .. " moved to seat " .. slotA .. ".") + end +end + +--- Whether `color` (a PlayerColor index) is already used by a seated player other than `exceptSlot`. +--- Colours are scarce — two players can't share one — so a colour change is rejected if taken. +---@param color number +---@param exceptSlot number +---@return boolean +local function IsColorTaken(color, exceptSlot) + local launch = CustomLobbyLaunchModel.GetSingleton() + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + if slot ~= exceptSlot then + local player = launch.Players[slot]() + if player and player.PlayerColor == color then + return true + end + end + end + return false +end + +--- Host-side: applies a faction multi-select to the player in `slot` (normalised) and re-broadcasts. +--- `Factions` is the source of truth; `Faction` is kept as its representative. A no-op on an empty slot. +---@param instance UICustomLobbyInstance +---@param slot number | nil +---@param factions number[] +local function ApplyFactions(instance, slot, factions) + local launch = CustomLobbyLaunchModel.GetSingleton() + if not (slot and launch.Players[slot]()) then + return + end + local normalized = CustomLobbyLaunchModel.NormalizeFactions(factions) + CustomLobbyLaunchModel.SetPlayerFields(launch, slot, { + Factions = normalized, + Faction = CustomLobbyLaunchModel.RepresentativeFaction(normalized), + }) + BroadcastPlayers(instance) +end + +--- Host-side: applies a colour to the player in `slot` (both PlayerColor + ArmyColor) and re-broadcasts. +--- Rejected (logged) if the colour is already taken by another seated player, or the slot is empty. +---@param instance UICustomLobbyInstance +---@param slot number | nil +---@param color number +local function ApplyColor(instance, slot, color) + local launch = CustomLobbyLaunchModel.GetSingleton() + if not (slot and launch.Players[slot]() and type(color) == 'number') then + return + end + if IsColorTaken(color, slot) then + WARN("CustomLobby: colour " .. tostring(color) .. " is already taken; ignoring") + return + end + CustomLobbyLaunchModel.SetPlayerFields(launch, slot, { PlayerColor = color, ArmyColor = color }) + BroadcastPlayers(instance) +end + +--- Host-side: applies a team to the player in `slot` (backend numbering: 1 = no team, 2..9 = teams +--- 1..8) and re-broadcasts. A no-op on an empty slot. (Binary AutoTeams modes still override the team +--- from the start position at launch — see `BuildGameConfiguration`.) +---@param instance UICustomLobbyInstance +---@param slot number | nil +---@param team number +local function ApplyTeam(instance, slot, team) + local launch = CustomLobbyLaunchModel.GetSingleton() + if not (slot and launch.Players[slot]() and type(team) == 'number') then + return + end + CustomLobbyLaunchModel.SetPlayerField(launch, slot, 'Team', team) + BroadcastPlayers(instance) +end + +--- Reads a numeric command-line argument (e.g. `/mean 1500`), falling back to the default +--- when it is absent or unparseable. +---@param key string +---@param default number +---@return number +local function GetCommandLineNumber(key, default) + local arg = GetCommandLineArg(key, 1) + if arg and arg[1] then + return tonumber(arg[1]) or default + end + return default +end + +--- Reads a string command-line argument (e.g. `/clan Yps`), falling back to the default when +--- it is absent. +---@param key string +---@param default string +---@return string +local function GetCommandLineString(key, default) + local arg = GetCommandLineArg(key, 1) + if arg and arg[1] then + return tostring(arg[1]) + end + return default +end + +--- Reads the faction from the flag args (`/uef` `/aeon` `/cybran` `/seraphim`), falling back to +--- the default when none is present. Mirrors the autolobby's CreateLocalPlayer. +---@param default number +---@return number +local function GetCommandLineFaction(default) + for index, faction in import("/lua/factions.lua").Factions do + if HasCommandLineArg("/" .. faction.Key) then + return index + end + end + return default +end + +--- Builds the local player's options from the profile / engine name. +---@param instance UICustomLobbyInstance +---@return UICustomLobbyPlayer +function CreateLocalPlayer(instance) + local name = instance:GetLocalPlayerName() or import("/lua/user/prefs.lua").GetFromCurrentProfile('Name') or "Player" + local player = import("/lua/ui/lobby/lobbycomm.lua").GetDefaultPlayerOptions(name) + + -- player info from the command line: the FAF client passes the player's real values, and the + -- dev launch script (scripts/LaunchCustomLobby.ps1) seeds random ones. Mirrors the autolobby's + -- CreateLocalPlayer and the legacy lobby's GetLocalPlayerData. The host preserves all of these + -- when it seats a joining peer (it only reassigns StartSpot / colors) — see ProcessAddPlayer. + + -- rating + game count + player.MEAN = GetCommandLineNumber("/mean", 1500) + player.DEV = GetCommandLineNumber("/deviation", 500) + player.NG = GetCommandLineNumber("/numgames", 0) + player.PL = math.floor(player.MEAN - 3 * player.DEV) + + -- faction + team. The faction multi-select (`Factions`) is the source of truth: seed it from the + -- single command-line / default faction — a concrete faction is a one-element set, the Random + -- sentinel becomes "all factions" — and keep `Faction` as its representative. + player.Faction = GetCommandLineFaction(player.Faction) + if player.Faction and player.Faction <= CustomLobbyLaunchModel.RealFactionCount then + player.Factions = { player.Faction } + else + player.Factions = CustomLobbyLaunchModel.NormalizeFactions(nil) -- all factions = random + end + player.Faction = CustomLobbyLaunchModel.RepresentativeFaction(player.Factions) + player.Team = GetCommandLineNumber("/team", player.Team) + + -- identity + league standing + player.PlayerClan = GetCommandLineString("/clan", player.PlayerClan or "") + player.Country = GetCommandLineString("/country", player.Country or "") + player.DIV = GetCommandLineString("/division", player.DIV or "") + player.SUBDIV = GetCommandLineString("/subdivision", player.SUBDIV or "") + + return player +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Connection events + +--- Called when we become the host. +---@param instance UICustomLobbyInstance +function OnHosting(instance) + LOG("OnHosting") + LobbyInstance = instance + + local localModel = CustomLobbyLocalModel.GetSingleton() + local id = instance:GetLocalPlayerID() + localModel.LocalPeerId:Set(id) + localModel.HostID:Set(id) + localModel.IsHost.OnDirty = function() + LOG("CustomLobby: IsHost changed to " .. tostring(localModel.IsHost())) + end + localModel.IsHost:Set(true) + + + local launch = CustomLobbyLaunchModel.GetSingleton() + local player = CreateLocalPlayer(instance) + player.OwnerID = id + player.StartSpot = 1 + player.PlayerColor = 1 + player.ArmyColor = 1 + CustomLobbyLaunchModel.SetPlayer(launch, 1, player) + + instance.Trash:Add(ForkThread(ShareCpuBenchmarkThread, instance)) +end + +--- Called when our connection to the host succeeds. +---@param instance UICustomLobbyInstance +---@param localId UILobbyPeerId +---@param localName string +---@param hostId UILobbyPeerId +function OnConnectionToHostEstablished(instance, localId, localName, hostId) + LobbyInstance = instance + + local localModel = CustomLobbyLocalModel.GetSingleton() + localModel.LocalPeerId:Set(localId) + localModel.HostID:Set(hostId) + localModel.IsHost:Set(false) + + local player = CreateLocalPlayer(instance) + player.OwnerID = localId + player.PlayerName = localName or player.PlayerName + + instance:SendData(hostId, { Type = 'AddPlayer', PlayerOptions = player }) + + instance.Trash:Add(ForkThread(ShareCpuBenchmarkThread, instance)) +end + +--- Called when the connection fails. +---@param instance UICustomLobbyInstance +---@param reason string +function OnConnectionFailed(instance, reason) + LOG("CustomLobby: connection failed: " .. tostring(reason)) +end + +--- Called when a peer disconnects. Only the host acts: it clears the peer's slot, +--- tells everyone still connected to drop their own link to that peer, and sends the +--- new authoritative player snapshot. (A non-host ignores it — the host's broadcasts +--- drive its state and mesh cleanup.) +---@param instance UICustomLobbyInstance +---@param peerName string +---@param uid UILobbyPeerId +function OnPeerDisconnected(instance, peerName, uid) + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + return + end + + -- tell the remaining peers to tear down their direct connection to the leaver + instance:BroadcastData({ Type = 'DisconnectPeer', PeerID = uid }) + + -- resolve the leaver's name before its slot is cleared (engine peerName as the fallback) + local name = FindNameForOwner(uid) + if name == tostring(uid) and peerName and peerName ~= '' then + name = peerName + end + + local slot = FindSlotForOwner(uid) + local lockChanged = false + if slot then + CustomLobbyLaunchModel.ClearPlayer(CustomLobbyLaunchModel.GetSingleton(), slot) + lockChanged = ClearSlotLock(slot) + end + BroadcastPlayers(instance) + if lockChanged then + BroadcastSessionState(instance) + end + + -- announce the leave in chat (the leaver is gone; the remaining peers + host see it). A host kick + -- also produces a separate "Host removed X." line from RequestEject — two lines for a kick is fine. + BroadcastSystemNotice(instance, name .. " left.") +end + +--- Called when the game launches. The engine has taken over in its own Lua state, so the lobby's +--- front-end state can be released: free everything registered in the session trash (the map +--- catalog today; the models, interface and instance follow as they are converted to the pattern). +---@param instance UICustomLobbyInstance +function OnGameLaunched(instance) + LOG("CustomLobby: GameLaunched — tearing down the lobby session") + CustomLobbySession.Teardown() +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Message handlers (run after Validate + Accept) + +--- Host seats a connecting peer and brings it up to date (players + launch + session). +---@param instance UICustomLobbyInstance +---@param data table +function ProcessAddPlayer(instance, data) + local slot = FindFreeSlot() + if not slot then + WARN("CustomLobby: no free slot for joining peer " .. tostring(data.SenderID)) + return + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + + ---@type UICustomLobbyPlayer + local player = data.PlayerOptions + player.OwnerID = data.SenderID + player.StartSpot = slot + player.PlayerColor = slot + player.ArmyColor = slot + CustomLobbyLaunchModel.SetPlayer(launch, slot, player) + + BroadcastPlayers(instance) + -- a fresh peer also needs the current launch config (scenario / options / mods) and + -- the session state (slot count / closed slots), not just the player list — without + -- them the map preview / slot grid have nothing to render + BroadcastLaunchInfo(instance) + BroadcastSessionState(instance) + + -- announce the join in chat (reaches everyone, including the peer that just joined) + BroadcastSystemNotice(instance, (player.PlayerName or "A player") .. " joined.") +end + +--- Everyone applies the host's player + observer snapshot (launch state). +---@param instance UICustomLobbyInstance +---@param data table +function ProcessSetPlayers(instance, data) + local launch = CustomLobbyLaunchModel.GetSingleton() + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + launch.Players[slot]:Set(data.Players[slot] or false) + end + if data.Observers then + launch.Observers:Set(data.Observers) + end +end + +--- Everyone applies the host's launch config (scenario / options / mods / teams / spawn +--- mex) — the part of the launch state that isn't the player list. +---@param instance UICustomLobbyInstance +---@param data UICustomLobbySentLaunchInfoMessage +function ProcessSentLaunchInfo(instance, data) + local launch = CustomLobbyLaunchModel.GetSingleton() + launch.ScenarioFile:Set(data.ScenarioFile or false) + launch.GameOptions:Set(data.GameOptions or {}) + launch.GameMods:Set(data.GameMods or {}) + launch.AutoTeams:Set(data.AutoTeams or {}) + launch.SpawnMex:Set(data.SpawnMex or {}) + launch.Restrictions:Set(data.Restrictions or {}) +end + +--- Everyone applies the host's session state (slot count / closed slots) — lobby-room +--- management, not part of the launch. +---@param instance UICustomLobbyInstance +---@param data UICustomLobbySetSessionStateMessage +function ProcessSetSessionState(instance, data) + local session = CustomLobbySessionModel.GetSingleton() + session.SlotCount:Set(data.SlotCount or session.SlotCount()) + session.ClosedSlots:Set(data.ClosedSlots or {}) + session.LockedSlots:Set(data.LockedSlots or {}) + session.SlotsPinned:Set(data.SlotsPinned and true or false) +end + +--- Host flips a peer's ready flag and re-broadcasts. +---@param instance UICustomLobbyInstance +---@param data table +function ProcessSetReady(instance, data) + local slot = FindSlotForOwner(data.SenderID) + if slot then + CustomLobbyLaunchModel.SetPlayerField(CustomLobbyLaunchModel.GetSingleton(), slot, 'Ready', data.Ready and true or false) + BroadcastPlayers(instance) + end +end + +--- Host applies a client's faction multi-select to that client's own slot and re-broadcasts. +---@param instance UICustomLobbyInstance +---@param data UICustomLobbySetFactionsMessage +function ProcessSetFactions(instance, data) + ApplyFactions(instance, FindSlotForOwner(data.SenderID), data.Factions) +end + +--- Host applies a client's colour choice to that client's own slot (scarcity-checked) and re-broadcasts. +---@param instance UICustomLobbyInstance +---@param data UICustomLobbySetColorMessage +function ProcessSetColor(instance, data) + ApplyColor(instance, FindSlotForOwner(data.SenderID), data.Color) +end + +--- Host applies a client's team choice to that client's own slot and re-broadcasts. +---@param instance UICustomLobbyInstance +---@param data UICustomLobbySetTeamMessage +function ProcessSetTeam(instance, data) + ApplyTeam(instance, FindSlotForOwner(data.SenderID), data.Team) +end + +--- Host moves a requesting client into the open slot it asked for. Ignored while seating +--- is pinned — only the host may change slots then (the host's own take goes through +--- RequestTakeSlot, which is exempt). +---@param instance UICustomLobbyInstance +---@param data UICustomLobbyTakeSlotMessage +function ProcessTakeSlot(instance, data) + if CustomLobbySessionModel.GetSingleton().SlotsPinned() then + return + end + TakeSlot(instance, data.SenderID, data.Slot) +end + +--- A client drops its direct connection to a peer the host says has left. The slot +--- itself is cleared by the SetPlayers snapshot the host sends alongside this. +---@param instance UICustomLobbyInstance +---@param data UICustomLobbyDisconnectPeerMessage +function ProcessDisconnectPeer(instance, data) + instance:DisconnectFromPeer(data.PeerID) +end + +--- Host records a peer's benchmark (sim-performance history) and re-broadcasts. +---@param instance UICustomLobbyInstance +---@param data UICustomLobbyReportCpuBenchmarkMessage +function ProcessReportCpuBenchmark(instance, data) + CustomLobbyLocalModel.SetCpuBenchmark(CustomLobbyLocalModel.GetSingleton(), data.SenderID, data.CpuBenchmark) + BroadcastCpuBenchmarks(instance) +end + +--- Everyone applies the host's authoritative benchmark snapshot. +---@param instance UICustomLobbyInstance +---@param data UICustomLobbySetCpuBenchmarksMessage +function ProcessSetCpuBenchmarks(instance, data) + CustomLobbyLocalModel.GetSingleton().CpuBenchmarks:Set(data.CpuBenchmarks) +end + +--- Everyone launches the game with the host's configuration (the engine takes over from here). +---@param instance UICustomLobbyInstance +---@param data UICustomLobbyLaunchGameMessage +function ProcessLaunchGame(instance, data) + instance:LaunchGame(data.GameConfig) +end + +--- Everyone applies the host's authoritative chat line: appends it, or reconciles the sender's own +--- optimistic Pending line by the echoed Id (see CustomLobbyChatModel.Receive). +---@param instance UICustomLobbyInstance +---@param data UICustomLobbyChatMessageMessage +function ProcessChatMessage(instance, data) + CustomLobbyChatModel.Receive(CustomLobbyChatModel.GetSingleton(), { + Id = data.Id, + SenderId = data.SenderID, + SenderName = data.SenderName, + Text = data.Text, + }) +end + +--- Everyone shows the host's lobby notice as a system line in chat (a peer joined / left, …). +---@param instance UICustomLobbyInstance +---@param data UICustomLobbySystemNoticeMessage +function ProcessSystemNotice(instance, data) + CustomLobbyChatModel.AppendSystem(CustomLobbyChatModel.GetSingleton(), data.Text) +end + +--- Host relays an accepted chat line — **the single chat chokepoint**. Resolves the sender's name +--- authoritatively (clients don't get to spoof it), broadcasts the line to everyone, and shows it in +--- the host's own feed (a broadcast doesn't loop back to the sender). Future mute / rate-limit / +--- slow-mode filtering lives right here: inspect `data` and return early to drop it. +---@param instance UICustomLobbyInstance +---@param data UICustomLobbyRequestChatMessage +function ProcessRequestChat(instance, data) + local message = { + Type = 'ChatMessage', + Id = data.Id, + SenderID = data.SenderID, + SenderName = FindNameForOwner(data.SenderID), + Text = data.Text, + } + instance:BroadcastData(message) + -- the broadcast doesn't echo back to us, so display it in the host's own feed directly + ProcessChatMessage(instance, message) +end + +--- Host emits a lobby notice (a senderless system line) to everyone and shows it locally too (the +--- broadcast doesn't loop back). Host-only; call it from the join/leave hooks. +---@param instance UICustomLobbyInstance +---@param text string +function BroadcastSystemNotice(instance, text) + local message = { Type = 'SystemNotice', Text = text } + instance:BroadcastData(message) + ProcessSystemNotice(instance, message) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Shared-state broadcasts +-- +-- The host owns the shared models and pushes whole snapshots: rather than syncing each +-- field with its own message, it broadcasts the relevant snapshot whenever any of it +-- changes (and to each peer as it joins). Call these after a host-side change. + +--- Host broadcasts the launch config snapshot (scenario / options / mods / teams / spawn). +---@param instance UICustomLobbyInstance +function BroadcastLaunchInfo(instance) + local launch = CustomLobbyLaunchModel.GetSingleton() + instance:BroadcastData({ + Type = 'SentLaunchInfo', + ScenarioFile = launch.ScenarioFile(), + GameOptions = launch.GameOptions(), + GameMods = launch.GameMods(), + AutoTeams = launch.AutoTeams(), + SpawnMex = launch.SpawnMex(), + Restrictions = launch.Restrictions(), + }) +end + +--- Host broadcasts the session state snapshot (slot count / closed slots). +---@param instance UICustomLobbyInstance +function BroadcastSessionState(instance) + local session = CustomLobbySessionModel.GetSingleton() + instance:BroadcastData({ + Type = 'SetSessionState', + SlotCount = session.SlotCount(), + ClosedSlots = session.ClosedSlots(), + LockedSlots = session.LockedSlots(), + SlotsPinned = session.SlotsPinned(), + }) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region CPU benchmark +-- +-- We share each peer's stored in-game sim-performance history (the engine's +-- `PerformanceTrackingV2` preference, accumulated over past games) rather than +-- running a live stress test. The host owns the table and broadcasts it; a client +-- sends its own to the host, which reconciles everyone. + +--- Host broadcasts the authoritative benchmark snapshot to everyone. +---@param instance UICustomLobbyInstance +function BroadcastCpuBenchmarks(instance) + instance:BroadcastData({ + Type = 'SetCpuBenchmarks', + CpuBenchmarks = CustomLobbyLocalModel.GetSingleton().CpuBenchmarks(), + }) +end + +--- Shares the local machine's stored sim-performance benchmark: the host records + +--- broadcasts it; a client sends it to the host. Forked so the brief defer doesn't +--- block the connection callback. +---@param instance UICustomLobbyInstance +function ShareCpuBenchmarkThread(instance) + -- defer briefly so connection negotiation / seating settles first + WaitSeconds(2.0) + if IsDestroyed(instance) then + return + end + + -- the machine's in-game sim-performance history (nil on a fresh install) + local benchmark = GetPreference('PerformanceTrackingV2') + if not benchmark then + return + end + + local localModel = CustomLobbyLocalModel.GetSingleton() + + -- show our own data immediately; the host's snapshots reconcile everyone + CustomLobbyLocalModel.SetCpuBenchmark(localModel, localModel.LocalPeerId(), benchmark) + + if localModel.IsHost() then + BroadcastCpuBenchmarks(instance) + else + instance:SendData(localModel.HostID(), { Type = 'ReportCpuBenchmark', CpuBenchmark = benchmark }) + end +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Intents (called by the UI or a chat command, e.g. `/take 2`, `/swap 2 3`) + +--- The local player takes an open slot. Host applies + broadcasts; a client asks the +--- host. Backs both a click on an open slot and a `/take ` chat command. +---@param slot number +function RequestTakeSlot(slot) + local instance = LobbyInstance + if not instance then + return + end + + local localModel = CustomLobbyLocalModel.GetSingleton() + if localModel.IsHost() then + TakeSlot(instance, localModel.LocalPeerId(), slot) + else + -- pinned seating is host-only: don't bother the host with a request it will reject + if CustomLobbySessionModel.GetSingleton().SlotsPinned() then + return + end + instance:SendData(localModel.HostID(), { Type = 'TakeSlot', Slot = slot }) + end +end + +--- The number of start spots (max players) a scenario declares, or 0 when it can't be read. +---@param scenarioFile FileName | false +---@return number +local function ScenarioSlotCount(scenarioFile) + if not scenarioFile then + return 0 + end + local info = import("/lua/ui/maputil.lua").LoadScenario(scenarioFile) + local armies = info + and info.Configurations + and info.Configurations.standard + and info.Configurations.standard.teams + and info.Configurations.standard.teams[1] + and info.Configurations.standard.teams[1].armies + return armies and table.getsize(armies) or 0 +end + +--- The host picks the scenario (map). Host-only — backs the map-select dialog and a +--- `/map ` chat command. Sets the scenario in the launch model, sizes the lobby room to the +--- map's start spots, and broadcasts both; the map preview / unit-cap react to the new `ScenarioFile`. +--- +--- TODO (options slice): when the scenario changes, the game-options *schema* changes too +--- (the map contributes its own options). Reconcile `GameOptions` here — drop values whose +--- keys no longer exist, seed defaults for new keys — before broadcasting. See CLAUDE.md. +---@param scenarioFile FileName +function RequestSetScenario(scenarioFile) + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can change the map") + return + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + local changed = scenarioFile ~= launch.ScenarioFile() + CustomLobbyLaunchModel.SetScenario(launch, scenarioFile) + + -- size the lobby room to the map's start spots, so all of its slots show (capped at MaxSlots) + local count = ScenarioSlotCount(scenarioFile) + if count > 0 then + local session = CustomLobbySessionModel.GetSingleton() + session.SlotCount:Set(math.min(count, CustomLobbyLaunchModel.MaxSlots)) + BroadcastSessionState(instance) + end + + BroadcastLaunchInfo(instance) + + -- announce only a real map change (re-selecting the same map is a no-op for the derived state) + if changed then + -- the scenario derived model resolved the new map synchronously (its observer fired on the Set) + local scenario = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua").GetScenario() + BroadcastSystemNotice(instance, "Host changed the map to " .. ((scenario and scenario.Name) or "a new map") .. ".") + end +end + +--- The host sets the active sim mods. Host-only — backs the mod-select dialog. Sets `GameMods` +--- in the launch model and broadcasts the launch config so every peer sees the same sim mods. +--- UI mods are per-peer and handled locally by the dialog, never here. +---@param gameMods table +function RequestSetGameMods(gameMods) + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can change the game mods") + return + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + -- a set (uuid -> true): table.equal is order-independent, matching the mods derived model's dedup + local changed = not table.equal(gameMods, launch.GameMods()) + CustomLobbyLaunchModel.SetGameMods(launch, gameMods) + BroadcastLaunchInfo(instance) + if changed then + BroadcastSystemNotice(instance, "Host changed the game mods (" .. table.getsize(gameMods) .. " active).") + end +end + +--- The host sets the unit restrictions. Host-only — backs the unit-select dialog and a +--- `/restrict ` chat command. Stores the preset-key list in the launch model and broadcasts, +--- so every peer sees the same restrictions. The keys are folded into the launch config's +--- `GameOptions.RestrictedCategories` at launch (`BuildGameConfiguration`); the sim expands them. +---@param keys string[] +function RequestSetRestrictions(keys) + local instance = LobbyInstance + if not instance then + WARN("CustomLobby: RequestSetRestrictions ignored — no lobby instance (UI-only, or the " + .. "controller was hot-reloaded; re-host to restore it)") + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can change the unit restrictions") + return + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + -- key the lists into sets so table.equal is order-independent (restrictions is a list, unlike the + -- mods/options keyed tables) — a mere reorder of the same keys is not a change + local changed = not table.equal(KeySet(keys), KeySet(launch.Restrictions())) + CustomLobbyLaunchModel.SetRestrictions(launch, keys) + LOG("CustomLobby: restrictions set (" .. table.getn(keys) .. ")") + BroadcastLaunchInfo(instance) + if changed then + BroadcastSystemNotice(instance, "Host changed the unit restrictions (" .. table.getn(keys) .. ").") + end +end + +--- The host sets the game options. Host-only — backs the options dialog. Replaces the whole +--- `GameOptions` value table in the launch model and broadcasts so every peer sees the same +--- options. The dialog already seeds defaults + drops stale keys, so this is the reconciled set. +---@param options table +function RequestSetGameOptions(options) + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can change the game options") + return + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + -- compare the value tables (matching the options derived model's publish dedup); a no-op apply + -- (the host opened the dialog and clicked OK without changing anything) announces nothing + local changed = not table.equal(options, launch.GameOptions()) + CustomLobbyLaunchModel.SetGameOptions(launch, options) + BroadcastLaunchInfo(instance) + if changed then + BroadcastSystemNotice(instance, "Host changed the game options.") + end +end + +--- The host resets every game option to its default. Host-only — backs the lobby's "Reset +--- options" button. Derives the current option schema (lobby + scenario + mods) and seeds the +--- defaults, then broadcasts, so it reconciles to exactly the options the current map/mods define. +function RequestResetGameOptions() + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can reset the game options") + return + end + + local OptionUtil = import("/lua/ui/optionutil.lua") + local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") + local launch = CustomLobbyLaunchModel.GetSingleton() + + local options = {} + for _, option in OptionUtil.GetLobbyOptions() do + table.insert(options, option) + end + for _, option in CustomLobbyMapCatalog.LoadOptions(launch.ScenarioFile()) do + table.insert(options, option) + end + for _, option in OptionUtil.GetModOptions(launch.GameMods()) do + table.insert(options, option) + end + + CustomLobbyLaunchModel.SetGameOptions(launch, OptionUtil.SeedDefaults(options, {})) + BroadcastLaunchInfo(instance) +end + +------------------------------------------------------------------------------- +--#region Launch + +--- Why the game can't launch right now, or nil when it can. Requires at least one seated player, +--- a selected map, and every *other* human to be ready — the launching host is exempt, since +--- clicking Launch is their commit. +---@return string | nil +local function LaunchBlockReason() + local launch = CustomLobbyLaunchModel.GetSingleton() + local localId = CustomLobbyLocalModel.GetSingleton().LocalPeerId() + local seated = 0 + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local player = launch.Players[slot]() + if player then + seated = seated + 1 + if player.Human and player.OwnerID ~= localId and not player.Ready then + return "not everyone is ready" + end + end + end + if seated == 0 then + return "no players are seated" + end + if not launch.ScenarioFile() then + return "no map is selected" + end + return nil +end + +--- Builds the engine launch configuration from the launch model — mirrors the autolobby's +--- `LaunchThread` and the legacy lobby's `LaunchGame`: +--- * `GameOptions`: the host's values with lobby/scenario/mod defaults seeded over them, plus the +--- scenario file and the ratings / clan-tag tables (sim + UI read those there); +--- * `PlayerOptions`: the seated players (fresh copies; a random faction resolved to a concrete +--- one), keyed by slot, with army numbers assigned in slot order and pushed to the server; +--- * `GameMods` + `Observers` straight off the model. +---@param instance UICustomLobbyInstance +---@return UILobbyLaunchConfiguration +local function BuildGameConfiguration(instance) + local OptionUtil = import("/lua/ui/optionutil.lua") + local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") + local Factions = import("/lua/factions.lua").Factions + local factionCount = table.getn(Factions) + local launch = CustomLobbyLaunchModel.GetSingleton() + + -- the full option set: the host's chosen values, with defaults seeded for anything unset + local schema = {} + for _, option in OptionUtil.GetLobbyOptions() do table.insert(schema, option) end + for _, option in CustomLobbyMapCatalog.LoadOptions(launch.ScenarioFile()) do table.insert(schema, option) end + for _, option in OptionUtil.GetModOptions(launch.GameMods()) do table.insert(schema, option) end + local gameOptions = OptionUtil.SeedDefaults(schema, launch.GameOptions()) + gameOptions.ScenarioFile = launch.ScenarioFile() + -- the unit restrictions live in their own launch-model field (so the options dialog can't wipe + -- them); fold them in here as the preset-key array the sim expands (see simInit.lua) + gameOptions.RestrictedCategories = launch.Restrictions() or {} + + -- seated players (fresh copies; random faction resolved to a concrete one) + local playerOptions = {} + local ratings, clanTags = {}, {} + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local player = launch.Players[slot]() + if player then + local options = table.copy(player) + -- resolve the faction multi-select to one concrete faction: pick uniformly from the + -- allowed set (`Factions`); a one-element set is that faction, a multi-element set is the + -- random pick. Fall back to the legacy single-Faction-or-random when no set is present. + local allowed = options.Factions + if allowed and table.getn(allowed) > 0 then + options.Faction = allowed[Random(1, table.getn(allowed))] + elseif (options.Faction or factionCount + 1) > factionCount then + options.Faction = Random(1, factionCount) + end + options.Factions = nil -- launch payload carries the resolved single faction only + options.StartSpot = options.StartSpot or slot + playerOptions[slot] = options + if options.PL then ratings[options.PlayerName] = options.PL end + if options.PlayerClan then clanTags[options.PlayerName] = options.PlayerClan end + end + end + gameOptions.Ratings = ratings + gameOptions.ClanTags = clanTags + + -- AutoTeams: under a binary positional mode (top/bottom, left/right, odd/even) the team is decided + -- by the start position, not the player's picked team — so resolve each seated player's `Team` from + -- its spot here. This is the lobby's position->team step (the balancer only re-seats; the team + -- falls out of position). Non-binary / manual teams are left untouched. + local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") + local mode = CustomLobbyRules.AutoTeamMode(gameOptions) + if mode then + local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua") + local resolver, resolved = CustomLobbyRules.BuildSideResolver(mode, CustomLobbyScenarioDerivedModel.GetScenario()) + if resolved and resolver then + for _, options in playerOptions do + local side = resolver(options.StartSpot) + if side == 1 or side == 2 then + options.Team = side + 1 -- backend numbering: side 1 -> team 2, side 2 -> team 3 + end + end + end + end + + -- army numbers are assigned in slot order; tell the server each seated player's army settings + local slots = {} + for slot, _ in playerOptions do + table.insert(slots, slot) + end + table.sort(slots) + for armyIndex, slot in ipairs(slots) do + local player = playerOptions[slot] + instance:SendPlayerOptionToServer(player.OwnerID, 'Team', player.Team) + instance:SendPlayerOptionToServer(player.OwnerID, 'Army', armyIndex) + instance:SendPlayerOptionToServer(player.OwnerID, 'StartSpot', player.StartSpot) + instance:SendPlayerOptionToServer(player.OwnerID, 'Faction', player.Faction) + end + + return { + -- the model holds the selected sim-mod uid set; the engine wants the resolved mod list + GameMods = import("/lua/mods.lua").GetGameMods(launch.GameMods()), + GameOptions = gameOptions, + PlayerOptions = playerOptions, + Observers = launch.Observers(), + } +end + +--- The host launches the game. Host-only — backs the Launch button. Validates readiness, builds +--- the launch configuration, broadcasts it so every peer launches with the same config, then +--- launches locally. The engine takes over from here (see `OnGameLaunched`). +--- +--- TODO: gate the button reactively / surface the block reason in the UI rather than only warning. +--- (The binary AutoTeams modes now resolve team-from-position in `BuildGameConfiguration`; the +--- remaining AutoTeams work — the `manual` marker mode and the spawn-variant resolution — is the +--- broader USER_STORIES § H slice.) +function RequestLaunch() + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can launch the game") + return + end + + local reason = LaunchBlockReason() + if reason then + WARN("CustomLobby: cannot launch — " .. reason) + return + end + + -- auto-save the launched setup as the reserved "last game" preset, so a rehost can restore it + -- (USER_STORIES.md § O); failure to persist must not block the launch + local ok, err = pcall(function() + CustomLobbyPresets.SavePreset(CustomLobbyPresets.LastGamePresetName, BuildSetupSnapshot()) + end) + if not ok then + WARN("CustomLobby: failed to auto-save the last-game preset — " .. tostring(err)) + end + + local gameConfiguration = BuildGameConfiguration(instance) + instance:BroadcastData({ Type = 'LaunchGame', GameConfig = gameConfiguration }) + instance:LaunchGame(gameConfiguration) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Setup presets (save / load named setup snapshots; § O) +-- +-- Persistence is in CustomLobbyPresets (pure prefs). Capturing the live launch state into a +-- snapshot and applying one back touch the synced model + network, so they live here (the host +-- is the only writer). A preset is **setup-only** — scenario / game options / sim mods / +-- restrictions. Players, observers and the (not-yet-applied) auto-teams / spawn-mex are +-- intentionally left out: a preset reconfigures a lobby, it doesn't restore a roster. The future +-- rehost reseat (§ O.4) will need its own player capture, not these presets. + +--- Reads the current launch state into a plain serializable setup snapshot. Used both by the +--- save-preset intent and the launch auto-save. Pure read — never mutates the model. +---@return UICustomLobbySetupSnapshot +function BuildSetupSnapshot() + local launch = CustomLobbyLaunchModel.GetSingleton() + + -- per-player Ratings / ClanTags are stamped fresh at launch; drop them from the saved options + local options = table.deepcopy(launch.GameOptions()) + options.Ratings = nil + options.ClanTags = nil + + return { + ScenarioFile = launch.ScenarioFile(), + GameOptions = options, + GameMods = table.deepcopy(launch.GameMods()), + Restrictions = table.deepcopy(launch.Restrictions()), + } +end + +--- Host-side: applies a setup snapshot to the launch model and broadcasts it. Sets the scenario +--- (re-sizing the lobby room), sim mods and restrictions, then reconciles the game options against +--- the now-current scenario+mods (drop stale keys, seed defaults) — the same reconcile the options +--- dialog / reset use — and broadcasts once. Players, observers and auto-teams / spawn-mex are left +--- untouched (a preset is setup-only — see the region note). Mirrors the legacy `ApplyGameSettings` +--- → single `UpdateGame`. +---@param setup UICustomLobbySetupSnapshot +function ApplySetup(setup) + local instance = LobbyInstance + if not instance then + WARN("CustomLobby: ApplySetup ignored — no lobby instance (UI-only, or the controller was " + .. "hot-reloaded; re-host to restore it)") + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can load a setup preset") + return + end + + if not setup then + WARN("CustomLobby: ApplySetup ignored — empty setup") + return + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + + -- scenario first: it sizes the lobby room and drives the option schema below + local scenario = setup.ScenarioFile or false + CustomLobbyLaunchModel.SetScenario(launch, scenario) + local count = ScenarioSlotCount(scenario) + if count > 0 then + local session = CustomLobbySessionModel.GetSingleton() + session.SlotCount:Set(math.min(count, CustomLobbyLaunchModel.MaxSlots)) + BroadcastSessionState(instance) + end + + CustomLobbyLaunchModel.SetGameMods(launch, setup.GameMods or {}) + CustomLobbyLaunchModel.SetRestrictions(launch, setup.Restrictions or {}) + + -- reconcile the saved option values against the current scenario+mods schema + local OptionUtil = import("/lua/ui/optionutil.lua") + local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") + local schema = {} + for _, option in OptionUtil.GetLobbyOptions() do table.insert(schema, option) end + for _, option in CustomLobbyMapCatalog.LoadOptions(scenario) do table.insert(schema, option) end + for _, option in OptionUtil.GetModOptions(launch.GameMods()) do table.insert(schema, option) end + CustomLobbyLaunchModel.SetGameOptions(launch, OptionUtil.SeedDefaults(schema, setup.GameOptions or {})) + + BroadcastLaunchInfo(instance) +end + +--- Saves the current setup under `name` (host-local prefs). Reads the live model, so a client may +--- also capture the host-dictated setup to reuse later; the save itself never mutates the lobby. +---@param name string +function RequestSaveSetupPreset(name) + if not name or name == "" then + WARN("CustomLobby: RequestSaveSetupPreset ignored — empty name") + return + end + CustomLobbyPresets.SavePreset(name, BuildSetupSnapshot()) + LOG("CustomLobby: setup preset saved (" .. name .. ")") +end + +--- Loads the named setup preset and applies it. Host-only (enforced in `ApplySetup`). +---@param name string +function RequestLoadSetupPreset(name) + local setup = CustomLobbyPresets.GetPreset(name) + if not setup then + WARN("CustomLobby: no setup preset named " .. tostring(name)) + return + end + ApplySetup(setup) + LOG("CustomLobby: setup preset loaded (" .. tostring(name) .. ")") +end + +--#endregion + +--- The host swaps the contents of two slots. Host-only (a client request isn't +--- offered) — backs a host-side drag/menu and a `/swap ` chat command. +---@param slotA number +---@param slotB number +function RequestSwapSlots(slotA, slotB) + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can swap slots") + return + end + SwapSlots(instance, slotA, slotB) +end + +--- The host pins or unpins seating. Host-only — backs the slots header's pin button. While +--- pinned, the host rejects client slot-takes (ProcessTakeSlot), so only the host can change +--- who sits where; the host itself is unaffected. Synced via the session-state snapshot. +---@param pinned boolean +function RequestSetSlotsPinned(pinned) + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can pin the slots") + return + end + + CustomLobbySessionModel.SetSlotsPinned(CustomLobbySessionModel.GetSingleton(), pinned) + BroadcastSessionState(instance) +end + +--- The host locks or unlocks a single seat. Host-only — backs the slot context menu's "Lock in +--- slot" toggle. A locked seat's player is held in place by auto-balance (only the unlocked players +--- are rearranged); see CustomLobbyBalancer. Synced via the session-state snapshot. +---@param slot number +---@param locked boolean +function RequestSetSlotLocked(slot, locked) + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can lock slots") + return + end + + CustomLobbySessionModel.SetLocked(CustomLobbySessionModel.GetSingleton(), slot, locked and true or false) + BroadcastSessionState(instance) +end + +--- The host applies a balanced re-seating, after confirming it in the preview. Host-only — backs +--- the auto-balance preview's Apply button. `arrangement` (slot -> ownerId) comes from +--- CustomLobbyBalancer; it only says *where* each player sits, never *what* they are (no rating / +--- faction / team — under AutoTeams the position decides the team), so it can't smuggle edits. The +--- whole board is rewritten from one snapshot — read by owner, moved to the target seat — then +--- broadcast once (re-seating via the model directly, not N pairwise swaps). +---@param arrangement table +function RequestApplyBalance(arrangement) + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can auto-balance") + return + end + if type(arrangement) ~= 'table' then + return + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + + -- snapshot the seated players by owner: lets us move players between seats safely, and validate + -- the arrangement against who is actually seated *now* (a player may have left since the preview) + local byOwner = {} + local seatedCount = 0 + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local player = launch.Players[slot]() + if player then + byOwner[player.OwnerID] = player + seatedCount = seatedCount + 1 + end + end + + -- build the target board; bail if the arrangement references someone no longer seated or doesn't + -- account for exactly the seated players (a stale preview) + local newPlayers = {} + local placedCount = 0 + for slot, ownerId in arrangement do + local source = byOwner[ownerId] + if not source then + WARN("CustomLobby: balance arrangement references an unseated player; ignoring") + return + end + local player = table.copy(source) + player.StartSpot = slot + player.Ready = false -- (re)seating resets readiness, as a manual move does + newPlayers[slot] = player + placedCount = placedCount + 1 + end + if placedCount ~= seatedCount then + WARN("CustomLobby: balance arrangement doesn't match the seated players; ignoring") + return + end + + -- write the new occupants and clear any seat that emptied, then broadcast once + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + if newPlayers[slot] then + CustomLobbyLaunchModel.SetPlayer(launch, slot, newPlayers[slot]) + elseif launch.Players[slot]() then + CustomLobbyLaunchModel.ClearPlayer(launch, slot) + end + end + BroadcastPlayers(instance) + BroadcastSystemNotice(instance, "Host balanced the teams.") +end + +--- Re-broadcasts the closed slots, then after a short delay opens every one of them. The +--- close→open pulse pushes two fresh session snapshots so a client that drifted out of sync +--- re-renders its closed slots correctly. Host-only — backs the slots header's reopen button. +function RequestReopenClosedSlots() + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can reopen the closed slots") + return + end + + local session = CustomLobbySessionModel.GetSingleton() + local closed = {} + for slot, isClosed in session.ClosedSlots() do + if isClosed then + table.insert(closed, slot) + end + end + if table.empty(closed) then + return + end + + instance.Trash:Add(ForkThread(function() + -- re-assert the closed state to everyone + BroadcastSessionState(instance) + WaitSeconds(ReopenClosedSlotsDelay) + if IsDestroyed(instance) then + return + end + -- then open the slots that were closed + local opened = table.copy(session.ClosedSlots()) + for _, slot in closed do + opened[slot] = nil + end + session.ClosedSlots:Set(opened) + BroadcastSessionState(instance) + end)) +end + +--- The host opens or closes a single slot. Host-only — backs the per-slot close/open button and its +--- context-menu entries. A closed slot has no army at launch (session state, not launched). Closing is +--- only allowed on an empty seat (we don't evict a player by closing under them); opening always works. +---@param slot number +---@param closed boolean +function RequestSetSlotClosed(slot, closed) + local instance = LobbyInstance + if not instance then + return + end + + local session = CustomLobbySessionModel.GetSingleton() + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can open or close slots") + return + end + if type(slot) ~= 'number' or slot < 1 or slot > session.SlotCount() then + return + end + -- closing an occupied seat would silently drop its player; require it empty first + if closed and CustomLobbyLaunchModel.GetSingleton().Players[slot]() then + WARN("CustomLobby: cannot close an occupied slot " .. tostring(slot)) + return + end + + CustomLobbySessionModel.SetClosed(session, slot, closed and true or false) + BroadcastSessionState(instance) +end + +--- The host closes every currently-open empty slot in one go (the header "close empty slots" button). +--- Occupied and already-closed slots are left as-is; one broadcast for the batch. +function RequestCloseEmptySlots() + local instance = LobbyInstance + if not instance then + return + end + + local session = CustomLobbySessionModel.GetSingleton() + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can close slots") + return + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + local closedSlots = table.copy(session.ClosedSlots()) + local changed = false + for slot = 1, session.SlotCount() do + if not launch.Players[slot]() and not closedSlots[slot] then + closedSlots[slot] = true + changed = true + end + end + if not changed then + return + end + session.ClosedSlots:Set(closedSlots) + BroadcastSessionState(instance) +end + +--- Ejects the player in `slot`: a human is dropped from the network (the resulting +--- PeerDisconnected clears the slot + re-broadcasts), an AI is just cleared. Host-only. +--- Slot-keyed so a chat command (`/eject `) can call it too; whether the caller +--- is permitted to is gated separately. +---@param slot number +function RequestEject(slot) + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can eject players") + return + end + + local player = PlayerInSlot(slot) + if not player then + return + end + BroadcastSystemNotice(instance, "Host removed " .. (player.PlayerName or "a player") .. ".") + if player.Human then + -- the resulting PeerDisconnected clears the slot (and its lock), re-broadcasts, and adds its own + -- "X left." line — two lines for a kick is fine + instance:EjectPeer(player.OwnerID, "KickedByHost") + else + CustomLobbyLaunchModel.ClearPlayer(CustomLobbyLaunchModel.GetSingleton(), slot) + BroadcastPlayers(instance) + if ClearSlotLock(slot) then + BroadcastSessionState(instance) + end + end +end + +--- Moves the player in `slot` into the observer list, then re-broadcasts. Host-only. +--- Slot-keyed for chat (`/observe `). The reverse (observer → slot) is +--- `RequestTakeSlot` ("Play this slot"). +---@param slot number +function RequestMoveToObserver(slot) + local instance = LobbyInstance + if not instance then + return + end + + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + WARN("CustomLobby: only the host can move players to observers") + return + end + + local player = PlayerInSlot(slot) + if not player then + return + end + local launch = CustomLobbyLaunchModel.GetSingleton() + CustomLobbyLaunchModel.AddObserver(launch, player) + CustomLobbyLaunchModel.ClearPlayer(launch, slot) + BroadcastPlayers(instance) + -- the seat is now empty; drop any lock so the next occupant isn't pinned + if ClearSlotLock(slot) then + BroadcastSessionState(instance) + end + BroadcastSystemNotice(instance, (player.PlayerName or "A player") .. " moved to observers.") +end + +--- The local player toggles their ready flag. Host applies + broadcasts; a client +--- asks the host to. +---@param ready boolean +function RequestSetReady(ready) + local instance = LobbyInstance + if not instance then + return + end + + local localModel = CustomLobbyLocalModel.GetSingleton() + if localModel.IsHost() then + local slot = FindSlotForOwner(localModel.LocalPeerId()) + if slot then + CustomLobbyLaunchModel.SetPlayerField(CustomLobbyLaunchModel.GetSingleton(), slot, 'Ready', ready and true or false) + BroadcastPlayers(instance) + end + else + instance:SendData(localModel.HostID(), { Type = 'SetReady', Ready = ready and true or false }) + end +end + +--- Sets the faction multi-select for a seat. The host applies directly to `slot` (it may edit any seat +--- — its own or an AI); a client can only change its own seat, so it asks the host (which applies to the +--- sender's slot, ignoring `slot`). `Factions` is a list of allowed real-faction indices — more than one +--- means random among them. Mirrors RequestSetReady's host/client split. +---@param slot number +---@param factions number[] +function RequestSetFactions(slot, factions) + local instance = LobbyInstance + if not instance then + return + end + + local localModel = CustomLobbyLocalModel.GetSingleton() + if localModel.IsHost() then + ApplyFactions(instance, slot, factions) + else + instance:SendData(localModel.HostID(), { Type = 'SetFactions', Factions = factions }) + end +end + +--- Sets the colour for a seat (host applies to `slot`; a client asks the host for its own seat). The +--- host rejects a colour already taken by another seated player — colours are scarce. +---@param slot number +---@param color number +function RequestSetColor(slot, color) + local instance = LobbyInstance + if not instance then + return + end + + local localModel = CustomLobbyLocalModel.GetSingleton() + if localModel.IsHost() then + ApplyColor(instance, slot, color) + else + instance:SendData(localModel.HostID(), { Type = 'SetColor', Color = color }) + end +end + +--- Sets the team for a seat (backend numbering: 1 = no team, 2..9 = teams 1..8). Host applies to +--- `slot`; a client asks the host for its own seat. (Binary AutoTeams modes still decide the team from +--- the start position at launch.) +---@param slot number +---@param team number +function RequestSetTeam(slot, team) + local instance = LobbyInstance + if not instance then + return + end + + local localModel = CustomLobbyLocalModel.GetSingleton() + if localModel.IsHost() then + ApplyTeam(instance, slot, team) + else + instance:SendData(localModel.HostID(), { Type = 'SetTeam', Team = team }) + end +end + +--- The local player sends a chat line. The host short-circuits straight to its relay chokepoint; a +--- client asks the host. `id` is the sender's client-stamped id (CustomLobbyChatModel.NextId), echoed +--- back in the broadcast so the sender's optimistic Pending line can reconcile. See +--- CustomLobbyChatController for the send pipeline that calls this. +---@param text string +---@param id string +function RequestChat(text, id) + local instance = LobbyInstance + if not instance then + return + end + + local localModel = CustomLobbyLocalModel.GetSingleton() + if localModel.IsHost() then + ProcessRequestChat(instance, { SenderID = localModel.LocalPeerId(), Text = text, Id = id }) + else + instance:SendData(localModel.HostID(), { Type = 'RequestChat', Text = text, Id = id }) + end +end + +--#endregion + +------------------------------------------------------------------------------- +--#region UI inspection (debug) +-- +-- A local, per-peer developer aid — never synced, no host authority. Toggles the engine's +-- "show control under mouse" overlay and, while it's on, binds a key to the one-shot "dump +-- controls under cursor" debug action so hovering + that key dumps the control beneath the +-- cursor (a button can't drive that command on its own — clicking one parks the cursor on the +-- button). Lives here so any lobby view (or a chat command) can drive the same toggle. Bound +-- straight into the engine key map (not the user's prefs), so it's transient and clears off. + +--- the key bound to the dump action while the overlay is on, and the debug key action it fires +local InspectDumpKey = 'q' +local InspectDumpAction = 'debug_dump_focus_ui_control' + +--- whether the inspect overlay is on (controller-owned so every caller reflects the same state) +local UiInspectOverlayEnabled = false + +--- The transient `{ key → action }` map bound while the overlay is on, resolved from +--- `debugKeyActions` so the console command isn't duplicated here. +---@return table +local function InspectDumpKeyMap() + local debugKeyActions = import("/lua/keymap/debugkeyactions.lua").debugKeyActions + return { [InspectDumpKey] = debugKeyActions[InspectDumpAction] } +end + +--- Turns the "show control under mouse" overlay on or off, binding/unbinding the dump key +--- alongside it. Idempotent — a no-op when already in the requested state. +---@param enabled boolean +function SetUiInspectOverlay(enabled) + enabled = enabled and true or false + if enabled == UiInspectOverlayEnabled then + return + end + UiInspectOverlayEnabled = enabled + ConExecute('UI_ShowControlUnderMouse ' .. (enabled and 'true' or 'false')) + if enabled then + IN_AddKeyMapTable(InspectDumpKeyMap()) + else + IN_RemoveKeyMapTable(InspectDumpKeyMap()) + end +end + +--- Flips the inspect overlay and returns the new state (so a toggle button can reflect it). +---@return boolean +function ToggleUiInspectOverlay() + SetUiInspectOverlay(not UiInspectOverlayEnabled) + return UiInspectOverlayEnabled +end + +--- Whether the inspect overlay is currently on (lets a view sync its toggle button on creation). +---@return boolean +function IsUiInspectOverlayEnabled() + return UiInspectOverlayEnabled +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-imports this module so the instance's forwarders resolve to +--- the fresh functions. (Note: the stored `LobbyInstance` resets to false on reload +--- and is re-set on the next engine callback.) +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbyInstance.lua b/lua/ui/lobby/customlobby/CustomLobbyInstance.lua new file mode 100644 index 00000000000..dcd82429cea --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyInstance.lua @@ -0,0 +1,206 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The moho.lobby_methods object the engine instantiates (via InternalCreateLobby in +-- /lua/ui/lobby/lobby.lua). Thin shell: it validates/dispatches network traffic and +-- forwards behavioural callbacks to CustomLobbyController. Engine-ABI methods we +-- don't override (HostGame, JoinGame, GetLocalPlayerID, IsHost, ConnectToPeer, …) +-- are inherited from moho.lobby_methods. +-- +-- Like AutolobbyInstance, this object does NOT hot-reload — keep behaviour in the +-- controller. See /lua/ui/lobby/customlobby/CLAUDE.md. + +local MohoLobbyMethods = moho.lobby_methods + +local CustomLobbyMessages = import("/lua/ui/lobby/customlobby/customlobbymessages.lua").CustomLobbyMessages +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyLog = import("/lua/ui/lobby/customlobby/customlobbylog.lua") + +---@class UICustomLobbyInstance : moho.lobby_methods +---@field Trash TrashBag +CustomLobbyInstance = Class(MohoLobbyMethods) { + + ---@param self UICustomLobbyInstance + __init = function(self) + self.Trash = TrashBag() + end, + + --------------------------------------------------------------------------- + --#region Engine interface (validated wrappers) + + --- Broadcasts data to all connected peers, after validating it. + ---@param self UICustomLobbyInstance + ---@param data table + BroadcastData = function(self, data) + local message = CustomLobbyMessages[data.Type] + if not message then + WARN("CustomLobby: blocked broadcast of unknown message type " .. tostring(data.Type)) + CustomLobbyLog.Broadcast(data, "unknown message type") + return + end + local ok, reason = message.Validate(self, data) + if not ok then + reason = reason or "malformed message" + WARN("CustomLobby: blocked broadcast of message " .. tostring(data.Type) .. " — " .. reason) + CustomLobbyLog.Broadcast(data, reason) + return + end + CustomLobbyLog.Broadcast(data) + return MohoLobbyMethods.BroadcastData(self, data) + end, + + --- Sends data to a single peer, after validating it. + ---@param self UICustomLobbyInstance + ---@param peerId UILobbyPeerId + ---@param data table + SendData = function(self, peerId, data) + local message = CustomLobbyMessages[data.Type] + if not message then + WARN("CustomLobby: blocked send of unknown message type " .. tostring(data.Type)) + CustomLobbyLog.Send(peerId, data, "unknown message type") + return + end + local ok, reason = message.Validate(self, data) + if not ok then + reason = reason or "malformed message" + WARN("CustomLobby: blocked send of message " .. tostring(data.Type) .. " — " .. reason) + CustomLobbyLog.Send(peerId, data, reason) + return + end + CustomLobbyLog.Send(peerId, data) + return MohoLobbyMethods.SendData(self, peerId, data) + end, + + --- Tells the server a seated player's army setting at launch (host-only). No-op unless we're + --- on the GPGNet path (the FAF client); a local test launch just skips it. + ---@param self UICustomLobbyInstance + ---@param peerId UILobbyPeerId + ---@param key 'Team' | 'Army' | 'StartSpot' | 'Faction' + ---@param value any + SendPlayerOptionToServer = function(self, peerId, key, value) + if self:IsHost() and GpgNetActive() then + GpgNetSend('PlayerOption', peerId, key, value) + end + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Engine callbacks (forwarded to the controller) + + ---@param self UICustomLobbyInstance + Hosting = function(self) + CustomLobbyController.OnHosting(self) + end, + + ---@param self UICustomLobbyInstance + ---@param localId UILobbyPeerId + ---@param localName string + ---@param hostId UILobbyPeerId + ConnectionToHostEstablished = function(self, localId, localName, hostId) + CustomLobbyController.OnConnectionToHostEstablished(self, localId, localName, hostId) + end, + + ---@param self UICustomLobbyInstance + ---@param reason string + ConnectionFailed = function(self, reason) + CustomLobbyController.OnConnectionFailed(self, reason) + end, + + ---@param self UICustomLobbyInstance + ---@param peerId UILobbyPeerId + ---@param peerConnectedTo UILobbyPeerId[] + EstablishedPeers = function(self, peerId, peerConnectedTo) + -- barebones: connectivity matrix not modelled yet + end, + + ---@param self UICustomLobbyInstance + ---@param peerName string + ---@param uid UILobbyPeerId + PeerDisconnected = function(self, peerName, uid) + CustomLobbyController.OnPeerDisconnected(self, peerName, uid) + end, + + ---@param self UICustomLobbyInstance + GameLaunched = function(self) + CustomLobbyController.OnGameLaunched(self) + end, + + ---@param self UICustomLobbyInstance + ---@param reason string + Ejected = function(self, reason) + LOG("CustomLobby: ejected: " .. tostring(reason)) + end, + + ---@param self UICustomLobbyInstance + ---@param text string + SystemMessage = function(self, text) + LOG("CustomLobby system: " .. tostring(text)) + end, + + ---@param self UICustomLobbyInstance + GameConfigRequested = function(self) + end, + + ---@param self UICustomLobbyInstance + ---@param reasonKey string + LaunchFailed = function(self, reasonKey) + WARN("CustomLobby: launch failed: " .. tostring(reasonKey)) + end, + + --- Validates, authorises and routes an incoming message. + ---@param self UICustomLobbyInstance + ---@param data table + DataReceived = function(self, data) + local message = CustomLobbyMessages[data.Type] + if not message then + WARN("CustomLobby: ignoring unknown message type " .. tostring(data.Type) .. " from " .. tostring(data.SenderID)) + CustomLobbyLog.Received(data, "unknown message type") + return + end + local valid, validReason = message.Validate(self, data) + if not valid then + validReason = validReason or "malformed message" + WARN("CustomLobby: ignoring message " .. tostring(data.Type) .. " from " .. tostring(data.SenderID) .. " — " .. validReason) + CustomLobbyLog.Received(data, validReason) + return + end + local accepted, acceptReason = message.Accept(self, data) + if not accepted then + acceptReason = acceptReason or "rejected" + WARN("CustomLobby: rejected message " .. tostring(data.Type) .. " from " .. tostring(data.SenderID) .. " — " .. acceptReason) + CustomLobbyLog.Received(data, acceptReason) + return + end + CustomLobbyLog.Received(data) + message.Handler(self, data) + end, + + --- Destroys the C-object and the trash bag. + ---@param self UICustomLobbyInstance + Destroy = function(self) + self.Trash:Destroy() + return MohoLobbyMethods.Destroy(self) + end, + + --#endregion +} diff --git a/lua/ui/lobby/customlobby/CustomLobbyInterface.lua b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua new file mode 100644 index 00000000000..ecee86484be --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyInterface.lua @@ -0,0 +1,469 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- Composition root for the custom-games lobby. It builds the area children and lays them out; each +-- child component subscribes to the model itself (see /lua/ui/lobby/TARGET_ARCHITECTURE.md). The +-- only model it reads directly is IsHost (for the action-bar buttons) and SlotCount (to size the +-- slot area); the slot rows + their drag coordination live in CustomLobbySlotsInterface. +-- +-- Layout is organised into labelled *areas* (Group containers), like the dialogs — flip the +-- module-level `Debug` flag to tint each so the regions are visible while iterating. Targeted at +-- the 1024x768 minimum resolution: +-- +-- ┌ TitleArea ─ title ─────────────────────────────────────────────────────────┐ +-- ├──────────────────────────────────────┬─────────────────────────────────────┤ +-- │ SlotsArea (slots — one or two team │ RightArea (the map preview + facts │ +-- │ columns, top-left, up to 16) │ line + read-only options summary) │ +-- ├──────────────────────────────────────┤ │ +-- │ BottomLeftArea (Chat / Observers │ │ +-- │ — tabs) │ │ +-- ├──────────────────────────────────────┴─────────────────────────────────────┤ +-- │ ActionArea (Leave · status · … · Launch) ─ full width │ +-- └────────────────────────────────────────────────────────────────────────────┘ +-- +-- The LEFT column splits vertically: the slots on top (CustomLobbySlotsInterface — one column, or +-- two team columns for the binary auto-team modes, with the team-rating indicator atop the cards), +-- the chat/observers tabs (CustomLobbyTabs) below. The RIGHT column is the map + options +-- (CustomLobbyConfigInterface — a bound map preview, a name/size/players/version facts line, and the +-- read-only options summary). A full-width action bar at the bottom holds the global actions: Leave +-- + status on the left, the host-only Launch on the right. The title bar is just the title. +-- +-- Options/mods are edited from the per-tab config gears in the right column; the action bar's old +-- generic Settings button (and the per-domain change-map / mod-select buttons) are gone. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") +local EscapeHandler = import("/lua/ui/dialogs/eschandler.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Button = import("/lua/maui/button.lua").Button +local Combo = import("/lua/ui/controls/combo.lua").Combo +local CustomLobbyBackground = import("/lua/ui/lobby/customlobby/customlobbybackground.lua") +local CustomLobbyBackgrounds = import("/lua/ui/lobby/customlobby/customlobbybackgrounds.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbySlotsInterface = import("/lua/ui/lobby/customlobby/slots/customlobbyslotsinterface.lua") +local CustomLobbyConfigInterface = import("/lua/ui/lobby/customlobby/config/customlobbyconfiginterface.lua") +local CustomLobbyTabs = import("/lua/ui/lobby/customlobby/customlobbytabs.lua") +local CustomLobbyChatPanel = import("/lua/ui/lobby/customlobby/social/customlobbychatpanel.lua") +local CustomLobbyChatModel = import("/lua/ui/lobby/customlobby/social/customlobbychatmodel.lua") +local CustomLobbyObserversPanel = import("/lua/ui/lobby/customlobby/social/customlobbyobserverspanel.lua") +local CustomLobbyLogsPanel = import("/lua/ui/lobby/customlobby/social/customlobbylogspanel.lua") +local CustomLobbyPresetSelect = import("/lua/ui/lobby/customlobby/presetselect/customlobbypresetselect.lua") + +local LazyVarCreate = import("/lua/lazyvar.lua").Create +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- the per-tab config gear (inside the tab, left of the label) — skinned button (up/down/over/dis). +-- Local copy of the config column's gear (drift-is-fine; see ../CLAUDE.md "On sharing"). +local GearTextures = { + up = UIUtil.SkinnableFile('/game/menu-btns/config_btn_up.dds'), + down = UIUtil.SkinnableFile('/game/menu-btns/config_btn_down.dds'), + over = UIUtil.SkinnableFile('/game/menu-btns/config_btn_over.dds'), + dis = UIUtil.SkinnableFile('/game/menu-btns/config_btn_dis.dds'), +} + +-- the icon for the compact Logs tab (the game's "log" button glyph; a plain UIFile path like the +-- config column's PreviewTool icons, not a skinnable callable — CreateBitmap resolves it via UIFile) +local LogsIcon = '/BUTTON/log/_btn_up.dds' + +--- Builds a tab `Action` (a config gear) for `CustomLobbyTabs`: a skinned button that runs `onOpen` +--- on click, with a tooltip. (The chat/observer settings dialogs don't exist yet — these are +--- placeholders, so `onOpen` is a no-op for now.) +---@param onOpen fun() +---@param title string +---@param body string +---@return UICustomLobbyTabAction +local function GearAction(onOpen, title, body) + return { + Create = function(parent) + local gear = Button(parent, GearTextures.up, GearTextures.down, GearTextures.over, GearTextures.dis) + gear.OnClick = function(button, modifiers) + onOpen() + end + Tooltip.AddControlTooltipManual(gear, title, body) + return gear + end, + } +end + +-- flip to tint each layout area so the regions are visible while iterating +local Debug = false + +-- the lobby content is designed for the 1024x768 floor; the root fills the frame (full-screen +-- backdrop) but the content is centered and capped to this size, so it never stretches on a +-- larger screen +local LobbyWidth = 1024 +local LobbyHeight = 768 + +local Pad = 8 +local TitleHeight = 48 +local RightWidth = 360 -- the right column (map preview + options summary); the left fills the rest +local ActionHeight = 52 -- the full-width action bar at the very bottom (status + launch) + +--- Creates a layout area (an invisible Group with an optional debug tint). +---@param parent Control +---@param name string +---@param color string +---@return Group +local function CreateArea(parent, name, color) + local area = Group(parent, name) + local bg = Bitmap(area) + bg:SetSolidColor(color) + bg:SetAlpha(Debug and 0.18 or 0.0) + bg:DisableHitTest() + Layouter(bg):Fill(area):End() + area.Bg = bg + return area +end + +---@class UICustomLobbyInterface : Group +---@field Trash TrashBag +---@field Background UICustomLobbyBackground +---@field Content Group +---@field TitleArea Group +---@field Title Text +---@field BackgroundCombo Combo +---@field BackgroundPaths (FileName | false)[] # parallel to the combo items; [1] is "(None)" +---@field LeaveButton Button +---@field SlotsArea Group +---@field Slots UICustomLobbySlotsInterface +---@field BottomLeftArea Group +---@field BottomLeftTabs UICustomLobbyTabs +---@field ChatBadge LazyVar # dummy count pill for the Chat tab (until the chat slice lands) +---@field ObserversBadge LazyVar # observer count pill for the Observers tab +---@field RightArea Group +---@field Config UICustomLobbyConfigInterface +---@field ActionArea Group +---@field StatusLabel Text +---@field LaunchButton Button +---@field PresetsButton Button # host-only: opens the setup-presets dialog +---@field IsHostObserver LazyVar +local CustomLobbyInterface = Class(Group) { + + ---@param self UICustomLobbyInterface + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyInterface") + + self.Trash = TrashBag() + + -- full-window background image (cover-fit, keeps aspect ratio); subscribes to the per-peer + -- selection itself. A solid backdrop shows through when no image is chosen. + self.Background = CustomLobbyBackground.Create(self) + + -- everything below lives in a centered, size-capped content group (see __post_init) + self.Content = Group(self, "CustomLobbyContent") + + --#region areas + self.TitleArea = CreateArea(self.Content, "TitleArea", 'ffcc4040') + self.SlotsArea = CreateArea(self.Content, "SlotsArea", 'ffcccc40') + self.BottomLeftArea = CreateArea(self.Content, "BottomLeftArea", 'ff40cc60') + self.RightArea = CreateArea(self.Content, "RightArea", 'ff4060cc') + self.ActionArea = CreateArea(self.Content, "ActionArea", 'ff808080') + --#endregion + + --#region title bar (title + background picker) + self.Title = UIUtil.CreateText(self.TitleArea, "Custom game", 20, UIUtil.titleFont) + self.Title:DisableHitTest() + + -- a basic background picker: a combo of every *.png in the backgrounds folder (plus a + -- leading "(None)"). The choice is local + cosmetic, persisted via CustomLobbyBackgrounds. + self.BackgroundCombo = Combo(self.TitleArea, 14, 8, nil, nil, "UI_Tab_Click_01", "UI_Tab_Rollover_01") + self:PopulateBackgrounds() + Tooltip.AddControlTooltipManual(self.BackgroundCombo, "Background", + "Choose the lobby background image (a local, cosmetic choice).") + --#endregion + + -- Leave lives in the action bar (bottom-left, beside the status text); created here so it's + -- always available regardless of host status + self.LeaveButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Leave") + self.LeaveButton.OnClick = function(button, modifiers) + -- leaving disconnects + returns to the menu via the escape handler lobby.lua + -- registered (one teardown, shared with the Esc key) + EscapeHandler.HandleEsc(false) + end + + --#region slots (top-left region — a single column of rows, owns its own drag coordination) + self.Slots = CustomLobbySlotsInterface.Create(self.SlotsArea) + --#endregion + + --#region bottom-left: chat / observers tabs + -- each tab gets a config gear (left) + a count pill (right), mirroring the config column. + -- Chat's count is a dummy until the chat slice lands; Observers shows the live observer count. + -- unread chat count (lines since the Chat tab was last viewed; the panel marks the feed seen + -- while it's open). Empty when nothing is unread or while the Chat tab is active. + self.ChatBadge = self.Trash:Add(LazyVarCreate()) + self.ChatBadge:Set(function() + local model = CustomLobbyChatModel.GetSingleton() + local unread = model.TotalCount() - model.SeenTotal() + return unread > 0 and tostring(unread) or "" + end) + self.ObserversBadge = self.Trash:Add(LazyVarCreate()) + self.ObserversBadge:Set(function() + return tostring(table.getn(CustomLobbyLaunchModel.GetSingleton().Observers())) + end) + + self.BottomLeftTabs = CustomLobbyTabs.Create(self.BottomLeftArea, { + Tabs = { + -- a compact, icon-only tab: a live feed of this peer's network traffic (host and + -- clients each see their own broadcasts / sends / receives) + { Label = "Logs", Create = CustomLobbyLogsPanel.Create, Icon = LogsIcon, Compact = true }, + { + Label = "Chat", Create = CustomLobbyChatPanel.Create, Badge = self.ChatBadge, + Action = GearAction(function() end, "Chat settings", "Chat settings — coming soon."), + }, + { + Label = "Observers", Create = CustomLobbyObserversPanel.Create, Badge = self.ObserversBadge, + Action = GearAction(function() end, "Observer settings", "Observer settings — coming soon."), + }, + }, + }) + --#endregion + + --#region right column: the map preview + facts line + read-only options summary + self.Config = CustomLobbyConfigInterface.Create(self.RightArea) + --#endregion + + --#region action bar (full-width, bottom): status + launch + self.StatusLabel = UIUtil.CreateText(self.ActionArea, "", 13, UIUtil.bodyFont) + self.StatusLabel:SetColor('ff9aa0a8') + self.StatusLabel:DisableHitTest() + + self.LaunchButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/large/', "Launch") + self.LaunchButton.OnClick = function(button, modifiers) + CustomLobbyController.RequestLaunch() + end + + -- host-only: save / load named setup presets (map, options, mods, restrictions) + self.PresetsButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Presets") + self.PresetsButton.OnClick = function(button, modifiers) + CustomLobbyPresetSelect.Open(GetFrame(0)) + end + Tooltip.AddControlTooltipManual(self.PresetsButton, "Presets", + "Save the current setup as a named preset, or load a saved one (host only).") + Tooltip.AddControlTooltipManual(self.LaunchButton, "Launch", "Start the game with the current setup (host only). Everyone else must be ready.") + --#endregion + + local localModel = CustomLobbyLocalModel.GetSingleton() + self.IsHostObserver = self.Trash:Add( + LazyVarDerive(localModel.IsHost, function(isHostLazy) + self:OnIsHostChanged(isHostLazy()) + end)) + end, + + ---@param self UICustomLobbyInterface + ---@param parent Control + __post_init = function(self, parent) + Layouter(self):Fill(parent):End() + -- self.Background lays itself out cover-fit against this root (see CustomLobbyBackground) + + -- centred content, capped at the 1024x768 design size (fills the frame at the minimum + -- resolution; centred with a backdrop border on anything larger) + self.Content.Width:Set(function() return math.min(self.Width(), LayoutHelpers.ScaleNumber(LobbyWidth)) end) + self.Content.Height:Set(function() return math.min(self.Height(), LayoutHelpers.ScaleNumber(LobbyHeight)) end) + Layouter(self.Content):AtCenterIn(self):End() + + --#region areas + -- the title and the action bar span the full width, top and bottom + Layouter(self.TitleArea):AtLeftIn(self.Content, Pad):AtRightIn(self.Content, Pad):AtTopIn(self.Content, Pad):Height(TitleHeight):End() + Layouter(self.ActionArea) + :AtLeftIn(self.Content, Pad):AtRightIn(self.Content, Pad):AtBottomIn(self.Content, Pad) + :Height(ActionHeight) + :End() + + -- the right column (map + options) is a fixed width filling the height between them + Layouter(self.RightArea) + :AtRightIn(self.Content, Pad):Width(RightWidth) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) + :End() + + -- the left column splits vertically: slots on top, chat/observers below. The slots region + -- is sized to its visible rows (one column) so chat/observers grows for smaller games; both + -- stop at the left edge of the right column + Layouter(self.SlotsArea):AtLeftIn(self.Content, Pad):AnchorToBottom(self.TitleArea, Pad):End() + self.SlotsArea.Right:Set(function() return self.RightArea.Left() - LayoutHelpers.ScaleNumber(Pad) end) + self.SlotsArea.Height:Set(function() return self.Slots:PreferredHeight() end) + + Layouter(self.BottomLeftArea) + :AtLeftIn(self.Content, Pad):AnchorToBottom(self.SlotsArea, Pad):AnchorToTop(self.ActionArea, Pad) + :End() + self.BottomLeftArea.Right:Set(function() return self.RightArea.Left() - LayoutHelpers.ScaleNumber(Pad) end) + --#endregion + + --#region title bar (title + background picker) + Layouter(self.Title):AtLeftIn(self.TitleArea, 8):AtVerticalCenterIn(self.TitleArea):End() + Layouter(self.BackgroundCombo) + :AtRightIn(self.TitleArea, 8):AtVerticalCenterIn(self.TitleArea):Width(180) + :End() + --#endregion + + --#region slots fill their area (the component stacks the rows + coordinates dragging) + Layouter(self.Slots):Fill(self.SlotsArea):End() + --#endregion + + --#region bottom-left tabs (chat / observers) + Layouter(self.BottomLeftTabs):Fill(self.BottomLeftArea):End() + --#endregion + + --#region right column: map preview + options summary fill the panel + Layouter(self.Config):Fill(self.RightArea):End() + --#endregion + + --#region action bar: Leave + status on the left, launch on the right + Layouter(self.LeaveButton):AtLeftIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.StatusLabel):AnchorToRight(self.LeaveButton, 8):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.LaunchButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.PresetsButton):AnchorToLeft(self.LaunchButton, 8):AtVerticalCenterIn(self.LaunchButton):End() + --#endregion + + -- size-dependent children build their scrollbars / first render now that they're sized + -- (three-phase init) + self.BottomLeftTabs:Initialize() + self.Config:Initialize() + end, + + --- Tracks host status: updates the status line and shows the host-only Launch button only to the + --- host. (Options editing lives on the right-column config gears, host-gated there; the + --- right-column options summary stays read-only-visible to everyone.) + ---@param self UICustomLobbyInterface + ---@param isHost boolean + OnIsHostChanged = function(self, isHost) + self.StatusLabel:SetText(isHost and "You are the host." or "The host controls the game.") + if isHost then + self.LaunchButton:Show() + self.PresetsButton:Show() + else + self.LaunchButton:Hide() + self.PresetsButton:Hide() + end + end, + + --- Fills the background picker from the *.png files on disk, with a leading "(None)" entry, and + --- selects the one currently chosen. The combo writes the choice back through + --- CustomLobbyBackgrounds.Select; the Background surface reacts on its own. + ---@param self UICustomLobbyInterface + PopulateBackgrounds = function(self) + local labels = { "(None)" } + local paths = { false } + for _, background in CustomLobbyBackgrounds.Discover() do + table.insert(labels, background.Name) + table.insert(paths, background.Path) + end + self.BackgroundPaths = paths + + local selected = CustomLobbyBackgrounds.GetSelected() + local selectedIndex = 1 + for index, path in paths do + if path == selected then + selectedIndex = index + break + end + end + + self.BackgroundCombo:ClearItems() + self.BackgroundCombo:AddItems(labels, selectedIndex) + self.BackgroundCombo.OnClick = function(combo, index, text) + CustomLobbyBackgrounds.Select(self.BackgroundPaths[index] or false) + end + end, + + ---@param self UICustomLobbyInterface + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +--#region Singleton + +--- A trashbag destroyed on reload. +local ModuleTrash = TrashBag() + +---@type UICustomLobbyInterface | false +local Instance = false + +--- Returns the interface singleton, creating it on first access. +---@return UICustomLobbyInterface +function GetSingleton() + if Instance then + return Instance + end + Instance = CustomLobbyInterface(GetFrame(0)) + ModuleTrash:Add(Instance) + return Instance +end + +--- Allocates a fresh interface singleton, replacing any existing one. +---@return UICustomLobbyInterface +function SetupSingleton() + if Instance then + Instance:Destroy() + end + Instance = CustomLobbyInterface(GetFrame(0)) + ModuleTrash:Add(Instance) + return Instance +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Called by the module manager when this module is reloaded. The rebuild is driven by +--- OnDirty's deferred thread (below), so there's nothing to do here. +---@param newModule any +function __moduleinfo.OnReload(newModule) +end + +--- Called by the module manager when this module becomes dirty. +--- +--- Two things matter here: +--- * the re-import is DEFERRED a couple of frames — a synchronous `import` re-enters the +--- module manager mid-reload (e.g. when an edit to a `mapselect/` file cascades up to this +--- importer), the reload never finishes, and the torn-down lobby is left as a black frame; +--- * the rebuild is done HERE (in the deferred thread, against the freshly imported module), +--- not in OnReload — OnReload isn't reliably called for a module reloaded *transitively*, +--- which is exactly the cascade case. We only rebuild if a lobby was actually mounted, so +--- editing a lobby file while in the menu doesn't spawn a phantom lobby. +function __moduleinfo.OnDirty() + local wasMounted = Instance ~= false + ModuleTrash:Destroy() + Instance = false + ForkThread( + function() + WaitFrames(2) + local newModule = import(__moduleinfo.name) + if wasMounted then + newModule.SetupSingleton() + end + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbyLog.lua b/lua/ui/lobby/customlobby/CustomLobbyLog.lua new file mode 100644 index 00000000000..e28804db023 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyLog.lua @@ -0,0 +1,149 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The **network traffic log**: a per-peer, never-synced record of every lobby message this peer +-- broadcasts, sends or receives. The instance ([CustomLobbyInstance.lua]) feeds it from its three +-- network choke points (BroadcastData / SendData / DataReceived); the Logs tab +-- ([social/CustomLobbyLogsPanel.lua]) renders it. Because each peer logs only its *own* traffic, the +-- host's log (lots of authoritative broadcasts) and a client's log (its requests + received +-- snapshots) naturally differ — no special host/client branching here. +-- +-- It is reactive (an `Entries` LazyVar) like the lobby models, but it is *not* one of the three +-- lobby models: it carries no launch/session/local game state, just a diagnostic feed. Capped to a +-- ring of the most recent `MaxEntries` so it can't grow without bound across a long session. + +local Create = import("/lua/lazyvar.lua").Create + +--- Most recent entries kept; older ones drop off the front. +local MaxEntries = 200 + +---@alias UICustomLobbyLogKind +---| 'broadcast' # an outgoing broadcast to every peer +---| 'send' # an outgoing send to a single peer +---| 'recv' # an incoming message from a peer + +---@class UICustomLobbyLogEntry +---@field Kind UICustomLobbyLogKind +---@field Type string # the message Type (e.g. "SetPlayers") +---@field Peer? UILobbyPeerId # the other peer (send target / receive sender); nil for a broadcast +---@field Time number # seconds since the log started (for a relative clock) +---@field Error? string # set when the message was malformed / unauthorised — the reason + +------------------------------------------------------------------------------- +--#region Reactive store + +---@class UICustomLobbyLog +---@field Entries LazyVar + +---@type UICustomLobbyLog | nil +local ModelInstance = nil + +--- The baseline for relative timestamps (set when the store is created). +---@type number | nil +local StartTime = nil + +--- Allocates a fresh log singleton, replacing any existing one. +---@return UICustomLobbyLog +function SetupSingleton() + ---@type UICustomLobbyLog + local model = { + Entries = Create({}), + } + ModelInstance = model + StartTime = GetSystemTimeSeconds() + return model +end + +--- Returns the log singleton, creating it on first access. +---@return UICustomLobbyLog +function GetSingleton() + if not ModelInstance then + SetupSingleton() + end + return ModelInstance --[[@as UICustomLobbyLog]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Write helpers +-- +-- Entries is a LazyVar value, so a write builds a NEW array and `:Set`s it (mutating in place never +-- marks dependents dirty — see /lua/ui/CLAUDE.md § 2). + +--- Appends one entry (copy-then-Set), trimming the oldest beyond the cap. +---@param kind UICustomLobbyLogKind +---@param messageType? string +---@param peer? UILobbyPeerId +---@param error? string +local function Append(kind, messageType, peer, error) + local model = GetSingleton() + local entries = table.copy(model.Entries()) + table.insert(entries, { + Kind = kind, + Type = messageType or "?", + Peer = peer, + Time = GetSystemTimeSeconds() - (StartTime or GetSystemTimeSeconds()), + Error = error, + }) + while table.getn(entries) > MaxEntries do + table.remove(entries, 1) + end + model.Entries:Set(entries) +end + +--- Logs an outgoing broadcast to every peer (`error` set when it was blocked as malformed). +---@param data table # the message ({ Type = ... }) +---@param error? string +function Broadcast(data, error) + Append('broadcast', data and data.Type, nil, error) +end + +--- Logs an outgoing send to a single peer (`error` set when it was blocked as malformed). +---@param peerId UILobbyPeerId +---@param data table +---@param error? string +function Send(peerId, data, error) + Append('send', data and data.Type, peerId, error) +end + +--- Logs an incoming message (its sender is `data.SenderID`; `error` set when it was rejected). +---@param data table +---@param error? string +function Received(data, error) + Append('recv', data and data.Type, data and data.SenderID, error) +end + +--- Empties the log entirely and restarts the relative clock (called when a lobby is exited, so the +--- next lobby starts with a clean feed timed from its own start). +function Clear() + GetSingleton().Entries:Set({}) + StartTime = GetSystemTimeSeconds() +end + +--#endregion + +-- NOTE: this module deliberately has **no hot-reload hooks**. It is fed by the lobby instance +-- ([CustomLobbyInstance.lua]), which holds its import from load time and never hot-reloads — so the +-- store has to be a single stable singleton that survives UI hot-reloads (interface / tabs / panel), +-- or the instance would keep writing into a replaced singleton and the panel would stop updating. +-- The cost is that edits to *this* file need a game restart to take effect. diff --git a/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua b/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua new file mode 100644 index 00000000000..e1eb8e316ae --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua @@ -0,0 +1,262 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The map preview, as one whole: the chrome (a glow border + dark backdrop) wrapped around the +-- shared CustomLobbyScenarioPreview surface, plus the faction spawn icon (the local +-- `MapPreviewSpawn`). Both preview consumers use this one component, so they look identical: +-- +-- * In the lobby — created with `Bound = true`. It subscribes to the derived scenario model +-- (CustomLobbyScenarioDerivedModel) and renders the resolved scenario automatically (hand info + markers +-- to the surface, show/hide). The model loads + dedups, so a launch-info rebroadcast of the same +-- map doesn't reload; per-slot faction-icon spawns refresh as players take/swap/recolour (no map +-- reload). +-- +-- * In the map-select dialog — created unbound (the default). No model wiring, numbered-dot spawns +-- (the surface default); the owner drives the preview itself through `self.Surface` +-- (`SetScenario` / `SetSpawnData` / `SetOverlayVisible` / `Clear`) to show the browse candidate, +-- and anchors its own overlays (name bar, info, …) to `self.Surface` — the inner map rect. +-- +-- Layout: this group is the OUTER rect (the glow fills it); the backdrop and the surface are inset +-- by `Padding`, so the map sits within and the glow frames it. The glow renders ON TOP of the map +-- (its depth is lifted above the surface), so the ring overlays the map's edges rather than hiding +-- behind them — the texture's centre is transparent, so the map shows through. +-- +-- The texture / overlay / positioning work all lives in the surface — see CustomLobbyScenarioPreview.lua. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local CustomLobbyScenarioPreview = import("/lua/ui/lobby/customlobby/customlobbyscenariopreview.lua") +local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +local GlowTexture = '/scx_menu/gameselect/map-panel-glow_bmp.dds' +local BackdropColor = 'ff000000' +local Padding = 14 -- map inset from the outer edge; the glow ring lives in this margin +local SpawnIconSize = 32 + +-------------------------------------------------------------------------------------------------- +--#region Faction spawn icon + +-- A start-position marker: a faction icon placed at a spawn. Faction-only — no lobby coupling. +-- The surface calls `:Update(faction)` for a seated spot and `:Reset()` for an empty one (so empty +-- spots stay blank). Used only when the preview is `Bound`; unbound previews get numbered dots. + +---@class UICustomLobbyMapPreviewSpawn : Bitmap +---@field Faction? number +local MapPreviewSpawn = ClassUI(Bitmap) { + + EmptyPath = "/textures/ui/common/dialogs/mapselect02/commander_alpha.dds", + FactionIconPaths = { + "/textures/ui/common/faction_icon-lg/uef_med.dds", + "/textures/ui/common/faction_icon-lg/aeon_med.dds", + "/textures/ui/common/faction_icon-lg/cybran_med.dds", + "/textures/ui/common/faction_icon-lg/seraphim_med.dds", + }, + + ---@param self UICustomLobbyMapPreviewSpawn + ---@param parent Control + __init = function(self, parent) + Bitmap.__init(self, parent, self.EmptyPath) + + self.Faction = nil + self:Hide() + end, + + ---@param self UICustomLobbyMapPreviewSpawn + ---@param parent Control + __post_init = function(self, parent) + Layouter(self):Width(SpawnIconSize):Height(SpawnIconSize):Over(parent, 32):End() + end, + + ---@param self UICustomLobbyMapPreviewSpawn + Reset = function(self) + self.Faction = nil + self:Hide() + end, + + ---@param self Control + ---@param event KeyEvent + ---@return boolean + HandleEvent = function(self, event) + if event.Type == 'MouseEnter' then + self:SetAlpha(0.25) + elseif event.Type == 'MouseExit' then + self:SetAlpha(1.0) + end + return true + end, + + ---@param self UICustomLobbyMapPreviewSpawn + Show = function(self) + if self.Faction then + Bitmap.Show(self) + else + self:Hide() + end + end, + + ---@param self UICustomLobbyMapPreviewSpawn + ---@param faction number + Update = function(self, faction) + local factionIcon = self.FactionIconPaths[faction] + if factionIcon then + self.Faction = faction + self:SetTexture(UIUtil.UIFile(factionIcon)) + self:Show() + end + end, +} + +--#endregion + +-------------------------------------------------------------------------------------------------- +--#region Map preview + +---@class UICustomLobbyMapPreviewOptions +---@field Bound? boolean # subscribe to the launch model + use faction-icon spawns (default false) + +---@class UICustomLobbyMapPreview : Group +---@field Trash TrashBag +---@field Bound boolean +---@field Glow Bitmap +---@field Backdrop Bitmap +---@field Surface UICustomLobbyScenarioPreview +---@field ScenarioObserver? LazyVar +---@field PlayerObservers? LazyVar[] +local CustomLobbyMapPreview = ClassUI(Group) { + + ---@param self UICustomLobbyMapPreview + ---@param parent Control + ---@param options? UICustomLobbyMapPreviewOptions + __init = function(self, parent, options) + Group.__init(self, parent, "CustomLobbyMapPreview") + + options = options or {} + self.Trash = TrashBag() + self.Bound = options.Bound or false + + -- chrome: a dark backdrop behind the map (shows through letterboxing / before the texture + -- loads); the glow ring is lifted on top of the map in __post_init to frame its edges + self.Backdrop = Bitmap(self) + self.Backdrop:SetSolidColor(BackdropColor) + self.Backdrop:DisableHitTest() + + -- the shared surface; faction-icon spawns when bound, numbered dots (the default) otherwise + self.Surface = CustomLobbyScenarioPreview.Create(self, { + CreateSpawnIcon = self.Bound and function(surface, index) + return MapPreviewSpawn(surface) + end or nil, + }) + + self.Glow = UIUtil.CreateBitmap(self, GlowTexture) + self.Glow:DisableHitTest() + + -- bound: the derived scenario model drives the preview. The resolved scenario renders the whole + -- map; each slot drives only the spawn icons (against the already-loaded scenario, so take/swap/ + -- faction changes don't reload the map). The model dedups by file, so a launch-info rebroadcast + -- of the same map doesn't re-fire here either. Unbound: the owner drives self.Surface directly. + if self.Bound then + self.ScenarioObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyScenarioDerivedModel.GetScenarioVar(), function(scenarioLazy) + self:OnScenarioChanged(scenarioLazy()) + end)) + + local model = CustomLobbyLaunchModel.GetSingleton() + self.PlayerObservers = {} + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + self.PlayerObservers[slot] = self.Trash:Add( + LazyVarDerive(model.Players[slot], function(playerLazy) + playerLazy() + self:OnPlayersChanged() + end)) + end + end + end, + + ---@param self UICustomLobbyMapPreview + __post_init = function(self) + Layouter(self.Glow):Fill(self):End() + Layouter(self.Backdrop):FillFixedBorder(self.Glow, Padding):End() + Layouter(self.Surface):FillFixedBorder(self.Glow, Padding):End() + + -- render the glow above the surface and all its overlays (spawn icons sit at + -- Preview.Depth()+10), so the ring frames the map's edges instead of hiding behind them + self.Glow.Depth:Set(function() return self.Surface.Depth() + 100 end) + end, + + ---@param self UICustomLobbyMapPreview + OnDestroy = function(self) + self.Trash:Destroy() + end, + + --- (Bound only) Hands the already-resolved scenario to the surface; hides the preview when there + --- is none. The derived model did the loading + dedup, so this just renders what it's given. + ---@param self UICustomLobbyMapPreview + ---@param scenario UICustomLobbyScenario | false + OnScenarioChanged = function(self, scenario) + if not scenario then + self.Surface:Clear() + self:Hide() + return + end + + self.Surface:SetScenario(scenario.Info, scenario.Markers) + self.Surface:SetSpawnData(self:GatherSpawnData()) + self:Show() + end, + + --- (Bound only) A slot changed: refresh the surface's spawn data (faction by start spot). + ---@param self UICustomLobbyMapPreview + OnPlayersChanged = function(self) + self.Surface:SetSpawnData(self:GatherSpawnData()) + end, + + --- The seated factions keyed by start spot (the spawn id the surface positions by). + ---@param self UICustomLobbyMapPreview + ---@return table + GatherSpawnData = function(self) + local model = CustomLobbyLaunchModel.GetSingleton() + local data = {} + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local player = model.Players[slot]() + if player then + data[player.StartSpot or slot] = player.Faction + end + end + return data + end, +} + +--#endregion + +---@param parent Control +---@param options? UICustomLobbyMapPreviewOptions +---@return UICustomLobbyMapPreview +Create = function(parent, options) + return CustomLobbyMapPreview(parent, options) +end diff --git a/lua/ui/lobby/customlobby/CustomLobbyMenus.lua b/lua/ui/lobby/customlobby/CustomLobbyMenus.lua new file mode 100644 index 00000000000..bec0cbd9a30 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyMenus.lua @@ -0,0 +1,184 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- What goes in each context menu, declaratively. A `Build*` function gathers a small +-- context table (slot, role, lobby state) and filters the entry list by each entry's +-- `when(ctx)` predicate, so the same definition produces a different menu for a host +-- vs a regular player, an open vs occupied slot, etc. +-- +-- TO ADD AN ITEM: append `{ label, when, action }` to the relevant list. `when(ctx)` +-- decides visibility for the current state; `action(ctx)` runs on click and should +-- call a CustomLobbyController intent (never touch the model directly). `enabled(ctx)` +-- is optional (default true) to show an item greyed-out instead of hiding it. +-- +-- The result feeds CustomLobbyContextMenu.Show (a list of { label, action, enabled }). + +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") + +------------------------------------------------------------------------------- +-- Slot menu + +--- The state a slot-menu entry is evaluated against. +---@class UICustomLobbySlotMenuContext +---@field slot number +---@field player UICustomLobbyPlayer | false +---@field isHost boolean +---@field isYou boolean +---@field isOpen boolean +---@field localIsObserver boolean # the local player is currently spectating (no slot) +---@field locked boolean # this seat is pinned in place for auto-balance + +--- A declarative slot-menu entry. +---@class UICustomLobbySlotMenuEntry +---@field label string +---@field when fun(ctx: UICustomLobbySlotMenuContext): boolean +---@field action fun(ctx: UICustomLobbySlotMenuContext) +---@field enabled? fun(ctx: UICustomLobbySlotMenuContext): boolean + +---@type UICustomLobbySlotMenuEntry[] +local SlotMenu = { + { + -- an observer joining a slot is "playing" it + label = "Play this slot", + when = function(ctx) return ctx.isOpen and ctx.localIsObserver end, + action = function(ctx) CustomLobbyController.RequestTakeSlot(ctx.slot) end, + }, + { + -- a seated player relocating to another open slot + label = "Take this slot", + when = function(ctx) return ctx.isOpen and not ctx.localIsObserver end, + action = function(ctx) CustomLobbyController.RequestTakeSlot(ctx.slot) end, + }, + { + label = "Ready up", + when = function(ctx) return ctx.isYou and ctx.player and not ctx.player.Ready end, + action = function(ctx) CustomLobbyController.RequestSetReady(true) end, + }, + { + label = "Cancel ready", + when = function(ctx) return ctx.isYou and ctx.player and ctx.player.Ready end, + action = function(ctx) CustomLobbyController.RequestSetReady(false) end, + }, + + -- Host actions: + { + -- pin a seated player so auto-balance keeps them where they are (e.g. to hold a premade + -- pair on the same team); only the unlocked players are rearranged + label = "Lock in slot", + when = function(ctx) return ctx.isHost and ctx.player and not ctx.locked end, + action = function(ctx) CustomLobbyController.RequestSetSlotLocked(ctx.slot, true) end, + }, + { + label = "Unlock slot", + when = function(ctx) return ctx.isHost and ctx.player and ctx.locked end, + action = function(ctx) CustomLobbyController.RequestSetSlotLocked(ctx.slot, false) end, + }, + { + label = "Move to observers", + when = function(ctx) return ctx.isHost and ctx.player and ctx.player.Human end, + action = function(ctx) CustomLobbyController.RequestMoveToObserver(ctx.slot) end, + }, + { + label = "Eject", + when = function(ctx) return ctx.isHost and ctx.player and not ctx.isYou end, + action = function(ctx) CustomLobbyController.RequestEject(ctx.slot) end, + }, +} + +--- Whether `ownerId` is in the observer list. +---@param launch UICustomLobbyLaunchModel +---@param ownerId UILobbyPeerId +---@return boolean +local function IsObserver(launch, ownerId) + local observers = launch.Observers() + for i = 1, table.getn(observers) do + if observers[i].OwnerID == ownerId then + return true + end + end + return false +end + +--- Snapshots the state a slot menu is built from. +---@param slot number +---@return UICustomLobbySlotMenuContext +local function SlotContext(slot) + local launch = CustomLobbyLaunchModel.GetSingleton() + local localModel = CustomLobbyLocalModel.GetSingleton() + local player = launch.Players[slot]() + local localId = localModel.LocalPeerId() + return { + slot = slot, + player = player, + isHost = localModel.IsHost(), + isYou = (player and player.OwnerID == localId) and true or false, + isOpen = not player, + localIsObserver = IsObserver(launch, localId), + locked = CustomLobbySessionModel.GetSingleton().LockedSlots()[slot] and true or false, + } +end + +--- Filters a declarative entry list against a context into concrete menu items. +---@generic T +---@param entries table[] +---@param ctx T +---@return UICustomLobbyContextMenuItem[] +local function Resolve(entries, ctx) + local items = {} + for i = 1, table.getn(entries) do + local entry = entries[i] + if entry.when(ctx) then + local entryAction = entry.action + local entryEnabled = entry.enabled + table.insert(items, { + label = entry.label, + enabled = entryEnabled == nil or entryEnabled(ctx), + action = function() entryAction(ctx) end, + }) + end + end + return items +end + +--- The context-menu items for a slot, given the current lobby state. +---@param slot number +---@return UICustomLobbyContextMenuItem[] +function BuildSlotMenu(slot) + return Resolve(SlotMenu, SlotContext(slot)) +end + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbyMessages.lua b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua new file mode 100644 index 00000000000..6b4035d0e35 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyMessages.lua @@ -0,0 +1,440 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The valid peer-to-peer messages for the custom lobby. Mirrors the autolobby's +-- AutolobbyMessages registry: each entry has Validate (drop nonsense), Accept (drop +-- unauthorised) and Handler (route to the controller). The instance's DataReceived / +-- BroadcastData / SendData run these before anything happens. +-- +-- **Validate / Accept return `true` when the message is fine, or `false, reason` when it is not** — +-- a human-readable reason string explaining why (never a bare `false` — always say *why*). The +-- instance logs that reason against the message (see CustomLobbyLog / the Logs tab) and the UI +-- surfaces it as a tooltip, so a bad or unauthorised message is explained rather than silently dropped. +-- +-- Each message's payload is typed via a `---@class … : UILobbyReceivedMessage` so the +-- handlers (and any future tooling) know its exact shape. + +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") + +--- Authorisation check: passes only for the host. +---@param lobby UICustomLobbyInstance +---@return boolean ok +---@return string? reason +local function RequireHost(lobby) + if lobby:IsHost() then + return true + end + return false, "only the host may act on this message" +end + +--- Authorisation check: passes only when the message came from the host. +---@param lobby UICustomLobbyInstance +---@param data UILobbyReceivedMessage +---@return boolean ok +---@return string? reason +local function RequireFromHost(lobby, data) + if data.SenderID == CustomLobbyLocalModel.GetSingleton().HostID() then + return true + end + return false, "message did not come from the host" +end + +---@class UICustomLobbyMessageHandler +---@field Validate fun(lobby: UICustomLobbyInstance, data: UILobbyReceivedMessage): boolean, string? +---@field Accept fun(lobby: UICustomLobbyInstance, data: UILobbyReceivedMessage): boolean, string? +---@field Handler fun(lobby: UICustomLobbyInstance, data: UILobbyReceivedMessage) + +---@type table +CustomLobbyMessages = { + + -- A connecting client announces itself to the host. + AddPlayer = { + ---@class UICustomLobbyAddPlayerMessage : UILobbyReceivedMessage + ---@field PlayerOptions UICustomLobbyPlayer + + ---@param data UICustomLobbyAddPlayerMessage + Validate = function(lobby, data) + if data.PlayerOptions == nil then + return false, "AddPlayer is missing its PlayerOptions" + end + return true + end, + Accept = function(lobby, data) + return RequireHost(lobby) + end, + ---@param data UICustomLobbyAddPlayerMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessAddPlayer(lobby, data) + end, + }, + + -- The host's authoritative snapshot of all slots, broadcast on any change. + SetPlayers = { + ---@class UICustomLobbySetPlayersMessage : UILobbyReceivedMessage + ---@field Players (UICustomLobbyPlayer | false)[] + ---@field Observers? UICustomLobbyPlayer[] + + ---@param data UICustomLobbySetPlayersMessage + Validate = function(lobby, data) + if type(data.Players) ~= 'table' then + return false, "SetPlayers has no Players array" + end + return true + end, + Accept = function(lobby, data) + return RequireFromHost(lobby, data) + end, + ---@param data UICustomLobbySetPlayersMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessSetPlayers(lobby, data) + end, + }, + + -- The host's launch configuration — the launch-state fields that aren't the player + -- list (scenario, options, mods, teams, spawn mex, unit restrictions). Broadcast on any + -- change and to each peer as it joins; the whole snapshot is sent rather than per-field deltas. + SentLaunchInfo = { + ---@class UICustomLobbySentLaunchInfoMessage : UILobbyReceivedMessage + ---@field ScenarioFile FileName | false + ---@field GameOptions table + ---@field GameMods table + ---@field AutoTeams table + ---@field SpawnMex table + ---@field Restrictions string[] + + ---@param data UICustomLobbySentLaunchInfoMessage + Validate = function(lobby, data) + if type(data.GameOptions) ~= 'table' or type(data.GameMods) ~= 'table' then + return false, "SentLaunchInfo is missing its GameOptions / GameMods" + end + return true + end, + Accept = function(lobby, data) + return RequireFromHost(lobby, data) + end, + ---@param data UICustomLobbySentLaunchInfoMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessSentLaunchInfo(lobby, data) + end, + }, + + -- The host's session state — lobby-room management that is NOT launched (slot count, + -- closed slots, locked slots). A separate snapshot from the launch config so each stays focused. + SetSessionState = { + ---@class UICustomLobbySetSessionStateMessage : UILobbyReceivedMessage + ---@field SlotCount number + ---@field ClosedSlots table + ---@field LockedSlots table + ---@field SlotsPinned boolean + + ---@param data UICustomLobbySetSessionStateMessage + Validate = function(lobby, data) + if type(data.SlotCount) ~= 'number' then + return false, "SetSessionState has no SlotCount" + end + return true + end, + Accept = function(lobby, data) + return RequireFromHost(lobby, data) + end, + ---@param data UICustomLobbySetSessionStateMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessSetSessionState(lobby, data) + end, + }, + + -- A client asks the host to flip its ready flag. + SetReady = { + ---@class UICustomLobbySetReadyMessage : UILobbyReceivedMessage + ---@field Ready boolean + + ---@param data UICustomLobbySetReadyMessage + Validate = function(lobby, data) + if type(data.Ready) ~= 'boolean' then + return false, "SetReady has no Ready flag" + end + return true + end, + Accept = function(lobby, data) + return RequireHost(lobby) + end, + ---@param data UICustomLobbySetReadyMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessSetReady(lobby, data) + end, + }, + + -- A client asks the host to set its own faction multi-select (the allowed real-faction indices; + -- more than one = random among them). The host applies it to the sender's slot. + SetFactions = { + ---@class UICustomLobbySetFactionsMessage : UILobbyReceivedMessage + ---@field Factions number[] + + ---@param data UICustomLobbySetFactionsMessage + Validate = function(lobby, data) + if type(data.Factions) ~= 'table' then + return false, "SetFactions has no Factions list" + end + return true + end, + Accept = function(lobby, data) + return RequireHost(lobby) + end, + ---@param data UICustomLobbySetFactionsMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessSetFactions(lobby, data) + end, + }, + + -- A client asks the host to set its own colour (a PlayerColor index). The host applies it to the + -- sender's slot, rejecting a colour already taken by another seated player. + SetColor = { + ---@class UICustomLobbySetColorMessage : UILobbyReceivedMessage + ---@field Color number + + ---@param data UICustomLobbySetColorMessage + Validate = function(lobby, data) + if type(data.Color) ~= 'number' then + return false, "SetColor has no Color index" + end + return true + end, + Accept = function(lobby, data) + return RequireHost(lobby) + end, + ---@param data UICustomLobbySetColorMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessSetColor(lobby, data) + end, + }, + + -- A client asks the host to set its own team (backend numbering: 1 = no team, 2..9 = teams 1..8). + -- The host applies it to the sender's slot. + SetTeam = { + ---@class UICustomLobbySetTeamMessage : UILobbyReceivedMessage + ---@field Team number + + ---@param data UICustomLobbySetTeamMessage + Validate = function(lobby, data) + if type(data.Team) ~= 'number' then + return false, "SetTeam has no Team number" + end + return true + end, + Accept = function(lobby, data) + return RequireHost(lobby) + end, + ---@param data UICustomLobbySetTeamMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessSetTeam(lobby, data) + end, + }, + + -- A client asks the host to move it into an open slot (also reachable via a + -- `/take ` chat command). The host validates the seat and re-broadcasts. + TakeSlot = { + ---@class UICustomLobbyTakeSlotMessage : UILobbyReceivedMessage + ---@field Slot number + + ---@param data UICustomLobbyTakeSlotMessage + Validate = function(lobby, data) + if type(data.Slot) ~= 'number' then + return false, "TakeSlot has no Slot number" + end + return true + end, + Accept = function(lobby, data) + return RequireHost(lobby) + end, + ---@param data UICustomLobbyTakeSlotMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessTakeSlot(lobby, data) + end, + }, + + -- A client asks the host to broadcast a chat line. Any connected peer may ask; the host is the + -- single chokepoint that decides whether to relay it (future filtering/muting/rate-limiting lives + -- in ProcessRequestChat). The host's own lines short-circuit straight to that handler. + RequestChat = { + ---@class UICustomLobbyRequestChatMessage : UILobbyReceivedMessage + ---@field Text string + ---@field Id string # the sender's client-stamped id, echoed back so the sender can reconcile + + ---@param data UICustomLobbyRequestChatMessage + Validate = function(lobby, data) + if type(data.Text) ~= 'string' or data.Text == '' then + return false, "RequestChat has no Text" + end + if type(data.Id) ~= 'string' then + return false, "RequestChat has no Id" + end + return true + end, + Accept = function(lobby, data) + -- any connected peer may request a chat line — the host filters in ProcessRequestChat + return true + end, + ---@param data UICustomLobbyRequestChatMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessRequestChat(lobby, data) + end, + }, + + -- The host's authoritative broadcast of an accepted chat line (with the sender's name resolved + -- host-side). Every peer appends it; the originating peer reconciles its optimistic Pending line by + -- the echoed Id. Sent to everyone (a whisper, slice 3, will send to a subset instead). + ChatMessage = { + ---@class UICustomLobbyChatMessageMessage : UILobbyReceivedMessage + ---@field Id string + ---@field SenderID UILobbyPeerId + ---@field SenderName string + ---@field Text string + + ---@param data UICustomLobbyChatMessageMessage + Validate = function(lobby, data) + if type(data.Text) ~= 'string' or data.Text == '' then + return false, "ChatMessage has no Text" + end + if type(data.Id) ~= 'string' then + return false, "ChatMessage has no Id" + end + return true + end, + Accept = function(lobby, data) + return RequireFromHost(lobby, data) + end, + ---@param data UICustomLobbyChatMessageMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessChatMessage(lobby, data) + end, + }, + + -- The host's authoritative lobby notice — a senderless system line shown in chat (a peer joined / + -- left, etc.). The host is the single source so every peer shows the same notice with no per-peer + -- roster diffing; the leaver simply isn't there to receive its own "left" line. + SystemNotice = { + ---@class UICustomLobbySystemNoticeMessage : UILobbyReceivedMessage + ---@field Text string + + ---@param data UICustomLobbySystemNoticeMessage + Validate = function(lobby, data) + if type(data.Text) ~= 'string' or data.Text == '' then + return false, "SystemNotice has no Text" + end + return true + end, + Accept = function(lobby, data) + return RequireFromHost(lobby, data) + end, + ---@param data UICustomLobbySystemNoticeMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessSystemNotice(lobby, data) + end, + }, + + -- The host tells everyone still connected to drop their direct link to a peer + -- that left, so the mesh is cleaned up (the player state follows via SetPlayers). + DisconnectPeer = { + ---@class UICustomLobbyDisconnectPeerMessage : UILobbyReceivedMessage + ---@field PeerID UILobbyPeerId + + ---@param data UICustomLobbyDisconnectPeerMessage + Validate = function(lobby, data) + if data.PeerID == nil then + return false, "DisconnectPeer is missing its PeerID" + end + return true + end, + Accept = function(lobby, data) + return RequireFromHost(lobby, data) + end, + ---@param data UICustomLobbyDisconnectPeerMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessDisconnectPeer(lobby, data) + end, + }, + + -- The host tells everyone to launch with the final game configuration (players, options, + -- mods, scenario). On receipt each peer hands it straight to the engine. + LaunchGame = { + ---@class UICustomLobbyLaunchGameMessage : UILobbyReceivedMessage + ---@field GameConfig UILobbyLaunchConfiguration + + ---@param data UICustomLobbyLaunchGameMessage + Validate = function(lobby, data) + if type(data.GameConfig) ~= 'table' then + return false, "LaunchGame has no GameConfig" + end + return true + end, + Accept = function(lobby, data) + return RequireFromHost(lobby, data) + end, + ---@param data UICustomLobbyLaunchGameMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessLaunchGame(lobby, data) + end, + }, + + -- A client reports its in-game sim-performance history (the rich benchmark). + ReportCpuBenchmark = { + ---@class UICustomLobbyReportCpuBenchmarkMessage : UILobbyReceivedMessage + ---@field CpuBenchmark UIPerformanceMetrics + + ---@param data UICustomLobbyReportCpuBenchmarkMessage + Validate = function(lobby, data) + if type(data.CpuBenchmark) ~= 'table' then + return false, "ReportCpuBenchmark has no CpuBenchmark" + end + return true + end, + Accept = function(lobby, data) + return RequireHost(lobby) + end, + ---@param data UICustomLobbyReportCpuBenchmarkMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessReportCpuBenchmark(lobby, data) + end, + }, + + -- The host's authoritative snapshot of everyone's benchmarks. + SetCpuBenchmarks = { + ---@class UICustomLobbySetCpuBenchmarksMessage : UILobbyReceivedMessage + ---@field CpuBenchmarks table + + ---@param data UICustomLobbySetCpuBenchmarksMessage + Validate = function(lobby, data) + if type(data.CpuBenchmarks) ~= 'table' then + return false, "SetCpuBenchmarks has no CpuBenchmarks table" + end + return true + end, + Accept = function(lobby, data) + return RequireFromHost(lobby, data) + end, + ---@param data UICustomLobbySetCpuBenchmarksMessage + Handler = function(lobby, data) + CustomLobbyController.ProcessSetCpuBenchmarks(lobby, data) + end, + }, +} diff --git a/lua/ui/lobby/customlobby/CustomLobbyPerformancePopover.lua b/lua/ui/lobby/customlobby/CustomLobbyPerformancePopover.lua new file mode 100644 index 00000000000..d8965fc13c1 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyPerformancePopover.lua @@ -0,0 +1,406 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- A hover popover that visualises a peer's sim-performance history (the +-- `PerformanceTrackingV2` data, see /lua/system/performance.lua). Framed like the +-- chat config, with a hand-built bitmap bar chart (no functional histogram control +-- exists). One bar per simulation-rate bucket: the bar spans the unit-count range +-- [Min, Max] observed at that rate (scaled to the busiest bucket), and its brightness +-- encodes how many samples back it up. Negative rates = the sim lagged; positive = +-- the machine had headroom. +-- +-- Singleton, shown/hidden by the slot rows on CPU-score hover. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap + +local Create = import("/lua/lazyvar.lua").Create +local Layouter = LayoutHelpers.ReusedLayoutFor + +local Buckets = 21 -- sim-rate buckets: index k -> rate (k - 11), i.e. -10 .. +10 +local BarWidth = 13 +local BarGap = 2 +local ChartHeight = 78 +local ContentPad = 14 +local AxisWidth = 38 -- left gutter for the unit-count (Y) axis labels +local PopoverWidth = ContentPad * 2 + AxisWidth + Buckets * (BarWidth + BarGap) +local PopoverHeight = 156 + +local GridlineColor = 'ffffffff' -- faint white reference lines +local GridlineAlpha = 0.10 +local CapColor = 'ffe0c020' -- the recommended-unit-cap line (and its label): a clear yellow +local CapAlpha = 0.85 + +--- Formats a unit count compactly for the Y axis (e.g. 5421 -> "5.4k"). +---@param value number +---@return string +local function FormatUnits(value) + if value >= 1000 then + return string.format("%.1fk", value / 1000) + end + return tostring(math.floor(value + 0.5)) +end + +--- The three game-type categories tracked, ordered for the "most played" pick. +local Categories = { 'Skirmish', 'SkirmishWithAI', 'Campaign' } +local CategoryLabels = { + Skirmish = 'Skirmish', + SkirmishWithAI = 'Skirmish vs AI', + Campaign = 'Campaign', +} + +------------------------------------------------------------------------------- +-- One bar: a column whose filled extent is the [Min, Max] unit-count range. + +---@class UIPerformanceBar : Group +---@field Background Bitmap +---@field Bar Bitmap +---@field Label Text +---@field PctBottom LazyVar +---@field PctTop LazyVar +local PerformanceBar = ClassUI(Group) { + + ---@param self UIPerformanceBar + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent) + + self.PctBottom = Create(0.0) + self.PctTop = Create(0.0) + + self.Background = Bitmap(self) + self.Background:SetSolidColor('ffffffff') + self.Background:SetAlpha(0.06) + self.Background:DisableHitTest() + + self.Bar = Bitmap(self) + self.Bar:SetSolidColor('ff999999') + self.Bar:DisableHitTest() + + self.Label = UIUtil.CreateText(self, '', 9, UIUtil.bodyFont) + self.Label:DisableHitTest() + end, + + ---@param self UIPerformanceBar + __post_init = function(self) + Layouter(self.Background):Fill(self):End() + + -- the bar fills the column horizontally; its vertical extent is driven by + -- the [Min, Max] percentages, measured up from the column's bottom + Layouter(self.Bar):AtLeftIn(self):AtRightIn(self):End() + self.Bar.Bottom:Set(function() return self.Bottom() - self.Height() * self.PctBottom() end) + self.Bar.Top:Set(function() return self.Bottom() - self.Height() * self.PctTop() end) + + Layouter(self.Label):AtHorizontalCenterIn(self):AnchorToBottom(self, 2):End() + end, + + --- @param self UIPerformanceBar + --- @param samples number + --- @param min number + --- @param max number + --- @param maxUnits number # largest Max across all buckets, for scaling + SetData = function(self, samples, min, max, maxUnits) + if samples <= 0 or maxUnits <= 0 then + self.PctBottom:Set(0.0) + self.PctTop:Set(0.0) + return + end + + -- clamp to the chart: a bucket above the scale (e.g. over the recommended + -- cap) pegs at the top rather than overflowing the popover + self.PctBottom:Set(math.clamp(min / maxUnits, 0.0, 1.0)) + self.PctTop:Set(math.clamp(max / maxUnits, 0.0, 1.0)) + + -- brightness grows with confidence (more samples -> whiter) + local c = 1 - 1 / math.sqrt(math.max(samples, 1)) + local v = math.floor(math.clamp(c, 0.15, 1.0) * 255) + self.Bar:SetSolidColor(string.format('ff%02x%02x%02x', v, v, v)) + end, + + ---@param self UIPerformanceBar + ---@param text string + SetLabel = function(self, text) + self.Label:SetText(text) + end, +} + +------------------------------------------------------------------------------- +-- The popover panel. + +---@class UICustomLobbyPerformancePopover : Group +---@field Border Bitmap +---@field Background Bitmap +---@field Title Text +---@field Subtitle Text +---@field Gridlines Bitmap[] # three faint horizontal reference lines (top / mid / bottom) +---@field AxisLabels Text[] # unit-count labels next to those lines (max / half / 0) +---@field CapLine Bitmap # yellow reference line at the recommended unit cap +---@field CapFraction LazyVar # cap as a fraction of the chart's scale (0 = bottom, 1 = top) +---@field Bars UIPerformanceBar[] +local CustomLobbyPerformancePopover = ClassUI(Group) { + + ---@param self UICustomLobbyPerformancePopover + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyPerformancePopover") + + -- simple 1px frame: a light border bitmap with a dark fill pinned 1px inside + -- (the chat-config border colour, for a consistent look) + self.Border = Bitmap(self) + self.Border:SetSolidColor('ff415055') + self.Border:DisableHitTest() + + self.Background = Bitmap(self) + self.Background:SetSolidColor('f0101418') + self.Background:DisableHitTest() + + self.Title = UIUtil.CreateText(self, "Sim performance", 14, UIUtil.titleFont) + self.Title:DisableHitTest() + self.Subtitle = UIUtil.CreateText(self, "", 10, UIUtil.bodyFont) + self.Subtitle:DisableHitTest() + self.Subtitle:SetColor('ff9aa0a8') + + -- the unit-count (Y) axis: three faint reference lines and their labels. + -- Created before the bars so the bars draw on top of the lines. + self.Gridlines = {} + self.AxisLabels = {} + for k = 1, 3 do + local line = Bitmap(self) + line:SetSolidColor(GridlineColor) + line:SetAlpha(GridlineAlpha) + line:DisableHitTest() + self.Gridlines[k] = line + + local label = UIUtil.CreateText(self, "", 9, UIUtil.bodyFont) + label:SetColor('ff9aa0a8') + label:DisableHitTest() + self.AxisLabels[k] = label + end + + self.Bars = {} + for k = 1, Buckets do + self.Bars[k] = PerformanceBar(self) + local rate = k - 11 + self.Bars[k]:SetLabel(rate >= 0 and ("+" .. rate) or tostring(rate)) + end + + -- the recommended-cap line floats at CapFraction of the chart; it draws over + -- the bars (created last) so it reads as a threshold the bars can exceed. + -- Hidden (alpha 0) until SetData is given a cap. + self.CapFraction = Create(0.0) + self.CapLine = Bitmap(self) + self.CapLine:SetSolidColor(CapColor) + self.CapLine:SetAlpha(0.0) + self.CapLine:DisableHitTest() + + -- purely informational; never capture the mouse (the slot row drives + -- show/hide on hover, and capturing would make it flicker) + self:DisableHitTest(true) + end, + + ---@param self UICustomLobbyPerformancePopover + __post_init = function(self) + Layouter(self):Width(PopoverWidth):Height(PopoverHeight):End() + Layouter(self.Border):Fill(self):End() + Layouter(self.Background) + :AtLeftIn(self, 1):AtRightIn(self, 1):AtTopIn(self, 1):AtBottomIn(self, 1) + :End() + + Layouter(self.Title):AtLeftTopIn(self, ContentPad, 8):End() + Layouter(self.Subtitle):AtLeftIn(self, ContentPad):AnchorToBottom(self.Title, 2):End() + + local chartTop = PopoverHeight - ContentPad - 14 - ChartHeight + for k = 1, Buckets do + local bar = self.Bars[k] + local builder = Layouter(bar):Width(BarWidth):Height(ChartHeight):Top(function() return self.Top() + LayoutHelpers.ScaleNumber(chartTop) end) + if k == 1 then + builder:AtLeftIn(self, ContentPad + AxisWidth) + else + builder:AnchorToRight(self.Bars[k - 1], BarGap) + end + builder:End() + end + + -- reference lines at the top (max), middle (half) and bottom (zero) of the + -- chart, with the unit-count labels right-aligned in the left gutter + local lineYs = { chartTop, chartTop + ChartHeight * 0.5, chartTop + ChartHeight } + for k = 1, 3 do + local y = lineYs[k] + Layouter(self.Gridlines[k]) + :AtLeftIn(self, ContentPad + AxisWidth):AtRightIn(self, ContentPad):Height(1) + :Top(function() return self.Top() + LayoutHelpers.ScaleNumber(y) end) + :End() + Layouter(self.AxisLabels[k]) + :AnchorToLeft(self.Bars[1], 4):AtVerticalCenterIn(self.Gridlines[k]) + :End() + end + + -- the yellow cap line spans the chart; its height up from the baseline is + -- CapFraction of the chart height (1 = top of chart) + Layouter(self.CapLine) + :AtLeftIn(self, ContentPad + AxisWidth):AtRightIn(self, ContentPad):Height(1) + :Top(function() + return self.Top() + + LayoutHelpers.ScaleNumber(chartTop + ChartHeight) + - self.CapFraction() * LayoutHelpers.ScaleNumber(ChartHeight) + end) + :End() + end, + + --- Fills the chart from a peer's performance metrics (the whole + --- `PerformanceTrackingV2` table). Picks the most-played category. + ---@param self UICustomLobbyPerformancePopover + ---@param metrics UIPerformanceMetrics | nil + ---@param unitCap? number # recommended total-unit ceiling (the yellow line), if known + SetData = function(self, metrics, unitCap) + local category, categoryKey, bestSamples = nil, nil, -1 + if metrics then + for _, key in Categories do + local c = metrics[key] + if c and (c.Samples or 0) > bestSamples then + bestSamples = c.Samples or 0 + category = c + categoryKey = key + end + end + end + + if not category or bestSamples <= 0 then + self.Subtitle:SetText("no data shared yet") + for k = 1, Buckets do + self.Bars[k]:SetData(0, 0, 0, 0) + end + for k = 1, 3 do + self.AxisLabels[k]:SetText("") + end + self.CapLine:SetAlpha(0.0) + return + end + + -- busiest unit count actually observed across the category + local observedMax = 0 + for k = 1, Buckets do + local entry = category[k] + if entry and entry.UnitCount and entry.UnitCount.Max > observedMax then + observedMax = entry.UnitCount.Max + end + end + + -- the chart scales to the data, but never below the recommended cap, so the + -- yellow line is always on-chart while the bars are free to rise above it. + local cap = (unitCap and unitCap > 0) and unitCap or nil + local chartMax = math.max(observedMax, cap or 0, 1) + + local subtitle = string.format("%s — %d game(s)", CategoryLabels[categoryKey] or categoryKey, bestSamples) + if cap then + subtitle = subtitle .. string.format(" · rec. cap %s", FormatUnits(cap)) + end + self.Subtitle:SetText(subtitle) + + for k = 1, Buckets do + local entry = category[k] + if entry and entry.UnitCount then + self.Bars[k]:SetData(entry.Samples or 0, entry.UnitCount.Min or 0, entry.UnitCount.Max or 0, chartMax) + else + self.Bars[k]:SetData(0, 0, 0, chartMax) + end + end + + -- label the reference lines (top = busiest / cap, middle = half, bottom = 0) + self.AxisLabels[1]:SetText(FormatUnits(chartMax)) + self.AxisLabels[2]:SetText(FormatUnits(chartMax * 0.5)) + self.AxisLabels[3]:SetText("0") + + -- place (or hide) the yellow recommended-cap line + if cap then + self.CapFraction:Set(cap / chartMax) + self.CapLine:SetAlpha(CapAlpha) + else + self.CapLine:SetAlpha(0.0) + end + end, +} + +------------------------------------------------------------------------------- +-- Singleton + show / hide + +local ModuleTrash = TrashBag() + +---@type UICustomLobbyPerformancePopover | false +local Instance = false + +---@return UICustomLobbyPerformancePopover +local function GetInstance() + if not Instance then + Instance = CustomLobbyPerformancePopover(GetFrame(0)) + ModuleTrash:Add(Instance) + end + return Instance +end + +--- Shows the popover next to `anchor`, filled with `metrics`. +---@param anchor Control +---@param metrics UIPerformanceMetrics | nil +---@param unitCap? number # recommended total-unit ceiling (the yellow line), if known +function Show(anchor, metrics, unitCap) + local popover = GetInstance() + popover:SetData(metrics, unitCap) + + local frame = GetFrame(0) + + -- float beside the hovered control, vertically centred on it. Prefer opening to its RIGHT, but + -- flip to the LEFT when the popover's own width would run it off the right screen edge (the old + -- screen-centre test ignored the popover width, so it clipped — worse at higher ui_scale). + local builder = Layouter(popover):AtVerticalCenterIn(anchor) + local pad = LayoutHelpers.ScaleNumber(12) + if anchor.Right() + pad + popover.Width() > frame.Width() then + builder:AnchorToLeft(anchor, 12) + else + builder:AnchorToRight(anchor, 12) + end + builder:End() + + -- overlay above everything else (lobby chrome, dialogs, …); child depths are bound to ours, + -- so lifting the popover lifts the whole chart with it + popover.Depth:Set(frame:GetTopmostDepth() + 1) + + popover:Show() +end + +function Hide() + if Instance then + Instance:Hide() + end +end + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + ModuleTrash:Destroy() + Instance = false +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbyPresets.lua b/lua/ui/lobby/customlobby/CustomLobbyPresets.lua new file mode 100644 index 00000000000..c3913f72fda --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyPresets.lua @@ -0,0 +1,129 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- Named setup presets for the custom lobby — the customlobby-native rebuild of the legacy +-- `/lua/ui/lobby/presets.lua` (which keyed `LobbyPresets`). Pure persistence: this module only +-- reads and writes the prefs; it never touches the lobby models or the network. Capturing the +-- current setup into a snapshot and applying a snapshot back are host-authoritative and live in +-- the controller (`CustomLobbyController.BuildSetupSnapshot` / `ApplySetup`). +-- +-- A preset is `{ Name = string, Setup = UICustomLobbySetupSnapshot }`, stored as an ordered array +-- under one prefs key so the user's creation order is preserved — mirroring the mod presets in +-- `/lua/ui/modutilities.lua`. A reserved `LastGamePresetName` entry is auto-saved at launch so a +-- rehost can restore the last game (see USER_STORIES.md § O). + +local Prefs = import("/lua/user/prefs.lua") + +--- The prefs key holding the ordered array of setup presets (the host's machine only). +local PrefsKey = "customlobby_setup_presets" + +--- The reserved preset name auto-saved at launch (the rehost source). Shown to the user as a +--- pinned "Last game" entry rather than a normal named preset. +LastGamePresetName = "lastGame" + +--- A serializable snapshot of the launch setup (written to disk). **Setup-only** — players, +--- observers and auto-teams / spawn-mex are deliberately not stored (a preset reconfigures a lobby, +--- it doesn't restore a roster). +---@class UICustomLobbySetupSnapshot +---@field ScenarioFile FileName | false +---@field GameOptions table # the host's option values, minus per-player Ratings/ClanTags +---@field GameMods table # sim-mod uid set +---@field Restrictions string[] # unit-restriction preset keys + +--- All saved presets, in creation order (the `lastGame` entry included). +---@return { Name: string, Setup: UICustomLobbySetupSnapshot }[] +function GetPresets() + return Prefs.GetFromCurrentProfile(PrefsKey) or {} +end + +--- Finds a preset's setup snapshot by name, or nil. +---@param name string +---@return UICustomLobbySetupSnapshot | nil +function GetPreset(name) + for _, preset in GetPresets() do + if preset.Name == name then + return preset.Setup + end + end + return nil +end + +--- Saves `setup` under `name`, overwriting an existing preset with the same name. The caller owns +--- the snapshot (it is stored by reference — pass a fresh `BuildSetupSnapshot()` result). +---@param name string +---@param setup UICustomLobbySetupSnapshot +function SavePreset(name, setup) + local presets = GetPresets() + for _, preset in presets do + if preset.Name == name then + preset.Setup = setup + Prefs.SetToCurrentProfile(PrefsKey, presets) + SavePreferences() + return + end + end + table.insert(presets, { Name = name, Setup = setup }) + Prefs.SetToCurrentProfile(PrefsKey, presets) + SavePreferences() +end + +--- Removes the preset with the given name (no-op if absent). +---@param name string +function DeletePreset(name) + local presets = GetPresets() + local kept = {} + for _, preset in presets do + if preset.Name ~= name then + table.insert(kept, preset) + end + end + Prefs.SetToCurrentProfile(PrefsKey, kept) + SavePreferences() +end + +--- Renames a preset, preserving its position. Rejects a blank name or a collision with another +--- existing preset (returns false); returns true on success. +---@param oldName string +---@param newName string +---@return boolean +function RenamePreset(oldName, newName) + if not newName or newName == "" or newName == oldName then + return false + end + local presets = GetPresets() + local target + for _, preset in presets do + if preset.Name == newName then + return false -- collision with a different preset + end + if preset.Name == oldName then + target = preset + end + end + if not target then + return false + end + target.Name = newName + Prefs.SetToCurrentProfile(PrefsKey, presets) + SavePreferences() + return true +end diff --git a/lua/ui/lobby/customlobby/CustomLobbyRules.lua b/lua/ui/lobby/customlobby/CustomLobbyRules.lua new file mode 100644 index 00000000000..51ac444bfb1 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyRules.lua @@ -0,0 +1,141 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- Game-rule derivations — the pure kernel behind the lobby's display hints and (eventually) the +-- host's launch-time team assignment. These are **pure functions**: every input is passed in (the map +-- size, the seated count, the AutoTeams mode, the resolved scenario), so this module reads no models +-- and holds no state. Callers own reading the state — the slots derived model applies these reactively +-- to publish `Side` / `Teams`, and the controller can apply the same kernel at launch with its own +-- inputs (without depending on a view-facing derived model). The model stays the source of truth. + +--- Units-per-player tier from the map's largest dimension (51.2 ogrids = 1 km), i.e. +--- 250 / 375 / 500 for 5x5 / 10x10 / 20x20-or-larger. Unknown size (0) → the top tier. +---@param maxDimension number +---@return number +local function UnitsPerPlayer(maxDimension) + if maxDimension > 0 and maxDimension <= 256 then + return 250 -- 5x5 + elseif maxDimension > 0 and maxDimension <= 512 then + return 375 -- 10x10 + end + return 500 -- 20x20 or larger / not yet known +end + +--- The recommended total-unit ceiling for `seatedCount` players on a map of `maxDimension` ogrids +--- (0 = unknown → top tier). Returns nil when there are no players to scale by. +---@param seatedCount number +---@param maxDimension number +---@return number | nil +function RecommendedUnitCap(seatedCount, maxDimension) + if not seatedCount or seatedCount < 1 then + return nil + end + return UnitsPerPlayer(maxDimension or 0) * seatedCount +end + +------------------------------------------------------------------------------- +--#region Auto-team sides + +-- The AutoTeams modes that resolve to exactly two sides; everything else (none / manual) has no +-- binary split. The labels mirror how the modes actually resolve at launch. +local BinaryModeLabels = { + tvsb = { "Top", "Bottom" }, -- by start-position Y + lvsr = { "Left", "Right" }, -- by start-position X + pvsi = { "Odd", "Even" }, -- by start-spot parity (needs no map) +} + +--- The AutoTeams mode in `gameOptions` when it forms two sides (tvsb / lvsr / pvsi), else nil. +---@param gameOptions table | nil +---@return string | nil +function AutoTeamMode(gameOptions) + local mode = (gameOptions or {}).AutoTeams + return BinaryModeLabels[mode] and mode or nil +end + +--- The two side labels for a binary mode (e.g. `{ "Left", "Right" }`), or nil for a non-binary mode. +---@param mode string | nil +---@return string[] | nil +function SideLabels(mode) + return mode and BinaryModeLabels[mode] or nil +end + +--- Builds a side resolver for a binary AutoTeams `mode` over a resolved `scenario`: a function +--- `startSpot -> 1|2|nil` (nil = "unresolved", i.e. a positional mode whose map/start positions +--- aren't loaded yet). Returns `resolver, resolved`: +--- * `resolver` is nil only when there is no binary mode (`mode` is nil); +--- * `resolved` is false when the mode is positional but positions are unavailable — callers that +--- need a definite split (e.g. the team score) hide in that case, while the two-column slot +--- layout still renders both columns and just withholds the side labels until it flips true. +--- The caller passes the resolved scenario bundle (e.g. from the scenario derived model, or the one +--- the controller is launching with), so this does no reading itself. +---@param mode string | nil # a binary mode (tvsb/lvsr/pvsi), or nil +---@param scenario UICustomLobbyScenario | false # the resolved scenario (for the positional modes) +---@return (fun(startSpot: number): number | nil) | nil resolver +---@return boolean resolved +function BuildSideResolver(mode, scenario) + if not mode then + return nil, false + end + + if mode == 'pvsi' then + return function(spot) + if not spot then return nil end + return (math.mod(spot, 2) == 1) and 1 or 2 + end, true + end + + -- positional: resolve each spot against the map centre, from the passed scenario's start spots + if not (scenario and scenario.Size) then + return function(spot) return nil end, false -- mode set, positions unknown → unresolved + end + + local positions = scenario.Markers.Spawns + if not positions or table.empty(positions) then + return function(spot) return nil end, false + end + local centreX, centreZ = scenario.Size[1] / 2, scenario.Size[2] / 2 + return function(spot) + local pos = spot and positions[spot] + if not pos then return nil end + if mode == 'tvsb' then + return (pos[2] < centreZ) and 1 or 2 + else -- lvsr + return (pos[1] < centreX) and 1 or 2 + end + end, true +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/CustomLobbyScenarioPreview.lua b/lua/ui/lobby/customlobby/CustomLobbyScenarioPreview.lua new file mode 100644 index 00000000000..16f1746b0ec --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyScenarioPreview.lua @@ -0,0 +1,423 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- A reusable map-preview *surface*: the scenario's map texture plus its overlays — start +-- spots, resource deposits (mass / hydrocarbon) and prebuilt wrecks — placed at the right +-- spots with aspect-correct maths. It is deliberately chrome-free: no title, border or glow. +-- Owners wrap it and add their own decoration — `CustomLobbyMapPreview` adds the glow + backdrop +-- frame (and the map-select dialog layers a title bar on top of that). +-- +-- It exists so the two preview consumers share ONE implementation of the fiddly bits — the +-- texture-leak-safe icon sharing, the aspect-correct positioning, the three-phase init — rather +-- than each maintaining a copy that drifts (and re-learns the same engine gotchas). +-- +-- Memory: the resource/wreck icons load their texture ONCE into hidden template bitmaps and +-- every marker shares it via `ShareTextures` (the engine never frees per-bitmap textures — see +-- mapselect/CLAUDE.md). The map texture itself is one `MapPreview`; don't instantiate this +-- control per list row. +-- +-- Spawn appearance is the owner's choice via the `CreateSpawnIcon` option: a bound +-- `CustomLobbyMapPreview` passes faction icons, the picker passes numbered dots (the +-- default). A spawn icon may implement `:Update(data)` / `:Reset()`; the surface calls `Update` +-- with `SetSpawnData()[index]` when present, else `Reset` — so faction icons hide on empty +-- spots while numbered dots (no Update/Reset) stay shown. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local MapPreview = import("/lua/ui/controls/mappreview.lua").MapPreview + +local Layouter = LayoutHelpers.ReusedLayoutFor + +local MassIcon = "/game/build-ui/icon-mass_bmp.dds" +local EnergyIcon = "/game/build-ui/icon-energy_bmp.dds" +local WreckIcon = "/scx_menu/lan-game-lobby/mappreview/wreckage.dds" + +local ResourceIconSize = 10 +local WreckIconSize = 11 +local SpawnDotSize = 18 + +---@class UICustomLobbyScenarioPreviewOptions +---@field CreateSpawnIcon? fun(surface: Control, index: number): Control # defaults to a numbered dot + +---@class UICustomLobbyScenarioPreview : Group +---@field Trash TrashBag +---@field MarkerTrash TrashBag # resource + wreck icons (rebuilt on SetScenario) +---@field SpawnTrash TrashBag # spawn icons (rebuilt on SetScenario / SetSpawnData) +---@field Preview MapPreview +---@field MassTemplate Bitmap # hidden; resource markers share its texture +---@field EnergyTemplate Bitmap +---@field WreckTemplate Bitmap +---@field WaterMask Bitmap # DUMMY placeholder water tint (no real mask yet) +---@field SpawnIcons table +---@field ResourceIcons Control[] +---@field WreckIcons Control[] +---@field ShowSpawns boolean +---@field ShowResources boolean +---@field ShowWrecks boolean +---@field ShowWater boolean +---@field ScenarioInfo? UILobbyScenarioInfo +---@field Markers? UICustomLobbyScenarioMarkers # extracted save bits (spawns + mass/hydro/wreck points) +---@field SpawnData table # per-start-spot data handed to spawn icons' :Update +---@field CreateSpawnIcon fun(surface: Control, index: number): Control +---@field Ready boolean # true once laid out by the parent; gates geometry reads +local CustomLobbyScenarioPreview = ClassUI(Group) { + + ---@param self UICustomLobbyScenarioPreview + ---@param parent Control + ---@param options? UICustomLobbyScenarioPreviewOptions + __init = function(self, parent, options) + Group.__init(self, parent, "CustomLobbyScenarioPreview") + + options = options or {} + + self.Trash = TrashBag() + self.MarkerTrash = self.Trash:Add(TrashBag()) + self.SpawnTrash = self.Trash:Add(TrashBag()) + + self.SpawnIcons = {} + self.ResourceIcons = {} + self.WreckIcons = {} + + self.ShowSpawns = true + self.ShowResources = true + self.ShowWrecks = true + self.ShowWater = false + + self.ScenarioInfo = nil + self.Markers = nil + self.SpawnData = {} + self.Ready = false + + self.CreateSpawnIcon = options.CreateSpawnIcon or function(surface, index) + return self:CreateNumberedDot(index) + end + + self.Preview = MapPreview(self) + + -- DUMMY water overlay: a translucent blue tint over the whole map until a real water mask + -- exists. Sits above the map texture but below the resource/wreck/spawn markers. Hidden by + -- default; the 'water' overlay toggle reveals it. + self.WaterMask = Bitmap(self) + self.WaterMask:SetSolidColor('66123a66') + self.WaterMask:DisableHitTest() + self.WaterMask:Hide() + + self.MassTemplate = self:CreateTemplateBitmap(MassIcon) + self.EnergyTemplate = self:CreateTemplateBitmap(EnergyIcon) + self.WreckTemplate = self:CreateTemplateBitmap(WreckIcon) + end, + + ---@param self UICustomLobbyScenarioPreview + __post_init = function(self) + Layouter(self.Preview):Fill(self):End() + Layouter(self.WaterMask):Fill(self.Preview):End() + self.WaterMask.Depth:Set(function() return self.Preview.Depth() + 1 end) + + -- our PARENT sizes us after this returns, so self.Preview.Width() isn't concrete yet; + -- defer the first render a frame, then let SetScenario/SetSpawnData drive updates + self.Trash:Add(ForkThread( + function() + WaitFrames(1) + if IsDestroyed(self) then + return + end + self.Ready = true + self:Render() + end + )) + end, + + ---@param self UICustomLobbyScenarioPreview + OnDestroy = function(self) + self.Trash:Destroy() + end, + + --------------------------------------------------------------------------- + --#region Public API + + --- Renders a scenario: map texture + resource/wreck/spawn overlays. Pass the already-loaded info + --- and the extracted markers (the owner gets them from the catalog / scenario model — the surface + --- never touches the raw save). A nil info clears the surface. + ---@param self UICustomLobbyScenarioPreview + ---@param scenarioInfo? UILobbyScenarioInfo + ---@param markers? UICustomLobbyScenarioMarkers + SetScenario = function(self, scenarioInfo, markers) + self.ScenarioInfo = scenarioInfo + self.Markers = markers + if self.Ready then + self:Render() + end + end, + + --- Updates the per-start-spot spawn data (e.g. seated players' factions) and refreshes just + --- the spawn icons against the already-loaded scenario — no map reload. + ---@param self UICustomLobbyScenarioPreview + ---@param spawnData table + SetSpawnData = function(self, spawnData) + self.SpawnData = spawnData or {} + if self.Ready and self.ScenarioInfo and self.Markers then + self:RenderSpawns() + end + end, + + --- Shows/hides an overlay group: 'spawns' | 'resources' | 'wrecks' | 'water'. + ---@param self UICustomLobbyScenarioPreview + ---@param kind string + ---@param visible boolean + SetOverlayVisible = function(self, kind, visible) + if kind == 'spawns' then + self.ShowSpawns = visible + elseif kind == 'resources' then + self.ShowResources = visible + elseif kind == 'wrecks' then + self.ShowWrecks = visible + elseif kind == 'water' then + self.ShowWater = visible + end + self:ApplyVisibility() + end, + + --- Clears the texture + all overlays (no scenario shown). + ---@param self UICustomLobbyScenarioPreview + Clear = function(self) + self.ScenarioInfo = nil + self.Markers = nil + self.MarkerTrash:Destroy() + self.SpawnTrash:Destroy() + self.SpawnIcons = {} + self.ResourceIcons = {} + self.WreckIcons = {} + self.Preview:ClearTexture() + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Rendering (private) + + ---@param self UICustomLobbyScenarioPreview + Render = function(self) + self.MarkerTrash:Destroy() + self.ResourceIcons = {} + self.WreckIcons = {} + + local info = self.ScenarioInfo + if not info then + self.SpawnTrash:Destroy() + self.SpawnIcons = {} + self.Preview:ClearTexture() + return + end + + if not self.Preview:SetTexture(info.preview) then + self.Preview:SetTextureFromMap(info.map) + end + + if self.Markers and info.size then + self:BuildResources() + self:BuildWrecks() + end + self:RenderSpawns() + self:ApplyVisibility() + end, + + --- Rebuilds only the spawn icons (resources/wrecks untouched). + ---@param self UICustomLobbyScenarioPreview + RenderSpawns = function(self) + self.SpawnTrash:Destroy() + self.SpawnIcons = {} + + local info = self.ScenarioInfo + if not (info and self.Markers and info.size) then + return + end + + local positions = self.Markers.Spawns + if not positions then + return + end + + for index, position in positions do + local icon = self.SpawnTrash:Add(self.CreateSpawnIcon(self, index)) + icon.Depth:Set(function() return self.Preview.Depth() + 10 end) + self:PlaceMarker(icon, info.size[1], info.size[2], position[1], position[2]) + + local data = self.SpawnData[index] + if data ~= nil and icon.Update then + icon:Update(data) + elseif icon.Reset then + icon:Reset() + end + + self.SpawnIcons[index] = icon + end + + if not self.ShowSpawns then + self:ApplyVisibility() + end + end, + + --- Places mass + hydrocarbon icons from the extracted resource points. + ---@param self UICustomLobbyScenarioPreview + BuildResources = function(self) + local info = self.ScenarioInfo + local function place(points, template) + for _, point in points do + local icon = self.MarkerTrash:Add(self:CreateMarkerIcon(template, ResourceIconSize)) + self:PlaceMarker(icon, info.size[1], info.size[2], point[1], point[2]) + table.insert(self.ResourceIcons, icon) + end + end + place(self.Markers.MassPoints, self.MassTemplate) + place(self.Markers.HydroPoints, self.EnergyTemplate) + end, + + --- Best-effort wreck icons: maps that expose prebuilt wreckage (extracted as wreck points). + ---@param self UICustomLobbyScenarioPreview + BuildWrecks = function(self) + local info = self.ScenarioInfo + for _, point in self.Markers.Wrecks do + local icon = self.MarkerTrash:Add(self:CreateMarkerIcon(self.WreckTemplate, WreckIconSize)) + self:PlaceMarker(icon, info.size[1], info.size[2], point[1], point[2]) + table.insert(self.WreckIcons, icon) + end + end, + + --- Shows/hides each overlay group per its flag. Spawn icons that override Show (e.g. the + --- faction icon hides itself with no faction) keep their own logic. + ---@param self UICustomLobbyScenarioPreview + ApplyVisibility = function(self) + local function setVisible(icons, visible) + for _, icon in icons do + if visible then + icon:Show() + else + icon:Hide() + end + end + end + setVisible(self.SpawnIcons, self.ShowSpawns) + setVisible(self.ResourceIcons, self.ShowResources) + setVisible(self.WreckIcons, self.ShowWrecks) + if self.ShowWater then + self.WaterMask:Show() + else + self.WaterMask:Hide() + end + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Icon factories (private) + + --- The default spawn icon: a small numbered dot. + ---@param self UICustomLobbyScenarioPreview + ---@param index number + ---@return Group + CreateNumberedDot = function(self, index) + local dot = Group(self) + dot:DisableHitTest() + + local bg = Bitmap(dot) + bg:SetSolidColor('cc1c2228') + bg:DisableHitTest() + + local label = UIUtil.CreateText(dot, tostring(index), 12, UIUtil.bodyFont) + label:DisableHitTest() + + Layouter(dot):Width(SpawnDotSize):Height(SpawnDotSize):End() + Layouter(bg):Fill(dot):End() + Layouter(label):AtCenterIn(dot):End() + return dot + end, + + --- Loads an overlay texture once into a hidden template bitmap that markers share from. + --- Given a dummy position (unanchored Left/Right are circular) and locked hidden so a parent + --- Show() can't reveal it. + ---@param self UICustomLobbyScenarioPreview + ---@param texture FileName + ---@return Bitmap + CreateTemplateBitmap = function(self, texture) + local template = UIUtil.CreateBitmap(self, texture) + template:DisableHitTest() + Layouter(template):Left(0):Top(0):Width(8):Height(8):End() + template:Hide() + template.OnHide = function(control, hidden) + return true + end + return template + end, + + --- A small resource/wreck marker sharing its texture with a template (loaded once). + ---@param self UICustomLobbyScenarioPreview + ---@param template Bitmap + ---@param size number + ---@return Bitmap + CreateMarkerIcon = function(self, template, size) + local icon = UIUtil.CreateBitmapColor(self, 'ffffff') + icon:DisableHitTest() + Layouter(icon):Width(size):Height(size):End() + icon:ShareTextures(template) + icon.Depth:Set(function() return self.Preview.Depth() + 5 end) + return icon + end, + + --- Positions an overlay control over the preview at a map coordinate (aspect-correct). + ---@param self UICustomLobbyScenarioPreview + ---@param icon Control + ---@param mapWidth number + ---@param mapHeight number + ---@param px number + ---@param pz number + PlaceMarker = function(self, icon, mapWidth, mapHeight, px, pz) + local size = self.Preview.Width() + local xOffset, xFactor, yOffset, yFactor = 0, 1, 0, 1 + if mapWidth > mapHeight then + local ratio = mapHeight / mapWidth + yOffset = ((size / ratio) - size) / 4 + yFactor = ratio + else + local ratio = mapWidth / mapHeight + xOffset = ((size / ratio) - size) / 4 + xFactor = ratio + end + + local x = xOffset + (px / mapWidth) * (size - 2) * xFactor + local z = yOffset + (pz / mapHeight) * (size - 2) * yFactor + + icon.Left:Set(function() return self.Preview.Left() + x - 0.5 * icon.Width() end) + icon.Top:Set(function() return self.Preview.Top() + z - 0.5 * icon.Height() end) + end, + + --#endregion +} + +---@param parent Control +---@param options? UICustomLobbyScenarioPreviewOptions +---@return UICustomLobbyScenarioPreview +Create = function(parent, options) + return CustomLobbyScenarioPreview(parent, options) +end diff --git a/lua/ui/lobby/customlobby/CustomLobbySession.lua b/lua/ui/lobby/customlobby/CustomLobbySession.lua new file mode 100644 index 00000000000..ca0a6f3103a --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbySession.lua @@ -0,0 +1,63 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The lobby session's "main trash bag". +-- +-- The custom lobby runs in the persistent *front-end* Lua state. That state is NOT reset when a +-- game launches in its own state, so anything we leave reachable after launch (or after leaving) — +-- a running thread, a cached table, a model singleton — leaks for the whole match. We need exactly +-- one call that frees *everything* lobby-scoped. +-- +-- This module owns a single session-lifetime `TrashBag`. Every lobby-scoped singleton that owns +-- resources (models, catalogs, the interface, the lobby instance) is a `Destroyable` added to it, +-- so `Teardown()` frees the lot at once and `GetTrash()` hands every owner the same bag. +-- +-- Why a TrashBag works here even though it is **weak-valued** (`__mode = 'v'`, see +-- /lua/system/trashbag.lua): a weak bag only holds things kept alive by a strong reference +-- elsewhere. That is precisely our singletons — each is pinned by its own module-level `Instance` +-- local, so the bag can still reach it at teardown but is never the reason it survives GC. A bare +-- `{ Destroy = fn }` disposable with no other owner would be collected before teardown; a real +-- singleton object will not. So: make resources real `ClassSimple` objects that implement +-- `Destroy`, pin them in their module local, and drop them in this bag. + +---@type TrashBag | false +local SessionTrash = false + +--- The session-lifetime trash bag. Lazily created; lives until `Teardown()`. Add any lobby-scoped +--- `Destroyable` (a model, a catalog, the interface, the lobby instance) to it. +---@return TrashBag +function GetTrash() + if not SessionTrash then + SessionTrash = TrashBag() + end + return SessionTrash +end + +--- Frees everything added to the session trash (models, catalogs, threads, UI, the lobby instance) +--- and starts a fresh bag for the next session. Idempotent — safe to call as a clean-slate guard at +--- the top of `CreateLobby`, and on both teardown paths (leave to menu, launch into the game). +function Teardown() + if SessionTrash then + SessionTrash:Destroy() + SessionTrash = false + end +end diff --git a/lua/ui/lobby/customlobby/CustomLobbyTabs.lua b/lua/ui/lobby/customlobby/CustomLobbyTabs.lua new file mode 100644 index 00000000000..d69d5e076fa --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyTabs.lua @@ -0,0 +1,457 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- A generic tabbed panel: a strip of tab buttons over a content area. The active tab's content +-- component is **created when its tab is selected and destroyed when you switch away** — only one +-- panel is alive at a time, so churn is cheap and there's no hidden-panel bleed (the same model the +-- config interface uses). +-- +-- Construct with a tab list `{ { Label = "Chat", Create = fn }, … }`, where `Create(parent)` builds +-- the content component (which may expose an `Initialize()` the container calls after sizing it). +-- An optional `OnSelect(index, label)` fires on every switch — used when a persistent sibling (e.g. +-- the map preview, which can't be churned) must be shown/hidden alongside a tab. +-- +-- The **flexible** tabs divide the strip evenly across the available width. A tab may instead be +-- `Compact = true`: a fixed narrow width, excluded from the division (the flexible tabs share what's +-- left) — for a small utility tab. A tab may show an `Icon` (a texture) centred *instead* of its +-- label (paired with Compact, a tidy icon-only tab with no distracting text). +-- +-- A flexible tab may carry an optional `Badge` LazyVar (a string): when non-empty it renders a small +-- grey count pill to the right of the label; and an optional `Action` (`{ Create, Visible? }`): a +-- small button the owner builds **inside the tab, left of the label** (e.g. a config gear that opens +-- that tab's editor). Its `Visible` LazyVar hides it (and collapses it out of the layout) when it +-- doesn't apply — e.g. for non-hosts. The action, label and pill are centred together as one +-- cluster, so nothing collides with the tab edge. The container only observes the LazyVars — the +-- owner decides what the count + action mean. +-- +-- The parent sizes this control and calls `Initialize()` after mounting (so the first panel reads a +-- concrete height — three-phase init, /lua/ui/CLAUDE.md § 1). + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +local TabHeight = 26 +local TabGap = 2 + +local TabIdleColor = 'ff141a20' +local TabHoverColor = 'ff1f262e' +local TabActiveColor = 'ff2c3e48' + +-- the count badge: a grey pill (rounded-look solid) with a number, sitting to the right of a label +local BadgeHeight = 13 +local BadgeMinWidth = 14 -- keeps a single digit roughly square +local BadgePadH = 4 -- horizontal text padding inside the pill +local BadgeGap = 5 -- between cluster items (action / label / pill) +local BadgeColor = 'ff454c56' +local BadgeTextColor = 'ffd0d4d8' + +-- the per-tab action button (e.g. a config gear), square, sitting left of the label +local ActionSize = 18 + +-- a compact (utility) tab: fixed narrow width, and the size of its centred icon +local CompactWidth = 28 +local TabIconSize = 16 + +---@class UICustomLobbyTabAction +---@field Create fun(parent: Control): Control # builds the action control (the owner wires its click) +---@field Visible? LazyVar # boolean; false hides the action + collapses it from the cluster (default: always shown) + +---@class UICustomLobbyTab +---@field Label string +---@field Create fun(parent: Control): Control +---@field Badge? LazyVar # optional count-badge text (a string; "" hides the pill) +---@field Action? UICustomLobbyTabAction # optional button inside the tab, left of the label +---@field Icon? FileName # optional centred icon (a UIFile path) shown instead of the label (tidy with Compact) +---@field Compact? boolean # fixed narrow width, excluded from the even division (a utility tab) +---@field Weight? number # flexible tabs share width in proportion to this (default 1); ignored for Compact + +---@class UICustomLobbyTabsOptions +---@field Tabs UICustomLobbyTab[] +---@field OnSelect? fun(index: number, label: string) + +---@class UICustomLobbyTabBadge : Group +---@field Bg Bitmap +---@field Text Text + +---@class UICustomLobbyTabButton : Group +---@field Bg Bitmap +---@field Label? Text # present only for a text tab (absent when the tab uses an Icon) +---@field Icon? Bitmap # present only when the tab defines an Icon +---@field Badge? UICustomLobbyTabBadge # present only when the tab defines a Badge LazyVar +---@field BadgeObserver? LazyVar # strong ref to the badge observer (kept alive; see /lua/ui/CLAUDE.md § 3) +---@field Action? Control # present only when the tab defines an Action +---@field ActionObserver? LazyVar # strong ref to the action-visibility observer (kept alive) + +---@class UICustomLobbyTabs : Group +---@field Trash TrashBag +---@field Tabs UICustomLobbyTab[] +---@field OnSelectCb? fun(index: number, label: string) +---@field TabStripArea Group +---@field TabContentArea Group +---@field TabButtons UICustomLobbyTabButton[] +---@field ActiveTab number +---@field CurrentPanel Control | false +local CustomLobbyTabs = ClassUI(Group) { + + ---@param self UICustomLobbyTabs + ---@param parent Control + ---@param options UICustomLobbyTabsOptions + __init = function(self, parent, options) + Group.__init(self, parent, "CustomLobbyTabs") + + self.Trash = TrashBag() + self.Tabs = options.Tabs + self.OnSelectCb = options.OnSelect + -- open on the first non-compact tab — a compact utility tab (e.g. Logs) shouldn't be the + -- default view just because it sits leftmost + self.ActiveTab = 1 + for index = 1, table.getn(self.Tabs) do + if not self.Tabs[index].Compact then + self.ActiveTab = index + break + end + end + self.CurrentPanel = false + + self.TabStripArea = Group(self, "CustomLobbyTabsStrip") + self.TabContentArea = Group(self, "CustomLobbyTabsContent") + + self.TabButtons = {} + for index = 1, table.getn(self.Tabs) do + self.TabButtons[index] = self:CreateTabButton(self.Tabs[index].Label, index) + end + end, + + ---@param self UICustomLobbyTabs + __post_init = function(self) + Layouter(self.TabStripArea):AtLeftIn(self):AtRightIn(self):AtTopIn(self):Height(TabHeight):End() + Layouter(self.TabContentArea) + :AtLeftIn(self):AtRightIn(self) + :AnchorToBottom(self.TabStripArea, 6):AtBottomIn(self) + :End() + + -- compact tabs take a fixed narrow width; the flexible tabs share what's left of the strip + -- in proportion to their `Weight` (default 1) — so a tab can claim more room than its peers. + -- Width/Left are bound to the strip so they re-flow with the column. + local count = table.getn(self.TabButtons) + local flexWeight = 0 + local compactCount = 0 + for index = 1, count do + if self.Tabs[index].Compact then + compactCount = compactCount + 1 + else + flexWeight = flexWeight + (self.Tabs[index].Weight or 1) + end + end + + -- the width of one unit of weight: the strip minus all gaps and all compact tabs, split by + -- total flexible weight + local function flexUnit() + local gap = LayoutHelpers.ScaleNumber(TabGap) + local compactW = LayoutHelpers.ScaleNumber(CompactWidth) + local remaining = self.TabStripArea.Width() - gap * (count - 1) - compactW * compactCount + if flexWeight <= 0 then + return 0 + end + return remaining / flexWeight + end + local function widthOf(index) + if self.Tabs[index].Compact then + return LayoutHelpers.ScaleNumber(CompactWidth) + end + return flexUnit() * (self.Tabs[index].Weight or 1) + end + + for index = 1, count do + local button = self.TabButtons[index] + local slot = index + Layouter(button):AtTopIn(self.TabStripArea):Height(TabHeight):End() + button.Width:Set(function() return widthOf(slot) end) + button.Left:Set(function() + local gap = LayoutHelpers.ScaleNumber(TabGap) + local x = self.TabStripArea.Left() + for j = 1, slot - 1 do + x = x + widthOf(j) + gap + end + return x + end) + Layouter(button.Bg):Fill(button):End() + self:LayoutButtonContent(button) + button.Bg:SetSolidColor(index == self.ActiveTab and TabActiveColor or TabIdleColor) + end + end, + + --- Lays out a button's content — an optional action button, the label, and an optional count + --- pill — centred together as one `[action] [label] [pill]` cluster, so nothing collides with + --- the tab edge for a long label. Each side piece collapses (contributes 0 width) when absent + --- or hidden, re-centring the rest. Private. + ---@param self UICustomLobbyTabs + ---@param button UICustomLobbyTabButton + LayoutButtonContent = function(self, button) + -- an icon tab is just a centred glyph (no label/action/pill cluster) + if button.Icon then + Layouter(button.Icon):AtCenterIn(button):Width(TabIconSize):Height(TabIconSize):End() + return + end + + local action = button.Action + local badge = button.Badge + + -- vertical placement for every piece; the action's Width is owned by its visibility wiring + -- (CreateTabButton), the pill's by its text. The action is hit-enabled and overlaps the + -- tab's solid Bg, so lift it a depth above Bg — otherwise (equal default depth) the click + -- could land on the tab background instead of the gear. + Layouter(button.Label):AtVerticalCenterIn(button):End() + if action then + Layouter(action):AtVerticalCenterIn(button):Height(ActionSize):Over(button.Bg, 1):End() + end + if badge then + Layouter(badge):AtVerticalCenterIn(button):Height(BadgeHeight):End() + badge.Width:Set(function() + local textWidth = badge.Text.Width() + if textWidth <= 0 then + return 0 + end + return math.max(LayoutHelpers.ScaleNumber(BadgeMinWidth), textWidth + LayoutHelpers.ScaleNumber(BadgePadH) * 2) + end) + Layouter(badge.Bg):Fill(badge):End() + Layouter(badge.Text):AtCenterIn(badge):End() + end + + -- cluster maths: each item contributes its width plus a leading gap only when it has width + local function gapFor(width) + return width > 0 and LayoutHelpers.ScaleNumber(BadgeGap) or 0 + end + local function actionWidth() + return action and action.Width() or 0 + end + local function badgeWidth() + return badge and badge.Width() or 0 + end + local function clusterLeft() + local cluster = actionWidth() + gapFor(actionWidth()) + + button.Label.Width() + + gapFor(badgeWidth()) + badgeWidth() + return button.Left() + (button.Width() - cluster) / 2 + end + + if action then + action.Left:Set(function() return clusterLeft() end) + end + button.Label.Left:Set(function() + return clusterLeft() + actionWidth() + gapFor(actionWidth()) + end) + if badge then + badge.Left:Set(function() return button.Label.Right() + gapFor(badgeWidth()) end) + end + end, + + --- Opens the initial tab. Called by the parent after it has sized this control (the content + --- needs a concrete height — three-phase init). + ---@param self UICustomLobbyTabs + Initialize = function(self) + self:SelectTab(self.ActiveTab) + end, + + --- Switches tabs: destroys the current content, builds the chosen one into the content area, + --- and recolours the buttons. Clicking the active tab again is a no-op. + ---@param self UICustomLobbyTabs + ---@param index number + SelectTab = function(self, index) + if self.ActiveTab == index and self.CurrentPanel then + return + end + self.ActiveTab = index + + for i = 1, table.getn(self.TabButtons) do + self.TabButtons[i].Bg:SetSolidColor(i == index and TabActiveColor or TabIdleColor) + end + + if self.CurrentPanel then + self.CurrentPanel:Destroy() + self.CurrentPanel = false + end + + local panel = self.Tabs[index].Create(self.TabContentArea) + Layouter(panel):Fill(self.TabContentArea):End() + if panel.Initialize then + panel:Initialize() + end + self.CurrentPanel = panel + + if self.OnSelectCb then + self.OnSelectCb(index, self.Tabs[index].Label) + end + end, + + --- Builds one clickable tab button (a tinted group + label, plus a count pill when the tab + --- defines a `Badge` LazyVar). Private. + ---@param self UICustomLobbyTabs + ---@param label string + ---@param index number + ---@return UICustomLobbyTabButton + CreateTabButton = function(self, label, index) + local tab = self.Tabs[index] + local button = Group(self.TabStripArea, "CustomLobbyTabButton") + + button.Bg = Bitmap(button) + button.Bg:SetSolidColor(TabIdleColor) + + if tab.Icon then + -- an icon tab: a centred glyph instead of the text label (and no badge/action cluster) + button.Icon = UIUtil.CreateBitmap(button, tab.Icon) + button.Icon:DisableHitTest() + button.Bg.HandleEvent = self:MakeTabEvent(button, index) + return button + end + + button.Label = UIUtil.CreateText(button, label, 13, UIUtil.titleFont) + button.Label:SetColor('ffc8ccd0') + button.Label:DisableHitTest() + + -- optional count pill: created only when the tab supplies a Badge LazyVar; the container + -- just mirrors that LazyVar's string (the owner decides what the count means) + local badgeLazy = self.Tabs[index].Badge + if badgeLazy then + local badge = Group(button, "CustomLobbyTabBadge") --[[@as UICustomLobbyTabBadge]] + badge:DisableHitTest() + badge.Bg = Bitmap(badge) + badge.Bg:SetSolidColor(BadgeColor) + badge.Bg:DisableHitTest() + badge.Text = UIUtil.CreateText(badge, "", 11, UIUtil.bodyFont) + badge.Text:SetColor(BadgeTextColor) + badge.Text:DisableHitTest() + button.Badge = badge + + -- the badge Derive fires synchronously on creation (before TabButtons[index] is set), + -- so operate on the captured `badge`, not a lookup by index. Stored on the button (a + -- strong ref) so the observer isn't garbage-collected — Trash:Add alone is weak, see + -- /lua/ui/CLAUDE.md § 3. + button.BadgeObserver = self.Trash:Add(LazyVarDerive(badgeLazy, function(badgeTextLazy) + self:SetBadge(badge, badgeTextLazy() or "") + end)) + end + + -- optional per-tab action (e.g. a config gear): the owner builds it (and wires its click), + -- the container places it and drives its visibility from the optional Visible LazyVar. The + -- action's width is owned here — full when shown, 0 when hidden — so the cluster re-centres. + local actionDef = self.Tabs[index].Action + if actionDef then + local action = actionDef.Create(button) + button.Action = action + if actionDef.Visible then + -- fires synchronously on creation (before TabButtons[index] is set) — operate on + -- the captured `action`, not a lookup by index. Stored on the button (a strong ref) + -- so the observer isn't garbage-collected — Trash:Add alone is weak, see + -- /lua/ui/CLAUDE.md § 3. + button.ActionObserver = self.Trash:Add(LazyVarDerive(actionDef.Visible, function(visibleLazy) + self:SetActionVisible(action, visibleLazy() and true or false) + end)) + else + action.Width:Set(LayoutHelpers.ScaleNumber(ActionSize)) + end + end + + button.Bg.HandleEvent = self:MakeTabEvent(button, index) + + return button + end, + + --- The tab background's event handler: click selects the tab, hover tints it (unless active). + --- Shared by text and icon tabs. Private. + ---@param self UICustomLobbyTabs + ---@param button UICustomLobbyTabButton + ---@param index number + ---@return fun(control: Control, event: KeyEvent): boolean + MakeTabEvent = function(self, button, index) + return function(control, event) + if event.Type == 'ButtonPress' then + self:SelectTab(index) + return true + elseif event.Type == 'MouseEnter' then + if self.ActiveTab ~= index then + button.Bg:SetSolidColor(TabHoverColor) + end + return true + elseif event.Type == 'MouseExit' then + if self.ActiveTab ~= index then + button.Bg:SetSolidColor(TabIdleColor) + end + return true + end + return false + end + end, + + --- Updates a count pill from its Badge LazyVar by setting its text. An empty string collapses + --- the pill to width 0 (its `Width` binding returns 0 when the text has no width), so the cluster + --- re-centres on the bare label and the grey `Bg` paints nothing. + --- + --- Drives *only* the text, never `Hide()`/`Show()`: the badge is often empty at construction + --- (the model populates after) and a control hidden before it is laid out doesn't reliably come + --- back on `Show()` — the hide-before-layout gotcha (see SetActionVisible / /lua/ui/CLAUDE.md). + --- Private. + ---@param self UICustomLobbyTabs + ---@param badge UICustomLobbyTabBadge + ---@param text string + SetBadge = function(self, badge, text) + badge.Text:SetText(text) + end, + + --- Shows or hides an action button by collapsing its width to 0 (the label/pill cluster then + --- re-centres as if the action weren't there) — used to drop a host-only action for clients. + --- + --- Deliberately drives *only* the width, never `Hide()`/`Show()`: this derive fires once at + --- construction (in a real lobby `IsHost` is still false then, before `__post_init` lays the + --- button out) and again when `IsHost` flips true. A `Button` hidden before it is laid out does + --- not reliably come back on `Show()` (the Hide/Show-before-layout gotcha — see /lua/ui/CLAUDE.md), + --- so the gear stayed invisible for the host. A 0-width button renders nothing and can't be + --- clicked, so width alone is a complete collapse. Private. + ---@param self UICustomLobbyTabs + ---@param action Control + ---@param shown boolean + SetActionVisible = function(self, action, shown) + action.Width:Set(shown and LayoutHelpers.ScaleNumber(ActionSize) or 0) + end, + + ---@param self UICustomLobbyTabs + OnDestroy = function(self) + if self.CurrentPanel then + self.CurrentPanel:Destroy() + self.CurrentPanel = false + end + self.Trash:Destroy() + end, +} + +---@param parent Control +---@param options UICustomLobbyTabsOptions +---@return UICustomLobbyTabs +Create = function(parent, options) + return CustomLobbyTabs(parent, options) +end diff --git a/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua b/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua new file mode 100644 index 00000000000..cc3b0d5cdc3 --- /dev/null +++ b/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua @@ -0,0 +1,134 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The accumulated team rating shown at the top of the lobby — `Side A 3150 · 3025 Side B`. +-- +-- It is shown ONLY for the binary auto-team formations, where the two sides are well-defined: +-- +-- tvsb → Top / Bottom (by start-position Y) +-- lvsr → Left / Right (by start-position X) +-- pvsi → Odd / Even (by start-spot parity — needs no map) +-- +-- For `none` / `manual` there's no reliable 2-side split, so the whole widget hides. The split +-- mirrors how auto-teams actually resolve at launch (start position), so the score reads true to +-- the map; the positional modes hide until a map (with start spots) is selected. +-- +-- It reads the resolved split from the slots derived model's **team aggregate** (`GetTeams`): mode, +-- side labels, whether the split is resolved, and the per-side rating totals — all computed once +-- there. So this widget is a single subscription with no logic of its own; it never writes the model. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyslotsderivedmodel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +---@class UICustomLobbyTeamScore : Group +---@field Trash TrashBag +---@field LabelA Text +---@field ScoreA Text +---@field Sep Text +---@field ScoreB Text +---@field LabelB Text +---@field Ready boolean +---@field TeamsObserver LazyVar +local CustomLobbyTeamScore = ClassUI(Group) { + + ---@param self UICustomLobbyTeamScore + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyTeamScore") + + self.Trash = TrashBag() + self.Ready = false + + self.LabelA = UIUtil.CreateText(self, "", 14, UIUtil.titleFont) + self.LabelA:SetColor('ff9aa0a8') + self.LabelA:DisableHitTest() + self.ScoreA = UIUtil.CreateText(self, "", 16, UIUtil.titleFont) + self.ScoreA:DisableHitTest() + self.Sep = UIUtil.CreateText(self, "·", 16, UIUtil.titleFont) + self.Sep:SetColor('ff5a606a') + self.Sep:DisableHitTest() + self.ScoreB = UIUtil.CreateText(self, "", 16, UIUtil.titleFont) + self.ScoreB:DisableHitTest() + self.LabelB = UIUtil.CreateText(self, "", 14, UIUtil.titleFont) + self.LabelB:SetColor('ff9aa0a8') + self.LabelB:DisableHitTest() + + -- one subscription: the slots derived model's team aggregate already resolved the side split + -- and summed the per-side ratings (and re-fires only when those move) + self.TeamsObserver = self.Trash:Add( + LazyVarDerive(CustomLobbySlotsDerivedModel.GetTeamsVar(), function(lazy) lazy() self:Refresh() end)) + end, + + ---@param self UICustomLobbyTeamScore + __post_init = function(self) + end, + + --- Builds the score after the parent has placed the widget (kept symmetric with the panels). + ---@param self UICustomLobbyTeamScore + Initialize = function(self) + self.Ready = true + -- centred row: LabelA ScoreA · ScoreB LabelB + Layouter(self.Sep):AtHorizontalCenterIn(self):AtVerticalCenterIn(self):End() + Layouter(self.ScoreA):AnchorToLeft(self.Sep, 10):AtVerticalCenterIn(self):End() + Layouter(self.LabelA):AnchorToLeft(self.ScoreA, 8):AtVerticalCenterIn(self):End() + Layouter(self.ScoreB):AnchorToRight(self.Sep, 10):AtVerticalCenterIn(self):End() + Layouter(self.LabelB):AnchorToRight(self.ScoreB, 8):AtVerticalCenterIn(self):End() + self:Refresh() + end, + + --- Paints the two side totals from the slots model's team aggregate, or hides the widget when the + --- mode has no 2-side split (none / manual) or the sides can't be determined yet (no map). + ---@param self UICustomLobbyTeamScore + Refresh = function(self) + if not self.Ready then + return + end + local teams = CustomLobbySlotsDerivedModel.GetTeams() + if not (teams.Labels and teams.Resolved) then + self:Hide() + return + end + + self.LabelA:SetText(teams.Labels[1]) + self.ScoreA:SetText(tostring(teams.Totals[1])) + self.LabelB:SetText(teams.Labels[2]) + self.ScoreB:SetText(tostring(teams.Totals[2])) + self:Show() + end, + + ---@param self UICustomLobbyTeamScore + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyTeamScore +Create = function(parent) + return CustomLobbyTeamScore(parent) +end diff --git a/lua/ui/lobby/customlobby/TODO.md b/lua/ui/lobby/customlobby/TODO.md new file mode 100644 index 00000000000..3c65196b4e9 --- /dev/null +++ b/lua/ui/lobby/customlobby/TODO.md @@ -0,0 +1,59 @@ +# Custom lobby — TODO / parked work + +Things deliberately deferred, with enough context to pick them up without re-deriving the problem. + +## Enrich specific-unit restrictions (name + icon) in the Restrictions panel + +**Status:** parked — needs a decision on how to manage blueprint-loading complexity in the front-end. + +### What works today + +The launch model's `Restrictions` is a `string[]` of keys. A key is one of two kinds: + +- a **preset** key (e.g. `"T3"`, `"AIR"`) — fully resolved by + [models/derived/CustomLobbyRestrictionsDerivedModel.lua](models/derived/CustomLobbyRestrictionsDerivedModel.lua) + against [`/lua/ui/lobby/unitsrestrictions.lua`](/lua/ui/lobby/unitsrestrictions.lua) → name / icon / tooltip. +- a **specific unit id** (e.g. `"uel0201"`) — **not** enriched yet; it currently shows as its raw id + (no icon, no name) via the fallback branch in `EnrichKey`. + +The goal: a unit-id restriction should show the unit's **icon + name** (+ tooltip), like the preset rows, +so it's easy to read. The panel already renders `{ Name, Icon, Tooltip }` generically — so this is purely a +model-side resolution problem; **no panel change is needed** once the model can produce those fields. + +### The complexity (why it's parked) + +There is no cheap, synchronous "unit id → name + icon" lookup available in the lobby (front-end) Lua state: + +- **`__blueprints` is sim-side only.** It is created when a *game* loads (see + [`/lua/RuleInit.lua`](/lua/RuleInit.lua) `__blueprints = …`) and is **not populated in the front-end** — + confirmed by the note in [`/lua/ui/lobby/UnitsAnalyzer.lua`](/lua/ui/lobby/UnitsAnalyzer.lua) (~line 496: + "we cannot use global `__blueprints` … because it is created on SIM side"). An earlier attempt that read + `__blueprints[unitId]` was reverted for this reason — it always hit nil and fell through. +- **The icon, on its own, *is* resolvable synchronously.** The build icon lives on disk at + `/textures/ui/common/icons/units/_icon.dds`, and `DiskGetFileInfo` works in the front-end (that's how + `UnitsAnalyzer.GetImagePath` resolves it). So **icon-only** enrichment needs no blueprint load. +- **The name needs a loaded blueprint.** In the front-end the only source is + [`/lua/ui/lobby/UnitsAnalyzer.lua`](/lua/ui/lobby/UnitsAnalyzer.lua), which loads blueprints by + **reading the `.bp` files itself** (`GetBlueprints` → `LoadBlueprints('*_unit.bp', …)` → `CacheUnit`, which + sets `bp.Name = GetUnitName(bp)` etc.). It is **async**, **mod-aware**, and **loads the entire unit set**. + It's wrapped reactively by [mapselect-style] [`unitselect/CustomLobbyUnitCatalog.lua`](unitselect/CustomLobbyUnitCatalog.lua) + (`EnsureLoaded(activeMods)` + `GetFactionsVar()`), which the unit-select dialog uses. + +### Options considered (pick one when we resume) + +1. **Icon + raw id (lightweight).** Resolve the icon via the disk path (sync), label = unit id. Zero load + cost, works for host + clients immediately. Loses the human-readable name. +2. **Full name via UnitsAnalyzer.** Have the model consume `CustomLobbyUnitCatalog` (trigger + `EnsureLoaded` only when unit-id restrictions are present, re-derive when `GetFactionsVar()` fires). + Proper names + tooltips, but loads *all* blueprints (async, heavy) in any lobby with unit restrictions, + on host **and** clients — a lot of machinery for a few labels. +3. **Lightweight single-`.bp` read.** Mirror what `UnitsAnalyzer`/`CacheUnit` do but for *one* unit: + synchronously `doscript` the unit's `_unit.bp` and read `General.UnitName` / `Description`. Avoids loading + the whole set, but re-implements a slice of the blueprint loader and needs care (mods, file path from id, + `doscript` safety/caching). + +### Open question + +How to manage this blueprint-loading complexity for a **read-only** panel without paying the full +load-everything cost — likely option 3 (a small cached "resolve one unit's name from its `.bp`") or option 1 +as an interim. Decide, then implement entirely in the derived model's `EnrichKey` (the panel is already generic). diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua new file mode 100644 index 00000000000..e91fc2f2413 --- /dev/null +++ b/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua @@ -0,0 +1,417 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The lobby's right column: the map preview pinned on top, three tabs below — +-- +-- ┌ map preview (square, bound) ─┐ +-- │ │ +-- └──────────────────────────────┘ +-- Seton's Clutch +-- 20km · 8 players · v3 +-- [ Options | Mods | Restrictions ] +-- ┌ active tab's panel ──────────┐ +-- │ … │ +-- └──────────────────────────────┘ +-- +-- The preview is the shared bound `CustomLobbyMapPreview` (subscribes to `ScenarioFile`, renders +-- faction spawns; the engine caches the single current map's texture, so churn is free). It is +-- pinned above the tabs — only the panel below churns on tab switch — with a short name + +-- size/players/version facts line between them. The three tab panels are all read-only: their own +-- per-domain action buttons are gone (the interface's action-bar Settings button opens the options +-- editor); the change-map / mod-select entry points return when the config rework resumes. +-- +-- This used to be a four-tab strip including a Map tab; the Map preview is now the pinned header. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Button = import("/lua/maui/button.lua").Button +local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") +local CustomLobbyTabs = import("/lua/ui/lobby/customlobby/customlobbytabs.lua") +local CustomLobbyOptionsPanel = import("/lua/ui/lobby/customlobby/config/customlobbyoptionspanel.lua") +local CustomLobbyModsPanel = import("/lua/ui/lobby/customlobby/config/customlobbymodspanel.lua") +local CustomLobbyUnitsPanel = import("/lua/ui/lobby/customlobby/config/customlobbyunitspanel.lua") +local CustomLobbyMapSelect = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapselect.lua") +local CustomLobbyOptionSelect = import("/lua/ui/lobby/customlobby/optionselect/customlobbyoptionselect.lua") +local CustomLobbyModSelect = import("/lua/ui/lobby/customlobby/modselect/customlobbymodselect.lua") +local CustomLobbyUnitSelect = import("/lua/ui/lobby/customlobby/unitselect/customlobbyunitselect.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") +local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua") +local CustomLobbyOptionsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyoptionsderivedmodel.lua") +local CustomLobbyRestrictionsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyrestrictionsderivedmodel.lua") +local CustomLobbyModsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbymodsderivedmodel.lua") + +local LazyVarCreate = import("/lua/lazyvar.lua").Create +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +local Inset = 6 +local NameMaxChars = 30 +local FactsColor = 'ff9aa0a8' + +-- the preview tool strip (to the right of the map): square icon buttons +local ToolSize = 28 +local ToolGap = 6 +local ToolIconInset = 5 +local ToolIdle = 'ff141a20' +local ToolHover = 'ff1f262e' +local ToolActive = 'ff2c4a5e' -- a lit toggle's background +local ToolIconDim = 0.40 -- icon alpha when a toggle is off + +-- icon textures (skin-relative; resolved through UIFile). The army icon reuses the start-position +-- commander silhouette; resources the mass icon; water a map pin; config an edit glyph. +local ArmyIcon = '/dialogs/mapselect02/commander_alpha.dds' +local ResourceIcon = '/game/build-ui/icon-mass_bmp.dds' +local WaterIcon = '/game/camera-btn/pinned_btn_up.dds' +local ConfigIcon = '/game/menu-btns/config_btn_up.dds' + +-- the per-tab config gear (inside the tab, left of the label): a fully-skinned button +-- (up/down/over/dis) that opens that tab's editor dialog +local GearTextures = { + up = UIUtil.SkinnableFile('/game/menu-btns/config_btn_up.dds'), + down = UIUtil.SkinnableFile('/game/menu-btns/config_btn_down.dds'), + over = UIUtil.SkinnableFile('/game/menu-btns/config_btn_over.dds'), + dis = UIUtil.SkinnableFile('/game/menu-btns/config_btn_dis.dds'), +} + +--- Builds a tab `Action` (a config gear) for `CustomLobbyTabs`: a skinned button that opens +--- `onOpen` on click, with a tooltip. `visibleLazy` (optional) hides the gear when it doesn't apply +--- — e.g. the host-only Options gear is hidden for clients (the tab's label re-centres). +---@param onOpen fun() +---@param title string +---@param body string +---@param visibleLazy? LazyVar +---@return UICustomLobbyTabAction +local function GearAction(onOpen, title, body, visibleLazy) + return { + Create = function(parent) + local gear = Button(parent, GearTextures.up, GearTextures.down, GearTextures.over, GearTextures.dis) + gear.OnClick = function(button, modifiers) + onOpen() + end + Tooltip.AddControlTooltipManual(gear, title, body) + return gear + end, + Visible = visibleLazy, + } +end + +-- A small square icon button used in the preview tool strip. A toggle flips Active on click and +-- calls `OnToggle(active)`; an action button (isToggle = false) just calls `OnPress`. The look is +-- the tab convention: idle / hover / lit-when-active background, with the icon dimmed when off. +---@class UICustomLobbyPreviewTool : Group +---@field Bg Bitmap +---@field Icon Bitmap +---@field IsToggle boolean +---@field Active boolean +---@field Hovered boolean +---@field OnToggle? fun(active: boolean) +---@field OnPress? fun() +local PreviewTool = ClassUI(Group) { + + ---@param self UICustomLobbyPreviewTool + ---@param parent Control + ---@param texture FileName + ---@param isToggle boolean + __init = function(self, parent, texture, isToggle) + Group.__init(self, parent, "CustomLobbyPreviewTool") + + self.IsToggle = isToggle or false + self.Active = true + self.Hovered = false + + self.Bg = Bitmap(self) + self.Bg:SetSolidColor(ToolIdle) + + self.Icon = UIUtil.CreateBitmap(self, texture) + self.Icon:DisableHitTest() + + self.Bg.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + if self.IsToggle then + self.Active = not self.Active + self:ApplyVisual() + if self.OnToggle then + self.OnToggle(self.Active) + end + elseif self.OnPress then + self.OnPress() + end + return true + elseif event.Type == 'MouseEnter' then + self.Hovered = true + self:ApplyVisual() + return true + elseif event.Type == 'MouseExit' then + self.Hovered = false + self:ApplyVisual() + return true + end + return false + end + end, + + ---@param self UICustomLobbyPreviewTool + __post_init = function(self) + Layouter(self.Bg):Fill(self):End() + Layouter(self.Icon):AtCenterIn(self):Width(ToolSize - 2 * ToolIconInset):Height(ToolSize - 2 * ToolIconInset):End() + self:ApplyVisual() + end, + + --- Repaints the background + icon for the current active/hover state. + ---@param self UICustomLobbyPreviewTool + ApplyVisual = function(self) + local bg = ToolIdle + if self.IsToggle and self.Active then + bg = ToolActive + elseif self.Hovered then + bg = ToolHover + end + self.Bg:SetSolidColor(bg) + local lit = (not self.IsToggle) or self.Active + self.Icon:SetAlpha(lit and 1.0 or ToolIconDim) + end, + + --- Sets the toggle state without firing `OnToggle` (for syncing the initial visual). + ---@param self UICustomLobbyPreviewTool + ---@param active boolean + SetActive = function(self, active) + self.Active = active + self:ApplyVisual() + end, +} + +--- Truncates `text` to `maxChars`, appending "…" when it had to cut. (Local copy — drift-fine, see +--- ../CLAUDE.md "On sharing".) +---@param text string +---@param maxChars number +---@return string +local function Truncate(text, maxChars) + text = text or "" + if string.len(text) > maxChars then + return string.sub(text, 1, maxChars - 1) .. "…" + end + return text +end + +---@class UICustomLobbyConfigInterface : Group +---@field Trash TrashBag +---@field Preview UICustomLobbyMapPreview +---@field Name Text +---@field Info Text +---@field ArmyToggle UICustomLobbyPreviewTool +---@field ResourceToggle UICustomLobbyPreviewTool +---@field WaterToggle UICustomLobbyPreviewTool +---@field ConfigButton UICustomLobbyPreviewTool +---@field Tabs UICustomLobbyTabs +---@field OptionsBadge LazyVar # count of non-default options (Options tab pill) +---@field ModsBadge LazyVar # "sim / ui" mod counts (Mods tab pill) +---@field RestrictionsBadge LazyVar # restriction count (Restrictions tab pill) +---@field ScenarioObserver LazyVar +---@field IsHostObserver LazyVar +local CustomLobbyConfigInterface = ClassUI(Group) { + + ---@param self UICustomLobbyConfigInterface + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyConfigInterface") + + self.Trash = TrashBag() + + self.Preview = CustomLobbyMapPreview.Create(self, { Bound = true }) + + self.Name = UIUtil.CreateText(self, "", 15, UIUtil.titleFont) + self.Name:DisableHitTest() + self.Info = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) + self.Info:SetColor(FactsColor) + self.Info:DisableHitTest() + + --#region preview tool strip (to the right of the map): overlay toggles + change-scenario + local surface = self.Preview.Surface + + self.ArmyToggle = PreviewTool(self, ArmyIcon, true) + self.ArmyToggle.OnToggle = function(active) surface:SetOverlayVisible('spawns', active) end + Tooltip.AddControlTooltipManual(self.ArmyToggle.Bg, "Army icons", "Show or hide the start-position army icons.") + + self.ResourceToggle = PreviewTool(self, ResourceIcon, true) + self.ResourceToggle.OnToggle = function(active) surface:SetOverlayVisible('resources', active) end + Tooltip.AddControlTooltipManual(self.ResourceToggle.Bg, "Mass & hydrocarbon", "Show or hide the mass and hydrocarbon deposit icons.") + + -- water defaults OFF (the surface's dummy mask starts hidden); sync the toggle's look + self.WaterToggle = PreviewTool(self, WaterIcon, true) + self.WaterToggle:SetActive(false) + self.WaterToggle.OnToggle = function(active) surface:SetOverlayVisible('water', active) end + Tooltip.AddControlTooltipManual(self.WaterToggle.Bg, "Water", "Show or hide the water (placeholder).") + + -- host-only action: open the map-select dialog to change the scenario + self.ConfigButton = PreviewTool(self, ConfigIcon, false) + self.ConfigButton.OnPress = function() CustomLobbyMapSelect.Open(GetFrame(0)) end + Tooltip.AddControlTooltipManual(self.ConfigButton.Bg, "Change map", "Pick a different scenario (host only).") + --#endregion + + -- count badges for the tab strip: computed LazyVars over the derived models (the tabs + -- container observes them and renders the grey pills). Built before the tabs so each + -- button can subscribe at creation. + + -- the badges always render, even at 0 (an empty string would collapse the pill) + self.OptionsBadge = self.Trash:Add(LazyVarCreate()) + self.OptionsBadge:Set(function() + return tostring(CustomLobbyOptionsDerivedModel.GetOptionsVar()().NonDefaultCount) + end) + + -- "sim / ui" — game (sim) mods and this peer's UI mods, from the mods derived model + self.ModsBadge = self.Trash:Add(LazyVarCreate()) + self.ModsBadge:Set(function() + local mods = CustomLobbyModsDerivedModel.GetModsVar()() + return mods.GameCount .. " / " .. mods.UiCount + end) + + -- the count of active unit restrictions (from the restrictions derived model) + self.RestrictionsBadge = self.Trash:Add(LazyVarCreate()) + self.RestrictionsBadge:Set(function() + return tostring(CustomLobbyRestrictionsDerivedModel.GetRestrictionsVar()().Count) + end) + + -- the read-only config tabs below the preview (created-on-select / destroyed-on-switch), + -- each with a config gear (inside the tab, left of the label) opening that tab's editor. + -- Options + Restrictions are host-only — their gears hide for clients (the host gate is the + -- IsHost LazyVar); Mods is open to everyone (UI mods are local, the sim portion is host-gated + -- in the dialog). + local isHost = CustomLobbyLocalModel.GetSingleton().IsHost + self.Tabs = CustomLobbyTabs.Create(self, { + Tabs = { + { + Label = "Options", Create = CustomLobbyOptionsPanel.Create, Badge = self.OptionsBadge, + Action = GearAction(function() CustomLobbyOptionSelect.Open(GetFrame(0)) end, + "Edit options", "Open the game options editor (host only).", isHost), + }, + { + Label = "Mods", Create = CustomLobbyModsPanel.Create, Badge = self.ModsBadge, + Action = GearAction(function() CustomLobbyModSelect.Open(GetFrame(0)) end, + "Manage mods", "Pick the game's sim mods (host) and your own UI mods."), + }, + { + Label = "Restrictions", Create = CustomLobbyUnitsPanel.Create, Badge = self.RestrictionsBadge, + -- the longest label (+ gear + badge); give it more of the strip than Options/Mods + Weight = 1.4, + Action = GearAction(function() CustomLobbyUnitSelect.Open(GetFrame(0)) end, + "Edit restrictions", "Pick the units and presets to restrict (host only).", isHost), + }, + }, + }) + + -- the derived scenario model already loaded + deduped the map; we just re-render the facts + -- when the resolved scenario actually changes (a same-map rebroadcast won't re-fire this) + self.ScenarioObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyScenarioDerivedModel.GetScenarioVar(), function(scenarioLazy) + scenarioLazy() + self:RefreshFacts() + end)) + -- the change-map button is host-only (the gears are gated per-tab via their Visible LazyVar) + self.IsHostObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyLocalModel.GetSingleton().IsHost, function(isHostLazy) + if isHostLazy() then + self.ConfigButton:Show() + else + self.ConfigButton:Hide() + end + end)) + end, + + ---@param self UICustomLobbyConfigInterface + __post_init = function(self) + -- the tool strip: a right-aligned vertical column of square buttons; the three toggles stack + -- from the top, the config button is pinned at the bottom (aligned with the preview's base) + local function placeTool(tool) + return Layouter(tool):AtRightIn(self, Inset):Width(ToolSize):Height(ToolSize) + end + placeTool(self.ArmyToggle):AtTopIn(self, Inset):End() + placeTool(self.ResourceToggle):AnchorToBottom(self.ArmyToggle, ToolGap):End() + placeTool(self.WaterToggle):AnchorToBottom(self.ResourceToggle, ToolGap):End() + placeTool(self.ConfigButton):End() + self.ConfigButton.Top:Set(function() return self.Preview.Bottom() - LayoutHelpers.ScaleNumber(ToolSize) end) + + -- the square preview fills the column from the left inset up to the tool strip + Layouter(self.Preview):AtLeftIn(self, Inset):AtTopIn(self, Inset):End() + self.Preview.Right:Set(function() return self.ArmyToggle.Left() - LayoutHelpers.ScaleNumber(ToolGap) end) + self.Preview.Height:Set(function() return self.Preview.Width() end) + + Layouter(self.Name):AtHorizontalCenterIn(self.Preview):AnchorToBottom(self.Preview, 8):End() + Layouter(self.Info):AtHorizontalCenterIn(self.Preview):AnchorToBottom(self.Name, 2):End() + + -- the tabs fill the rest of the column below the facts line + Layouter(self.Tabs) + :AtLeftIn(self):AtRightIn(self) + :AnchorToBottom(self.Info, 8):AtBottomIn(self) + :End() + end, + + --- Three-phase init: the interface calls this after sizing the column. Forwards to the tabs + --- container (its first panel's grid needs a concrete height) and renders the first facts line. + ---@param self UICustomLobbyConfigInterface + Initialize = function(self) + self:RefreshFacts() + self.Tabs:Initialize() + end, + + --- Renders the map name + the size · players · version facts line — straight from the derived + --- scenario model (no disk read here; the model already fished the values out). + ---@param self UICustomLobbyConfigInterface + RefreshFacts = function(self) + local scenario = CustomLobbyScenarioDerivedModel.GetScenario() + if scenario then + self.Name:SetText(Truncate(scenario.Name, NameMaxChars)) + local parts = {} + if scenario.Size then + table.insert(parts, string.format("%dkm", math.floor(scenario.Size[1] / 50))) + end + if scenario.ArmyCount > 0 then + table.insert(parts, scenario.ArmyCount .. " players") + end + if scenario.Version then + table.insert(parts, "v" .. tostring(scenario.Version)) + end + self.Info:SetText(table.concat(parts, " · ")) + else + local hasFile = CustomLobbyLaunchModel.GetSingleton().ScenarioFile() + self.Name:SetText(hasFile and "Unknown map" or "No map selected") + self.Info:SetText("") + end + end, + + ---@param self UICustomLobbyConfigInterface + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +--- Builds the right-column config composition. The parent sizes it and calls `Initialize()` after +--- mounting (three-phase init). +---@param parent Control +---@return UICustomLobbyConfigInterface +Create = function(parent) + return CustomLobbyConfigInterface(parent) +end diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua new file mode 100644 index 00000000000..c0e5506e4e8 --- /dev/null +++ b/lua/ui/lobby/customlobby/config/CustomLobbyMapPanel.lua @@ -0,0 +1,456 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The Map tab's content: the selected scenario's details as stacked, labelled sections — +-- +-- Author +-- Jip Willem Wijnia +-- +-- Reclaim +-- 1.0M [mass] · 120k [energy] +-- +-- Description +-- +-- +-- Each optional section (author, reclaim) collapses when the map doesn't declare it, so the +-- description floats up. The bottom action sub-area holds the "Open page" link (secondary, left, +-- when the map has an allowed url) and the host-only "Change map" button (primary, right). The +-- preview + name + size + players + version are pinned above the tab strip by the config +-- interface (the preview's textures aren't freed, so it must not be destroyed). +-- +-- A config-interface tab panel: the host creates it when the Map tab is selected and destroys it +-- on switch, so it's the live/visible panel for its lifetime. `Initialize` (called by the host +-- after sizing it) binds the description's width + builds its scrollbar and renders. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local TextArea = import("/lua/ui/controls/textarea.lua").TextArea +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") +local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") +local CustomLobbyMapSelect = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapselect.lua") +local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +local ActionHeight = 40 +local IconSize = 14 +local PreviewMaxSize = 240 -- the square preview grows with the panel, capped to this +local NameMaxChars = 28 +local LabelColor = 'ff8a909a' -- the section labels (Author / Reclaim / Description) +local ValueColor = 'ffc8ccd0' +local MassIcon = "/game/build-ui/icon-mass_bmp.dds" +local EnergyIcon = "/game/build-ui/icon-energy_bmp.dds" + +-- map pages we'll open in a browser, matched against the URL host (exact or as a subdomain). +-- Mirrors the map/mod dialogs; add a line to extend. +local AllowedUrlDomains = { + "github.com", + "githubusercontent.com", + "gitlab.com", + "github.io", + "faforever.com", +} + +--- The lowercased host of a URL (between the scheme and the first `/` or `:`), or "". +---@param url string +---@return string +local function UrlHost(url) + local rest = string.gsub(string.lower(url), "^https?://", "") + return (string.gsub(rest, "[/:].*$", "")) +end + +--- Whether `url` is an http(s) link to an allowed domain (or a subdomain of one). +---@param url any +---@return boolean +local function IsAllowedUrl(url) + if type(url) ~= 'string' or not string.find(string.lower(url), "^https?://") then + return false + end + local host = UrlHost(url) + for _, domain in AllowedUrlDomains do + local escaped = string.gsub(domain, "%.", "%%.") + if host == domain or string.find(host, "%." .. escaped .. "$") then + return true + end + end + return false +end + +--- Downgrades an `https://` URL to `http://` — the engine's `OpenURL` only handles `http://`. +---@param url string +---@return string +local function ToOpenableUrl(url) + return (string.gsub(url, "^https://", "http://")) +end + +--- Formats a resource amount compactly: 1016424 -> "1.0M", 119858 -> "120k", 950 -> "950". +---@param amount number +---@return string +local function FormatAmount(amount) + if amount >= 1000000 then + return string.format("%.1fM", amount / 1000000) + elseif amount >= 1000 then + return string.format("%.0fk", amount / 1000) + end + return string.format("%d", amount) +end + +--- Truncates `text` to `maxChars`, appending "…" when it had to cut. +---@param text string +---@param maxChars number +---@return string +local function Truncate(text, maxChars) + text = text or "" + if string.len(text) > maxChars then + return string.sub(text, 1, maxChars - 1) .. "…" + end + return text +end + +--- Number of start spots a scenario declares, or 0. +---@param scenario UILobbyScenarioInfo +---@return number +local function ArmyCount(scenario) + local armies = scenario.Configurations + and scenario.Configurations.standard + and scenario.Configurations.standard.teams + and scenario.Configurations.standard.teams[1] + and scenario.Configurations.standard.teams[1].armies + return armies and table.getsize(armies) or 0 +end + +---@class UICustomLobbyMapPanel : Group +---@field Trash TrashBag +---@field Ready boolean +---@field IsHost boolean +---@field CurrentUrl string | false +---@field Preview UICustomLobbyMapPreview +---@field Name Text +---@field Info Text +---@field AuthorLabel Text +---@field AuthorValue Text +---@field ReclaimLabel Text +---@field ReclaimValue Group +---@field ReclaimMass Text +---@field ReclaimMassIcon Bitmap +---@field ReclaimEnergy Text +---@field ReclaimEnergyIcon Bitmap +---@field DescriptionLabel Text +---@field Description TextArea +---@field DescriptionScrollbar Scrollbar | false +---@field ActionArea Group +---@field UrlButton Text +---@field ChangeButton Button +---@field ScenarioObserver LazyVar +---@field IsHostObserver LazyVar +local CustomLobbyMapPanel = ClassUI(Group) { + + ---@param self UICustomLobbyMapPanel + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyMapPanel") + + self.Trash = TrashBag() + self.Ready = false + self.IsHost = false + self.CurrentUrl = false + self.DescriptionScrollbar = false + + --#region map preview + name + size/players/version (preview left, the rest to its right) + self.Preview = CustomLobbyMapPreview.Create(self, { Bound = true }) + self.Name = UIUtil.CreateText(self, "", 16, UIUtil.titleFont) + self.Name:DisableHitTest() + self.Info = UIUtil.CreateText(self, "", 13, UIUtil.bodyFont) + self.Info:SetColor('ff9aa0a8') + self.Info:DisableHitTest() + --#endregion + + --#region Author section + self.AuthorLabel = self:CreateSectionLabel("Author") + self.AuthorValue = UIUtil.CreateText(self, "", 13, UIUtil.bodyFont) + self.AuthorValue:SetColor(ValueColor) + self.AuthorValue:DisableHitTest() + --#endregion + + --#region Reclaim section (amount + mass/energy icon, amount + energy icon) + self.ReclaimLabel = self:CreateSectionLabel("Reclaim") + self.ReclaimValue = Group(self, "CustomLobbyMapReclaim") + self.ReclaimValue:DisableHitTest() + self.ReclaimMass = UIUtil.CreateText(self.ReclaimValue, "", 13, UIUtil.bodyFont) + self.ReclaimMass:SetColor(ValueColor) + self.ReclaimMass:DisableHitTest() + self.ReclaimMassIcon = Bitmap(self.ReclaimValue) + self.ReclaimMassIcon:SetTexture(UIUtil.UIFile(MassIcon)) + self.ReclaimMassIcon:DisableHitTest() + self.ReclaimEnergy = UIUtil.CreateText(self.ReclaimValue, "", 13, UIUtil.bodyFont) + self.ReclaimEnergy:SetColor(ValueColor) + self.ReclaimEnergy:DisableHitTest() + self.ReclaimEnergyIcon = Bitmap(self.ReclaimValue) + self.ReclaimEnergyIcon:SetTexture(UIUtil.UIFile(EnergyIcon)) + self.ReclaimEnergyIcon:DisableHitTest() + --#endregion + + --#region Description section + self.DescriptionLabel = self:CreateSectionLabel("Description") + self.Description = TextArea(self, 200, 80) + self.Description:SetFont(UIUtil.bodyFont, 12) + self.Description:SetColors(ValueColor, "00000000", ValueColor, "00000000") + --#endregion + + --#region actions + self.ActionArea = Group(self, "CustomLobbyMapActions") + + self.UrlButton = UIUtil.CreateText(self.ActionArea, "Open page", 12, UIUtil.bodyFont) + self.UrlButton:SetColor('ff7fb3ff') + self.UrlButton:Hide() + self.UrlButton.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + if self.CurrentUrl then + OpenURL(ToOpenableUrl(self.CurrentUrl)) + end + return true + elseif event.Type == 'MouseEnter' then + control:SetColor('ffaecbff') + return true + elseif event.Type == 'MouseExit' then + control:SetColor('ff7fb3ff') + return true + end + return false + end + Tooltip.AddControlTooltipManual(self.UrlButton, "Map page", "Open the map's web page in your browser.") + + self.ChangeButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Change map") + self.ChangeButton.OnClick = function(button, modifiers) + CustomLobbyMapSelect.Open(GetFrame(0)) + end + --#endregion + + self.ScenarioObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyLaunchModel.GetSingleton().ScenarioFile, function(lazy) + lazy() + self:Refresh() + end)) + self.IsHostObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyLocalModel.GetSingleton().IsHost, function(isHostLazy) + self.IsHost = isHostLazy() + self:ApplyHostVisibility() + end)) + end, + + ---@param self UICustomLobbyMapPanel + __post_init = function(self) + Layouter(self.ActionArea):AtLeftIn(self):AtRightIn(self):AtBottomIn(self):Height(ActionHeight):End() + Layouter(self.ChangeButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.UrlButton):AtLeftIn(self.ActionArea, 6):AtVerticalCenterIn(self.ActionArea):End() + + -- the preview is a square on the left, growing with the panel height but capped so it never + -- swallows the details column; the name, info and labelled sections stack to its right + Layouter(self.Preview):AtLeftIn(self, 6):AtTopIn(self, 6):End() + self.Preview.Height:Set(function() + local avail = self.ActionArea.Top() - self.Preview.Top() - LayoutHelpers.ScaleNumber(8) + return math.min(avail, LayoutHelpers.ScaleNumber(PreviewMaxSize)) + end) + self.Preview.Width:Set(function() return self.Preview.Height() end) + Layouter(self.Name):AnchorToRight(self.Preview, 12):AtTopIn(self, 6):End() + Layouter(self.Info):AnchorToRight(self.Preview, 12):AnchorToBottom(self.Name, 4):End() + + -- the reclaim value row: amount + mass icon, amount + energy icon (fixed internal layout; + -- the group's position is set by LayoutSections) + LayoutHelpers.SetHeight(self.ReclaimValue, IconSize + 4) + Layouter(self.ReclaimMass):AtLeftIn(self.ReclaimValue):AtVerticalCenterIn(self.ReclaimValue):End() + Layouter(self.ReclaimMassIcon):AnchorToRight(self.ReclaimMass, 3):AtVerticalCenterIn(self.ReclaimValue):Width(IconSize):Height(IconSize):End() + Layouter(self.ReclaimEnergy):AnchorToRight(self.ReclaimMassIcon, 10):AtVerticalCenterIn(self.ReclaimValue):End() + Layouter(self.ReclaimEnergyIcon):AnchorToRight(self.ReclaimEnergy, 3):AtVerticalCenterIn(self.ReclaimValue):Width(IconSize):Height(IconSize):End() + + -- the description's left/right are fixed here so its Width can be bound in Initialize + -- (the TextArea reflows on the Width bind, which reads Left/Right); its top/bottom are set + -- dynamically by LayoutSections. Left = right of the preview, so it sits in the right column + Layouter(self.Description):AnchorToRight(self.Preview, 12):AtRightIn(self, 32):End() + end, + + --- Builds a dim section label (Author / Reclaim / Description). Private. + ---@param self UICustomLobbyMapPanel + ---@param text string + ---@return Text + CreateSectionLabel = function(self, text) + local label = UIUtil.CreateText(self, text, 12, UIUtil.titleFont) + label:SetColor(LabelColor) + label:DisableHitTest() + return label + end, + + --- Binds the description's width + builds its scrollbar, then renders. Called by the host after + --- it sizes the panel (the TextArea wraps to Width(), so it must be bound to the laid-out span + --- now — see the TextArea gotcha in ../CLAUDE.md). + ---@param self UICustomLobbyMapPanel + Initialize = function(self) + self.Ready = true + self.Description.Width:Set(function() return self.Description.Right() - self.Description.Left() end) + self.DescriptionScrollbar = UIUtil.CreateVertScrollbarFor(self.Description) + self:Refresh() + self:ApplyHostVisibility() + + -- the scrollbar's need depends on the reflowed line count vs the laid-out box height, and + -- neither is final until a frame after mount — Initialize can run pre-frame (the tab host + -- calls it synchronously). Re-check once settled so a fitting description doesn't keep a bar. + self.Trash:Add(ForkThread(function() + WaitFrames(1) + if not IsDestroyed(self) then + self:UpdateScrollbar() + end + end)) + end, + + --- Loads the current scenario's info and fills the labelled sections (author / reclaim / + --- description) + the url link, collapsing sections the map doesn't provide. + ---@param self UICustomLobbyMapPanel + Refresh = function(self) + if not self.Ready then + return + end + local scenarioFile = CustomLobbyLaunchModel.GetSingleton().ScenarioFile() + -- `info` is a table when a readable scenario is selected, else false (no map) / nil + local info = scenarioFile and CustomLobbyMapCatalog.LoadInfo(scenarioFile) + local fields = type(info) == "table" and info or {} + + -- header: name + the size · players · version line + if type(info) == "table" then + self.Name:SetText(Truncate(LOC(info.name) or "?", NameMaxChars)) + local parts = {} + if info.size then + table.insert(parts, string.format("%dkm", math.floor(info.size[1] / 50))) + end + local players = ArmyCount(info) + if players > 0 then + table.insert(parts, players .. " players") + end + if info.map_version then + table.insert(parts, "v" .. tostring(info.map_version)) + end + self.Info:SetText(table.concat(parts, " · ")) + else + self.Name:SetText(scenarioFile and "Unknown map" or "No map selected") + self.Info:SetText("") + end + + local author = fields.author + local reclaim = fields.reclaim + local description = fields.description + local hasAuthor = type(author) == "string" and author ~= "" + local hasReclaim = type(reclaim) == "table" and reclaim[1] ~= nil and reclaim[2] ~= nil + + if hasAuthor then + self.AuthorValue:SetText(author) + end + if hasReclaim then + self.ReclaimMass:SetText(FormatAmount(reclaim[1])) + self.ReclaimEnergy:SetText(FormatAmount(reclaim[2])) + end + self.Description:SetText((type(description) == "string" and LOC(description)) or "") + + self.CurrentUrl = IsAllowedUrl(fields.url) and fields.url or false + if self.CurrentUrl then + self.UrlButton:Show() + else + self.UrlButton:Hide() + end + + self:LayoutSections(hasAuthor, hasReclaim) + self:UpdateScrollbar() + end, + + --- Stacks the visible sections top-to-bottom (collapsing absent ones) and floats the + --- description into the remaining space above the action area. + ---@param self UICustomLobbyMapPanel + ---@param hasAuthor boolean + ---@param hasReclaim boolean + LayoutSections = function(self, hasAuthor, hasReclaim) + -- everything lives in the column to the right of the preview, stacking below the info line + local prev = self.Info + + -- places a label + value pair under `prev`, or hides both + local function place(label, value, visible) + if visible then + label:Show() + value:Show() + Layouter(label):AnchorToRight(self.Preview, 12):AnchorToBottom(prev, 8):End() + Layouter(value):AnchorToRight(self.Preview, 12):AnchorToBottom(label, 2):End() + prev = value + else + label:Hide() + value:Hide() + end + end + + place(self.AuthorLabel, self.AuthorValue, hasAuthor) + place(self.ReclaimLabel, self.ReclaimValue, hasReclaim) + + -- description always shows; its label sits under the last visible section, then it fills + -- the rest of the right column down to the action bar + Layouter(self.DescriptionLabel):AnchorToRight(self.Preview, 12):AnchorToBottom(prev, 8):End() + Layouter(self.Description) + :AnchorToRight(self.Preview, 12):AtRightIn(self, 32) + :AnchorToBottom(self.DescriptionLabel, 4):AnchorToTop(self.ActionArea, 8) + :End() + end, + + --- The change-map button is host-only. + ---@param self UICustomLobbyMapPanel + ApplyHostVisibility = function(self) + if self.IsHost then + self.ChangeButton:Show() + else + self.ChangeButton:Hide() + end + end, + + --- Shows the description's scrollbar only when it overflows. + ---@param self UICustomLobbyMapPanel + UpdateScrollbar = function(self) + if not self.DescriptionScrollbar then + return + end + if self.Description:GetTextHeight() > self.Description.Height() then + self.DescriptionScrollbar:Show() + else + self.DescriptionScrollbar:Hide() + end + end, + + ---@param self UICustomLobbyMapPanel + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyMapPanel +Create = function(parent) + return CustomLobbyMapPanel(parent) +end diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua new file mode 100644 index 00000000000..f16ecfdf3fc --- /dev/null +++ b/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua @@ -0,0 +1,221 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The Mods tab panel of the config interface: the enabled mods, grouped into Game mods (the +-- shared sim mods) and UI mods (this peer's local choice), each row showing the mod's **icon + name** +-- with author/version on hover. The grid fills the whole panel — the "Manage mods" button is gone +-- for now (the per-domain edit buttons are removed during the layout rework). +-- +-- It reads the **mods derived model** (the enabled mods, already split into groups and enriched with +-- name / icon / author / version), so the panel does no uid resolution or formatting itself. It is a +-- tab panel: created when its tab is selected and destroyed on switch, so it's the live/visible panel +-- for its whole lifetime; the (synced) sim mods refresh it live (UI-mod prefs aren't reactive — same +-- caveat as before, the model re-reads them whenever the sim mods change). + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Grid = import("/lua/maui/grid.lua").Grid + +local CustomLobbyModsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbymodsderivedmodel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- taller rows than a plain text list so each mod's icon reads clearly (like the Restrictions panel) +local RowHeight = 34 +local IconSize = 26 +local ScrollGap = 32 -- standard lobby scrollbar gutter (see ModSelect) +local GridContentWidth = 360 - 6 - ScrollGap +local LabelMaxChars = 28 +local NormalColor = 'ffc8ccd0' + +--- Truncates `text` to `maxChars`, appending "…" when it had to cut. +---@param text string +---@param maxChars number +---@return string +local function Truncate(text, maxChars) + text = text or "" + if string.len(text) > maxChars then + return string.sub(text, 1, maxChars - 1) .. "…" + end + return text +end + +---@class UICustomLobbyModsPanel : Group +---@field Trash TrashBag +---@field Ready boolean +---@field ModsGrid Grid +---@field Scrollbar Scrollbar | false +---@field Empty Text +---@field ModsObserver LazyVar +local CustomLobbyModsPanel = ClassUI(Group) { + + ---@param self UICustomLobbyModsPanel + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyModsPanel") + + self.Trash = TrashBag() + self.Ready = false + self.Scrollbar = false + + self.ModsGrid = Grid(self, GridContentWidth, RowHeight) + self.Empty = UIUtil.CreateText(self, "No mods enabled", 13, UIUtil.bodyFont) + self.Empty:SetColor('ff8a909a') + self.Empty:DisableHitTest() + self.Empty:Hide() + + -- the derived model already split + enriched the enabled mods; one subscription rebuilds the + -- panel when they change (Refresh is Ready-gated for the immediate fire on creation) + self.ModsObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyModsDerivedModel.GetModsVar(), function(lazy) + lazy() + self:Refresh() + end)) + end, + + ---@param self UICustomLobbyModsPanel + __post_init = function(self) + Layouter(self.ModsGrid) + :AtLeftIn(self, 6):Width(GridContentWidth) + :AtTopIn(self, 6):AtBottomIn(self, 4) + :End() + Layouter(self.Empty):AtHorizontalCenterIn(self.ModsGrid):AtTopIn(self.ModsGrid, 8):End() + end, + + --- Builds the grid's scrollbar + does the first render (picking up the current UI-mod prefs). + --- Called by the host after it has sized the panel (the grid needs a concrete height). + ---@param self UICustomLobbyModsPanel + Initialize = function(self) + self.Ready = true + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.ModsGrid) + UIUtil.ForwardWheelToScroll(self.ModsGrid, self.ModsGrid) + self:Refresh() + end, + + --- Rebuilds the enabled-mods grid from the derived model's groups: a Game mods section + a UI mods + --- section, each mod shown as icon + name. The bundle is already split + enriched + sorted. + ---@param self UICustomLobbyModsPanel + Refresh = function(self) + if not self.Ready then + return + end + + local rows = {} + for _, group in CustomLobbyModsDerivedModel.GetMods().Groups do + if table.getn(group.Mods) > 0 then + table.insert(rows, { Header = group.Title }) + for _, mod in group.Mods do + table.insert(rows, { Mod = mod }) + end + end + end + + self.ModsGrid:DeleteAndDestroyAll(true) + if table.getn(rows) > 0 then + self.Empty:Hide() + self.ModsGrid:AppendCols(1, true) + self.ModsGrid:AppendRows(table.getn(rows), true) + for index, row in rows do + local control = row.Header and self:CreateSectionHeader(row.Header) or self:CreateModRow(row.Mod) + self.ModsGrid:SetItem(control, 1, index, true) + end + self.ModsGrid:EndBatch() + else + self.Empty:Show() + end + self:UpdateScrollbar() + end, + + --- Builds a section header row (Game mods / UI mods) with a thin underline. + ---@param self UICustomLobbyModsPanel + ---@param title string + ---@return Group + CreateSectionHeader = function(self, title) + local row = Group(self.ModsGrid) + LayoutHelpers.SetDimensions(row, GridContentWidth, RowHeight) + + local label = UIUtil.CreateText(row, string.upper(title), 12, UIUtil.titleFont) + label:SetColor('ff8a909a') + label:DisableHitTest() + Layouter(label):AtLeftIn(row, 2):AtVerticalCenterIn(row):End() + + local line = Bitmap(row) + line:SetSolidColor('ff3a4048') + line:DisableHitTest() + Layouter(line):AtLeftIn(row, 2):AtRightIn(row, 2):AtBottomIn(row):Height(1):End() + + return row + end, + + --- Builds one enabled-mod row: the mod's icon + name, with author/version on hover (the section + --- header conveys sim vs. UI). + ---@param self UICustomLobbyModsPanel + ---@param mod UICustomLobbyMod + ---@return Group + CreateModRow = function(self, mod) + local row = Group(self.ModsGrid) + LayoutHelpers.SetDimensions(row, GridContentWidth, RowHeight) + + local icon = Bitmap(row) + icon:SetTexture(mod.Icon) + Layouter(icon):AtLeftIn(row, 4):AtVerticalCenterIn(row):Width(IconSize):Height(IconSize):End() + -- author · version on hover (titled with the mod's full name, in case it was truncated) + local facts = mod.Version ~= "" and (mod.Author .. " · " .. mod.Version) or mod.Author + Tooltip.AddControlTooltipManual(icon, mod.Name, facts) + + local label = UIUtil.CreateText(row, Truncate(mod.Name, LabelMaxChars), 13, UIUtil.bodyFont) + label:SetColor(NormalColor) + label:DisableHitTest() + Layouter(label):AtLeftIn(row, 4 + IconSize + 8):AtVerticalCenterIn(row):End() + + return row + end, + + --- Shows the scrollbar only when the grid overflows. + ---@param self UICustomLobbyModsPanel + UpdateScrollbar = function(self) + if not self.Scrollbar then + return + end + if self.ModsGrid:IsScrollable("Vert") then + self.Scrollbar:Show() + else + self.Scrollbar:Hide() + end + end, + + ---@param self UICustomLobbyModsPanel + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyModsPanel +Create = function(parent) + return CustomLobbyModsPanel(parent) +end diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua new file mode 100644 index 00000000000..032b6e001e6 --- /dev/null +++ b/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua @@ -0,0 +1,270 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The Options tab of the config interface: a read-only view of the current option values, grouped +-- into Lobby / Scenario / Mods sections, with a hide-defaults toggle. The grid fills the whole +-- panel — the per-domain action buttons (open editor / reset) are gone; the interface's action-bar +-- Settings button owns opening the options editor, and they'll be reconsidered when the config +-- rework resumes. +-- +-- Options that come from the map or a mod are flagged with a gold marker + tinted label; the +-- marker's tooltip names the precise origin (`Map: …` / `Mod: …`), and an option's help shows as a +-- tooltip on its label. All of this is read straight from the **options derived model** (categorized, +-- enriched, schema-cached) — the panel does no schema gathering or value interpretation itself. +-- +-- It is a tab panel: created when its tab is selected and destroyed on switch, so it's the +-- live/visible panel for its whole lifetime — model observers just rebuild it. `Initialize` (called +-- by the tabs container after sizing it) builds the grid's scrollbar + does the first render; the +-- grid needs a concrete height by then. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Grid = import("/lua/maui/grid.lua").Grid + +local CustomLobbyOptionsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyoptionsderivedmodel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +local RowHeight = 22 +local ScrollGap = 32 -- standard lobby scrollbar gutter (see ModSelect) +local GridContentWidth = 360 - 6 - ScrollGap +local LabelMaxChars = 22 +local ValueMaxChars = 22 +local Debug = false + +local SpecialColor = 'ffd0a24c' -- marker + label tint for a map/mod option +local NormalColor = 'ffc8ccd0' +local ValueColor = 'ff9aa0a8' + +--- Truncates `text` to `maxChars`, appending "…" when it had to cut. +---@param text string +---@param maxChars number +---@return string +local function Truncate(text, maxChars) + text = text or "" + if string.len(text) > maxChars then + local limit = math.max(1, math.floor(maxChars)) + return string.sub(text, 1, limit - 1) .. "…" + end + return text +end + +---@class UICustomLobbyOptionsPanel : Bitmap +---@field Trash TrashBag +---@field Ready boolean +---@field HideDefaults boolean +---@field HideDefaultsToggle Checkbox +---@field OptionsGrid Grid +---@field Scrollbar Scrollbar | false +---@field Empty Text +---@field OptionsObserver LazyVar +local CustomLobbyOptionsPanel = ClassUI(Bitmap) { + + ---@param self UICustomLobbyOptionsPanel + ---@param parent Control + __init = function(self, parent) + Bitmap.__init(self, parent) + self:SetSolidColor(Debug and '303080ff' or '00000000') + self:DisableHitTest() + + self.Trash = TrashBag() + self.Ready = false + self.HideDefaults = true + self.Scrollbar = false + + self.HideDefaultsToggle = UIUtil.CreateCheckbox(self, '/CHECKBOX/', "Hide defaults", true, 13) + self.HideDefaultsToggle:SetCheck(self.HideDefaults, true) + self.HideDefaultsToggle.OnCheck = function(control, checked) + self.HideDefaults = checked + self:Refresh() + end + Tooltip.AddControlTooltipManual(self.HideDefaultsToggle, "Hide defaults", + "Show only the options that have been changed from their default value.") + + self.OptionsGrid = Grid(self, GridContentWidth, RowHeight) + self.Empty = UIUtil.CreateText(self, "All options at default", 13, UIUtil.bodyFont) + self.Empty:SetColor('ff8a909a') + self.Empty:DisableHitTest() + self.Empty:Hide() + + -- the panel is created/destroyed with its tab, so it's always the live/visible panel while + -- it exists — the observer just rebuilds (Refresh is Ready-gated); no show/hide juggling. One + -- subscription: the derived model already joins scenario + mods + values into the bundle. + self.OptionsObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyOptionsDerivedModel.GetOptionsVar(), function(lazy) lazy(); self:Refresh() end)) + end, + + ---@param self UICustomLobbyOptionsPanel + __post_init = function(self, parent) + Layouter(self):Fill(parent):End() + Layouter(self.HideDefaultsToggle):AtLeftIn(self, 6):AtTopIn(self, 4):End() + Layouter(self.OptionsGrid) + :AtLeftIn(self, 6):Width(GridContentWidth) + :AnchorToBottom(self.HideDefaultsToggle, 6):AtBottomIn(self, 4) + :End() + Layouter(self.Empty):AtHorizontalCenterIn(self.OptionsGrid):AtTopIn(self.OptionsGrid, 8):End() + end, + + --- Builds the grid's scrollbar + does the first render. Called by the tabs container after it + --- has sized the panel (the grid needs a concrete height — three-phase init, /lua/ui/CLAUDE.md § 1). + ---@param self UICustomLobbyOptionsPanel + Initialize = function(self) + self.Ready = true + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.OptionsGrid) + UIUtil.ForwardWheelToScroll(self.OptionsGrid, self.OptionsGrid) + self:Refresh() + end, + + --- Rebuilds the read-only options grid from the derived model's categories, with the hide-defaults + --- filter applied. The bundle is already split + enriched, so the panel only filters + lays out. + ---@param self UICustomLobbyOptionsPanel + Refresh = function(self) + if not self.Ready then + return + end + local options = CustomLobbyOptionsDerivedModel.GetOptions() + + local rows = {} + for _, category in options.Categories do + local visible = {} + for _, option in category.Options do + if not (self.HideDefaults and option.IsDefault) then + table.insert(visible, option) + end + end + if table.getn(visible) > 0 then + table.insert(rows, { Header = category.Title }) + for _, option in visible do + table.insert(rows, { Option = option }) + end + end + end + + self.OptionsGrid:DeleteAndDestroyAll(true) + if table.getn(rows) > 0 then + self.Empty:Hide() + self.OptionsGrid:AppendCols(1, true) + self.OptionsGrid:AppendRows(table.getn(rows), true) + for index, row in rows do + local control = row.Header and self:CreateSectionHeader(row.Header) or self:CreateOptionRow(row.Option) + self.OptionsGrid:SetItem(control, 1, index, true) + end + self.OptionsGrid:EndBatch() + else + self.Empty:Show() + end + self:UpdateScrollbar() + end, + + --- Builds a section header row (Lobby / Scenario / Mods) with a thin underline. + ---@param self UICustomLobbyOptionsPanel + ---@param title string + ---@return Group + CreateSectionHeader = function(self, title) + local row = Group(self.OptionsGrid) + LayoutHelpers.SetDimensions(row, GridContentWidth, RowHeight) + + local label = UIUtil.CreateText(row, string.upper(title), 12, UIUtil.titleFont) + label:SetColor('ff8a909a') + label:DisableHitTest() + Layouter(label):AtLeftIn(row, 2):AtVerticalCenterIn(row):End() + + local line = Bitmap(row) + line:SetSolidColor('ff3a4048') + line:DisableHitTest() + Layouter(line):AtLeftIn(row, 2):AtRightIn(row, 2):AtBottomIn(row):Height(1):End() + + return row + end, + + --- Builds one read-only option row from an enriched option: origin marker (special only) + label + --- (help as a tooltip) + current value. Everything is pre-resolved on the bundle. + ---@param self UICustomLobbyOptionsPanel + ---@param option UICustomLobbyOption + ---@return Group + CreateOptionRow = function(self, option) + local row = Group(self.OptionsGrid) + LayoutHelpers.SetDimensions(row, GridContentWidth, RowHeight) + + local labelLeft = 4 + if option.Origin then + local marker = Bitmap(row) + marker:SetSolidColor(SpecialColor) + Layouter(marker):AtLeftIn(row, 4):AtVerticalCenterIn(row):Width(8):Height(8):End() + local prefix = (option.Origin.Kind == 'scenario') and "Map: " or "Mod: " + Tooltip.AddControlTooltipManual(marker, "Source", prefix .. option.Origin.Name) + labelLeft = 18 + end + + local label = UIUtil.CreateText(row, Truncate(option.Label, LabelMaxChars), 13, UIUtil.bodyFont) + label:SetColor(option.Origin and SpecialColor or NormalColor) + -- the option's help reads as a tooltip on its label (hit-test stays on for that); no help → inert + if option.Help then + Tooltip.AddControlTooltipManual(label, option.Label, option.Help) + else + label:DisableHitTest() + end + Layouter(label):AtLeftIn(row, labelLeft):AtVerticalCenterIn(row):End() + + local value = UIUtil.CreateText(row, Truncate(option.ValueText, ValueMaxChars), 13, UIUtil.bodyFont) + value:SetColor(ValueColor) + -- the chosen value's help reads as a tooltip (titled with the full value text, in case it was + -- truncated); no help → inert + if option.ValueHelp then + Tooltip.AddControlTooltipManual(value, option.ValueText, option.ValueHelp) + else + value:DisableHitTest() + end + Layouter(value):AtRightIn(row, 4):AtVerticalCenterIn(row):End() + + return row + end, + + --- Shows the scrollbar only when the grid overflows. + ---@param self UICustomLobbyOptionsPanel + UpdateScrollbar = function(self) + if not self.Scrollbar then + return + end + if self.OptionsGrid:IsScrollable("Vert") then + self.Scrollbar:Show() + else + self.Scrollbar:Hide() + end + end, + + ---@param self UICustomLobbyOptionsPanel + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyOptionsPanel +Create = function(parent) + return CustomLobbyOptionsPanel(parent) +end diff --git a/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua b/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua new file mode 100644 index 00000000000..48ba3244bbd --- /dev/null +++ b/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua @@ -0,0 +1,185 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The Units tab panel of the config interface: a **read-only** view of the active unit +-- restrictions. A config-interface tab panel — the host creates it when the Units tab is selected +-- and destroys it on switch, and calls `Initialize` after sizing it (same interface as the others). +-- +-- It reads the **restrictions derived model** (the active restrictions, each already enriched with its +-- preset name / icon / tooltip) and lists each one with its preset icon + name. Editing happens in the +-- host-only `CustomLobbyUnitSelect` dialog behind this tab's config gear (see CustomLobbyConfigInterface). + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Grid = import("/lua/maui/grid.lua").Grid + +local CustomLobbyRestrictionsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyrestrictionsderivedmodel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- taller rows than a plain text list so each restriction's preset icon reads clearly +local RowHeight = 34 +local IconSize = 26 +local ScrollbarGap = 32 +local LabelColor = 'ffc8ccd0' +local DimColor = 'ff8a909a' + +---@class UICustomLobbyUnitsPanel : Group +---@field Trash TrashBag +---@field Grid Grid +---@field Scrollbar Scrollbar | false +---@field Empty Text +---@field Ready boolean +---@field RestrictionsObserver LazyVar +local CustomLobbyUnitsPanel = ClassUI(Group) { + + ---@param self UICustomLobbyUnitsPanel + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyUnitsPanel") + + self.Trash = TrashBag() + self.Ready = false + self.Scrollbar = false + + -- a single-column list: the column width is nominal (rows bind their own Width to the grid), + -- only RowHeight drives the vertical layout / scroll. Grid scales these itself (pass unscaled). + self.Grid = Grid(self, 200, RowHeight) + + self.Empty = UIUtil.CreateText(self, "No unit restrictions.", 14, UIUtil.bodyFont) + self.Empty:SetColor(DimColor) + self.Empty:DisableHitTest() + self.Empty:Hide() + + -- gated behind Ready so the immediate fire on creation (hot-reload, model already populated) + -- doesn't rebuild rows before the parent has sized us — see ../CLAUDE.md layout gotchas + self.RestrictionsObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyRestrictionsDerivedModel.GetRestrictionsVar(), function(restrictionsLazy) + restrictionsLazy() + if self.Ready then + self:Refresh() + end + end)) + end, + + ---@param self UICustomLobbyUnitsPanel + __post_init = function(self) + Layouter(self.Grid):AtLeftIn(self, 6):AtTopIn(self, 6):AtBottomIn(self, 6):End() + self.Grid.Right:Set(function() return self.Right() - LayoutHelpers.ScaleNumber(ScrollbarGap) end) + Layouter(self.Empty):AtHorizontalCenterIn(self):AtTopIn(self, 16):End() + end, + + --- Three-phase init: the tabs container calls this after sizing the panel (the Grid needs a + --- concrete height). Builds the scrollbar and renders the first list. + ---@param self UICustomLobbyUnitsPanel + Initialize = function(self) + self.Ready = true + if not self.Scrollbar then + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Grid) + UIUtil.ForwardWheelToScroll(self.Grid, self.Grid) + end + self:Refresh() + end, + + --- Rebuilds the list of active restrictions (icon + name) from the derived model. + ---@param self UICustomLobbyUnitsPanel + Refresh = function(self) + local restrictions = CustomLobbyRestrictionsDerivedModel.GetRestrictions() + local items = restrictions.Items + + self.Grid:DeleteAndDestroyAll(true) + + if restrictions.Count == 0 then + self.Empty:Show() + self:UpdateScrollbar() + return + end + self.Empty:Hide() + + self.Grid:AppendCols(1, true) + self.Grid:AppendRows(restrictions.Count, true) + for row, item in items do + self.Grid:SetItem(self:CreateRow(item), 1, row, true) + end + self.Grid:EndBatch() + self:UpdateScrollbar() + end, + + --- Builds one read-only row: the restriction's preset icon + name (the icon's tooltip is the + --- preset's description). Private. + ---@param self UICustomLobbyUnitsPanel + ---@param item UICustomLobbyRestriction + ---@return Group + CreateRow = function(self, item) + local row = Group(self.Grid) + LayoutHelpers.SetDimensions(row, 10, RowHeight) + row.Width:Set(function() return self.Grid.Width() end) + + local labelLeft = 4 + if item.Icon then + local icon = Bitmap(row) + icon:SetTexture(item.Icon) + Layouter(icon):AtLeftIn(row, 4):AtVerticalCenterIn(row):Width(IconSize):Height(IconSize):End() + if item.Tooltip then + Tooltip.AddControlTooltipManual(icon, item.Name, item.Tooltip) + else + icon:DisableHitTest() + end + labelLeft = 4 + IconSize + 8 + end + + local label = UIUtil.CreateText(row, item.Name, 13, UIUtil.bodyFont) + label:SetColor(LabelColor) + label:DisableHitTest() + Layouter(label):AtLeftIn(row, labelLeft):AtVerticalCenterIn(row):End() + return row + end, + + --- Shows the scrollbar only when the grid actually overflows. + ---@param self UICustomLobbyUnitsPanel + UpdateScrollbar = function(self) + if not self.Scrollbar then + return + end + if self.Grid:IsScrollable("Vert") then + self.Scrollbar:Show() + else + self.Scrollbar:Hide() + end + end, + + ---@param self UICustomLobbyUnitsPanel + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyUnitsPanel +Create = function(parent) + return CustomLobbyUnitsPanel(parent) +end diff --git a/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md b/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md new file mode 100644 index 00000000000..1d6ae21f212 --- /dev/null +++ b/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md @@ -0,0 +1,125 @@ +# Session teardown via `Destroyable` singletons + a main trash bag + +Status: **in progress** — the map catalog is converted as the worked example; the rest of the +rollout is pending. This doc captures the pattern, the recipe, and the open decisions so we can +resume later. + +## Problem + +The custom lobby runs in the **persistent front-end Lua state**. That state is *not* reset when a +game launches in its own state. So anything we leave reachable after launch (or after leaving the +lobby) — a running `ForkThread`, a cached table, a model singleton — leaks for the **whole match**. +We need exactly one call that frees everything lobby-scoped. + +A previous attempt used a strong list of teardown callbacks because `TrashBag` is **weak-valued** +(`__mode = 'v'`, see [`/lua/system/trashbag.lua`](/lua/system/trashbag.lua)) and so cannot hold a +bare `{ Destroy = fn }` disposable (it would be GC'd before teardown). That work was never committed. + +## The pattern + +Make each lobby-scoped resource a **real object** — a `ClassSimple` that implements `Destroy` +(the `Destroyable` interface) — and register it in **one session-lifetime `TrashBag`**. Then a +single `bag:Destroy()` frees the lot. + +The weak-bag objection disappears: a weak bag only fails to hold things that nothing else +references. Each singleton is **pinned by its own module-level `Instance` local**, so the bag can +still reach it at teardown but is never the reason it survives GC. Make resources real objects, +pin them in their module local, drop them in the bag. + +### The owner: `CustomLobbySession.lua` + +A tiny module owning one session-lifetime bag: + +- `GetTrash()` — hands every owner the same bag (lazily created). +- `Teardown()` — `bag:Destroy()` then start a fresh bag; idempotent. + +`Teardown()` is called at three lifecycle points (all wired): + +- top of `CreateLobby` (clean slate before building a new session), +- the leave path (escape handler in [`/lua/ui/lobby/lobby.lua`](/lua/ui/lobby/lobby.lua)), +- `OnGameLaunched` in [`CustomLobbyController.lua`](../CustomLobbyController.lua). + +### The recipe (per resource) + +1. Wrap the module state in a `ClassSimple` with an `__init` and a `Destroy`. +2. Forward-declare `local Instance` **above** the class so the class methods capture it as an + upvalue (not a global — this trips `undefined-global` otherwise). +3. `Destroy`: idempotent (guard with a `Destroyed` flag), release real resources (kill threads, + destroy the instance's own `Trash`), then `if Instance == self then Instance = nil end` so the + next session rebuilds fresh. +4. `GetSingleton()` creates on first use and `CustomLobbySession.GetTrash():Add(Instance)` + (self-registration), so it re-registers in each new session's bag. +5. Keep the public module functions as **thin facades** over the singleton so callers don't change + (decision below). + +### Worked example: the map catalog + +[`mapselect/CustomLobbyMapCatalog.lua`](../mapselect/CustomLobbyMapCatalog.lua) is the reference +implementation. `UICustomLobbyMapCatalog : Destroyable`: + +- `Destroy()` kills the in-flight load thread, drops the caches, destroys its own `Trash` (which + frees the `Scenarios` LazyVar), and nils the module singleton. +- The load thread is tracked on `self.Worker` (**not** in the trash) so `Refresh()` can kill it + while keeping the **same** `Scenarios` LazyVar — replacing the LazyVar would orphan the dialog's + `Derive` subscribers. +- The module functions (`LoadInfo`, `LoadSave`, `GetScenarios`, …) are thin facades; the five call + sites are untouched. + +## Open decisions (settle before converting the interface) + +1. **Teardown ordering — the main risk.** A flat `TrashBag` destroys in arbitrary order + (`for k, trash in self`). Fine while resources are independent (today). But once the + **interface** and the **models** are both in the bag, the UI's `Derive` observers point at the + models' LazyVars. If a model is destroyed before the UI, an observer could fire into freed + state. *Probably* safe — destroying a control empties its own trash, severing its observers, so + the dependency edge is cut from whichever side dies first — but the old design used LIFO + deliberately. **Decide:** either prove edge-severing makes order irrelevant, or keep the + interface out of the shared bag and have it own its teardown (registering only a "nil my + singleton" disposable). + +2. **Facade: permanent or migration shim?** The catalog now has two surfaces — `Catalog:LoadInfo()` + (object) and module `LoadInfo()` (delegating). Keeping it stabilises call sites; dropping it + (`GetSingleton():LoadInfo()`) removes a layer but touches callers. Current lean: **keep** for + query-style services like the catalog. + +3. **✅ Resolved (implemented). Models keep free-function writers; `Destroy` is thin.** The three + models are now `ClassSimple : Destroyable` instances, but their writers stay **free functions** + (`SetPlayer(model, …)` — the documented autolobby idiom; *not* converted to methods), and `Destroy` + is **thin**: nil the module singleton and let the LazyVars GC once the views observing them are torn + down (no own `Trash` proactively freeing them — see #1, the interface isn't in the bag yet). + "Service" singletons (catalog / derived models: real methods, own `Trash` that frees its resources) + and "data" singletons (these models: free-function writers + thin `Destroy`) legitimately differ in + shape. + +### Minor wrinkles (non-blocking) + +- Self-registration gives `GetSingleton()` a first-access side effect (mutates the session bag). + Harmless given `Teardown()` is the clean-slate; a singleton touched before `CreateLobby` just gets + rebuilt. +- Hot-reload: if the load thread is mid-stream when the file is saved, the old instance lives on + (the thread is a GC root) streaming into a dead LazyVar until it finishes. Dev-only; the explicit + `Destroy` gives us a hook if it ever matters. + +## Rollout order (proposed) + +1. ✅ Map catalog (done — reference implementation). +2. ✅ **All five derived models** ([`models/derived/`](../models/derived/CLAUDE.md)) — done. Each is a + `ClassSimple : Destroyable` whose own `Trash` owns its published LazyVar(s) + its observer(s), + registered in the session bag on first access, with an idempotent `Destroy` that severs the + subscriptions and nils the module singleton; `OnReload` destroys-then-rebuilds. Per-session state + that used to be module-locals moved onto the instance: scenario `self.LoadedFile`; restrictions + `self.LoadedSignature`; mods `self.LoadedSignature` (it gained the dedup it lacked, via + `table.concatkeys`); slots `self.LoadedSignature` + `self.LoadedTeamsSignature` + `self.Observers`; + options `self.Schema` + `self.SchemaKey` (the cached schema). The set-based dedup uses stock + `utils.lua` (`table.concatkeys` / `table.sorted` + `table.concat`) — no bespoke helper. +3. ✅ The three authoritative models (Launch / Session / Local) — done. Each is a + `ClassSimple : Destroyable` registered in the session bag; per open decision #3 the **write helpers + stay free functions** and `Destroy` is **thin** — it nils the module singleton and lets the LazyVars + GC once the views observing them are torn down. (We deliberately do *not* give them an own `Trash` + that proactively destroys the LazyVars: the interface that subscribes to them isn't in the bag yet + (#5 below), so destroying them on `Teardown` could fire an observer into freed state. The thin + teardown sidesteps the ordering risk in open decision #1.) +4. The mod catalog (mirror the map catalog). +5. ~~`CustomLobbyRules` map-dimension cache.~~ — N/A: `CustomLobbyRules` is now a **pure, stateless** kernel (all inputs passed in; the cached map-dimension lookup is gone), so it holds nothing to tear down. +6. The interface + performance popover — **after** the ordering decision (#1 above). +7. Track the lobby instance in the session bag (drop the manual `Instance:Destroy()` in lobby.lua). diff --git a/lua/ui/lobby/customlobby/design/team-aware-slot-layout.md b/lua/ui/lobby/customlobby/design/team-aware-slot-layout.md new file mode 100644 index 00000000000..80916857f9b --- /dev/null +++ b/lua/ui/lobby/customlobby/design/team-aware-slot-layout.md @@ -0,0 +1,108 @@ +# Team-aware slot layout (parked) + +> **Status: parked (2026-06-22)** pending a decision on **where scenario +> information is stored** — see [Open question](#open-question). Resume once the +> scenario data model is settled. + +A re-imagining of the CustomLobby players pane so the slot arrangement reflects the +team structure, instead of every player sharing one flat list with a per-row "team" +dropdown. Your team becomes *where you are* on screen, which removes a control and +makes balance readable at a glance. + +This is part of the broader "tabs" lobby direction (player table on the left, a +single tabbed panel — Map / Options / Mods / Units — on the right, persistent chat). + +## Layout is driven by `AutoTeams` + +The layout reads the existing [`AutoTeams`](/lua/ui/lobby/lobbyOptions.lua) lobby +option (already host-authoritative and synced) — no new option needed: + +| `AutoTeams` | Meaning | Layout | Column headers | +|---|---|---|---| +| `tvsb` | Top vs Bottom | **2 columns** | `TOP` / `BOTTOM` | +| `lvsr` | Left vs Right | **2 columns** | `LEFT` / `RIGHT` | +| `pvsi` | Odd vs Even | **2 columns** | `ODD` / `EVEN` | +| `manual` | host assigns on map | **flat list** | (team column shown) | +| `none` | no auto teams | **flat list** | (team column shown) | + +The three binary auto-splits are inherently 2-team, so they map straight to two +columns. `none`/`manual` have no reliable 2-team structure, so they fall back to the +flat list. There is **no 4-team grid** — no stock `AutoTeams` mode produces four +fixed teams. (Open to letting `none` try columns when players happen to split 50/50, +but not assumed.) + +## Layouts + +### 2-column (headers follow the mode — shown here for `tvsb`) + +``` ++-------------------------------------------------+-----------------------------------------+ +| PLAYERS Top vs Bottom | [ Map ] Options Mods Units | +| +----------------------+ +----------------------+ +-----------------------------------+ | +| | TOP ~1842 | | BOTTOM ~1790 | | map preview | | +| | NL Jip * UEF ###. x | | US Sprouto AEO ##.. x| | . 2(top) . 4(bot) | | +| | DE Tagada CYB ###. . | | AI Sorian SER #### x| | . 1(top) . 3(bot) | | +| | - open - | | - open - | +-----------------------------------+ | +| | - open - | | - open - | CHAT | +| +----------------------+ +----------------------+ [Jip] hf all | +| OBSERVERS: Zock 1850 [+ obs] | > _ | ++-------------------------------------------------+-----------------------------------------+ +``` + +Headers swap with the mode: `LEFT`/`RIGHT` for `lvsr`, `ODD`/`EVEN` for `pvsi`. Each +block header shows team size + average rating, replacing the legacy "team ratings if +>2 teams" line. + +### Flat list (`none` / `manual`) + +``` +PLAYERS 6 / 8 + # CC name fac col team cpu rdy + 1 NL Jip * UEF [4] 1 ###. [x] + 2 US Sprouto AEO [1] 1 ##.. [x] + 3 DE Tagada CYB [7] 2 ###. [ ] + 4 AI Sorian SER [2] 2 #### [x] + 5 -- - open - + 6 -- - open - +``` + +The per-row team dropdown only exists here (there's no positional rule to lean on). +`manual` is the same list — the host does the actual assignment by clicking map +markers, as today. + +## Interaction model + +- In 2-column mode the per-row team dropdown disappears; the auto-team is derived + from **start position**, so the two columns literally mirror the map split. +- **Join / switch team** = take a spawn on that side: click the map, or click an + `- open -` slot in a column (which picks a free spawn there). Client → host + request; host applies. Host can drag any token (matches legacy swap/move). +- Compact cells: flag · name · faction · cpu · ready; colour swatch + full + rating/ping on hover (reuse the CPU performance popover pattern). Right-click a + cell = kick / move / →observer / AI personality. +- Observers sit in a thin strip under the blocks/list regardless of layout. + +## How it maps onto the MVC structure + +- A new **`CustomLobbyPlayersInterface`** component subscribes to `AutoTeams` + + `SlotCount` + every slot, computes the strategy (2 columns vs flat list), and + arranges the existing `CustomLobbySlotInterface` rows into team-group containers. + The slot-row component barely changes — it gets re-parented / compacted. +- `CustomLobbyInterface` shrinks to: header, this players component (left), the + tabbed panel (right), footer. +- No model changes beyond reading `AutoTeams` (already present on + `CustomLobbyLaunchModel` as its own LazyVar) and `SlotCount` (on `CustomLobbySessionModel`). + +## Open question + +**How is a card's column decided for display?** + +1. **Projected from start spot** — top-half spawns → `TOP`, etc. True to how + auto-teams actually resolve at launch and visually consistent with the map, but + needs the map's **spawn coordinates** loaded. +2. **By the `Team` field** — bucket Team 1 vs Team 2. Simple, no map dependency, but + can drift from what auto-teams will do at launch. + +Preference: **(1)** for honesty, with **(2)** as a pre-map fallback. This is exactly +why the design is parked — it depends on where scenario information (spawn data) +ends up being stored. diff --git a/lua/ui/lobby/customlobby/mapselect/CLAUDE.md b/lua/ui/lobby/customlobby/mapselect/CLAUDE.md new file mode 100644 index 00000000000..0e786b50495 --- /dev/null +++ b/lua/ui/lobby/customlobby/mapselect/CLAUDE.md @@ -0,0 +1,60 @@ +# Map select + +The custom lobby's **map-select dialog** and its supporting pieces, split into their own folder +because the dialog is a self-contained sub-MVC and grew large. Opened by the host's "Change Map" +button in [`../CustomLobbyInterface.lua`](../CustomLobbyInterface.lua). + +> Read [`../CLAUDE.md`](../CLAUDE.md) first (the lobby's MVC + the layout/init gotchas). This doc +> is map-select-specific. + +## Files + +| File | Role | +|------|------| +| [CustomLobbyMapSelect.lua](CustomLobbyMapSelect.lua) | the dialog (transient `Popup`, host-only). Laid out in **areas** (Group containers: title / left {filter, selection, stats} / preview / actions — flip the module `Debug` flag to tint them). Searchable list + size & player filters with comparison operators (`=`/`>=`/`<=`, persisted to Prefs); candidate preview (the shared `../CustomLobbyScenarioPreview` surface with numbered-dot spawns) with name overlay, an "Open page" link (allowlisted `url` → `OpenURL`), and toggle-able overlays (spawns / resources / wrecks); scrollable description; file-health check that disables Select for broken maps; Random / Select / Cancel. On Select → `CustomLobbyController.RequestSetScenario(file)`. Owns no synced state. | +| [CustomLobbyMapCatalog.lua](CustomLobbyMapCatalog.lua) | the lobby's **own** scenario data layer — and its single source of truth for what a scenario is, with **no MapUtil dependency** (the `_scenario.lua` / `_save.lua` `doscript` loaders + start-spot derivation are re-implemented here as small pure locals). Async enumeration of playable skirmish maps (streams into a `Scenarios` `LazyVar` across frames via `EnsureLoaded`; *not* `MapUtil.EnumerateSkirmishScenarios`), plus on-demand `LoadInfo` / `LoadSave` / `LoadMarkers` / `LoadOptions` (the save is memoised by path with a FIFO bound — the save `doscript` is expensive and browsing re-loads the same maps; `LoadMarkers` extracts the preview's start-spot / mass / hydro / wreck points so callers never touch the raw save; `LoadOptions` reads + validates the map's `_options.lua` schema, so the catalog is the one reader of every scenario file — `_scenario.lua` / `_save.lua` / `_options.lua`). **Reference data, never synced** — local only; the host syncs just its `ScenarioFile` choice. | +| [CustomLobbyMapList.lua](CustomLobbyMapList.lua) | the dialog's scrollable, **virtualised** list: a fixed pool of **text-only** rows (name + `size · players`) reused while scrolling, standard scrollbar contract, mouse-driven (no keyboard focus — a custom Group list can't take it). `OnSelect` / `OnConfirm`. | + +This is the first sub-dialog split out of the legacy `dialogs/mapselect.lua` god-dialog: it does +**map selection only**. Game options, mods and unit restrictions become their own components (the +options panel will derive its schema from the selected map + mods — see [`../CLAUDE.md`](../CLAUDE.md)). + +## The `MapPreview` texture leak (why the list is text-only) + +The single most important constraint in this folder. **The engine never frees the textures a +`MapPreview` loads** — and that memory is needed in-match. + +### Symptom + +With a mini `MapPreview` thumbnail per list row, scrolling a real map vault grew the process by +~30 MB (≈120 → 155 MB) and the memory **never came back** — not while the dialog stayed open, and +not after it closed. + +### What we ruled out + +`MapPreview` exposes only `SetTexture` / `SetTextureFromMap` / `ClearTexture` — no release API, and +there is no global texture-cache flush. We tried, and none of these freed the memory: + +- re-using one preview control per row and re-calling `SetTextureFromMap` (re-texture); +- **destroying and recreating** the `MapPreview` control per scenario; +- calling **`ClearTexture()`** before destroy, and on every row teardown + on dialog close. + +The textures are cached engine-side by name, independent of any control's lifetime. Nothing we can +call from Lua releases them. + +### Resolution + +**Don't put a `MapPreview` in a list row.** The list is text-only; only the dialog's **single +candidate preview** loads a texture — once per selection. Total live previews ≈ 1, so the leak is +bounded to a trickle instead of one-per-row-scrolled. + +The few small overlay icons (mass / hydro / wreck) load their texture **once** each into hidden +template bitmaps in `__init` and every marker shares it via `ShareTextures` — so they don't multiply +either. (Those templates are locked hidden with an `OnHide` returning `true`, the `TexturePool` +trick, so a parent `Show()` can't reveal an unpositioned bitmap.) + +### Rule of thumb + +A `MapPreview` (or any `Bitmap` via `SetNewTexture`) loading a **distinct, per-item** texture is a +leak in disguise. Render at most a handful, total — never one per list row / pool slot. If you need +many thumbnails, you need a different approach than `MapPreview`. diff --git a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua new file mode 100644 index 00000000000..32ad5071372 --- /dev/null +++ b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua @@ -0,0 +1,605 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The map catalog: the custom lobby's list of playable skirmish maps. +-- +-- This is the custom lobby's OWN scenario layer — it does **not** depend on MapUtil at all. It +-- neither calls `EnumerateSkirmishScenarios` (which eagerly loads each map's options + strings and +-- blocks while it reads the whole `/maps` tree) nor borrows MapUtil's file loaders: the `doscript` +-- loaders for `_scenario.lua` / `_save.lua` and the start-spot derivation are re-implemented below +-- (they are tiny), so the catalog is the lobby's single source of truth for what a scenario is. +-- +-- Two differences from the legacy enumerator: +-- * **lighter** — we load only the scenario *info* (name / map / preview / size / +-- Configurations), not its `_options.lua` / `_strings.lua`. The list, preview and info +-- panel need nothing more; the options schema is loaded separately by the options slice. +-- * **async** — maps stream in across frames rather than blocking the UI on open. The list +-- is a `LazyVar` that re-fires as each batch lands, so the dialog can show a live +-- "N maps loaded" count and fill in progressively. +-- +-- This stays *reference data*, never on the wire: the LazyVar gives local reactivity (progress), +-- not host-dictated sync. Only the host's *choice* (`ScenarioFile`, in the launch model) syncs; +-- every peer enumerates its own disk. See CLAUDE.md and the `customlobby-model-choice` skill. +-- +-- LIFETIME. The catalog is a `ClassSimple` singleton implementing `Destroyable`, registered in the +-- session trash bag (see CustomLobbySession) the moment it is created. So one `CustomLobbySession +-- .Teardown()` kills its streaming load thread and drops its cached map list / save cache, instead +-- of pinning them in the persistent front-end Lua state for the whole match. The module-level +-- functions below are thin facades over the singleton, so callers are unaffected by the object +-- shape. This is the worked example for the broader singleton rework — the models and the other +-- catalog follow the same pattern. + +local Create = import("/lua/lazyvar.lua").Create +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") + +--- The *interesting* bits extracted from a scenario's save — what the preview overlays and the +--- rules read, so nothing downstream has to know the raw save's shape. Points are 2-element `{x, z}` +--- map coordinates (ogrids). Produced by `LoadMarkers`. +---@class UICustomLobbyScenarioMarkers +---@field Spawns Vector2[] # start spots, one {x, z} per army (army order) +---@field MassPoints Vector2[] # mass deposit positions +---@field HydroPoints Vector2[] # hydrocarbon deposit positions +---@field Wrecks Vector2[] # prebuilt-wreckage positions (maps that define any) + +-- maps loaded per frame-slice before yielding — keeps the open responsive on big vaults +local BatchSize = 5 + +-- save files memoised before the FIFO bound kicks in — the save `doscript` is expensive and +-- browsing re-loads the same maps +local SaveCacheMax = 24 + +-- The catalog singleton. Forward-declared here (before the class) so the class methods capture it +-- as an upvalue rather than resolving it as a global. Assigned in `GetSingleton`, cleared in +-- `Destroy`. +---@type UICustomLobbyMapCatalog | nil +local Instance = nil + +------------------------------------------------------------------------------- +--#region Scenario file loaders (pure; no instance state) +-- +-- Our own `doscript` loaders, so the catalog doesn't depend on MapUtil. A scenario's files are Lua +-- scripts that, when run, populate globals (`ScenarioInfo`, `Scenario`, …); we run them into a fresh +-- sandbox table seeded with `dataInit` rather than into `_G`. + +--- Runs a scenario-family file (`_scenario.lua` / `_save.lua`) into a fresh sandbox table, so its +--- globals land there instead of polluting `_G`. Returns the populated table, or nil if the file is +--- absent. The `doscript`s are the expensive part — callers cache the results. +---@param path FileName +---@return table | nil +local function LoadScenarioFile(path) + if not DiskGetFileInfo(path) then + return nil + end + local data = {} + doscript('/lua/dataInit.lua', data) + doscript(path, data) + return data +end + +--- Loads a scenario's info from its `_scenario.lua` (the `ScenarioInfo` table), handling the legacy +--- v1 shape where the info lived at the top level. nil if the file is missing. +---@param path FileName +---@return UIScenarioInfoFile | nil +local function LoadScenarioInfoFile(path) + local data = LoadScenarioFile(path) + if not data then + return nil + end + if data.version == 1 then -- legacy: the info table WAS the top-level data + return data --[[@as UIScenarioInfoFile]] + end + return data.ScenarioInfo +end + +--- Loads a scenario's save (the `Scenario` table) from its `_save.lua`. nil if the file is missing. +--- Expensive (`doscript`) — `LoadSave` memoises the result. +---@param path FileName +---@return UIScenarioSaveFile | nil +local function LoadScenarioSaveFile(path) + local data = LoadScenarioFile(path) + return data and data.Scenario +end + +--- The playable armies of a scenario (`{ 'ARMY_1', 'ARMY_2', … }`), or nil when malformed. Reads the +--- `standard` FFA team config — the same shape `IsPlayableSkirmish` requires for a map to be listed. +---@param scenarioInfo UIScenarioInfoFile +---@return string[] | nil +local function GetArmies(scenarioInfo) + local standard = scenarioInfo.Configurations and scenarioInfo.Configurations.standard + local teams = standard and standard.teams + if not teams then + return nil + end + for _, teamConfig in teams do + if teamConfig.name == 'FFA' then + return teamConfig.armies + end + end + return nil +end + +--- The start spots of a scenario as `{x, z}` per army (army order), read from the save's master-chain +--- markers (each army has a like-named marker). nil when armies / markers are missing; a `{0, 0}` +--- placeholder keeps the array aligned with the army list for any army that lacks a marker. +---@param scenarioInfo UIScenarioInfoFile +---@param scenarioSave UIScenarioSaveFile +---@return Vector2[] | nil +local function GetStartPositions(scenarioInfo, scenarioSave) + local armies = GetArmies(scenarioInfo) + if not armies then + return nil + end + local masterChain = scenarioSave.MasterChain and scenarioSave.MasterChain['_MASTERCHAIN_'] + local markers = masterChain and masterChain.Markers + if not markers then + return nil + end + local output = {} + for _, army in armies do + local marker = markers[army] + if marker and marker.position then + table.insert(output, { marker.position[1], marker.position[3] }) + else + table.insert(output, { 0, 0 }) + end + end + return output +end + +--- The path to a scenario's `_options.lua`, derived from its `_scenario.lua` path — the reference +--- isn't stored in the info file. Mirrors MapUtil: strip the trailing "scenario.lua" and append +--- "options.lua" (`/maps/x/x_scenario.lua` -> `/maps/x/x_options.lua`). +---@param scenarioFile FileName +---@return FileName +local function OptionsPathFor(scenarioFile) + return string.sub(scenarioFile, 1, string.len(scenarioFile) - string.len("scenario.lua")) .. "options.lua" +end + +--- Repairs malformed `default` indices in an options list *in place* (a `default` is a 1-based index +--- into `values`, not a value). The common mistake is storing the wanted value-key as the default; we +--- recover by finding that key's index. Untrusted disk data, so callers pcall this. +---@param options ScenarioOption[] +local function ValidateOptions(options) + for _, option in options do + local values = option.values + if type(values) == "table" then + local default = option.default + if type(default) ~= "number" or default <= 0 or default > table.getn(values) then + local replacement = 1 + for index, value in values do + local valueKey = (type(value) == "table") and value.key or value + if valueKey == default then + replacement = index + break + end + end + option.default = replacement + end + end + end +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Enumeration helpers (pure; no instance state) + +--- A scenario is listable if it's a skirmish map with at least one start spot. +---@param scenario UIScenarioInfoFile +---@return boolean +local function IsPlayableSkirmish(scenario) + if scenario.type ~= "skirmish" then + return false + end + local standard = scenario.Configurations and scenario.Configurations.standard + local teams = standard and standard.teams + local first = teams and teams[1] + return first ~= nil and first.armies ~= nil +end + +---@param a UILobbyScenarioInfo +---@param b UILobbyScenarioInfo +---@return boolean +local function SortByName(a, b) + return string.upper(a.name or "") < string.upper(b.name or "") +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Catalog class + +--- The lobby's map catalog. One per lobby session, owned by the session trash bag. +---@class UICustomLobbyMapCatalog : Destroyable +---@field Trash TrashBag # owns the Scenarios LazyVar (freed on Destroy) +---@field Scenarios LazyVar # growing list; re-fired (new table ref) as batches land +---@field Worker thread | nil # the in-flight enumeration thread, if any +---@field Loading boolean # a load thread is currently running +---@field Loaded boolean # the disk has been fully enumerated +---@field SaveCache table # memoised saves keyed by lowercased path; false = known-missing +---@field SaveCacheOrder string[] # FIFO order of SaveCache keys, bounded by SaveCacheMax +---@field Destroyed boolean +local Catalog = ClassSimple { + + ---@param self UICustomLobbyMapCatalog + __init = function(self) + self.Trash = TrashBag() + self.Scenarios = self.Trash:Add(Create({})) + self.Worker = nil + self.Loading = false + self.Loaded = false + self.SaveCache = {} + self.SaveCacheOrder = {} + self.Destroyed = false + end, + + --- Publishes a fresh (sorted) copy of the accumulator so dependents go dirty. + ---@param self UICustomLobbyMapCatalog + ---@param accumulator UILobbyScenarioInfo[] + Publish = function(self, accumulator) + local snapshot = table.copy(accumulator) + table.sort(snapshot, SortByName) + self.Scenarios:Set(snapshot) + end, + + --- Enumerates `/maps` across frames, loading each map's info and streaming the playable + --- skirmish maps into the `Scenarios` LazyVar in batches. + --- TODO: mod maps (legacy also scanned `mods.AllSelectableMods()`); skipped for now. + ---@param self UICustomLobbyMapCatalog + LoadThread = function(self) + local files = DiskFindFiles('/maps', '*_scenario.lua') + + local accumulator = {} + local seen = 0 + for _, file in files do + local scenario = LoadScenarioInfoFile(file) + if scenario and IsPlayableSkirmish(scenario) then + scenario.file = file + table.insert(accumulator, scenario) + end + + seen = seen + 1 + if math.mod(seen, BatchSize) == 0 then + self:Publish(accumulator) + WaitFrames(5) + end + end + + self.Loaded = true + self.Loading = false + self.Worker = nil + self:Publish(accumulator) + end, + + --- Kicks off the async enumeration if it hasn't run yet. Idempotent — safe to call on every + --- dialog open; once loaded it's a no-op and the cached list stays. + ---@param self UICustomLobbyMapCatalog + EnsureLoaded = function(self) + if self.Loading or self.Loaded then + return + end + self.Loading = true + self.Scenarios:Set({}) + -- tracked on `self` (not the trash) so Refresh can kill it without dropping the LazyVar + self.Worker = ForkThread(self.LoadThread, self) + end, + + --- The current (possibly partial) list of maps. + ---@param self UICustomLobbyMapCatalog + ---@return UILobbyScenarioInfo[] + GetScenarios = function(self) + return self.Scenarios() + end, + + --- How many maps are currently loaded. + ---@param self UICustomLobbyMapCatalog + ---@return number + GetCount = function(self) + return table.getn(self.Scenarios()) + end, + + --- Whether the disk has been fully enumerated (vs. still streaming). + ---@param self UICustomLobbyMapCatalog + ---@return boolean + IsLoaded = function(self) + return self.Loaded + end, + + --- Finds the loaded map whose file path matches `scenarioFile` (case-insensitive), or nil. + ---@param self UICustomLobbyMapCatalog + ---@param scenarioFile FileName | false + ---@return UILobbyScenarioInfo | nil + FindByFile = function(self, scenarioFile) + if not scenarioFile then + return nil + end + local target = string.lower(scenarioFile) + for _, scenario in self.Scenarios() do + if string.lower(scenario.file) == target then + return scenario + end + end + return nil + end, + + --- Loads a scenario's info for `scenarioFile`. Returns the already-enumerated entry if we have + --- it (the streamed list is the info cache), else reads it from disk. nil if unreadable. + ---@param self UICustomLobbyMapCatalog + ---@param scenarioFile FileName | false + ---@return UILobbyScenarioInfo | nil + LoadInfo = function(self, scenarioFile) + if not scenarioFile then + return nil + end + local cached = self:FindByFile(scenarioFile) + if cached then + return cached + end + local info = LoadScenarioInfoFile(scenarioFile) + if not info then + return nil + end + info.file = scenarioFile + return info --[[@as UILobbyScenarioInfo]] + end, + + --- Loads (and caches) a scenario's save file — the marker data the preview overlays need. + --- The save `doscript` is expensive, and both the in-lobby preview and the picker re-load the + --- same maps repeatedly, so results are memoised by save path with a small FIFO bound. Returns + --- nil if the save is missing / unreadable (cached as a negative so we don't retry the disk). + ---@param self UICustomLobbyMapCatalog + ---@param scenarioInfo UILobbyScenarioInfo + ---@return UIScenarioSaveFile | nil + LoadSave = function(self, scenarioInfo) + if not (scenarioInfo and scenarioInfo.save) then + return nil + end + + local key = string.lower(scenarioInfo.save) + local cached = self.SaveCache[key] + if cached ~= nil then + return cached or nil -- `false` = known-missing + end + + ---@type UIScenarioSaveFile | false + local save = LoadScenarioSaveFile(scenarioInfo.save) or false + + self.SaveCache[key] = save + table.insert(self.SaveCacheOrder, key) + if table.getn(self.SaveCacheOrder) > SaveCacheMax then + local oldest = table.remove(self.SaveCacheOrder, 1) + self.SaveCache[oldest] = nil + end + + return save or nil + end, + + --- Extracts the *interesting* bits of a scenario's save — the things the preview overlays and the + --- rules actually read — so callers never touch the raw save shape. Loads the (memoised) save once + --- and returns the start spots plus mass / hydrocarbon / wreck point lists, each normalised to a + --- 2-element `{x, z}` (the save's resource markers carry a 3D `position`, so we take `[1]` and + --- `[3]`). Always returns a table — empty lists when the save is missing / unreadable — so callers + --- can read its fields unguarded. + ---@param self UICustomLobbyMapCatalog + ---@param scenarioInfo UILobbyScenarioInfo + ---@return UICustomLobbyScenarioMarkers + LoadMarkers = function(self, scenarioInfo) + ---@type UICustomLobbyScenarioMarkers + local markers = { Spawns = {}, MassPoints = {}, HydroPoints = {}, Wrecks = {} } + + local save = self:LoadSave(scenarioInfo) + if not save then + return markers + end + + -- start spots: one {x, z} per army, in army order + markers.Spawns = GetStartPositions(scenarioInfo, save) or {} + + -- resource + wreck deposits: walk the master-chain markers once, bucket by type + local masterChain = save.MasterChain and save.MasterChain['_MASTERCHAIN_'] + local raw = (masterChain and masterChain.Markers) or {} + for _, marker in raw do + local position = marker.position + if position then + if marker.type == "Mass" then + table.insert(markers.MassPoints, { position[1], position[3] }) + elseif marker.type == "Hydrocarbon" then + table.insert(markers.HydroPoints, { position[1], position[3] }) + elseif marker.type and string.find(string.lower(marker.type), 'wreck') then + table.insert(markers.Wrecks, { position[1], position[3] }) + end + end + end + + return markers + end, + + --- Loads (and validates) a scenario's own lobby options from its `_options.lua` — the option + --- *schema* the map contributes (separate from the synced option values). Most maps declare none → + --- an empty list. The load + validate are pcall'd (untrusted disk `doscript` / malformed options). + --- Not cached here — the only caller (the options derived model) caches the gathered schema itself. + ---@param self UICustomLobbyMapCatalog + ---@param scenarioFile FileName | false + ---@return ScenarioOption[] + LoadOptions = function(self, scenarioFile) + if not scenarioFile then + return {} + end + local ok, options = pcall(function() + local data = LoadScenarioFile(OptionsPathFor(scenarioFile)) + return data and data.options + end) + if not ok or type(options) ~= "table" then + return {} + end + pcall(ValidateOptions, options) + return options + end, + + --- Drops everything so the next `EnsureLoaded` re-reads from disk (e.g. maps changed on disk). + --- Kills any in-flight load thread and resets the cached list *in place* (same LazyVar) so + --- existing subscribers stay valid — this is a reset, not a teardown. + ---@param self UICustomLobbyMapCatalog + Refresh = function(self) + if self.Worker then + KillThread(self.Worker) + self.Worker = nil + end + self.Loaded = false + self.Loading = false + self.Scenarios:Set({}) + self.SaveCache = {} + self.SaveCacheOrder = {} + end, + + --- `Destroyable`: kill the load thread and drop the cached list + save cache. Called by the + --- session trash on `Teardown()`. Idempotent. Clears the module singleton so the next access + --- rebuilds a fresh catalog (and re-registers it in the next session's trash). + ---@param self UICustomLobbyMapCatalog + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + if self.Worker then + KillThread(self.Worker) + self.Worker = nil + end + self.Loaded = false + self.Loading = false + self.SaveCache = {} + self.SaveCacheOrder = {} + self.Trash:Destroy() -- frees the Scenarios LazyVar + if Instance == self then + Instance = nil + end + end, +} + +--#endregion + +------------------------------------------------------------------------------- +--#region Singleton + facade +-- +-- The catalog data outlives any single map-select dialog (it's cached across opens) but dies with +-- the lobby session. So the singleton is created on first use and registered in the session trash; +-- `CustomLobbySession.Teardown()` destroys it. The module functions are thin facades so the five +-- call sites keep using `CustomLobbyMapCatalog.LoadInfo(file)` etc. unchanged. + +--- Returns the catalog singleton, creating (and registering it in the session trash) on first +--- access — including after a teardown, so the registry is reusable across lobby sessions. +---@return UICustomLobbyMapCatalog +function GetSingleton() + if not Instance then + Instance = Catalog() + CustomLobbySession.GetTrash():Add(Instance) + end + return Instance +end + +--- Kicks off the async enumeration if it hasn't run yet. +function EnsureLoaded() + GetSingleton():EnsureLoaded() +end + +--- The maps LazyVar — subscribe to it (via `Derive`) to react as the list streams in. +---@return LazyVar +function GetScenariosVar() + return GetSingleton().Scenarios +end + +--- The current (possibly partial) list of maps. +---@return UILobbyScenarioInfo[] +function GetScenarios() + return GetSingleton():GetScenarios() +end + +--- How many maps are currently loaded. +---@return number +function GetCount() + return GetSingleton():GetCount() +end + +--- Whether the disk has been fully enumerated (vs. still streaming). +---@return boolean +function IsLoaded() + return GetSingleton():IsLoaded() +end + +--- Finds the loaded map whose file path matches `scenarioFile` (case-insensitive), or nil. +---@param scenarioFile FileName | false +---@return UILobbyScenarioInfo | nil +function FindByFile(scenarioFile) + return GetSingleton():FindByFile(scenarioFile) +end + +--- Loads a scenario's info for `scenarioFile`. +---@param scenarioFile FileName | false +---@return UILobbyScenarioInfo | nil +function LoadInfo(scenarioFile) + return GetSingleton():LoadInfo(scenarioFile) +end + +--- Loads (and caches) a scenario's save file — the marker data the preview overlays need. +---@param scenarioInfo UILobbyScenarioInfo +---@return UIScenarioSaveFile | nil +function LoadSave(scenarioInfo) + return GetSingleton():LoadSave(scenarioInfo) +end + +--- Extracts a scenario's interesting save markers (spawns + mass / hydro / wreck points) so callers +--- read structured points instead of fishing through the raw save. See the method. +---@param scenarioInfo UILobbyScenarioInfo +---@return UICustomLobbyScenarioMarkers +function LoadMarkers(scenarioInfo) + return GetSingleton():LoadMarkers(scenarioInfo) +end + +--- Loads (and validates) a scenario's own lobby options from its `_options.lua`. See the method. +---@param scenarioFile FileName | false +---@return ScenarioOption[] +function LoadOptions(scenarioFile) + return GetSingleton():LoadOptions(scenarioFile) +end + +--- Drops everything so the next `EnsureLoaded` re-reads from disk (e.g. maps changed on disk). +function Refresh() + GetSingleton():Refresh() +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-imports this module after a couple of frames. The cache is just a +--- perf optimisation, so letting it rebuild on the next access is harmless. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapList.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapList.lua new file mode 100644 index 00000000000..57253a3db99 --- /dev/null +++ b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapList.lua @@ -0,0 +1,351 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- A scrollable, pooled map list for the map-select dialog: each row shows the map name and a +-- `size · players` badge. Replaces the flat `ItemList` so rows can carry richer content (see +-- /lua/ui/CLAUDE.md § 6.1). +-- +-- NOTE: rows used to carry a mini map-preview thumbnail, but the engine never releases the +-- textures `MapPreview:SetTextureFromMap` / `SetTexture` allocate — not on re-texture, not on +-- `ClearTexture`, not on `Destroy`, not on dialog close. Scrolling a vault leaked tens of MB +-- that the game needs in-match, so thumbnails were removed. Rows are now text-only. +-- +-- It is *virtualised*: a fixed pool of row controls (sized to the visible height) is reused as +-- you scroll. The standard scrollbar contract (GetScrollValues / ScrollLines / ScrollPages / +-- ScrollSetTop / IsScrollable / CalcVisible) drives the windowing. +-- +-- Mouse-driven (click selects, double-click confirms, wheel + scrollbar scroll). A custom Group +-- list can't take keyboard focus the way ItemList can, so arrow-key navigation lives with the +-- owner (the dialog wires Enter on its search box / Esc on the popup). + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap + +local Layouter = LayoutHelpers.ReusedLayoutFor + +local RowHeight = 24 + +local SelectedColor = 'ff2c3e48' +local HoverColor = 'ff1a2630' +local IdleColor = '00000000' + +--- Number of start spots a scenario declares, or 0. +---@param scenario UILobbyScenarioInfo +---@return number +local function ArmyCount(scenario) + local armies = scenario.Configurations + and scenario.Configurations.standard + and scenario.Configurations.standard.teams + and scenario.Configurations.standard.teams[1] + and scenario.Configurations.standard.teams[1].armies + return armies and table.getsize(armies) or 0 +end + +---@class UICustomLobbyMapListRow : Group +---@field Background Bitmap +---@field Name Text +---@field Meta Text +---@field _poolIndex number +---@field _hover boolean + +---@class UICustomLobbyMapList : Group +---@field Trash TrashBag +---@field Items UILobbyScenarioInfo[] +---@field Rows UICustomLobbyMapListRow[] +---@field PoolCount number +---@field ScrollTop number # 0-based scroll offset (first visible = Items[ScrollTop+1]); NOT the `Top` edge LazyVar +---@field Selected number | false # selected item index (1-based) +---@field Scrollbar Scrollbar | false # hidden while everything fits +---@field OnSelect fun(scenario: UILobbyScenarioInfo, index: number) +---@field OnConfirm fun(scenario: UILobbyScenarioInfo) +local CustomLobbyMapList = ClassUI(Group) { + + ---@param self UICustomLobbyMapList + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyMapList") + + self.Trash = TrashBag() + self.Items = {} + self.Rows = {} + self.PoolCount = 0 + self.ScrollTop = 0 + self.Selected = false + self.Scrollbar = false + self.OnSelect = nil + self.OnConfirm = nil + end, + + ---@param self UICustomLobbyMapList + __post_init = function(self) + self.HandleEvent = function(control, event) + if event.Type == 'WheelRotation' then + local lines = event.WheelRotation > 0 and -3 or 3 + self:ScrollLines(nil, lines) + return true + end + return false + end + end, + + --- Builds the row pool sized to the (now concrete) height and attaches the scrollbar. + --- Called by the owner after the list is laid out + mounted — the pool count is read from + --- `Height()`, which isn't settled during __post_init (three-phase init, /lua/ui/CLAUDE.md § 1). + ---@param self UICustomLobbyMapList + Initialize = function(self) + if self.PoolCount > 0 then + return + end + + local count = math.floor(self.Height() / LayoutHelpers.ScaleNumber(RowHeight)) + if count < 1 then + count = 1 + end + for i = 1, count do + self.Rows[i] = self:CreateRow(i) + local top = (i - 1) * RowHeight + Layouter(self.Rows[i]) + :AtLeftIn(self) + :AtRightIn(self) + :AtTopIn(self, top) + :Height(RowHeight) + :End() + end + -- set the pool count only once the whole pool exists, so a build error never leaves + -- CalcVisible iterating past the rows that were actually created + self.PoolCount = count + + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self) + self:CalcVisible() + end, + + --- Builds one pooled row (name + size/players badge). Private. + ---@param self UICustomLobbyMapList + ---@param poolIndex number + ---@return UICustomLobbyMapListRow + CreateRow = function(self, poolIndex) + ---@type UICustomLobbyMapListRow + local row = Group(self) + row._poolIndex = poolIndex + row._hover = false + + row.Background = Bitmap(row) + row.Background:SetSolidColor(IdleColor) + + row.Name = UIUtil.CreateText(row, "", 14, UIUtil.bodyFont) + row.Name:DisableHitTest() + + row.Meta = UIUtil.CreateText(row, "", 12, UIUtil.bodyFont) + row.Meta:SetColor('ff9aa0a8') + row.Meta:DisableHitTest() + + Layouter(row.Background):Fill(row):End() + Layouter(row.Name):AtLeftIn(row, 10):AtVerticalCenterIn(row):End() + Layouter(row.Meta):AtRightIn(row, 10):AtVerticalCenterIn(row):End() + + -- the background catches the mouse; children are hit-test-disabled so they don't block it + row.Background.HandleEvent = function(control, event) + local index = self.ScrollTop + poolIndex + local scenario = self.Items[index] + if not scenario then + return false + end + if event.Type == 'ButtonPress' then + self:SetSelection(index) + if self.OnSelect then + self.OnSelect(scenario, index) + end + return true + elseif event.Type == 'ButtonDClick' then + if self.OnConfirm then + self.OnConfirm(scenario) + end + return true + elseif event.Type == 'MouseEnter' then + row._hover = true + self:PaintRow(row, index) + return true + elseif event.Type == 'MouseExit' then + row._hover = false + self:PaintRow(row, index) + return true + end + return false + end + + return row + end, + + --- Replaces the data set and refreshes the window (resets scroll to the top). + ---@param self UICustomLobbyMapList + ---@param items UILobbyScenarioInfo[] + SetItems = function(self, items) + self.Items = items or {} + self.ScrollTop = 0 + self.Selected = false + self:CalcVisible() + end, + + --- Selects an item by index (1-based) and repaints; does not scroll (see ShowItem). + ---@param self UICustomLobbyMapList + ---@param index number | false + SetSelection = function(self, index) + self.Selected = index or false + self:CalcVisible() + end, + + --- The selected scenario, or nil. + ---@param self UICustomLobbyMapList + ---@return UILobbyScenarioInfo | nil + GetSelected = function(self) + return self.Selected and self.Items[self.Selected] or nil + end, + + --- Scrolls so item `index` (1-based) is within the visible window. + ---@param self UICustomLobbyMapList + ---@param index number + ShowItem = function(self, index) + if self.PoolCount == 0 then + return + end + if index <= self.ScrollTop then + self.ScrollTop = index - 1 + elseif index > self.ScrollTop + self.PoolCount then + self.ScrollTop = index - self.PoolCount + end + self:ClampTop() + self:CalcVisible() + end, + + --- Paints a single row to reflect its data + selection/hover state. Private. + ---@param self UICustomLobbyMapList + ---@param row UICustomLobbyMapListRow + ---@param index number + PaintRow = function(self, row, index) + if not row then + return + end + local scenario = self.Items[index] + if not scenario then + row:Hide() + return + end + row:Show() + + local color = IdleColor + if index == self.Selected then + color = SelectedColor + elseif row._hover then + color = HoverColor + end + row.Background:SetSolidColor(color) + + row.Name:SetText(LOC(scenario.name) or "?") + + local players = ArmyCount(scenario) + local size = scenario.size and math.floor(scenario.size[1] / 50) or "?" + row.Meta:SetText(size .. "km · " .. players .. "p") + end, + + --------------------------------------------------------------------------- + --#region Scrollbar contract + + ---@param self UICustomLobbyMapList + CalcVisible = function(self) + for i = 1, self.PoolCount do + self:PaintRow(self.Rows[i], self.ScrollTop + i) + end + self:UpdateScrollbar() + end, + + --- Shows the scrollbar only when there are more items than fit the pool. + ---@param self UICustomLobbyMapList + UpdateScrollbar = function(self) + if not self.Scrollbar then + return + end + if table.getn(self.Items) > self.PoolCount then + self.Scrollbar:Show() + else + self.Scrollbar:Hide() + end + end, + + ---@param self UICustomLobbyMapList + ClampTop = function(self) + local maxTop = math.max(0, table.getn(self.Items) - self.PoolCount) + if self.ScrollTop > maxTop then + self.ScrollTop = maxTop + end + if self.ScrollTop < 0 then + self.ScrollTop = 0 + end + end, + + ---@param self UICustomLobbyMapList + GetScrollValues = function(self, axis) + local size = table.getn(self.Items) + return 0, size, self.ScrollTop, math.min(self.ScrollTop + self.PoolCount, size) + end, + + ---@param self UICustomLobbyMapList + ScrollLines = function(self, axis, delta) + self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta)) + end, + + ---@param self UICustomLobbyMapList + ScrollPages = function(self, axis, delta) + self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta) * self.PoolCount) + end, + + ---@param self UICustomLobbyMapList + ScrollSetTop = function(self, axis, top) + top = math.floor(top) + if top == self.ScrollTop then + return + end + self.ScrollTop = top + self:ClampTop() + self:CalcVisible() + end, + + ---@param self UICustomLobbyMapList + IsScrollable = function(self, axis) + return true + end, + + --#endregion + + ---@param self UICustomLobbyMapList + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyMapList +Create = function(parent) + return CustomLobbyMapList(parent) +end diff --git a/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua new file mode 100644 index 00000000000..e1f30da8dc4 --- /dev/null +++ b/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapSelect.lua @@ -0,0 +1,1041 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The map-select dialog: a searchable, filterable list of scenarios with a preview + info, and +-- Random / Select / Cancel. +-- +-- Layout is organised into labelled *areas* (Group containers) — title, left (filters + +-- selection + stats), preview (right), and actions (bottom). Flip the module-level `Debug` flag +-- to tint each area so the regions are visible while iterating on layout. +-- +-- It is a transient picker, NOT a persistent model component: +-- * it subscribes to the catalog (CustomLobbyMapCatalog), which streams maps in across frames, +-- * it previews the *highlighted candidate* (decoupled from the launch model) via the shared +-- CustomLobbyMapPreview, created unbound so this dialog drives it (vs. the in-lobby preview's +-- model binding), here with numbered-dot spawns + a title bar / url link layered on top, and +-- * on Select it calls the controller intent `RequestSetScenario(file)` — the same path a +-- `/map ` chat command would use. It owns no synced state. +-- +-- This is the first sub-dialog split out of the legacy `dialogs/mapselect.lua` god-dialog: map +-- selection ONLY (options / mods / units become their own components). +-- +-- The selection list is text-only — per-row map-preview thumbnails leaked GPU/CPU memory the +-- game needs in-match (the engine never frees MapPreview textures), so only the single candidate +-- preview on the right renders one. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") +local Prefs = import("/lua/user/prefs.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Edit = import("/lua/maui/edit.lua").Edit +local Popup = import("/lua/ui/controls/popups/popup.lua").Popup +local Combo = import("/lua/ui/controls/combo.lua").Combo +local TextArea = import("/lua/ui/controls/textarea.lua").TextArea + +local CustomLobbyMapPreview = import("/lua/ui/lobby/customlobby/customlobbymappreview.lua") +local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") +local CustomLobbyMapList = import("/lua/ui/lobby/customlobby/mapselect/customlobbymaplist.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- flip to tint each layout area so the regions are visible while iterating +local Debug = false + +local DialogWidth = 720 +local DialogHeight = 620 +local Pad = 12 +local ColumnGap = 30 -- whitespace separating the left column from the preview column +local LeftWidth = 300 +local PreviewSize = 220 -- square; kept small enough that the title + sections + description fit + -- under it in the preview column (DialogHeight) without overflowing +local TitleHeight = 32 +local ActionHeight = 48 +local FilterHeight = 134 +local StatsHeight = 22 + +-- map-detail presentation (mirrors the in-lobby Map tab — see config/CustomLobbyMapPanel.lua) +local IconSize = 14 +local LabelColor = 'ff8a909a' -- the section labels (Author / Reclaim / Description) +local ValueColor = 'ffc8ccd0' +local MassIcon = "/game/build-ui/icon-mass_bmp.dds" +local EnergyIcon = "/game/build-ui/icon-energy_bmp.dds" + +local PrefsKey = "customlobby_mapselect" + +-- Filter dropdowns. The first entry is the "no filter" option; `value` matches the scenario +-- (size = `scenarioInfo.size[1]` ogrids; players = number of start spots). +local SizeFilters = { + { label = "Any size", value = false }, + { label = "5 km", value = 256 }, + { label = "10 km", value = 512 }, + { label = "20 km", value = 1024 }, + { label = "40 km", value = 2048 }, + { label = "81 km", value = 4096 }, +} + +local PlayerFilters = { { label = "Any", value = false } } +for n = 2, 16 do + table.insert(PlayerFilters, { label = tostring(n), value = n }) +end + +-- comparison operators applied to the size / player filters +local Operators = { "=", ">=", "<=" } + +-- Map web pages we'll open in a browser (some scenarios carry a `url`). Matched against the +-- URL's host — exact or as a subdomain — so "faforever.com" also covers "forums.faforever.com". +-- Add a line to extend. +local AllowedUrlDomains = { + "github.com", + "githubusercontent.com", + "gitlab.com", + "github.io", + "faforever.com", +} + +--- The lowercased host of a URL (between the scheme and the first `/` or `:`), or "". +---@param url string +---@return string +local function UrlHost(url) + local rest = string.gsub(string.lower(url), "^https?://", "") + return (string.gsub(rest, "[/:].*$", "")) +end + +--- Whether `url` is an http(s) link to an allowed domain (or a subdomain of one). Guards +--- against look-alikes ("github.com.evil.com" is rejected) by matching the host suffix. +---@param url any +---@return boolean +local function IsAllowedUrl(url) + if type(url) ~= 'string' or not string.find(string.lower(url), "^https?://") then + return false + end + local host = UrlHost(url) + for _, domain in AllowedUrlDomains do + local escaped = string.gsub(domain, "%.", "%%.") + if host == domain or string.find(host, "%." .. escaped .. "$") then + return true + end + end + return false +end + +--- Downgrades an `https://` URL to `http://` — the engine's `OpenURL` only handles `http://`. +---@param url string +---@return string +local function ToOpenableUrl(url) + return (string.gsub(url, "^https://", "http://")) +end + +--- Shows `scrollbar` only when the TextArea's content is taller than the box it sits in. +---@param textArea TextArea +---@param scrollbar Scrollbar | false +local function UpdateTextAreaScrollbar(textArea, scrollbar) + if not scrollbar then + return + end + if textArea:GetTextHeight() > textArea.Height() then + scrollbar:Show() + else + scrollbar:Hide() + end +end + +--- Pulls the `.label` column out of a filter table for `Combo:AddItems`. +---@param filters table[] +---@return string[] +local function FilterLabels(filters) + local labels = {} + for i, filter in filters do + labels[i] = filter.label + end + return labels +end + +--- Number of start spots a scenario declares, or 0. +---@param scenario UILobbyScenarioInfo +---@return number +local function ArmyCount(scenario) + local armies = scenario.Configurations + and scenario.Configurations.standard + and scenario.Configurations.standard.teams + and scenario.Configurations.standard.teams[1] + and scenario.Configurations.standard.teams[1].armies + return armies and table.getsize(armies) or 0 +end + +--- Formats a resource amount compactly: 1016424 -> "1.0M", 119858 -> "120k", 950 -> "950". +---@param amount number +---@return string +local function FormatAmount(amount) + if amount >= 1000000 then + return string.format("%.1fM", amount / 1000000) + elseif amount >= 1000 then + return string.format("%.0fk", amount / 1000) + end + return string.format("%d", amount) +end + +--- Applies a comparison operator. A nil/false target means "no filter" → always passes. +---@param value number +---@param op string +---@param target number | false +---@return boolean +local function PassesComparison(value, op, target) + if not target then + return true + end + if op == ">=" then + return value >= target + elseif op == "<=" then + return value <= target + end + return value == target +end + +--- Clamps a stored combo index to a table's range (defaults to 1). +---@param index any +---@param options table[] +---@return number +local function ClampIndex(index, options) + if type(index) ~= 'number' or index < 1 or index > table.getn(options) then + return 1 + end + return index +end + +--- Creates a layout area (an invisible Group with an optional debug tint). +---@param parent Control +---@param name string +---@param color string +---@return Group +local function CreateArea(parent, name, color) + local area = Group(parent, name) + local bg = Bitmap(area) + bg:SetSolidColor(color) + bg:SetAlpha(Debug and 0.18 or 0.0) + bg:DisableHitTest() + Layouter(bg):Fill(area):End() + area.Bg = bg + return area +end + +---@class UICustomLobbyMapSelect : Group +---@field Trash TrashBag +---@field TitleArea Group +---@field LeftArea Group +---@field FilterArea Group +---@field SelectionArea Group +---@field StatsArea Group +---@field PreviewArea Group +---@field ActionArea Group +---@field Title Text +---@field FilterTitle Text +---@field SearchLabel Text +---@field Search Edit +---@field SizeLabel Text +---@field SizeCombo Combo +---@field SizeOpCombo Combo +---@field PlayersLabel Text +---@field PlayersCombo Combo +---@field PlayersOpCombo Combo +---@field SizeFilter number | false +---@field SizeOp string +---@field PlayerFilter number | false +---@field PlayerOp string +---@field SizeIndex number +---@field SizeOpIndex number +---@field PlayerIndex number +---@field PlayerOpIndex number +---@field MapList UICustomLobbyMapList +---@field EmptyLabel Text +---@field SpawnsToggle Checkbox +---@field ResourcesToggle Checkbox +---@field WrecksToggle Checkbox +---@field Preview UICustomLobbyMapPreview +---@field Surface UICustomLobbyScenarioPreview +---@field PreviewTitle Text +---@field InfoMeta Text +---@field AuthorLabel Text +---@field AuthorValue Text +---@field ReclaimLabel Text +---@field ReclaimValue Group +---@field ReclaimMass Text +---@field ReclaimMassIcon Bitmap +---@field ReclaimEnergy Text +---@field ReclaimEnergyIcon Bitmap +---@field DescriptionLabel Text +---@field Description TextArea +---@field DescriptionScrollbar Scrollbar | false +---@field Warning Text +---@field UrlButton Text +---@field CurrentUrl string | false +---@field RandomButton Button +---@field SelectButton Button +---@field CancelButton Button +---@field CountLabel Text +---@field Spinner Text +---@field OnConfirmCb fun(scenarioFile: FileName) +---@field OnCancelCb fun() +---@field ScenariosObserver LazyVar +---@field Scenarios UILobbyScenarioInfo[] +---@field Filtered UILobbyScenarioInfo[] +---@field Selected? UILobbyScenarioInfo +---@field LastInspected? UILobbyScenarioInfo +---@field CurrentFile FileName | false +---@field Ready boolean +local CustomLobbyMapSelect = ClassUI(Group) { + + ---@param self UICustomLobbyMapSelect + ---@param parent Control + ---@param onConfirm fun(scenarioFile: FileName) + ---@param onCancel fun() + __init = function(self, parent, onConfirm, onCancel) + Group.__init(self, parent, "CustomLobbyMapSelect") + + self.Trash = TrashBag() + self.OnConfirmCb = onConfirm + self.OnCancelCb = onCancel + + self.Ready = false + self.Filtered = {} + self.Selected = nil + self.CurrentFile = CustomLobbyLaunchModel.GetSingleton().ScenarioFile() + self.Scenarios = CustomLobbyMapCatalog.GetScenarios() + + -- restore the last-used filters + search (persisted across opens) + local saved = Prefs.GetFromCurrentProfile(PrefsKey) or {} + self.SizeIndex = ClampIndex(saved.sizeIndex, SizeFilters) + self.SizeOpIndex = ClampIndex(saved.sizeOpIndex, Operators) + self.PlayerIndex = ClampIndex(saved.playersIndex, PlayerFilters) + self.PlayerOpIndex = ClampIndex(saved.playersOpIndex, Operators) + self.SizeFilter = SizeFilters[self.SizeIndex].value + self.SizeOp = Operators[self.SizeOpIndex] + self.PlayerFilter = PlayerFilters[self.PlayerIndex].value + self.PlayerOp = Operators[self.PlayerOpIndex] + + -- areas + self.TitleArea = CreateArea(self, "TitleArea", 'ffcc4040') + self.LeftArea = CreateArea(self, "LeftArea", 'ff4060cc') + self.FilterArea = CreateArea(self.LeftArea, "FilterArea", 'ff40cc60') + self.SelectionArea = CreateArea(self.LeftArea, "SelectionArea", 'ffcccc40') + self.StatsArea = CreateArea(self.LeftArea, "StatsArea", 'ff40cccc') + self.PreviewArea = CreateArea(self, "PreviewArea", 'ffcc40cc') + self.ActionArea = CreateArea(self, "ActionArea", 'ff808080') + + self.Title = UIUtil.CreateText(self.TitleArea, "Select scenario", 22, UIUtil.titleFont) + + --#region filters (in FilterArea) + self.FilterTitle = UIUtil.CreateText(self.FilterArea, "Filter", 14, UIUtil.titleFont) + + self.SearchLabel = UIUtil.CreateText(self.FilterArea, "Search", 13, UIUtil.bodyFont) + self.SearchLabel:SetColor('ff9aa0a8') + + self.Search = Edit(self.FilterArea) + Layouter(self.Search):Left(0):Top(0):Width(96):Height(22):End() + self.Search:SetFont(UIUtil.bodyFont, 16) + self.Search:SetForegroundColor(UIUtil.fontColor) + self.Search:ShowBackground(true) + self.Search:SetBackgroundColor('77778888') + self.Search:SetText(saved.search or "") + self.Search.OnTextChanged = function(control, newText, oldText) + self:Populate() + end + self.Search.OnEnterPressed = function(control, text) + self:Confirm() + return true + end + Tooltip.AddControlTooltipManual(self.Search, "Search", "Filter the list by map name or author.") + + self.SizeLabel = UIUtil.CreateText(self.FilterArea, "Size", 13, UIUtil.bodyFont) + self.SizeLabel:SetColor('ff9aa0a8') + self.SizeCombo, self.SizeOpCombo = self:CreateFilterRow( + SizeFilters, self.SizeIndex, self.SizeOpIndex, + function(index) self.SizeIndex = index; self.SizeFilter = SizeFilters[index].value end, + function(index) self.SizeOpIndex = index; self.SizeOp = Operators[index] end, + "Map size", "Filter by map dimensions, with a comparison operator.") + + self.PlayersLabel = UIUtil.CreateText(self.FilterArea, "Players", 13, UIUtil.bodyFont) + self.PlayersLabel:SetColor('ff9aa0a8') + self.PlayersCombo, self.PlayersOpCombo = self:CreateFilterRow( + PlayerFilters, self.PlayerIndex, self.PlayerOpIndex, + function(index) self.PlayerIndex = index; self.PlayerFilter = PlayerFilters[index].value end, + function(index) self.PlayerOpIndex = index; self.PlayerOp = Operators[index] end, + "Player count", "Filter by number of start positions, with a comparison operator.") + --#endregion + + --#region selection list + stats + self.MapList = CustomLobbyMapList.Create(self.SelectionArea) + self.MapList.OnSelect = function(scenario, index) + self:OnMapSelected(scenario) + end + self.MapList.OnConfirm = function(scenario) + self:OnMapSelected(scenario) + self:Confirm() + end + + self.EmptyLabel = UIUtil.CreateText(self.SelectionArea, "No maps match", 14, UIUtil.bodyFont) + self.EmptyLabel:SetColor('ff8a909a') + self.EmptyLabel:DisableHitTest() + self.EmptyLabel:Hide() + + self.CountLabel = UIUtil.CreateText(self.StatsArea, "", 13, UIUtil.bodyFont) + self.CountLabel:SetColor('ff9aa0a8') + self.Spinner = UIUtil.CreateText(self.StatsArea, "", 13, UIUtil.bodyFont) + self.Spinner:SetColor('ff9aa0a8') + --#endregion + + --#region preview area (toggles, preview, info, description) + -- the candidate preview — the same component as the in-lobby preview, created unbound so we + -- drive it ourselves (no model wiring); spawns render as numbered dots (the surface default). + -- Resources/wrecks start hidden — the toggles flip them; spawns start shown. + self.Preview = CustomLobbyMapPreview.Create(self.PreviewArea) + self.Surface = self.Preview.Surface + self.Surface:SetOverlayVisible('resources', false) + self.Surface:SetOverlayVisible('wrecks', false) + + self.SpawnsToggle = self:CreateToggle("Spawns", true, + function(checked) self.Surface:SetOverlayVisible('spawns', checked) end, + "Spawns", "Show the start positions.") + self.ResourcesToggle = self:CreateToggle("Resources", false, + function(checked) self.Surface:SetOverlayVisible('resources', checked) end, + "Resources", "Show mass and hydrocarbon deposits.") + self.WrecksToggle = self:CreateToggle("Wrecks", false, + function(checked) self.Surface:SetOverlayVisible('wrecks', checked) end, + "Wrecks", "Show prebuilt wreckage (if the map defines any).") + + -- map title + info line, centred below the preview (mirrors the in-lobby Map header) + self.PreviewTitle = UIUtil.CreateText(self.PreviewArea, "", 16, UIUtil.titleFont) + self.PreviewTitle:DisableHitTest() + self.InfoMeta = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) + self.InfoMeta:SetColor('ff9aa0a8') + self.InfoMeta:DisableHitTest() + + -- labelled detail sections below, exactly as the Map tab (config/CustomLobbyMapPanel.lua): + --#region Author section + self.AuthorLabel = self:CreateSectionLabel("Author") + self.AuthorValue = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) + self.AuthorValue:SetColor(ValueColor) + self.AuthorValue:DisableHitTest() + --#endregion + + --#region Reclaim section (amount + mass icon, amount + energy icon) + self.ReclaimLabel = self:CreateSectionLabel("Reclaim") + self.ReclaimValue = Group(self.PreviewArea, "CustomLobbyMapSelectReclaim") + self.ReclaimValue:DisableHitTest() + self.ReclaimMass = UIUtil.CreateText(self.ReclaimValue, "", 13, UIUtil.bodyFont) + self.ReclaimMass:SetColor(ValueColor) + self.ReclaimMass:DisableHitTest() + self.ReclaimMassIcon = Bitmap(self.ReclaimValue) + self.ReclaimMassIcon:SetTexture(UIUtil.UIFile(MassIcon)) + self.ReclaimMassIcon:DisableHitTest() + self.ReclaimEnergy = UIUtil.CreateText(self.ReclaimValue, "", 13, UIUtil.bodyFont) + self.ReclaimEnergy:SetColor(ValueColor) + self.ReclaimEnergy:DisableHitTest() + self.ReclaimEnergyIcon = Bitmap(self.ReclaimValue) + self.ReclaimEnergyIcon:SetTexture(UIUtil.UIFile(EnergyIcon)) + self.ReclaimEnergyIcon:DisableHitTest() + --#endregion + + --#region Description section + self.DescriptionLabel = self:CreateSectionLabel("Description") + self.Description = TextArea(self.PreviewArea, 200, 80) + self.Description:SetFont(UIUtil.bodyFont, 12) + self.Description:SetColors(ValueColor, "00000000", ValueColor, "00000000") + --#endregion + + -- file-health warning (dialog-only); pinned to the bottom of the preview column + self.Warning = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) + self.Warning:SetColor('ffff6b6b') + self.Warning:DisableHitTest() + + -- "Open page" link → opens an allowed map url; secondary action, bottom-left (like the tab) + self.CurrentUrl = false + self.UrlButton = UIUtil.CreateText(self.ActionArea, "Open page", 12, UIUtil.bodyFont) + self.UrlButton:SetColor('ff7fb3ff') + self.UrlButton:Hide() + self.UrlButton.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + if self.CurrentUrl then + OpenURL(ToOpenableUrl(self.CurrentUrl)) + end + return true + elseif event.Type == 'MouseEnter' then + control:SetColor('ffaecbff') + return true + elseif event.Type == 'MouseExit' then + control:SetColor('ff7fb3ff') + return true + end + return false + end + Tooltip.AddControlTooltipManual(self.UrlButton, "Map page", "Open the map's web page in your browser.") + --#endregion + + --#region actions + self.RandomButton = UIUtil.CreateButtonStd(self, '/scx_menu/small-btn/small', "Random", 16, 2) + self.RandomButton.OnClick = function(button, modifiers) + self:PickRandom() + end + Tooltip.AddControlTooltipManual(self.RandomButton, "Random", "Pick a random map from the current filtered list.") + + self.SelectButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Select", 16, 2) + self.SelectButton.OnClick = function(button, modifiers) + self:Confirm() + end + + self.CancelButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Cancel", 16, 2) + self.CancelButton.OnClick = function(button, modifiers) + self.OnCancelCb() + end + --#endregion + + self.ScenariosObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyMapCatalog.GetScenariosVar(), function(scenariosLazy) + self:OnScenariosChanged(scenariosLazy()) + end)) + + CustomLobbyMapCatalog.EnsureLoaded() + + -- spin a small throbber beside the count while the catalog is still streaming + self.Trash:Add(ForkThread(function() + local frames = { "|", "/", "-", "\\" } + local i = 1 + while not CustomLobbyMapCatalog.IsLoaded() do + if IsDestroyed(self) then + return + end + self.Spinner:SetText(frames[i]) + i = math.mod(i, 4) + 1 + WaitSeconds(0.12) + end + if not IsDestroyed(self) then + self.Spinner:SetText("") + end + end)) + end, + + ---@param self UICustomLobbyMapSelect + __post_init = function(self) + self.Width:Set(LayoutHelpers.ScaleNumber(DialogWidth)) + self.Height:Set(LayoutHelpers.ScaleNumber(DialogHeight)) + + --#region areas + Layouter(self.TitleArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtTopIn(self, Pad):Height(TitleHeight):End() + Layouter(self.ActionArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtBottomIn(self, Pad):Height(ActionHeight):End() + Layouter(self.LeftArea) + :AtLeftIn(self, Pad):Width(LeftWidth) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) + :End() + Layouter(self.PreviewArea) + :AnchorToRight(self.LeftArea, ColumnGap):AtRightIn(self, Pad) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) + :End() + + Layouter(self.FilterArea):AtLeftIn(self.LeftArea):AtRightIn(self.LeftArea):AtTopIn(self.LeftArea):Height(FilterHeight):End() + Layouter(self.StatsArea):AtLeftIn(self.LeftArea):AtRightIn(self.LeftArea):AtBottomIn(self.LeftArea):Height(StatsHeight):End() + Layouter(self.SelectionArea) + :AtLeftIn(self.LeftArea):AtRightIn(self.LeftArea) + :AnchorToBottom(self.FilterArea, Pad):AnchorToTop(self.StatsArea, Pad) + :End() + --#endregion + + Layouter(self.Title):AtHorizontalCenterIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() + + --#region filters + Layouter(self.FilterTitle):AtLeftIn(self.FilterArea):AtTopIn(self.FilterArea):End() + Layouter(self.SearchLabel):AtLeftIn(self.FilterArea):AtVerticalCenterIn(self.Search):End() + Layouter(self.Search):AnchorToRight(self.SearchLabel, 8):AtRightIn(self.FilterArea):AnchorToBottom(self.FilterTitle, 8):Height(22):End() + + Layouter(self.SizeCombo):AtLeftIn(self.FilterArea, 56):AnchorToBottom(self.Search, 12):Width(110):End() + Layouter(self.SizeLabel):AtLeftIn(self.FilterArea):AtVerticalCenterIn(self.SizeCombo):End() + Layouter(self.SizeOpCombo):AnchorToRight(self.SizeCombo, 8):AtVerticalCenterIn(self.SizeCombo):Width(56):End() + + Layouter(self.PlayersCombo):AtLeftIn(self.FilterArea, 56):AnchorToBottom(self.SizeCombo, 10):Width(110):End() + Layouter(self.PlayersLabel):AtLeftIn(self.FilterArea):AtVerticalCenterIn(self.PlayersCombo):End() + Layouter(self.PlayersOpCombo):AnchorToRight(self.PlayersCombo, 8):AtVerticalCenterIn(self.PlayersCombo):Width(56):End() + --#endregion + + --#region selection list + stats + Layouter(self.MapList):AtLeftIn(self.SelectionArea):AtTopIn(self.SelectionArea):AtBottomIn(self.SelectionArea):End() + self.MapList.Right:Set(function() return self.SelectionArea.Right() - LayoutHelpers.ScaleNumber(32) end) + Layouter(self.EmptyLabel):AtHorizontalCenterIn(self.SelectionArea):AtVerticalCenterIn(self.SelectionArea):End() + + Layouter(self.CountLabel):AtLeftIn(self.StatsArea):AtVerticalCenterIn(self.StatsArea):End() + Layouter(self.Spinner):AnchorToRight(self.CountLabel, 8):AtVerticalCenterIn(self.StatsArea):End() + --#endregion + + --#region preview area + -- overlay toggles centred above the preview + Layouter(self.ResourcesToggle):AtHorizontalCenterIn(self.PreviewArea):AtTopIn(self.PreviewArea):End() + Layouter(self.SpawnsToggle):AnchorToLeft(self.ResourcesToggle, 16):AtVerticalCenterIn(self.ResourcesToggle):End() + Layouter(self.WrecksToggle):AnchorToRight(self.ResourcesToggle, 16):AtVerticalCenterIn(self.ResourcesToggle):End() + + -- size the preview (glow + backdrop); the surface (the map) is inset within it. Overlays + -- below anchor to self.Surface, so they land on the map, not the glow margin. + Layouter(self.Preview) + :AtHorizontalCenterIn(self.PreviewArea):AnchorToBottom(self.ResourcesToggle, 10) + :Width(PreviewSize):Height(PreviewSize) + :End() + + -- title + info line, centred under the preview (the labelled sections below are placed + -- dynamically by LayoutSections so empty ones collapse) + Layouter(self.PreviewTitle):AtHorizontalCenterIn(self.PreviewArea):AnchorToBottom(self.Preview, 8):End() + Layouter(self.InfoMeta):AtHorizontalCenterIn(self.PreviewArea):AnchorToBottom(self.PreviewTitle, 2):End() + + -- the reclaim value row: amount + mass icon, amount + energy icon (fixed internal layout; + -- the group's position is set by LayoutSections) + LayoutHelpers.SetHeight(self.ReclaimValue, IconSize + 4) + Layouter(self.ReclaimMass):AtLeftIn(self.ReclaimValue):AtVerticalCenterIn(self.ReclaimValue):End() + Layouter(self.ReclaimMassIcon):AnchorToRight(self.ReclaimMass, 3):AtVerticalCenterIn(self.ReclaimValue):Width(IconSize):Height(IconSize):End() + Layouter(self.ReclaimEnergy):AnchorToRight(self.ReclaimMassIcon, 10):AtVerticalCenterIn(self.ReclaimValue):End() + Layouter(self.ReclaimEnergyIcon):AnchorToRight(self.ReclaimEnergy, 3):AtVerticalCenterIn(self.ReclaimValue):Width(IconSize):Height(IconSize):End() + + -- file-health warning pinned to the bottom-left; the description floats above it + Layouter(self.Warning):AtLeftIn(self.PreviewArea, 6):AtBottomIn(self.PreviewArea):End() + + -- the description's left/right are fixed here so its Width can be bound in Initialize (the + -- TextArea reflows on the Width bind, which reads Left/Right); top/bottom set by LayoutSections + Layouter(self.Description):AtLeftIn(self.PreviewArea, 6):AtRightIn(self.PreviewArea, 32):End() + self.DescriptionScrollbar = UIUtil.CreateVertScrollbarFor(self.Description) + UIUtil.ForwardWheelToScroll(self.Description, self.Description) + --#endregion + + --#region actions + Layouter(self.CancelButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.SelectButton):AnchorToLeft(self.CancelButton, 12):AtVerticalCenterIn(self.ActionArea):End() + -- Random: horizontally centred under the left area, vertically centred in the actions + Layouter(self.RandomButton):AtHorizontalCenterIn(self.LeftArea):AtVerticalCenterIn(self.ActionArea):End() + -- "Open page" link: secondary action, bottom-left + Layouter(self.UrlButton):AtLeftIn(self.ActionArea, 6):AtVerticalCenterIn(self.ActionArea):End() + --#endregion + + self.MapList:AcquireKeyboardFocus(true) + end, + + --- Builds a value-combo + comparison-operator-combo pair in the filter area. Returns both. + ---@param self UICustomLobbyMapSelect + ---@param values table[] + ---@param valueIndex number + ---@param opIndex number + ---@param onValue fun(index: number) + ---@param onOp fun(index: number) + ---@param tooltipTitle string + ---@param tooltipBody string + ---@return Combo valueCombo + ---@return Combo opCombo + CreateFilterRow = function(self, values, valueIndex, opIndex, onValue, onOp, tooltipTitle, tooltipBody) + local valueCombo = Combo(self.FilterArea, 14, 8, nil, nil, "UI_Tab_Click_01", "UI_Tab_Rollover_01") + valueCombo:AddItems(FilterLabels(values), valueIndex) + valueCombo.OnClick = function(combo, index, text) + onValue(index) + self:Populate() + end + Tooltip.AddControlTooltipManual(valueCombo, tooltipTitle, tooltipBody) + + local opCombo = Combo(self.FilterArea, 14, 3, nil, nil, "UI_Tab_Click_01", "UI_Tab_Rollover_01") + opCombo:AddItems(Operators, opIndex) + opCombo.OnClick = function(combo, index, text) + onOp(index) + self:Populate() + end + Tooltip.AddControlTooltipManual(opCombo, tooltipTitle, "Comparison operator (=, at least, at most).") + + return valueCombo, opCombo + end, + + --- Builds a labelled checkbox "switch" wired to `onChange(checked)`, with a tooltip. + ---@param self UICustomLobbyMapSelect + ---@param label string + ---@param initial boolean + ---@param onChange fun(checked: boolean) + ---@param tooltipTitle string + ---@param tooltipBody string + ---@return Checkbox + CreateToggle = function(self, label, initial, onChange, tooltipTitle, tooltipBody) + local checkbox = UIUtil.CreateCheckbox(self.PreviewArea, '/CHECKBOX/', label, true, 13) + checkbox:SetCheck(initial, true) + checkbox.OnCheck = function(control, checked) + onChange(checked) + end + Tooltip.AddControlTooltipManual(checkbox, tooltipTitle, tooltipBody) + return checkbox + end, + + --- Builds the list pool + populates. Called by the opener after the dialog is mounted + + --- centred by Popup (three-phase init, /lua/ui/CLAUDE.md § 1). + ---@param self UICustomLobbyMapSelect + Initialize = function(self) + self.Ready = true + + -- TextArea pins Width to its constructor value and wraps to Width(), not Left..Right. Bind + -- it to the laid-out (map-wide) span now — not in __post_init: the bind eagerly fires + -- Width.OnDirty → ReflowText, which reads parent geometry that's circular until mounted. + self.Description.Width:Set(function() return self.Description.Right() - self.Description.Left() end) + + self.MapList:Initialize() + self:Populate() + end, + + --- The catalog published a new (possibly larger) map list: recount always, and re-list + --- once we're mounted. + ---@param self UICustomLobbyMapSelect + ---@param scenarios UILobbyScenarioInfo[] + OnScenariosChanged = function(self, scenarios) + self.Scenarios = scenarios + if self.Ready then + self:Populate() + else + self:UpdateCount() + end + end, + + --- Updates the footer: "X of Y maps" when a filter/search narrows it, else "Y maps". + ---@param self UICustomLobbyMapSelect + UpdateCount = function(self) + local total = table.getn(self.Scenarios) + local shown = table.getn(self.Filtered) + if self.Ready and shown < total then + self.CountLabel:SetText(LOCF("%d of %d maps", shown, total)) + else + self.CountLabel:SetText(LOCF("%d maps", total)) + end + end, + + --- Persists the current filters + search for next time. + ---@param self UICustomLobbyMapSelect + SavePrefs = function(self) + Prefs.SetToCurrentProfile(PrefsKey, { + sizeIndex = self.SizeIndex, + sizeOpIndex = self.SizeOpIndex, + playersIndex = self.PlayerIndex, + playersOpIndex = self.PlayerOpIndex, + search = self.Search:GetText() or "", + }) + end, + + --- Rebuilds the list from the catalog, applying the name search + size/player filters and + --- keeping the current highlight selected (falling back to the lobby's active map). + ---@param self UICustomLobbyMapSelect + Populate = function(self) + local search = string.lower(self.Search:GetText() or "") + local targetFile = (self.Selected and self.Selected.file) or self.CurrentFile + local target = targetFile and string.lower(targetFile) + + self.Filtered = {} + local selectedRow = 0 + + for _, scenario in self.Scenarios do + -- search matches the map name or (when the scenario provides one) its author + local haystack = string.lower(scenario.name or "") + if scenario.author then + haystack = haystack .. " " .. string.lower(scenario.author) + end + local matchesName = search == "" or string.find(haystack, search, 1, true) + if matchesName and self:PassesFilters(scenario) then + table.insert(self.Filtered, scenario) + if target and string.lower(scenario.file) == target then + selectedRow = table.getn(self.Filtered) + end + end + end + + self.MapList:SetItems(self.Filtered) + + if table.getn(self.Filtered) > 0 then + self.EmptyLabel:Hide() + local row = selectedRow > 0 and selectedRow or 1 + self.MapList:SetSelection(row) + self.MapList:ShowItem(row) + self:OnMapSelected(self.Filtered[row]) + else + self.EmptyLabel:Show() + self.Selected = nil + self:ClearDetails() + self.SelectButton:Disable() + self.RandomButton:Disable() + end + + self:UpdateCount() + end, + + --- Whether a scenario passes the size + player-count filters (with their comparators). + ---@param self UICustomLobbyMapSelect + ---@param scenario UILobbyScenarioInfo + ---@return boolean + PassesFilters = function(self, scenario) + local size = scenario.size and scenario.size[1] or 0 + if not PassesComparison(size, self.SizeOp, self.SizeFilter) then + return false + end + if not PassesComparison(ArmyCount(scenario), self.PlayerOp, self.PlayerFilter) then + return false + end + return true + end, + + --- Highlights a random map from the current filtered set (leaves confirming to the user). + ---@param self UICustomLobbyMapSelect + PickRandom = function(self) + local count = table.getn(self.Filtered) + if count == 0 then + return + end + local row = math.random(1, count) + self.MapList:SetSelection(row) + self.MapList:ShowItem(row) + self:OnMapSelected(self.Filtered[ ow]) + end, + + --- A map was selected: make it the candidate and refresh the preview + info. + ---@param self UICustomLobbyMapSelect + ---@param scenario UILobbyScenarioInfo + OnMapSelected = function(self, scenario) + if not scenario then + return + end + self.Selected = scenario + if self.LastInspected ~= scenario then + self.LastInspected = scenario + self:Inspect(scenario) + end + end, + + --- Shows a candidate: hands the scenario to the preview surface, fills the info, and runs a + --- file-health check that gates Select. + ---@param self UICustomLobbyMapSelect + ---@param scenario UILobbyScenarioInfo + Inspect = function(self, scenario) + local problems = {} + if not DiskGetFileInfo(scenario.map) then + table.insert(problems, "map missing") + end + if not DiskGetFileInfo(scenario.script) then + table.insert(problems, "script missing") + end + + if not DiskGetFileInfo(scenario.save) then + table.insert(problems, "save missing") + end + + -- the catalog owns scenario loading + caching (the save doscript is expensive and we + -- re-inspect the same maps as you browse); it also extracts the markers the surface renders + self.Surface:SetScenario(scenario, CustomLobbyMapCatalog.LoadMarkers(scenario)) + self:UpdateInfo(scenario) + self.RandomButton:Enable() + + if table.getn(problems) > 0 then + self.Warning:SetText("! " .. table.concat(problems, ", ")) + self.SelectButton:Disable() + else + self.Warning:SetText("") + self.SelectButton:Enable() + end + end, + + --- Builds a dim section label (Author / Reclaim / Description). Private. + ---@param self UICustomLobbyMapSelect + ---@param text string + ---@return Text + CreateSectionLabel = function(self, text) + local label = UIUtil.CreateText(self.PreviewArea, text, 12, UIUtil.titleFont) + label:SetColor(LabelColor) + label:DisableHitTest() + return label + end, + + --- Stacks the visible sections under the info line (collapsing absent ones) and floats the + --- description into the space above the warning. Mirrors the Map tab's LayoutSections. + ---@param self UICustomLobbyMapSelect + ---@param hasAuthor boolean + ---@param hasReclaim boolean + LayoutSections = function(self, hasAuthor, hasReclaim) + local prev = self.InfoMeta -- the sections begin under the info line + + -- places a label + value pair under `prev`, or hides both + local function place(label, value, visible) + if visible then + label:Show() + value:Show() + Layouter(label):AtLeftIn(self.PreviewArea, 6):AnchorToBottom(prev, 8):End() + Layouter(value):AtLeftIn(self.PreviewArea, 6):AnchorToBottom(label, 2):End() + prev = value + else + label:Hide() + value:Hide() + end + end + + place(self.AuthorLabel, self.AuthorValue, hasAuthor) + place(self.ReclaimLabel, self.ReclaimValue, hasReclaim) + + -- description always shows; its label sits under the last visible section + Layouter(self.DescriptionLabel):AtLeftIn(self.PreviewArea, 6):AnchorToBottom(prev, 8):End() + Layouter(self.Description) + :AtLeftIn(self.PreviewArea, 6):AtRightIn(self.PreviewArea, 24) + :AnchorToBottom(self.DescriptionLabel, 4):AnchorToTop(self.Warning, 8) + :End() + end, + + --- Fills the title + info line (size · players · version), the labelled sections (author / + --- reclaim / description) and the url link — collapsing sections the map doesn't provide. + ---@param self UICustomLobbyMapSelect + ---@param scenario UILobbyScenarioInfo + UpdateInfo = function(self, scenario) + self.PreviewTitle:SetText(LOC(scenario.name) or "?") + + local parts = {} + if scenario.size then + table.insert(parts, string.format("%dkm", math.floor(scenario.size[1] / 50))) + end + local players = ArmyCount(scenario) + if players > 0 then + table.insert(parts, players .. " players") + end + if scenario.map_version then + table.insert(parts, "v" .. scenario.map_version) + end + self.InfoMeta:SetText(table.concat(parts, " · ")) + + local author = scenario.author + local reclaim = scenario.reclaim + local hasAuthor = type(author) == "string" and author ~= "" + local hasReclaim = type(reclaim) == "table" and reclaim[1] ~= nil and reclaim[2] ~= nil + if hasAuthor then + self.AuthorValue:SetText(author) + end + if hasReclaim then + self.ReclaimMass:SetText(FormatAmount(reclaim[1])) + self.ReclaimEnergy:SetText(FormatAmount(reclaim[2])) + end + self.Description:SetText(scenario.description and LOC(scenario.description) or "") + + -- some scenarios carry a `url` to their source/page; offer to open allowed ones + if scenario.url and IsAllowedUrl(scenario.url) then + self.CurrentUrl = scenario.url + self.UrlButton:Show() + else + self.CurrentUrl = false + self.UrlButton:Hide() + end + + self:LayoutSections(hasAuthor, hasReclaim) + UpdateTextAreaScrollbar(self.Description, self.DescriptionScrollbar) + end, + + --- Clears the preview + info (no candidate / empty list). + ---@param self UICustomLobbyMapSelect + ClearDetails = function(self) + self.LastInspected = nil + self.Surface:Clear() + self.PreviewTitle:SetText("") + self.InfoMeta:SetText("") + self.Warning:SetText("") + self.Description:SetText("") + self.CurrentUrl = false + self.UrlButton:Hide() + self:LayoutSections(false, false) + UpdateTextAreaScrollbar(self.Description, self.DescriptionScrollbar) + end, + + --- Commits the highlighted candidate via the controller intent. + ---@param self UICustomLobbyMapSelect + Confirm = function(self) + if not self.Selected then + return + end + self.OnConfirmCb(self.Selected.file) + end, + + ---@param self UICustomLobbyMapSelect + OnDestroy = function(self) + self:SavePrefs() + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +--#region Singleton + open / close + +---@type Popup | false +local Instance = false + +--- Opens the map-select dialog over `parent` (defaults to the root frame). Confirming sets +--- the scenario through the host-authoritative `RequestSetScenario` intent. Replaces any +--- dialog already open. +---@param parent? Control +function Open(parent) + parent = parent or GetFrame(0) + if Instance then + Instance:Close() + end + + local popup + local content = CustomLobbyMapSelect(parent, + function(scenarioFile) + CustomLobbyController.RequestSetScenario(scenarioFile) + if popup then + popup:Close() + end + end, + function() + if popup then + popup:Close() + end + end) + + popup = Popup(parent, content) + local baseOnClosed = popup.OnClosed + popup.OnClosed = function(self) + baseOnClosed(self) + Instance = false + end + Instance = popup + + -- now that Popup has mounted + centred the content, it's safe to build the list pool + + -- populate (both read concrete geometry) + content:Initialize() +end + +--- Closes the dialog if open. +function Close() + if Instance then + Instance:Close() + Instance = false + end +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + Close() +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/models/CustomLobbyLaunchModel.lua b/lua/ui/lobby/customlobby/models/CustomLobbyLaunchModel.lua new file mode 100644 index 00000000000..846aa632815 --- /dev/null +++ b/lua/ui/lobby/customlobby/models/CustomLobbyLaunchModel.lua @@ -0,0 +1,351 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The **launch** state: everything the host dictates that becomes part of the launched +-- game (the gameInfo replacement from TARGET_ARCHITECTURE.md). This model IS the launch +-- payload — the host broadcasts it whole (see CustomLobbyController.BroadcastLaunchInfo / +-- BroadcastPlayers), and what the game launches with is exactly what's here. +-- +-- It is one of three lobby models — see /lua/ui/lobby/customlobby/CLAUDE.md: +-- * LaunchModel (this) — shared, launched. +-- * SessionModel — shared, lobby-room only (slot count / closed slots). +-- * LocalModel — per-peer, never synced (identity, CPU benchmarks). +-- +-- Players live as an array of per-slot LazyVars so a change to one slot only re-fires +-- that slot's row. Write helpers keep the copy-then-`Set` discipline (see /lua/ui/CLAUDE.md +-- § 2 — never mutate a held table in place). + +local Create = import("/lua/lazyvar.lua").Create +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") + +--- Maximum number of player slots the engine supports. +MaxSlots = 16 + +--- The number of real (non-random) factions — the upper bound of the faction multi-select, and the +--- index just below the Random sentinel. Reads factions.lua so custom factions are counted too. +RealFactionCount = table.getn(import("/lua/factions.lua").Factions) + +------------------------------------------------------------------------------- +--#region Shapes + +--- A player (human or AI) occupying a slot. Mirrors the fields the legacy lobby's +--- PlayerData carries; trimmed to what the UI reads. +---@class UICustomLobbyPlayer +---@field PlayerName string +---@field OwnerID UILobbyPeerId +---@field Human boolean +---@field Faction number # 1=UEF 2=Aeon 3=Cybran 4=Seraphim 5=Random — the representative (see Factions) +---@field Factions number[] # the multi-select: the real-faction indices the player allows; >1 = random among them +---@field PlayerColor number +---@field ArmyColor number +---@field Team number # 1 = no team (FFA), 2..9 = teams 1..8 +---@field StartSpot number +---@field Ready boolean +---@field PL? number # rating +---@field MEAN? number +---@field DEV? number +---@field NG? number # number of games +---@field DIV? string # league division (e.g. "gold") +---@field SUBDIV? string # league subdivision (e.g. "III") +---@field PlayerClan? string +---@field Country? string +---@field AIPersonality? string + +--#endregion + +------------------------------------------------------------------------------- +--#region Reactive model + +-- LIFETIME. A `ClassSimple` implementing `Destroyable`, registered in the session trash bag (see +-- CustomLobbySession) on first access, so one `CustomLobbySession.Teardown()` resets it. Per the +-- teardown design's decision #3 the write helpers stay **free functions** (below) and `Destroy` is +-- **thin** (nil the module singleton; the LazyVars — the 16 per-slot vars + the rest — are freed by GC +-- once the views observing them are torn down, not proactively, since the interface that subscribes to +-- them isn't in the bag yet). See design/session-trashbag-teardown.md. + +-- The singleton, forward-declared above the class so `Destroy` captures it as an upvalue. Assigned in +-- `SetupSingleton`, cleared in `Destroy`. +---@type UICustomLobbyLaunchModel | nil +local Instance = nil + +--- Reactive launch-state singleton (shared, host-dictated, part of the launch). +---@class UICustomLobbyLaunchModel : Destroyable +---@field Players LazyVar[] # one LazyVar per slot (1..MaxSlots); false = empty +---@field Observers LazyVar # observer list +---@field SpawnMex LazyVar> # adaptive-map spawn-mex flags (embedded into the scenario at launch) +---@field AutoTeams LazyVar> +---@field GameOptions LazyVar +---@field GameMods LazyVar
+---@field Restrictions LazyVar # unit-restriction preset keys (folded into GameOptions.RestrictedCategories at launch) +---@field ScenarioFile LazyVar +---@field Destroyed boolean +local LaunchModel = ClassSimple { + + ---@param self UICustomLobbyLaunchModel + __init = function(self) + local players = {} + for slot = 1, MaxSlots do + players[slot] = Create(false) + end + self.Players = players + self.Observers = Create({}) + self.SpawnMex = Create({}) + self.AutoTeams = Create({}) + self.GameOptions = Create({}) + self.GameMods = Create({}) + self.Restrictions = Create({}) + self.ScenarioFile = Create(false) + self.Destroyed = false + end, + + --- `Destroyable`: thin teardown — drop the module singleton so the next session rebuilds (and + --- re-registers). The LazyVars GC once the views observing them are gone. Idempotent. + ---@param self UICustomLobbyLaunchModel + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + if Instance == self then + Instance = nil + end + end, +} + +--- Allocates a fresh launch-model singleton and registers it in the session trash. +---@return UICustomLobbyLaunchModel +function SetupSingleton() + Instance = LaunchModel() + CustomLobbySession.GetTrash():Add(Instance) + return Instance +end + +--- Returns the launch-model singleton, creating (and registering) it on first access. +---@return UICustomLobbyLaunchModel +function GetSingleton() + if not Instance then + SetupSingleton() + end + return Instance --[[@as UICustomLobbyLaunchModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Write helpers +-- +-- The synced tables are LazyVar values, so a write must build a NEW table/value and +-- `:Set` it — mutating in place never marks dependents dirty (see /lua/ui/CLAUDE.md +-- § 2). These helpers keep that discipline in one place. + +--- Places (or replaces) a player at a slot. +---@param model UICustomLobbyLaunchModel +---@param slot number +---@param player UICustomLobbyPlayer +function SetPlayer(model, slot, player) + model.Players[slot]:Set(player) +end + +--- Empties a slot. +---@param model UICustomLobbyLaunchModel +---@param slot number +function ClearPlayer(model, slot) + model.Players[slot]:Set(false) +end + +--- Sets a single field on the player in a slot (copy-then-Set on that slot only). +---@param model UICustomLobbyLaunchModel +---@param slot number +---@param key string +---@param value any +function SetPlayerField(model, slot, key, value) + local current = model.Players[slot]() + if not current then + return + end + local player = table.copy(current) + player[key] = value + model.Players[slot]:Set(player) +end + +--- Merges several fields onto the player in a slot in one copy-then-Set (one re-render, not N). +--- Use when a change touches more than one field at once (e.g. the faction multi-select updates +--- both `Factions` and the representative `Faction`). +---@param model UICustomLobbyLaunchModel +---@param slot number +---@param fields table +function SetPlayerFields(model, slot, fields) + local current = model.Players[slot]() + if not current then + return + end + local player = table.copy(current) + for key, value in fields do + player[key] = value + end + model.Players[slot]:Set(player) +end + +--- The single representative faction for a multi-select: the chosen one when exactly one faction is +--- picked, else the Random sentinel (`RealFactionCount + 1`). Keeps `player.Faction` coherent for the +--- readers that want one value (skin, the launch fallback) while `player.Factions` stays the source of +--- truth for the choice. An empty / nil set reads as full random. +---@param factions number[] | nil +---@return number +function RepresentativeFaction(factions) + if factions and table.getn(factions) == 1 then + return factions[1] + end + return RealFactionCount + 1 +end + +--- Normalises a faction multi-select: a sorted list of the in-range real-faction indices, de-duped. +--- An empty / nil / all-invalid set falls back to "all factions" (full random), so a player always +--- has at least one allowed faction. +---@param factions number[] | nil +---@return number[] +function NormalizeFactions(factions) + local seen, out = {}, {} + if factions then + for _, index in factions do + if type(index) == 'number' and index >= 1 and index <= RealFactionCount and not seen[index] then + seen[index] = true + table.insert(out, index) + end + end + end + if table.empty(out) then + for index = 1, RealFactionCount do + table.insert(out, index) + end + return out + end + table.sort(out) + return out +end + +--- Sets a single game option (copy-then-Set). +---@param model UICustomLobbyLaunchModel +---@param key string +---@param value any +function SetGameOption(model, key, value) + local options = table.copy(model.GameOptions()) + options[key] = value + model.GameOptions:Set(options) +end + +--- Replaces the whole game-options value table (copy-then-Set). +---@param model UICustomLobbyLaunchModel +---@param options table +function SetGameOptions(model, options) + model.GameOptions:Set(table.copy(options)) +end + +--- Sets the scenario file. +---@param model UICustomLobbyLaunchModel +---@param scenarioFile FileName | false +function SetScenario(model, scenarioFile) + model.ScenarioFile:Set(scenarioFile) +end + +--- Sets the active sim mods (a uid set). UI mods are per-peer and never live here — only sim +--- mods become part of the launch (and must agree across players). +---@param model UICustomLobbyLaunchModel +---@param gameMods table +function SetGameMods(model, gameMods) + model.GameMods:Set(table.copy(gameMods)) +end + +--- Sets the unit-restriction preset keys (a list of strings). Folded into the launch config's +--- `GameOptions.RestrictedCategories` at launch (the sim expands the keys — see simInit.lua). +---@param model UICustomLobbyLaunchModel +---@param keys string[] +function SetRestrictions(model, keys) + model.Restrictions:Set(table.copy(keys)) +end + +--- Appends a player to the observer list (copy-then-Set). +---@param model UICustomLobbyLaunchModel +---@param player UICustomLobbyPlayer +function AddObserver(model, player) + local observers = table.copy(model.Observers()) + table.insert(observers, player) + model.Observers:Set(observers) +end + +--- Removes the observer owned by `ownerId` (copy-then-Set) and returns it, or nil. +---@param model UICustomLobbyLaunchModel +---@param ownerId UILobbyPeerId +---@return UICustomLobbyPlayer | nil +function RemoveObserver(model, ownerId) + local observers = model.Observers() + local kept, removed = {}, nil + for i = 1, table.getn(observers) do + if observers[i].OwnerID == ownerId then + removed = observers[i] + else + table.insert(kept, observers[i]) + end + end + if removed then + model.Observers:Set(kept) + end + return removed +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: rebuilds the singleton and copies the current values across. +--- +--- NOTE: maintained by hand — add a field to the model, add a copy line here too, or +--- its value is lost on every hot-reload. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if Instance then + local handle = newModule.SetupSingleton() + for slot = 1, MaxSlots do + handle.Players[slot]:Set(Instance.Players[slot]()) + end + handle.Observers:Set(Instance.Observers()) + handle.SpawnMex:Set(Instance.SpawnMex()) + handle.AutoTeams:Set(Instance.AutoTeams()) + handle.GameOptions:Set(Instance.GameOptions()) + handle.GameMods:Set(Instance.GameMods()) + handle.Restrictions:Set(Instance.Restrictions()) + handle.ScenarioFile:Set(Instance.ScenarioFile()) + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/models/CustomLobbyLocalModel.lua b/lua/ui/lobby/customlobby/models/CustomLobbyLocalModel.lua new file mode 100644 index 00000000000..37cb67b558e --- /dev/null +++ b/lua/ui/lobby/customlobby/models/CustomLobbyLocalModel.lua @@ -0,0 +1,145 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The **local** state: this peer's own state, NOT shared and NOT broadcast. Two kinds: +-- * identity — who this client is in the lobby (`LocalPeerId`, `HostID`, `IsHost`), +-- established by the connection handshake (OnHosting / OnConnectionToHostEstablished). +-- These are per-peer: the host's `IsHost` is true, a client's is false — broadcasting +-- them would corrupt the receiver's sense of itself, so they never go on the wire. +-- * connectivity — high-frequency local data (CPU benchmarks now; ping / connection +-- status later) whose churn must not dirty the synced launch/session snapshots. +-- +-- One of three lobby models — see /lua/ui/lobby/customlobby/CLAUDE.md: +-- * LaunchModel — shared, launched. +-- * SessionModel — shared, lobby-room only. +-- * LocalModel (this) — per-peer, never synced. + +local Create = import("/lua/lazyvar.lua").Create +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") + +------------------------------------------------------------------------------- +--#region Reactive model +-- +-- LIFETIME. A `ClassSimple` implementing `Destroyable`, registered in the session trash bag (see +-- CustomLobbySession) on first access, so one `CustomLobbySession.Teardown()` resets it. Per the +-- teardown design's decision #3 the write helpers stay **free functions** (below) and `Destroy` is +-- **thin** — it just nils the module singleton so the next session rebuilds; the LazyVars are freed by +-- GC once the views observing them are torn down (we don't proactively destroy them, since the +-- interface that subscribes to them isn't in the bag yet). See design/session-trashbag-teardown.md. + +-- The singleton, forward-declared above the class so `Destroy` captures it as an upvalue. Assigned in +-- `SetupSingleton`, cleared in `Destroy`. +---@type UICustomLobbyLocalModel | nil +local Instance = nil + +--- Reactive local-state singleton (per-peer, never synced). +---@class UICustomLobbyLocalModel : Destroyable +---@field LocalPeerId LazyVar # this client's peer id +---@field HostID LazyVar # the host's peer id +---@field IsHost LazyVar # whether this client is the host +---@field CpuBenchmarks LazyVar> # peer id -> in-game sim-performance history (see /lua/system/performance.lua) +---@field Destroyed boolean +local LocalModel = ClassSimple { + + ---@param self UICustomLobbyLocalModel + __init = function(self) + self.LocalPeerId = Create("-1") + self.HostID = Create("-1") + self.IsHost = Create(false) + self.CpuBenchmarks = Create({}) + self.Destroyed = false + end, + + --- `Destroyable`: thin teardown — drop the module singleton so the next session rebuilds (and + --- re-registers). The LazyVars GC once the views observing them are gone. Idempotent. + ---@param self UICustomLobbyLocalModel + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + if Instance == self then + Instance = nil + end + end, +} + +--- Allocates a fresh local-model singleton and registers it in the session trash. +---@return UICustomLobbyLocalModel +function SetupSingleton() + Instance = LocalModel() + CustomLobbySession.GetTrash():Add(Instance) + return Instance +end + +--- Returns the local-model singleton, creating (and registering) it on first access. +---@return UICustomLobbyLocalModel +function GetSingleton() + if not Instance then + SetupSingleton() + end + return Instance --[[@as UICustomLobbyLocalModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Write helpers + +--- Records a peer's in-game sim-performance history / benchmark (copy-then-Set). +---@param model UICustomLobbyLocalModel +---@param ownerId UILobbyPeerId +---@param benchmark UIPerformanceMetrics +function SetCpuBenchmark(model, ownerId, benchmark) + local all = table.copy(model.CpuBenchmarks()) + all[ownerId] = benchmark + model.CpuBenchmarks:Set(all) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: rebuilds the singleton (registering the new one) and copies the values across. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if Instance then + local handle = newModule.SetupSingleton() + handle.LocalPeerId:Set(Instance.LocalPeerId()) + handle.HostID:Set(Instance.HostID()) + handle.IsHost:Set(Instance.IsHost()) + handle.CpuBenchmarks:Set(Instance.CpuBenchmarks()) + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/models/CustomLobbySessionModel.lua b/lua/ui/lobby/customlobby/models/CustomLobbySessionModel.lua new file mode 100644 index 00000000000..bd73582a1e9 --- /dev/null +++ b/lua/ui/lobby/customlobby/models/CustomLobbySessionModel.lua @@ -0,0 +1,170 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The **session** state: host-dictated lobby-room management that is shared with everyone +-- but is NOT part of the launched game. A closed slot just means "no army there" at launch +-- and the slot count is map-derived presentation — neither reaches the scenario, so they +-- live here rather than in the launch payload. +-- +-- One of three lobby models — see /lua/ui/lobby/customlobby/CLAUDE.md: +-- * LaunchModel — shared, launched. +-- * SessionModel (this) — shared, lobby-room only. +-- * LocalModel — per-peer, never synced. +-- +-- Synced host -> clients as a whole snapshot (CustomLobbyController.BroadcastSessionState). + +local Create = import("/lua/lazyvar.lua").Create +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") + +------------------------------------------------------------------------------- +--#region Reactive model +-- +-- LIFETIME. A `ClassSimple` implementing `Destroyable`, registered in the session trash bag (see +-- CustomLobbySession) on first access, so one `CustomLobbySession.Teardown()` resets it. Per the +-- teardown design's decision #3 the write helpers stay **free functions** (below) and `Destroy` is +-- **thin** (nil the module singleton; the LazyVars GC once the views observing them are torn down). + +-- The singleton, forward-declared above the class so `Destroy` captures it as an upvalue. Assigned in +-- `SetupSingleton`, cleared in `Destroy`. +---@type UICustomLobbySessionModel | nil +local Instance = nil + +--- Reactive session-state singleton (shared, host-dictated, not launched). +---@class UICustomLobbySessionModel : Destroyable +---@field SlotCount LazyVar # player slots the current map supports +---@field ClosedSlots LazyVar> +---@field LockedSlots LazyVar> # host pinned a seat: its player is held in place by auto-balance +---@field SlotsPinned LazyVar # host locked seating: only the host may change slots +---@field Destroyed boolean +local SessionModel = ClassSimple { + + ---@param self UICustomLobbySessionModel + ---@param slotCount? number + __init = function(self, slotCount) + self.SlotCount = Create(slotCount or 8) + self.ClosedSlots = Create({}) + self.LockedSlots = Create({}) + self.SlotsPinned = Create(false) + self.Destroyed = false + end, + + --- `Destroyable`: thin teardown — drop the module singleton so the next session rebuilds (and + --- re-registers). The LazyVars GC once the views observing them are gone. Idempotent. + ---@param self UICustomLobbySessionModel + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + if Instance == self then + Instance = nil + end + end, +} + +--- Allocates a fresh session-model singleton and registers it in the session trash. +---@param slotCount? number +---@return UICustomLobbySessionModel +function SetupSingleton(slotCount) + Instance = SessionModel(slotCount) + CustomLobbySession.GetTrash():Add(Instance) + return Instance +end + +--- Returns the session-model singleton, creating (and registering) it on first access. +---@return UICustomLobbySessionModel +function GetSingleton() + if not Instance then + SetupSingleton() + end + return Instance --[[@as UICustomLobbySessionModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Write helpers + +--- Sets the number of active slots. +---@param model UICustomLobbySessionModel +---@param slotCount number +function SetSlotCount(model, slotCount) + model.SlotCount:Set(slotCount) +end + +--- Sets the closed flag for a slot (copy-then-Set). +---@param model UICustomLobbySessionModel +---@param slot number +---@param closed boolean +function SetClosed(model, slot, closed) + local closedSlots = table.copy(model.ClosedSlots()) + closedSlots[slot] = closed + model.ClosedSlots:Set(closedSlots) +end + +--- Sets the locked flag for a slot (copy-then-Set). A locked seat's player is held in place by +--- auto-balance — see CustomLobbyBalancer. +---@param model UICustomLobbySessionModel +---@param slot number +---@param locked boolean +function SetLocked(model, slot, locked) + local lockedSlots = table.copy(model.LockedSlots()) + lockedSlots[slot] = locked or nil + model.LockedSlots:Set(lockedSlots) +end + +--- Sets whether seating is pinned (only the host may change slots while on). +---@param model UICustomLobbySessionModel +---@param pinned boolean +function SetSlotsPinned(model, pinned) + model.SlotsPinned:Set(pinned and true or false) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: rebuilds the singleton and copies the current values across. +--- +--- NOTE: maintained by hand — add a field to the model, add a copy line here too. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if Instance then + local handle = newModule.SetupSingleton(Instance.SlotCount()) + handle.ClosedSlots:Set(Instance.ClosedSlots()) + handle.LockedSlots:Set(Instance.LockedSlots()) + handle.SlotsPinned:Set(Instance.SlotsPinned()) + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/models/derived/CLAUDE.md b/lua/ui/lobby/customlobby/models/derived/CLAUDE.md new file mode 100644 index 00000000000..a7aee210659 --- /dev/null +++ b/lua/ui/lobby/customlobby/models/derived/CLAUDE.md @@ -0,0 +1,62 @@ +# Derived models + +Read-only reactive **projections** of the authoritative lobby state. The three models +([`CustomLobbyLaunchModel`](/lua/ui/lobby/customlobby/models/CustomLobbyLaunchModel.lua) / `SessionModel` / `LocalModel`) hold +the state in its most **compact** form — a map is a single `ScenarioFile`, sim mods are a set of +UUIDs, restrictions are preset keys. Turning those into something a view can actually render (the +map's name / size / start spots / texture, a mod's title + icon + dependencies, a restriction's +display name) takes work — disk loads, catalog lookups, lookups against reference data. + +A **derived model** does that work **once** and exposes the result reactively, so every consumer +just reads the field it needs instead of each re-resolving the compact value. It is the "fishing" +layer between the compact synced fields and the views. + +## The contract + +- **Derived, never authoritative.** A derived model is a pure function of the models it reads. It is + **read-only**: it has no write helpers, and the **controller never touches it**. To change what it + holds, change the *source field* it derives from (the controller writes that). This keeps the + authoritative model the single source of truth and the derived model a cache/projection. +- **Reactive + deduped.** It subscribes to its source field(s) and republishes a resolved bundle via + a `LazyVar`. Because `LazyVar:Set` always re-fires (and the host rebroadcasts whole snapshots, + re-setting fields to unchanged values), the internal observer **dedups by key** — the same input + arriving twice is a no-op, so downstream views don't needlessly reload. Consumers subscribe to the + bundle var (`Derive`) or pull the current value (`GetScenario()` etc.). +- **Not on the wire.** Purely local reactive reference data, like the catalogs — every peer derives + its own from the synced compact fields. +- **Naming.** File **and** class carry the `DerivedModel` suffix + (`CustomLobbyDerivedModel`), and the file lives here in `models/derived/`, so it is unmistakable at + the import site and in the type that this is derived state — not something a controller writes. +- **Lifetime.** Each is a `ClassSimple` implementing `Destroyable`, registered in the session trash + (see [`../../CustomLobbySession`](/lua/ui/lobby/customlobby/customlobbysession.lua)) on first access, + so one `CustomLobbySession.Teardown()` frees its LazyVar(s) and severs its subscriptions instead of + leaking them into the persistent front-end state for the whole match. Each owns a `Trash` that holds + its published LazyVar(s) **and** its observer(s); `Destroy` is idempotent (`Destroyed` guard), + `Trash:Destroy()`s, and nils the module `Instance` so the next access rebuilds. Observers are pinned + on the instance (`self.Observer` / `self.Observers`) *and* added to the trash — the trash is + weak-valued, so the trash alone wouldn't keep them alive. The map catalog + ([`../../mapselect/CustomLobbyMapCatalog`](/lua/ui/lobby/customlobby/mapselect/CustomLobbyMapCatalog.lua)) + is the original worked example; see [`../../design/session-trashbag-teardown.md`](/lua/ui/lobby/customlobby/design/session-trashbag-teardown.md). + +## Files + +| File | Derives | From | Read by | +|------|---------|------|---------| +| [CustomLobbyScenarioDerivedModel.lua](CustomLobbyScenarioDerivedModel.lua) | the resolved `Scenario` (info for the texture + extracted save markers `Spawns`/`MassPoints`/`HydroPoints`/`Wrecks` + `MaxDimension`/`ArmyCount`/`Name`/`Size`/`Version`) | the launch model's `ScenarioFile` | the bound [`CustomLobbyMapPreview`](/lua/ui/lobby/customlobby/CustomLobbyMapPreview.lua), the [`config/CustomLobbyConfigInterface`](/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua) facts line, [`CustomLobbyRules`](/lua/ui/lobby/customlobby/CustomLobbyRules.lua) (map size + start spots) | +| [CustomLobbyOptionsDerivedModel.lua](CustomLobbyOptionsDerivedModel.lua) | the `Options` view: options split into **Categories** lobby / scenario / mods, each option **enriched** (label, help, chosen value-key + display, `IsDefault`, origin), plus `NonDefaultCount`. **Two dedups:** a *disk* dedup (the `SchemaKey`-keyed schema cache, so a value change doesn't re-read the option files) and a *publish* dedup (`table.equal` over the scenario file + mod set + option values, so an unrelated launch-info rebroadcast doesn't rebuild the panel). | the launch model's `GameOptions` + `GameMods` and the scenario derived model (map file + name); the scenario `_options.lua` schema via the catalog's `LoadOptions`, the lobby/mod schema + value interpretation via [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua) | the [`config/CustomLobbyOptionsPanel`](/lua/ui/lobby/customlobby/config/CustomLobbyOptionsPanel.lua) and the Options tab badge in [`config/CustomLobbyConfigInterface`](/lua/ui/lobby/customlobby/config/CustomLobbyConfigInterface.lua) | +| [CustomLobbyRestrictionsDerivedModel.lua](CustomLobbyRestrictionsDerivedModel.lua) | the `Restrictions` view: each active **preset** key **enriched** with its `Name` / `Icon` / `Tooltip`, plus `Count`. De-duped by an order-independent set signature (`table.concat(table.sorted(keys), …)`). Keys can also be **specific unit ids**, but enriching those (name + icon) is **parked** — see [TODO.md](/lua/ui/lobby/customlobby/TODO.md) — so a unit id currently shows as its raw id. | the launch model's `Restrictions`, joined with the preset table in [`/lua/ui/lobby/unitsrestrictions.lua`](/lua/ui/lobby/unitsrestrictions.lua) | the [`config/CustomLobbyUnitsPanel`](/lua/ui/lobby/customlobby/config/CustomLobbyUnitsPanel.lua) (icon + name rows) and the Restrictions tab badge | +| [CustomLobbyModsDerivedModel.lua](CustomLobbyModsDerivedModel.lua) | the `Mods` view: enabled mods split into **game** (sim) + **ui** `Groups`, each mod **enriched** (`Name` / `Icon` / `Author` / `Version` / `UiOnly`), plus `GameCount` / `UiCount`. No disk load (`Mods.AllMods()` is synchronous). De-duped by an order-independent set signature (`table.concatkeys` over the game + ui sets). | the launch model's `GameMods` (synced sim mods) + `ModUtilities.GetSelectedUIMods()` (per-peer prefs, re-read on each sim-mod change), joined with `/lua/mods.lua` | the [`config/CustomLobbyModsPanel`](/lua/ui/lobby/customlobby/config/CustomLobbyModsPanel.lua) (icon + name rows) and the Mods tab badge (`sim / ui`) | +| [CustomLobbySlotsDerivedModel.lua](CustomLobbySlotsDerivedModel.lua) | **two faces** over the seating board. `Slots` — a **lookup table** (one entry per slot, 1..MaxSlots): each seat merges its **player** (resolved `PlayerView`), scenario **placement** (`StartSpot` + map `Position`), **closed** flag, **locked** flag (the gold lock stripe / auto-balance pin), **CPU benchmark** (`CpuView` + raw `Benchmark`/`UnitCap` for the popover), and its binary auto-team **`Side`** (1/2/false). `Teams` — the **aggregate** (`Mode` / `Labels` / `Resolved` / per-side rating `Totals`). The side rule stays in [`CustomLobbyRules`](/lua/ui/lobby/customlobby/CustomLobbyRules.lua) (`BuildSideResolver`); this model *applies* it once so the two-column layout reads `entry.Side` and the score reads `Teams`. Each face deduped by its **own** signature, so a rating change re-fires `Teams` but not the rows. | the launch model's `Players[slot]` + `GameOptions` (the AutoTeams mode), the session model's `ClosedSlots`, the local model's `CpuBenchmarks` (per-peer, **not launched**), and the scenario derived model (placement + map size, via `CustomLobbyRules`) | the [`slots/CustomLobbySlotBase`](/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua) (`Slots`), the [`slots/twocolumn/CustomLobbyTwoColumnSlots`](/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua) (`entry.Side`) and [`CustomLobbyTeamScore`](/lua/ui/lobby/customlobby/CustomLobbyTeamScore.lua) (`Teams`) | + +The options model also shows a second trait worth copying: it **caches the expensive part**. Gathering +the option schema is disk work (the map's `_options.lua` `doscript`, mod option files) and only changes +when the scenario / mod set changes, so it is rebuilt only when those inputs change (keyed) — a value +edit just re-enriches the cached schema. (Composing derived models is fine too: both the options model +and the slots model read the scenario *from the scenario derived model*, not by re-resolving the file.) + +The slots model shows a third variation: **one table rebuilt whole vs. a var per item.** The other +models expose a list/bundle, but the slots model is keyed by slot index because a seat's CPU bar is +styled against the unit cap, which depends on the *total* seated count — so filling one seat must +restyle every seat. A per-slot var would leave the others stale (only the changed seat's var fires); a +single table rebuilt whole keeps the board consistent. The de-dup then compares a **signature** of the +rendered fields so an unchanged rebroadcast still re-publishes nothing. diff --git a/lua/ui/lobby/customlobby/models/derived/CustomLobbyModsDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbyModsDerivedModel.lua new file mode 100644 index 00000000000..14e089b6697 --- /dev/null +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbyModsDerivedModel.lua @@ -0,0 +1,276 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- ============================================================================================ +-- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/models/derived/CLAUDE.md. +-- +-- A derived model is a pure function of the authoritative models: it resolves a compact synced field +-- into a rich, ready-to-read bundle and exposes it reactively. Views read it; the **controller never +-- writes it** (there are no write helpers) — to change what it holds, change the source it derives +-- from. That keeps the launch model the single source of truth and this a cache/projection. +-- ============================================================================================ +-- +-- The **derived mods**: the lobby's enabled mods come in two flavours, each stored as a uid set — +-- * **game** (sim) mods — the launch model's `GameMods`, host-dictated + synced; +-- * **UI** mods — this peer's own choice, in prefs (`ModUtilities.GetSelectedUIMods`), never synced. +-- A uid on its own says nothing about the mod, so this model joins each uid to its `ModInfo` (from +-- `/lua/mods.lua`) and **enriches** it with display name / icon / author / version — so the Mods panel +-- (and the tab badge) just read those fields instead of resolving uids and formatting them itself. +-- +-- Unlike scenario / restriction data this needs no disk load or `__blueprints` — `Mods.AllMods()` is +-- already available synchronously in the lobby — which is why it's the simplest of the derived models. +-- +-- NOTE on icons: the mod-select *dialog* keeps its list text-only because one distinct texture per row +-- over a big vault leaks (see modselect/CLAUDE.md). Here we only ever show the **enabled** mods (a +-- handful), and re-rendering reuses the engine's by-name texture cache, so the icon count is bounded — +-- the same bounded trickle the single map preview accepts. +-- +-- LIFETIME. It is a `ClassSimple` singleton implementing `Destroyable`, registered in the session +-- trash bag (see CustomLobbySession) on first access, so one `CustomLobbySession.Teardown()` frees its +-- `Mods` LazyVar and severs its launch-model subscription instead of leaking them for the whole match. +-- The module functions are thin facades. See the scenario derived model + the map catalog for the +-- pattern. + +local Create = import("/lua/lazyvar.lua").Create +local Derive = import("/lua/lazyvar.lua").Derive +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") +local ModUtilities = import("/lua/ui/modutilities.lua") +local Mods = import("/lua/mods.lua") + +-- shown when a mod declares no icon (or its icon file is missing) — matches the mod-select catalog +local FallbackIcon = '/textures/ui/common/dialogs/mod-manager/generic-icon_bmp.dds' + +------------------------------------------------------------------------------- +--#region Shape + +--- One enriched enabled mod. +---@class UICustomLobbyMod +---@field Uid string +---@field Name string # formatted display name (version suffix stripped, capitalised) +---@field Icon string # icon texture path — always set (FallbackIcon when the mod has none) +---@field Author string # formatted author ("UNKNOWN" when none) +---@field Version string # formatted version ("vN", or "" when none) +---@field UiOnly boolean + +--- A group of enabled mods (game / ui). +---@class UICustomLobbyModGroup +---@field Key 'game' | 'ui' +---@field Title string +---@field Mods UICustomLobbyMod[] # sorted by name + +--- The fully-derived mods view. +---@class UICustomLobbyMods +---@field Groups UICustomLobbyModGroup[] # always two, in order: game, ui +---@field GameCount number +---@field UiCount number + +--#endregion + +------------------------------------------------------------------------------- +--#region Derived model + +-- The singleton, forward-declared above the class so the class methods (`Destroy`) capture it as an +-- upvalue. Assigned in `SetupSingleton`, cleared in `Destroy`. +---@type UICustomLobbyModsDerivedModel | nil +local Instance = nil + +--- Enriches one mod uid against the all-mods table; returns a minimal entry for an unknown uid. +---@param uid string +---@param allMods table +---@return UICustomLobbyMod +local function EnrichMod(uid, allMods) + local mod = allMods[uid] + if not mod then + return { Uid = uid, Name = uid, Icon = FallbackIcon, Author = "", Version = "", UiOnly = false } + end + + local icon = mod.icon + if not icon or icon == "" or not DiskGetFileInfo(icon) then + icon = FallbackIcon + end + return { + Uid = uid, + Name = ModUtilities.FormatName(mod), + Icon = icon, + Author = ModUtilities.FormatAuthor(mod), + Version = ModUtilities.FormatVersion(mod), + UiOnly = mod.ui_only and true or false, + } +end + +--- Builds the enriched, name-sorted mod list for one uid set. +---@param uidSet table +---@param allMods table +---@return UICustomLobbyMod[] +local function BuildGroup(uidSet, allMods) + local mods = {} + for uid in uidSet do + table.insert(mods, EnrichMod(uid, allMods)) + end + table.sort(mods, function(a, b) return string.upper(a.Name) < string.upper(b.Name) end) + return mods +end + +--- Builds the full mods bundle (game + ui groups + counts) from the current uid sets. +---@param gameMods table +---@param uiMods table +---@return UICustomLobbyMods +local function BuildMods(gameMods, uiMods) + local allMods = Mods.AllMods() + local game = BuildGroup(gameMods, allMods) + local ui = BuildGroup(uiMods, allMods) + return { + Groups = { + { Key = 'game', Title = "Game mods", Mods = game }, + { Key = 'ui', Title = "UI mods", Mods = ui }, + }, + GameCount = table.getn(game), + UiCount = table.getn(ui), + } +end + +--- Reactive derived-mods singleton — a `ClassSimple` implementing `Destroyable`, registered in the +--- session trash so one `CustomLobbySession.Teardown()` frees it. **Read-only** — no write helpers; +--- the controller never touches it. It re-derives from the launch model's `GameMods` (+ UI-mod prefs). +---@class UICustomLobbyModsDerivedModel : Destroyable +---@field Trash TrashBag # owns the Mods var + the observer (freed on Destroy) +---@field Mods LazyVar +---@field Observer LazyVar # internal: re-derives on GameMods change +---@field LoadedSignature string | false # signature of the last-published game+ui sets — the de-dup key +---@field Destroyed boolean +local ModsModel = ClassSimple { + + ---@param self UICustomLobbyModsDerivedModel + __init = function(self) + self.Trash = TrashBag() + self.Mods = self.Trash:Add(Create({ Groups = {}, GameCount = 0, UiCount = 0 })) + self.LoadedSignature = false + self.Destroyed = false + + -- re-derive now and whenever the (reactive) sim mods change (`Derive` fires synchronously on + -- creation). Pinned on `self.Observer` AND in the trash, so it isn't GC'd and Destroy frees it. + local launch = CustomLobbyLaunchModel.GetSingleton() + self.Observer = self.Trash:Add(Derive(launch.GameMods, function(lazy) + lazy() + self:Recompute() + end)) + end, + + --- Re-derives the mods bundle from the current launch state + UI-mod prefs and publishes it — + --- unless the enabled game+ui sets are unchanged, in which case it's a no-op (the de-dup): the host + --- re-sets `GameMods` to an equal value on every launch-info rebroadcast, so without this the Mods + --- panel would rebuild its icon rows on every unrelated option change. + --- NOTE: UI mods are prefs, not a reactive field, so they're re-read here whenever the (reactive) + --- sim mods change — the "good enough until the mod dialog is rewired" caveat the panel/badge had. + ---@param self UICustomLobbyModsDerivedModel + Recompute = function(self) + local gameMods = CustomLobbyLaunchModel.GetSingleton().GameMods() + local uiMods = ModUtilities.GetSelectedUIMods() + -- order-independent signature of the enabled game + ui sets (sorted uids joined); "\1" between + -- uids and "\2" between the two sets so {game={A}} and {ui={A}} can't share a signature + local signature = table.concatkeys(gameMods, "\1") .. "\2" .. table.concatkeys(uiMods, "\1") + if signature == self.LoadedSignature then + return + end + self.LoadedSignature = signature + self.Mods:Set(BuildMods(gameMods, uiMods)) + end, + + --- `Destroyable`: frees the `Mods` var + the observer subscription and clears the module singleton, + --- so the next access rebuilds and re-registers in the next session's trash. Idempotent. + ---@param self UICustomLobbyModsDerivedModel + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + self.Trash:Destroy() -- frees the Mods LazyVar + the launch-model subscription + if Instance == self then + Instance = nil + end + end, +} + +--- Allocates a fresh mods-model singleton and registers it in the session trash. (Because `Derive` +--- fires synchronously on creation, the current mods resolve immediately.) +---@return UICustomLobbyModsDerivedModel +function SetupSingleton() + Instance = ModsModel() + CustomLobbySession.GetTrash():Add(Instance) + return Instance +end + +--- Returns the mods-model singleton, creating (and registering) it on first access — including after +--- a teardown, so it is reusable across lobby sessions. +---@return UICustomLobbyModsDerivedModel +function GetSingleton() + if not Instance then + SetupSingleton() + end + return Instance --[[@as UICustomLobbyModsDerivedModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Accessors + +--- The reactive mods var — subscribe to it (via `Derive`) to react when the sim mods change. +---@return LazyVar +function GetModsVar() + return GetSingleton().Mods +end + +--- The current enriched mods bundle. +---@return UICustomLobbyMods +function GetMods() + return GetSingleton().Mods() +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: destroy the old singleton (severing its subscription) and rebuild via the new +--- module so its observer re-subscribes and re-derives. The bundle is fully derived from the launch +--- model + prefs, so there is no state to copy across. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if Instance then + Instance:Destroy() + newModule.SetupSingleton() + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/models/derived/CustomLobbyOptionsDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbyOptionsDerivedModel.lua new file mode 100644 index 00000000000..fa2e127a424 --- /dev/null +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbyOptionsDerivedModel.lua @@ -0,0 +1,380 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- ============================================================================================ +-- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/models/derived/CLAUDE.md. +-- +-- A derived model is a pure function of the authoritative models: it resolves compact synced fields +-- into a rich, ready-to-read bundle and exposes it reactively. Views read it; the **controller never +-- writes it** (there are no write helpers) — to change what it holds, change the source fields it +-- derives from. That keeps the launch model the single source of truth and this a cache/projection. +-- ============================================================================================ +-- +-- The **derived game options**: the launch model stores options in their most compact form — a flat +-- `GameOptions` value table (`key -> chosen value-key`). On its own that says nothing about what an +-- option *is* (its label, help, possible values, which category it belongs to, what its default is). +-- That meaning lives in the option *schema*, gathered from three sources: +-- * **lobby** — the static base options (lobbyOptions.lua), via `/lua/ui/optionutil.lua`; +-- * **scenario** — the selected map's `_options.lua`, via the map catalog's `LoadOptions`; +-- * **mods** — each selected sim mod's lobby options, via `/lua/ui/optionutil.lua`. +-- +-- This model joins the two: it splits the options into those three categories and **enriches** each +-- one with its localized text + help, its currently-chosen value (display + key) and whether that is +-- the default — so consumers (the Options panel, the Options tab badge) just read the field they need +-- instead of re-gathering the schema and re-interpreting values themselves. +-- +-- **Schema caching.** Gathering the schema is disk work (the map's `_options.lua` is a `doscript`, +-- mod options are file reads), and it only changes when the scenario or the mod set changes — not when +-- a value changes. So the schema is cached and rebuilt only when its inputs change (keyed by scenario +-- file + mod set); a value edit just re-enriches the cached schema, which is cheap. (The old Options +-- panel re-read those files on *every* refresh — i.e. every option tweak.) +-- +-- It reads the scenario from the scenario derived model (already deduped), and the mod set + values +-- from the launch model. Reference data, never on the wire — every peer derives its own. +-- +-- LIFETIME. It is a `ClassSimple` implementing `Destroyable`, registered in the session trash bag (see +-- CustomLobbySession) on first access, so one `CustomLobbySession.Teardown()` frees its `Options` +-- LazyVar, severs its 3 subscriptions and drops the cached schema — instead of leaking them for the +-- whole match. The schema cache lives on the instance, so a new session re-gathers it fresh. See the +-- scenario / mods / restrictions / slots models for the pattern. + +local Create = import("/lua/lazyvar.lua").Create +local Derive = import("/lua/lazyvar.lua").Derive +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua") +local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") +local OptionUtil = import("/lua/ui/optionutil.lua") + +------------------------------------------------------------------------------- +--#region Shape + +--- Where an option comes from — `false` for the lobby base options, else the map / mod it belongs to. +---@class UICustomLobbyOptionOrigin +---@field Kind 'scenario' | 'mod' +---@field Name string # the map name / the mod name + +--- One enriched option: the schema entry joined with its current value. +---@class UICustomLobbyOption +---@field Key string +---@field Label string # LOC'd display text +---@field Help string | false # LOC'd help/description, or false when none +---@field ValueKey any # the chosen value-key in effect (stored, else default) +---@field ValueText string # the chosen value's display text (its `text`, not its key) +---@field ValueHelp string | false # LOC'd help for the chosen value, or false when none +---@field IsDefault boolean # whether the value in effect is the option's default +---@field Origin UICustomLobbyOptionOrigin | false +---@field Option ScenarioOption # the raw schema entry (escape hatch: value list, etc.) + +--- A category of options (lobby / scenario / mods). +---@class UICustomLobbyOptionCategory +---@field Key 'lobby' | 'scenario' | 'mods' +---@field Title string +---@field Options UICustomLobbyOption[] + +--- The fully-derived options view: the three categories + the count changed from default. +---@class UICustomLobbyOptions +---@field Categories UICustomLobbyOptionCategory[] # always three, in order: lobby, scenario, mods +---@field NonDefaultCount number # options changed from their default (backs the badge) + +--#endregion + +------------------------------------------------------------------------------- +--#region Derived model + +-- The singleton, forward-declared above the class so the class methods (`Destroy`) capture it as an +-- upvalue. Assigned in `SetupSingleton`, cleared in `Destroy`. The schema cache lives on the instance +-- (`self.Schema` / `self.SchemaKey`), so it resets with each new session's model. +---@type UICustomLobbyOptionsDerivedModel | nil +local Instance = nil + +--- An empty (no-options) bundle — the initial value, before the first derivation runs. +---@return UICustomLobbyOptions +local function EmptyOptions() + return { + Categories = { + { Key = 'lobby', Title = "Lobby", Options = {} }, + { Key = 'scenario', Title = "Scenario", Options = {} }, + { Key = 'mods', Title = "Mods", Options = {} }, + }, + NonDefaultCount = 0, + } +end + +--- A stable key for the schema inputs: scenario file + the sorted selected mod uids. +---@param scenarioFile FileName | false +---@param gameMods table +---@return string +local function SchemaKeyFor(scenarioFile, gameMods) + local uids = {} + for uid in (gameMods or {}) do + table.insert(uids, uid) + end + table.sort(uids) + return tostring(scenarioFile) .. "|" .. table.concat(uids, ",") +end + +--- (Re)gathers the option schema if the scenario / mod set changed since last time; otherwise keeps +--- the cache. This is the only disk-touching step, so it is deduped by `self.SchemaKey`. +---@param self UICustomLobbyOptionsDerivedModel +---@param scenario UICustomLobbyScenario | false +---@param gameMods table +local function EnsureSchema(self, scenario, gameMods) + local scenarioFile = scenario and scenario.File or false + local key = SchemaKeyFor(scenarioFile, gameMods) + if self.Schema and key == self.SchemaKey then + return + end + self.SchemaKey = key + self.Schema = { + Lobby = OptionUtil.GetLobbyOptions(), + Scenario = CustomLobbyMapCatalog.LoadOptions(scenarioFile), + ScenarioName = scenario and scenario.Name or false, + ModGroups = OptionUtil.GetModOptionsByMod(gameMods or {}), + } +end + +--- Joins one schema option with the current values into an enriched option. +---@param option ScenarioOption +---@param values table +---@param origin UICustomLobbyOptionOrigin | false +---@return UICustomLobbyOption +local function EnrichOption(option, values, origin) + local valueKey = OptionUtil.GetCurrentValueKey(option, values) + -- the value *entry* in effect (a `{ key, text, help }` table, or a bare value) — resolve it from + -- the key so we show its `text` (not the raw key) and can surface its `help` + local valueEntry = option.values and option.values[OptionUtil.FindValueIndex(option, valueKey)] + return { + Key = option.key, + Label = LOC(option.label) or option.key, + Help = (option.help and LOC(option.help)) or false, + ValueKey = valueKey, + ValueText = OptionUtil.ValueDisplay(option, valueEntry), + ValueHelp = (type(valueEntry) == 'table' and valueEntry.help and LOC(valueEntry.help)) or false, + IsDefault = OptionUtil.IsDefault(option, values), + Origin = origin, + Option = option, + } +end + +--- Builds the full enriched, categorized options bundle for the current schema + values. +---@param self UICustomLobbyOptionsDerivedModel +---@param scenario UICustomLobbyScenario | false +---@param gameMods table +---@param values table +---@return UICustomLobbyOptions +local function BuildOptions(self, scenario, gameMods, values) + EnsureSchema(self, scenario, gameMods) + values = values or {} + + local lobby = {} + for _, option in self.Schema.Lobby do + table.insert(lobby, EnrichOption(option, values, false)) + end + + local scenarioOrigin = { Kind = 'scenario', Name = self.Schema.ScenarioName or "the map" } + local scenarioOpts = {} + for _, option in self.Schema.Scenario do + table.insert(scenarioOpts, EnrichOption(option, values, scenarioOrigin)) + end + + local mods = {} + for _, group in self.Schema.ModGroups do + local origin = { Kind = 'mod', Name = group.name } + for _, option in group.options do + table.insert(mods, EnrichOption(option, values, origin)) + end + end + + -- count changed-from-default (mirrors OptionUtil.CountNonDefault: lobby + scenario as-is, mods + -- deduped by key so a key two mods both declare is counted once) + local count = 0 + for _, e in lobby do if not e.IsDefault then count = count + 1 end end + for _, e in scenarioOpts do if not e.IsDefault then count = count + 1 end end + local seenMod = {} + for _, e in mods do + if not seenMod[e.Key] then + seenMod[e.Key] = true + if not e.IsDefault then count = count + 1 end + end + end + + return { + Categories = { + { Key = 'lobby', Title = "Lobby", Options = lobby }, + { Key = 'scenario', Title = "Scenario", Options = scenarioOpts }, + { Key = 'mods', Title = "Mods", Options = mods }, + }, + NonDefaultCount = count, + } +end + +--- Reactive derived-options singleton — a `ClassSimple` implementing `Destroyable`, registered in the +--- session trash so one `CustomLobbySession.Teardown()` frees it. **Read-only** — no write helpers; the +--- controller never touches it. It re-derives from the launch model's `GameOptions` / `GameMods` and +--- the scenario derived model, caching the disk-gathered option schema on the instance. +---@class UICustomLobbyOptionsDerivedModel : Destroyable +---@field Trash TrashBag # owns the Options var + the 3 observers (freed on Destroy) +---@field Options LazyVar +---@field Observers LazyVar[] # internal: scenario/mods/options subscriptions (strong refs; also in Trash) +---@field Schema table | false # cached option schema (lobby/scenario/mod), keyed by SchemaKey — the *disk* dedup +---@field SchemaKey string | false # scenario file + sorted mod uids the Schema was gathered for +---@field LoadedFile FileName | false # publish-dedup: the scenario file of the last published bundle +---@field LoadedMods table | false # publish-dedup: the sim-mod set of the last published bundle (held snapshot) +---@field LoadedValues table | false # publish-dedup: the option values of the last published bundle (false until first publish) +---@field Destroyed boolean +local OptionsModel = ClassSimple { + + ---@param self UICustomLobbyOptionsDerivedModel + __init = function(self) + self.Trash = TrashBag() + self.Options = self.Trash:Add(Create(EmptyOptions())) + self.Observers = {} + self.Schema = false + self.SchemaKey = false + self.LoadedFile = false + self.LoadedMods = false + self.LoadedValues = false -- false until the first publish (the "have we published" guard) + self.Destroyed = false + + -- re-derive on a scenario / mod-set / option-value change. Each observer is kept strongly on + -- self.Observers (the trash is weak-valued) AND added to the trash, so it isn't GC'd and Destroy + -- severs all three. `Derive` fires synchronously on creation → the options resolve now. + local function subscribe(source) + table.insert(self.Observers, self.Trash:Add(Derive(source, function(lazy) + lazy() + self:Recompute() + end))) + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + subscribe(CustomLobbyScenarioDerivedModel.GetScenarioVar()) + subscribe(launch.GameMods) + subscribe(launch.GameOptions) + end, + + --- Re-derives the options bundle from the current scenario + mods + values and publishes it. The + --- schema is re-gathered (disk) only when the scenario / mod set changed (the `SchemaKey` cache); + --- a value-only change just re-enriches the cached schema. + ---@param self UICustomLobbyOptionsDerivedModel + Recompute = function(self) + local scenario = CustomLobbyScenarioDerivedModel.GetScenario() + local launch = CustomLobbyLaunchModel.GetSingleton() + local file = scenario and scenario.File or false + local gameMods, values = launch.GameMods(), launch.GameOptions() + + -- publish-dedup: the bundle is a pure function of (scenario file, sim-mod set, option values), + -- so skip the re-publish *and* the enrichment when all three are unchanged. `table.equal` is the + -- cheap count + per-value compare; copy-then-Set means the held tables are stable snapshots. + -- `self.LoadedValues` is false until the first publish, which forces that first build. (This is + -- separate from the SchemaKey cache, which dedups the *disk* read inside BuildOptions.) + if self.LoadedValues + and file == self.LoadedFile + and table.equal(gameMods, self.LoadedMods) + and table.equal(values, self.LoadedValues) then + return + end + self.LoadedFile = file + self.LoadedMods = gameMods + self.LoadedValues = values + self.Options:Set(BuildOptions(self, scenario, gameMods, values)) + end, + + --- `Destroyable`: frees the `Options` var + the 3 subscriptions (so they stop firing) and clears the + --- module singleton, so the next access rebuilds and re-registers in the next session's trash. The + --- cached schema dies with the instance. Idempotent. + ---@param self UICustomLobbyOptionsDerivedModel + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + self.Trash:Destroy() -- frees the Options LazyVar + all observer subscriptions + if Instance == self then + Instance = nil + end + end, +} + +--- Allocates a fresh options-model singleton and registers it in the session trash. (Because `Derive` +--- fires synchronously on creation, the options resolve immediately.) +---@return UICustomLobbyOptionsDerivedModel +function SetupSingleton() + Instance = OptionsModel() + CustomLobbySession.GetTrash():Add(Instance) + return Instance +end + +--- Returns the options-model singleton, creating (and registering) it on first access — including +--- after a teardown, so it is reusable across lobby sessions. +---@return UICustomLobbyOptionsDerivedModel +function GetSingleton() + if not Instance then + SetupSingleton() + end + return Instance --[[@as UICustomLobbyOptionsDerivedModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Accessors + +--- The reactive options var — subscribe to it (via `Derive`) to react when options/values change. +---@return LazyVar +function GetOptionsVar() + return GetSingleton().Options +end + +--- The current enriched, categorized options bundle. +---@return UICustomLobbyOptions +function GetOptions() + return GetSingleton().Options() +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: destroy the old singleton (severing its subscriptions + dropping its schema cache) +--- and rebuild via the new module. The bundle is fully derived from the launch + scenario models, so +--- there is no state to copy across. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if Instance then + Instance:Destroy() + newModule.SetupSingleton() + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/models/derived/CustomLobbyRestrictionsDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbyRestrictionsDerivedModel.lua new file mode 100644 index 00000000000..03a4bb56f18 --- /dev/null +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbyRestrictionsDerivedModel.lua @@ -0,0 +1,233 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- ============================================================================================ +-- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/models/derived/CLAUDE.md. +-- +-- A derived model is a pure function of the authoritative models: it resolves a compact synced field +-- into a rich, ready-to-read bundle and exposes it reactively. Views read it; the **controller never +-- writes it** (there are no write helpers) — to change what it holds, change the source field it +-- derives from. That keeps the launch model the single source of truth and this a cache/projection. +-- ============================================================================================ +-- +-- The **derived unit restrictions**: the launch model stores restrictions in their most compact form — +-- a list of preset *keys* (e.g. `"T3"`, `"AIR"`). On its own that's just an identifier; what it +-- means (its display name, icon and tooltip) lives in the restriction-preset table in +-- `/lua/ui/lobby/unitsrestrictions.lua`. This model joins the two: it maps each active key to its +-- preset and exposes the enriched item — so consumers (the Restrictions panel, the tab badge) just +-- read `Name` / `Icon` / `Tooltip` instead of looking the preset up themselves. +-- +-- NOTE: a restriction key can also be a **specific unit blueprint id** (not just a preset). Enriching +-- those with a real name + icon is **parked** — `__blueprints` isn't available in the lobby front-end, +-- so it needs the heavier `UnitsAnalyzer` path. See /lua/ui/lobby/customlobby/TODO.md. For now a +-- non-preset key falls through and shows as its raw id. +-- +-- LIFETIME. It is a `ClassSimple` singleton implementing `Destroyable`, registered in the session +-- trash bag (see CustomLobbySession) on first access, so one `CustomLobbySession.Teardown()` frees its +-- `Restrictions` LazyVar and severs its launch-model subscription. See the scenario / mods derived +-- models + the map catalog for the pattern. + +local Create = import("/lua/lazyvar.lua").Create +local Derive = import("/lua/lazyvar.lua").Derive +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") +local UnitsRestrictions = import("/lua/ui/lobby/unitsrestrictions.lua") + +------------------------------------------------------------------------------- +--#region Shape + +--- One enriched restriction: a preset key joined with its preset definition. +---@class UICustomLobbyRestriction +---@field Key string +---@field Name string # LOC'd display name (falls back to the key) +---@field Icon string | false # preset icon texture path, or false when the preset has none +---@field Tooltip string | false # LOC'd tooltip body, or false when none + +--- The fully-derived restrictions view. +---@class UICustomLobbyRestrictions +---@field Items UICustomLobbyRestriction[] # one per active preset key, in the launch model's order +---@field Count number + +--#endregion + +------------------------------------------------------------------------------- +--#region Derived model + +-- The singleton, forward-declared above the class so the class methods (`Destroy`) capture it as an +-- upvalue. Assigned in `SetupSingleton`, cleared in `Destroy`. +---@type UICustomLobbyRestrictionsDerivedModel | nil +local Instance = nil + +--- Resolves one restriction key into an enriched item — a preset (the common case), else a specific +--- unit blueprint, else an unknown key shown as-is. +---@param key string +---@param presets table # UnitsRestrictions.GetPresetsData() +---@return UICustomLobbyRestriction +local function EnrichKey(key, presets) + local preset = presets[key] + if preset then + return { + Key = key, + Name = (preset.name and LOC(preset.name)) or key, + Icon = preset.Icon or false, + Tooltip = (preset.tooltip and LOC(preset.tooltip)) or false, + } + end + + -- a non-preset key is a specific unit blueprint id; enriching it with a real name + icon is + -- parked (needs blueprints, which aren't loaded in the lobby front-end — see TODO.md), so for + -- now it shows as its raw id + return { Key = key, Name = key, Icon = false, Tooltip = false } +end + +--- Builds the enriched, counted restrictions bundle from the active keys (presets and/or unit ids). +---@param keys string[] +---@return UICustomLobbyRestrictions +local function BuildRestrictions(keys) + local presets = UnitsRestrictions.GetPresetsData() + local items = {} + for _, key in keys do + table.insert(items, EnrichKey(key, presets)) + end + return { Items = items, Count = table.getn(keys) } +end + +--- Reactive derived-restrictions singleton — a `ClassSimple` implementing `Destroyable`, registered in +--- the session trash so one `CustomLobbySession.Teardown()` frees it. **Read-only** — no write helpers; +--- the controller never touches it. It re-derives from the launch model's `Restrictions`. +---@class UICustomLobbyRestrictionsDerivedModel : Destroyable +---@field Trash TrashBag # owns the Restrictions var + the observer (freed on Destroy) +---@field Restrictions LazyVar +---@field Observer LazyVar # internal: re-derives on Restrictions change +---@field LoadedSignature string | false # order-independent signature of the last-published key set — the de-dup key +---@field Destroyed boolean +local RestrictionsModel = ClassSimple { + + ---@param self UICustomLobbyRestrictionsDerivedModel + __init = function(self) + self.Trash = TrashBag() + self.Restrictions = self.Trash:Add(Create({ Items = {}, Count = 0 })) + self.LoadedSignature = false + self.Destroyed = false + + -- re-derive now and on every change (`Derive` fires synchronously on creation). Pinned on + -- `self.Observer` AND in the trash, so it isn't GC'd and Destroy frees it. + local launch = CustomLobbyLaunchModel.GetSingleton() + self.Observer = self.Trash:Add(Derive(launch.Restrictions, function(lazy) + lazy() + self:Recompute() + end)) + end, + + --- Re-derives the restrictions bundle and publishes it — unless the active set is unchanged, in + --- which case it's a no-op (the de-dup): `LazyVar:Set` always re-fires, and the host re-sets + --- `Restrictions` to the same list on every launch-info rebroadcast, so without this the panel would + --- rebuild its icon rows on every unrelated option change. + ---@param self UICustomLobbyRestrictionsDerivedModel + Recompute = function(self) + local keys = CustomLobbyLaunchModel.GetSingleton().Restrictions() + -- order-independent signature of the active keys (sorted, joined) — a reorder isn't a change + local signature = table.concat(table.sorted(keys), "\1") + if signature == self.LoadedSignature then + return + end + self.LoadedSignature = signature + self.Restrictions:Set(BuildRestrictions(keys)) + end, + + --- `Destroyable`: frees the `Restrictions` var + the observer subscription and clears the module + --- singleton, so the next access rebuilds and re-registers in the next session's trash. Idempotent. + ---@param self UICustomLobbyRestrictionsDerivedModel + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + self.Trash:Destroy() -- frees the Restrictions LazyVar + the launch-model subscription + if Instance == self then + Instance = nil + end + end, +} + +--- Allocates a fresh restrictions-model singleton and registers it in the session trash. (Because +--- `Derive` fires synchronously on creation, the current restrictions resolve immediately.) +---@return UICustomLobbyRestrictionsDerivedModel +function SetupSingleton() + Instance = RestrictionsModel() + CustomLobbySession.GetTrash():Add(Instance) + return Instance +end + +--- Returns the restrictions-model singleton, creating (and registering) it on first access — including +--- after a teardown, so it is reusable across lobby sessions. +---@return UICustomLobbyRestrictionsDerivedModel +function GetSingleton() + if not Instance then + SetupSingleton() + end + return Instance --[[@as UICustomLobbyRestrictionsDerivedModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Accessors + +--- The reactive restrictions var — subscribe to it (via `Derive`) to react when restrictions change. +---@return LazyVar +function GetRestrictionsVar() + return GetSingleton().Restrictions +end + +--- The current enriched restrictions bundle. +---@return UICustomLobbyRestrictions +function GetRestrictions() + return GetSingleton().Restrictions() +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: destroy the old singleton (severing its subscription) and rebuild via the new +--- module. The bundle is fully derived from the launch model, so there is no state to copy across. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if Instance then + Instance:Destroy() + newModule.SetupSingleton() + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/models/derived/CustomLobbyScenarioDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbyScenarioDerivedModel.lua new file mode 100644 index 00000000000..1f295065b8a --- /dev/null +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbyScenarioDerivedModel.lua @@ -0,0 +1,250 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- ============================================================================================ +-- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/models/derived/CLAUDE.md. +-- +-- A derived model is a pure function of the authoritative models: it resolves a compact synced field +-- into a rich, ready-to-read bundle and exposes it reactively. Views read it; the **controller never +-- writes it** (there are no write helpers) — to change what it holds, change the source field it +-- derives from. That keeps the launch model the single source of truth and this a cache/projection. +-- ============================================================================================ +-- +-- The **derived scenario** state: the launch model stores the map in its most compact form — a single +-- `ScenarioFile` path. Turning that into something a view can use (name, size, player count, start +-- spots, resource/wreck markers, the map texture) means loading the scenario off disk. This model does +-- that fishing **once** and exposes the result, so every consumer (the map preview, the facts line, the +-- rules) just reads the field it needs instead of re-resolving the file itself. +-- +-- It is reactive (a `Scenario` LazyVar) like the lobby models, but it is **derived, not authoritative** +-- — it holds no host-dictated state and never goes on the wire. It is purely a function of the launch +-- model's `ScenarioFile`. +-- +-- **De-duplication.** `LazyVar:Set` always re-fires its observers, even when the value is unchanged — +-- and the host rebroadcasts the whole launch info (re-setting `ScenarioFile` to the *same* path) on any +-- option tweak. So an internal observer dedups by file: the same scenario arriving twice is a no-op, +-- and the `Scenario` var only re-fires (→ the preview reloads, the facts re-render) on an actual change. +-- +-- Scenario is the first derived model. Mods (compact UUIDs → full mod info), restrictions and the slot +-- info are the planned siblings; they follow this same shape (see the folder's CLAUDE.md). +-- +-- LIFETIME. It is a `ClassSimple` singleton implementing `Destroyable`, registered in the session +-- trash bag (see CustomLobbySession) on first access. So one `CustomLobbySession.Teardown()` frees its +-- `Scenario` LazyVar and severs its launch-model subscription, instead of leaking them into the +-- persistent front-end Lua state for the whole match. The module functions are thin facades over the +-- singleton. This is the first derived model converted to the singleton-teardown pattern; the catalog +-- (mapselect/CustomLobbyMapCatalog) is the original worked example. + +local Create = import("/lua/lazyvar.lua").Create +local Derive = import("/lua/lazyvar.lua").Derive +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") + +------------------------------------------------------------------------------- +--#region Shape + +--- The fully-resolved current scenario. Holds the lightweight `_scenario.lua` info (the surface needs +--- it for the map texture + size) and the *extracted* save markers — never the raw save itself. +---@class UICustomLobbyScenario +---@field File FileName # the file this was resolved from +---@field Info UILobbyScenarioInfo # _scenario.lua: preview, map, size, Configurations +---@field Markers UICustomLobbyScenarioMarkers # extracted save bits (spawns + mass/hydro/wreck points) +---@field MaxDimension number # largest map dimension in ogrids (0 if unknown) +---@field ArmyCount number # number of start spots the scenario declares +---@field Name string # LOC'd display name +---@field Size table | false # {x, z} in ogrids, or false +---@field Version number | false # map_version, or false + +--#endregion + +------------------------------------------------------------------------------- +--#region Derived model + +-- The singleton, forward-declared above the class so the class methods (`Destroy`) capture it as an +-- upvalue rather than resolving it as a global. Assigned in `SetupSingleton`, cleared in `Destroy`. +---@type UICustomLobbyScenarioDerivedModel | nil +local Instance = nil + +--- Number of start spots a scenario declares, from its standard configuration (0 if none). +---@param info UILobbyScenarioInfo +---@return number +local function ArmyCount(info) + local armies = info.Configurations + and info.Configurations.standard + and info.Configurations.standard.teams + and info.Configurations.standard.teams[1] + and info.Configurations.standard.teams[1].armies + return armies and table.getsize(armies) or 0 +end + +--- Resolves a scenario file into the rich bundle (the one place the disk work happens), or false if +--- the file can't be read. Reuses the catalog's loaders — no new disk code here. +---@param file FileName +---@return UICustomLobbyScenario | false +local function Resolve(file) + local info = CustomLobbyMapCatalog.LoadInfo(file) + if type(info) ~= "table" then + return false + end + + local size = info.size or false + local maxDimension = 0 + if size then + maxDimension = math.max(size[1] or 0, size[2] or 0) + end + + return { + File = file, + Info = info, + Markers = CustomLobbyMapCatalog.LoadMarkers(info), + MaxDimension = maxDimension, + ArmyCount = ArmyCount(info), + Name = LOC(info.name) or "?", + Size = size, + Version = info.map_version or false, + } +end + +--- Reactive derived-scenario singleton — a `ClassSimple` implementing `Destroyable`, registered in +--- the session trash so one `CustomLobbySession.Teardown()` frees it. **Read-only** — no write +--- helpers; the controller never touches it. It re-derives from the launch model's `ScenarioFile`. +---@class UICustomLobbyScenarioDerivedModel : Destroyable +---@field Trash TrashBag # owns the Scenario var + the observer (freed on Destroy) +---@field Scenario LazyVar # the resolved scenario, or false when none / unreadable +---@field Observer LazyVar # internal: resolves ScenarioFile (deduped) +---@field LoadedFile string | false # the file currently resolved (lowercased) — the de-dup key +---@field Destroyed boolean +local ScenarioModel = ClassSimple { + + ---@param self UICustomLobbyScenarioDerivedModel + __init = function(self) + self.Trash = TrashBag() + self.Scenario = self.Trash:Add(Create(false)) + self.LoadedFile = false + self.Destroyed = false + + -- resolve the current scenario now and on every change (`Derive` fires synchronously on + -- creation). Pinned on `self.Observer` AND in the trash, so it isn't GC'd and Destroy frees it. + local launch = CustomLobbyLaunchModel.GetSingleton() + self.Observer = self.Trash:Add(Derive(launch.ScenarioFile, function(scenarioFileLazy) + self:OnScenarioFileChanged(scenarioFileLazy()) + end)) + end, + + --- Resolves `file` into the bundle, but skips the work (and the re-fire) when it is the same + --- scenario we already hold — that is the de-dup. + ---@param self UICustomLobbyScenarioDerivedModel + ---@param file FileName | false + OnScenarioFileChanged = function(self, file) + local key = (type(file) == "string") and string.lower(file) or false + if key == self.LoadedFile then + return + end + self.LoadedFile = key + + if not file then + self.Scenario:Set(false) + return + end + self.Scenario:Set(Resolve(file) or false) + end, + + --- `Destroyable`: frees the `Scenario` var + the observer subscription (so it stops firing) and + --- clears the module singleton, so the next access rebuilds a fresh model and re-registers it in + --- the next session's trash. Idempotent. Called by the session trash on `Teardown()`. + ---@param self UICustomLobbyScenarioDerivedModel + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + self.Trash:Destroy() -- frees the Scenario LazyVar + the launch-model subscription + if Instance == self then + Instance = nil + end + end, +} + +--- Allocates a fresh scenario-model singleton and registers it in the session trash. (Because `Derive` +--- fires synchronously on creation, the current scenario resolves immediately.) +---@return UICustomLobbyScenarioDerivedModel +function SetupSingleton() + Instance = ScenarioModel() + CustomLobbySession.GetTrash():Add(Instance) + return Instance +end + +--- Returns the scenario-model singleton, creating (and registering) it on first access — including +--- after a teardown, so it is reusable across lobby sessions. +---@return UICustomLobbyScenarioDerivedModel +function GetSingleton() + if not Instance then + SetupSingleton() + end + return Instance --[[@as UICustomLobbyScenarioDerivedModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Accessors + +--- The reactive scenario var — subscribe to it (via `Derive`) to react when the map actually changes. +---@return LazyVar +function GetScenarioVar() + return GetSingleton().Scenario +end + +--- The current resolved scenario (or false when no map / unreadable). For pull-based callers (rules). +---@return UICustomLobbyScenario | false +function GetScenario() + return GetSingleton().Scenario() +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: destroy the old singleton (severing its subscription) and rebuild via the new +--- module so its observer re-subscribes and re-resolves the current scenario. The bundle is fully +--- derived from `ScenarioFile`, so there is no state to copy across. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if Instance then + Instance:Destroy() + newModule.SetupSingleton() + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua b/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua new file mode 100644 index 00000000000..983887c3b0a --- /dev/null +++ b/lua/ui/lobby/customlobby/models/derived/CustomLobbySlotsDerivedModel.lua @@ -0,0 +1,597 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- ============================================================================================ +-- DERIVED MODEL — read-only. See /lua/ui/lobby/customlobby/models/derived/CLAUDE.md. +-- +-- A derived model is a pure function of the authoritative models: it resolves compact synced fields +-- into a rich, ready-to-read bundle and exposes it reactively. Views read it; the **controller never +-- writes it** (there are no write helpers) — to change what it holds, change the source it derives +-- from. That keeps the launch / session / local models the single source of truth and this a projection. +-- ============================================================================================ +-- +-- The **derived slots**: a single lookup table, one entry per slot, that merges *every* model that has +-- something to say about a seat into one place so a slot view reads one field instead of joining four +-- models itself. Each entry combines — +-- * the seated **player** (launch model, synced) — name / colour / faction / team / ready; +-- * the scenario **placement** (the derived scenario model) — the start spot's map position, i.e. +-- *where* on the map this seat sits, plus the per-player unit cap the map size implies; +-- * the **closed** flag (session model, synced lobby-room state, not launched); +-- * the **CPU benchmark** (local model, per-peer, never synced and *not part of the game*) — the +-- sim-performance projection the slot shows as a unit count + green→red headroom indicator; +-- * the **binary auto-team side** (1/2/false) the seat resolves to, applied once from the rule in +-- `CustomLobbyRules` so the two-column layout reads `entry.Side` instead of re-resolving it. +-- The slot/faction/CPU formatting that used to live in each slot control now lives here, once, so the +-- presentations (the thin row + the fat card) are pure arrangement over a resolved view. +-- +-- **Two read faces.** Per-seat `Slots` (the rows/cards) and the board's binary auto-team aggregate +-- `Teams` (the team-score strip — side labels + per-side rating totals). Each is deduped by its own +-- signature, so a player's *rating* change re-fires only `Teams` (a total moved) without re-rendering +-- the rows, and a mode/map change that moves the split re-fires both. The side rule itself stays in +-- `CustomLobbyRules` (the controller will reuse it at launch); this model only *applies* it once. +-- +-- **Why one table, not a var per slot.** The CPU indicator's colour depends on the recommended unit +-- cap, which depends on the *total seated count* — so a seat filling anywhere restyles every other +-- seat's CPU bar. A per-slot var would leave those stale (only the changed slot's var fires); a single +-- table rebuilt whole keeps the whole board consistent. The user-facing model is literally "the slots, +-- looked up by index". +-- +-- **De-duplication.** `LazyVar:Set` always re-fires, and the host rebroadcasts the whole launch info +-- (re-setting every `Players[slot]` to an equal value) on any option tweak. So the rebuild compares a +-- signature of the rendered fields and only re-publishes `Slots` when something visible actually +-- changed — a rebroadcast of an unchanged board is a no-op, not 16 needless re-renders per seat. +-- +-- LIFETIME. It is a `ClassSimple` implementing `Destroyable`, registered in the session trash bag (see +-- CustomLobbySession) on first access. So one `CustomLobbySession.Teardown()` frees its `Slots` + +-- `Teams` LazyVars and severs *all* its subscriptions (every slot's player, closed slots, CPU +-- benchmarks, the scenario, game options) — instead of leaking ~20 observers for the whole match. The +-- module functions are thin facades. See the scenario / mods / restrictions models for the pattern. + +local Create = import("/lua/lazyvar.lua").Create +local Derive = import("/lua/lazyvar.lua").Derive +local GameColors = import("/lua/gamecolors.lua").GameColors +local Color = import("/lua/shared/color.lua") +local Factions = import("/lua/factions.lua").Factions + +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") +local CustomLobbyScenarioDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyscenarioderivedmodel.lua") +local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") + +local MaxSlots = CustomLobbyLaunchModel.MaxSlots + +------------------------------------------------------------------------------- +--#region Shape + +--- The seated player's resolved presentation (false on an empty seat — the view paints "- open -"). +---@class UICustomLobbySlotPlayerView +---@field colorHex string +---@field name string +---@field nameColor string +---@field faction string +---@field team string +---@field ready string +---@field readyColor string +---@field rating string # the player's displayed rating (PL), "" when unrated +---@field games string # number of games played, "" when none / unknown +---@field flag FileName | false # country flag texture, false when no country +---@field factionIcons FileName[] # the selected factions as icons (single icon = Random when the full set is allowed) + +--- The CPU column's resolved presentation (false when there is no player / no benchmark to show). +---@class UICustomLobbySlotCpuView +---@field text string +---@field textColor string +---@field indicatorColor Color | nil +---@field showIndicator boolean + +--- One fully-resolved slot: the merge of player (launch) + placement (scenario) + closed (session) + +--- CPU benchmark (local). Carries both the ready-to-paint views and the raw refs interaction needs. +---@class UICustomLobbySlot +---@field IsLocalPeer boolean # true when the seated player is the local peer +---@field Slot number # slot index 1..MaxSlots +---@field Player UICustomLobbyPlayer | false # the seated player (raw), false when empty — for intents/drag +---@field Closed boolean # session: seat closed (no army at launch) +---@field Locked boolean # session: seat pinned in place for auto-balance +---@field StartSpot number | false # the player's chosen start spot +---@field Position table | false # {x, z} of that start spot on the map, or false +---@field Side 1 | 2 | false # binary auto-team side (keyed on start spot, else slot), false when none/unresolved +---@field PlayerView UICustomLobbySlotPlayerView | false +---@field CpuView UICustomLobbySlotCpuView | false +---@field Benchmark UIPerformanceMetrics | false # raw metrics for the owner (the hover popover reads this) +---@field UnitCap number | false # recommended cap used for the indicator (popover reads this) + +--- The board's binary auto-team aggregate (the team-score strip reads this). Derived once from +--- `CustomLobbyRules` (the rule's home) over the resolved per-seat `Side`. `Labels` is false unless the +--- mode forms two well-defined sides; `Resolved` is false for a positional mode whose map isn't loaded. +---@class UICustomLobbyTeams +---@field Mode string | false # the binary AutoTeams mode (tvsb/lvsr/pvsi), or false +---@field Labels table | false # the two side labels {a, b}, or false for a non-binary mode +---@field Resolved boolean # whether the split is determined (false: positional mode, no map yet) +---@field Totals table # accumulated rating per side {a, b} + +--#endregion + +------------------------------------------------------------------------------- +--#region Formatting (single-sourced here; the slot presentations are pure arrangement) + +--- Short faction label for a faction index (5 = Random has no factions.lua entry). +---@param faction number +---@return string +local function FactionLabel(faction) + local data = Factions[faction] + if data then + return data.DisplayName or data.Key or tostring(faction) + end + return "Random" +end + +--- The small "any faction" icon, shown when the whole faction set is allowed (pure random). +local RandomFactionIcon = '/faction_icon-sm/random_ico.dds' + +--- The selected factions as a list of small icons: the full set (or none) reads as pure random and +--- shows a single Random icon; a subset shows that subset's icons; one shows one. So a partial random +--- (e.g. UEF + Cybran) is legible as exactly those two icons. +---@param player UICustomLobbyPlayer +---@return FileName[] +local function FactionIcons(player) + local factions = player.Factions + local count = factions and table.getn(factions) or 0 + if count == 0 or count >= CustomLobbyLaunchModel.RealFactionCount then + return { RandomFactionIcon } + end + local icons = {} + for _, index in factions do + local data = Factions[index] + if data and data.SmallIcon then + table.insert(icons, data.SmallIcon) + end + end + if table.empty(icons) then + return { RandomFactionIcon } + end + return icons +end + +--- The sim-rate categories tracked in PerformanceTrackingV2, ordered for the "most-played" pick. +local PerformanceCategories = { 'Skirmish', 'SkirmishWithAI', 'Campaign' } + +--- The bucket index for a sim rate (index k holds rate k - 11, so +0 -> 11, -4 -> 7). +---@param rate number +---@return number +local function BucketForRate(rate) + return rate + 11 +end + +--- The most-played category in a benchmark, by sample count, or nil if none. +---@param metrics UIPerformanceMetrics | nil +---@return table | nil +local function PickCategory(metrics) + if not metrics then + return nil + end + local best, bestSamples = nil, -1 + for _, key in PerformanceCategories do + local c = metrics[key] + if c and (c.Samples or 0) > bestSamples then + bestSamples = c.Samples or 0 + best = c + end + end + if not best or bestSamples <= 0 then + return nil + end + return best +end + +--- Compact unit count for the slot label (e.g. 1421 -> "1.4k"). +---@param value number +---@return string +local function FormatUnits(value) + if value >= 1000 then + return string.format("%.1fk", value / 1000) + end + return tostring(math.floor(value + 0.5)) +end + +--- Indicator colour for how far the sim has to slow down to sustain the cap: green when +0 already +--- suffices (step 0), fading to red at -4 or worse (step 4). +---@param step number # sim-rate steps below +0 needed to reach the cap (0..4) +---@return Color +local function StepColor(step) + local t = math.clamp(step / 4, 0.0, 1.0) + local hue = (1.0 - t) * 0.333 -- 0.333 turns = green, 0 = red + return Color.ColorHSV(hue, 1.0, 0.85, 1.0) +end + +--- The player presentation for a seated player. +---@param player UICustomLobbyPlayer +---@return UICustomLobbySlotPlayerView +local function BuildPlayerView(player) + -- rating: the displayed (conservative) rating PL; blank for unrated / AI with no rating + local rating = "" + if player.PL and player.PL > 0 then + rating = tostring(math.floor(player.PL + 0.5)) + end + -- games played; blank when none / unknown (e.g. AI) + local games = "" + if player.NG and player.NG > 0 then + games = tostring(player.NG) + end + + return { + colorHex = GameColors.PlayerColors[player.PlayerColor] or 'ffffffff', + name = player.PlayerName or "?", + nameColor = player.Human and 'ffffffff' or 'ffd9c97a', + faction = FactionLabel(player.Faction), + team = (player.Team and player.Team > 1) and ("T" .. (player.Team - 1)) or "-", + ready = player.Ready and "ready" or "", + readyColor = player.Ready and 'ff7ad97a' or 'ff888888', + rating = rating, + games = games, + flag = (player.Country and player.Country ~= "") and ('/countries/' .. player.Country .. '.dds') or false, + factionIcons = FactionIcons(player), + } +end + +--- The CPU presentation for a seated player from its shared sim-performance benchmark: the label is the +--- max units the machine handled at full speed (+0), and the indicator is green if that already covers +--- the recommended cap, fading to red the further the sim must slow (down to -4). Returns false when +--- there is no benchmark to show. +---@param metrics UIPerformanceMetrics | nil +---@param cap number | nil +---@return UICustomLobbySlotCpuView | false +local function BuildCpuView(metrics, cap) + local category = PickCategory(metrics) + local atZero = category and category[BucketForRate(0)] + if not (atZero and atZero.UnitCount) then + return { text = "—", textColor = 'ff9aa0a8', showIndicator = false } + end + + local maxAtZero = atZero.UnitCount.Max or 0 + local view = { text = FormatUnits(maxAtZero), textColor = 'ff9aa0a8', showIndicator = false } + + if cap and cap > 0 then + -- how many sim-rate steps below +0 are needed before the machine sustains the cap (0 = fine at + -- +0); worst case (red) if even -4 falls short + local step = 0 + if maxAtZero < cap then + step = 4 + for s = 1, 4 do + local bucket = category[BucketForRate(-s)] + if bucket and bucket.UnitCount and (bucket.UnitCount.Max or 0) >= cap then + step = s + break + end + end + end + view.indicatorColor = StepColor(step) + view.showIndicator = true + end + + return view +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Derived model + +-- The singleton, forward-declared above the class so the class methods (`Destroy`) capture it as an +-- upvalue. Assigned in `SetupSingleton`, cleared in `Destroy`. +---@type UICustomLobbySlotsDerivedModel | nil +local Instance = nil + +--- Builds one slot entry by joining every model for that seat. +---@param slot number +---@param player UICustomLobbyPlayer | false +---@param closed boolean +---@param locked boolean +---@param benchmarks table +---@param spawns table | nil # scenario start-spot -> {x, z} +---@param cap number | nil # recommended unit cap (seated-count × per-player tier) +---@param side 1 | 2 | false # resolved binary auto-team side for this seat +---@return UICustomLobbySlot +local function BuildSlot(slot, player, closed, locked, benchmarks, spawns, cap, side, IsLocalPeer) + if not player then + return { + Slot = slot, Player = false, Closed = closed, Locked = locked, + StartSpot = false, Position = false, Side = side, + PlayerView = false, CpuView = false, Benchmark = false, UnitCap = false, + IsLocalPeer = false, + } + end + + local startSpot = player.StartSpot or false + local benchmark = benchmarks[player.OwnerID] or false + return { + Slot = slot, + Player = player, + Closed = closed, + Locked = locked, + StartSpot = startSpot, + Position = (spawns and startSpot and spawns[startSpot]) or false, + Side = side, + PlayerView = BuildPlayerView(player), + CpuView = BuildCpuView(benchmark or nil, cap), + Benchmark = benchmark, + UnitCap = cap or false, + IsLocalPeer = IsLocalPeer + } +end + +--- A compact signature of the table's rendered fields (everything that affects what a slot paints), so +--- the rebuild can skip re-publishing when nothing visible changed. +---@param slots UICustomLobbySlot[] +---@return string +local function Signature(slots) + local parts = {} + for slot = 1, MaxSlots do + local entry = slots[slot] + local pv = entry.PlayerView + local cv = entry.CpuView + local pos = entry.Position + -- built with `..` (not table.concat) so numbers coerce to strings — Lua 5.0's table.concat + -- rejects non-string elements + parts[slot] = tostring(slot) + .. "|" .. (entry.Closed and "C" or "_") + .. (entry.Locked and "L" or "_") + .. "|" .. (pv and (pv.colorHex .. pv.name .. pv.nameColor .. pv.faction .. pv.team .. pv.ready .. pv.readyColor .. pv.rating .. pv.games .. tostring(pv.flag) .. table.concat(pv.factionIcons, ",")) or "-") + .. "|" .. (cv and (cv.text .. cv.textColor .. (cv.showIndicator and tostring(cv.indicatorColor) or "0")) or "-") + .. "|" .. tostring(entry.StartSpot) + .. "|" .. (pos and (tostring(pos[1]) .. ":" .. tostring(pos[2])) or "-") + .. "|" .. tostring(entry.Side) + end + return table.concat(parts, "/") +end + +--- A compact signature of the `Teams` aggregate (mode + labels + resolved + the two totals), so the +--- rebuild only re-publishes `Teams` when the split or a side total actually moved. +---@param teams UICustomLobbyTeams +---@return string +local function TeamsSignature(teams) + return tostring(teams.Mode) + .. "|" .. (teams.Labels and (teams.Labels[1] .. "/" .. teams.Labels[2]) or "-") + .. "|" .. tostring(teams.Resolved) + .. "|" .. tostring(teams.Totals[1]) .. "/" .. tostring(teams.Totals[2]) +end + +--- Reactive derived-slots singleton — a `ClassSimple` implementing `Destroyable`, registered in the +--- session trash so one `CustomLobbySession.Teardown()` frees it. **Read-only** — no write helpers; the +--- controller never touches it. Two read faces over the same board: per-seat `Slots` (the row/card +--- paint these) and the binary auto-team aggregate `Teams` (the team-score strip), each deduped +--- independently so a rating-only change re-fires `Teams` but not `Slots`. +---@class UICustomLobbySlotsDerivedModel : Destroyable +---@field Trash TrashBag # owns the Slots/Teams vars + every observer (freed on Destroy) +---@field Slots LazyVar # the lookup table, indexed 1..MaxSlots +---@field Teams LazyVar # the binary auto-team aggregate (side labels + per-side rating totals) +---@field Observers LazyVar[] # internal: the source subscriptions (strong refs; also in Trash) +---@field LoadedSignature string | false # de-dup key for the Slots face +---@field LoadedTeamsSignature string | false # de-dup key for the Teams face +---@field Destroyed boolean +local SlotsModel = ClassSimple { + + ---@param self UICustomLobbySlotsDerivedModel + __init = function(self) + self.Trash = TrashBag() + self.Slots = self.Trash:Add(Create({})) + self.Teams = self.Trash:Add(Create({ Mode = false, Labels = false, Resolved = false, Totals = { 0, 0 } })) + self.Observers = {} + self.LoadedSignature = false + self.LoadedTeamsSignature = false + self.Destroyed = false + + -- subscribe to every source that feeds a seat. Each observer is kept strongly on self.Observers + -- (the trash is weak-valued, so the trash alone wouldn't keep it alive) AND added to the trash, + -- so Destroy severs them all. `Derive` fires synchronously on creation → the table resolves now. + local function subscribe(source) + table.insert(self.Observers, self.Trash:Add(Derive(source, function(lazy) + lazy() + self:Recompute() + end))) + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + for slot = 1, MaxSlots do + subscribe(launch.Players[slot]) + end + subscribe(CustomLobbySessionModel.GetSingleton().ClosedSlots) + subscribe(CustomLobbySessionModel.GetSingleton().LockedSlots) + subscribe(CustomLobbyLocalModel.GetSingleton().CpuBenchmarks) + subscribe(CustomLobbyScenarioDerivedModel.GetScenarioVar()) + -- GameOptions feeds the AutoTeams mode (the side split); the per-face dedup keeps an unrelated + -- option tweak from re-firing either var + subscribe(launch.GameOptions) + end, + + --- Rebuilds the whole lookup table + team aggregate from the current launch / session / local / + --- scenario state and publishes each — but only when its own signature changed (the de-dup). + ---@param self UICustomLobbySlotsDerivedModel + Recompute = function(self) + LOG("Recompute") + local launch = CustomLobbyLaunchModel.GetSingleton() + local session = CustomLobbySessionModel.GetSingleton() + local closedSlots = session.ClosedSlots() + local lockedSlots = session.LockedSlots() + local peerModel = CustomLobbyLocalModel.GetSingleton() + local benchmarks = peerModel.CpuBenchmarks() + local peerId = peerModel.LocalPeerId() + + local scenario = CustomLobbyScenarioDerivedModel.GetScenario() + local spawns = scenario and scenario.Markers and scenario.Markers.Spawns or nil + local maxDimension = scenario and scenario.MaxDimension or 0 + + -- the recommended cap scales by seated count (same for every seat), so count once up front; we + -- feed the inputs to the pure rule (CustomLobbyRules reads no models itself) + local seatedCount = 0 + for slot = 1, MaxSlots do + if launch.Players[slot]() then + seatedCount = seatedCount + 1 + end + end + local cap = CustomLobbyRules.RecommendedUnitCap(seatedCount, maxDimension) + + -- the binary auto-team split, applied once here (the rule lives in CustomLobbyRules). A seat's + -- side is keyed on its start spot, falling back to the slot index when empty so empty cards still + -- place; `resolved` is false for a positional mode whose map/start spots aren't loaded yet. + local mode = CustomLobbyRules.AutoTeamMode(launch.GameOptions()) + local labels = CustomLobbyRules.SideLabels(mode) + local resolver, resolved = CustomLobbyRules.BuildSideResolver(mode, scenario) + + local slots = {} + local totalA, totalB = 0, 0 + for slot = 1, MaxSlots do + local player = launch.Players[slot]() + local spot = (player and player.StartSpot) or slot + local side = (resolved and resolver and resolver(spot)) or false + local isLocalPeer = player and player.OwnerID == peerId or false + slots[slot] = BuildSlot(slot, player, closedSlots[slot] and true or false, + lockedSlots[slot] and true or false, benchmarks, spawns, cap, side, isLocalPeer) + if player then + if side == 1 then + totalA = totalA + (player.PL or 0) + elseif side == 2 then + totalB = totalB + (player.PL or 0) + end + end + end + + -- per-seat face (rows/cards): re-publish only when a rendered field or a seat's side changed + local signature = Signature(slots) + if signature ~= self.LoadedSignature then + self.LoadedSignature = signature + self.Slots:Set(slots) + end + + -- aggregate face (team-score strip): re-publish only when the split or a total moved + local teams = { + Mode = mode or false, + Labels = labels or false, + Resolved = resolved, + Totals = { math.floor(totalA), math.floor(totalB) }, + } + local teamsSignature = TeamsSignature(teams) + if teamsSignature ~= self.LoadedTeamsSignature then + self.LoadedTeamsSignature = teamsSignature + self.Teams:Set(teams) + end + end, + + --- `Destroyable`: frees the Slots/Teams vars + every source subscription (so they stop firing) and + --- clears the module singleton, so the next access rebuilds and re-registers in the next session's + --- trash. Idempotent. + ---@param self UICustomLobbySlotsDerivedModel + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + self.Trash:Destroy() -- frees the Slots/Teams LazyVars + all observer subscriptions + if Instance == self then + Instance = nil + end + end, +} + +--- Allocates a fresh slots-model singleton and registers it in the session trash. (Because `Derive` +--- fires synchronously on creation, the table resolves immediately.) +---@return UICustomLobbySlotsDerivedModel +function SetupSingleton() + Instance = SlotsModel() + CustomLobbySession.GetTrash():Add(Instance) + return Instance +end + +--- Returns the slots-model singleton, creating (and registering) it on first access — including after +--- a teardown, so it is reusable across lobby sessions. +---@return UICustomLobbySlotsDerivedModel +function GetSingleton() + if not Instance then + SetupSingleton() + end + return Instance --[[@as UICustomLobbySlotsDerivedModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Accessors + +--- The reactive slots var — subscribe to it (via `Derive`) to react when any seat changes. +---@return LazyVar +function GetSlotsVar() + return GetSingleton().Slots +end + +--- The current lookup table (indexed 1..MaxSlots). +---@return UICustomLobbySlot[] +function GetSlots() + return GetSingleton().Slots() +end + +--- The resolved entry for a single slot (always present, 1..MaxSlots). +---@param slot number +---@return UICustomLobbySlot +function GetSlot(slot) + return GetSingleton().Slots()[slot] +end + +--- The reactive team aggregate var — subscribe to it (via `Derive`) to react when the side split or a +--- side's rating total changes (the team-score strip's single source). +---@return LazyVar +function GetTeamsVar() + return GetSingleton().Teams +end + +--- The current binary auto-team aggregate (mode / labels / resolved / per-side totals). +---@return UICustomLobbyTeams +function GetTeams() + return GetSingleton().Teams() +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: destroy the old singleton (severing all its subscriptions) and rebuild via the new +--- module. The table is fully derived from the source models, so there is no state to copy across. +---@param newModule any +function __moduleinfo.OnReload(newModule) + if Instance then + Instance:Destroy() + newModule.SetupSingleton() + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/modselect/CLAUDE.md b/lua/ui/lobby/customlobby/modselect/CLAUDE.md new file mode 100644 index 00000000000..bc580e3b809 --- /dev/null +++ b/lua/ui/lobby/customlobby/modselect/CLAUDE.md @@ -0,0 +1,50 @@ +# Mod select + +The custom lobby's **mod-select dialog** and its supporting pieces, in their own folder — the +mod-side counterpart to [`../mapselect/`](../mapselect/CLAUDE.md), built to the same shape. Opened +by the "Mods" button in [`../CustomLobbyInterface.lua`](../CustomLobbyInterface.lua), and usable +standalone (the main-menu mod manager use case). + +> Read [`../CLAUDE.md`](../CLAUDE.md) first (the lobby's MVC + the layout/init gotchas), then +> [`../mapselect/CLAUDE.md`](../mapselect/CLAUDE.md) — this dialog mirrors it, including the +> **texture-leak** rule that keeps the list text-only. + +## Files + +| File | Role | +|------|------| +| [CustomLobbyModSelect.lua](CustomLobbyModSelect.lua) | the dialog (transient `Popup`). Same **areas** layout as the map dialog (title / left {filter, list, stats} / detail / actions — flip the module `Debug` flag to tint them). Left: name/author search + **Game / UI / Unavailable** type filters (persisted to Prefs) + a checkbox list; right: the highlighted mod's icon + title + quick-facts (version · type) + website/source links (allowlisted → `OpenURL`), then **labelled sections** matching the map dialog / Map tab — **Author / Description / Dependencies** (requires/conflicts/missing) **/ Details** (copyright/uid/location), absent sections collapsing. Bottom: **presets** (load / save / delete) + OK / Cancel. Owns a *working selection set*, not synced state; on OK it hands the set to an `onConfirm` callback. | +| [CustomLobbyModCatalog.lua](CustomLobbyModCatalog.lua) | the lobby's **own** mod data layer — **reference data, never synced** (each peer enumerates its own disk; only the host's sim-mod *choice* syncs). Async-streams classified, display-ready `UILobbyModInfo` entries into a `LazyVar` (built from `ModUtilities`, which fronts `/lua/mods.lua`), so the dialog shows a live "N mods" count and fills in progressively. `EnsureLoaded` / `GetModsVar` / `FindByUid` / `Refresh`. | +| [CustomLobbyModList.lua](CustomLobbyModList.lua) | the dialog's scrollable, **virtualised** list: a fixed pool of rows reused while scrolling, each a **checkbox + name + type badge**. The list doesn't own the selection — it paints checkboxes from a set the dialog hands it (`SetChecked`) and asks a predicate whether each row may be toggled (`SetCanToggle`). `OnSelect` / `OnToggle` / `OnConfirm`. | + +The mod domain logic lives one level up in [`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua) — +the UI-facing front door over `/lua/mods.lua` (the sibling of `maputil.lua` for maps): mod +classification, name/version/author formatting (lifted out of the legacy `ModsManager.lua`), +dependency-aware selection edits, **prefs persistence** (so a dialog never touches prefs), and +**presets** (named snapshots, replacing the legacy "favorites"). + +## The MVC boundary (where the selection goes) + +The dialog is decoupled from the lobby models — it just returns a uid set. The **opener** decides +what that means, which is the whole point: + +- **`Open` (in-lobby).** Seeds from the host's sim mods (`launch.GameMods()`) + this peer's UI + mods (prefs). On OK, **sim** mods go through the host-authoritative `RequestSetGameMods` intent + → `GameMods` → broadcast (a non-host sees them read-only via `canEditGameMods`); **UI** mods are + this peer's own choice and persist locally via `ModUtilities.SetSelectedUIMods`. UI mods never + go on the wire — see the `customlobby-model-choice` skill. +- **`OpenStandalone` (no lobby).** Seeds from `ModUtilities.GetSelectedMods()`; on OK the whole + selection persists to the preference file via `ModUtilities.SetSelectedMods`. + +Selection edits (checkbox ticks, preset loads) run through `ModUtilities.ResolveEnable` / +`ResolveDisable`, which pull in requirements and drop conflicts; the dialog repaints the list from +the resulting set. Blacklisted / missing-dependency mods can't be enabled. + +## Texture leak (same as mapselect) + +Rows are **text-only** (checkbox + name + badge) for the same reason the map list is: a mod's +`icon` is a distinct texture per mod, and the engine never frees the textures a `Bitmap`/ +`MapPreview` loads (full writeup in [`../mapselect/CLAUDE.md`](../mapselect/CLAUDE.md)). One icon +per row × a big vault would leak the memory the game needs in-match. The icon is shown **once**, in +the detail panel, re-textured per highlighted mod — the same bounded trickle the map dialog's +single preview accepts. diff --git a/lua/ui/lobby/customlobby/modselect/CustomLobbyModCatalog.lua b/lua/ui/lobby/customlobby/modselect/CustomLobbyModCatalog.lua new file mode 100644 index 00000000000..f7ab77d47db --- /dev/null +++ b/lua/ui/lobby/customlobby/modselect/CustomLobbyModCatalog.lua @@ -0,0 +1,204 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The mod catalog: the custom lobby's list of selectable mods, the mod-side counterpart to +-- CustomLobbyMapCatalog. +-- +-- Like the map catalog, this is *reference data*, NOT a model — it's identical on every peer +-- (each enumerates its own disk) and never goes on the wire. Only the host's *choice* of sim +-- mods (the launch model's `GameMods`) and each peer's UI-mod choice (prefs) sync/persist; the +-- catalog is just the menu they pick from. See the `customlobby-model-choice` skill. +-- +-- It builds normalized, display-ready `UILobbyModInfo` entries (classified type, formatted +-- name / version / author, resolved dependency sets) from `ModUtilities`, the UI mod helper that +-- fronts `/lua/mods.lua`. Classification runs `GetDependencies` per mod, so we stream the build +-- across frames in batches and re-fire the `Mods` LazyVar as each batch lands — the dialog shows +-- a live "N mods loaded" count and fills in progressively, exactly like the map list. + +local Create = import("/lua/lazyvar.lua").Create + +-- the single point of contact with the mod domain layer (which in turn fronts /lua/mods.lua) +local ModUtilities = import("/lua/ui/modutilities.lua") + +local FallbackIcon = '/textures/ui/common/dialogs/mod-manager/generic-icon_bmp.dds' + +-- mods classified per frame-slice before yielding — keeps the open responsive +local BatchSize = 5 + +--- The growing list of selectable mods. Re-fired (new table ref) as batches land. +---@type LazyVar +local ModList = Create({}) + +local Loading = false -- a load thread is currently running +local Loaded = false -- the disk has been fully enumerated + classified + +------------------------------------------------------------------------------- +--#region Enumeration + +--- Builds the normalized, display-ready entry for one raw `ModInfo`. +---@param mod ModInfo +---@return UILobbyModInfo +local function BuildEntry(mod) + local dependencies = ModUtilities.GetDependencies(mod.uid) + local icon = mod.icon + if not icon or icon == "" or not DiskGetFileInfo(icon) then + icon = FallbackIcon + end + + ---@type UILobbyModInfo + return { + uid = mod.uid, + name = mod.name or mod.uid, + title = ModUtilities.FormatName(mod), + versionText = ModUtilities.FormatVersion(mod), + author = ModUtilities.FormatAuthor(mod), + description = mod.description or "", + copyright = mod.copyright, + icon = icon, + location = mod.location, + ui_only = mod.ui_only and true or false, + type = ModUtilities.Classify(mod), + blacklistReason = ModUtilities.GetBlacklistReason(mod.uid), + url = mod.url, + github = mod.github, + requires = dependencies.requires, + missing = dependencies.missing, + conflicts = dependencies.conflicts, + } +end + +---@param a UILobbyModInfo +---@param b UILobbyModInfo +---@return boolean +local function SortByTitle(a, b) + return string.upper(a.title or "") < string.upper(b.title or "") +end + +--- Publishes a fresh (sorted) copy of the accumulator so dependents go dirty. +---@param accumulator UILobbyModInfo[] +local function Publish(accumulator) + local snapshot = table.copy(accumulator) + table.sort(snapshot, SortByTitle) + ModList:Set(snapshot) +end + +--- Classifies every selectable mod across frames, streaming the entries into `ModList`. +local function LoadThread() + local selectable = ModUtilities.GetSelectableMods() + + local accumulator = {} + local seen = 0 + for _, mod in selectable do + table.insert(accumulator, BuildEntry(mod)) + + seen = seen + 1 + if math.mod(seen, BatchSize) == 0 then + Publish(accumulator) + WaitFrames(1) + end + end + + Loaded = true + Loading = false + Publish(accumulator) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Public API + +--- Kicks off the enumeration if it hasn't run yet. Idempotent — safe to call on every open; +--- once loaded it's a no-op and the cached list stays. +function EnsureLoaded() + if Loading or Loaded then + return + end + Loading = true + ModList:Set({}) + ForkThread(LoadThread) +end + +--- The mods LazyVar — subscribe to it (via `Derive`) to react as the list streams in. +---@return LazyVar +function GetModsVar() + return ModList +end + +--- The current (possibly partial) list of mods. +---@return UILobbyModInfo[] +function GetMods() + return ModList() +end + +--- How many mods are currently loaded. +---@return number +function GetCount() + return table.getn(ModList()) +end + +--- Whether the disk has been fully enumerated (vs. still streaming). +---@return boolean +function IsLoaded() + return Loaded +end + +--- Finds the loaded mod with the given uid, or nil. +---@param uid string | false +---@return UILobbyModInfo | nil +function FindByUid(uid) + if not uid then + return nil + end + for _, mod in ModList() do + if mod.uid == uid then + return mod + end + end + return nil +end + +--- Drops everything so the next `EnsureLoaded` re-reads from disk (e.g. mods changed on disk). +function Refresh() + Loaded = false + Loading = false + ModUtilities.Refresh() + ModList:Set({}) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-imports this module after a couple of frames. The list rebuilds on the +--- next access, so dropping the cache is harmless. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/modselect/CustomLobbyModList.lua b/lua/ui/lobby/customlobby/modselect/CustomLobbyModList.lua new file mode 100644 index 00000000000..f0ac2261212 --- /dev/null +++ b/lua/ui/lobby/customlobby/modselect/CustomLobbyModList.lua @@ -0,0 +1,418 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- A scrollable, pooled mod list for the mod-select dialog. Each row carries a checkbox (select / +-- deselect the mod), the mod name, and a short type badge. The sibling of CustomLobbyMapList — +-- same virtualisation, same scrollbar contract — with selection checkboxes added. +-- +-- Rows are *text-only* (no per-row mod icons) on purpose: a mod's icon is a distinct texture per +-- mod, and the engine never frees the textures a `Bitmap`/`MapPreview` loads (see +-- mapselect/CLAUDE.md). One icon per row × a big vault would leak the memory the game needs +-- in-match, so the icon is shown once, in the dialog's detail panel, for the highlighted mod. +-- +-- Two interactions, two regions: +-- * the checkbox toggles membership in the selection (`OnToggle`); +-- * clicking the rest of the row highlights it for the detail panel (`OnSelect`), and +-- double-clicking confirms (`OnConfirm`). +-- +-- The list does not own the selection — it paints checkboxes from a selection set the dialog +-- hands it (`SetChecked`) and asks the dialog whether each row may be toggled (`SetCanToggle`, +-- e.g. a non-host can't change sim mods, blacklisted mods can't be enabled). After the dialog +-- mutates the selection it calls `Refresh` to repaint. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap + +local Layouter = LayoutHelpers.ReusedLayoutFor + +local RowHeight = 26 + +-- mod names are single-line Text (which doesn't clip), so cap them with an ellipsis to stop a +-- long name running under the type badge. Char-based (the engine's Text has no width-measure). +local NameMaxChars = 28 + +--- Truncates `text` to `maxChars`, appending "…" when it had to cut. +---@param text string +---@param maxChars number +---@return string +local function Truncate(text, maxChars) + text = text or "" + if string.len(text) > maxChars then + return string.sub(text, 1, maxChars - 1) .. "…" + end + return text +end + +local SelectedColor = 'ff2c3e48' +local HoverColor = 'ff1a2630' +local IdleColor = '00000000' + +local EnabledNameColor = 'ffe9ece9' +local DisabledNameColor = 'ff6a7078' + +-- short badge text + colour per mod type +local TypeBadges = { + GAME = { text = "GAME", color = 'ffd0a24c' }, + UI = { text = "UI", color = 'ff6db3e2' }, + BLACKLISTED = { text = "BL", color = 'ffb05050' }, + NO_DEPENDENCY = { text = "DEP", color = 'ffb05050' }, + LOCAL = { text = "LCL", color = 'ffb0902c' }, +} + +---@class UICustomLobbyModListRow : Group +---@field Background Bitmap +---@field Check Checkbox +---@field Name Text +---@field Badge Text +---@field _poolIndex number +---@field _hover boolean + +---@class UICustomLobbyModList : Group +---@field Trash TrashBag +---@field Items UILobbyModInfo[] +---@field Rows UICustomLobbyModListRow[] +---@field PoolCount number +---@field ScrollTop number # 0-based scroll offset; NOT the `Top` edge LazyVar +---@field Selected number | false # highlighted item index (1-based) +---@field Scrollbar Scrollbar | false # hidden while everything fits +---@field Checked UIModSelection # the dialog's selection set (read-only here) +---@field CanToggle fun(mod: UILobbyModInfo): boolean +---@field OnSelect fun(mod: UILobbyModInfo, index: number) +---@field OnToggle fun(mod: UILobbyModInfo, checked: boolean) +---@field OnConfirm fun(mod: UILobbyModInfo) +local CustomLobbyModList = ClassUI(Group) { + + ---@param self UICustomLobbyModList + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbyModList") + + self.Trash = TrashBag() + self.Items = {} + self.Rows = {} + self.PoolCount = 0 + self.ScrollTop = 0 + self.Selected = false + self.Scrollbar = false + self.Checked = {} + self.CanToggle = function(mod) return true end + self.OnSelect = nil + self.OnToggle = nil + self.OnConfirm = nil + end, + + ---@param self UICustomLobbyModList + __post_init = function(self) + self.HandleEvent = function(control, event) + if event.Type == 'WheelRotation' then + local lines = event.WheelRotation > 0 and -3 or 3 + self:ScrollLines(nil, lines) + return true + end + return false + end + end, + + --- Builds the row pool sized to the (now concrete) height and attaches the scrollbar. + --- Called by the owner after the list is laid out + mounted (three-phase init, + --- /lua/ui/CLAUDE.md § 1) — the pool count reads `Height()`, unsettled during __post_init. + ---@param self UICustomLobbyModList + Initialize = function(self) + if self.PoolCount > 0 then + return + end + + local count = math.floor(self.Height() / LayoutHelpers.ScaleNumber(RowHeight)) + if count < 1 then + count = 1 + end + for i = 1, count do + self.Rows[i] = self:CreateRow(i) + local top = (i - 1) * RowHeight + Layouter(self.Rows[i]) + :AtLeftIn(self) + :AtRightIn(self) + :AtTopIn(self, top) + :Height(RowHeight) + :End() + end + -- set the pool count only once the whole pool exists, so a build error never leaves + -- CalcVisible iterating past rows that were actually created + self.PoolCount = count + + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self) + self:CalcVisible() + end, + + --- Builds one pooled row (checkbox + name + type badge). Private. + ---@param self UICustomLobbyModList + ---@param poolIndex number + ---@return UICustomLobbyModListRow + CreateRow = function(self, poolIndex) + ---@type UICustomLobbyModListRow + local row = Group(self) + row._poolIndex = poolIndex + row._hover = false + + row.Background = Bitmap(row) + row.Background:SetSolidColor(IdleColor) + + row.Check = UIUtil.CreateCheckbox(row, '/CHECKBOX/', "", false, 11) + row.Check.OnCheck = function(control, checked) + local index = self.ScrollTop + poolIndex + local mod = self.Items[index] + if mod and self.OnToggle then + self.OnToggle(mod, checked) + end + end + + row.Name = UIUtil.CreateText(row, "", 14, UIUtil.bodyFont) + row.Name:DisableHitTest() + + row.Badge = UIUtil.CreateText(row, "", 11, UIUtil.bodyFont) + row.Badge:DisableHitTest() + + Layouter(row.Background):Fill(row):End() + Layouter(row.Check):AtLeftIn(row, 6):AtVerticalCenterIn(row):End() + Layouter(row.Name):AnchorToRight(row.Check, 8):AtVerticalCenterIn(row):End() + Layouter(row.Badge):AtRightIn(row, 10):AtVerticalCenterIn(row):End() + + -- the background catches selection / confirm; the checkbox handles its own clicks, and + -- the text labels are hit-test-disabled so they don't block the background + row.Background.HandleEvent = function(control, event) + local index = self.ScrollTop + poolIndex + local mod = self.Items[index] + if not mod then + return false + end + if event.Type == 'ButtonPress' then + self:SetSelection(index) + if self.OnSelect then + self.OnSelect(mod, index) + end + return true + elseif event.Type == 'ButtonDClick' then + if self.OnConfirm then + self.OnConfirm(mod) + end + return true + elseif event.Type == 'MouseEnter' then + row._hover = true + self:PaintRow(row, index) + return true + elseif event.Type == 'MouseExit' then + row._hover = false + self:PaintRow(row, index) + return true + end + return false + end + + return row + end, + + --- Replaces the data set and refreshes the window (resets scroll to the top). + ---@param self UICustomLobbyModList + ---@param items UILobbyModInfo[] + SetItems = function(self, items) + self.Items = items or {} + self.ScrollTop = 0 + self.Selected = false + self:CalcVisible() + end, + + --- Points the list at the dialog's selection set (held by reference; the dialog mutates it + --- and calls `Refresh`). Drives each row's checkbox state. + ---@param self UICustomLobbyModList + ---@param checked UIModSelection + SetChecked = function(self, checked) + self.Checked = checked or {} + self:CalcVisible() + end, + + --- Sets the predicate deciding whether a row's checkbox is interactive (e.g. a non-host + --- can't toggle sim mods; blacklisted mods can't be enabled). + ---@param self UICustomLobbyModList + ---@param predicate fun(mod: UILobbyModInfo): boolean + SetCanToggle = function(self, predicate) + self.CanToggle = predicate or function(mod) return true end + end, + + --- Repaints the visible window (call after the dialog mutates the selection). + ---@param self UICustomLobbyModList + Refresh = function(self) + self:CalcVisible() + end, + + --- Selects an item by index (1-based) and repaints; does not scroll (see ShowItem). + ---@param self UICustomLobbyModList + ---@param index number | false + SetSelection = function(self, index) + self.Selected = index or false + self:CalcVisible() + end, + + --- The highlighted mod, or nil. + ---@param self UICustomLobbyModList + ---@return UILobbyModInfo | nil + GetSelected = function(self) + return self.Selected and self.Items[self.Selected] or nil + end, + + --- Scrolls so item `index` (1-based) is within the visible window. + ---@param self UICustomLobbyModList + ---@param index number + ShowItem = function(self, index) + if self.PoolCount == 0 then + return + end + if index <= self.ScrollTop then + self.ScrollTop = index - 1 + elseif index > self.ScrollTop + self.PoolCount then + self.ScrollTop = index - self.PoolCount + end + self:ClampTop() + self:CalcVisible() + end, + + --- Paints a single row to reflect its data + selection / hover / checkbox state. Private. + ---@param self UICustomLobbyModList + ---@param row UICustomLobbyModListRow + ---@param index number + PaintRow = function(self, row, index) + if not row then + return + end + local mod = self.Items[index] + if not mod then + row:Hide() + return + end + row:Show() + + local color = IdleColor + if index == self.Selected then + color = SelectedColor + elseif row._hover then + color = HoverColor + end + row.Background:SetSolidColor(color) + + local canToggle = self.CanToggle(mod) + row.Check:SetCheck(self.Checked[mod.uid] and true or false, true) + if canToggle then + row.Check:Enable() + else + row.Check:Disable() + end + + row.Name:SetText(Truncate(mod.title or mod.name or "?", NameMaxChars)) + row.Name:SetColor(canToggle and EnabledNameColor or DisabledNameColor) + + local badge = TypeBadges[mod.type] or TypeBadges.GAME + row.Badge:SetText(badge.text) + row.Badge:SetColor(badge.color) + end, + + --------------------------------------------------------------------------- + --#region Scrollbar contract + + ---@param self UICustomLobbyModList + CalcVisible = function(self) + for i = 1, self.PoolCount do + self:PaintRow(self.Rows[i], self.ScrollTop + i) + end + self:UpdateScrollbar() + end, + + --- Shows the scrollbar only when there are more items than fit the pool. + ---@param self UICustomLobbyModList + UpdateScrollbar = function(self) + if not self.Scrollbar then + return + end + if table.getn(self.Items) > self.PoolCount then + self.Scrollbar:Show() + else + self.Scrollbar:Hide() + end + end, + + ---@param self UICustomLobbyModList + ClampTop = function(self) + local maxTop = math.max(0, table.getn(self.Items) - self.PoolCount) + if self.ScrollTop > maxTop then + self.ScrollTop = maxTop + end + if self.ScrollTop < 0 then + self.ScrollTop = 0 + end + end, + + ---@param self UICustomLobbyModList + GetScrollValues = function(self, axis) + local size = table.getn(self.Items) + return 0, size, self.ScrollTop, math.min(self.ScrollTop + self.PoolCount, size) + end, + + ---@param self UICustomLobbyModList + ScrollLines = function(self, axis, delta) + self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta)) + end, + + ---@param self UICustomLobbyModList + ScrollPages = function(self, axis, delta) + self:ScrollSetTop(axis, self.ScrollTop + math.floor(delta) * self.PoolCount) + end, + + ---@param self UICustomLobbyModList + ScrollSetTop = function(self, axis, top) + top = math.floor(top) + if top == self.ScrollTop then + return + end + self.ScrollTop = top + self:ClampTop() + self:CalcVisible() + end, + + ---@param self UICustomLobbyModList + IsScrollable = function(self, axis) + return true + end, + + --#endregion + + ---@param self UICustomLobbyModList + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyModList +Create = function(parent) + return CustomLobbyModList(parent) +end diff --git a/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua b/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua new file mode 100644 index 00000000000..d3cdaf62d82 --- /dev/null +++ b/lua/ui/lobby/customlobby/modselect/CustomLobbyModSelect.lua @@ -0,0 +1,1134 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The mod-select dialog: a searchable, type-filterable list of mods you tick on the left, with +-- the highlighted mod's details on the right, plus named presets. The sibling of the map-select +-- dialog (CustomLobbyMapSelect) — same areas/three-phase-init/Prefs shape — but for mods. +-- +-- It is a transient picker, NOT a model component: it owns no synced state. It works off the +-- CustomLobbyModCatalog (reference data, streamed in) and a *working selection set*, and on OK +-- it just hands that set to an `onConfirm` callback. Where the selection goes is the opener's +-- decision (the MVC boundary): +-- * `Open` (in-lobby) — sim mods route through the host-authoritative `RequestSetGameMods` +-- intent (synced via the launch model's `GameMods`); UI mods are this peer's own choice and +-- persist locally via `ModUtilities.SetSelectedUIMods`. +-- * `OpenStandalone` — no lobby; the whole selection persists to the preference file via +-- `ModUtilities.SetSelectedMods` (the main-menu mod-manager use case). +-- +-- Sim-mod checkboxes are read-only for a non-host (`canEditGameMods`); blacklisted / missing- +-- dependency mods can't be enabled. Selection edits run through `ModUtilities.ResolveEnable` / +-- `ResolveDisable`, which pull in requirements and drop conflicts. +-- +-- Like the map dialog, the list is text-only; only the detail panel shows a mod icon, one at a +-- time (the engine never frees mod-icon textures — see mapselect/CLAUDE.md). + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") +local Prefs = import("/lua/user/prefs.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Edit = import("/lua/maui/edit.lua").Edit +local Popup = import("/lua/ui/controls/popups/popup.lua").Popup +local Combo = import("/lua/ui/controls/combo.lua").Combo +local TextArea = import("/lua/ui/controls/textarea.lua").TextArea + +local ModUtilities = import("/lua/ui/modutilities.lua") +local CustomLobbyModCatalog = import("/lua/ui/lobby/customlobby/modselect/customlobbymodcatalog.lua") +local CustomLobbyModList = import("/lua/ui/lobby/customlobby/modselect/customlobbymodlist.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- flip to tint each layout area so the regions are visible while iterating +local Debug = false + +local DialogWidth = 760 +local DialogHeight = 620 +local Pad = 12 +local ColumnGap = 24 +local LeftWidth = 320 +local IconSize = 64 +local TitleHeight = 32 +local ActionHeight = 120 -- two stacked rows: presets on top, OK / Cancel at the bottom +local FilterHeight = 120 +local StatsHeight = 22 + +-- detail-panel label/value presentation (mirrors the map dialog / Map tab) +local LabelColor = 'ff8a909a' -- the section labels (Author / Description / …) +local ValueColor = 'ffc8ccd0' + +local PrefsKey = "customlobby_modselect" + +-- Mod web pages we'll open in a browser (mods carry `url` / `github`). Matched against the URL's +-- host — exact or as a subdomain — so look-alikes ("github.com.evil.com") are rejected. Mirrors +-- the map dialog's allowlist; add a line to extend. +local AllowedUrlDomains = { + "github.com", + "githubusercontent.com", + "gitlab.com", + "github.io", + "faforever.com", +} + +--- The lowercased host of a URL (between the scheme and the first `/` or `:`), or "". +---@param url string +---@return string +local function UrlHost(url) + local rest = string.gsub(string.lower(url), "^https?://", "") + return (string.gsub(rest, "[/:].*$", "")) +end + +--- Whether `url` is an http(s) link to an allowed domain (or a subdomain of one). +---@param url any +---@return boolean +local function IsAllowedUrl(url) + if type(url) ~= 'string' or not string.find(string.lower(url), "^https?://") then + return false + end + local host = UrlHost(url) + for _, domain in AllowedUrlDomains do + local escaped = string.gsub(domain, "%.", "%%.") + if host == domain or string.find(host, "%." .. escaped .. "$") then + return true + end + end + return false +end + +--- Downgrades an `https://` URL to `http://` — the engine's `OpenURL` only handles `http://`. +---@param url string +---@return string +local function ToOpenableUrl(url) + return (string.gsub(url, "^https://", "http://")) +end + +-- the detail title is single-line Text (which doesn't clip), so cap it with an ellipsis so a long +-- mod name doesn't run off the panel. Char-based (the engine's Text has no width-measure). +local TitleMaxChars = 32 + +--- Truncates `text` to `maxChars`, appending "…" when it had to cut. +---@param text string +---@param maxChars number +---@return string +local function Truncate(text, maxChars) + text = text or "" + if string.len(text) > maxChars then + return string.sub(text, 1, maxChars - 1) .. "…" + end + return text +end + +--- Shows `scrollbar` only when the TextArea's content is taller than the box it sits in. +---@param textArea TextArea +---@param scrollbar Scrollbar | false +local function UpdateTextAreaScrollbar(textArea, scrollbar) + if not scrollbar then + return + end + if textArea:GetTextHeight() > textArea.Height() then + scrollbar:Show() + else + scrollbar:Hide() + end +end + +--- Joins a uid set's display names (looked up in the catalog) into "A, B, C", or "" if empty. +---@param uidSet table | nil +---@return string +local function NamesOf(uidSet) + if not uidSet then + return "" + end + local names = {} + for uid in uidSet do + local mod = CustomLobbyModCatalog.FindByUid(uid) + table.insert(names, mod and mod.title or uid) + end + table.sort(names) + return table.concat(names, ", ") +end + +--- Creates a layout area (an invisible Group with an optional debug tint). +---@param parent Control +---@param name string +---@param color string +---@return Group +local function CreateArea(parent, name, color) + local area = Group(parent, name) + local bg = Bitmap(area) + bg:SetSolidColor(color) + bg:SetAlpha(Debug and 0.18 or 0.0) + bg:DisableHitTest() + Layouter(bg):Fill(area):End() + area.Bg = bg + return area +end + +---@class UICustomLobbyModSelect : Group +---@field Trash TrashBag +---@field CanEditGameMods boolean +---@field Selection UIModSelection +---@field OnConfirmCb fun(selection: UIModSelection) +---@field OnCancelCb fun() +---@field TitleArea Group +---@field LeftArea Group +---@field FilterArea Group +---@field SelectionArea Group +---@field StatsArea Group +---@field PreviewArea Group +---@field ActionArea Group +---@field Title Text +---@field FilterTitle Text +---@field SearchLabel Text +---@field Search Edit +---@field GameToggle Checkbox +---@field UIToggle Checkbox +---@field UnavailableToggle Checkbox +---@field ShowGame boolean +---@field ShowUI boolean +---@field ShowUnavailable boolean +---@field ModList UICustomLobbyModList +---@field EmptyLabel Text +---@field CountLabel Text +---@field SelectedLabel Text +---@field Spinner Text +---@field Icon Bitmap +---@field DetailTitle Text +---@field DetailMeta Text +---@field UrlButton Text +---@field GithubButton Text +---@field CurrentUrl string | false +---@field CurrentGithub string | false +---@field AuthorLabel Text +---@field AuthorValue Text +---@field DescriptionLabel Text +---@field Description TextArea +---@field DescriptionScrollbar Scrollbar | false +---@field DepsLabel Text +---@field DepsText TextArea +---@field DepsScrollbar Scrollbar | false +---@field DetailsLabel Text +---@field DetailsText TextArea +---@field DetailsScrollbar Scrollbar | false +---@field Note Text +---@field PresetLabel Text +---@field PresetCombo Combo +---@field SavePresetButton Button +---@field DeletePresetButton Button +---@field SelectButton Button +---@field CancelButton Button +---@field ClearButton Button +---@field PresetNames string[] +---@field ModsObserver LazyVar +---@field Mods UILobbyModInfo[] +---@field Filtered UILobbyModInfo[] +---@field Highlighted? UILobbyModInfo +---@field Ready boolean +local CustomLobbyModSelect = ClassUI(Group) { + + ---@param self UICustomLobbyModSelect + ---@param parent Control + ---@param options { initial: UIModSelection, canEditGameMods: boolean, onConfirm: fun(selection: UIModSelection), onCancel: fun() } + __init = function(self, parent, options) + Group.__init(self, parent, "CustomLobbyModSelect") + + self.Trash = TrashBag() + self.OnConfirmCb = options.onConfirm + self.OnCancelCb = options.onCancel + self.CanEditGameMods = options.canEditGameMods ~= false + self.Selection = table.copy(options.initial or {}) + + self.Ready = false + self.Filtered = {} + self.Highlighted = nil + self.Mods = CustomLobbyModCatalog.GetMods() + self.PresetNames = {} + + -- restore the last-used filters + search (persisted across opens) + local saved = Prefs.GetFromCurrentProfile(PrefsKey) or {} + self.ShowGame = saved.showGame ~= false + self.ShowUI = saved.showUI ~= false + self.ShowUnavailable = saved.showUnavailable == true + + -- areas + self.TitleArea = CreateArea(self, "TitleArea", 'ffcc4040') + self.LeftArea = CreateArea(self, "LeftArea", 'ff4060cc') + self.FilterArea = CreateArea(self.LeftArea, "FilterArea", 'ff40cc60') + self.SelectionArea = CreateArea(self.LeftArea, "SelectionArea", 'ffcccc40') + self.StatsArea = CreateArea(self.LeftArea, "StatsArea", 'ff40cccc') + self.PreviewArea = CreateArea(self, "PreviewArea", 'ffcc40cc') + self.ActionArea = CreateArea(self, "ActionArea", 'ff808080') + + self.Title = UIUtil.CreateText(self.TitleArea, "Select mods", 22, UIUtil.titleFont) + + --#region filters (in FilterArea) + self.FilterTitle = UIUtil.CreateText(self.FilterArea, "Filter", 14, UIUtil.titleFont) + + self.SearchLabel = UIUtil.CreateText(self.FilterArea, "Search", 13, UIUtil.bodyFont) + self.SearchLabel:SetColor('ff9aa0a8') + + self.Search = Edit(self.FilterArea) + Layouter(self.Search):Left(0):Top(0):Width(96):Height(22):End() + self.Search:SetFont(UIUtil.bodyFont, 16) + self.Search:SetForegroundColor(UIUtil.fontColor) + self.Search:ShowBackground(true) + self.Search:SetBackgroundColor('77778888') + self.Search:SetText(saved.search or "") + self.Search.OnTextChanged = function(control, newText, oldText) + self:Populate() + end + Tooltip.AddControlTooltipManual(self.Search, "Search", "Filter the list by mod name or author.") + + self.GameToggle = self:CreateToggle("Game", self.ShowGame, + function(checked) self.ShowGame = checked; self:Populate() end, + "Game mods", "Show mods that change the simulation (all players need them).") + self.UIToggle = self:CreateToggle("UI", self.ShowUI, + function(checked) self.ShowUI = checked; self:Populate() end, + "UI mods", "Show mods that change only your interface (per-player).") + self.UnavailableToggle = self:CreateToggle("Deprecated", self.ShowUnavailable, + function(checked) self.ShowUnavailable = checked; self:Populate() end, + "Deprecated mods", "Show blacklisted mods and mods missing a dependency.") + --#endregion + + --#region selection list + stats + self.ModList = CustomLobbyModList.Create(self.SelectionArea) + self.ModList.OnSelect = function(mod, index) + self:OnModHighlighted(mod) + end + self.ModList.OnToggle = function(mod, checked) + self:ToggleMod(mod, checked) + end + self.ModList.OnConfirm = function(mod) + self:Confirm() + end + + self.EmptyLabel = UIUtil.CreateText(self.SelectionArea, "No mods match", 14, UIUtil.bodyFont) + self.EmptyLabel:SetColor('ff8a909a') + self.EmptyLabel:DisableHitTest() + self.EmptyLabel:Hide() + + self.CountLabel = UIUtil.CreateText(self.StatsArea, "", 13, UIUtil.bodyFont) + self.CountLabel:SetColor('ff9aa0a8') + self.SelectedLabel = UIUtil.CreateText(self.StatsArea, "", 13, UIUtil.bodyFont) + self.SelectedLabel:SetColor('ff9aa0a8') + self.Spinner = UIUtil.CreateText(self.StatsArea, "", 13, UIUtil.bodyFont) + self.Spinner:SetColor('ff9aa0a8') + --#endregion + + --#region detail panel (right) + self.Icon = Bitmap(self.PreviewArea) + self.Icon:DisableHitTest() + self.Icon:Hide() + + self.DetailTitle = UIUtil.CreateText(self.PreviewArea, "", 18, UIUtil.titleFont) + self.DetailTitle:DisableHitTest() + self.DetailMeta = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) + self.DetailMeta:SetColor(ValueColor) + self.DetailMeta:DisableHitTest() + + self.CurrentUrl = false + self.UrlButton = self:CreateLink("Website", "Open the mod's web page in your browser.", + function() return self.CurrentUrl end) + self.CurrentGithub = false + self.GithubButton = self:CreateLink("Source", "Open the mod's source repository in your browser.", + function() return self.CurrentGithub end) + + -- labelled detail sections below the header (Author / Description / Dependencies / Details), + -- mirroring the map dialog / Map tab — see CreateSectionLabel + LayoutDetail + self.AuthorLabel = self:CreateSectionLabel("Author") + self.AuthorValue = UIUtil.CreateText(self.PreviewArea, "", 13, UIUtil.bodyFont) + self.AuthorValue:SetColor(ValueColor) + self.AuthorValue:DisableHitTest() + + self.DescriptionLabel = self:CreateSectionLabel("Description") + self.Description = TextArea(self.PreviewArea, 200, 80) + self.Description:SetFont(UIUtil.bodyFont, 12) + self.Description:SetColors(ValueColor, "00000000", ValueColor, "00000000") + + self.DepsLabel = self:CreateSectionLabel("Dependencies") + self.DepsText = TextArea(self.PreviewArea, 200, 60) + self.DepsText:SetFont(UIUtil.bodyFont, 12) + self.DepsText:SetColors('ff9aa0a8', "00000000", 'ff9aa0a8', "00000000") + + self.DetailsLabel = self:CreateSectionLabel("Details") + self.DetailsText = TextArea(self.PreviewArea, 200, 52) + self.DetailsText:SetFont(UIUtil.bodyFont, 12) + self.DetailsText:SetColors('ff9aa0a8', "00000000", 'ff9aa0a8', "00000000") + + self.Note = UIUtil.CreateText(self.PreviewArea, "", 12, UIUtil.bodyFont) + self.Note:SetColor('ffd0a24c') + self.Note:DisableHitTest() + --#endregion + + --#region actions (presets + ok/cancel) + self.PresetLabel = UIUtil.CreateText(self.ActionArea, "Preset", 13, UIUtil.bodyFont) + self.PresetLabel:SetColor('ff9aa0a8') + + self.PresetCombo = Combo(self.ActionArea, 14, 8, nil, nil, "UI_Tab_Click_01", "UI_Tab_Rollover_01") + self.PresetCombo.OnClick = function(combo, index, text) + self:LoadPreset(text) + end + Tooltip.AddControlTooltipManual(self.PresetCombo, "Presets", "Load a saved selection of mods.") + + self.SavePresetButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Save", 14, 2) + self.SavePresetButton.OnClick = function(button, modifiers) + self:PromptSavePreset() + end + Tooltip.AddControlTooltipManual(self.SavePresetButton, "Save preset", "Save the current selection as a named preset.") + + self.DeletePresetButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Delete", 14, 2) + self.DeletePresetButton.OnClick = function(button, modifiers) + self:DeleteSelectedPreset() + end + Tooltip.AddControlTooltipManual(self.DeletePresetButton, "Delete preset", "Delete the selected preset.") + + self.SelectButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "OK", 16, 2) + self.SelectButton.OnClick = function(button, modifiers) + self:Confirm() + end + + self.CancelButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Cancel", 16, 2) + self.CancelButton.OnClick = function(button, modifiers) + self.OnCancelCb() + end + + self.ClearButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Clear", 14, 2) + self.ClearButton.OnClick = function(button, modifiers) + self:ClearSelection() + end + Tooltip.AddControlTooltipManual(self.ClearButton, "Clear", + "Deselect every mod you can change (UI mods, plus game mods when you're the host).") + --#endregion + + self.ModsObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyModCatalog.GetModsVar(), function(modsLazy) + self:OnModsChanged(modsLazy()) + end)) + + CustomLobbyModCatalog.EnsureLoaded() + + -- spin a small throbber beside the count while the catalog is still streaming + self.Trash:Add(ForkThread(function() + local frames = { "|", "/", "-", "\\" } + local i = 1 + while not CustomLobbyModCatalog.IsLoaded() do + if IsDestroyed(self) then + return + end + self.Spinner:SetText(frames[i]) + i = math.mod(i, 4) + 1 + WaitSeconds(0.12) + end + if not IsDestroyed(self) then + self.Spinner:SetText("") + end + end)) + end, + + ---@param self UICustomLobbyModSelect + __post_init = function(self) + self.Width:Set(LayoutHelpers.ScaleNumber(DialogWidth)) + self.Height:Set(LayoutHelpers.ScaleNumber(DialogHeight)) + + --#region areas + Layouter(self.TitleArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtTopIn(self, Pad):Height(TitleHeight):End() + Layouter(self.ActionArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtBottomIn(self, Pad):Height(ActionHeight):End() + Layouter(self.LeftArea) + :AtLeftIn(self, Pad):Width(LeftWidth) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) + :End() + Layouter(self.PreviewArea) + :AnchorToRight(self.LeftArea, ColumnGap):AtRightIn(self, Pad) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) + :End() + + Layouter(self.FilterArea):AtLeftIn(self.LeftArea):AtRightIn(self.LeftArea):AtTopIn(self.LeftArea):Height(FilterHeight):End() + Layouter(self.StatsArea):AtLeftIn(self.LeftArea):AtRightIn(self.LeftArea):AtBottomIn(self.LeftArea):Height(StatsHeight):End() + Layouter(self.SelectionArea) + :AtLeftIn(self.LeftArea):AtRightIn(self.LeftArea) + :AnchorToBottom(self.FilterArea, Pad):AnchorToTop(self.StatsArea, Pad) + :End() + --#endregion + + Layouter(self.Title):AtHorizontalCenterIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() + + --#region filters + Layouter(self.FilterTitle):AtLeftIn(self.FilterArea):AtTopIn(self.FilterArea):End() + Layouter(self.SearchLabel):AtLeftIn(self.FilterArea):AtVerticalCenterIn(self.Search):End() + Layouter(self.Search):AnchorToRight(self.SearchLabel, 8):AtRightIn(self.FilterArea):AnchorToBottom(self.FilterTitle, 8):Height(22):End() + Layouter(self.GameToggle):AtLeftIn(self.FilterArea):AnchorToBottom(self.Search, 12):End() + Layouter(self.UIToggle):AnchorToRight(self.GameToggle, 16):AtVerticalCenterIn(self.GameToggle):End() + Layouter(self.UnavailableToggle):AnchorToRight(self.UIToggle, 16):AtVerticalCenterIn(self.UIToggle):End() + --#endregion + + --#region selection list + stats + Layouter(self.ModList):AtLeftIn(self.SelectionArea):AtTopIn(self.SelectionArea):AtBottomIn(self.SelectionArea):End() + self.ModList.Right:Set(function() return self.SelectionArea.Right() - LayoutHelpers.ScaleNumber(32) end) + Layouter(self.EmptyLabel):AtHorizontalCenterIn(self.SelectionArea):AtVerticalCenterIn(self.SelectionArea):End() + + Layouter(self.CountLabel):AtLeftIn(self.StatsArea):AtVerticalCenterIn(self.StatsArea):End() + Layouter(self.Spinner):AnchorToRight(self.CountLabel, 8):AtVerticalCenterIn(self.StatsArea):End() + Layouter(self.SelectedLabel):AtRightIn(self.StatsArea):AtVerticalCenterIn(self.StatsArea):End() + --#endregion + + --#region detail panel + -- header: icon + title + quick-facts meta (version · type) + links + Layouter(self.Icon):AtLeftIn(self.PreviewArea):AtTopIn(self.PreviewArea):Width(IconSize):Height(IconSize):End() + Layouter(self.DetailTitle):AnchorToRight(self.Icon, 12):AtTopIn(self.PreviewArea, 2):End() + Layouter(self.DetailMeta):AnchorToRight(self.Icon, 12):AnchorToBottom(self.DetailTitle, 6):End() + Layouter(self.UrlButton):AnchorToRight(self.Icon, 12):AnchorToBottom(self.DetailMeta, 8):End() + Layouter(self.GithubButton):AnchorToRight(self.UrlButton, 16):AtVerticalCenterIn(self.UrlButton):End() + + Layouter(self.Note):AtLeftIn(self.PreviewArea):AtBottomIn(self.PreviewArea):End() + + -- Details (copyright / uid / location): a fixed block pinned above the note + Layouter(self.DetailsText) + :AtLeftIn(self.PreviewArea, 6):AtRightIn(self.PreviewArea, 32) + :AnchorToTop(self.Note, 10):Height(52) + :End() + Layouter(self.DetailsLabel):AtLeftIn(self.PreviewArea, 6):AnchorToTop(self.DetailsText, 2):End() + self.DetailsScrollbar = UIUtil.CreateVertScrollbarFor(self.DetailsText) + UIUtil.ForwardWheelToScroll(self.DetailsText, self.DetailsText) + + -- Description: left/right + height fixed here (width is bound in Initialize); its top + the + -- Author section above it are placed by LayoutDetail so Author can collapse when absent + Layouter(self.Description):AtLeftIn(self.PreviewArea, 6):AtRightIn(self.PreviewArea, 32):Height(120):End() + self.DescriptionScrollbar = UIUtil.CreateVertScrollbarFor(self.Description) + UIUtil.ForwardWheelToScroll(self.Description, self.Description) + + -- Dependencies: fills the gap between the description and the details block; scrolls + Layouter(self.DepsLabel):AtLeftIn(self.PreviewArea, 6):AnchorToBottom(self.Description, 10):End() + Layouter(self.DepsText) + :AtLeftIn(self.PreviewArea, 6):AtRightIn(self.PreviewArea, 32) + :AnchorToBottom(self.DepsLabel, 4):AnchorToTop(self.DetailsLabel, 8) + :End() + self.DepsScrollbar = UIUtil.CreateVertScrollbarFor(self.DepsText) + UIUtil.ForwardWheelToScroll(self.DepsText, self.DepsText) + --#endregion + + --#region actions + -- presets on the top row, OK / Cancel on the bottom row — the gap between them is the + -- vertical space the tall action area buys + -- top row: pin the (tall) combo + buttons to the area's top edge, and centre the short + -- label on the combo — centring the tall controls on a top-aligned label would clip them + Layouter(self.PresetCombo):AtLeftIn(self.ActionArea, 48):AtTopIn(self.ActionArea):Width(150):End() + Layouter(self.PresetLabel):AtLeftIn(self.ActionArea):AtVerticalCenterIn(self.PresetCombo):End() + Layouter(self.SavePresetButton):AnchorToRight(self.PresetCombo, 8):AtVerticalCenterIn(self.PresetCombo):End() + Layouter(self.DeletePresetButton):AnchorToRight(self.SavePresetButton, 4):AtVerticalCenterIn(self.PresetCombo):End() + + -- bottom row: Clear on the left, Cancel / OK on the right + Layouter(self.SelectButton):AtRightIn(self.ActionArea):AtBottomIn(self.ActionArea):End() + Layouter(self.CancelButton):AnchorToLeft(self.SelectButton, 12):AtVerticalCenterIn(self.SelectButton):End() + Layouter(self.ClearButton):AtLeftIn(self.ActionArea):AtVerticalCenterIn(self.SelectButton):End() + --#endregion + end, + + --- Builds a labelled checkbox filter wired to `onChange(checked)`, with a tooltip. + ---@param self UICustomLobbyModSelect + ---@param label string + ---@param initial boolean + ---@param onChange fun(checked: boolean) + ---@param tooltipTitle string + ---@param tooltipBody string + ---@return Checkbox + CreateToggle = function(self, label, initial, onChange, tooltipTitle, tooltipBody) + local checkbox = UIUtil.CreateCheckbox(self.FilterArea, '/CHECKBOX/', label, true, 13) + checkbox:SetCheck(initial, true) + checkbox.OnCheck = function(control, checked) + onChange(checked) + end + Tooltip.AddControlTooltipManual(checkbox, tooltipTitle, tooltipBody) + return checkbox + end, + + --- Builds a clickable text link whose target comes from `getUrl()` (so it can change as the + --- highlighted mod changes). Hidden until shown by UpdateDetail. + ---@param self UICustomLobbyModSelect + ---@param label string + ---@param tooltipBody string + ---@param getUrl fun(): string | false + ---@return Text + CreateLink = function(self, label, tooltipBody, getUrl) + local link = UIUtil.CreateText(self.PreviewArea, label, 12, UIUtil.bodyFont) + link:SetColor('ff7fb3ff') + link:Hide() + link.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + local url = getUrl() + if url then + OpenURL(ToOpenableUrl(url)) + end + return true + elseif event.Type == 'MouseEnter' then + control:SetColor('ffaecbff') + return true + elseif event.Type == 'MouseExit' then + control:SetColor('ff7fb3ff') + return true + end + return false + end + Tooltip.AddControlTooltipManual(link, "Open link", tooltipBody) + return link + end, + + --- Builds a dim section label (Author / Description / Dependencies / Details). Private. + ---@param self UICustomLobbyModSelect + ---@param text string + ---@return Text + CreateSectionLabel = function(self, text) + local label = UIUtil.CreateText(self.PreviewArea, text, 12, UIUtil.titleFont) + label:SetColor(LabelColor) + label:DisableHitTest() + return label + end, + + --- Places the Author section (collapsing it when the mod declares no author) and the + --- Description label/area below it — the rest of the stack (Dependencies, Details) is anchored + --- statically off the Description in __post_init. + ---@param self UICustomLobbyModSelect + ---@param hasAuthor boolean + LayoutDetail = function(self, hasAuthor) + if hasAuthor then + self.AuthorLabel:Show() + self.AuthorValue:Show() + Layouter(self.AuthorLabel):AtLeftIn(self.PreviewArea, 6):AnchorToBottom(self.Icon, 14):End() + Layouter(self.AuthorValue):AtLeftIn(self.PreviewArea, 6):AnchorToBottom(self.AuthorLabel, 2):End() + Layouter(self.DescriptionLabel):AtLeftIn(self.PreviewArea, 6):AnchorToBottom(self.AuthorValue, 8):End() + else + self.AuthorLabel:Hide() + self.AuthorValue:Hide() + Layouter(self.DescriptionLabel):AtLeftIn(self.PreviewArea, 6):AnchorToBottom(self.Icon, 14):End() + end + Layouter(self.Description) + :AtLeftIn(self.PreviewArea, 6):AtRightIn(self.PreviewArea, 32) + :AnchorToBottom(self.DescriptionLabel, 4):Height(120) + :End() + end, + + --- Builds the list pool + populates + wires the selection. Called by the opener after the + --- dialog is mounted + centred by Popup (three-phase init, /lua/ui/CLAUDE.md § 1). + ---@param self UICustomLobbyModSelect + Initialize = function(self) + self.Ready = true + + -- TextArea pins Width to its constructor value and wraps to Width(), not Left..Right. + -- Bind it to the laid-out span so text wraps at the real panel width — but only now (the + -- opener calls Initialize after Popup mounts): the bind eagerly fires Width.OnDirty → + -- ReflowText, which reads the parent geometry, circular until we're mounted. + self.Description.Width:Set(function() return self.Description.Right() - self.Description.Left() end) + self.DepsText.Width:Set(function() return self.DepsText.Right() - self.DepsText.Left() end) + self.DetailsText.Width:Set(function() return self.DetailsText.Right() - self.DetailsText.Left() end) + + self.ModList:Initialize() + self.ModList:SetCanToggle(function(mod) + if mod.type == 'BLACKLISTED' or mod.type == 'NO_DEPENDENCY' then + return false + end + if not mod.ui_only and not self.CanEditGameMods then + return false + end + return true + end) + self.ModList:SetChecked(self.Selection) + self:RefreshPresets() + self:Populate() + end, + + --- The catalog published a new (possibly larger) mod list: recount always, re-list once + --- we're mounted. + ---@param self UICustomLobbyModSelect + ---@param mods UILobbyModInfo[] + OnModsChanged = function(self, mods) + self.Mods = mods + if self.Ready then + self:Populate() + else + self:UpdateStats() + end + end, + + --- Persists the current filters + search for next time. + ---@param self UICustomLobbyModSelect + SavePrefs = function(self) + Prefs.SetToCurrentProfile(PrefsKey, { + showGame = self.ShowGame, + showUI = self.ShowUI, + showUnavailable = self.ShowUnavailable, + search = self.Search:GetText() or "", + }) + end, + + --- Rebuilds the list from the catalog, applying the name/author search + type filters, and + --- keeps the current highlight. + ---@param self UICustomLobbyModSelect + Populate = function(self) + local search = string.lower(self.Search:GetText() or "") + local targetUid = self.Highlighted and self.Highlighted.uid + + self.Filtered = {} + local highlightRow = 0 + + for _, mod in self.Mods do + if self:PassesFilters(mod, search) then + table.insert(self.Filtered, mod) + if targetUid and mod.uid == targetUid then + highlightRow = table.getn(self.Filtered) + end + end + end + + self.ModList:SetItems(self.Filtered) + self.ModList:SetChecked(self.Selection) + + if table.getn(self.Filtered) > 0 then + self.EmptyLabel:Hide() + local row = highlightRow > 0 and highlightRow or 1 + self.ModList:SetSelection(row) + self.ModList:ShowItem(row) + self:OnModHighlighted(self.Filtered[row]) + else + self.EmptyLabel:Show() + self.Highlighted = nil + self:ClearDetail() + end + + self:UpdateStats() + end, + + --- Whether a mod passes the type filters + name/author search. + ---@param self UICustomLobbyModSelect + ---@param mod UILobbyModInfo + ---@param search string # already lowercased + ---@return boolean + PassesFilters = function(self, mod, search) + local unavailable = mod.type == 'BLACKLISTED' or mod.type == 'NO_DEPENDENCY' + if unavailable then + if not self.ShowUnavailable then + return false + end + elseif mod.ui_only then + if not self.ShowUI then + return false + end + else + if not self.ShowGame then + return false + end + end + + if search ~= "" then + local haystack = string.lower((mod.title or "") .. " " .. (mod.author or "")) + if not string.find(haystack, search, 1, true) then + return false + end + end + return true + end, + + --- A mod was highlighted (row click): show its details. + ---@param self UICustomLobbyModSelect + ---@param mod UILobbyModInfo + OnModHighlighted = function(self, mod) + if not mod then + return + end + self.Highlighted = mod + self:UpdateDetail(mod) + end, + + --- Toggles a mod's membership in the working selection (with dependency / conflict + --- resolution), repaints the list, and refreshes the stats. + ---@param self UICustomLobbyModSelect + ---@param mod UILobbyModInfo + ---@param checked boolean + ToggleMod = function(self, mod, checked) + local disabled = {} + if checked then + self.Selection, disabled = ModUtilities.ResolveEnable(self.Selection, mod.uid) + else + self.Selection = ModUtilities.ResolveDisable(self.Selection, mod.uid) + end + + self.ModList:SetChecked(self.Selection) + self:UpdateStats() + + if table.getn(disabled) > 0 then + local conflictSet = {} + for _, uid in disabled do + conflictSet[uid] = true + end + self.Note:SetText("Disabled conflicting: " .. NamesOf(conflictSet)) + else + self.Note:SetText("") + end + end, + + --- Fills the detail panel for `mod` as labelled sections (Author / Description / Dependencies / + --- Details), plus the icon, title, quick-facts meta (version · type) and links. + ---@param self UICustomLobbyModSelect + ---@param mod UILobbyModInfo + UpdateDetail = function(self, mod) + -- one re-textured icon for the whole dialog (never one per row — see the header note) + self.Icon:SetTexture(mod.icon) + self.Icon:Show() + + self.DetailTitle:SetText(Truncate(mod.title or mod.name or "?", TitleMaxChars)) + + -- quick facts beside the icon (author moved to its own labelled section below) + local parts = {} + if mod.versionText ~= "" then + table.insert(parts, mod.versionText) + end + table.insert(parts, mod.ui_only and "UI mod" or "Game mod") + self.DetailMeta:SetText(table.concat(parts, " · ")) + + self.CurrentUrl = IsAllowedUrl(mod.url) and mod.url or false + if self.CurrentUrl then self.UrlButton:Show() else self.UrlButton:Hide() end + self.CurrentGithub = IsAllowedUrl(mod.github) and mod.github or false + if self.CurrentGithub then self.GithubButton:Show() else self.GithubButton:Hide() end + + local author = mod.author + local hasAuthor = type(author) == "string" and author ~= "" + if hasAuthor then + self.AuthorValue:SetText(author) + end + + self.DescriptionLabel:Show() + self.Description:SetText(mod.description and LOC(mod.description) or "") + + -- Dependencies (requires / conflicts / missing) and Details (copyright / uid / location) + -- are now their own labelled sections + local deps = self:DependencySummary(mod) + self.DepsLabel:Show() + self.DepsText:SetText(deps ~= "" and deps or "None") + self.DetailsLabel:Show() + self.DetailsText:SetText(self:ModInfoLines(mod)) + self.Note:SetText("") + + self:LayoutDetail(hasAuthor) + UpdateTextAreaScrollbar(self.Description, self.DescriptionScrollbar) + UpdateTextAreaScrollbar(self.DepsText, self.DepsScrollbar) + UpdateTextAreaScrollbar(self.DetailsText, self.DetailsScrollbar) + end, + + --- The minor identity fields shown at the bottom of the detail block: copyright, uid, and the + --- mod's location on disk. + ---@param self UICustomLobbyModSelect + ---@param mod UILobbyModInfo + ---@return string + ModInfoLines = function(self, mod) + -- always show all three labels; a missing field reads "omitted" rather than vanishing + local function value(v) + return (v and v ~= "") and v or "omitted" + end + return table.concat({ + "Copyright: " .. value(mod.copyright), + "UID: " .. value(mod.uid), + "Location: " .. value(mod.location), + }, "\n") + end, + + --- A one-or-more-line summary of a mod's requirements / conflicts / missing dependencies. + ---@param self UICustomLobbyModSelect + ---@param mod UILobbyModInfo + ---@return string + DependencySummary = function(self, mod) + local lines = {} + if mod.blacklistReason then + table.insert(lines, "Blacklisted: " .. LOC(mod.blacklistReason)) + end + if mod.missing then + table.insert(lines, "Missing dependencies: " .. NamesOf(mod.missing)) + end + if mod.requires then + table.insert(lines, "Requires: " .. NamesOf(mod.requires)) + end + if mod.conflicts then + table.insert(lines, "Conflicts with: " .. NamesOf(mod.conflicts)) + end + return table.concat(lines, "\n") + end, + + --- Clears the detail panel (no highlight / empty list): blanks the values and hides the + --- section labels so no empty headings linger. + ---@param self UICustomLobbyModSelect + ClearDetail = function(self) + -- keep the stack anchored (Description's top lives in LayoutDetail) so an empty open + -- doesn't leave it unanchored → circular dependency at render + self:LayoutDetail(false) + self.Icon:Hide() + self.DetailTitle:SetText("") + self.DetailMeta:SetText("") + self.AuthorLabel:Hide() + self.AuthorValue:SetText("") + self.AuthorValue:Hide() + self.DescriptionLabel:Hide() + self.Description:SetText("") + self.DepsLabel:Hide() + self.DepsText:SetText("") + self.DetailsLabel:Hide() + self.DetailsText:SetText("") + self.Note:SetText("") + self.CurrentUrl = false + self.CurrentGithub = false + self.UrlButton:Hide() + self.GithubButton:Hide() + UpdateTextAreaScrollbar(self.Description, self.DescriptionScrollbar) + UpdateTextAreaScrollbar(self.DepsText, self.DepsScrollbar) + UpdateTextAreaScrollbar(self.DetailsText, self.DetailsScrollbar) + end, + + --- Updates the footer: "X of Y mods" + the selected breakdown ("G game · U UI"), so the host + --- can see what actually syncs (game) vs. stays local (UI). + ---@param self UICustomLobbyModSelect + UpdateStats = function(self) + local total = table.getn(self.Mods) + local shown = table.getn(self.Filtered) + if self.Ready and shown < total then + self.CountLabel:SetText(LOCF("%d of %d mods", shown, total)) + else + self.CountLabel:SetText(LOCF("%d mods", total)) + end + + local game = table.getsize(ModUtilities.FilterSimMods(self.Selection)) + local ui = table.getsize(ModUtilities.FilterUIMods(self.Selection)) + self.SelectedLabel:SetText(LOCF("%d game · %d UI", game, ui)) + end, + + --------------------------------------------------------------------------- + --#region Presets + + --- Reloads the preset dropdown from stored presets. + ---@param self UICustomLobbyModSelect + RefreshPresets = function(self) + self.PresetNames = {} + for _, preset in ModUtilities.GetPresets() do + table.insert(self.PresetNames, preset.Name) + end + self.PresetCombo:ClearItems() + if table.getn(self.PresetNames) > 0 then + self.PresetCombo:AddItems(self.PresetNames) + self.DeletePresetButton:Enable() + else + self.PresetCombo:AddItems({ "(no presets)" }) + self.DeletePresetButton:Disable() + end + end, + + --- Loads a preset's selection into the working set (pruned to installed mods; sim mods are + --- dropped for a non-host who can't change them). + ---@param self UICustomLobbyModSelect + ---@param name string + LoadPreset = function(self, name) + local stored = ModUtilities.GetPreset(name) + if not stored then + return + end + local selection = ModUtilities.PruneMissing(stored) + if not self.CanEditGameMods then + selection = ModUtilities.FilterUIMods(selection) + end + self.Selection = selection + self.ModList:SetChecked(self.Selection) + self:UpdateStats() + self.Note:SetText("Loaded preset '" .. name .. "'") + end, + + --- Prompts for a name and saves the current selection as a preset. + ---@param self UICustomLobbyModSelect + PromptSavePreset = function(self) + UIUtil.CreateInputDialog(GetFrame(0), "Name this preset", function(dialog, name) + if not name or name == "" then + return + end + ModUtilities.SavePreset(name, self.Selection) + self:RefreshPresets() + self.Note:SetText("Saved preset '" .. name .. "'") + end) + end, + + --- Deletes the preset currently shown in the dropdown. + ---@param self UICustomLobbyModSelect + DeleteSelectedPreset = function(self) + local index = self.PresetCombo:GetItem() + local name = self.PresetNames[index] + if not name then + return + end + ModUtilities.DeletePreset(name) + self:RefreshPresets() + self.Note:SetText("Deleted preset '" .. name .. "'") + end, + + --#endregion + + --- Deselects everything the user is allowed to change: all mods for the host, only UI mods for + --- a non-host (their sim mods are the host's choice and stay put). + ---@param self UICustomLobbyModSelect + ClearSelection = function(self) + if self.CanEditGameMods then + self.Selection = {} + else + self.Selection = ModUtilities.FilterSimMods(self.Selection) + end + self.ModList:SetChecked(self.Selection) + self:UpdateStats() + self.Note:SetText("Cleared selection") + end, + + --- Commits the working selection via the opener's callback. + ---@param self UICustomLobbyModSelect + Confirm = function(self) + self.OnConfirmCb(self.Selection) + end, + + ---@param self UICustomLobbyModSelect + OnDestroy = function(self) + self:SavePrefs() + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +--#region Singleton + open / close + +---@type Popup | false +local Instance = false + +--- Internal: builds the dialog over `parent` with the given options and wires the Popup. +---@param parent Control +---@param options { initial: UIModSelection, canEditGameMods: boolean, onConfirm: fun(selection: UIModSelection) } +local function OpenWith(parent, options) + if Instance then + Instance:Close() + end + + local popup + local content = CustomLobbyModSelect(parent, { + initial = options.initial, + canEditGameMods = options.canEditGameMods, + onConfirm = function(selection) + options.onConfirm(selection) + if popup then + popup:Close() + end + end, + onCancel = function() + if popup then + popup:Close() + end + end, + }) + + popup = Popup(parent, content) + local baseOnClosed = popup.OnClosed + popup.OnClosed = function(self) + baseOnClosed(self) + Instance = false + end + Instance = popup + + -- now that Popup has mounted + centred the content, it's safe to build the list pool + + -- populate (both read concrete geometry) + content:Initialize() +end + +--- Opens the mod-select dialog for the **lobby**. Sim mods route through the host-authoritative +--- `RequestSetGameMods` intent (a non-host sees them read-only); UI mods persist locally. Starts +--- from the launch model's sim mods + this peer's UI mods. +---@param parent? Control +function Open(parent) + parent = parent or GetFrame(0) + + local launch = CustomLobbyLaunchModel.GetSingleton() + local isHost = CustomLobbyLocalModel.GetSingleton().IsHost() + + -- seed: host-dictated sim mods (synced) + this peer's own UI mods (local) + local initial = table.copy(launch.GameMods() or {}) + for uid in ModUtilities.GetSelectedUIMods() do + initial[uid] = true + end + + OpenWith(parent, { + initial = initial, + canEditGameMods = isHost, + onConfirm = function(selection) + ModUtilities.SetSelectedUIMods(selection) -- this peer's UI mods, applied + persisted + if isHost then + CustomLobbyController.RequestSetGameMods(ModUtilities.FilterSimMods(selection)) + end + end, + }) +end + +--- Opens the mod-select dialog **standalone** (no lobby — the main-menu mod manager). The whole +--- selection persists to the preference file. `onClosed` (optional) runs after it closes. +---@param parent? Control +---@param onClosed? fun() +function OpenStandalone(parent, onClosed) + parent = parent or GetFrame(0) + + OpenWith(parent, { + initial = ModUtilities.GetSelectedMods(), + canEditGameMods = true, + onConfirm = function(selection) + ModUtilities.SetSelectedMods(selection) + end, + }) + + if onClosed then + local popup = Instance + if popup then + local baseOnClosed = popup.OnClosed + popup.OnClosed = function(self) + baseOnClosed(self) + onClosed() + end + end + end +end + +--- Closes the dialog if open. +function Close() + if Instance then + Instance:Close() + Instance = false + end +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + Close() +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/optionselect/CLAUDE.md b/lua/ui/lobby/customlobby/optionselect/CLAUDE.md new file mode 100644 index 00000000000..ffa31b0c333 --- /dev/null +++ b/lua/ui/lobby/customlobby/optionselect/CLAUDE.md @@ -0,0 +1,55 @@ +# Option select + +The custom lobby's **options dialog** — three columns (lobby / scenario / mod options) over the +selected scenario + mods. The third sibling of [`../mapselect/`](../mapselect/CLAUDE.md) and +[`../modselect/`](../modselect/CLAUDE.md), built to the same shape. Opened by the host-only +"Options" button in [`../CustomLobbyInterface.lua`](../CustomLobbyInterface.lua). + +> Read [`../CLAUDE.md`](../CLAUDE.md) first (the lobby's MVC + the layout/init gotchas), then the +> map/mod dialog docs — this mirrors them. + +## Files + +| File | Role | +|------|------| +| [CustomLobbyOptionSelect.lua](CustomLobbyOptionSelect.lua) | the dialog (transient `Popup`). Three fixed columns + a top filter bar (search by label + a **Hide defaults** toggle) + Reset / OK / Cancel. Owns a *working copy* of the option values; on OK it seeds defaults for every untouched option and hands the complete set to `onConfirm`. The in-lobby opener routes that through the host-authoritative `RequestSetGameOptions` intent. Owns no synced state. | +| [CustomLobbyOptionColumn.lua](CustomLobbyOptionColumn.lua) | one column: header (title + shown count), a native **`Grid`** of option rows, an auto-hiding scrollbar, and an **empty state**. Each row is `[marker] label … [value dropdown]`; the marker + a tinted label flag an option that's **not at its default**. Reads/writes the shared values table and reads the schema via `optionutil`. | + +The option domain logic lives one level up in [`/lua/ui/optionutil.lua`](/lua/ui/optionutil.lua) — +the `maputil.lua` / `modutilities.lua` sibling (inspired by Penguin5's `optionutil.lua`): it +**gathers** the schema from the three sources, and **interprets** options + values (default value, +current value, is-default, value index, display text, seed-defaults). + +## The schema (reference data) vs. the values (synced) + +The option **schema** is derived per-peer from the synced `ScenarioFile` + `GameMods` — it is +*reference data*, not a model (like the map/mod catalogs). The three sources: + +- **lobby** — the static base options (team ∪ global ∪ AI) from [`../../lobbyOptions.lua`](../../lobbyOptions.lua); +- **scenario** — the selected map's `_options.lua`, via the map catalog's `LoadOptions` + ([`../mapselect/CustomLobbyMapCatalog.lua`](../mapselect/CustomLobbyMapCatalog.lua) — the lobby's + one reader of scenario files; the `_options.lua` name mirrors `_scenario.lua` / `mod_info.lua`); +- **mods** — each selected sim mod's `/lua/AI/LobbyOptions/lobbyoptions.lua` (`AIOpts`). + +Only the option **values** sync: the host edits them here and they ride in the launch model's +`GameOptions` (broadcast via `BroadcastLaunchInfo`, applied in `ProcessSentLaunchInfo`). The dialog +seeds defaults + drops keys absent from the current schema, so confirming is also the +**reconciliation** the [`../CLAUDE.md`](../CLAUDE.md) options-slice note called for. + +## Why a `Grid`, not the pooled list + +The map/mod lists are text-only and virtualise by hand. Option rows hold a live **`Combo`** each, +which is painful to repaint on every scroll tick. The native `Grid` hides off-screen rows itself, +so the combos are created once (per filter rebuild) with no clipping tricks and no per-scroll +churn. Rows are rebuilt only when the **filter** (search / hide-defaults) changes — never on a +value edit — so editing never disturbs an open dropdown. + +## Known gaps (deliberate, for a later pass) + +- **No per-column show/hide toggles.** The `Grid` destroys its items on `SetDimensions`, so + reflowing to fewer/wider columns means a rebuild; the three columns are fixed for now. The top + bar is search + hide-defaults only. +- **Host-only.** Options are all host-dictated + synced, so the dialog is host-only (the button + hides for clients; the intent is host-gated regardless). No read-only client view yet. +- **No live cross-mod/scenario key-clash handling.** `optionutil` de-dups within a source by key; + it doesn't resolve a scenario option colliding with a base lobby key (rare). diff --git a/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionColumn.lua b/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionColumn.lua new file mode 100644 index 00000000000..37b78c9e7ca --- /dev/null +++ b/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionColumn.lua @@ -0,0 +1,274 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- One column of the options dialog (lobby / scenario / mod). A header (title + shown count), a +-- scrollable `Grid` of option rows, an auto-hiding scrollbar, and an empty-state label. +-- +-- Each row is `[marker] label …………… [value dropdown]`. The dropdown picks among the option's +-- values; the marker (a dot) lights up when the option is *not* at its default, so changed +-- options stand out. The column doesn't own the values — it reads/writes a shared values table +-- (handed in via `SetData`) and calls `onChange` so the dialog can react (re-count, etc.). +-- +-- The list uses the native `Grid`, which hides off-screen rows itself, so the value dropdowns are +-- real `Combo`s created once (no per-scroll rebuild, no clipping tricks). Rows are rebuilt only +-- when the filter (search / hide-defaults) changes — not on a value edit, so editing never +-- disturbs an open dropdown. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Grid = import("/lua/maui/grid.lua").Grid +local Combo = import("/lua/ui/controls/combo.lua").Combo + +local OptionUtil = import("/lua/ui/optionutil.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor + +local HeaderHeight = 24 +local RowHeight = 30 +local ScrollbarGap = 32 -- space reserved on the column's right for the scrollbar (standard lobby gutter) +local ComboWidth = 116 +local MarkerWidth = 14 + +local ModifiedColor = 'ffd0a24c' -- label + marker colour for a non-default option +local DefaultColor = 'ffc8ccd0' + +local LabelMaxChars = 22 -- single-line Text doesn't clip; cap with an ellipsis + +--- Truncates `text` to `maxChars`, appending "…" when it had to cut. +---@param text string +---@param maxChars number +---@return string +local function Truncate(text, maxChars) + text = text or "" + if string.len(text) > maxChars then + return string.sub(text, 1, maxChars - 1) .. "…" + end + return text +end + +---@class UICustomLobbyOptionColumn : Group +---@field Trash TrashBag +---@field ContentWidth number +---@field Header Text +---@field Count Text +---@field Grid Grid +---@field Scrollbar Scrollbar | false +---@field Empty Text +---@field Options ScenarioOption[] +---@field Values table +---@field OnChange fun() +---@field Shown number # options currently displayed (after filtering) +local CustomLobbyOptionColumn = ClassUI(Group) { + + ---@param self UICustomLobbyOptionColumn + ---@param parent Control + ---@param title string + ---@param contentWidth number # unscaled width of the grid / rows (excludes the scrollbar gap) + __init = function(self, parent, title, contentWidth) + Group.__init(self, parent, "CustomLobbyOptionColumn") + + self.Trash = TrashBag() + self.ContentWidth = contentWidth + self.Options = {} + self.Values = {} + self.OnChange = nil + self.Scrollbar = false + self.Shown = 0 + + self.Header = UIUtil.CreateText(self, title, 16, UIUtil.titleFont) + self.Header:DisableHitTest() + + self.Count = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) + self.Count:SetColor('ff9aa0a8') + self.Count:DisableHitTest() + + self.Grid = Grid(self, contentWidth, RowHeight) + + self.Empty = UIUtil.CreateText(self, "No options", 13, UIUtil.bodyFont) + self.Empty:SetColor('ff8a909a') + self.Empty:DisableHitTest() + self.Empty:Hide() + end, + + ---@param self UICustomLobbyOptionColumn + __post_init = function(self) + Layouter(self.Header):AtLeftIn(self):AtTopIn(self):End() + Layouter(self.Count):AtRightIn(self, ScrollbarGap):AtVerticalCenterIn(self.Header):End() + Layouter(self.Grid) + :AtLeftIn(self):AnchorToBottom(self.Header, 6):AtBottomIn(self) + :Width(self.ContentWidth) + :End() + Layouter(self.Empty):AtHorizontalCenterIn(self.Grid):AtTopIn(self.Grid, 12):End() + end, + + --- Points the column at its options + the shared values table, and the change callback. The + --- title can be refreshed too. Does not build rows yet (see Refresh). + ---@param self UICustomLobbyOptionColumn + ---@param options ScenarioOption[] + ---@param values table + ---@param onChange fun() + SetData = function(self, options, values, onChange) + self.Options = options or {} + self.Values = values or {} + self.OnChange = onChange + end, + + --- Builds the scrollbar; called by the dialog after mount (the Grid needs a concrete height). + ---@param self UICustomLobbyOptionColumn + Initialize = function(self) + if self.Scrollbar then + return + end + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Grid) + UIUtil.ForwardWheelToScroll(self.Grid, self.Grid) + end, + + --- Rebuilds the visible rows applying the search + hide-defaults filter, updates the count and + --- the empty state, and shows the scrollbar only when it's needed. + ---@param self UICustomLobbyOptionColumn + ---@param search string # already lowercased; "" = no search + ---@param hideDefaults boolean + Refresh = function(self, search, hideDefaults) + self.Grid:DeleteAndDestroyAll(true) + + local filtered = {} + for _, option in self.Options do + if self:Passes(option, search, hideDefaults) then + table.insert(filtered, option) + end + end + self.Shown = table.getn(filtered) + + if self.Shown > 0 then + self.Empty:Hide() + self.Grid:AppendCols(1, true) + self.Grid:AppendRows(self.Shown, true) + for row, option in filtered do + self.Grid:SetItem(self:CreateRow(option), 1, row, true) + end + self.Grid:EndBatch() + else + self.Empty:Show() + end + + self.Count:SetText(tostring(self.Shown)) + self:UpdateScrollbar() + end, + + --- Whether an option survives the current filter. + ---@param self UICustomLobbyOptionColumn + ---@param option ScenarioOption + ---@param search string + ---@param hideDefaults boolean + ---@return boolean + Passes = function(self, option, search, hideDefaults) + if hideDefaults and OptionUtil.IsDefault(option, self.Values) then + return false + end + if search ~= "" then + if not string.find(string.lower(LOC(option.label) or ""), search, 1, true) then + return false + end + end + return true + end, + + --- Builds one option row: a non-default marker, the label, and a value dropdown. Private. + ---@param self UICustomLobbyOptionColumn + ---@param option ScenarioOption + ---@return Group + CreateRow = function(self, option) + local row = Group(self.Grid) + LayoutHelpers.SetDimensions(row, self.ContentWidth, RowHeight) + + local marker = Bitmap(row) + marker:SetSolidColor(ModifiedColor) + marker:DisableHitTest() + + local label = UIUtil.CreateText(row, Truncate(LOC(option.label) or option.key, LabelMaxChars), 14, UIUtil.bodyFont) + label:DisableHitTest() + + local combo = Combo(row, 14, 10, nil, nil, "UI_Tab_Click_01", "UI_Tab_Rollover_01") + combo:AddItems(OptionUtil.ValueLabels(option), OptionUtil.FindValueIndex(option, OptionUtil.GetCurrentValueKey(option, self.Values))) + combo.OnClick = function(control, index, text) + self.Values[option.key] = OptionUtil.ValueKeyOf(option.values[index]) + self:PaintMarker(marker, label, option) + if self.OnChange then + self.OnChange() + end + end + + Layouter(marker):AtLeftIn(row):AtVerticalCenterIn(row):Width(6):Height(6):End() + Layouter(combo):AtRightIn(row, 4):AtVerticalCenterIn(row):Width(ComboWidth):End() + Layouter(label):AtLeftIn(row, MarkerWidth):AtVerticalCenterIn(row):End() + + Tooltip.AddControlTooltipManual(label, LOC(option.label) or option.key, LOC(option.help) or "") + self:PaintMarker(marker, label, option) + + return row + end, + + --- Lights the marker + tints the label when the option is off its default, dims both when not. + ---@param self UICustomLobbyOptionColumn + ---@param marker Bitmap + ---@param label Text + ---@param option ScenarioOption + PaintMarker = function(self, marker, label, option) + if OptionUtil.IsDefault(option, self.Values) then + marker:Hide() + label:SetColor(DefaultColor) + else + marker:Show() + label:SetColor(ModifiedColor) + end + end, + + --- Shows the scrollbar only when the grid actually overflows. + ---@param self UICustomLobbyOptionColumn + UpdateScrollbar = function(self) + if not self.Scrollbar then + return + end + if self.Grid:IsScrollable("Vert") then + self.Scrollbar:Show() + else + self.Scrollbar:Hide() + end + end, + + ---@param self UICustomLobbyOptionColumn + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@param title string +---@param contentWidth number +---@return UICustomLobbyOptionColumn +Create = function(parent, title, contentWidth) + return CustomLobbyOptionColumn(parent, title, contentWidth) +end diff --git a/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua b/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua new file mode 100644 index 00000000000..46b0ad1ce05 --- /dev/null +++ b/lua/ui/lobby/customlobby/optionselect/CustomLobbyOptionSelect.lua @@ -0,0 +1,370 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The options dialog: three columns — lobby, scenario and mod options — over the currently +-- selected scenario + mods. The third sibling of the map- and mod-select dialogs. +-- +-- It is a transient picker, NOT a model component: it owns no synced state. Its inputs are the +-- selected `ScenarioFile`, the selected sim mods (`GameMods`) and the existing option *values*; +-- it derives the option *schema* per column via `optionutil` (lobby = lobbyOptions.lua, scenario = +-- the map's `_options.lua`, mods = each sim mod's lobbyoptions). Only options from the selected +-- scenario / mods are shown, so a column is empty (with an empty state) when that source has none. +-- +-- It edits a working copy of the values and, on OK, hands the complete value set (defaults seeded +-- for everything untouched) to an `onConfirm` callback. The in-lobby opener routes that through +-- the host-authoritative `RequestSetGameOptions` intent (game options are synced). An option that +-- is *not* at its default is marked per row (a dot + tinted label) by the column. +-- +-- Top bar: a search filter (by option label) and a "Hide defaults" toggle. (Per-column show/hide +-- toggles would need the columns to reflow, which the Grid can't do without rebuilding — left for +-- later; the three columns are fixed for now.) + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Edit = import("/lua/maui/edit.lua").Edit +local Popup = import("/lua/ui/controls/popups/popup.lua").Popup + +local OptionUtil = import("/lua/ui/optionutil.lua") +local CustomLobbyMapCatalog = import("/lua/ui/lobby/customlobby/mapselect/customlobbymapcatalog.lua") +local CustomLobbyOptionColumn = import("/lua/ui/lobby/customlobby/optionselect/customlobbyoptioncolumn.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- flip to tint each layout area so the regions are visible while iterating +local Debug = false + +local DialogWidth = 980 +local DialogHeight = 620 +local Pad = 12 +local ColGap = 16 +local TitleHeight = 32 +local FilterHeight = 28 +local ActionHeight = 48 + +-- three equal columns spanning the inner width, each reserving room on its right for a scrollbar +local ColTotalWidth = math.floor((DialogWidth - 2 * Pad - 2 * ColGap) / 3) +local ColContentWidth = ColTotalWidth - 32 -- reserve the standard 32px scrollbar gutter + +local PrefsKey = "customlobby_optionselect" + +--- Concatenates the three columns' option lists into one (for seeding defaults across all of them). +---@param lobby ScenarioOption[] +---@param scenario ScenarioOption[] +---@param mods ScenarioOption[] +---@return ScenarioOption[] +local function ConcatOptions(lobby, scenario, mods) + local all = {} + for _, list in { lobby, scenario, mods } do + for _, option in list do + table.insert(all, option) + end + end + return all +end + +--- Creates a layout area (an invisible Group with an optional debug tint). +---@param parent Control +---@param name string +---@param color string +---@return Group +local function CreateArea(parent, name, color) + local area = Group(parent, name) + local bg = Bitmap(area) + bg:SetSolidColor(color) + bg:SetAlpha(Debug and 0.18 or 0.0) + bg:DisableHitTest() + Layouter(bg):Fill(area):End() + return area +end + +---@class UICustomLobbyOptionSelect : Group +---@field Trash TrashBag +---@field Values table +---@field LobbyOptions ScenarioOption[] +---@field ScenarioOptions ScenarioOption[] +---@field ModOptions ScenarioOption[] +---@field OnConfirmCb fun(values: table) +---@field OnCancelCb fun() +---@field HideDefaults boolean +---@field TitleArea Group +---@field ActionArea Group +---@field Title Text +---@field SearchLabel Text +---@field Search Edit +---@field HideDefaultsToggle Checkbox +---@field LobbyColumn UICustomLobbyOptionColumn +---@field ScenarioColumn UICustomLobbyOptionColumn +---@field ModColumn UICustomLobbyOptionColumn +---@field ResetButton Button +---@field SelectButton Button +---@field CancelButton Button +---@field Ready boolean +local CustomLobbyOptionSelect = ClassUI(Group) { + + ---@param self UICustomLobbyOptionSelect + ---@param parent Control + ---@param options { scenarioFile: FileName|false, gameMods: table, values: table, onConfirm: fun(values: table), onCancel: fun() } + __init = function(self, parent, options) + Group.__init(self, parent, "CustomLobbyOptionSelect") + + self.Trash = TrashBag() + self.OnConfirmCb = options.onConfirm + self.OnCancelCb = options.onCancel + self.Ready = false + + -- working copy of the values; the columns read + write this same table + self.Values = table.copy(options.values or {}) + + -- the schema, derived per-column from the selected scenario + mods (reference data) + self.LobbyOptions = OptionUtil.GetLobbyOptions() + self.ScenarioOptions = CustomLobbyMapCatalog.LoadOptions(options.scenarioFile) + self.ModOptions = OptionUtil.GetModOptions(options.gameMods) + + local saved = import("/lua/user/prefs.lua").GetFromCurrentProfile(PrefsKey) or {} + self.HideDefaults = saved.hideDefaults == true + + self.TitleArea = CreateArea(self, "TitleArea", 'ffcc4040') + self.ActionArea = CreateArea(self, "ActionArea", 'ff808080') + + self.Title = UIUtil.CreateText(self.TitleArea, "Game options", 22, UIUtil.titleFont) + + --#region top filter bar + self.SearchLabel = UIUtil.CreateText(self, "Search", 13, UIUtil.bodyFont) + self.SearchLabel:SetColor('ff9aa0a8') + + self.Search = Edit(self) + Layouter(self.Search):Left(0):Top(0):Width(96):Height(22):End() + self.Search:SetFont(UIUtil.bodyFont, 16) + self.Search:SetForegroundColor(UIUtil.fontColor) + self.Search:ShowBackground(true) + self.Search:SetBackgroundColor('77778888') + self.Search:SetText(saved.search or "") + self.Search.OnTextChanged = function(control, newText, oldText) + self:RefreshColumns() + end + Tooltip.AddControlTooltipManual(self.Search, "Search", "Filter options by name across all three columns.") + + self.HideDefaultsToggle = UIUtil.CreateCheckbox(self, '/CHECKBOX/', "Hide defaults", true, 13) + self.HideDefaultsToggle:SetCheck(self.HideDefaults, true) + self.HideDefaultsToggle.OnCheck = function(control, checked) + self.HideDefaults = checked + self:RefreshColumns() + end + Tooltip.AddControlTooltipManual(self.HideDefaultsToggle, "Hide defaults", + "Show only the options that have been changed from their default value.") + --#endregion + + --#region columns + self.LobbyColumn = CustomLobbyOptionColumn.Create(self, "Lobby", ColContentWidth) + self.ScenarioColumn = CustomLobbyOptionColumn.Create(self, "Scenario", ColContentWidth) + self.ModColumn = CustomLobbyOptionColumn.Create(self, "Mods", ColContentWidth) + + self.LobbyColumn:SetData(self.LobbyOptions, self.Values) + self.ScenarioColumn:SetData(self.ScenarioOptions, self.Values) + self.ModColumn:SetData(self.ModOptions, self.Values) + --#endregion + + --#region actions + self.ResetButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Reset", 16, 2) + self.ResetButton.OnClick = function(button, modifiers) + self:ResetToDefaults() + end + Tooltip.AddControlTooltipManual(self.ResetButton, "Reset", "Reset every option to its default value.") + + self.SelectButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "OK", 16, 2) + self.SelectButton.OnClick = function(button, modifiers) + self:Confirm() + end + + self.CancelButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Cancel", 16, 2) + self.CancelButton.OnClick = function(button, modifiers) + self.OnCancelCb() + end + --#endregion + end, + + ---@param self UICustomLobbyOptionSelect + __post_init = function(self) + self.Width:Set(LayoutHelpers.ScaleNumber(DialogWidth)) + self.Height:Set(LayoutHelpers.ScaleNumber(DialogHeight)) + + Layouter(self.TitleArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtTopIn(self, Pad):Height(TitleHeight):End() + Layouter(self.ActionArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtBottomIn(self, Pad):Height(ActionHeight):End() + Layouter(self.Title):AtHorizontalCenterIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() + + --#region top filter bar + Layouter(self.SearchLabel):AtLeftIn(self, Pad):AnchorToBottom(self.TitleArea, 10):End() + Layouter(self.Search):AnchorToRight(self.SearchLabel, 8):AtVerticalCenterIn(self.SearchLabel):Width(220):Height(FilterHeight - 6):End() + Layouter(self.HideDefaultsToggle):AnchorToRight(self.Search, 24):AtVerticalCenterIn(self.Search):End() + --#endregion + + --#region columns (three fixed equal columns between the filter bar and the actions) + Layouter(self.LobbyColumn) + :AtLeftIn(self, Pad):Width(ColTotalWidth) + :AnchorToBottom(self.Search, 12):AnchorToTop(self.ActionArea, 10) + :End() + Layouter(self.ScenarioColumn) + :AnchorToRight(self.LobbyColumn, ColGap):Width(ColTotalWidth) + :AnchorToBottom(self.Search, 12):AnchorToTop(self.ActionArea, 10) + :End() + Layouter(self.ModColumn) + :AnchorToRight(self.ScenarioColumn, ColGap):Width(ColTotalWidth) + :AnchorToBottom(self.Search, 12):AnchorToTop(self.ActionArea, 10) + :End() + --#endregion + + --#region actions + Layouter(self.SelectButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.CancelButton):AnchorToLeft(self.SelectButton, 12):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.ResetButton):AtLeftIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + --#endregion + end, + + --- Builds the columns' scrollbars + first render. Called by the opener after Popup mounts (the + --- Grids need a concrete height — three-phase init, /lua/ui/CLAUDE.md § 1). + ---@param self UICustomLobbyOptionSelect + Initialize = function(self) + self.Ready = true + self.LobbyColumn:Initialize() + self.ScenarioColumn:Initialize() + self.ModColumn:Initialize() + self:RefreshColumns() + end, + + --- Re-applies the search + hide-defaults filter to every column. + ---@param self UICustomLobbyOptionSelect + RefreshColumns = function(self) + if not self.Ready then + return + end + local search = string.lower(self.Search:GetText() or "") + self.LobbyColumn:Refresh(search, self.HideDefaults) + self.ScenarioColumn:Refresh(search, self.HideDefaults) + self.ModColumn:Refresh(search, self.HideDefaults) + end, + + --- Resets every option to its default by clearing the working values in place (the columns + --- share the table by reference), then re-rendering. + ---@param self UICustomLobbyOptionSelect + ResetToDefaults = function(self) + for key in self.Values do + self.Values[key] = nil + end + self:RefreshColumns() + end, + + --- Persists the search + hide-defaults filter for next time. Called once on close (not per + --- interaction — `SetToCurrentProfile` writes the profile, too costly to fire per keystroke). + ---@param self UICustomLobbyOptionSelect + SavePrefs = function(self) + import("/lua/user/prefs.lua").SetToCurrentProfile(PrefsKey, { + hideDefaults = self.HideDefaults, + search = self.Search:GetText() or "", + }) + end, + + --- Commits the complete value set (defaults seeded for every untouched option) via the opener. + ---@param self UICustomLobbyOptionSelect + Confirm = function(self) + local all = ConcatOptions(self.LobbyOptions, self.ScenarioOptions, self.ModOptions) + self.OnConfirmCb(OptionUtil.SeedDefaults(all, self.Values)) + end, + + ---@param self UICustomLobbyOptionSelect + OnDestroy = function(self) + self:SavePrefs() + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +--#region Singleton + open / close + +---@type Popup | false +local Instance = false + +--- Opens the options dialog over `parent`, seeded from the launch model's scenario / mods / +--- option values. Confirming routes the new values through the host-authoritative +--- `RequestSetGameOptions` intent (game options are synced). Replaces any dialog already open. +---@param parent? Control +function Open(parent) + parent = parent or GetFrame(0) + if Instance then + Instance:Close() + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + + local popup + local content = CustomLobbyOptionSelect(parent, { + scenarioFile = launch.ScenarioFile(), + gameMods = launch.GameMods(), + values = launch.GameOptions(), + onConfirm = function(values) + CustomLobbyController.RequestSetGameOptions(values) + if popup then + popup:Close() + end + end, + onCancel = function() + if popup then + popup:Close() + end + end, + }) + + popup = Popup(parent, content) + local baseOnClosed = popup.OnClosed + popup.OnClosed = function(self) + baseOnClosed(self) + Instance = false + end + Instance = popup + + -- Popup has mounted + centred the content; safe to build the grids' scrollbars + render + content:Initialize() +end + +--- Closes the dialog if open. +function Close() + if Instance then + Instance:Close() + Instance = false + end +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + Close() +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/presetselect/CLAUDE.md b/lua/ui/lobby/customlobby/presetselect/CLAUDE.md new file mode 100644 index 00000000000..c8f34fbe55e --- /dev/null +++ b/lua/ui/lobby/customlobby/presetselect/CLAUDE.md @@ -0,0 +1,45 @@ +# Preset select + +The custom lobby's **setup-presets dialog** — named setup snapshots (map / options / mods / +restrictions) the host can Load / Save / Rename / Delete. The customlobby-native rebuild of the +legacy [`/lua/ui/lobby/presets.lua`](../../presets.lua) dialog (which keyed `LobbyPresets`), built +to the same shape as the [`../mapselect/`](../mapselect/CLAUDE.md) / [`../modselect/`](../modselect/CLAUDE.md) +dialogs. Opened by the host-only **Presets** button in the action bar +([`../CustomLobbyInterface.lua`](../CustomLobbyInterface.lua)). + +> Read [`../CLAUDE.md`](../CLAUDE.md) first (the lobby's MVC + the layout/init gotchas). + +## Files + +| File | Role | +|------|------| +| [CustomLobbyPresetSelect.lua](CustomLobbyPresetSelect.lua) | the dialog (transient `Popup`). **Areas** layout (title / preset list (left) / detail (right) / actions — flip the module `Debug` flag to tint them), three-phase init. Left: an `ItemList` of saved presets (the reserved `lastGame` entry shows as "Last game"). Right: a read-only `ItemList` of the selected preset's facts (map · #mods · #restrictions · #players). Bottom: **Load / Save / Rename / Delete** + Close. Owns **no** synced state — Load/Save go through the controller's host-authoritative intents; Delete/Rename are host-local prefs (called straight on `CustomLobbyPresets`). | + +The persistence lives one level up in [`../CustomLobbyPresets.lua`](../CustomLobbyPresets.lua) (pure +prefs CRUD, key `customlobby_setup_presets`, mirroring the mod presets in +[`/lua/ui/modutilities.lua`](/lua/ui/modutilities.lua)). Capturing the live launch state into a +snapshot (`BuildSetupSnapshot`) and applying one back (`ApplySetup`) touch the synced model + the +network, so they live in [`../CustomLobbyController.lua`](../CustomLobbyController.lua) (the host is +the only writer) — the dialog only calls the `RequestSaveSetupPreset` / `RequestLoadSetupPreset` +intents. + +## The MVC boundary (where the setup goes) + +- **Save** → `RequestSaveSetupPreset(name)` → `CustomLobbyPresets.SavePreset(name, BuildSetupSnapshot())`. + A pure read of the model + a local prefs write; never mutates the lobby. +- **Load** → `RequestLoadSetupPreset(name)` → `ApplySetup(setup)`: host-only. Sets scenario (re-sizing + the room), mods, restrictions and teams/spawn-mex on the launch model, **reconciles** the saved + option values against the now-current scenario+mods schema (drop stale keys, seed defaults — same + path as the options dialog / reset), then **one** `BroadcastLaunchInfo`. Mirrors the legacy + `ApplyGameSettings` → single `UpdateGame`. +- **Delete / Rename** → straight to `CustomLobbyPresets` (host-local prefs; no sync). + +## Setup-only — no players (§ O) + +A preset stores **only** the setup: scenario / game options / sim mods / restrictions. Players, +observers and the (not-yet-applied) auto-teams / spawn-mex are **not** captured — a preset +reconfigures a lobby, it doesn't restore a roster. The launch still auto-saves the reserved +`lastGame` preset, so the *configuration* of the last game can be reapplied, but it carries no +roster. The future rehost reseat (§ O.4 — reseat returning players, in-lobby "Rehost last game" +button + `/rehost` detection at `CreateLobby`) is a separate slice that will need its **own** player +capture, not these presets (and the missing AI-add / per-player slot intents — roadmap slice #3). diff --git a/lua/ui/lobby/customlobby/presetselect/CustomLobbyPresetSelect.lua b/lua/ui/lobby/customlobby/presetselect/CustomLobbyPresetSelect.lua new file mode 100644 index 00000000000..1c4a5edc2a4 --- /dev/null +++ b/lua/ui/lobby/customlobby/presetselect/CustomLobbyPresetSelect.lua @@ -0,0 +1,455 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The setup-presets dialog: a host-only list of named setup snapshots (map / options / mods / +-- restrictions) you can Load / Save / Rename / Delete — the customlobby-native rebuild of the legacy +-- `/lua/ui/lobby/presets.lua` dialog. Built to the map/mod-select shape (areas layout, three-phase +-- init, Popup singleton). +-- +-- It is a transient picker, NOT a model component: it owns no synced state. Persistence is in +-- CustomLobbyPresets (pure prefs); applying a preset to the synced launch state is host-authoritative +-- and goes through the controller intents (`RequestLoadSetupPreset` / `RequestSaveSetupPreset`). +-- +-- Scope note (§ O): a preset is **setup-only** — scenario / options / mods / restrictions. Players, +-- observers and auto-teams / spawn-mex are not stored (a preset reconfigures a lobby, it doesn't +-- restore a roster). The reserved `lastGame` preset (auto-saved at launch) is shown as a pinned +-- "Last game" entry; the future rehost reseat needs its own roster capture. See ../CLAUDE.md. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Popup = import("/lua/ui/controls/popups/popup.lua").Popup +local ItemList = import("/lua/maui/itemlist.lua").ItemList + +local CustomLobbyPresets = import("/lua/ui/lobby/customlobby/customlobbypresets.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- flip to tint each layout area so the regions are visible while iterating +local Debug = false + +-- the five action buttons (Load / Save / Rename / Delete · Close) sit in one row, each a +-- `/BUTTON/medium/` (132×44 unscaled — the same standard button the lobby's Leave/Launch use; the +-- `/BUTTON/` tree isn't in texture-dimensions.csv, these dims are from the DDS headers). The dialog +-- is sized to hold 5×132 + the inter-button gaps + padding without overlap. +local DialogWidth = 720 +local DialogHeight = 470 +local Pad = 12 +local ColumnGap = 20 +local ListWidth = 250 +local ScrollbarInset = 20 +local TitleHeight = 32 +local ButtonGap = 8 +local ActionHeight = 52 + +local LabelColor = 'ffc8ccd0' + +--- Creates a layout area (an invisible Group with an optional debug tint). +---@param parent Control +---@param name string +---@param color string +---@return Group +local function CreateArea(parent, name, color) + local area = Group(parent, name) + local bg = Bitmap(area) + bg:SetSolidColor(color) + bg:SetAlpha(Debug and 0.18 or 0.0) + bg:DisableHitTest() + Layouter(bg):Fill(area):End() + area.Bg = bg + return area +end + +--- The user-facing label for a preset name (the reserved last-game key shows as "Last game"). +---@param name string +---@return string +local function DisplayName(name) + if name == CustomLobbyPresets.LastGamePresetName then + return "Last game" + end + return name +end + +--- The map's display name for a snapshot, or "(none)" / the raw file when it can't be read. +---@param setup UICustomLobbySetupSnapshot +---@return string +local function MapNameOf(setup) + local scenarioFile = setup.ScenarioFile + if not scenarioFile then + return "(none)" + end + local ok, info = pcall(function() + return import("/lua/ui/maputil.lua").LoadScenario(scenarioFile) + end) + if ok and info and info.name then + return info.name + end + return tostring(scenarioFile) +end + +--- The read-only fact lines shown for the selected preset. +---@param setup UICustomLobbySetupSnapshot +---@return string[] +local function FactsFor(setup) + return { + "Map: " .. MapNameOf(setup), + "Mods: " .. table.getsize(setup.GameMods or {}), + "Restrictions: " .. table.getn(setup.Restrictions or {}), + } +end + +---@class UICustomLobbyPresetSelect : Group +---@field Trash TrashBag +---@field OnCloseCb fun() +---@field TitleArea Group +---@field ListArea Group +---@field DetailArea Group +---@field ActionArea Group +---@field Title Text +---@field PresetList ItemList +---@field DetailList ItemList +---@field EmptyLabel Text +---@field LoadButton Button +---@field SaveButton Button +---@field RenameButton Button +---@field DeleteButton Button +---@field CloseButton Button +---@field OrderedNames string[] # actual preset names, parallel to the list rows (0-based +1) +---@field Ready boolean +local CustomLobbyPresetSelect = ClassUI(Group) { + + ---@param self UICustomLobbyPresetSelect + ---@param parent Control + ---@param options { onClose: fun() } + __init = function(self, parent, options) + Group.__init(self, parent, "CustomLobbyPresetSelect") + + self.Trash = TrashBag() + self.OnCloseCb = options.onClose + self.Ready = false + self.OrderedNames = {} + + -- areas + self.TitleArea = CreateArea(self, "TitleArea", 'ffcc4040') + self.ListArea = CreateArea(self, "ListArea", 'ff4060cc') + self.DetailArea = CreateArea(self, "DetailArea", 'ffcc40cc') + self.ActionArea = CreateArea(self, "ActionArea", 'ff808080') + + self.Title = UIUtil.CreateText(self.TitleArea, "Setup presets", 22, UIUtil.titleFont) + self.Title:DisableHitTest() + + --#region preset list (left) + self.PresetList = ItemList(self.ListArea) + self.PresetList:SetFont(UIUtil.bodyFont, 14) + self.PresetList:ShowMouseoverItem(true) + self.PresetList.OnClick = function(control, row, event) + control:SetSelection(row) + self:OnSelectRow(row) + end + self.PresetList.OnKeySelect = function(control, row) + self:OnSelectRow(row) + end + self.PresetList.OnDoubleClick = function(control, row) + self:OnSelectRow(row) + self:LoadSelected() + end + + self.EmptyLabel = UIUtil.CreateText(self.ListArea, "No saved presets", 14, UIUtil.bodyFont) + self.EmptyLabel:SetColor('ff8a909a') + self.EmptyLabel:DisableHitTest() + self.EmptyLabel:Hide() + --#endregion + + --#region detail (right) — a read-only fact list + self.DetailList = ItemList(self.DetailArea) + self.DetailList:SetFont(UIUtil.bodyFont, 13) + self.DetailList:SetColors(LabelColor, "00000000") + self.DetailList:DisableHitTest() + --#endregion + + --#region actions + self.LoadButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Load") + self.LoadButton.OnClick = function(button, modifiers) + self:LoadSelected() + end + Tooltip.AddControlTooltipManual(self.LoadButton, "Load preset", + "Apply the selected preset's map, options, mods and restrictions to the lobby.") + + self.SaveButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Save") + self.SaveButton.OnClick = function(button, modifiers) + self:PromptSave() + end + Tooltip.AddControlTooltipManual(self.SaveButton, "Save preset", "Save the current lobby setup as a named preset.") + + self.RenameButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Rename") + self.RenameButton.OnClick = function(button, modifiers) + self:PromptRename() + end + Tooltip.AddControlTooltipManual(self.RenameButton, "Rename preset", "Rename the selected preset.") + + self.DeleteButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Delete") + self.DeleteButton.OnClick = function(button, modifiers) + self:DeleteSelected() + end + Tooltip.AddControlTooltipManual(self.DeleteButton, "Delete preset", "Delete the selected preset.") + + self.CloseButton = UIUtil.CreateButtonWithDropshadow(self.ActionArea, '/BUTTON/medium/', "Close") + self.CloseButton.OnClick = function(button, modifiers) + self.OnCloseCb() + end + --#endregion + end, + + ---@param self UICustomLobbyPresetSelect + __post_init = function(self) + self.Width:Set(LayoutHelpers.ScaleNumber(DialogWidth)) + self.Height:Set(LayoutHelpers.ScaleNumber(DialogHeight)) + + --#region areas + Layouter(self.TitleArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtTopIn(self, Pad):Height(TitleHeight):End() + Layouter(self.ActionArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtBottomIn(self, Pad):Height(ActionHeight):End() + Layouter(self.ListArea) + :AtLeftIn(self, Pad):Width(ListWidth) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) + :End() + Layouter(self.DetailArea) + :AnchorToRight(self.ListArea, ColumnGap):AtRightIn(self, Pad) + :AnchorToBottom(self.TitleArea, Pad):AnchorToTop(self.ActionArea, Pad) + :End() + --#endregion + + Layouter(self.Title):AtHorizontalCenterIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() + + --#region list + detail + Layouter(self.PresetList):AtLeftIn(self.ListArea):AtTopIn(self.ListArea):AtBottomIn(self.ListArea):End() + self.PresetList.Right:Set(function() return self.ListArea.Right() - LayoutHelpers.ScaleNumber(ScrollbarInset) end) + UIUtil.CreateLobbyVertScrollbar(self.PresetList, 2) + Layouter(self.EmptyLabel):AtHorizontalCenterIn(self.ListArea):AtVerticalCenterIn(self.ListArea):End() + + Layouter(self.DetailList):AtLeftIn(self.DetailArea):AtTopIn(self.DetailArea):AtBottomIn(self.DetailArea):End() + self.DetailList.Right:Set(function() return self.DetailArea.Right() - LayoutHelpers.ScaleNumber(ScrollbarInset) end) + --#endregion + + --#region actions: Load / Save / Rename / Delete cluster on the left, Close on the right + Layouter(self.LoadButton):AtLeftIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.SaveButton):AnchorToRight(self.LoadButton, ButtonGap):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.RenameButton):AnchorToRight(self.SaveButton, ButtonGap):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.DeleteButton):AnchorToRight(self.RenameButton, ButtonGap):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.CloseButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + --#endregion + end, + + --- Post-mount first render (the opener calls this after Popup centres the dialog). + ---@param self UICustomLobbyPresetSelect + Initialize = function(self) + self.Ready = true + self:RefreshList() + end, + + --- Rebuilds the preset list from prefs, preserving the selection by name where possible. + ---@param self UICustomLobbyPresetSelect + ---@param keepName? string + RefreshList = function(self, keepName) + local presets = CustomLobbyPresets.GetPresets() + self.OrderedNames = {} + self.PresetList:DeleteAllItems() + for _, preset in presets do + table.insert(self.OrderedNames, preset.Name) + self.PresetList:AddItem(DisplayName(preset.Name)) + end + + local count = table.getn(self.OrderedNames) + if count == 0 then + self.EmptyLabel:Show() + self:OnSelectRow(nil) + return + end + self.EmptyLabel:Hide() + + -- restore the previous selection by name, else select the first row + local select = 0 + if keepName then + for index, name in self.OrderedNames do + if name == keepName then + select = index - 1 + break + end + end + end + self.PresetList:SetSelection(select) + self:OnSelectRow(select) + end, + + --- The actual preset name for a 0-based list row, or nil. + ---@param self UICustomLobbyPresetSelect + ---@param row number | nil + ---@return string | nil + NameForRow = function(self, row) + if type(row) ~= 'number' then + return nil + end + return self.OrderedNames[row + 1] + end, + + --- Updates the detail panel + button enablement for the selected row (nil = no selection). + ---@param self UICustomLobbyPresetSelect + ---@param row number | nil + OnSelectRow = function(self, row) + local name = self:NameForRow(row) + self.DetailList:DeleteAllItems() + if not name then + self.LoadButton:Disable() + self.RenameButton:Disable() + self.DeleteButton:Disable() + return + end + + self.LoadButton:Enable() + self.DeleteButton:Enable() + -- the reserved last-game entry can't be renamed (its name is the rehost contract) + if name == CustomLobbyPresets.LastGamePresetName then + self.RenameButton:Disable() + else + self.RenameButton:Enable() + end + + local setup = CustomLobbyPresets.GetPreset(name) + if setup then + for _, line in FactsFor(setup) do + self.DetailList:AddItem(line) + end + end + end, + + --- Loads (applies) the selected preset and closes the dialog. + ---@param self UICustomLobbyPresetSelect + LoadSelected = function(self) + local name = self:NameForRow(self.PresetList:GetSelection()) + if not name then + return + end + CustomLobbyController.RequestLoadSetupPreset(name) + self.OnCloseCb() + end, + + --- Prompts for a name and saves the current lobby setup as a preset. + ---@param self UICustomLobbyPresetSelect + PromptSave = function(self) + UIUtil.CreateInputDialog(GetFrame(0), "Name this preset", function(dialog, name) + if not name or name == "" then + return + end + CustomLobbyController.RequestSaveSetupPreset(name) + self:RefreshList(name) + end) + end, + + --- Prompts for a new name and renames the selected preset. + ---@param self UICustomLobbyPresetSelect + PromptRename = function(self) + local oldName = self:NameForRow(self.PresetList:GetSelection()) + if not oldName or oldName == CustomLobbyPresets.LastGamePresetName then + return + end + UIUtil.CreateInputDialog(GetFrame(0), "Rename preset", function(dialog, newName) + if CustomLobbyPresets.RenamePreset(oldName, newName) then + self:RefreshList(newName) + end + end) + end, + + --- Deletes the selected preset. + ---@param self UICustomLobbyPresetSelect + DeleteSelected = function(self) + local name = self:NameForRow(self.PresetList:GetSelection()) + if not name then + return + end + CustomLobbyPresets.DeletePreset(name) + self:RefreshList() + end, + + ---@param self UICustomLobbyPresetSelect + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +--#region Singleton + open / close + +---@type Popup | false +local Instance = false + +--- Opens the setup-presets dialog over `parent` (host-only entry point — the action-bar button). +---@param parent? Control +function Open(parent) + parent = parent or GetFrame(0) + + if Instance then + Instance:Close() + end + + local popup + local content = CustomLobbyPresetSelect(parent, { + onClose = function() + if popup then + popup:Close() + end + end, + }) + + popup = Popup(parent, content) + local baseOnClosed = popup.OnClosed + popup.OnClosed = function(self) + baseOnClosed(self) + Instance = false + end + Instance = popup + + -- now that Popup has mounted + centred the content, it's safe to populate (the lists read + -- concrete geometry) + content:Initialize() +end + +--- Closes the dialog if open. +function Close() + if Instance then + Instance:Close() + Instance = false + end +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + Close() +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/readme.md b/lua/ui/lobby/customlobby/readme.md new file mode 100644 index 00000000000..9e7f8c7aae7 --- /dev/null +++ b/lua/ui/lobby/customlobby/readme.md @@ -0,0 +1,164 @@ + +_Architecture spine (layers overview)_ + +```mermaid +flowchart TD + Entry["lobby.lua (entry)"] + Root["CustomLobbyInterface (root)"] + Net["Instance + Messages (networking)"] + Views["Views: config column · slots · social"] + Dialogs["Editor dialogs (map/mod/option/unit/preset)"] + Derived["Derived models (read-only projections)"] + Catalogs["Catalogs (map/mod/unit reference data)"] + Ctrl["Controller (host authority)"] + Models["Authoritative models (Launch/Session/Local)"] + Rules["Rules (pure kernel)"] + + Entry --> Root + Entry --> Net + Root --> Views + Root --> Dialogs + Root --> Ctrl + Net --> Ctrl + Views --> Derived + Dialogs --> Catalogs + Dialogs --> Ctrl + Derived --> Catalogs + Derived --> Rules + Derived --> Models + Catalogs --> Models + Ctrl --> Models + Ctrl -.->|deferred re-fish| Catalogs +``` + +_2. Config column_ + +```mermaid +flowchart TD + CFG["ConfigInterface"] + MPrev["MapPreview"] + OP["OptionsPanel"] + MP["ModsPanel"] + UP["UnitsPanel"] + MS["MapSelect"] + OS["OptionSelect"] + MdS["ModSelect"] + US["UnitSelect"] + DScen["ScenarioDerivedModel"] + DOpt["OptionsDerivedModel"] + DMods["ModsDerivedModel"] + DRes["RestrictionsDerivedModel"] + + CFG --> MPrev + CFG --> OP + CFG --> MP + CFG --> UP + CFG --> MS + CFG --> OS + CFG --> MdS + CFG --> US + CFG --> DScen + CFG --> DOpt + CFG --> DMods + CFG --> DRes + OP --> DOpt + MP --> DMods + UP --> DRes + MPrev --> DScen +``` + +_3. Slot subsystem_ + +```mermaid +flowchart TD + SI["SlotsInterface"] + OCS["OneColumnSlots"] + TCS["TwoColumnSlots"] + SRow["SlotRow"] + SCard["SlotCard"] + SB["SlotBase"] + TS["TeamScore"] + DSlots["SlotsDerivedModel"] + Rules["Rules"] + Ctrl["Controller"] + + SI --> OCS + SI --> TCS + SI --> Rules + SI --> Ctrl + OCS --> SRow + TCS --> SCard + TCS --> TS + TCS --> DSlots + SRow --> SB + SCard --> SB + SB --> DSlots + SB --> Ctrl + TS --> DSlots +``` + +_Derived layer + its sources_ + +```mermaid +flowchart TD + DScen["ScenarioDerivedModel"] + DOpt["OptionsDerivedModel"] + DMods["ModsDerivedModel"] + DRes["RestrictionsDerivedModel"] + DSlots["SlotsDerivedModel"] + LM["LaunchModel"] + SM["SessionModel"] + LoM["LocalModel"] + Rules["Rules (pure kernel)"] + MCat["MapCatalog"] + + DScen --> LM + DScen --> MCat + DOpt --> LM + DOpt --> DScen + DOpt --> MCat + DMods --> LM + DRes --> LM + DSlots --> LM + DSlots --> SM + DSlots --> LoM + DSlots --> DScen + DSlots --> Rules +``` + +_Networking, controller & intents_ + +```mermaid +flowchart TD + INST["Instance"] + MSG["Messages"] + Ctrl["Controller"] + MS["MapSelect"] + MdS["ModSelect"] + OS["OptionSelect"] + US["UnitSelect"] + PS["PresetSelect"] + Menus["Menus"] + LM["LaunchModel"] + SM["SessionModel"] + LoM["LocalModel"] + SESS["Session (trashbag)"] + PRE["Presets"] + MCat["MapCatalog"] + + INST --> MSG + INST --> Ctrl + MSG --> Ctrl + MS --> Ctrl + MdS --> Ctrl + OS --> Ctrl + US --> Ctrl + PS --> Ctrl + Menus --> Ctrl + Ctrl --> LM + Ctrl --> SM + Ctrl --> LoM + Ctrl --> SESS + Ctrl --> PRE + Ctrl -.->|deferred re-fish| MCat +``` \ No newline at end of file diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua new file mode 100644 index 00000000000..2a0447f6f86 --- /dev/null +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotBase.lua @@ -0,0 +1,691 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The behaviour of a single slot, shared by every presentation (the thin CustomLobbySlotRow and the +-- fat CustomLobbySlotCard). It owns *everything except the visible widgets and their layout*: +-- +-- * one subscription to the derived slots model (the lookup table that already merged this seat's +-- player / placement / closed flag / CPU benchmark into a ready-to-paint entry), +-- * the click / right-click / drag-to-swap gesture handling and the controller intents, +-- * the layout-agnostic overlays (background, drop highlight, the full-row click catcher). +-- +-- A presentation subclasses this (`Class(import(...).SlotBase) { ... }`) and implements two hooks: +-- +-- CreateContents(self) build the visible widgets (+ the CPU hover zone, wired to +-- `self:HandleCpuHoverEvent`); called from this base's `__init`. +-- LayoutContents(self) lay them out; called from this base's `__post_init`. +-- +-- A presentation just has to provide the standard named controls — `ColorSwatch`, `Name`, `Faction`, +-- `Team`, `Ready`, `Cpu` (Texts) and `CpuIndicator` (Bitmap) — arranged however it likes; the base's +-- `RenderPlayer` / `RenderCpu` paint those from the entry's resolved player / CPU views. The formatting +-- (faction label, `T1`, ready/CPU colours, unit string, the green→red headroom step) lives in the +-- derived model now — see derived/CustomLobbySlotsDerivedModel.lua — so it is single-sourced and the +-- CPU bar restyles consistently across every seat when the unit cap moves. A presentation that needs a +-- different mapping (e.g. a faction *icon*) can override `RenderPlayer` / `RenderCpu`. +-- +-- This keeps the drag/intent logic single-sourced; the presentations are pure arrangement. + +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local UIUtil = import("/lua/ui/uiutil.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Dragger = import("/lua/maui/dragger.lua").Dragger +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") +local CustomLobbyPerformancePopover = import("/lua/ui/lobby/customlobby/customlobbyperformancepopover.lua") +local CustomLobbyContextMenu = import("/lua/ui/lobby/customlobby/customlobbycontextmenu.lua") +local CustomLobbyMenus = import("/lua/ui/lobby/customlobby/customlobbymenus.lua") +local CustomLobbySlotPickers = import("/lua/ui/lobby/customlobby/slots/customlobbyslotpickers.lua") +local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyslotsderivedmodel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +local Layouter = LayoutHelpers.ReusedLayoutFor +local scaled = LayoutHelpers.ScaleNumber + +-- cursor travel (screen px) before a press becomes a drag instead of a click +local DragThreshold = 5 + +-- the faction display is a small left-to-right strip of the selected factions' icons +local FactionIconSize = 14 +local FactionIconGap = 2 + +--- The container that owns the slot rows and coordinates drag-to-swap. The row only +--- starts the gesture; the coordinator hit-tests across all rows (it's the only thing +--- that knows their rects) and resolves a drop to a controller intent. +---@class UICustomLobbySlotCoordinator +---@field CanDrag fun(self: UICustomLobbySlotCoordinator, slot: number): boolean +---@field SlotIndexAt fun(self: UICustomLobbySlotCoordinator, x: number, y: number): number | nil +---@field OnSlotDragMove fun(self: UICustomLobbySlotCoordinator, source: number, x: number, y: number) +---@field OnSlotDrop fun(self: UICustomLobbySlotCoordinator, source: number, x: number, y: number) +---@field OnSlotDragEnd fun(self: UICustomLobbySlotCoordinator) + +--- A presentation-supplied view of a seated player (nil = the empty state). +---@class UICustomLobbySlotPlayerView +---@field colorHex string +---@field name string +---@field nameColor string +---@field faction string +---@field team string +---@field ready string +---@field readyColor string +---@field rating string +---@field games string +---@field flag FileName | false + +--- A presentation-supplied view of the CPU column (nil = no data, clear it). +---@class UICustomLobbySlotCpuView +---@field text string +---@field textColor string +---@field indicatorColor? Color +---@field showIndicator boolean + +--- The host-only per-seat close/open button (a labelled surface). +---@class UICustomLobbySlotButton : Group +---@field Bg Bitmap +---@field Label Text + +---@class UICustomLobbySlotBase : Group +---@field Trash TrashBag +---@field SlotIndex number +---@field Coordinator UICustomLobbySlotCoordinator +---@field Background Bitmap +---@field DropHighlight Bitmap +---@field LockStripe Bitmap # left-edge accent shown while this seat is locked +---@field ClickArea Bitmap +---@field SlotObserver LazyVar +---@field CurrentEntry UICustomLobbySlot | nil # the seat's last resolved entry (interaction reads it) +---@field CurrentPlayer UICustomLobbyPlayer | false +-- the standard named controls a presentation must provide (the base's Render* paint these): +---@field ColorSwatch Bitmap +---@field Name Text +---@field Team Text +---@field Ready Text +---@field Cpu Text +---@field CpuIndicator Bitmap +-- optional controls a presentation may provide (the base paints these when present): +---@field Faction? Text # legacy text faction label (presentations now use FactionIcons) +---@field FactionIcons? Group # the selected factions as a strip of small icons +---@field FactionIconBitmaps? Bitmap[] # the icon slots inside FactionIcons +---@field Rating? Text +---@field Games? Text +---@field Flag? Bitmap +---@field Avatar? Bitmap +---@field SlotButton? UICustomLobbySlotButton # host-only per-seat close/open button +---@field SlotButtonMode? "close" | "open" | false +local CustomLobbySlotBase = Class(Group) { + + ---@param self UICustomLobbySlotBase + ---@param parent Control + ---@param slotIndex number + ---@param coordinator UICustomLobbySlotCoordinator + __init = function(self, parent, slotIndex, coordinator) + Group.__init(self, parent, "CustomLobbySlot" .. tostring(slotIndex)) + + self.Trash = TrashBag() + self.SlotIndex = slotIndex + self.Coordinator = coordinator + self.CurrentPlayer = false + + self.Background = Bitmap(self) + self.Background:SetSolidColor('22ffffff') + self.Background:DisableHitTest() + + -- drop-target highlight during a drag (sits above the background, below text) + self.DropHighlight = Bitmap(self) + self.DropHighlight:SetSolidColor('ffffffff') + self.DropHighlight:SetAlpha(0.0) + self.DropHighlight:DisableHitTest() + + -- left-edge accent shown while this seat is locked for auto-balance (same gold as the header's + -- "Locked" notice); hidden otherwise. Layout-agnostic, so it never overlaps a presentation's + -- content. + self.LockStripe = Bitmap(self) + self.LockStripe:SetSolidColor('ffd9c97a') + self.LockStripe:SetAlpha(0.0) + self.LockStripe:DisableHitTest() + + -- transparent overlay that catches clicks on the whole row (take / ready / context / drag) + self.ClickArea = Bitmap(self) + self.ClickArea:SetSolidColor('00000000') + self.ClickArea.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + if event.Modifiers.Right then + self:OnRowContext(event) + else + self:OnRowPress(event) + end + return true + end + return false + end + + -- the presentation builds its visible widgets (+ the CPU hover zone) + self:CreateContents() + + -- one subscription to the derived slots table: it already merged this seat's player, + -- placement, closed flag and CPU benchmark into a resolved entry. The table is rebuilt whole, so + -- this fires for every seat whenever the unit cap (seated count) moves — exactly when a CPU bar + -- needs restyling — not only when *this* seat's player changes. + self.SlotObserver = self.Trash:Add( + LazyVarDerive(CustomLobbySlotsDerivedModel.GetSlotsVar(), function(slotsLazy) + self:OnSlotChanged(slotsLazy()[slotIndex]) + end)) + end, + + ---@param self UICustomLobbySlotBase + ---@param parent Control + __post_init = function(self, parent) + Layouter(self.Background):Fill(self):End() + Layouter(self.DropHighlight):Fill(self):End() + Layouter(self.LockStripe):AtLeftIn(self):AtTopIn(self):AtBottomIn(self):Width(3):Over(self, 15):End() + Layouter(self.ClickArea):Fill(self):Over(self, 10):End() + + -- the presentation lays out its widgets (its CPU hover zone sits above ClickArea, Over 20) + self:LayoutContents() + end, + + --------------------------------------------------------------------------- + --#region Presentation hooks (implemented by the subclass) + + --- Builds the visible widgets + the CPU hover zone (wired to `self:HandleCpuHoverEvent`). + ---@param self UICustomLobbySlotBase + CreateContents = function(self) + end, + + --- Lays out the widgets built in CreateContents. + ---@param self UICustomLobbySlotBase + LayoutContents = function(self) + end, + + --- Paints a player (or the empty state when `view` is nil) onto the standard named controls. + --- Overridable for a presentation that maps the fields differently. + ---@param self UICustomLobbySlotBase + ---@param view UICustomLobbySlotPlayerView | nil + RenderPlayer = function(self, view) + if not view then + self.ColorSwatch:SetSolidColor('00000000') + self.Name:SetText("- open -") + self.Name:SetColor('ff888888') + if self.Faction then self.Faction:SetText("") end + self.Team:SetText("") + self.Ready:SetText("") + self:RenderExtras(nil) + return + end + + self.ColorSwatch:SetSolidColor(view.colorHex) + self.Name:SetText(view.name) + self.Name:SetColor(view.nameColor) + if self.Faction then self.Faction:SetText(view.faction) end + self.Team:SetText(view.team) + self.Ready:SetText(view.ready) + self.Ready:SetColor(view.readyColor) + self:RenderExtras(view) + end, + + --- Paints the optional controls a presentation may provide (rating / games / country flag / avatar + --- placeholder) — guarded so a presentation that omits one is unaffected. `view` is nil on an empty + --- seat (everything clears / hides). + ---@param self UICustomLobbySlotBase + ---@param view UICustomLobbySlotPlayerView | nil + RenderExtras = function(self, view) + if self.Rating then + self.Rating:SetText((view and view.rating) or "") + end + if self.Games then + local games = (view and view.games) or "" + self.Games:SetText(games ~= "" and ("G:" .. games) or "") + end + if self.Flag then + local flag = view and view.flag + if flag then + self.Flag:SetTexture(UIUtil.UIFile(flag)) + self.Flag:SetAlpha(1.0) + else + self.Flag:SetAlpha(0.0) + end + end + -- the avatar is a reserved placeholder for now: a dim box while the seat is occupied + if self.Avatar then + self.Avatar:SetAlpha(view and 1.0 or 0.0) + end + self:RenderFactionIcons(view) + end, + + --- Paints the faction icon strip from `view.factionIcons` (one icon per allowed faction; a single + --- Random icon when the full set is allowed). Sizes the strip to the shown count so neighbours + --- reflow, and hides it entirely on an empty seat. + ---@param self UICustomLobbySlotBase + ---@param view UICustomLobbySlotPlayerView | nil + RenderFactionIcons = function(self, view) + local bitmaps = self.FactionIconBitmaps + if not (self.FactionIcons and bitmaps) then + return + end + local icons = (view and view.factionIcons) or {} + local count = table.getn(icons) + local slots = table.getn(bitmaps) + local shown = math.min(count, slots) + for k = 1, slots do + if k <= shown then + bitmaps[k]:SetTexture(UIUtil.UIFile(icons[k])) + bitmaps[k]:SetAlpha(1.0) + else + bitmaps[k]:SetAlpha(0.0) + end + end + local width = (shown > 0) and (shown * FactionIconSize + (shown - 1) * FactionIconGap) or 0 + self.FactionIcons.Width:Set(scaled(width)) + end, + + --- Paints the CPU column (or clears it when `view` is nil) onto the standard named controls. + ---@param self UICustomLobbySlotBase + ---@param view UICustomLobbySlotCpuView | nil + RenderCpu = function(self, view) + if not view then + self.Cpu:SetText("") + self.CpuIndicator:SetAlpha(0.0) + return + end + + self.Cpu:SetText(view.text) + self.Cpu:SetColor(view.textColor) + if view.showIndicator then + self.CpuIndicator:SetSolidColor(view.indicatorColor) + self.CpuIndicator:SetAlpha(1.0) + else + self.CpuIndicator:SetAlpha(0.0) + end + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Rendering (paints the resolved entry; the derived model did the formatting) + + --- The seat's resolved entry changed: keep the raw refs interaction needs (the player for + --- click/drag, the whole entry for the CPU popover) and paint the pre-resolved player + CPU views. + ---@param self UICustomLobbySlotBase + ---@param entry UICustomLobbySlot + OnSlotChanged = function(self, entry) + self.CurrentEntry = entry + self.CurrentPlayer = entry.Player + self:RenderPlayer(entry.PlayerView or nil) + self:RenderCpu(entry.CpuView or nil) + + -- feature: highlight the seat of the local peer + if entry.IsLocalPeer then + self.Background:SetSolidColor('44ffffff') + else + self.Background:SetSolidColor('22ffffff') + end + + -- a closed empty seat reads "- closed -" instead of "- open -" + if entry.Closed and not entry.Player then + self.Name:SetText("- closed -") + self.Name:SetColor('ff6a7078') + end + -- a locked seat (only meaningful when occupied) shows the geold left-edge accent + self.LockStripe:SetAlpha((entry.Locked and entry.Player) and 1.0 or 0.0) + -- the host-only per-seat close/open button (shown only on an empty or closed seat) + self:RenderSlotButton(entry) + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Interaction + + --- Routes the CPU hover zone's events: enter shows the popover, exit hides it, a press is a + --- click / context like the rest of the row. The presentation attaches this to its hover bitmap. + ---@param self UICustomLobbySlotBase + ---@param event KeyEvent + ---@return boolean + HandleCpuHoverEvent = function(self, event) + if event.Type == 'MouseEnter' then + self:OnCpuHoverEnter() + return true + elseif event.Type == 'MouseExit' then + CustomLobbyPerformancePopover.Hide() + return true + elseif event.Type == 'ButtonPress' then + if event.Modifiers.Right then + self:OnRowContext(event) + else + self:OnRowPress(event) + end + return true + end + return false + end, + + --- Mouse entered the CPU score: show this player's sim-performance popover. The presentation + --- passes the control to anchor the popover to (its CPU label). + ---@param self UICustomLobbySlotBase + OnCpuHoverEnter = function(self) + local entry = self.CurrentEntry + if not (entry and entry.Player) then + CustomLobbyPerformancePopover.Hide() + return + end + -- the entry already carries the owner's raw benchmark + the unit cap the indicator was sized to + CustomLobbyPerformancePopover.Show(self:CpuAnchor(), entry.Benchmark or nil, entry.UnitCap or nil) + end, + + --- The control the performance popover anchors to (the CPU label). Overridable; defaults to the + --- whole row if a presentation has no dedicated CPU control. + ---@param self UICustomLobbySlotBase + ---@return Control + CpuAnchor = function(self) + return self.Cpu or self + end, + + --- A press on the row: if the coordinator allows dragging this slot (host, holding + --- a player) start a drag-to-swap; otherwise it's a plain click. + ---@param self UICustomLobbySlotBase + ---@param event KeyEvent + OnRowPress = function(self, event) + if self.Coordinator and self.Coordinator:CanDrag(self.SlotIndex) then + self:BeginDrag(event) + else + self:OnClicked() + end + end, + + --- Right-click: open this slot's context menu (entries depend on lobby state — + --- see CustomLobbyMenus). Empty menus simply don't show. + ---@param self UICustomLobbySlotBase + ---@param event KeyEvent + OnRowContext = function(self, event) + CustomLobbyPerformancePopover.Hide() + CustomLobbyContextMenu.Show(CustomLobbyMenus.BuildSlotMenu(self.SlotIndex), event.MouseX, event.MouseY) + end, + + --- Starts a drag from this row. The press only becomes a drag once the cursor + --- travels past DragThreshold — under it, the release is treated as a click, so + --- take/ready still work. Movement + drop are routed to the coordinator (it owns + --- the hit-test across rows); a drop resolves to RequestSwapSlots. + ---@param self UICustomLobbySlotBase + ---@param event KeyEvent + BeginDrag = function(self, event) + -- the press may have started on the CPU hover zone; the captured mouse won't + -- fire MouseExit, so dismiss the popover up front + CustomLobbyPerformancePopover.Hide() + + local startX, startY = event.MouseX, event.MouseY + local moved = false + local source = self.SlotIndex + local coordinator = self.Coordinator + + local drag = Dragger() + drag.OnMove = function(dragSelf, x, y) + if not moved and (math.abs(x - startX) > DragThreshold or math.abs(y - startY) > DragThreshold) then + moved = true + end + if moved then + coordinator:OnSlotDragMove(source, x, y) + end + end + drag.OnRelease = function(dragSelf, x, y) + if moved then + coordinator:OnSlotDrop(source, x, y) + coordinator:OnSlotDragEnd() + else + self:OnClicked() + end + drag:Destroy() + end + drag.OnCancel = function(dragSelf) + coordinator:OnSlotDragEnd() + drag:Destroy() + end + PostDragger(self:GetRootFrame(), event.KeyCode, drag) + end, + + --- Toggles the drop-target highlight (called by the coordinator during a drag). + ---@param self UICustomLobbySlotBase + ---@param on boolean + SetDropHighlight = function(self, on) + self.DropHighlight:SetAlpha(on and 0.15 or 0.0) + end, + + --- Click on the row (a controller intent — the host applies and broadcasts it): + --- an open slot is taken by the local player; your own slot toggles ready. + ---@param self UICustomLobbySlotBase + OnClicked = function(self) + local player = self.CurrentPlayer + if not player then + CustomLobbyController.RequestTakeSlot(self.SlotIndex) + return + end + if player.OwnerID == CustomLobbyLocalModel.GetSingleton().LocalPeerId() then + CustomLobbyController.RequestSetReady(not player.Ready) + end + end, + + --- Whether the local peer may edit this seat's per-player settings (faction / colour / team): your + --- own seat while you're not ready, or any AI seat if you're the host. (Another human's seat is not + --- editable.) A per-`kind` gate refines this — the team picker is hidden under a binary AutoTeams + --- mode (the team is decided by start position there). + ---@param self UICustomLobbySlotBase + ---@param kind "color" | "faction" | "team" + ---@return boolean + CanEdit = function(self, kind) + local player = self.CurrentPlayer + if not player then + return false + end + local localModel = CustomLobbyLocalModel.GetSingleton() + local mine = player.Human and player.OwnerID == localModel.LocalPeerId() and not player.Ready + local hostAi = localModel.IsHost() and not player.Human + if not (mine or hostAi) then + return false + end + if kind == "team" and CustomLobbyRules.AutoTeamMode(CustomLobbyLaunchModel.GetSingleton().GameOptions()) then + return false -- binary AutoTeams decides the team from the start position + end + return true + end, + + --- Opens the picker for an editable element, anchored at the cursor. + ---@param self UICustomLobbySlotBase + ---@param kind "color" | "faction" | "team" + ---@param event KeyEvent + OpenPicker = function(self, kind, event) + local player = self.CurrentPlayer + if not player then + return + end + local x, y = event.MouseX, event.MouseY + if kind == "color" then + CustomLobbySlotPickers.ShowColorPicker(self.SlotIndex, player, x, y) + elseif kind == "faction" then + CustomLobbySlotPickers.ShowFactionPicker(self.SlotIndex, player, x, y) + elseif kind == "team" then + CustomLobbySlotPickers.ShowTeamPicker(self.SlotIndex, player, x, y) + end + end, + + --- Routes an edit-zone press: a left press on an editable element opens its picker; otherwise it + --- behaves like a normal row press (take / ready / host-drag), and a right press opens the context + --- menu — so the zone never swallows the row's default interactions when it isn't editable. + ---@param self UICustomLobbySlotBase + ---@param kind "color" | "faction" | "team" + ---@param event KeyEvent + ---@return boolean + HandleEditZoneEvent = function(self, kind, event) + if event.Type ~= 'ButtonPress' then + return false + end + if event.Modifiers.Right then + self:OnRowContext(event) + elseif self:CanEdit(kind) then + self:OpenPicker(kind, event) + else + self:OnRowPress(event) + end + return true + end, + + --- Builds a transparent hit zone over an editable element (the presentation lays it out above the + --- ClickArea, like the CPU hover zone). Routes presses through `HandleEditZoneEvent`. + ---@param self UICustomLobbySlotBase + ---@param kind "color" | "faction" | "team" + ---@return Bitmap + CreateEditZone = function(self, kind) + local zone = Bitmap(self) + zone:SetSolidColor('00000000') + zone.HandleEvent = function(control, event) + return self:HandleEditZoneEvent(kind, event) + end + return zone + end, + + --- Builds the faction icon strip: a Group with one icon slot per real faction (hidden until + --- `RenderFactionIcons` fills them). The presentation positions the outer group and calls + --- `LayoutFactionIcons` to lay the slots inside it; the edit zone overlays the group. + ---@param self UICustomLobbySlotBase + ---@return Group + CreateFactionIcons = function(self) + local group = Group(self) + local bitmaps = {} + for k = 1, CustomLobbyLaunchModel.RealFactionCount do + local icon = Bitmap(group) + icon:DisableHitTest() + icon:SetAlpha(0.0) + bitmaps[k] = icon + end + self.FactionIcons = group + self.FactionIconBitmaps = bitmaps + return group + end, + + --- Lays the icon slots left-to-right inside the faction-icon group (called from the presentation's + --- LayoutContents after it has positioned the outer group). The slots track the group's left edge, + --- so the strip follows the group regardless of column orientation. + ---@param self UICustomLobbySlotBase + LayoutFactionIcons = function(self) + if not self.FactionIconBitmaps then + return + end + self.FactionIcons.Height:Set(scaled(FactionIconSize)) + for k, icon in self.FactionIconBitmaps do + local offset = (k - 1) * (FactionIconSize + FactionIconGap) + Layouter(icon):Width(FactionIconSize):Height(FactionIconSize):AtVerticalCenterIn(self.FactionIcons) + :Left(function() return self.FactionIcons.Left() + scaled(offset) end):End() + end + end, + + --- Builds the host-only per-seat close/open button (hidden until `RenderSlotButton` shows it on an + --- empty / closed seat). A small labelled surface; the presentation positions the outer group and + --- calls `LayoutSlotButton` to bind its internals. + ---@param self UICustomLobbySlotBase + ---@return Group + CreateSlotButton = function(self) + ---@type UICustomLobbySlotButton + local btn = Group(self) + btn.Bg = Bitmap(btn) + btn.Bg:SetSolidColor('ff2a333c') + btn.Label = UIUtil.CreateText(btn, "", 12, UIUtil.bodyFont) + btn.Label:SetColor('ffd0d4d8') + btn.Label:DisableHitTest() + btn.Bg.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + self:OnSlotButtonPress() + return true + elseif event.Type == 'MouseEnter' then + control:SetSolidColor('ff37424d') + return true + elseif event.Type == 'MouseExit' then + control:SetSolidColor('ff2a333c') + return true + end + return false + end + + btn:Hide() + self.SlotButton = btn + return btn + end, + + --- Binds the slot button's internals (called from the presentation's LayoutContents after it has + --- positioned the outer group). + ---@param self UICustomLobbySlotBase + LayoutSlotButton = function(self) + if not self.SlotButton then + return + end + Layouter(self.SlotButton.Bg):Fill(self.SlotButton):End() + Layouter(self.SlotButton.Label):AtCenterIn(self.SlotButton):End() + end, + + --- Updates the close/open button for the resolved entry: the host sees "Close" on an empty open + --- seat and "Open" on a closed seat; it is hidden otherwise (occupied seat, or a non-host peer). + ---@param self UICustomLobbySlotBase + ---@param entry UICustomLobbySlot + RenderSlotButton = function(self, entry) + local btn = self.SlotButton + if not btn then + return + end + + btn:Hide() + local isHost = CustomLobbyLocalModel.GetSingleton().IsHost() + if isHost and not entry.Player and not entry.Closed then + self.SlotButtonMode = "close" + btn.Label:SetText("Close") + btn:Show() + elseif isHost and entry.Closed then + self.SlotButtonMode = "open" + btn.Label:SetText("Open") + btn:Show() + else + btn.Label:SetText("HELP") + self.SlotButtonMode = false + btn:Hide() + end + end, + + --- Click on the close/open button: opens or closes this seat (host-authoritative). + ---@param self UICustomLobbySlotBase + OnSlotButtonPress = function(self) + if self.SlotButtonMode == "close" then + CustomLobbyController.RequestSetSlotClosed(self.SlotIndex, true) + elseif self.SlotButtonMode == "open" then + CustomLobbyController.RequestSetSlotClosed(self.SlotIndex, false) + end + end, + + --#endregion + + ---@param self UICustomLobbySlotBase + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +-- exported for the presentations to subclass (`Class(import(...).SlotBase) { ... }`) +SlotBase = CustomLobbySlotBase diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotPickers.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotPickers.lua new file mode 100644 index 00000000000..c5d64b7cfb3 --- /dev/null +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotPickers.lua @@ -0,0 +1,402 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The three small floating pickers a player opens by clicking a card's editable element: the **faction +-- multi-toggle** (tick a subset of factions — more than one means random among them), the **colour +-- grid** (taken colours greyed; colours are scarce), and the **team list**. Each is a framed panel +-- anchored at a screen point, dismissed on click-outside / Esc — the same singleton + full-screen-cover +-- pattern as CustomLobbyContextMenu, lifted above the slot rows' raised hit area. +-- +-- A picker only proposes a change: it calls the matching host-authoritative controller intent +-- (`RequestSetFactions` / `RequestSetColor` / `RequestSetTeam`, keyed by slot) and the seat re-renders +-- from the synced model. It never writes a model itself. The faction picker applies live on each toggle +-- (and stays open); the colour / team pickers apply and close (a single choice). + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local EscapeHandler = import("/lua/ui/dialogs/eschandler.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local GameColors = import("/lua/gamecolors.lua").GameColors +local Factions = import("/lua/factions.lua").Factions +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") + +local Layouter = LayoutHelpers.ReusedLayoutFor +local scaled = LayoutHelpers.ScaleNumber + +local Pad = 6 +local RowHeight = 24 +local LabelPadX = 8 +local RowWidth = 150 -- faction / team rows +local Swatch = 22 -- colour grid cell +local SwatchGap = 4 +local SwatchesPerRow = 8 + +local BorderColor = 'ff415055' +local FillColor = 'f0101418' +local HoverColor = '22ffffff' +local CheckOn = 'ff7ad97a' +local CheckOff = '00000000' +local CheckBorder = 'ff5a6470' +local MaxTeams = 8 -- "No team" + Team 1..MaxTeams + +------------------------------------------------------------------------------- +--#region Singleton + framing (mirrors CustomLobbyContextMenu) + +local ModuleTrash = TrashBag() +---@type Group | false +local Instance = false +---@type Bitmap | false +local Cover = false + +--- Closes the open picker (if any). Idempotent. +function Hide() + if not Instance then + return + end + EscapeHandler.PopEscapeHandler() + Instance:Destroy() + Instance = false + if Cover then + Cover:Destroy() + Cover = false + end +end + +--- A full-screen invisible catcher: a click off the panel dismisses it. +---@return Bitmap +local function CreateCover() + local cover = Bitmap(GetFrame(0)) + cover:SetSolidColor('00000000') + cover.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + Hide() + return true + end + return false + end + Layouter(cover):Fill(GetFrame(0)):End() + return cover +end + +--- Builds an empty framed panel (border + dark fill) of the given unscaled size, parented to the +--- frame. Children anchor into it; the caller positions it via `Mount`. +---@param width number +---@param height number +---@return Group +local function CreatePanel(width, height) + local panel = Group(GetFrame(0), "CustomLobbySlotPicker") + panel.Width:Set(scaled(width)) + panel.Height:Set(scaled(height)) + + local border = Bitmap(panel) + border:SetSolidColor(BorderColor) + border:DisableHitTest() + local fill = Bitmap(panel) + fill:SetSolidColor(FillColor) + fill:DisableHitTest() + Layouter(border):Fill(panel):End() + Layouter(fill):AtLeftIn(panel, 1):AtRightIn(panel, 1):AtTopIn(panel, 1):AtBottomIn(panel, 1):End() + return panel +end + +--- Shows `panel` at the screen point, kept on-screen, above the slot rows' raised hit area, with the +--- click-outside cover and an Esc handler. Replaces any picker already open. +---@param panel Group +---@param x number +---@param y number +local function Mount(panel, x, y) + local frame = GetFrame(0) + local baseDepth = frame:GetTopmostDepth() + + Cover = CreateCover() + Cover.Depth:Set(baseDepth + 10) + + panel.Depth:Set(baseDepth + 20) + ModuleTrash:Add(panel) + Instance = panel + + local left = math.min(x, frame.Right() - panel.Width()) + local top = math.min(y, frame.Bottom() - panel.Height()) + panel.Left:Set(math.max(0, left)) + panel.Top:Set(math.max(0, top)) + + EscapeHandler.PushEscapeHandler(function() Hide() end) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Faction multi-toggle + +--- The sorted list of ticked faction indices in `selected`. +---@param selected table +---@return number[] +local function SelectedFactions(selected) + local out = {} + for index = 1, CustomLobbyLaunchModel.RealFactionCount do + if selected[index] then + table.insert(out, index) + end + end + return out +end + +--- Opens the faction multi-toggle for `slot`'s player at the screen point. Each row is a faction with a +--- tick box; clicking toggles it and applies live (more than one ticked = random among them). The last +--- ticked faction can't be un-ticked, so a player always has at least one allowed faction. +---@param slot number +---@param player UICustomLobbyPlayer +---@param x number +---@param y number +function ShowFactionPicker(slot, player, x, y) + Hide() + local count = CustomLobbyLaunchModel.RealFactionCount + + local selected = {} + for _, index in (player.Factions or { player.Faction }) do + if type(index) == 'number' and index >= 1 and index <= count then + selected[index] = true + end + end + + local panel = CreatePanel(RowWidth, Pad * 2 + count * RowHeight) + + for i = 1, count do + local faction = Factions[i] + local top = Pad + (i - 1) * RowHeight + + local row = Group(panel) + local surface = Bitmap(row) + surface:SetSolidColor(HoverColor) + surface:SetAlpha(0.0) + + -- the frame first (behind), then the fill on top so the tick shows over it + local checkBorder = Bitmap(row) + checkBorder:SetSolidColor(CheckBorder) + checkBorder:DisableHitTest() + local check = Bitmap(row) + check:DisableHitTest() + check:SetSolidColor(selected[i] and CheckOn or CheckOff) + + local icon = Bitmap(row) + icon:DisableHitTest() + if faction.SmallIcon then + icon:SetTexture(UIUtil.UIFile(faction.SmallIcon)) + end + + local label = UIUtil.CreateText(row, faction.DisplayName or faction.Key or tostring(i), 13, UIUtil.bodyFont) + label:DisableHitTest() + + surface.HandleEvent = function(control, event) + if event.Type == 'MouseEnter' then + control:SetAlpha(1.0) + return true + elseif event.Type == 'MouseExit' then + control:SetAlpha(0.0) + return true + elseif event.Type == 'ButtonPress' then + -- toggle, but never empty the set (the last allowed faction stays ticked) + if selected[i] and table.getn(SelectedFactions(selected)) <= 1 then + return true + end + selected[i] = not selected[i] + check:SetSolidColor(selected[i] and CheckOn or CheckOff) + CustomLobbyController.RequestSetFactions(slot, SelectedFactions(selected)) + return true + end + return false + end + + Layouter(row):AtLeftIn(panel, Pad):AtRightIn(panel, Pad):Height(RowHeight) + :Top(function() return panel.Top() + scaled(top) end):End() + Layouter(surface):Fill(row):End() + Layouter(checkBorder):AtLeftIn(row):AtVerticalCenterIn(row):Width(14):Height(14):End() + Layouter(check):AtLeftIn(checkBorder, 1):AtTopIn(checkBorder, 1):AtRightIn(checkBorder, 1):AtBottomIn(checkBorder, 1):End() + Layouter(icon):AnchorToRight(checkBorder, LabelPadX):AtVerticalCenterIn(row):Width(14):Height(14):End() + Layouter(label):AnchorToRight(icon, 6):AtVerticalCenterIn(row):End() + end + + Mount(panel, x, y) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Colour grid + +--- The set of PlayerColor indices already used by a seated player other than `exceptSlot` (greyed in +--- the grid — colours are scarce). Mirrors the host-side scarcity check, for UX. +---@param exceptSlot number +---@return table +local function TakenColors(exceptSlot) + local launch = CustomLobbyLaunchModel.GetSingleton() + local taken = {} + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + if slot ~= exceptSlot then + local player = launch.Players[slot]() + if player and player.PlayerColor then + taken[player.PlayerColor] = true + end + end + end + return taken +end + +--- Opens the colour grid for `slot`'s player. Free colours are pickable (click applies + closes); +--- colours taken by another seated player are greyed and inert; the player's current colour is framed. +---@param slot number +---@param player UICustomLobbyPlayer +---@param x number +---@param y number +function ShowColorPicker(slot, player, x, y) + Hide() + local colors = GameColors.PlayerColors + local total = table.getn(colors) + local rows = math.ceil(total / SwatchesPerRow) + local taken = TakenColors(slot) + + local width = Pad * 2 + SwatchesPerRow * Swatch + (SwatchesPerRow - 1) * SwatchGap + local height = Pad * 2 + rows * Swatch + (rows - 1) * SwatchGap + local panel = CreatePanel(width, height) + + for i = 1, total do + local col = math.mod(i - 1, SwatchesPerRow) + local rowIdx = math.floor((i - 1) / SwatchesPerRow) + local left = Pad + col * (Swatch + SwatchGap) + local top = Pad + rowIdx * (Swatch + SwatchGap) + local isTaken = taken[i] + local isCurrent = player.PlayerColor == i + + -- a white frame behind the current colour's swatch + if isCurrent then + local frame = Bitmap(panel) + frame:SetSolidColor('ffffffff') + frame:DisableHitTest() + Layouter(frame):Width(Swatch + 4):Height(Swatch + 4) + :Left(function() return panel.Left() + scaled(left - 2) end) + :Top(function() return panel.Top() + scaled(top - 2) end):End() + end + + local swatch = Bitmap(panel) + swatch:SetSolidColor(colors[i]) + swatch:SetAlpha(isTaken and 0.25 or 1.0) + if not isTaken and not isCurrent then + swatch.HandleEvent = function(control, event) + if event.Type == 'MouseEnter' then + control:SetAlpha(0.7) + return true + elseif event.Type == 'MouseExit' then + control:SetAlpha(1.0) + return true + elseif event.Type == 'ButtonPress' then + Hide() + CustomLobbyController.RequestSetColor(slot, i) + return true + end + return false + end + else + swatch:DisableHitTest() + end + + Layouter(swatch):Width(Swatch):Height(Swatch) + :Left(function() return panel.Left() + scaled(left) end) + :Top(function() return panel.Top() + scaled(top) end):End() + end + + Mount(panel, x, y) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Team list + +--- Opens the team list for `slot`'s player: "No team" + Team 1..MaxTeams (backend numbering 1, 2..9). +--- Clicking applies + closes. The current team is highlighted. +---@param slot number +---@param player UICustomLobbyPlayer +---@param x number +---@param y number +function ShowTeamPicker(slot, player, x, y) + Hide() + local entries = {} + table.insert(entries, { label = "No team", team = 1 }) + for t = 1, MaxTeams do + table.insert(entries, { label = "Team " .. tostring(t), team = t + 1 }) + end + local count = table.getn(entries) + + local panel = CreatePanel(RowWidth, Pad * 2 + count * RowHeight) + + for i = 1, count do + local entry = entries[i] + local top = Pad + (i - 1) * RowHeight + local isCurrent = (player.Team or 1) == entry.team + + local row = Group(panel) + local surface = Bitmap(row) + surface:SetSolidColor(HoverColor) + surface:SetAlpha(isCurrent and 0.12 or 0.0) + + local label = UIUtil.CreateText(row, entry.label, 13, UIUtil.bodyFont) + label:SetColor(isCurrent and CheckOn or 'ffffffff') + label:DisableHitTest() + + surface.HandleEvent = function(control, event) + if event.Type == 'MouseEnter' then + control:SetAlpha(0.12) + return true + elseif event.Type == 'MouseExit' then + control:SetAlpha(isCurrent and 0.12 or 0.0) + return true + elseif event.Type == 'ButtonPress' then + Hide() + CustomLobbyController.RequestSetTeam(slot, entry.team) + return true + end + return false + end + + Layouter(row):AtLeftIn(panel, Pad):AtRightIn(panel, Pad):Height(RowHeight) + :Top(function() return panel.Top() + scaled(top) end):End() + Layouter(surface):Fill(row):End() + Layouter(label):AtLeftIn(row, LabelPadX):AtVerticalCenterIn(row):End() + end + + Mount(panel, x, y) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + Hide() + ModuleTrash:Destroy() +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua new file mode 100644 index 00000000000..44cc3ede7fa --- /dev/null +++ b/lua/ui/lobby/customlobby/slots/CustomLobbySlotsInterface.lua @@ -0,0 +1,627 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The slot subsystem's entry point: the "Players" header over the active *layout body*, plus the +-- shared drag coordinator. The composition root mounts this and fills its area with it. +-- +-- One layout is alive at a time, picked by the AutoTeams mode: the one-column layout +-- ([onecolumn/CustomLobbyOneColumnSlots](onecolumn/CustomLobbyOneColumnSlots.lua)) for the non-team +-- modes, and the two-column team layout for the binary modes (left/right, top/bottom, even/odd). +-- (The two-column layout + the AutoTeams-driven swap land in the next step; for now it is always +-- one-column.) +-- +-- This selector is the rows' **drag coordinator** (`UICustomLobbySlotCoordinator`) for *every* +-- layout, because it alone needs to hit-test across the rows and float the drag ghost — so the +-- layout bodies stay pure "build + place + reveal" and never duplicate the drag logic. Each body is +-- handed this selector as its rows' coordinator and exposes its `Rows` for the hit-test. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyRules = import("/lua/ui/lobby/customlobby/customlobbyrules.lua") +local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyslotsderivedmodel.lua") +local CustomLobbyBalancePreview = import("/lua/ui/lobby/customlobby/customlobbybalancepreview.lua") +local CustomLobbyOneColumnSlots = import("/lua/ui/lobby/customlobby/slots/onecolumn/customlobbyonecolumnslots.lua") +local CustomLobbyTwoColumnSlots = import("/lua/ui/lobby/customlobby/slots/twocolumn/customlobbytwocolumnslots.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +local HeaderHeight = 24 -- the "Players" header + gap above the layout body + +-- the header tool buttons (right of the "Players" label): small square icon buttons, the same +-- idle/hover/lit look as the config column's preview tools (drift-fine local copy — see CLAUDE.md). +-- The icon (ToolSize - 2*ToolIconInset = 18px) matches the config strip; these textures are full +-- title-bar button glyphs, so anything smaller renders the pin tiny. +local ToolSize = 22 +local ToolGap = 4 +local ToolIconInset = 2 +local ToolIdle = 'ff141a20' +local ToolHover = 'ff1f262e' +local ToolActive = 'ff2c4a5e' -- a lit toggle's background (pin on) + +-- icon textures (skin-relative, resolved through UIFile). The pin uses the window pin-button glyphs +-- (unpinned vs pinned, same as window.lua's title-bar pin); balance the lobby auto-balance button +-- art; reopen the circular recall ("refresh") icon. +local PinIcon = '/game/menu-btns/pin_btn_up.dds' -- unpinned (toggle off) +local PinnedIcon = '/game/menu-btns/pinned_btn_up.dds' -- pinned (toggle on / the lock-notice glyph) +local BalanceIcon = '/BUTTON/autobalance/_btn_up.dds' +local ReopenIcon = '/game/recall-panel/icon-recall_bmp.dds' +local CloseEmptyIcon = '/dialogs/close_btn/close.dds' -- close all empty open slots (pairs with reopen) +local InspectIcon = '/dialogs/zoom_btn/zoom_btn_up.dds' -- /debug-only: toggle the "show control under mouse" overlay + +-- the auto-teams quick-cycle button steps through these modes in order (matches the options dropdown); +-- its glyph is the faction-skinned per-mode art the legacy lobby used (/BUTTON/autoteam//). +local AutoTeamOrder = { 'none', 'tvsb', 'lvsr', 'pvsi', 'manual' } + +--- The button glyph for an auto-teams mode. +---@param mode string +---@return FileName +local function AutoTeamIcon(mode) + return '/BUTTON/autoteam/' .. mode .. '/_btn_up.dds' +end + +--- The next mode in the cycle after `current` (wraps). +---@param current string +---@return string +local function NextAutoTeam(current) + local index = 1 + for i, mode in ipairs(AutoTeamOrder) do + if mode == current then + index = i + break + end + end + return AutoTeamOrder[math.mod(index, table.getn(AutoTeamOrder)) + 1] +end + +--- A small square icon button for the header tool strip: a solid background that lights on hover, +--- plus an `Active` state (driven externally for the pin toggle) that lights it the "on" colour. A +--- toggle can pass a second `activeTexture` so the icon glyph swaps while active (the pin shows the +--- unpinned vs pinned art, like the window title-bar pin). Clicking calls `OnPress`; the owner decides +--- what that means (fire an intent, flip a synced flag, …). Mirrors the config column's `PreviewTool`. +---@class UICustomLobbySlotTool : Group +---@field Bg Bitmap +---@field Icon Bitmap +---@field IdleTexture FileName +---@field ActiveTexture FileName | nil +---@field Active boolean +---@field Enabled boolean +---@field Hovered boolean +---@field OnPress? fun() +local SlotTool = Class(Group) { + + ---@param self UICustomLobbySlotTool + ---@param parent Control + ---@param texture FileName # the icon (idle / toggle-off) + ---@param activeTexture? FileName # glyph swapped in while Active (toggle-on); omit for plain actions + __init = function(self, parent, texture, activeTexture) + Group.__init(self, parent, "CustomLobbySlotTool") + + self.Active = false + self.Enabled = true + self.Hovered = false + self.IdleTexture = texture + self.ActiveTexture = activeTexture + + self.Bg = Bitmap(self) + self.Bg:SetSolidColor(ToolIdle) + + self.Icon = UIUtil.CreateBitmap(self, texture) + self.Icon:DisableHitTest() + + self.Bg.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + if self.Enabled and self.OnPress then + self.OnPress() + end + return true + elseif event.Type == 'MouseEnter' then + self.Hovered = true + self:ApplyVisual() + return true + elseif event.Type == 'MouseExit' then + self.Hovered = false + self:ApplyVisual() + return true + end + return false + end + end, + + ---@param self UICustomLobbySlotTool + __post_init = function(self) + Layouter(self.Bg):Fill(self):End() + Layouter(self.Icon):AtCenterIn(self):Width(ToolSize - 2 * ToolIconInset):Height(ToolSize - 2 * ToolIconInset):End() + self:ApplyVisual() + end, + + --- Repaints the background (idle / hover / lit-when-active) and, for a toggle with an + --- `ActiveTexture`, swaps the icon glyph for the active/inactive state. + ---@param self UICustomLobbySlotTool + ApplyVisual = function(self) + local bg = ToolIdle + if self.Enabled then + if self.Active then + bg = ToolActive + elseif self.Hovered then + bg = ToolHover + end + end + self.Bg:SetSolidColor(bg) + -- a disabled action reads as greyed: dim its glyph and don't light on hover + self.Icon:SetAlpha(self.Enabled and 1 or 0.3) + if self.ActiveTexture then + self.Icon:SetTexture(UIUtil.UIFile(self.Active and self.ActiveTexture or self.IdleTexture)) + end + end, + + --- Sets the lit/active state (the pin toggle drives this from the synced model). + ---@param self UICustomLobbySlotTool + ---@param active boolean + SetActive = function(self, active) + self.Active = active + self:ApplyVisual() + end, + + --- Enables or disables the button: a disabled button ignores presses and reads greyed. The + --- balance button drives this from the seated teams (auto-balance needs exactly two). + ---@param self UICustomLobbySlotTool + ---@param enabled boolean + SetEnabled = function(self, enabled) + self.Enabled = enabled and true or false + self:ApplyVisual() + end, + + --- Swaps the icon glyph (for a cycling action like the auto-teams button, whose glyph reflects the + --- current mode). Plain actions don't paint the icon in ApplyVisual, so this is safe to drive externally. + ---@param self UICustomLobbySlotTool + ---@param texture FileName + SetIcon = function(self, texture) + self.IdleTexture = texture + self.Icon:SetTexture(UIUtil.UIFile(texture)) + end, +} + +---@alias UICustomLobbySlotsBody UICustomLobbyOneColumnSlots | UICustomLobbyTwoColumnSlots + +---@class UICustomLobbySlotsInterface : Group, UICustomLobbySlotCoordinator +---@field Trash TrashBag +---@field Header Text +---@field LockIcon Bitmap # lock glyph shown (with LockLabel) while seating is pinned +---@field LockLabel Text # "Locked" notice, visible to everyone while pinned +---@field Tools Group # host-only tool strip (right of the header) +---@field PinButton UICustomLobbySlotTool +---@field AutoTeamsButton UICustomLobbySlotTool +---@field BalanceButton UICustomLobbySlotTool +---@field CloseEmptyButton UICustomLobbySlotTool +---@field ReopenButton UICustomLobbySlotTool +---@field DebugButton? UICustomLobbySlotTool # /debug-only host UI-inspection toggle +---@field Body UICustomLobbySlotsBody | false # the active layout body +---@field LayoutKind "one" | "two" | false # which layout Body currently is +---@field Mounted boolean # true once __post_init has laid us out +---@field GameOptionsObserver LazyVar +---@field IsHostObserver LazyVar +---@field SlotsPinnedObserver LazyVar +---@field BalanceGateObserver LazyVar +---@field HighlightedSlot number | false # slot currently shown as a drop target +---@field DragGhost Group | false # floating label following the cursor mid-drag +local CustomLobbySlotsInterface = Class(Group) { + + ---@param self UICustomLobbySlotsInterface + ---@param parent Control + __init = function(self, parent) + Group.__init(self, parent, "CustomLobbySlotsInterface") + + self.Trash = TrashBag() + self.HighlightedSlot = false + self.DragGhost = false + self.Mounted = false + self.Body = false + self.LayoutKind = false + + self.Header = UIUtil.CreateText(self, "Players", 14, UIUtil.titleFont) + self.Header:SetColor('ff9aa0a8') + self.Header:DisableHitTest() + + -- lock notice (right of the "Players" label): shown to everyone while seating is pinned, so a + -- client can see seating is host-controlled before it clicks an open slot to no effect. The + -- host also has the lit pin button; this reinforces the state for both. + self.LockIcon = UIUtil.CreateBitmap(self, PinnedIcon) + self.LockIcon:DisableHitTest() + self.LockIcon:Hide() + self.LockLabel = UIUtil.CreateText(self, "Locked", 12, UIUtil.bodyFont) + self.LockLabel:SetColor('ffd9c97a') + self.LockLabel:DisableHitTest() + self.LockLabel:Hide() + + -- host-only tool strip, right-aligned in the header band: pin seating · auto-balance · + -- reopen closed slots. Grouped so a single Show/Hide gates them all on host status. + self.Tools = Group(self, "CustomLobbySlotTools") + + self.PinButton = SlotTool(self.Tools, PinIcon, PinnedIcon) + self.PinButton.OnPress = function() + CustomLobbyController.RequestSetSlotsPinned(not CustomLobbySessionModel.GetSingleton().SlotsPinned()) + end + Tooltip.AddControlTooltipManual(self.PinButton.Bg, "Pin slots", + "Lock seating so only you (the host) can move players between slots.") + + -- cycles the AutoTeams mode; its glyph shows the current mode and the slot layout follows + self.AutoTeamsButton = SlotTool(self.Tools, AutoTeamIcon('none')) + self.AutoTeamsButton.OnPress = function() + local launch = CustomLobbyLaunchModel.GetSingleton() + local options = table.copy(launch.GameOptions()) + options.AutoTeams = NextAutoTeam(options.AutoTeams or 'none') + CustomLobbyController.RequestSetGameOptions(options) + end + Tooltip.AddControlTooltipManual(self.AutoTeamsButton.Bg, "Auto teams", + "Choose how teams form from the start spots. Click to step through: None, Top vs Bottom, Left vs Right, Odd vs Even, Manual.") + + self.BalanceButton = SlotTool(self.Tools, BalanceIcon) + self.BalanceButton.OnPress = function() + CustomLobbyBalancePreview.Open() + end + Tooltip.AddControlTooltipManual(self.BalanceButton.Bg, "Auto balance", + "Preview a balanced two-team split before applying it. Locked players stay put.") + + -- closes every currently-open empty slot in one go (pairs with the reopen button) + self.CloseEmptyButton = SlotTool(self.Tools, CloseEmptyIcon) + self.CloseEmptyButton.OnPress = function() + CustomLobbyController.RequestCloseEmptySlots() + end + Tooltip.AddControlTooltipManual(self.CloseEmptyButton.Bg, "Close empty slots", + "Close every open slot that has no player, so no more players can join those seats.") + + self.ReopenButton = SlotTool(self.Tools, ReopenIcon) + self.ReopenButton.OnPress = function() + CustomLobbyController.RequestReopenClosedSlots() + end + Tooltip.AddControlTooltipManual(self.ReopenButton.Bg, "Reopen closed slots", + "Close, then re-open every closed slot to refresh the lobby for everyone.") + + -- a UI-inspection toggle, created only when the game was launched with /debug: lights the + -- "show control under mouse" overlay so any control can be inspected by hovering it (the + -- overlay + dump-key wiring lives in the controller, see ToggleUiInspectOverlay). Unlike the + -- host action buttons it sits on the far left, directly right of the "Players" label (a + -- developer aid, not a lobby action) — so it's parented to the header band rather than the + -- right-aligned Tools strip, and the IsHost observer below gates its visibility separately + -- (creation is gated on /debug, visibility on IsHost). + if HasCommandLineArg('/debug') then + self.DebugButton = SlotTool(self, InspectIcon) + self.DebugButton:SetActive(CustomLobbyController.IsUiInspectOverlayEnabled()) + self.DebugButton.OnPress = function() + self.DebugButton:SetActive(CustomLobbyController.ToggleUiInspectOverlay()) + end + Tooltip.AddControlTooltipManual(self.DebugButton.Bg, "Inspect controls", + "Toggle the 'show control under mouse' overlay, then hover any UI control to inspect it. (/debug only)") + end + + -- the tool strip (and the far-left /debug inspect button) are host-only; hide for clients + self.IsHostObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyLocalModel.GetSingleton().IsHost, function(isHostLazy) + local isHost = isHostLazy() + if isHost then + self.Tools:Show() + else + self.Tools:Hide() + end + if self.DebugButton then + if isHost then + self.DebugButton:Show() + else + self.DebugButton:Hide() + end + end + end)) + + -- light the host's pin button and show the (everyone-visible) lock notice while seating is + -- pinned (synced session state) + self.SlotsPinnedObserver = self.Trash:Add( + LazyVarDerive(CustomLobbySessionModel.GetSingleton().SlotsPinned, function(pinnedLazy) + local pinned = pinnedLazy() + self.PinButton:SetActive(pinned) + if pinned then + self.LockIcon:Show() + self.LockLabel:Show() + else + self.LockIcon:Hide() + self.LockLabel:Hide() + end + end)) + + -- gate the auto-balance button: it needs a resolved binary AutoTeams split, which is exactly + -- the slots derived model's team aggregate — so one subscription to it covers the gate (it + -- re-fires when the mode or the resolved-ness changes). + self.BalanceGateObserver = self.Trash:Add( + LazyVarDerive(CustomLobbySlotsDerivedModel.GetTeamsVar(), function(teamsLazy) + teamsLazy() + self:UpdateBalanceGate() + end)) + + -- the active layout body, picked by the AutoTeams mode (created now, laid out on mount) + self:RebuildBody() + + -- a binary AutoTeams mode (left/right, top/bottom, even/odd) swaps in the two-column layout; + -- everything else uses one column + self.GameOptionsObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyLaunchModel.GetSingleton().GameOptions, function(lazy) + lazy() + self:OnAutoTeamsChanged() + end)) + end, + + ---@param self UICustomLobbySlotsInterface + __post_init = function(self) + Layouter(self.Header):AtLeftIn(self, 4):AtTopIn(self):End() + + -- the /debug inspect toggle sits on the far left, directly right of the "Players" label + if self.DebugButton then + Layouter(self.DebugButton) + :CenteredRightOf(self.Header, 6):Width(ToolSize):Height(ToolSize) + :End() + end + + -- the lock notice, just right of the "Players" label (right of the inspect button when it's + -- present), hidden unless seating is pinned + local lockAnchor = self.DebugButton or self.Header + Layouter(self.LockIcon):CenteredRightOf(lockAnchor, 8):Width(ToolSize):Height(ToolSize):End() + Layouter(self.LockLabel):CenteredRightOf(self.LockIcon, 3):End() + + -- the tool strip: a fixed-width band pinned top-right, the five square buttons inside it laid + -- out right-to-left, centred on the header + Layouter(self.Tools) + :AtRightIn(self, 4) + :AtVerticalCenterIn(self.Header) + :Width(5 * ToolSize + 4 * ToolGap):Height(ToolSize) + :End() + local function placeTool(tool) + return Layouter(tool):AtTopIn(self.Tools):Width(ToolSize):Height(ToolSize) + end + -- reading left to right: Pin · Auto teams · Balance · Close empty · Reopen + placeTool(self.ReopenButton):AtRightIn(self.Tools):End() + placeTool(self.CloseEmptyButton):LeftOf(self.ReopenButton, ToolGap):End() + placeTool(self.BalanceButton):LeftOf(self.CloseEmptyButton, ToolGap):End() + placeTool(self.AutoTeamsButton):LeftOf(self.BalanceButton, ToolGap):End() + placeTool(self.PinButton):LeftOf(self.AutoTeamsButton, ToolGap):End() + + self.Mounted = true + self:LayoutBody() + end, + + --- Re-evaluates whether auto-balance is offered and (en/dis)ables the button. + ---@param self UICustomLobbySlotsInterface + UpdateBalanceGate = function(self) + self.BalanceButton:SetEnabled(self:CanAutoBalance()) + end, + + --- Auto-balance is offered only when there are exactly two sides — i.e. a binary AutoTeams mode + --- whose split is resolved (a map loaded, for the positional ones; odd/even needs none). That's + --- exactly the slots derived model's `Teams` aggregate, so the gate reads it directly. + ---@param self UICustomLobbySlotsInterface + ---@return boolean + CanAutoBalance = function(self) + local teams = CustomLobbySlotsDerivedModel.GetTeams() + return (teams.Mode and teams.Resolved) and true or false + end, + + --- The layout kind the current AutoTeams mode calls for: "two" for a binary team mode, else "one". + ---@param self UICustomLobbySlotsInterface + ---@return "one" | "two" + KindForMode = function(self) + return CustomLobbyRules.AutoTeamMode(CustomLobbyLaunchModel.GetSingleton().GameOptions()) and "two" or "one" + end, + + --- (Re)creates the layout body for the current mode, destroying the previous one. Lays it out + --- immediately when already mounted (a live mode switch); otherwise __post_init lays it out. + ---@param self UICustomLobbySlotsInterface + RebuildBody = function(self) + local kind = self:KindForMode() + self.LayoutKind = kind + if self.Body then + self.Body:Destroy() + end + -- the selector is the rows' coordinator regardless of layout (passed as `self`) + if kind == "two" then + self.Body = CustomLobbyTwoColumnSlots.Create(self, self) + else + self.Body = CustomLobbyOneColumnSlots.Create(self, self) + end + if self.Mounted then + self:LayoutBody() + end + end, + + --- Fills the area below the header with the active body. + ---@param self UICustomLobbySlotsInterface + LayoutBody = function(self) + if not self.Body then + return + end + Layouter(self.Body) + :AtLeftIn(self):AtRightIn(self) + :AnchorToBottom(self.Header, 4):AtBottomIn(self) + :End() + end, + + --- The mode changed: swap the layout body if the kind (one vs two column) flipped. A change + --- within the same kind (e.g. lvsr→tvsb) is handled by the body's own re-layout. + ---@param self UICustomLobbySlotsInterface + OnAutoTeamsChanged = function(self) + -- keep the quick-cycle button's glyph in sync with the mode (also catches changes from the + -- options dialog or a reset, not just the button itself) + self.AutoTeamsButton:SetIcon(AutoTeamIcon(CustomLobbyLaunchModel.GetSingleton().GameOptions().AutoTeams or 'none')) + if self:KindForMode() ~= self.LayoutKind then + self:RebuildBody() + end + end, + + --- The (scaled) height the slot area wants: the header plus the active layout's column block. + --- Computed straight from the mode + model (not the live body) so it re-fires correctly on a mode + --- swap regardless of observer order. The composition root binds the slot area's height to this. + ---@param self UICustomLobbySlotsInterface + ---@return number + PreferredHeight = function(self) + local count = CustomLobbySessionModel.GetSingleton().SlotCount() + local body = self:KindForMode() == "two" + and CustomLobbyTwoColumnSlots.HeightForCount(count) + or CustomLobbyOneColumnSlots.HeightForCount(math.max(count, 1)) + return LayoutHelpers.ScaleNumber(HeaderHeight) + body + end, + + --------------------------------------------------------------------------- + --#region Slot drag coordination (UICustomLobbySlotCoordinator) + -- + -- A drop resolves to the RequestSwapSlots intent, host-authoritative; the state here is purely + -- visual. Rows belong to the active layout body, reached via `self.Body.Rows`. + + --- Only the host can drag, and only a slot that holds a player (you grab a token). + ---@param self UICustomLobbySlotsInterface + ---@param slot number + ---@return boolean + CanDrag = function(self, slot) + if not CustomLobbyLocalModel.GetSingleton().IsHost() then + return false + end + return CustomLobbyLaunchModel.GetSingleton().Players[slot]() ~= false + end, + + --- The active slot whose row contains the screen point, or nil. + ---@param self UICustomLobbySlotsInterface + ---@param x number + ---@param y number + ---@return number | nil + SlotIndexAt = function(self, x, y) + local count = CustomLobbySessionModel.GetSingleton().SlotCount() + for slot = 1, count do + local row = self.Body.Rows[slot] + if row and x >= row.Left() and x <= row.Right() and y >= row.Top() and y <= row.Bottom() then + return slot + end + end + return nil + end, + + --- Mid-drag: follow the cursor with the ghost and highlight the row under it. + ---@param self UICustomLobbySlotsInterface + ---@param source number + ---@param x number + ---@param y number + OnSlotDragMove = function(self, source, x, y) + if not self.DragGhost then + self.DragGhost = self:CreateDragGhost(source) + end + self.DragGhost.Left:Set(x + LayoutHelpers.ScaleNumber(12)) + self.DragGhost.Top:Set(y + LayoutHelpers.ScaleNumber(8)) + self:HighlightSlot(self:SlotIndexAt(x, y)) + end, + + --- Drop: swap the source slot with whatever row the cursor is over (a move if it's + --- empty). Dropping outside any row is a no-op. + ---@param self UICustomLobbySlotsInterface + ---@param source number + ---@param x number + ---@param y number + OnSlotDrop = function(self, source, x, y) + local target = self:SlotIndexAt(x, y) + if target and target ~= source then + CustomLobbyController.RequestSwapSlots(source, target) + end + end, + + --- Clears the transient drag visuals. + ---@param self UICustomLobbySlotsInterface + OnSlotDragEnd = function(self) + self:HighlightSlot(nil) + if self.DragGhost then + self.DragGhost:Destroy() + self.DragGhost = false + end + end, + + --- Moves the drop-target highlight to `slot` (nil clears it). + ---@param self UICustomLobbySlotsInterface + ---@param slot number | nil + HighlightSlot = function(self, slot) + slot = slot or false + if self.HighlightedSlot == slot then + return + end + if self.HighlightedSlot and self.Body.Rows[self.HighlightedSlot] then + self.Body.Rows[self.HighlightedSlot]:SetDropHighlight(false) + end + self.HighlightedSlot = slot + if slot and self.Body.Rows[slot] then + self.Body.Rows[slot]:SetDropHighlight(true) + end + end, + + --- Builds the floating drag label (the grabbed player's name) so it draws above the rows. + --- Destroyed in OnSlotDragEnd. + ---@param self UICustomLobbySlotsInterface + ---@param source number + ---@return Group + CreateDragGhost = function(self, source) + local player = CustomLobbyLaunchModel.GetSingleton().Players[source]() + local name = (player and player.PlayerName) or ("Slot " .. tostring(source)) + + local ghost = Group(self, "CustomLobbyDragGhost") + ghost:DisableHitTest() + + local bg = Bitmap(ghost) + bg:SetSolidColor('cc101418') + bg:DisableHitTest() + + local label = UIUtil.CreateText(ghost, name, 14, UIUtil.bodyFont) + label:DisableHitTest() + + Layouter(label):AtLeftTopIn(ghost, 6, 3):End() + Layouter(bg):Fill(ghost):End() + ghost.Width:Set(function() return label.Width() + LayoutHelpers.ScaleNumber(12) end) + ghost.Height:Set(function() return label.Height() + LayoutHelpers.ScaleNumber(6) end) + return ghost + end, + + --#endregion + + ---@param self UICustomLobbySlotsInterface + OnDestroy = function(self) + -- if the inspect overlay was left on, turn it off so it doesn't outlive the lobby + -- (idempotent in the controller, so it's safe even when the button never existed) + if self.DebugButton then + CustomLobbyController.SetUiInspectOverlay(false) + end + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbySlotsInterface +Create = function(parent) + return CustomLobbySlotsInterface(parent) +end diff --git a/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbyOneColumnSlots.lua b/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbyOneColumnSlots.lua new file mode 100644 index 00000000000..a614671109f --- /dev/null +++ b/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbyOneColumnSlots.lua @@ -0,0 +1,117 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The one-column slot layout: every slot stacked in a single full-width column of thin +-- CustomLobbySlotRow rows. Used for the non-team AutoTeams modes (none / manual). +-- +-- It is a *layout body* under CustomLobbySlotsInterface: that selector owns the "Players" header and +-- is the rows' drag coordinator (it alone needs to hit-test across rows), so this body just builds +-- the rows, stacks them, reveals the active ones (1..SlotCount), and exposes `Rows` for the +-- coordinator + `HeightForCount` for the selector's preferred-height calc. The selector passes +-- itself as the `coordinator` each row is wired to. + +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") +local CustomLobbySlotRow = import("/lua/ui/lobby/customlobby/slots/onecolumn/customlobbyslotrow.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +local SlotHeight = 24 + +---@class UICustomLobbyOneColumnSlots : Group +---@field Trash TrashBag +---@field Coordinator UICustomLobbySlotCoordinator +---@field Rows UICustomLobbySlotBase[] +---@field SlotCountObserver LazyVar +local CustomLobbyOneColumnSlots = Class(Group) { + + ---@param self UICustomLobbyOneColumnSlots + ---@param parent Control + ---@param coordinator UICustomLobbySlotCoordinator + __init = function(self, parent, coordinator) + Group.__init(self, parent, "CustomLobbyOneColumnSlots") + + self.Trash = TrashBag() + self.Coordinator = coordinator + + self.Rows = {} + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + self.Rows[slot] = CustomLobbySlotRow.Create(self, slot, coordinator) + end + + self.SlotCountObserver = self.Trash:Add( + LazyVarDerive(CustomLobbySessionModel.GetSingleton().SlotCount, function(slotCountLazy) + self:OnSlotCountChanged(slotCountLazy()) + end)) + end, + + ---@param self UICustomLobbyOneColumnSlots + __post_init = function(self) + -- stack the rows top-to-bottom (slot i sits under slot i-1) + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local row = self.Rows[slot] + local builder = Layouter(row):AtLeftIn(self):AtRightIn(self):Height(SlotHeight) + if slot == 1 then + builder:AtTopIn(self) + else + builder:AnchorToBottom(self.Rows[slot - 1], 0) + end + builder:End() + end + end, + + --- Shows the active slots (1..count) and hides the rest. + ---@param self UICustomLobbyOneColumnSlots + ---@param count number + OnSlotCountChanged = function(self, count) + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + if slot <= count then + self.Rows[slot]:Show() + else + self.Rows[slot]:Hide() + end + end + end, + + ---@param self UICustomLobbyOneColumnSlots + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +--- The (scaled) height of the row block for `count` slots — the selector adds the header on top. +---@param count number +---@return number +HeightForCount = function(count) + return math.max(count or 0, 1) * LayoutHelpers.ScaleNumber(SlotHeight) +end + +---@param parent Control +---@param coordinator UICustomLobbySlotCoordinator +---@return UICustomLobbyOneColumnSlots +Create = function(parent, coordinator) + return CustomLobbyOneColumnSlots(parent, coordinator) +end diff --git a/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbySlotRow.lua b/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbySlotRow.lua new file mode 100644 index 00000000000..d2cb1542d9b --- /dev/null +++ b/lua/ui/lobby/customlobby/slots/onecolumn/CustomLobbySlotRow.lua @@ -0,0 +1,148 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The thin slot presentation: one full-width, single-line row used by the one-column layout — +-- +-- 1 ▣ PlayerName Cybran ▢ 1.4k T1 ready +-- +-- It is pure arrangement over CustomLobbySlotBase: it builds the widgets, lays them out in a row, +-- and assigns the base's normalised player / CPU views to them. All behaviour (subscriptions, CPU +-- math, drag-to-swap, intents) lives in the base. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local CustomLobbySlotBase = import("/lua/ui/lobby/customlobby/slots/customlobbyslotbase.lua").SlotBase + +local Layouter = LayoutHelpers.ReusedLayoutFor + +---@class UICustomLobbySlotRow : UICustomLobbySlotBase +---@field SlotNumber Text +---@field Avatar Bitmap +---@field Flag Bitmap +---@field ColorSwatch Bitmap +---@field Name Text +---@field Rating Text +---@field Games Text +---@field FactionIcons Group +---@field Cpu Text +---@field CpuIndicator Bitmap +---@field Team Text +---@field Ready Text +---@field CpuHover Bitmap +---@field ColorZone Bitmap +---@field FactionZone Bitmap +---@field TeamZone Bitmap +local CustomLobbySlotRow = Class(CustomLobbySlotBase) { + + ---@param self UICustomLobbySlotRow + CreateContents = function(self) + self.SlotNumber = UIUtil.CreateText(self, tostring(self.SlotIndex), 14, UIUtil.bodyFont) + + -- a reserved avatar placeholder (a dim box for now; a real FAF avatar lands later) + self.Avatar = Bitmap(self) + self.Avatar:SetSolidColor('33ffffff') + self.Avatar:SetAlpha(0.0) + self.Avatar:DisableHitTest() + + -- the player's country flag (hidden until a country resolves) + self.Flag = Bitmap(self) + self.Flag:SetAlpha(0.0) + self.Flag:DisableHitTest() + + self.ColorSwatch = Bitmap(self) + self.ColorSwatch:SetSolidColor('00000000') + self.Name = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + + -- rating (PL) + games played, dim, in the right cluster + self.Rating = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + self.Rating:SetColor('ffc8ccd0') + self.Games = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) + self.Games:SetColor('ff9aa0a8') + + -- the selected factions as a small icon strip (Random icon when the full set is allowed) + self:CreateFactionIcons() + + self.Cpu = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + -- a small square left of the CPU label: green when the machine sustains the + -- recommended unit cap at full speed, fading to red the more the sim must slow + self.CpuIndicator = Bitmap(self) + self.CpuIndicator:SetSolidColor('ff7ad97a') + self.CpuIndicator:SetAlpha(0.0) + self.CpuIndicator:DisableHitTest() + self.Team = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + self.Ready = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + + -- a hover zone over the CPU score; the base routes enter/exit/press + self.CpuHover = Bitmap(self) + self.CpuHover:SetSolidColor('00000000') + self.CpuHover.HandleEvent = function(control, event) + return self:HandleCpuHoverEvent(event) + end + + -- click zones over the editable elements (colour / faction / team); the base opens the matching + -- picker when the seat is editable, else falls through to the normal row press + self.ColorZone = self:CreateEditZone("color") + self.FactionZone = self:CreateEditZone("faction") + self.TeamZone = self:CreateEditZone("team") + + -- the host-only close/open button (shown only on an empty / closed seat) + self:CreateSlotButton() + end, + + ---@param self UICustomLobbySlotRow + LayoutContents = function(self) + -- left: # · swatch · avatar · flag · name + Layouter(self.SlotNumber):AtLeftIn(self, 6):AtVerticalCenterIn(self):End() + Layouter(self.ColorSwatch):AnchorToRight(self.SlotNumber, 8):AtVerticalCenterIn(self):Width(14):Height(14):End() + Layouter(self.Avatar):AnchorToRight(self.ColorSwatch, 6):AtVerticalCenterIn(self):Width(16):Height(16):End() + Layouter(self.Flag):AnchorToRight(self.Avatar, 6):AtVerticalCenterIn(self):Width(18):Height(12):End() + Layouter(self.Name):AnchorToRight(self.Flag, 8):AtVerticalCenterIn(self):End() + + -- right (laid out right-to-left): ready · team · cpu · faction · games · rating + Layouter(self.Ready):AtRightIn(self, 8):AtVerticalCenterIn(self):End() + Layouter(self.Team):AnchorToLeft(self.Ready, 12):AtVerticalCenterIn(self):End() + Layouter(self.Cpu):AnchorToLeft(self.Team, 12):AtVerticalCenterIn(self):End() + Layouter(self.CpuIndicator):AnchorToLeft(self.Cpu, 5):AtVerticalCenterIn(self):Width(8):Height(12):End() + Layouter(self.FactionIcons):AnchorToLeft(self.CpuIndicator, 10):AtVerticalCenterIn(self):End() + self:LayoutFactionIcons() + Layouter(self.Games):AnchorToLeft(self.FactionIcons, 12):AtVerticalCenterIn(self):End() + Layouter(self.Rating):AnchorToLeft(self.Games, 8):AtVerticalCenterIn(self):End() + + Layouter(self.CpuHover):Fill(self.Cpu):Over(self, 20):End() + Layouter(self.ColorZone):Fill(self.ColorSwatch):Over(self, 20):End() + Layouter(self.FactionZone):Fill(self.FactionIcons):Over(self, 20):End() + Layouter(self.TeamZone):Fill(self.Team):Over(self, 20):End() + + Layouter(self.SlotButton):AtRightIn(self, 8):AtVerticalCenterIn(self):Width(54):Height(18):Over(self, 20):End() + self:LayoutSlotButton() + end, +} + +---@param parent Control +---@param slotIndex number +---@param coordinator UICustomLobbySlotCoordinator +---@return UICustomLobbySlotRow +Create = function(parent, slotIndex, coordinator) + return CustomLobbySlotRow(parent, slotIndex, coordinator) +end diff --git a/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbySlotCard.lua b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbySlotCard.lua new file mode 100644 index 00000000000..ab84d8aa4c6 --- /dev/null +++ b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbySlotCard.lua @@ -0,0 +1,197 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The fat slot presentation: a half-width, double-height card used by the two-column team layout. +-- The same data as the thin row, reflowed onto two lines so it reads in a narrow column — +-- +-- left column right column (mirrored, via SetMirrored) +-- ┌──────────────────────────┐ ┌──────────────────────────┐ +-- │ ▣ PlayerName ready │ │ ready PlayerName ▣ │ line 1: swatch · name · ready +-- │ Cybran · T1 1.4k ▢ │ │ ▢ 1.4k T1 · Cybran │ line 2: faction · team · cpu +-- └──────────────────────────┘ └──────────────────────────┘ +-- +-- The right column is laid out mirrored so the two teams face each other (the leading edge — +-- swatch + name, faction — sits on the inner side). +-- +-- It is pure arrangement over CustomLobbySlotBase: it builds the standard named controls and lays +-- them out; the base paints them and owns all behaviour (subscriptions, CPU math, drag-to-swap, +-- intents). + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local CustomLobbySlotBase = import("/lua/ui/lobby/customlobby/slots/customlobbyslotbase.lua").SlotBase + +local Layouter = LayoutHelpers.ReusedLayoutFor + +--- Restores a control's Left/Right to the default circular relationship, clearing whichever edge a +--- previous layout pass pinned. Re-mirroring re-anchors the *opposite* horizontal edge, so without +--- this the old pin survives and the control stays stuck to its old side. Leaves Width/Height alone +--- (unlike Control.ResetLayout), so a Text keeps its intrinsic auto-width. +---@param control Control +local function ResetHorizontalEdges(control) + control.Left:Set(function() return control.Right() - control.Width() end) + control.Right:Set(function() return control.Left() + control.Width() end) +end + +---@class UICustomLobbySlotCard : UICustomLobbySlotBase +---@field Avatar Bitmap +---@field ColorSwatch Bitmap +---@field Name Text +---@field Flag Bitmap +---@field Ready Text +---@field FactionIcons Group +---@field Team Text +---@field Rating Text +---@field Cpu Text +---@field CpuIndicator Bitmap +---@field CpuHover Bitmap +---@field ColorZone Bitmap +---@field FactionZone Bitmap +---@field TeamZone Bitmap +---@field Mirrored boolean # right-column cards lay out mirrored so the two teams face each other +local CustomLobbySlotCard = Class(CustomLobbySlotBase) { + + ---@param self UICustomLobbySlotCard + CreateContents = function(self) + self.Mirrored = false + + -- a reserved avatar placeholder (a dim box for now; a real FAF avatar lands later) + self.Avatar = Bitmap(self) + self.Avatar:SetSolidColor('33ffffff') + self.Avatar:SetAlpha(0.0) + self.Avatar:DisableHitTest() + + self.ColorSwatch = Bitmap(self) + self.ColorSwatch:SetSolidColor('00000000') + self.Name = UIUtil.CreateText(self, "", 14, UIUtil.bodyFont) + + -- the player's country flag (hidden until a country resolves) + self.Flag = Bitmap(self) + self.Flag:SetAlpha(0.0) + self.Flag:DisableHitTest() + + self.Ready = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) + -- the selected factions as a small icon strip (Random icon when the full set is allowed) + self:CreateFactionIcons() + self.Team = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) + + self.Rating = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) + self.Rating:SetColor('ffc8ccd0') + + self.Cpu = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) + self.CpuIndicator = Bitmap(self) + self.CpuIndicator:SetSolidColor('ff7ad97a') + self.CpuIndicator:SetAlpha(0.0) + self.CpuIndicator:DisableHitTest() + + -- a hover zone over the CPU score; the base routes enter/exit/press + self.CpuHover = Bitmap(self) + self.CpuHover:SetSolidColor('00000000') + self.CpuHover.HandleEvent = function(control, event) + return self:HandleCpuHoverEvent(event) + end + + -- click zones over the editable elements (colour / faction / team); the base opens the matching + -- picker when the seat is editable, else falls through to the normal row press + self.ColorZone = self:CreateEditZone("color") + self.FactionZone = self:CreateEditZone("faction") + self.TeamZone = self:CreateEditZone("team") + + -- the host-only close/open button (shown only on an empty / closed seat) + self:CreateSlotButton() + end, + + --- Lays the card out, mirrored for a right-column card so the leading edge (swatch + name, + --- faction) is on the *inner* side and the two teams face each other. Texts auto-size their + --- width, so anchoring the opposite edge right-aligns them — no manual measuring needed. + ---@param self UICustomLobbySlotCard + LayoutContents = function(self) + -- clear any horizontal edge a previous (opposite-orientation) pass pinned, so only the new + -- anchor binds each control (the vertical anchors don't change between orientations) + for _, control in { self.Avatar, self.ColorSwatch, self.Name, self.Flag, self.Ready, self.FactionIcons, self.Team, self.Rating, self.Cpu, self.CpuIndicator } do + ResetHorizontalEdges(control) + end + + if self.Mirrored then + -- line 1 (top): ready … name · swatch · avatar + Layouter(self.Avatar):AtRightIn(self, 6):AtTopIn(self, 6):Width(16):Height(16):End() + Layouter(self.ColorSwatch):AnchorToLeft(self.Avatar, 6):AtVerticalCenterIn(self.Avatar):Width(14):Height(14):End() + Layouter(self.Name):AnchorToLeft(self.ColorSwatch, 6):AtVerticalCenterIn(self.ColorSwatch):End() + Layouter(self.Ready):AtLeftIn(self, 6):AtVerticalCenterIn(self.ColorSwatch):End() + + -- line 2 (bottom): cpu [indicator] · rating … team · faction · flag + Layouter(self.Flag):AtRightIn(self, 6):AtBottomIn(self, 8):Width(18):Height(12):End() + Layouter(self.FactionIcons):AnchorToLeft(self.Flag, 6):AtVerticalCenterIn(self.Flag):End() + Layouter(self.Team):AnchorToLeft(self.FactionIcons, 8):AtVerticalCenterIn(self.Flag):End() + Layouter(self.Cpu):AtLeftIn(self, 6):AtVerticalCenterIn(self.Flag):End() + Layouter(self.CpuIndicator):AnchorToRight(self.Cpu, 5):AtVerticalCenterIn(self.Flag):Width(8):Height(12):End() + Layouter(self.Rating):AnchorToRight(self.CpuIndicator, 8):AtVerticalCenterIn(self.Flag):End() + else + -- line 1 (top): avatar · swatch · name … ready + Layouter(self.Avatar):AtLeftIn(self, 6):AtTopIn(self, 6):Width(16):Height(16):End() + Layouter(self.ColorSwatch):AnchorToRight(self.Avatar, 6):AtVerticalCenterIn(self.Avatar):Width(14):Height(14):End() + Layouter(self.Name):AnchorToRight(self.ColorSwatch, 6):AtVerticalCenterIn(self.ColorSwatch):End() + Layouter(self.Ready):AtRightIn(self, 6):AtVerticalCenterIn(self.ColorSwatch):End() + + -- line 2 (bottom): flag · faction · team … rating · [indicator] cpu + Layouter(self.Flag):AtLeftIn(self, 6):AtBottomIn(self, 8):Width(18):Height(12):End() + Layouter(self.FactionIcons):AnchorToRight(self.Flag, 6):AtVerticalCenterIn(self.Flag):End() + Layouter(self.Team):AnchorToRight(self.FactionIcons, 8):AtVerticalCenterIn(self.Flag):End() + Layouter(self.Cpu):AtRightIn(self, 6):AtVerticalCenterIn(self.Flag):End() + Layouter(self.CpuIndicator):AnchorToLeft(self.Cpu, 5):AtVerticalCenterIn(self.Flag):Width(8):Height(12):End() + Layouter(self.Rating):AnchorToLeft(self.CpuIndicator, 8):AtVerticalCenterIn(self.Flag):End() + end + self:LayoutFactionIcons() + + Layouter(self.CpuHover):Fill(self.Cpu):Over(self, 20):End() + Layouter(self.ColorZone):Fill(self.ColorSwatch):Over(self, 20):End() + Layouter(self.FactionZone):Fill(self.FactionIcons):Over(self, 20):End() + Layouter(self.TeamZone):Fill(self.Team):Over(self, 20):End() + + -- centred so it reads cleanly on an empty / closed card, regardless of mirror + Layouter(self.SlotButton):AtCenterIn(self):Width(60):Height(18):Over(self, 20):End() + self:LayoutSlotButton() + end, + + --- Sets the mirror state (the two-column layout calls this per the card's column) and re-lays the + --- contents if it changed. + ---@param self UICustomLobbySlotCard + ---@param mirrored boolean + SetMirrored = function(self, mirrored) + mirrored = mirrored or false + if self.Mirrored == mirrored then + return + end + self.Mirrored = mirrored + self:LayoutContents() + end, +} + +---@param parent Control +---@param slotIndex number +---@param coordinator UICustomLobbySlotCoordinator +---@return UICustomLobbySlotCard +Create = function(parent, slotIndex, coordinator) + return CustomLobbySlotCard(parent, slotIndex, coordinator) +end diff --git a/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua new file mode 100644 index 00000000000..9b8bed79ec7 --- /dev/null +++ b/lua/ui/lobby/customlobby/slots/twocolumn/CustomLobbyTwoColumnSlots.lua @@ -0,0 +1,195 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The two-column slot layout: the binary AutoTeams modes (left/right, top/bottom, even/odd) split +-- the slots into two team columns of half-width, double-height CustomLobbySlotCard cards. Each +-- slot's side is read straight from the slots derived model (`entry.Side`), which resolves it once +-- (via CustomLobbyRules.BuildSideResolver) — this body no longer re-derives the split itself. +-- +-- The CustomLobbyTeamScore widget sits in a strip across the top — it *is* the side indicator +-- (`Left 3150 · 3025 Right`), so it doubles as the columns' header. It self-hides for the +-- "two columns, unresolved" case (a positional mode whose map / start positions aren't loaded yet), +-- where we still show both columns but fill them by slot-index parity; once positions resolve a +-- Relayout snaps the cards to their true sides and the score reappears. The strip is reserved either +-- way, so the cards don't jump when it appears. +-- +-- It is a *layout body* under CustomLobbySlotsInterface: that selector owns the "Players" header and +-- is the rows' drag coordinator, so this body just builds the cards, places them by side, reveals +-- the active ones, and (with the module HeightForCount) reports how tall the taller column is. The +-- selector passes itself as the cards' coordinator. + +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") +local CustomLobbySlotsDerivedModel = import("/lua/ui/lobby/customlobby/models/derived/customlobbyslotsderivedmodel.lua") +local CustomLobbyTeamScore = import("/lua/ui/lobby/customlobby/customlobbyteamscore.lua") +local CustomLobbySlotCard = import("/lua/ui/lobby/customlobby/slots/twocolumn/customlobbyslotcard.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +local CardHeight = 48 +local CardGap = 2 +local ColumnGap = 8 -- the gutter between the two columns +local ScoreHeight = 26 -- the team-score strip across the top (the side indicator) + +--- Assigns the visible slots (1..count) to the two team columns, in slot order. Each seat's side is +--- read straight from the slots derived model (`entry.Side`, resolved once there); an unresolved seat +--- (a positional mode whose positions aren't loaded, or a slot past the map's start spots) is `false` +--- and falls back to slot-index parity so both columns still populate. +---@param count number +---@return table cols # { [1] = {slots…}, [2] = {slots…} } +local function ComputeColumns(count) + local slots = CustomLobbySlotsDerivedModel.GetSlots() + local cols = { {}, {} } + for slot = 1, count do + local entry = slots[slot] + local side = entry and entry.Side + if side ~= 1 and side ~= 2 then + side = (math.mod(slot, 2) == 1) and 1 or 2 + end + table.insert(cols[side], slot) + end + return cols +end + +---@class UICustomLobbyTwoColumnSlots : Group +---@field Trash TrashBag +---@field Coordinator UICustomLobbySlotCoordinator +---@field Rows UICustomLobbySlotCard[] +---@field TeamScore UICustomLobbyTeamScore # the side indicator strip atop the columns +---@field Columns Group[] # [1] = side A container, [2] = side B +---@field Ready boolean +---@field SlotCountObserver LazyVar +---@field SlotsObserver LazyVar +local CustomLobbyTwoColumnSlots = Class(Group) { + + ---@param self UICustomLobbyTwoColumnSlots + ---@param parent Control + ---@param coordinator UICustomLobbySlotCoordinator + __init = function(self, parent, coordinator) + Group.__init(self, parent, "CustomLobbyTwoColumnSlots") + + self.Trash = TrashBag() + self.Coordinator = coordinator + self.Ready = false + + -- the side indicator (Left/Top/Odd · ratings · Right/Bottom/Even); self-manages its content + -- and visibility (it hides when the sides can't be determined yet) + self.TeamScore = CustomLobbyTeamScore.Create(self) + + self.Columns = { Group(self, "Col1"), Group(self, "Col2") } + + self.Rows = {} + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + self.Rows[slot] = CustomLobbySlotCard.Create(self, slot, coordinator) + end + + -- each seat's side is resolved in the slots derived model, so re-place whenever that table + -- changes (a seat's side flips when the mode / map / start positions change); the reveal count + -- is session state, so watch it too + self.SlotsObserver = self.Trash:Add( + LazyVarDerive(CustomLobbySlotsDerivedModel.GetSlotsVar(), function(lazy) lazy(); self:Relayout() end)) + self.SlotCountObserver = self.Trash:Add( + LazyVarDerive(CustomLobbySessionModel.GetSingleton().SlotCount, function(lazy) + lazy() + self:Relayout() + end)) + end, + + ---@param self UICustomLobbyTwoColumnSlots + __post_init = function(self) + -- the team-score strip spans the top; the two columns fill the rest, split down the middle + -- with a small gutter (a column is the positioning frame the cards anchor into) + Layouter(self.TeamScore):AtLeftIn(self):AtRightIn(self):AtTopIn(self):Height(ScoreHeight):End() + + Layouter(self.Columns[1]):AtLeftIn(self):AnchorToBottom(self.TeamScore, 2):AtBottomIn(self):End() + self.Columns[1].Right:Set(function() return self.Left() + 0.5 * self.Width() - LayoutHelpers.ScaleNumber(0.5 * ColumnGap) end) + Layouter(self.Columns[2]):AtRightIn(self):AnchorToBottom(self.TeamScore, 2):AtBottomIn(self):End() + self.Columns[2].Left:Set(function() return self.Left() + 0.5 * self.Width() + LayoutHelpers.ScaleNumber(0.5 * ColumnGap) end) + + self.Ready = true + self.TeamScore:Initialize() + self:Relayout() + end, + + --- Splits the active slots into the two columns and stacks each column's cards from its top; + --- re-run whenever the mode / map / slot count changes. + ---@param self UICustomLobbyTwoColumnSlots + Relayout = function(self) + if not self.Ready then + return + end + local count = CustomLobbySessionModel.GetSingleton().SlotCount() + local cols = ComputeColumns(count) + + for column = 1, 2 do + local prev = nil + for _, slot in cols[column] do + local card = self.Rows[slot] + local builder = Layouter(card):AtLeftIn(self.Columns[column]):AtRightIn(self.Columns[column]):Height(CardHeight) + if not prev then + builder:AtTopIn(self.Columns[column]) + else + builder:AnchorToBottom(prev, CardGap) + end + builder:End() + card:SetMirrored(column == 2) -- the right column faces inward (mirrored) + card:Show() + prev = card + end + end + + -- hide the rows past the active count (every slot 1..count landed in a column above) + for slot = count + 1, CustomLobbyLaunchModel.MaxSlots do + self.Rows[slot]:Hide() + end + end, + + ---@param self UICustomLobbyTwoColumnSlots + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +--- The (scaled) height of the score strip plus the taller team column for `count` slots — the +--- selector adds the "Players" header. Mirrors Relayout's split so the area fits what's drawn. +---@param count number +---@return number +HeightForCount = function(count) + local cols = ComputeColumns(count) + local rows = math.max(table.getn(cols[1]), table.getn(cols[2])) + local height = LayoutHelpers.ScaleNumber(ScoreHeight) + rows * LayoutHelpers.ScaleNumber(CardHeight) + if rows > 1 then + height = height + (rows - 1) * LayoutHelpers.ScaleNumber(CardGap) + end + return height +end + +---@param parent Control +---@param coordinator UICustomLobbySlotCoordinator +---@return UICustomLobbyTwoColumnSlots +Create = function(parent, coordinator) + return CustomLobbyTwoColumnSlots(parent, coordinator) +end diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyChatController.lua b/lua/ui/lobby/customlobby/social/CustomLobbyChatController.lua new file mode 100644 index 00000000000..b28deff5e0c --- /dev/null +++ b/lua/ui/lobby/customlobby/social/CustomLobbyChatController.lua @@ -0,0 +1,107 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The lobby chat **send pipeline** — the edit box's entry point. It decides command-vs-chat and writes +-- the chat model (CustomLobbyChatModel); it never touches the wire itself. Mirrors the in-game +-- ChatController.Send split (/lua/ui/game/chat/ChatController.lua): +-- +-- Send(text) +-- ├─ '/' prefix? → a chat command — handled locally, NOTHING goes on the wire +-- │ (the command registry lands in slice 2; for now a stub system line) +-- └─ plain text → append an optimistic `Pending` echo, then hand to the controller's RequestChat +-- +-- The optimistic echo is shown immediately; when the host's authoritative `ChatMessage` echo returns +-- (carrying the same id), CustomLobbyChatModel.Receive reconciles it to `Confirmed`. The host's own +-- send reconciles in the same frame (the broadcast doesn't loop back, so ProcessRequestChat displays +-- it locally) — same mechanism, see CustomLobbyController. +-- +-- The *wire* half (RequestChat / ProcessRequestChat / ProcessChatMessage) lives in CustomLobbyController +-- for consistency with every other Request*/Process* intent and the host-authority rule. + +local CustomLobbyChatModel = import("/lua/ui/lobby/customlobby/social/customlobbychatmodel.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") + +--- Appends a local-only system line (command feedback). Local to this peer; nothing is sent. +---@param text string +function AppendLocalSystem(text) + CustomLobbyChatModel.AppendSystem(CustomLobbyChatModel.GetSingleton(), text) +end + +--- Sends a chat line. A slash line is a command (handled locally, never broadcast); anything else is +--- echoed optimistically and routed to the host through the controller. +---@param text string +function Send(text) + if not text or text == '' then + return + end + + -- a slash line is a chat command, never broadcast. The command registry arrives in slice 2; for now + -- just surface a local notice so the split is visible and nothing goes on the wire. + if string.sub(text, 1, 1) == '/' then + AppendLocalSystem("Chat commands are coming soon.") + return + end + + -- drop an all-whitespace body + if not string.find(text, "%S") then + return + end + + local localModel = CustomLobbyLocalModel.GetSingleton() + local peerId = localModel.LocalPeerId() + local id = CustomLobbyChatModel.NextId(peerId) + + -- shown immediately as Pending; the host's echo reconciles it to Confirmed (see the model's Receive) + CustomLobbyChatModel.Append(CustomLobbyChatModel.GetSingleton(), { + Id = id, + SenderId = peerId, + SenderName = CustomLobbyController.FindNameForOwner(peerId), + Text = text, + Status = 'Pending', + Kind = 'chat', + Time = GetSystemTimeSeconds(), + }) + + CustomLobbyController.RequestChat(text, id) +end + +--- Sets the current send target. Reserved for whisper (slice 3); 'all' for now. +---@param recipient 'all' +function SetRecipient(recipient) + CustomLobbyChatModel.GetSingleton().Recipient:Set(recipient) +end + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-imports this module after a couple of frames (no state of its own). +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyChatModel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyChatModel.lua new file mode 100644 index 00000000000..de840e8d130 --- /dev/null +++ b/lua/ui/lobby/customlobby/social/CustomLobbyChatModel.lua @@ -0,0 +1,266 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The lobby **chat feed**: a per-peer, never-synced record of the chat lines this peer has seen. Like +-- CustomLobbyLog it is a reactive `Entries` ring buffer, but it carries chat (not diagnostics) and is +-- *written by the controller* — so, unlike the Log, it is a `ClassSimple : Destroyable` registered in +-- the session trash (CustomLobbySession), freed on `Teardown()` so a session's chat doesn't leak into +-- the persistent front-end state for the whole match. +-- +-- Chat is host-authoritative: the host relays every accepted message (CustomLobbyController's +-- `ProcessRequestChat` is the single filter chokepoint), and every peer accumulates what it receives. +-- A line you send is shown to you **immediately** as `Pending`, then reconciled to `Confirmed` when the +-- host's authoritative echo (carrying the same client-stamped `Id`) comes back — see `Receive`. That is +-- why an entry has a mutable `Status` and a stable `Id`. +-- +-- This is *not* one of the three lobby models (no launch/session/local game state) and never goes on +-- the wire — it is the chat equivalent of CustomLobbyLog. The wire half (the `RequestChat` / +-- `ChatMessage` messages and their handlers) lives in CustomLobbyController; the edit-box send pipeline +-- lives in CustomLobbyChatController. Both write this model through the helpers below. + +local Create = import("/lua/lazyvar.lua").Create +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") + +--- Most recent lines kept; older ones drop off the front (matches CustomLobbyLog's cap). +local MaxEntries = 200 + +--- Monotonic counter behind `NextId`. Module-local so it survives a model teardown (ids only have to +--- be unique within this peer's session; the peer-id prefix keeps them unique across peers). +local SendCounter = 0 + +--- The baseline for the relative clock (set when the feed is created). Entries store absolute +--- `GetSystemTimeSeconds()`; subtract this to render a `mm:ss` stamp like the traffic log. +---@type number | nil +local StartTime = nil + +---@alias UICustomLobbyChatStatus +---| 'Pending' # an outgoing line shown optimistically, awaiting the host's echo +---| 'Confirmed' # the host broadcast it (authoritative) +---| 'Rejected' # the host dropped/filtered it (shown greyed, for the sender only) + +---@alias UICustomLobbyChatKind +---| 'chat' # a player message +---| 'system' # a local-only notice (command feedback, later: join/leave) + +---@class UICustomLobbyChatEntry +---@field Id string | false # client-stamped id (peerId:counter); false for local system lines +---@field SenderId UILobbyPeerId | false # the sender's peer id; false for system lines +---@field SenderName string # display name (resolved authoritatively by the host) +---@field Text string +---@field Status UICustomLobbyChatStatus +---@field Kind UICustomLobbyChatKind +---@field Time number # GetSystemTimeSeconds() when this entry was added + +------------------------------------------------------------------------------- +--#region Reactive model + +-- The singleton, forward-declared above the class so `Destroy` captures it as an upvalue. Assigned in +-- `SetupSingleton`, cleared in `Destroy`. +---@type UICustomLobbyChatModel | nil +local Instance = nil + +--- Reactive per-peer chat feed — a `ClassSimple` implementing `Destroyable`, registered in the session +--- trash so one `CustomLobbySession.Teardown()` frees it. Written by the controller (the wire half) and +--- the chat controller (the send pipeline) through the free-function helpers below; views only read it. +---@class UICustomLobbyChatModel : Destroyable +---@field Trash TrashBag # owns the LazyVars (freed on Destroy) +---@field Entries LazyVar # the feed, capped to MaxEntries +---@field Recipient LazyVar # current send target ('all') — reserved for whisper (slice 3) +---@field TotalCount LazyVar # monotonic count of lines ever appended (survives the ring-buffer trim) — drives the unread badge +---@field SeenTotal LazyVar # `TotalCount` as of the last time the feed was viewed; unread = TotalCount - SeenTotal +---@field Destroyed boolean +local ChatModel = ClassSimple { + + ---@param self UICustomLobbyChatModel + __init = function(self) + self.Trash = TrashBag() + self.Entries = self.Trash:Add(Create({})) + self.Recipient = self.Trash:Add(Create('all')) + self.TotalCount = self.Trash:Add(Create(0)) + self.SeenTotal = self.Trash:Add(Create(0)) + self.Destroyed = false + end, + + --- `Destroyable`: frees the LazyVars and clears the module singleton, so the next access rebuilds a + --- fresh feed and re-registers it in the next session's trash. Idempotent. Called by the session + --- trash on `Teardown()`. + ---@param self UICustomLobbyChatModel + Destroy = function(self) + if self.Destroyed then + return + end + self.Destroyed = true + self.Trash:Destroy() + if Instance == self then + Instance = nil + end + end, +} + +--- Allocates a fresh chat-model singleton and registers it in the session trash. +---@return UICustomLobbyChatModel +function SetupSingleton() + Instance = ChatModel() + StartTime = GetSystemTimeSeconds() + CustomLobbySession.GetTrash():Add(Instance) + return Instance +end + +--- The relative-clock baseline (when this feed started). Subtract from an entry's `Time` to render a +--- `mm:ss` stamp, the same way the traffic log does. +---@return number +function GetStartTime() + return StartTime or GetSystemTimeSeconds() +end + +--- Returns the chat-model singleton, creating (and registering) it on first access — including after a +--- teardown, so it is reusable across lobby sessions. +---@return UICustomLobbyChatModel +function GetSingleton() + if not Instance then + SetupSingleton() + end + return Instance --[[@as UICustomLobbyChatModel]] +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Write helpers +-- +-- Entries is a LazyVar value, so a write builds a NEW array and `:Set`s it (mutating in place never +-- marks dependents dirty — see /lua/ui/CLAUDE.md § 2). + +--- A fresh, per-session-unique id for an outgoing line. The peer-id prefix keeps it unique across peers +--- so the host's echo reconciles only the sender's own optimistic entry. +---@param peerId UILobbyPeerId +---@return string +function NextId(peerId) + SendCounter = SendCounter + 1 + return tostring(peerId) .. ":" .. tostring(SendCounter) +end + +--- Appends one entry (copy-then-Set), trimming the oldest beyond the cap. Used for an optimistic +--- outgoing line (`Pending`) and for local system notices. +---@param model UICustomLobbyChatModel +---@param entry UICustomLobbyChatEntry +function Append(model, entry) + local entries = table.copy(model.Entries()) + table.insert(entries, entry) + while table.getn(entries) > MaxEntries do + table.remove(entries, 1) + end + -- bump the monotonic total BEFORE publishing Entries: a panel observing Entries refreshes + -- synchronously and marks the feed seen, so it must read the new total (else it lags by one) + model.TotalCount:Set(model.TotalCount() + 1) + model.Entries:Set(entries) +end + +--- Marks the feed seen up to the current total (resets the unread count to 0). The chat panel calls +--- this whenever it renders — it only exists while the Chat tab is open, so "rendered" means "viewed". +---@param model UICustomLobbyChatModel +function MarkSeen(model) + model.SeenTotal:Set(model.TotalCount()) +end + +--- Appends a system notice (a senderless `Confirmed` line — command feedback, join/leave, …). The one +--- place the system-entry shape is built, so the chat controller (local notices) and the lobby +--- controller (host-broadcast join/leave) stay in step. +---@param model UICustomLobbyChatModel +---@param text string +function AppendSystem(model, text) + Append(model, { + Id = false, + SenderId = false, + SenderName = "System", + Text = text, + Status = 'Confirmed', + Kind = 'system', + Time = GetSystemTimeSeconds(), + }) +end + +--- Applies an authoritative chat message from the host. If we already hold a `Pending` entry with the +--- same `Id` (our own optimistic echo), reconcile it to `Confirmed`; otherwise append a new `Confirmed` +--- entry (someone else's message, or our own when we are not the originator). Ids are unique per peer, +--- so this never reconciles the wrong line. +---@param model UICustomLobbyChatModel +---@param fields { Id: string, SenderId: UILobbyPeerId, SenderName: string, Text: string } +function Receive(model, fields) + local entries = model.Entries() + for index, entry in entries do + if entry.Id and entry.Id == fields.Id then + -- reconcile in a fresh array (replace the entry object, then Set so dependents go dirty) + local copy = table.copy(entries) + copy[index] = { + Id = entry.Id, + SenderId = entry.SenderId, + SenderName = fields.SenderName, + Text = entry.Text, + Status = 'Confirmed', + Kind = entry.Kind, + Time = entry.Time, + } + model.Entries:Set(copy) + return + end + end + Append(model, { + Id = fields.Id, + SenderId = fields.SenderId, + SenderName = fields.SenderName, + Text = fields.Text, + Status = 'Confirmed', + Kind = 'chat', + Time = GetSystemTimeSeconds(), + }) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: rebuilds the singleton and copies the current feed across so chat survives an edit. +--- (Maintained by hand — add a field to the model, add a copy line here too.) +---@param newModule any +function __moduleinfo.OnReload(newModule) + if Instance then + local handle = newModule.SetupSingleton() + handle.Entries:Set(Instance.Entries()) + handle.Recipient:Set(Instance.Recipient()) + handle.TotalCount:Set(Instance.TotalCount()) + handle.SeenTotal:Set(Instance.SeenTotal()) + end +end + +--- Hot-reload hook: re-imports this module after a couple of frames. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua new file mode 100644 index 00000000000..cee1ec71aa4 --- /dev/null +++ b/lua/ui/lobby/customlobby/social/CustomLobbyChatPanel.lua @@ -0,0 +1,415 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The Chat tab of the lobby's bottom-left tabbed panel: a scrollable feed of chat lines (fed by +-- CustomLobbyChatModel) over an edit box. Built to the same shape as the Logs tab +-- (../social/CustomLobbyLogsPanel.lua): a `Grid` of fixed-height rows + a vertical scrollbar that +-- sticks to the newest line unless you've scrolled up. The grid is built in `Initialize` because its +-- cell width needs the panel's concrete (post-mount) width. +-- +-- The edit box's Enter routes through CustomLobbyChatController.Send — which decides command-vs-chat, +-- echoes the line optimistically (Pending) and asks the host to broadcast it. The host's echo +-- reconciles the line to Confirmed (see CustomLobbyChatModel). A line's Status tints it: Pending dim, +-- Rejected greyed, system lines in a muted colour. +-- +-- Long messages **wrap**: each entry's "Name: text" label is word-wrapped to the message-column width +-- (BuildLines + WrapLine, via /lua/maui/text.lua WrapText measured by a hidden font-matched text), and +-- each wrapped line becomes one fixed-height grid row — the time stamp rides only the entry's first line. +-- +-- A bottom-left tab content component: created when its tab is selected and destroyed on switch (see +-- ../CustomLobbyTabs.lua), so it's the live panel for its whole lifetime — the model observer just +-- rebuilds it (Refresh is Ready-gated). + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") + +local Group = import("/lua/maui/group.lua").Group +local Edit = import("/lua/maui/edit.lua").Edit +local Grid = import("/lua/maui/grid.lua").Grid +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap + +-- flip to true to tint this panel's sections (the feed grid vs. the edit box) — see /lua/ui/CLAUDE.md § 7.1 +local Debug = false + +local CustomLobbyChatModel = import("/lua/ui/lobby/customlobby/social/customlobbychatmodel.lua") +local CustomLobbyChatController = import("/lua/ui/lobby/customlobby/social/customlobbychatcontroller.lua") + +local WrapText = import("/lua/maui/text.lua").WrapText +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- the standard scrollbar gutter (matches the Logs panel / ModSelect): the grid reserves these 32px on +-- its right by being that much narrower, and CreateVertScrollbarFor(grid) hangs the bar on grid.Right +-- (offset 0), so it lands in the strip. Reservation lives in the content width, NOT the scrollbar call. +local ScrollGap = 32 +local RowHeight = 18 +local Pad = 4 +local EditHeight = 20 +local EditGap = 6 +local RowFont = 13 + +-- the input field: a bordered, filled box at the bottom so it's clear where to type +local FieldHeight = 24 +local FieldInset = 5 -- horizontal text padding inside the field +local FieldColor = 'ff10151b' -- field fill +local FieldBorderColor = 'ff2b333d' -- field 1px border +local PromptColor = 'ff5a606a' -- the dim "type a message" placeholder + +-- the time column (a relative mm:ss stamp at the left of each row, like the Logs tab) +local TimeFont = 11 +local TimeWidth = 30 -- fits "mm:ss" +local TimeGap = 4 +local TimeColor = 'ff6a707a' +local TimeLeft = Pad +local NameLeft = TimeLeft + TimeWidth + TimeGap -- the message text starts after the time column + +-- line colours by status / kind +local ChatColor = 'ffc8ccd0' -- a confirmed chat line +local PendingColor = 'ff7e848c' -- our own line, awaiting the host's echo (dimmed) +local RejectedColor = 'ff8a5a52' -- the host dropped it (greyed-red, sender only) +local SystemColor = 'ff8a909a' -- a local system notice + +--- `mm:ss` from a seconds offset (Lua 5.0 — no `%`, use `math.mod`). Mirrors the Logs tab. +---@param seconds number +---@return string +local function FormatClock(seconds) + local whole = math.floor(seconds) + return string.format("%02d:%02d", math.floor(whole / 60), math.mod(whole, 60)) +end + +--- The rendered label + colour for an entry, by kind and status. The label is wrapped to the message +--- column width (see WrapLines), so the whole "Name: text" string flows across as many rows as it needs. +---@param entry UICustomLobbyChatEntry +---@return string label +---@return string color +local function RenderEntry(entry) + if entry.Kind == 'system' then + return entry.Text, SystemColor + end + local label = (entry.SenderName or "?") .. ": " .. entry.Text + if entry.Status == 'Pending' then + return label, PendingColor + elseif entry.Status == 'Rejected' then + return label, RejectedColor + end + return label, ChatColor +end + +---@class UICustomLobbyChatPanel : Group +---@field Trash TrashBag +---@field Ready boolean +---@field Grid Grid | false +---@field RowWidth number # unscaled row width (recomputed each Refresh from the panel width) +---@field Scrollbar Scrollbar | false +---@field EditFrame Bitmap # input-field border +---@field EditField Bitmap # input-field fill (inset 1px inside the border) +---@field EditBox Edit +---@field Prompt Text # dim "type a message" placeholder, shown while the box is empty +---@field Empty Text +---@field Measure Text # hidden, font-matched text used only to measure widths for wrapping +---@field EntriesObserver LazyVar +---@diagnostic disable-next-line +local CustomLobbyChatPanel = ClassUI(Bitmap) { + + ---@param self UICustomLobbyChatPanel + ---@param parent Control + __init = function(self, parent) + ---@diagnostic disable-next-line: param-type-mismatch + Bitmap.__init(self, parent) + self:SetSolidColor(Debug and '303080ff' or '00000000') + self:DisableHitTest() + + self.Trash = TrashBag() + self.Ready = false + self.Grid = false + self.Scrollbar = false + + self.Empty = UIUtil.CreateText(self, "No messages yet", 13, UIUtil.bodyFont) + self.Empty:SetColor('ff5a606a') + self.Empty:DisableHitTest() + self.Empty:Hide() + + -- a hidden, font-matched text whose :GetStringAdvance measures wrap widths (same font/size as a + -- row's message text, so the measurement matches what's rendered) + self.Measure = UIUtil.CreateText(self, "", RowFont, UIUtil.bodyFont) + self.Measure:DisableHitTest() + self.Measure:Hide() + + -- a bordered, filled input field at the bottom so it's clear where to type. Created before the + -- edit box (and prompt) so they render on top of it (sibling depth follows creation order). + self.EditFrame = Bitmap(self) + self.EditFrame:SetSolidColor(FieldBorderColor) + self.EditFrame:DisableHitTest() + self.EditField = Bitmap(self) + self.EditField:SetSolidColor(FieldColor) + self.EditField:DisableHitTest() + + self.EditBox = Edit(self) + -- `SetupEditStd` reads the control's bounds before `__post_init` runs; seed placeholder values + -- so it doesn't trip the default circular Left/Right/Width chain (see /lua/ui/CLAUDE.md § 1). + Layouter(self.EditBox):Left(0):Top(0):Width(200):Height(EditHeight):End() + UIUtil.SetupEditStd(self.EditBox, + UIUtil.fontColor, nil, UIUtil.highlightColor, + UIUtil.highlightColor, UIUtil.bodyFont, 14, 200) + self.EditBox:ShowBackground(false) + self.EditBox:SetText('') + + -- a dim placeholder shown while the box is empty (hidden as soon as there's text) + self.Prompt = UIUtil.CreateText(self, "Type a message…", RowFont, UIUtil.bodyFont) + self.Prompt:SetColor(PromptColor) + self.Prompt:DisableHitTest() + + self.EditBox.OnEnterPressed = function(_, text) + if text and text ~= '' then + CustomLobbyChatController.Send(text) + end + self.EditBox:SetText('') + self.Prompt:Show() + end + self.EditBox.OnTextChanged = function(_, newText) + if newText and newText ~= '' then + self.Prompt:Hide() + else + self.Prompt:Show() + end + end + + -- created/destroyed with its tab, so always the live panel while it exists — the observer just + -- rebuilds (Refresh is Ready-gated until Initialize builds the grid) + self.EntriesObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyChatModel.GetSingleton().Entries, function(entriesLazy) + entriesLazy() + self:Refresh() + end)) + end, + + ---@param self UICustomLobbyChatPanel + __post_init = function(self, parent) + Layouter(self):Fill(parent):End() + -- the input field (border + inset fill) pinned to the bottom + Layouter(self.EditFrame) + :AtLeftIn(self, Pad):AtRightIn(self, Pad) + :AtBottomIn(self, Pad):Height(FieldHeight) + :End() + Layouter(self.EditField) + :AtLeftIn(self.EditFrame, 1):AtRightIn(self.EditFrame, 1) + :AtTopIn(self.EditFrame, 1):AtBottomIn(self.EditFrame, 1) + :End() + + -- The `__init` placeholder pinned Left/Top/Width/Height (so SetupEditStd could read bounds). + -- Override every one here, and ResetWidth to drop the placeholder's concrete Width(200); + -- AtVerticalCenterIn re-sets Top (off the placeholder's 0), centring the box in the field. + Layouter(self.EditBox) + :AtLeftIn(self.EditField, FieldInset):AtRightIn(self.EditField, FieldInset):ResetWidth() + :Height(EditHeight):AtTopIn(self.EditField, Pad) + :End() + Layouter(self.Prompt):AtLeftIn(self.EditField, FieldInset):AtTopIn(self.EditField, Pad):End() + + Layouter(self.Empty):AtHorizontalCenterIn(self):AtTopIn(self, Pad):End() + Layouter(self.Measure):AtLeftTopIn(self, 0):End() -- hidden; only its font metrics are used + end, + + --- Builds the scrollable grid (its cell width needs the panel's concrete width) + scrollbar, and + --- does the first render. Three-phase init (/lua/ui/CLAUDE.md § 1). + ---@param self UICustomLobbyChatPanel + Initialize = function(self) + self.Ready = true + + -- Chat is the default tab, so this can run during the initial mount before the panel's width + -- has settled. So anchor the grid's edges **reactively** into the panel (left+right insets, + -- reserving the scrollbar gutter) rather than baking a fixed width from a possibly-stale + -- `self.Width()` — the scrollbar attaches to `grid.Right`, so a stale right edge throws the bar + -- way off. + -- + -- The Grid hides any item outside its visible window; the visible **column** count is + -- `floor(gridWidth / itemWidth)`, so if itemWidth ever exceeds the grid's content width the count + -- is 0 and EVERY row is hidden (rows present, nothing drawn). This is a single-column list, so the + -- column stride is irrelevant — pass itemWidth = 1 to guarantee ≥ 1 visible column regardless of + -- width/timing; the rows are sized from the real content width in Refresh. + self.RowWidth = self:ComputeRowWidth() + self.Grid = Grid(self, 1, RowHeight) + Layouter(self.Grid) + :AtLeftIn(self, Pad):AtRightIn(self, ScrollGap) + :AtTopIn(self, Pad):AnchorToTop(self.EditFrame, EditGap) + :End() + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Grid) + self.Scrollbar:Hide() -- shown by UpdateScrollbar only when the feed overflows + -- let the wheel scroll the grid when the cursor is over the rows, not just over the scrollbar + UIUtil.ForwardWheelToScroll(self.Grid, self.Grid) + + self:Refresh() + end, + + --- The grid's content width (panel width minus the left pad and the scrollbar gutter), unscaled, + --- for row sizing. Matches the grid's `:AtLeftIn(self, Pad):AtRightIn(self, ScrollGap)` insets so a + --- row spans exactly the visible feed. Clamped so an early/unsettled read can't go negative. + ---@param self UICustomLobbyChatPanel + ---@return number + ComputeRowWidth = function(self) + local scale = LayoutHelpers.GetPixelScaleFactor() + return math.max(32, math.floor(self.Width() / scale) - Pad - ScrollGap) + end, + + --- Rebuilds the list from the current entries, keeping the view pinned to the newest line unless + --- the user has scrolled up. + ---@param self UICustomLobbyChatPanel + Refresh = function(self) + if not self.Ready or not self.Grid then + return + end + + -- the panel only exists while the Chat tab is open, so a render means the feed is being viewed: + -- clear the unread count (the badge in CustomLobbyInterface reads TotalCount - SeenTotal) + CustomLobbyChatModel.MarkSeen(CustomLobbyChatModel.GetSingleton()) + + -- recompute the row width from the (now-settled) panel width so rows size correctly even if the + -- first Initialize ran before the layout settled + self.RowWidth = self:ComputeRowWidth() + + -- flatten entries into visual lines (one per wrapped line); a long message spans several rows + local lines = self:BuildLines(CustomLobbyChatModel.GetSingleton().Entries()) + local total = table.getn(lines) + + -- Bottom-align the feed (chat grows upward from the input box). A Grid renders rows top-down, so + -- a few lines would otherwise float at the top of the tall area, far from the edit box. Pin the + -- grid's bottom to the edit box (done in Initialize) and float its top so the grid is only as + -- tall as its rows — capped at the available height, beyond which it fills and scrolls. + local rowsHeight = total * LayoutHelpers.ScaleNumber(RowHeight) + local availableHeight = self.EditFrame.Top() - self.Top() - LayoutHelpers.ScaleNumber(Pad + EditGap) + LayoutHelpers.SetHeight(self.Grid, math.max(0, math.min(rowsHeight, availableHeight))) + + -- were we at (or near) the bottom before the rebuild? if so, stick to the bottom after + local _, rangeMax, _, visibleMax = self.Grid:GetScrollValues("Vert") + local stick = visibleMax >= rangeMax - 1 + + self.Grid:DeleteAndDestroyAll(true) + if total == 0 then + self.Empty:Show() + self.Grid:EndBatch() + self:UpdateScrollbar() + return + end + self.Empty:Hide() + + self.Grid:AppendCols(1, true) + self.Grid:AppendRows(total, true) + for index, line in lines do + self.Grid:SetItem(self:CreateRow(line), 1, index, true) + end + self.Grid:EndBatch() + + if stick then + self.Grid:ScrollSetTop("Vert", total) + end + self:UpdateScrollbar() + end, + + --- Flattens the entries into renderable lines: each entry's "Name: text" label is wrapped to the + --- message-column width, yielding one descriptor per wrapped line. The time stamp rides the first + --- line of each entry; continuation lines align under the message column with no stamp. + ---@param self UICustomLobbyChatPanel + ---@param entries UICustomLobbyChatEntry[] + ---@return { Stamp: string | false, Text: string, Color: string }[] + BuildLines = function(self, entries) + local width = LayoutHelpers.ScaleNumber(self.RowWidth - NameLeft - Pad) + local start = CustomLobbyChatModel.GetStartTime() + local lines = {} + for _, entry in entries do + local label, color = RenderEntry(entry) + local stamp = FormatClock(entry.Time - start) + local wrapped = self:WrapLine(label, width) + for i, text in wrapped do + table.insert(lines, { + Stamp = (i == 1) and stamp or false, + Text = text, + Color = color, + }) + end + end + return lines + end, + + --- Wraps `label` to `width` (actual pixels) via the hidden measuring text. Never returns empty. + ---@param self UICustomLobbyChatPanel + ---@param label string + ---@param width number + ---@return string[] + WrapLine = function(self, label, width) + if width < 1 then + return { label } + end + local measure = self.Measure + local lines = WrapText(label, width, function(chunk) return measure:GetStringAdvance(chunk) end) + if table.empty(lines) then + lines = { label } + end + return lines + end, + + --- Shows the scrollbar only when the list overflows. + ---@param self UICustomLobbyChatPanel + UpdateScrollbar = function(self) + if not self.Scrollbar then + return + end + if self.Grid and self.Grid:IsScrollable("Vert") then + self.Scrollbar:Show() + else + self.Scrollbar:Hide() + end + end, + + --- Builds one rendered row: the wrapped text in the message column, tinted by status/kind, with the + --- mm:ss stamp at the left only on an entry's first line (continuation lines have no stamp). Private. + ---@param self UICustomLobbyChatPanel + ---@param line { Stamp: string | false, Text: string, Color: string } + ---@return Group + CreateRow = function(self, line) + local row = Group(self.Grid, "CustomLobbyChatRow") + LayoutHelpers.SetDimensions(row, self.RowWidth, RowHeight) + + if line.Stamp then + local time = UIUtil.CreateText(row, line.Stamp, TimeFont, UIUtil.bodyFont) + time:SetColor(TimeColor) + time:DisableHitTest() + Layouter(time):AtLeftIn(row, TimeLeft):AtVerticalCenterIn(row):End() + end + + local text = UIUtil.CreateText(row, line.Text, RowFont, UIUtil.bodyFont) + text:SetColor(line.Color) + text:DisableHitTest() + Layouter(text):AtLeftIn(row, NameLeft):AtRightIn(row, Pad):AtVerticalCenterIn(row):End() + + return row + end, + + ---@param self UICustomLobbyChatPanel + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyChatPanel +Create = function(parent) + return CustomLobbyChatPanel(parent) +end diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyLogsPanel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyLogsPanel.lua new file mode 100644 index 00000000000..c7ac4662526 --- /dev/null +++ b/lua/ui/lobby/customlobby/social/CustomLobbyLogsPanel.lua @@ -0,0 +1,384 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The Logs tab of the lobby's bottom-left tabbed panel: a live view of this peer's network traffic +-- (every message it broadcasts / sends / receives), fed by `CustomLobbyLog`. A small **toolbar** +-- (a Copy icon-button that puts the whole log on the clipboard + a `Logs (N)` title) sits over a +-- **scrollable list** of rows. +-- +-- Each row is laid out in columns, **fixed-width columns first then the flexible name** so the names +-- line up: `time · kind · peer · ⚠ · name`. A malformed / unauthorised message (its Validate or +-- Accept returned a reason) tints the name and fills the ⚠ slot with a warning icon whose tooltip is +-- the reason. +-- +-- The list is a `Grid` (one column, a row Group per cell) + a vertical scrollbar — the same +-- scrollable-rows pattern the config column's option/mod panels use; the Grid hides off-window rows +-- so it scrolls without needing clip-to-bounds (MAUI has none). It sticks to the newest entry unless +-- you've scrolled up. The Grid is built in `Initialize` because its cell width needs the panel's +-- concrete (post-mount) width. +-- +-- A bottom-left tab content component: created when its tab is selected and destroyed on switch (see +-- ../CustomLobbyTabs.lua), so it's the live panel for its whole lifetime — the model observer just +-- rebuilds it (Refresh is Ready-gated). + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Grid = import("/lua/maui/grid.lua").Grid + +local CustomLobbyLog = import("/lua/ui/lobby/customlobby/customlobbylog.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +local ToolbarHeight = 26 -- the Copy button + title band at the top +-- the standard scrollbar gutter (matches ModSelect): the grid reserves these 32px on its right by +-- being that much narrower, and CreateVertScrollbarFor(grid) hangs the bar on grid.Right (offset 0), +-- so it lands in the strip. Reservation lives in the content width, NOT the scrollbar call — never +-- also pass -32 to CreateVertScrollbarFor or it double-reserves and the bar overlaps the text. +local ScrollGap = 32 +local RowHeight = 21 +local Pad = 4 +local ColGap = 3 + +-- the Copy icon-button (idle / hover background + a centred icon), mirroring the config column's +-- PreviewTool / the slot tool strip +local ButtonSize = 20 +local ButtonIdle = 'ff141a20' +local ButtonHover = 'ff1f262e' +local ButtonIconInset = 4 +local ButtonIconColor = 'ffc8ccd0' -- TODO: temporary 1-colour placeholder until a copy/clipboard icon exists +local Debug = true + +-- font sizes per column (the message type is the one you read, so it's the largest) +local TimeFont = 12 +local KindFont = 16 +local PeerFont = 14 +local NameFont = 14 +local TitleFont = 14 + +-- fixed column widths (left → right); the name column is flexible and fills whatever is left +local TimeWidth = 30 +local KindWidth = 24 +local PeerWidth = 54 +local IconSize = 13 +local NameMax = 40 +local PeerMax = 16 + +-- left edges of each column, accumulated so every row's name starts at the same x (aligned) +local TimeLeft = Pad +local KindLeft = TimeLeft + TimeWidth + ColGap +local PeerLeft = KindLeft + KindWidth + ColGap +local WarnLeft = PeerLeft + PeerWidth + ColGap +local NameLeft = WarnLeft + IconSize + ColGap + +local TimeColor = 'ff6a707a' +local OutColor = 'ff7aa6c8' -- outgoing kind glyph (broadcast / send) +local RecvColor = 'ff8ac88a' -- incoming kind glyph (recv) +local NameColor = 'ffc8ccd0' +local ErrorColor = 'ffd0824c' -- name tint when the message was bad + +local WarnIcon = '/MODS/mod_type_warning.dds' + +-- the kind glyph (its own column, so the name aligns regardless of direction) +local KindGlyph = { + broadcast = "→→", + send = "→", + recv = "←", +} + +-- plain-text kind label for the clipboard copy (glyphs don't paste usefully) +local KindText = { + broadcast = "BROADCAST", + send = "SEND", + recv = "RECV", +} + +--- `mm:ss` from a seconds offset (Lua 5.0 — no `%`, use `math.mod`). +---@param seconds number +---@return string +local function FormatClock(seconds) + local whole = math.floor(seconds) + return string.format("%02d:%02d", math.floor(whole / 60), math.mod(whole, 60)) +end + +--- Truncates `text` to `maxChars`, appending "…" when it had to cut. +---@param text string +---@param maxChars number +---@return string +local function Truncate(text, maxChars) + text = text or "" + if string.len(text) > maxChars then + local limit = math.max(1, math.floor(maxChars)) + return string.sub(text, 1, limit - 1) .. "…" + end + return text +end + +--- One entry as a plain-text clipboard line: `mm:ss RECV Type <- peer [ERROR: reason]`. +---@param entry UICustomLobbyLogEntry +---@return string +local function EntryToText(entry) + local line = FormatClock(entry.Time) .. " " .. (KindText[entry.Kind] or "?") .. " " .. entry.Type + if entry.Peer then + line = line .. (entry.Kind == 'recv' and " <- " or " -> ") .. tostring(entry.Peer) + end + if entry.Error then + line = line .. " [ERROR: " .. entry.Error .. "]" + end + return line +end + +---@class UICustomLobbyLogsPanel : Bitmap +---@field Trash TrashBag +---@field Ready boolean +---@field CopyButton Group # icon-button (Bg + Icon); copies the log to the clipboard +---@field CopyBg Bitmap +---@field CopyIcon Bitmap +---@field Title Text +---@field Grid Grid | false +---@field RowWidth number # unscaled cell width (set in Initialize from the panel width) +---@field Scrollbar Scrollbar | false +---@field Empty Text +---@field EntriesObserver LazyVar +local CustomLobbyLogsPanel = ClassUI(Bitmap) { + + ---@param self UICustomLobbyLogsPanel + ---@param parent Control + __init = function(self, parent) + Bitmap.__init(self, parent) + self:SetSolidColor(Debug and '303080ff' or '00000000') + self:DisableHitTest() + + self.Trash = TrashBag() + self.Ready = false + self.Grid = false + self.Scrollbar = false + + --#region toolbar: Copy icon-button + title + self.CopyButton = Group(self, "CustomLobbyLogCopy") + self.CopyBg = Bitmap(self.CopyButton) + self.CopyBg:SetSolidColor(ButtonIdle) + self.CopyIcon = Bitmap(self.CopyButton) + self.CopyIcon:SetSolidColor(ButtonIconColor) + self.CopyIcon:DisableHitTest() + self.CopyBg.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + self:CopyAll() + return true + elseif event.Type == 'MouseEnter' then + self.CopyBg:SetSolidColor(ButtonHover) + return true + elseif event.Type == 'MouseExit' then + self.CopyBg:SetSolidColor(ButtonIdle) + return true + end + return false + end + Tooltip.AddControlTooltipManual(self.CopyBg, "Copy log", + "Copy the whole traffic log to the clipboard (handy when something looks wrong).") + + self.Title = UIUtil.CreateText(self, "Logs (0)", TitleFont, UIUtil.titleFont) + self.Title:DisableHitTest() + --#endregion + + self.Empty = UIUtil.CreateText(self, "No messages yet", 13, UIUtil.bodyFont) + self.Empty:SetColor('ff5a606a') + self.Empty:DisableHitTest() + self.Empty:Hide() + + -- created/destroyed with its tab, so always the live panel while it exists — the observer + -- just rebuilds (Refresh is Ready-gated until Initialize builds the grid) + self.EntriesObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyLog.GetSingleton().Entries, function(entriesLazy) + entriesLazy() + self:Refresh() + end)) + end, + + ---@param self UICustomLobbyLogsPanel + __post_init = function(self, parent) + Layouter(self):Fill(parent):End() + Layouter(self.CopyButton):AtLeftIn(self, Pad):AtTopIn(self, Pad):Width(ButtonSize):Height(ButtonSize):End() + Layouter(self.CopyBg):Fill(self.CopyButton):End() + Layouter(self.CopyIcon) + :AtCenterIn(self.CopyButton) + :Width(ButtonSize - 2 * ButtonIconInset):Height(ButtonSize - 2 * ButtonIconInset) + :End() + Layouter(self.Title):AnchorToRight(self.CopyButton, 8):AtVerticalCenterIn(self.CopyButton):End() + Layouter(self.Empty):AtHorizontalCenterIn(self):AtTopIn(self, ToolbarHeight + 8):End() + end, + + --- The grid's content width (panel width minus the left pad and the scrollbar gutter), unscaled, + --- for row sizing. Matches the grid's `:AtLeftIn(self, Pad):AtRightIn(self, ScrollGap)` insets + --- so a row spans exactly the visible feed. Clamped so an early/unsettled read can't go negative. + ---@param self UICustomLobbyLogsPanel + ---@return number + ComputeRowWidth = function(self) + local scale = LayoutHelpers.GetPixelScaleFactor() + return math.max(1, math.floor(self.Width() / scale) - Pad - ScrollGap) + end, + + --- Builds the scrollable grid (its cell width needs the panel's concrete width) + scrollbar, and + --- does the first render. Three-phase init (/lua/ui/CLAUDE.md § 1). + ---@param self UICustomLobbyLogsPanel + Initialize = function(self) + self.Ready = true + + -- The Grid hides any item outside its visible window; the visible column count is + -- floor(gridWidth / itemWidth), so if itemWidth exceeds the visible content width every row is + -- hidden. Use a dummy item width of 1 and size each row independently from the real width. + self.RowWidth = self:ComputeRowWidth() + self.Grid = Grid(self, 1, RowHeight) + Layouter(self.Grid) + :AtLeftIn(self, Pad):AtRightIn(self, ScrollGap) + :AnchorToBottom(self.CopyButton, 6):AtBottomIn(self, Pad) + :End() + self.Scrollbar = UIUtil.CreateVertScrollbarFor(self.Grid) + -- let the wheel scroll the grid when the cursor is over the rows, not just over the scrollbar + UIUtil.ForwardWheelToScroll(self.Grid, self.Grid) + + self:Refresh() + end, + + --- Rebuilds the list from the current entries, keeping the view pinned to the newest entry + --- unless the user has scrolled up. + ---@param self UICustomLobbyLogsPanel + Refresh = function(self) + if not self.Ready or not self.Grid then + return + end + + -- Recompute after mount so rows size correctly even if Initialize ran before layout settled. + self.RowWidth = self:ComputeRowWidth() + + local entries = CustomLobbyLog.GetSingleton().Entries() + local total = table.getn(entries) + self.Title:SetText("Logs (" .. total .. ")") + + -- were we at (or near) the bottom before the rebuild? if so, stick to the bottom after + local _, rangeMax, _, visibleMax = self.Grid:GetScrollValues("Vert") + local stick = visibleMax >= rangeMax - 1 + + self.Grid:DeleteAndDestroyAll(true) + if total == 0 then + self.Empty:Show() + self.Grid:EndBatch() + self:UpdateScrollbar() + return + end + self.Empty:Hide() + + self.Grid:AppendCols(1, true) + self.Grid:AppendRows(total, true) + for index, entry in entries do + self.Grid:SetItem(self:CreateRow(entry), 1, index, true) + end + self.Grid:EndBatch() + + if stick then + self.Grid:ScrollSetTop("Vert", total) + end + self:UpdateScrollbar() + end, + + --- Shows the scrollbar only when the list overflows. + ---@param self UICustomLobbyLogsPanel + UpdateScrollbar = function(self) + if not self.Scrollbar then + return + end + if self.Grid and self.Grid:IsScrollable("Vert") then + self.Scrollbar:Show() + else + self.Scrollbar:Hide() + end + end, + + --- Copies the whole log to the clipboard as plain text (the engine's global `CopyToClipboard`). + ---@param self UICustomLobbyLogsPanel + CopyAll = function(self) + local entries = CustomLobbyLog.GetSingleton().Entries() + local lines = {} + for index, entry in entries do + lines[index] = EntryToText(entry) + end + CopyToClipboard(table.concat(lines, "\n")) + end, + + --- Builds one log row: time · kind · peer · (warn) · name. The peer is kept as separate network + --- metadata so the description stays to the right of all transport details. The warn icon only + --- appears for a bad message; its column slot is still reserved so names stay aligned. Private. + ---@param self UICustomLobbyLogsPanel + ---@param entry UICustomLobbyLogEntry + ---@return Group + CreateRow = function(self, entry) + local row = Group(self.Grid, "CustomLobbyLogRow") + LayoutHelpers.SetDimensions(row, self.RowWidth, RowHeight) + + local time = UIUtil.CreateText(row, FormatClock(entry.Time), TimeFont, UIUtil.bodyFont) + time:SetColor(TimeColor) + time:DisableHitTest() + Layouter(time):AtLeftIn(row, TimeLeft):AtVerticalCenterIn(row):End() + + local kind = UIUtil.CreateText(row, KindGlyph[entry.Kind] or "·", KindFont, UIUtil.titleFont) + kind:SetColor(entry.Kind == 'recv' and RecvColor or OutColor) + kind:DisableHitTest() + Layouter(kind):AtLeftIn(row, KindLeft):AtVerticalCenterIn(row):End() + + local peer = UIUtil.CreateText(row, Truncate(entry.Peer and tostring(entry.Peer) or "", PeerMax), PeerFont, UIUtil.bodyFont) + peer:SetColor(TimeColor) + peer:DisableHitTest() + Layouter(peer):AtLeftIn(row, PeerLeft):AtVerticalCenterIn(row):Width(PeerWidth):End() + + local name = UIUtil.CreateText(row, Truncate(entry.Type, NameMax), NameFont, UIUtil.bodyFont) + name:SetColor(entry.Error and ErrorColor or NameColor) + name:DisableHitTest() + Layouter(name):AtLeftIn(row, NameLeft):AtRightIn(row, Pad):AtVerticalCenterIn(row):End() + + if entry.Error then + local warn = UIUtil.CreateBitmap(row, WarnIcon) + warn:DisableHitTest() + Layouter(warn):AtLeftIn(row, WarnLeft):AtVerticalCenterIn(row):Width(IconSize):Height(IconSize):End() + -- a hit-test-enabled overlay so the tooltip (the failure reason) shows on hover + local hover = Group(row, "CustomLobbyLogWarnHover") + Layouter(hover):AtLeftIn(row, WarnLeft):AtVerticalCenterIn(row):Width(IconSize):Height(IconSize):End() + Tooltip.AddControlTooltipManual(hover, "Invalid message", entry.Error) + end + + return row + end, + + ---@param self UICustomLobbyLogsPanel + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyLogsPanel +Create = function(parent) + return CustomLobbyLogsPanel(parent) +end diff --git a/lua/ui/lobby/customlobby/social/CustomLobbyObserversPanel.lua b/lua/ui/lobby/customlobby/social/CustomLobbyObserversPanel.lua new file mode 100644 index 00000000000..fedfb984cc0 --- /dev/null +++ b/lua/ui/lobby/customlobby/social/CustomLobbyObserversPanel.lua @@ -0,0 +1,133 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The Observers tab of the lobby's bottom-left tabbed panel: the observer list plus a "Become +-- observer" button. Everyone may drop to observers; the move is host-authoritative — a client's +-- click asks the host through the `RequestMoveToObserver` intent. +-- +-- A bottom-left tab content component: created when its tab is selected and destroyed on switch +-- (see ../CustomLobbyTabs.lua). + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive + +local Layouter = LayoutHelpers.ReusedLayoutFor +local Debug = true + +--- The local player's slot (the one this peer owns), or nil if they're an observer / unseated. +---@return number | nil +local function FindLocalSlot() + local launch = CustomLobbyLaunchModel.GetSingleton() + local localId = CustomLobbyLocalModel.GetSingleton().LocalPeerId() + for slot = 1, CustomLobbyLaunchModel.MaxSlots do + local player = launch.Players[slot]() + if player and player.OwnerID == localId then + return slot + end + end + return nil +end + +---@class UICustomLobbyObserversPanel : Bitmap +---@field ObserveButton Button +---@field Header Text +---@field Names Text +---@field Trash TrashBag +---@field ObserversObserver LazyVar +local CustomLobbyObserversPanel = ClassUI(Bitmap) { + + ---@param self UICustomLobbyObserversPanel + ---@param parent Control + __init = function(self, parent) + Bitmap.__init(self, parent) + self:SetSolidColor(Debug and '303080ff' or '00000000') + self:DisableHitTest() + + self.Trash = TrashBag() + + self.Header = UIUtil.CreateText(self, "Observers (0)", 14, UIUtil.titleFont) + self.Names = UIUtil.CreateText(self, "—", 12, UIUtil.bodyFont) + self.Names:SetColor('ff9aa0a8') + + self.ObserveButton = UIUtil.CreateButtonWithDropshadow(self, '/BUTTON/medium/', "Become observer") + self.ObserveButton.OnClick = function(button, modifiers) + local slot = FindLocalSlot() + if slot then + CustomLobbyController.RequestMoveToObserver(slot) + end + end + Tooltip.AddControlTooltipManual(self.ObserveButton, "Become observer", "Leave your slot and watch as an observer.") + + local model = CustomLobbyLaunchModel.GetSingleton() + self.ObserversObserver = self.Trash:Add( + LazyVarDerive(model.Observers, function(observersLazy) + self:OnObserversChanged(observersLazy()) + end)) + end, + + ---@param self UICustomLobbyObserversPanel + __post_init = function(self, parent) + Layouter(self):Fill(parent):End() + Layouter(self.Header):AtLeftTopIn(self):End() + Layouter(self.Names):AtLeftIn(self):AnchorToBottom(self.Header, 4):End() + Layouter(self.ObserveButton):AtHorizontalCenterIn(self):AnchorToBottom(self.Names, 8):End() + end, + + --- Renders the observer count + names. + ---@param self UICustomLobbyObserversPanel + ---@param observers UICustomLobbyPlayer[] + OnObserversChanged = function(self, observers) + local count = table.getn(observers) + self.Header:SetText("Observers (" .. count .. ")") + + if count == 0 then + self.Names:SetText("—") + return + end + + local names = {} + for i = 1, count do + names[i] = observers[i].PlayerName or "?" + end + self.Names:SetText(table.concat(names, ", ")) + end, + + ---@param self UICustomLobbyObserversPanel + OnDestroy = function(self) + self.Trash:Destroy() + end, +} + +---@param parent Control +---@return UICustomLobbyObserversPanel +Create = function(parent) + return CustomLobbyObserversPanel(parent) +end diff --git a/lua/ui/lobby/customlobby/social/chat-design.md b/lua/ui/lobby/customlobby/social/chat-design.md new file mode 100644 index 00000000000..ccd72410a0d --- /dev/null +++ b/lua/ui/lobby/customlobby/social/chat-design.md @@ -0,0 +1,99 @@ +# Lobby chat — design + +The custom lobby's chat. The in-game chat ([`/lua/ui/game/chat/`](/lua/ui/game/chat/)) can't be +reused — it transfers over the engine's `SessionSendChatMessage` across *armies*, and a lobby has +*peers/slots*, not armies. So chat is built on the lobby's own host-authoritative message layer. + +## Two principles + +1. **The host is authoritative for broadcasting chat.** A client never broadcasts directly; it *asks* + the host, and the host decides whether to relay. That gives one chokepoint + ([`CustomLobbyController.ProcessRequestChat`](/lua/ui/lobby/customlobby/CustomLobbyController.lua)) + where filtering — mute, rate-limit, slow-mode, profanity — will live, without touching anything else. +2. **A line you send is shown to you immediately, then its state can change.** The sender echoes the + line locally as `Pending` the instant they hit Enter; when the host's authoritative copy comes + back, it reconciles to `Confirmed` (or could be `Rejected`/dropped). This is why a chat entry has a + mutable `Status` and a stable, client-stamped `Id`. + +## Chat is a per-peer feed, not synced state + +What is host-authoritative is the *decision to broadcast a message* — a transient wire message — not +stored shared state. Each peer just accumulates the lines it receives. So chat is **not** a fourth +synced model; it is the chat equivalent of [`CustomLobbyLog`](/lua/ui/lobby/customlobby/CustomLobbyLog.lua): +a per-peer reactive `Entries` ring buffer. Unlike the Log it is *written by the controller*, so it is +a `ClassSimple : Destroyable` registered in the session trash (freed on `Teardown()`), like the +[derived models](/lua/ui/lobby/customlobby/models/derived/CLAUDE.md). + +## The flow + +``` +edit box Enter + └─ CustomLobbyChatController.Send(text) [input path: command vs chat] + ├─ '/' line → handled locally, NOTHING on the wire (registry = slice 2; stub for now) + └─ plain → ChatModel.Append(Pending) + Controller.RequestChat(text, id) + +CustomLobbyController.RequestChat(text, id) [the wire half] + ├─ host: ProcessRequestChat(self, {SenderID=me, Text, Id}) (short-circuit) + └─ client: SendData(host, { Type='RequestChat', Text, Id }) + +CustomLobbyController.ProcessRequestChat(instance, data) ★ the single chat chokepoint + ├─ (future) mute / rate-limit / slow-mode — return early to drop + ├─ SenderName = FindNameForOwner(data.SenderID) (host resolves it; clients can't spoof) + ├─ instance:BroadcastData({ Type='ChatMessage', Id, SenderID, SenderName, Text }) + └─ ProcessChatMessage(instance, message) (broadcast doesn't loop back → show locally) + +CustomLobbyController.ProcessChatMessage(instance, data) [every peer] + └─ CustomLobbyChatModel.Receive{ Id, SenderId, SenderName, Text } + ├─ a held Pending line with this Id → reconcile to Confirmed (our own echo) + └─ otherwise → append a new Confirmed line (someone else's) +``` + +Ids are `localPeerId .. ':' .. counter`, unique across peers, so the echo reconciles only the +originator's optimistic line. The host's own line reconciles in the same frame (its broadcast doesn't +loop back, so `ProcessRequestChat` displays it directly); a client's line takes a real round-trip — +the same mechanism either way. + +## Files (slice 1) + +| File | Role | +|---|---| +| [CustomLobbyChatModel.lua](CustomLobbyChatModel.lua) | per-peer reactive `Entries` ring buffer; `Append` (optimistic / system) + `Receive` (reconcile-or-append) + `NextId`. | +| [CustomLobbyChatController.lua](CustomLobbyChatController.lua) | the send pipeline: `Send` (command-vs-chat), `AppendLocalSystem`, `SetRecipient`. | +| [CustomLobbyChatPanel.lua](CustomLobbyChatPanel.lua) | the Chat tab: a scrollable feed (built to the [Logs-tab](CustomLobbyLogsPanel.lua) shape) over an edit box. | +| [`../CustomLobbyMessages.lua`](/lua/ui/lobby/customlobby/CustomLobbyMessages.lua) | `RequestChat` (Accept: any peer) + `ChatMessage` (Accept: from host) + `SystemNotice` (Accept: from host — join/leave). | +| [`../CustomLobbyController.lua`](/lua/ui/lobby/customlobby/CustomLobbyController.lua) | `RequestChat` / `ProcessRequestChat` (chokepoint) / `ProcessChatMessage` + `FindNameForOwner`; `BroadcastSystemNotice` / `ProcessSystemNotice` (join/leave, hooked in `ProcessAddPlayer` / `OnPeerDisconnected`). | + +## Roadmap (later slices) + +- **Slice 2 — commands.** A `social/commands/` registry mirroring + [`/lua/ui/game/chat/commands/`](/lua/ui/game/chat/commands/) (own `Commands` table, own resolvers + `Slot`/`Peer`, own dispatch `ctx`). Built-ins call existing controller intents (`/take`, `/close`, + `/help`). **Host-gating goes in `Accept`, not `ShouldRegister`** — `IsHost` flips only after the + connection handshake, so it must be evaluated per-invocation. `Send`'s `/` branch dispatches here. +- **Slice 3 — whisper.** `RequestChat` carries a `Recipient`; `ProcessRequestChat` `SendData`s to + sender+target instead of `BroadcastData`. Same chokepoint. +- **Slice 4 — system notices (done).** The host emits a `SystemNotice` (a senderless system line) at + its authoritative change sites; `BroadcastSystemNotice` broadcasts it **and** shows it on the host too + (the broadcast doesn't loop back). Chosen over the originally-sketched "each peer diffs the roster + locally" because (a) the host doesn't run `ProcessSetPlayers` on itself, so a local diff would skip the + host, and (b) a joining peer would otherwise spam "X joined" for the whole existing roster on its first + snapshot. Host-broadcast is one symmetric path with no diff and no baseline-flag. + - Wired: **join** (`ProcessAddPlayer`), **leave** (`OnPeerDisconnected`), **kick** (`RequestEject` adds + "Host removed X."; a human kick also produces the disconnect's "left" line — two lines is fine), + **map / mods / options / restrictions** changes — each `RequestSet*` intent compares the incoming + value against the current launch-model value (the same inputs the derived models dedup on: file / + mod set / option values / order-independent restriction keys) and announces **only on a real + change**, so a no-op apply (open the dialog, click OK, nothing changed) is silent. They fire + once per action — *not* on snapshot rebroadcasts, so no spam), **seat swap / move** (`SwapSlots`, + covering host-initiated and any client-requested swap), **move-to-observers** + (`RequestMoveToObserver`), and **auto-balance applied** (`RequestApplyBalance`). + - Not yet emitted (deliberately, as noise-prone): ready toggles, slot takes, faction/colour picks. + Per-option diffs ("changed Unit Cap to 1000") would read better than the generic "changed the game + options" but need an old-vs-new compare in the intent. +- **Refinements.** Multi-line wrapping is **done** — `CustomLobbyChatPanel.BuildLines`/`WrapLine` wrap + each entry's label to the message-column width (via [`/lua/maui/text.lua`](/lua/maui/text.lua) + `WrapText`, measured by a hidden font-matched text) and emit one fixed-height grid row per wrapped line. + Unread badge is **done** — the model keeps a monotonic `TotalCount` (bumped per append, survives the + ring-buffer trim) and a `SeenTotal` marker; the panel calls `MarkSeen` whenever it renders (it only + exists while the Chat tab is open), and the tab badge shows `TotalCount - SeenTotal`. +``` diff --git a/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitCatalog.lua b/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitCatalog.lua new file mode 100644 index 00000000000..54ba50890b7 --- /dev/null +++ b/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitCatalog.lua @@ -0,0 +1,178 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The unit catalog: the custom lobby's per-faction unit blueprints for the restriction dialog — +-- the unit-side counterpart to CustomLobbyModCatalog. *Reference data*, NOT a model: it is built +-- from the player's own game files + the lobby's sim mods and never goes on the wire (only the +-- host's restriction *choice* — the launch model's `Restrictions` — syncs). +-- +-- It wraps the heavy `UnitsAnalyzer` blueprint pipeline (the same one the legacy UnitsManager uses) +-- behind a small reactive surface: `EnsureLoaded(activeMods)` kicks off the async fetch, `Progress` +-- / `ProgressText` stream the load progress, and `Factions` fires once with the grouped result — +-- a list of `{ Name, Order, Blueprints, Units }` per faction, where `Units` is the type grouping +-- (ACU / SCU / AIR / LAND / NAVAL / CONSTRUCT / ECONOMIC / SUPPORT / CIVILIAN / DEFENSES) from +-- `UnitsAnalyzer.GetUnitsGroups`. The dialog renders one faction tab per entry. + +local Create = import("/lua/lazyvar.lua").Create +local UnitsAnalyzer = import("/lua/ui/lobby/unitsanalyzer.lua") + +-- left-to-right tab order; an unknown faction is appended after these +local FactionOrder = { SERAPHIM = 1, UEF = 2, CYBRAN = 3, AEON = 4, NOMADS = 5 } + +---@class UICustomLobbyFaction +---@field Name string +---@field Order number +---@field Blueprints table[] # the faction's blueprints (list) +---@field Units table # type group -> { [unitId] = blueprint } + +--- The grouped factions, fired once when the load completes (empty until then). +---@type LazyVar +local Factions = Create({}) + +--- Load progress in [0, 1] and the current task label, streamed while loading. +local Progress = Create(0) +local ProgressText = Create("") + +local Loading = false +local Loaded = false + +------------------------------------------------------------------------------- +--#region Build + +--- Groups the analyzer's flat blueprint list into per-faction entries (each with its type groups), +--- sorted by `FactionOrder`, and publishes them on the `Factions` LazyVar. +local function PublishFactions() + local blueprints = UnitsAnalyzer.GetBlueprintsList() + local byName = {} + for _, bp in blueprints.All do + if bp.Faction then + local faction = byName[bp.Faction] + if not faction then + faction = { Name = bp.Faction, Blueprints = {}, Units = {} } + byName[bp.Faction] = faction + end + table.insert(faction.Blueprints, bp) + end + end + + local list = {} + for name, faction in byName do + UnitsAnalyzer.GetUnitsGroups(faction.Blueprints, faction) + faction.Order = FactionOrder[name] or (table.getsize(FactionOrder) + table.getn(list) + 1) + table.insert(list, faction) + end + table.sort(list, function(a, b) return a.Order < b.Order end) + + Factions:Set(list) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Public API + +--- Kicks off the async blueprint fetch for the given sim mods if it hasn't run yet. Idempotent — +--- safe to call on every open; once loaded it's a no-op and the cached factions stay. +---@param activeMods table[] # resolved sim-mod list (e.g. `Mods.GetGameMods(launch.GameMods())`) +function EnsureLoaded(activeMods) + if Loading or Loaded then + return + end + Loading = true + Progress:Set(0) + ProgressText:Set("Loading blueprints…") + Factions:Set({}) + + local notifier = import("/lua/ui/lobby/tasknotifier.lua").Create() + notifier:Reset() + notifier.OnProgressCallback = function(task) + Progress:Set(notifier.totalProgress or 0) + if task and task.name then + ProgressText:Set(task.name .. " …") + end + end + notifier.OnCompleteCallback = function() + PublishFactions() + Progress:Set(1) + ProgressText:Set("") + Loaded = true + Loading = false + end + + UnitsAnalyzer.FetchBlueprints(activeMods or {}, false, notifier) +end + +--- The factions LazyVar — subscribe (via `Derive`) to react when the load completes. +---@return LazyVar +function GetFactionsVar() + return Factions +end + +--- The current (possibly empty, until loaded) list of factions. +---@return UICustomLobbyFaction[] +function GetFactions() + return Factions() +end + +--- The load-progress LazyVar in [0, 1]. +---@return LazyVar +function GetProgressVar() + return Progress +end + +--- The current load-task label LazyVar. +---@return LazyVar +function GetProgressTextVar() + return ProgressText +end + +--- Whether the blueprints have finished loading. +---@return boolean +function IsLoaded() + return Loaded +end + +--- Drops the cache so the next `EnsureLoaded` re-fetches (e.g. the sim mods changed). +function Refresh() + UnitsAnalyzer.StopBlueprints() + Loaded = false + Loading = false + Factions:Set({}) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +--- Hot-reload hook: re-imports this module after a couple of frames. The factions rebuild on the +--- next access, so dropping the cache is harmless. +function __moduleinfo.OnDirty() + ForkThread( + function() + WaitFrames(2) + import(__moduleinfo.name) + end + ) +end + +--#endregion diff --git a/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitSelect.lua b/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitSelect.lua new file mode 100644 index 00000000000..6a1c0301d2b --- /dev/null +++ b/lua/ui/lobby/customlobby/unitselect/CustomLobbyUnitSelect.lua @@ -0,0 +1,968 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- The unit-restriction dialog: the custom-lobby rebuild of the legacy UnitsManager, laid out as +-- * a shared **presets strip** on top — the faction-agnostic restriction presets (No T1, No +-- Nukes, …), which restrict by category for everyone; +-- * a row of **faction tabs** (Seraphim / UEF / Cybran / Aeon / …, from the loaded blueprints); +-- * the active faction's **unit grid** below — every unit as an icon you can individually +-- restrict (grouped by type: land / air / naval / construction / economy / support / defenses). +-- +-- It is a transient `Popup`, NOT a model component: it owns a *working selection* (a set of keys) +-- and on OK hands the **array of keys** to `onConfirm`. A key is either a preset key +-- (UnitsRestrictions) or a raw unit blueprint id — the sim expands both at launch (a non-preset key +-- is treated as a custom category/id expression — see simInit.lua). `Open` routes the result through +-- the host-authoritative `RequestSetRestrictions` intent (synced via the launch model's +-- `Restrictions`). `editable = false` → read-only (tiles can't toggle; OK / Clear hidden). +-- +-- The unit blueprints come from `CustomLobbyUnitCatalog` (reference data, streamed in by +-- `UnitsAnalyzer` for the lobby's sim mods); a progress label shows while they load. + +local UIUtil = import("/lua/ui/uiutil.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") +local Mods = import("/lua/mods.lua") + +local Group = import("/lua/maui/group.lua").Group +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Grid = import("/lua/maui/grid.lua").Grid +local Popup = import("/lua/ui/controls/popups/popup.lua").Popup + +local UnitsRestrictions = import("/lua/ui/lobby/unitsrestrictions.lua") +local UnitsAnalyzer = import("/lua/ui/lobby/unitsanalyzer.lua") +local UnitsTooltip = import("/lua/ui/lobby/unitstooltip.lua") +local CustomLobbyUnitCatalog = import("/lua/ui/lobby/customlobby/unitselect/customlobbyunitcatalog.lua") +local CustomLobbyController = import("/lua/ui/lobby/customlobby/customlobbycontroller.lua") +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") + +local LazyVarDerive = import("/lua/lazyvar.lua").Derive +local Layouter = LayoutHelpers.ReusedLayoutFor + +-- flip to tint each layout area so the regions are visible while iterating +local Debug = false + +-- sized for the lobby's 1024×768 floor (the Popup centres it, leaving a small margin) +local DialogWidth = 960 +local DialogHeight = 710 +local Pad = 12 +local TitleHeight = 32 +local TabsHeight = 34 +local ActionHeight = 44 +local ScrollbarGap = 32 -- standard lobby gutter reserved on a list's right + +local DimColor = 'ff9aa0a8' +local TabActiveColor = 'ffffffff' +local TabIdleColor = 'ff7a828c' + +-- icon tiles: a square icon in a clickable cell; the tile is TileSize, the Grid cell TileStride +-- (so there's a small gap between tiles). Used for both presets and units. +local TileSize = 48 +local TileGap = 4 +local TileStride = TileSize + TileGap +local TileIconInset = 2 +local TileIdle = 'ff141a20' -- an unselected tile's background +local TileHover = 'ff1f262e' +local TileSelected = 'ff7a2d2d' -- a restricted preset/unit is lit red (forbidden) +local TileIconDim = 0.55 -- icon alpha when not restricted + +-- the presets strip shows this many rows of preset icons (it scrolls if there are more) +local PresetRows = 3 +local PresetAreaHeight = PresetRows * TileStride + 22 -- + the "Presets" label row + +-- the order the unit-type groups are shown in within a faction's grid +local UnitGroupOrder = { 'LAND', 'AIR', 'NAVAL', 'CONSTRUCT', 'ECONOMIC', 'SUPPORT', 'DEFENSES', 'CIVILIAN', 'SCU', 'ACU' } + +-- the per-faction "disable entire faction" presets — keyed by the blueprint faction name, which +-- matches the UnitsRestrictions preset key. These are NOT shown in the presets strip; instead each +-- faction tab's icon toggles its own (so a whole faction is banned from its tab). +local FactionPresetKeys = { + UEF = true, + CYBRAN = true, + AEON = true, + SERAPHIM = true, + NOMADS = true, +} + +-- faction-tab chrome +local TabBgIdle = 'ff10151b' +local TabBgActive = 'ff223344' +local TabBgHover = 'ff1a2128' + +--- The faction's icon path (reused from its UnitsRestrictions "disable faction" preset), or nil. +---@param factionName string +---@return FileName | nil +local function FactionIcon(factionName) + local preset = UnitsRestrictions.GetPresetsData()[factionName] + return preset and preset.Icon +end + +--- One icon tile (preset or unit): a background + icon. Clicking toggles the restriction (when +--- editable), lighting the tile red and brightening the icon. Mirrors the config column's +--- `PreviewTool` look. The owner wires `OnToggle` (and, for units, `OnHover`/`OnHoverEnd` to drive +--- the unit tooltip). +---@class UICustomLobbyIconTile : Group +---@field Editable boolean +---@field Selected boolean +---@field Hovered boolean +---@field DimWhenSelected boolean +---@field OnToggle? fun(selected: boolean) +---@field OnHover? fun() +---@field OnHoverEnd? fun() +---@field Bg Bitmap +---@field Icon Bitmap +local IconTile = ClassUI(Group) { + + ---@param self UICustomLobbyIconTile + ---@param parent Control + ---@param texture FileName + ---@param selected boolean + ---@param editable boolean + ---@param dimWhenSelected boolean # units: bright by default, dim (disabled-looking) when restricted + __init = function(self, parent, texture, selected, editable, dimWhenSelected) + Group.__init(self, parent, "CustomLobbyIconTile") + + self.Editable = editable + self.Selected = selected and true or false + self.Hovered = false + self.DimWhenSelected = dimWhenSelected and true or false + + self.Bg = Bitmap(self) + self.Bg:SetSolidColor(TileIdle) + + self.Icon = Bitmap(self) + if texture then + self.Icon:SetTexture(texture) + end + self.Icon:DisableHitTest() + + self.Bg.HandleEvent = function(control, event) + if event.Type == 'MouseEnter' then + self.Hovered = true + self:ApplyVisual() + if self.OnHover then self.OnHover() end + return true + elseif event.Type == 'MouseExit' then + self.Hovered = false + self:ApplyVisual() + if self.OnHoverEnd then self.OnHoverEnd() end + return true + elseif event.Type == 'ButtonPress' and self.Editable then + self.Selected = not self.Selected + self:ApplyVisual() + if self.OnToggle then self.OnToggle(self.Selected) end + return true + end + return false + end + end, + + ---@param self UICustomLobbyIconTile + __post_init = function(self) + Layouter(self.Bg):Fill(self):End() + Layouter(self.Icon):AtCenterIn(self):Width(TileSize - 2 * TileIconInset):Height(TileSize - 2 * TileIconInset):End() + self:ApplyVisual() + end, + + ---@param self UICustomLobbyIconTile + ApplyVisual = function(self) + local bg = TileIdle + if self.Selected then + bg = TileSelected + elseif self.Hovered then + bg = TileHover + end + self.Bg:SetSolidColor(bg) + -- presets light up when selected (active rule); units dim when selected (the unit is + -- disabled), so a restricted unit reads as greyed-out + if self.DimWhenSelected then + self.Icon:SetAlpha(self.Selected and TileIconDim or 1.0) + else + self.Icon:SetAlpha(self.Selected and 1.0 or TileIconDim) + end + end, + + --- Sets the selected state without firing `OnToggle` (initial paint + Clear). + ---@param self UICustomLobbyIconTile + ---@param selected boolean + SetSelected = function(self, selected) + self.Selected = selected and true or false + self:ApplyVisual() + end, +} + +--- Creates a layout area (an invisible Group with an optional debug tint). +---@param parent Control +---@param name string +---@param color string +---@return Group +local function CreateArea(parent, name, color) + local area = Group(parent, name) + local bg = Bitmap(area) + bg:SetSolidColor(color) + bg:SetAlpha(Debug and 0.18 or 0.0) + bg:DisableHitTest() + Layouter(bg):Fill(area):End() + area.Bg = bg + return area +end + +--- Collects a unit-group map ({ [id] = bp }) into an id-sorted list of blueprints. +---@param units table +---@return table[] +local function SortedUnits(units) + local list = {} + for _, bp in units do + table.insert(list, bp) + end + table.sort(list, function(a, b) return (a.ID or "") < (b.ID or "") end) + return list +end + +--- One faction tab: a full-cell clickable strip (click → view that faction's units) carrying the +--- faction icon, name and a unit-restriction count badge. The icon doubles as the "disable entire +--- faction" toggle (click → ban every unit of the faction); when the faction is disabled the icon +--- goes red + dim. The owner wires `OnSelect` / `OnToggleDisable`. +---@class UICustomLobbyFactionTab : Group +---@field Name string +---@field Editable boolean +---@field Active boolean +---@field Disabled boolean +---@field Hovered boolean +---@field OnSelect? fun() +---@field OnToggleDisable? fun() +---@field Bg Bitmap +---@field IconBg Bitmap +---@field Icon Bitmap +---@field Label Text +---@field Badge Text +local FactionTab = ClassUI(Group) { + + ---@param self UICustomLobbyFactionTab + ---@param parent Control + ---@param faction UICustomLobbyFaction + ---@param editable boolean + __init = function(self, parent, faction, editable) + Group.__init(self, parent, "CustomLobbyFactionTab") + + self.Name = faction.Name + self.Editable = editable + self.Active = false + self.Disabled = false + self.Hovered = false + + self.Bg = Bitmap(self) + self.Bg:SetSolidColor(TabBgIdle) + self.Bg.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' then + if self.OnSelect then self.OnSelect() end + return true + elseif event.Type == 'MouseEnter' then + self.Hovered = true + self:ApplyVisual() + return true + elseif event.Type == 'MouseExit' then + self.Hovered = false + self:ApplyVisual() + return true + end + return false + end + + -- the faction icon doubles as the "disable entire faction" toggle + self.IconBg = Bitmap(self) + self.IconBg:SetSolidColor('00000000') + self.Icon = Bitmap(self.IconBg) + local icon = FactionIcon(self.Name) + if icon then + self.Icon:SetTexture(icon) + end + self.Icon:DisableHitTest() + self.IconBg.HandleEvent = function(control, event) + if event.Type == 'ButtonPress' and self.Editable then + if self.OnToggleDisable then self.OnToggleDisable() end + return true + end + return false + end + Tooltip.AddControlTooltipManual(self.IconBg, "Disable faction", "Restrict every unit of this faction.") + + self.Label = UIUtil.CreateText(self, self.Name, 15, UIUtil.titleFont) + self.Label:DisableHitTest() + self.Badge = UIUtil.CreateText(self, "", 12, UIUtil.bodyFont) + self.Badge:SetColor(DimColor) + self.Badge:DisableHitTest() + end, + + ---@param self UICustomLobbyFactionTab + __post_init = function(self) + local iconSize = 24 + Layouter(self.Bg):Fill(self):End() + -- the icon doubles as the disable toggle, so it must sit *above* the tab's Bg (which handles + -- the select-click) to receive its own clicks + Layouter(self.IconBg):AtLeftIn(self, 8):AtVerticalCenterIn(self):Width(iconSize):Height(iconSize):Over(self, 10):End() + Layouter(self.Icon):Fill(self.IconBg):Over(self.IconBg, 1):End() + Layouter(self.Label):AnchorToRight(self.IconBg, 8):AtVerticalCenterIn(self):End() + Layouter(self.Badge):AnchorToRight(self.Label, 6):AtVerticalCenterIn(self):End() + self:ApplyVisual() + end, + + ---@param self UICustomLobbyFactionTab + ApplyVisual = function(self) + local bg = TabBgIdle + if self.Active then + bg = TabBgActive + elseif self.Hovered then + bg = TabBgHover + end + self.Bg:SetSolidColor(bg) + self.Label:SetColor(self.Active and TabActiveColor or TabIdleColor) + -- the icon button shows the faction-disabled state (red + dimmed icon = banned) + self.IconBg:SetSolidColor(self.Disabled and TileSelected or '00000000') + self.Icon:SetAlpha(self.Disabled and TileIconDim or 1.0) + end, + + ---@param self UICustomLobbyFactionTab + ---@param active boolean + SetActive = function(self, active) + self.Active = active and true or false + self:ApplyVisual() + end, + + ---@param self UICustomLobbyFactionTab + ---@param disabled boolean + SetDisabled = function(self, disabled) + self.Disabled = disabled and true or false + self:ApplyVisual() + end, + + ---@param self UICustomLobbyFactionTab + ---@param count number + SetBadge = function(self, count) + self.Badge:SetText("(" .. count .. ")") + end, +} + +---@class UICustomLobbyUnitSelect : Group +---@field Trash TrashBag +---@field Editable boolean +---@field ActiveMods table[] +---@field Selection table +---@field OnConfirmCb fun(keys: string[]) +---@field OnCancelCb fun() +---@field Ready boolean +---@field PresetTiles table +---@field UnitTiles table +---@field Factions UICustomLobbyFaction[] +---@field FactionIds table> # faction name -> set of its unit ids +---@field ActiveFaction string | false +---@field FactionTabs { name: string, tab: UICustomLobbyFactionTab }[] +---@field PresetPerRow number +---@field UnitPerRow number +---@field TitleArea Group +---@field PresetArea Group +---@field TabsArea Group +---@field UnitArea Group +---@field ActionArea Group +---@field Title Text +---@field Stats Text +---@field PresetLabel Text +---@field PresetGrid Grid +---@field PresetScrollbar Scrollbar | false +---@field UnitGrid Grid +---@field UnitScrollbar Scrollbar | false +---@field ProgressLabel Text +---@field SelectButton Button +---@field CancelButton Button +---@field ClearButton Button +---@field FactionsObserver LazyVar +---@field ProgressObserver LazyVar +local CustomLobbyUnitSelect = ClassUI(Group) { + + ---@param self UICustomLobbyUnitSelect + ---@param parent Control + ---@param options { initial: string[], editable: boolean, activeMods: table[], onConfirm: fun(keys: string[]), onCancel: fun() } + __init = function(self, parent, options) + Group.__init(self, parent, "CustomLobbyUnitSelect") + + self.Trash = TrashBag() + self.OnConfirmCb = options.onConfirm + self.OnCancelCb = options.onCancel + self.Editable = options.editable ~= false + self.ActiveMods = options.activeMods or {} + self.Ready = false + self.PresetTiles = {} + self.UnitTiles = {} + self.Factions = {} + self.FactionIds = {} + self.ActiveFaction = false + self.FactionTabs = {} + self.PresetPerRow = 1 + self.UnitPerRow = 1 + self.PresetScrollbar = false + self.UnitScrollbar = false + + -- working selection: a set built from the initial key array + self.Selection = {} + for _, key in (options.initial or {}) do + self.Selection[key] = true + end + + -- areas + self.TitleArea = CreateArea(self, "TitleArea", 'ffcc4040') + self.PresetArea = CreateArea(self, "PresetArea", 'ff40cc60') + self.TabsArea = CreateArea(self, "TabsArea", 'ffcc8040') + self.UnitArea = CreateArea(self, "UnitArea", 'ff4060cc') + self.ActionArea = CreateArea(self, "ActionArea", 'ff808080') + + self.Title = UIUtil.CreateText(self.TitleArea, "Unit restrictions", 22, UIUtil.titleFont) + self.Title:DisableHitTest() + self.Stats = UIUtil.CreateText(self.TitleArea, "", 14, UIUtil.bodyFont) + self.Stats:SetColor(DimColor) + self.Stats:DisableHitTest() + + self.PresetLabel = UIUtil.CreateText(self.PresetArea, "Presets", 13, UIUtil.titleFont) + self.PresetLabel:SetColor(DimColor) + self.PresetLabel:DisableHitTest() + + -- two icon grids; Grid scales itemWidth / itemHeight itself (pass unscaled) + self.PresetGrid = Grid(self.PresetArea, TileStride, TileStride) + self.UnitGrid = Grid(self.UnitArea, TileStride, TileStride) + + self.ProgressLabel = UIUtil.CreateText(self.UnitArea, "Loading blueprints…", 16, UIUtil.bodyFont) + self.ProgressLabel:SetColor(DimColor) + self.ProgressLabel:DisableHitTest() + + --#region actions + self.SelectButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "OK", 16, 2) + self.SelectButton.OnClick = function(button, modifiers) self:Confirm() end + + self.CancelButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', + self.Editable and "Cancel" or "Close", 16, 2) + self.CancelButton.OnClick = function(button, modifiers) self.OnCancelCb() end + + self.ClearButton = UIUtil.CreateButtonStd(self.ActionArea, '/scx_menu/small-btn/small', "Clear", 14, 2) + self.ClearButton.OnClick = function(button, modifiers) self:ClearSelection() end + Tooltip.AddControlTooltipManual(self.ClearButton, "Clear", "Remove every unit restriction.") + + if not self.Editable then + self.SelectButton:Hide() + self.ClearButton:Hide() + end + --#endregion + + -- react to the catalog streaming in (gated by Ready so the immediate fire on creation — + -- e.g. when already loaded from a previous open — doesn't build grids before we're sized) + self.FactionsObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyUnitCatalog.GetFactionsVar(), function(factionsLazy) + local factions = factionsLazy() + if self.Ready and table.getn(factions) > 0 then + self:OnFactionsLoaded(factions) + end + end)) + self.ProgressObserver = self.Trash:Add( + LazyVarDerive(CustomLobbyUnitCatalog.GetProgressTextVar(), function(textLazy) + local text = textLazy() + if self.Ready and not CustomLobbyUnitCatalog.IsLoaded() then + self.ProgressLabel:SetText(text ~= "" and text or "Loading blueprints…") + end + end)) + end, + + ---@param self UICustomLobbyUnitSelect + __post_init = function(self) + self.Width:Set(LayoutHelpers.ScaleNumber(DialogWidth)) + self.Height:Set(LayoutHelpers.ScaleNumber(DialogHeight)) + + Layouter(self.TitleArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtTopIn(self, Pad):Height(TitleHeight):End() + Layouter(self.PresetArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AnchorToBottom(self.TitleArea, Pad):Height(PresetAreaHeight):End() + Layouter(self.TabsArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AnchorToBottom(self.PresetArea, Pad):Height(TabsHeight):End() + Layouter(self.ActionArea):AtLeftIn(self, Pad):AtRightIn(self, Pad):AtBottomIn(self, Pad):Height(ActionHeight):End() + Layouter(self.UnitArea) + :AtLeftIn(self, Pad):AtRightIn(self, Pad) + :AnchorToBottom(self.TabsArea, Pad):AnchorToTop(self.ActionArea, Pad) + :End() + + Layouter(self.Title):AtLeftIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() + Layouter(self.Stats):AtRightIn(self.TitleArea):AtVerticalCenterIn(self.TitleArea):End() + + Layouter(self.PresetLabel):AtLeftIn(self.PresetArea):AtTopIn(self.PresetArea):End() + Layouter(self.PresetGrid):AtLeftIn(self.PresetArea):AnchorToBottom(self.PresetLabel, 4):AtBottomIn(self.PresetArea):End() + self.PresetGrid.Right:Set(function() return self.PresetArea.Right() - LayoutHelpers.ScaleNumber(ScrollbarGap) end) + + Layouter(self.UnitGrid):AtLeftIn(self.UnitArea):AtTopIn(self.UnitArea):AtBottomIn(self.UnitArea):End() + self.UnitGrid.Right:Set(function() return self.UnitArea.Right() - LayoutHelpers.ScaleNumber(ScrollbarGap) end) + Layouter(self.ProgressLabel):AtHorizontalCenterIn(self.UnitArea):AtVerticalCenterIn(self.UnitArea):End() + + -- one row: Clear on the left, the Cancel / OK pair on the right + Layouter(self.ClearButton):AtLeftIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.SelectButton):AtRightIn(self.ActionArea):AtVerticalCenterIn(self.ActionArea):End() + Layouter(self.CancelButton):AnchorToLeft(self.SelectButton, 12):AtVerticalCenterIn(self.SelectButton):End() + end, + + --- Three-phase init: the opener calls this after Popup mounts + centres the dialog (the Grids + --- need a concrete width to know how many tiles fit a row). Builds the presets strip, kicks off + --- the blueprint load, and renders the factions if they're already cached. + ---@param self UICustomLobbyUnitSelect + Initialize = function(self) + self.Ready = true + + self.PresetPerRow = math.max(1, self.PresetGrid:GetVisible()) + self.UnitPerRow = math.max(1, self.UnitGrid:GetVisible()) + + self:BuildPresetGrid() + if not self.PresetScrollbar then + self.PresetScrollbar = UIUtil.CreateVertScrollbarFor(self.PresetGrid) + UIUtil.ForwardWheelToScroll(self.PresetGrid, self.PresetGrid) + end + if not self.UnitScrollbar then + self.UnitScrollbar = UIUtil.CreateVertScrollbarFor(self.UnitGrid) + UIUtil.ForwardWheelToScroll(self.UnitGrid, self.UnitGrid) + end + + CustomLobbyUnitCatalog.EnsureLoaded(self.ActiveMods) + if CustomLobbyUnitCatalog.IsLoaded() then + self:OnFactionsLoaded(CustomLobbyUnitCatalog.GetFactions()) + else + self.ProgressLabel:Show() + end + + self:UpdateStats() + self:UpdatePresetLabel() + self:UpdateScrollbar(self.PresetGrid, self.PresetScrollbar) + end, + + --------------------------------------------------------------------------- + --#region Presets strip + + --- Builds the presets strip: every restriction preset as an icon tile, flowing left-to-right and + --- starting a fresh row at each preset-group separator. (All presets — they're faction-agnostic.) + ---@param self UICustomLobbyUnitSelect + BuildPresetGrid = function(self) + local presets = UnitsRestrictions.GetPresetsData() + local order = UnitsRestrictions.GetPresetsOrder() + local perRow = self.PresetPerRow + + self.PresetGrid:DeleteAndDestroyAll(true) + self.PresetTiles = {} + + local placements = {} + local col, row, maxRow = 1, 1, 1 + local placedAny = false + local pendingBreak = false + for _, key in order do + if key == "" then + pendingBreak = true + elseif presets[key] and presets[key].name and not FactionPresetKeys[key] then + -- faction presets are excluded here — each faction tab's icon toggles its own + if pendingBreak and placedAny and col > 1 then + row = row + 1 + col = 1 + end + pendingBreak = false + table.insert(placements, { key = key, col = col, row = row }) + placedAny = true + if row > maxRow then maxRow = row end + col = col + 1 + if col > perRow then col = 1; row = row + 1 end + end + end + + if table.getn(placements) == 0 then + return + end + + self.PresetGrid:AppendCols(perRow, true) + self.PresetGrid:AppendRows(maxRow, true) + for _, placement in placements do + self.PresetGrid:SetItem(self:CreatePresetTile(presets[placement.key]), placement.col, placement.row, true) + end + self.PresetGrid:EndBatch() + end, + + --- Builds one preset cell: an icon tile wired to the working selection. Private. + ---@param self UICustomLobbyUnitSelect + ---@param preset table + ---@return Group + CreatePresetTile = function(self, preset) + local key = preset.key + local cell = Group(self.PresetGrid) + LayoutHelpers.SetDimensions(cell, TileStride, TileStride) + + local tile = IconTile(cell, preset.Icon, self.Selection[key] == true, self.Editable, false) + tile.OnToggle = function(selected) self:ToggleKey(key, selected) end + Layouter(tile):AtLeftIn(cell):AtTopIn(cell):Width(TileSize):Height(TileSize):End() + + if preset.tooltip then + Tooltip.AddControlTooltipManual(tile.Bg, LOC(preset.name) or key, LOC(preset.tooltip) or "") + end + self.PresetTiles[key] = tile + return cell + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Faction tabs + unit grid + + --- The catalog finished loading: cache the factions, build the faction tabs, and render the + --- first faction's units. + ---@param self UICustomLobbyUnitSelect + ---@param factions UICustomLobbyFaction[] + OnFactionsLoaded = function(self, factions) + self.Factions = factions + self.ProgressLabel:Hide() + + -- per-faction set of unit ids (for the tab badges) + self.FactionIds = {} + for _, faction in factions do + local ids = {} + for _, bp in faction.Blueprints do + if bp.ID then ids[bp.ID] = true end + end + self.FactionIds[faction.Name] = ids + end + + self:BuildFactionTabs() + if table.getn(factions) > 0 then + self.ActiveFaction = factions[1].Name + self:PaintTabs() + self:BuildUnitGrid(factions[1]) + end + end, + + --- (Re)builds the faction tabs, dividing the tabs area into equal full-width cells. Each tab + --- selects its faction on click; its icon toggles "disable entire faction". + ---@param self UICustomLobbyUnitSelect + BuildFactionTabs = function(self) + for _, entry in self.FactionTabs do + entry.tab:Destroy() + end + self.FactionTabs = {} + + local count = table.getn(self.Factions) + if count == 0 then + return + end + + for index, faction in self.Factions do + local name = faction.Name + local tab = FactionTab(self.TabsArea, faction, self.Editable) + tab.OnSelect = function() self:SetActiveFaction(name) end + tab.OnToggleDisable = function() self:ToggleFaction(name) end + -- equal full-width cells: cell `index` of `count` across the tabs area + local i = index + tab.Left:Set(function() return self.TabsArea.Left() + (i - 1) * (self.TabsArea.Width() / count) end) + tab.Right:Set(function() return self.TabsArea.Left() + i * (self.TabsArea.Width() / count) end) + tab.Top:Set(function() return self.TabsArea.Top() end) + tab.Bottom:Set(function() return self.TabsArea.Bottom() end) + table.insert(self.FactionTabs, { name = name, tab = tab }) + end + end, + + --- Switches the active faction and rebuilds its unit grid (a no-op if already active). + ---@param self UICustomLobbyUnitSelect + ---@param name string + SetActiveFaction = function(self, name) + if self.ActiveFaction == name then + return + end + self.ActiveFaction = name + self:PaintTabs() + for _, faction in self.Factions do + if faction.Name == name then + self:BuildUnitGrid(faction) + break + end + end + end, + + --- Refreshes every faction tab: which is active, which faction is fully disabled, and each + --- tab's restricted-unit-count badge. + ---@param self UICustomLobbyUnitSelect + PaintTabs = function(self) + for _, entry in self.FactionTabs do + entry.tab:SetActive(entry.name == self.ActiveFaction) + entry.tab:SetDisabled(self.Selection[entry.name] == true) + entry.tab:SetBadge(self:CountForFaction(entry.name)) + end + end, + + --- Toggles "disable entire faction" — the faction's UnitsRestrictions preset key (its own name). + --- Driven by the faction tab's icon. Not shown in the presets strip; reflected on the tab. + ---@param self UICustomLobbyUnitSelect + ---@param name string + ToggleFaction = function(self, name) + self.Selection[name] = (not self.Selection[name]) and true or nil + self:UpdateStats() + self:PaintTabs() + end, + + --- How many of a faction's units are currently in the selection (its tab badge count). + ---@param self UICustomLobbyUnitSelect + ---@param name string + ---@return number + CountForFaction = function(self, name) + local ids = self.FactionIds[name] + if not ids then + return 0 + end + local count = 0 + for key in self.Selection do + if ids[key] then count = count + 1 end + end + return count + end, + + --- Builds the active faction's unit grid: each type group (land / air / naval / …) flows its + --- units left-to-right and starts on a fresh row. + ---@param self UICustomLobbyUnitSelect + ---@param faction UICustomLobbyFaction + BuildUnitGrid = function(self, faction) + local perRow = self.UnitPerRow + + self.UnitGrid:DeleteAndDestroyAll(true) + self.UnitTiles = {} + + -- gather the placements group by group + local placements = {} + local col, row, maxRow = 1, 1, 1 + local placedAny = false + for _, group in UnitGroupOrder do + local units = faction.Units[group] + if units and not table.empty(units) then + if placedAny and col > 1 then -- start each group on a fresh row + row = row + 1 + col = 1 + end + for _, bp in SortedUnits(units) do + table.insert(placements, { bp = bp, col = col, row = row }) + placedAny = true + if row > maxRow then maxRow = row end + col = col + 1 + if col > perRow then col = 1; row = row + 1 end + end + end + end + + if table.getn(placements) == 0 then + self:UpdateScrollbar(self.UnitGrid, self.UnitScrollbar) + return + end + + self.UnitGrid:AppendCols(perRow, true) + self.UnitGrid:AppendRows(maxRow, true) + for _, placement in placements do + self.UnitGrid:SetItem(self:CreateUnitTile(placement.bp, faction.Name), placement.col, placement.row, true) + end + self.UnitGrid:EndBatch() + self:UpdateScrollbar(self.UnitGrid, self.UnitScrollbar) + end, + + --- Builds one unit cell: an icon tile wired to the working selection (keyed by blueprint id), + --- with the unit tooltip on hover. Private. + ---@param self UICustomLobbyUnitSelect + ---@param bp table + ---@param factionName string + ---@return Group + CreateUnitTile = function(self, bp, factionName) + local id = bp.ID + local cell = Group(self.UnitGrid) + LayoutHelpers.SetDimensions(cell, TileStride, TileStride) + + local texture = UnitsAnalyzer.GetImagePath(bp, factionName) + local tile = IconTile(cell, texture, self.Selection[id] == true, self.Editable, true) + tile.OnToggle = function(selected) self:ToggleKey(id, selected) end + tile.OnHover = function() UnitsTooltip.Create(tile, bp) end + tile.OnHoverEnd = function() UnitsTooltip.Destroy() end + Layouter(tile):AtLeftIn(cell):AtTopIn(cell):Width(TileSize):Height(TileSize):End() + + self.UnitTiles[id] = tile + return cell + end, + + --#endregion + + --------------------------------------------------------------------------- + --#region Selection + + --- Adds or removes a key (preset key or unit id) from the working selection and refreshes the + --- stats + tab badges. Keeps any other tile showing the same key in sync. + ---@param self UICustomLobbyUnitSelect + ---@param key string + ---@param selected boolean + ToggleKey = function(self, key, selected) + if selected then + self.Selection[key] = true + else + self.Selection[key] = nil + end + -- a key can appear in both grids (e.g. a preset and a unit are distinct keys, but a unit id + -- shown again after a faction switch); keep the matching tiles consistent + if self.PresetTiles[key] then self.PresetTiles[key]:SetSelected(selected) end + if self.UnitTiles[key] then self.UnitTiles[key]:SetSelected(selected) end + self:UpdateStats() + self:UpdatePresetLabel() + self:PaintTabs() + end, + + --- Clears the whole working selection and unlights every tile. + ---@param self UICustomLobbyUnitSelect + ClearSelection = function(self) + self.Selection = {} + for _, tile in self.PresetTiles do tile:SetSelected(false) end + for _, tile in self.UnitTiles do tile:SetSelected(false) end + self:UpdateStats() + self:UpdatePresetLabel() + self:PaintTabs() + end, + + --- Updates the "N restrictions" stat (total selected keys). + ---@param self UICustomLobbyUnitSelect + UpdateStats = function(self) + local count = table.getsize(self.Selection) + if count == 0 then + self.Stats:SetText("No restrictions") + elseif count == 1 then + self.Stats:SetText("1 restriction") + else + self.Stats:SetText(count .. " restrictions") + end + end, + + --- Updates the presets-strip label with the count of selected presets (excluding the faction + --- ones, which live on the tabs). + ---@param self UICustomLobbyUnitSelect + UpdatePresetLabel = function(self) + local presets = UnitsRestrictions.GetPresetsData() + local count = 0 + for key in self.Selection do + if presets[key] and not FactionPresetKeys[key] then + count = count + 1 + end + end + self.PresetLabel:SetText(count > 0 and ("Presets (" .. count .. ")") or "Presets") + end, + + --- Shows a grid's scrollbar only when it actually overflows. + ---@param self UICustomLobbyUnitSelect + ---@param grid Grid + ---@param scrollbar Scrollbar | false + UpdateScrollbar = function(self, grid, scrollbar) + if not scrollbar then + return + end + if grid:IsScrollable("Vert") then + scrollbar:Show() + else + scrollbar:Hide() + end + end, + + --- Commits the working selection (as a key array) via the opener's callback. + ---@param self UICustomLobbyUnitSelect + Confirm = function(self) + local keys = {} + for key in self.Selection do + table.insert(keys, key) + end + self.OnConfirmCb(keys) + end, + + --#endregion + + ---@param self UICustomLobbyUnitSelect + OnDestroy = function(self) + UnitsTooltip.Destroy() + self.Trash:Destroy() + end, +} + +------------------------------------------------------------------------------- +--#region Singleton + open / close + +---@type Popup | false +local Instance = false + +--- Opens the unit-restriction dialog. Seeds from the synced `Restrictions`; the host can edit and +--- on OK the new key list routes through the host-authoritative `RequestSetRestrictions` intent. A +--- non-host opens it read-only (a window into the host's choice). +---@param parent? Control +function Open(parent) + parent = parent or GetFrame(0) + + if Instance then + Instance:Close() + end + + local launch = CustomLobbyLaunchModel.GetSingleton() + local isHost = CustomLobbyLocalModel.GetSingleton().IsHost() + + local popup + local content = CustomLobbyUnitSelect(parent, { + initial = launch.Restrictions() or {}, + editable = isHost, + activeMods = Mods.GetGameMods(launch.GameMods()), + onConfirm = function(keys) + CustomLobbyController.RequestSetRestrictions(keys) + if popup then + popup:Close() + end + end, + onCancel = function() + if popup then + popup:Close() + end + end, + }) + + popup = Popup(parent, content) + local baseOnClosed = popup.OnClosed + popup.OnClosed = function(self) + baseOnClosed(self) + Instance = false + end + Instance = popup + + -- now that Popup has mounted + centred the content, it's safe to build the grids (they read + -- concrete geometry) + content:Initialize() +end + +--- Closes the dialog if open. +function Close() + if Instance then + Instance:Close() + Instance = false + end +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Debugging + +function __moduleinfo.OnDirty() + Close() +end + +--#endregion diff --git a/lua/ui/lobby/lobby-old.lua b/lua/ui/lobby/lobby-old.lua new file mode 100644 index 00000000000..a76654e3527 --- /dev/null +++ b/lua/ui/lobby/lobby-old.lua @@ -0,0 +1,7386 @@ +--***************************************************************************** +--* File: lua/modules/ui/lobby/lobby.lua +--* Author: Chris Blackwell +--* Summary: Game selection UI +--* +--* Copyright © 2005 Gas Powered Games, Inc. All rights reserved. +--***************************************************************************** +local GameVersion = import("/lua/version.lua").GetVersion +local UIUtil = import("/lua/ui/uiutil.lua") +local MenuCommon = import("/lua/ui/menus/menucommon.lua") +local Prefs = import("/lua/user/prefs.lua") +local MapUtil = import("/lua/ui/maputil.lua") +local Group = import("/lua/maui/group.lua").Group +local RadioButton = import("/lua/ui/controls/radiobutton.lua").RadioButton +local ResourceMapPreview = import("/lua/ui/controls/resmappreview.lua").ResourceMapPreview +local Popup = import("/lua/ui/controls/popups/popup.lua").Popup +local Slider = import("/lua/maui/slider.lua").Slider +local PlayerData = import("/lua/ui/lobby/data/playerdata.lua").PlayerData +local GameInfo = import("/lua/ui/lobby/data/gamedata.lua") +local WatchedValueArray = import("/lua/ui/lobby/data/watchedvalue/watchedvaluearray.lua").WatchedValueArray +local LayoutHelpers = import("/lua/maui/layouthelpers.lua") +local Bitmap = import("/lua/maui/bitmap.lua").Bitmap +local Button = import("/lua/maui/button.lua").Button +local ToggleButton = import("/lua/ui/controls/togglebutton.lua").ToggleButton +local Edit = import("/lua/maui/edit.lua").Edit +local LobbyComm = import("/lua/ui/lobby/lobbycomm.lua") +local Tooltip = import("/lua/ui/game/tooltip.lua") +local Mods = import("/lua/mods.lua") +local FactionData = import("/lua/factions.lua") +local TextArea = import("/lua/ui/controls/textarea.lua").TextArea +local Presets = import("/lua/ui/lobby/presets.lua") + +local utils = import("/lua/system/utils.lua") + +local Trueskill = import("/lua/ui/lobby/trueskill.lua") +local Player = Trueskill.Player +local Rating = Trueskill.Rating +local ModBlacklist = import("/etc/faf/blacklist.lua").Blacklist +local Teams = Trueskill.Teams +local EscapeHandler = import("/lua/ui/dialogs/eschandler.lua") +local CountryTooltips = import("/lua/ui/help/tooltips-country.lua").tooltip +local SetUtils = import("/lua/system/setutils.lua") +local JSON = import("/lua/system/dkson.lua").json +local UTF = import("/lua/utf.lua") +-- Uveso - aitypes inside aitypes.lua are now also available as a function. +local aitypes +local AIKeys = {} +local AIStrings = {} +local AITooltips = {} + + + +function GetAITypes() + AIKeys = {} + AIStrings = {} + AITooltips = {} + aitypes = import("/lua/ui/lobby/aitypes.lua").GetAItypes() + for _, aidata in aitypes do + table.insert(AIKeys, aidata.key) + table.insert(AIStrings, aidata.name) + table.insert(AITooltips, 'aitype_'..aidata.key) + end +end +GetAITypes() + +--This is a special table that allows us to pass data to blueprints.lua, before the rest of the game is loaded. +-- do not use this for anything that doesnt do blueprint modding, use GameOptions for that instead, which will load it into sim. + +local IsSyncReplayServer = false + +local AddUnicodeCharToEditText = import("/lua/utf.lua").AddUnicodeCharToEditText + +if HasCommandLineArg("/syncreplay") and HasCommandLineArg("/gpgnet") then + IsSyncReplayServer = true +end + +local globalOpts = import("/lua/ui/lobby/lobbyoptions.lua").globalOpts +local teamOpts = import("/lua/ui/lobby/lobbyoptions.lua").teamOptions +local AIOpts = import("/lua/ui/lobby/lobbyoptions.lua").AIOpts +local gameColors = import("/lua/gamecolors.lua").GameColors + +local numOpenSlots = LobbyComm.maxPlayerSlots + +-- Add lobby options from AI mods +function ImportModAIOptions() + local simMods = import("/lua/mods.lua").AllMods() + local OptionData + local alreadyStored + for Index, ModData in simMods do + if exists(ModData.location..'/lua/AI/LobbyOptions/lobbyoptions.lua') then + OptionData = import(ModData.location..'/lua/AI/LobbyOptions/lobbyoptions.lua').AIOpts + for s, t in OptionData do + -- check, if we have this option already stored + alreadyStored = false + for k, v in AIOpts do + if v.key == t.key then + alreadyStored = true + break + end + end + if not alreadyStored then + table.insert(AIOpts, t) + end + end + end + end +end +ImportModAIOptions() + +-- Maps faction identifiers to their names. +local FACTION_NAMES = {[1] = "uef", [2] = "aeon", [3] = "cybran", [4] = "seraphim", [5] = "random" } + +local rehostPlayerOptions = {} -- Player options loaded from preset, used for rehosting + +local formattedOptions = {} +local nonDefaultFormattedOptions = {} +local LrgMap = false + +local HostUtils +local mapPreviewSlotSwapFrom = 0 +local mapPreviewSlotSwap = false + +teamIcons = { + '/lobby/team_icons/team_no_icon.dds', + '/lobby/team_icons/team_1_icon.dds', + '/lobby/team_icons/team_2_icon.dds', + '/lobby/team_icons/team_3_icon.dds', + '/lobby/team_icons/team_4_icon.dds', + '/lobby/team_icons/team_5_icon.dds', + '/lobby/team_icons/team_6_icon.dds', + '/lobby/team_icons/team_7_icon.dds', + '/lobby/team_icons/team_8_icon.dds', +} + +DebugEnabled = Prefs.GetFromCurrentProfile('LobbyDebug') or '' +local HideDefaultOptions = Prefs.GetFromCurrentProfile('LobbyHideDefaultOptions') == 'true' + +local connectedTo = {} -- by UID +CurrentConnection = {} -- by Name +ConnectionEstablished = {} -- by Name +ConnectedWithProxy = {} -- by UID + + +allAvailableFactionsList = {} + +local availableMods = {} -- map from peer ID to set of available mods; each set is a map from "mod id"->true +local selectedSimMods = {} -- Similar map for activated sim mods +local selectedUIMods = {} -- Similar map for activated UI mods + +local CPU_Benchmarks = {} -- Stores CPU benchmark data + +local function parseCommandlineArguments() + -- Set of all possible command line option keys. + -- The client sometimes gives us empty-string as some args, which gets interpreted as that key + -- having as value the name of the next key. This set lets us interpret that case using the + -- default option. + local CMDLINE_ARGUMENT_KEYS = { + ["/init"] = true, + ["/country"] = true, + ["/numgames"] = true, + ["/mean"] = true, + ["/clan"] = true, + ["/deviation"] = true, + ["/joincustom"] = true, + ["/gpgnet"] = true, + } + + local function GetCommandLineArgOrDefault(argname, default) + local arg = GetCommandLineArg(argname, 1) + if arg and not CMDLINE_ARGUMENT_KEYS[arg[1]] then + return arg[1] + end + + return default + end + + return { + PrefLanguage = tostring(string.lower(GetCommandLineArgOrDefault("/country", "world"))), + isRehost = HasCommandLineArg("/rehost"), + initName = GetCommandLineArgOrDefault("/init", ""), + numGames = tonumber(GetCommandLineArgOrDefault("/numgames", 0)), + playerMean = tonumber(GetCommandLineArgOrDefault("/mean", 1500)), + playerClan = tostring(GetCommandLineArgOrDefault("/clan", "")), + playerDeviation = tonumber(GetCommandLineArgOrDefault("/deviation", 500)), + debugLobby = HasCommandLineArg("/debugLobby"), -- Used by LaunchFAInstances script to set players as ready by default + } +end +local argv = parseCommandlineArguments() + +local playerRating = math.floor(Trueskill.round2((argv.playerMean - 3 * argv.playerDeviation) / 100.0) * 100) + +local function ParseWhisper(params) + local delimStart = string.find(params, " ") + if delimStart then + local name = string.sub(params, 1, delimStart-1) + local targID = FindIDForName(name) + if targID then + PrivateChat(targID, string.sub(params, delimStart+1)) + else + AddChatText(LOC("Invalid whisper target.")) + end + end +end + +local commands = { + pm = ParseWhisper, + private = ParseWhisper, + w = ParseWhisper, + whisper = ParseWhisper, +} + +local Strings = LobbyComm.Strings + +---@type LobbyComm +local lobbyComm = false +local localPlayerName = "" +local gameName = "" +local hostID = false +local singlePlayer = false +---@type Group +local GUI = false +local localPlayerID = false +---@type GameData | WatchedGameData +local gameInfo = false +local lastKickMessage = UTF.UnescapeString(Prefs.GetFromCurrentProfile('lastKickMessage') or "") + +local defaultMode =(HasCommandLineArg("/windowed") and "windowed") or Prefs.GetFromCurrentProfile('options').primary_adapter +local windowedMode = defaultMode == "windowed" or (HasCommandLineArg("/windowed")) + +function SetWindowedLobby(windowed) + -- Dont change resolution if user already using windowed mode + if windowed == windowedMode or defaultMode == 'windowed' then + return + end + + if windowed then + ConExecute('SC_PrimaryAdapter windowed') + else + ConExecute('SC_PrimaryAdapter ' .. tostring(defaultMode)) + end + + windowedMode = windowed +end + +-- String from which to build the various "Move player to slot" labels. +local slotMenuStrings = { + open = "Open", + close = "Close", + closed = "Closed", + occupy = "Occupy", + pm = "Private Message", + remove_to_kik = "Kick Player", + remove_to_observer = "Move Player to Observer", + close_spawn_mex = "Close - spawn mex", + closed_spawn_mex = "Closed - spawn mex", +} +local slotMenuData = { + open = { + host = { + 'close', + 'occupy', + 'ailist', + }, + client = { + 'occupy', + }, + }, + closed = { + host = { + 'open', + }, + client = { + }, + }, + player = { + host = { + 'pm', + 'remove_to_observer', + 'remove_to_kik', + 'move' + }, + client = { + 'pm', + }, + }, + ai = { + host = { + 'remove_to_kik', + 'ailist', + }, + client = { + }, + }, +} + +function GetNumAvailStartSpots() + local numAvailStartSpots = nil + local scenarioInfo = nil + if gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= "") then + scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + end + if scenarioInfo then + local armyTable = MapUtil.GetArmies(scenarioInfo) + if armyTable then + if gameInfo.GameOptions['RandomMap'] == 'Off' then + numAvailStartSpots = table.getn(armyTable) + else + numAvailStartSpots = numberOfPlayers + end + end + else + WARN("Can't assign random start spots, no scenario selected.") + end + return numAvailStartSpots +end + +local function GetSlotMenuData() + if gameInfo.AdaptiveMap then + if not slotMenuData.closed_spawn_mex then + slotMenuData.closed_spawn_mex = { + host = { + 'open', + 'close', + }, + client = { + }, + } + table.insert(slotMenuData.open.host, 2, 'close_spawn_mex') + table.insert(slotMenuData.closed.host, 2, 'close_spawn_mex') + end + else + if slotMenuData.closed_spawn_mex then + slotMenuData.closed_spawn_mex = nil + table.remove(slotMenuData.open.host, 2) + table.remove(slotMenuData.closed.host, 2) + end + end + return slotMenuData +end + +local function GetSlotMenuTables(stateKey, hostKey, slotNum) + local keys = {} + local strings = {} + local tooltips = {} + + if not GetSlotMenuData()[stateKey] then + WARN("Invalid slot menu state selected: " .. tostring(stateKey)) + return nil + end + + if not GetSlotMenuData()[stateKey][hostKey] then + WARN("Invalid slot menu host key selected: " .. tostring(hostKey)) + return nil + end + + local isPlayerReady = false + local localPlayerSlot = FindSlotForID(localPlayerID) + if localPlayerSlot then + if gameInfo.PlayerOptions[localPlayerSlot].Ready then + isPlayerReady = true + end + end + + for index, key in GetSlotMenuData()[stateKey][hostKey] do + if key == 'ailist' then + if slotNum then + for i = 1, numOpenSlots, 1 do + if i ~= slotNum then + table.insert(keys, 'move_player_to_slot' .. i) + table.insert(strings, LOCF("Move AI to slot %s", i)) + table.insert(tooltips, nil) + end + end + end + table.destructiveCat(keys, AIKeys) + table.destructiveCat(strings, AIStrings) + table.destructiveCat(tooltips, AITooltips) + elseif key == 'move' then + -- Generate the "move player to slot X" entries. + for i = 1, numOpenSlots, 1 do + if i ~= slotNum then + table.insert(keys, 'move_player_to_slot' .. i) + table.insert(strings, LOCF("Move Player to slot %s", i)) + table.insert(tooltips, nil) + end + end + else + if not (isPlayerReady and key == 'occupy') then + table.insert(keys, key) + table.insert(strings, slotMenuStrings[key]) + -- Add a tooltip key here if we ever get any interesting options. + table.insert(tooltips, nil) + end + end + end + + return keys, strings, tooltips +end + +--- Get the value of the LastColor, sanitised in case it's an unsafe value. +-- In case a new patch removes a color +function GetSanitisedLastColor() + local lastColor = Prefs.GetFromCurrentProfile('LastColorFAF') or 1 + if lastColor > table.getn(gameColors.PlayerColors) or lastColor < 1 then + lastColor = 1 + end + + return lastColor +end + +--- Get the value of the LastFaction, sanitised in case it's an unsafe value. +-- +-- This means when some retarded mod (*cough*Nomads*cough*) writes a large number to LastFaction, we +-- don't catch fire. +function GetSanitisedLastFaction() + local lastFaction = Prefs.GetFromCurrentProfile('LastFaction') or 1 + if lastFaction > table.getn(FactionData.Factions) + 1 or lastFaction < 1 then + lastFaction = 1 + end + + return lastFaction +end + +--- Get a PlayerData object for the local player, configured using data from their profile. +function GetLocalPlayerData() + + local version, gametype, commit = import("/lua/version.lua").GetVersionData() + + return PlayerData( + { + PlayerName = localPlayerName, + OwnerID = localPlayerID, + Human = true, + PlayerColor = GetSanitisedLastColor(), + Faction = GetSanitisedLastFaction(), + PlayerClan = argv.playerClan, + PL = playerRating, + NG = argv.numGames, + MEAN = argv.playerMean, + DEV = argv.playerDeviation, + Country = argv.PrefLanguage, + + Version = version, + GameType = gametype, + Commit = commit, + + Ready = argv.debugLobby, + } +) +end + +--- Compute an estimation of the rating of the given AI. The values originate from 'aitypes.lua' +---@param gameOptions table +---@param aiLobbyProperties AILobbyProperties +---@return number +function ComputeAIRating(gameOptions, aiLobbyProperties) + + if not aiLobbyProperties then + return 0 + end + + if not aiLobbyProperties.rating then + return 0 + end + + if not gameInfo.GameOptions.ScenarioFile then + return 0 + end + + -- try and take into account map + local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + if not (scenarioInfo and scenarioInfo.size and scenarioInfo.size[1] and scenarioInfo.size[2]) then + return 0 + end + + -- clamp the value + local maparea = math.max(scenarioInfo.size[1], scenarioInfo.size[2]) + if maparea < 256 then + maparea = 256 + elseif maparea > 4096 then + maparea = 4096 + end + + -- process various multipliers to determine rating + local mapMultiplier = aiLobbyProperties.ratingMapMultiplier[maparea] or 1.0 + local cheatBuildMultiplier = (tonumber(gameOptions.BuildMult) or 1.0) - 1.0 + local cheatResourceMultiplier = (tonumber(gameOptions.CheatMult) or 1.0) - 1.0 + + -- compute the rating + local cheatBuildValue = (aiLobbyProperties.ratingBuildMultiplier or 0.0) * cheatBuildMultiplier + local cheatResourceValue = (aiLobbyProperties.ratingCheatMultiplier or 0.0) * cheatResourceMultiplier + local cheatOmniValue = (gameOptions.OmniCheat == 'on' and aiLobbyProperties.ratingOmniBonus) or 0.0 + local rating = mapMultiplier * (aiLobbyProperties.rating + cheatBuildValue + cheatResourceValue + cheatOmniValue) + + -- prevent very low numbers + if rating < aiLobbyProperties.ratingNegativeThreshold then + rating = aiLobbyProperties.ratingNegativeThreshold + (rating - aiLobbyProperties.ratingNegativeThreshold) * 0.2 + end + + return math.floor(rating) +end + +function GetAIPlayerData(name, AIPersonality, slot) + local AIColor + -- gets the color of the player/AI occupying the slot directly prior if available + for i = 1, LobbyComm.maxPlayerSlots do + if gameInfo.PlayerOptions[i].StartSpot == slot then + if IsColorFree(gameInfo.PlayerOptions[i].PlayerColor, slot) then + AIColor = gameInfo.PlayerOptions[i].PlayerColor + end + break + end + end + if not AIColor then + AIColor = GetAvailableColor() + end + + -- retrieve properties from AI table + ---@type AILobbyProperties | nil + local aiLobbyProperties = nil + for k, entry in aitypes do + if entry.key == AIPersonality then + aiLobbyProperties = entry + end + end + local iRating = ComputeAIRating(gameInfo.GameOptions, aiLobbyProperties) + + return PlayerData( + { + OwnerID = hostID, + PlayerName = name, + Ready = true, + Human = false, + AIPersonality = AIPersonality, + PlayerColor = AIColor, + ArmyColor = AIColor, + + PL = iRating, + MEAN = iRating, + DEV = 0, + + -- keep track of the AI lobby properties for easier access + AILobbyProperties = aiLobbyProperties, + } +) +end + +local function DoSlotBehavior(slot, key, name) + if key == 'open' then + HostUtils.SetSlotClosed(slot, false) + elseif key == 'close' then + HostUtils.SetSlotClosed(slot, true) + elseif key == 'close_spawn_mex' then + HostUtils.SetSlotClosedSpawnMex(slot) + elseif key == 'occupy' then + if IsPlayer(localPlayerID) and not gameInfo.PlayerOptions[FindSlotForID(localPlayerID)].Ready then + if lobbyComm:IsHost() then + HostUtils.MovePlayerToEmptySlot(FindSlotForID(localPlayerID), slot) + else + lobbyComm:SendData(hostID, {Type = 'MovePlayer', RequestedSlot = slot}) + end + elseif IsObserver(localPlayerID) then + if lobbyComm:IsHost() then + local requestedFaction = GetSanitisedLastFaction() + HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(localPlayerID), slot) + else + lobbyComm:SendData( + hostID, + { + Type = 'RequestConvertToPlayer', + ObserverSlot = FindObserverSlotForID(localPlayerID), + PlayerSlot = slot + } + ) + end + end + UpdateFactionSelector() + elseif key == 'pm' then + if gameInfo.PlayerOptions[slot].Human then + GUI.chatEdit:SetText(string.format("/whisper %s ", gameInfo.PlayerOptions[slot].PlayerName)) + end + -- Handle the various "Move to slot X" options. + elseif string.sub(key, 1, 19) == 'move_player_to_slot' then + HostUtils.SwapPlayers(slot, tonumber(string.sub(key, 20))) + elseif key == 'remove_to_observer' then + local playerInfo = gameInfo.PlayerOptions[slot] + if playerInfo.Human then + HostUtils.ConvertPlayerToObserver(slot) + end + elseif key == 'remove_to_kik' then + if gameInfo.PlayerOptions[slot].Human then + local kickMessage = function(self, str) + local msg + + msg = "\n Kicked by host. \n Reason: " .. str + + SendSystemMessage("lobui_0756", gameInfo.PlayerOptions[slot].PlayerName) + lobbyComm:EjectPeer(gameInfo.PlayerOptions[slot].OwnerID, msg) + + -- Save message for next time + Prefs.SetToCurrentProfile('lastKickMessage', UTF.EscapeString(str)) + lastKickMessage = str + end + + CreateInputDialog(GUI, "Are you sure?", kickMessage, lastKickMessage) + else + HostUtils.RemoveAI(slot) + end + else + -- We're adding an AI of some sort. + if lobbyComm:IsHost() then + HostUtils.AddAI(name, key, slot) + end + end +end + +local function IsModAvailable(modId) + for k,v in availableMods do + if not v[modId] then + return false + end + end + return true +end + + +function Reset() + lobbyComm = false + localPlayerName = "" + gameName = "" + hostID = false + singlePlayer = false + GUI = false + localPlayerID = false + availableMods = {} + selectedUIMods = Mods.GetSelectedUIMods() + selectedSimMods = Mods.GetSelectedSimMods() + numOpenSlots = LobbyComm.maxPlayerSlots + gameInfo = GameInfo.CreateGameInfo(LobbyComm.maxPlayerSlots) +end + +--- Create a new, unconnected lobby. +function ReallyCreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider, over, exitBehavior, playerHasSupcom) + Reset() + + -- Among other things, this clears uimain's override escape handler, allowing our escape + -- handler manager to work. + MenuCommon.MenuCleanup() + + if GUI then + WARN('CreateLobby called twice for UI construction (Should be unreachable)') + GUI:Destroy() + return + end + + -- Make sure we have a profile + if not GetPreference("profile.current") then + Prefs.CreateProfile("FAF_"..desiredPlayerName) + end + + GUI = UIUtil.CreateScreenGroup(over, "CreateLobby ScreenGroup") + + GUI.exitBehavior = exitBehavior + + GUI.optionControls = {} + GUI.slots = {} + + -- Set up the base escape handler first: want this one at the bottom of the stack. + GUI.exitLobbyEscapeHandler = function() + GUI.chatEdit:AbandonFocus() + local quitDialog = UIUtil.QuickDialog(GUI, + "Exit game lobby?", + "", function() + EscapeHandler.PopEscapeHandler() + if HasCommandLineArg("/gpgnet") then + -- Quit to desktop + EscapeHandler.SafeQuit() + else + -- Back to main menu + ReturnToMenu(false) + end + end, + + -- Fight to keep our focus on the chat input box, to prevent keybinding madness. + "", function() + GUI.chatEdit:AcquireFocus() + end, + nil, nil, + true, + {escapeButton = 2, enterButton = 1, worldCover = true} + ) + end + EscapeHandler.PushEscapeHandler(GUI.exitLobbyEscapeHandler) + + GUI.connectdialog = UIUtil.ShowInfoDialog(GUI, Strings.TryingToConnect, Strings.AbortConnect, ReturnToMenu) + -- Prevent the dialog from being closed due to user action. + GUI.connectdialog.OnEscapePressed = function() end + GUI.connectdialog.OnShadowClicked = function() end + + InitLobbyComm(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider) + + -- Store off the validated playername + localPlayerName = lobbyComm:GetLocalPlayerName() + local Prefs = import("/lua/user/prefs.lua") + local windowed = Prefs.GetFromCurrentProfile('WindowedLobby') or 'false' + SetWindowedLobby(windowed == 'true') + +end + +-- A map from message types to functions that process particular message types. +local MESSAGE_HANDLERS = { + -- TODO: Finalise signature and semantics. + ConnectivityState = function() + end +} + +--- Handle an incoming message from the FAF client via the GPGNet protocol. +-- +-- @param jsonBlob A JSON string containing the message to process. +-- Messages are JSON strings containing two fields: +-- command_id: A string identifying the type of message. This string is used as a key into +-- MESSAGE_HANDLERS to find the function to use to process this message. +-- arguments: An array of arguments that should be passed to the handler function. +function HandleGPGNetMessage(jsonBlob) + local jsonObj = JSON.decode(jsonBlob) + table.print(jsonObj) + local handler = MESSAGE_HANDLERS[jsonObj.command_id] + if not handler then + WARN("Incomprehensible JSON message: \n" .. jsonBlob) + return + end + + handler(unpack(jsonObj.arguments)) +end + +--- Start a synchronous replay session +-- +-- @param replayID The ID of the replay to download and play. +function StartSyncReplaySession(replayID) + SetFrontEndData('syncreplayid', replayID) + local dl = UIUtil.QuickDialog(GetFrame(0), "Downloading the replay file...") + LaunchReplaySession('gpgnet://' .. GetCommandLineArg('/gpgnet',1)[1] .. '/' .. import("/lua/user/prefs.lua").GetFromCurrentProfile('Name')) + dl:Destroy() + UIUtil.QuickDialog(GetFrame(0), "You dont have this map.", "Exit", function() ExitApplication() end) +end + +--- Create a new unconnected lobby/Entry point for processing messages sent from the FAF lobby. +-- +-- This function is called exactly once by the game when a new lobby should be created. +-- @see ReallyCreateLobby +-- +-- This function is called whenever the FAF lobby sends a message into the game, with the message +-- in the desiredPlayerName parameter as a JSON string with a length no greater than 4061 bytes. +-- This madness is justified by this being one of the smallish number of functions we can have +-- called from outside. +-- @see HandleGPGNetMessage +-- +-- This function is also called by the sync replay server when a session should be started. (this +-- should probably be refactored to use the JSON messenger protocol) +-- @see StartSyncReplaySession +function CreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider, over, exitBehavior, playerHasSupcom) + -- Is this an incoming GPGNet message? + if localPort == -1 then + HandleGPGNetMessage(desiredPlayerName) + return + end + + -- Special-casing for sync-replay. + -- TODO: Consider replacing this with a gpgnet message type. + if IsSyncReplayServer then + StartSyncReplaySession(localPlayerUID) + return + end + + -- Okay, so we actually are creating a lobby, instead of doing some ridiculous hack. + ReallyCreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider, over, exitBehavior, playerHasSupcom) +end + +-- create the lobby as a host +function HostGame(desiredGameName, scenarioFileName, inSinglePlayer) + singlePlayer = inSinglePlayer + gameName = lobbyComm:MakeValidGameName(desiredGameName) + lobbyComm.desiredScenario = string.gsub(scenarioFileName, ".v%d%d%d%d_scenario.lua", "_scenario.lua") + lobbyComm:HostGame() +end + +-- join an already existing lobby +function JoinGame(address, asObserver, playerName, uid) + lobbyComm:JoinGame(address, playerName, uid) +end + +function ConnectToPeer(addressAndPort,name,uid) + if not string.find(addressAndPort, '127.0.0.1') then + LOG("ConnectToPeer (name=" .. name .. ", uid=" .. uid .. ", address=" .. addressAndPort ..")") + else + DisconnectFromPeer(uid) + LOG("ConnectToPeer (name=" .. name .. ", uid=" .. uid .. ", address=" .. addressAndPort ..", USE PROXY)") + table.insert(ConnectedWithProxy, uid) + end + lobbyComm:ConnectToPeer(addressAndPort,name,uid) +end + +function DisconnectFromPeer(uid) + LOG("DisconnectFromPeer (uid=" .. uid ..")") + if wasConnected(uid) then + table.remove(connectedTo, uid) + end + GpgNetSend('Disconnected', string.format("%d", uid)) + lobbyComm:DisconnectFromPeer(uid) +end + +function SetHasSupcom(cmd) + -- TODO: Refactor SyncReplayServer gubbins to use generalised JSON protocol. + if IsSyncReplayServer then + if cmd == 0 then + SessionResume() + elseif cmd == 1 then + SessionRequestPause() + end + end +end + +function SetHasForgedAlliance(speed) + if IsSyncReplayServer then + if GetGameSpeed() ~= speed then + SetGameSpeed(speed) + end + end +end + +-- TODO: These functions are dumb. We have these things called "hashmaps". +function FindSlotForID(id) + for k, player in gameInfo.PlayerOptions:pairs() do + if player.OwnerID == id and player.Human then + return k + end + end + return nil +end + +function FindRehostSlotForID(id) + for index, player in ipairs(rehostPlayerOptions) do + if player.OwnerID == id and player.Human then + return player.StartSpot + end + end + return nil +end + +function FindNameForID(id) + if (IsObserver(id)) then + return (FindObserverNameForID(id)) + end + + for k, player in gameInfo.PlayerOptions:pairs() do + if player.OwnerID == id and player.Human then + return player.PlayerName + end + end + return nil +end + +function FindIDForName(name) + for k, player in gameInfo.PlayerOptions:pairs() do + if player.PlayerName == name and player.Human then + return player.OwnerID + end + end + return nil +end + +function FindObserverSlotForID(id) + for k, observer in gameInfo.Observers:pairs() do + if observer.OwnerID == id then + return k + end + end + + return nil +end + +function FindObserverNameForID(id) + for k, observer in gameInfo.Observers:pairs() do + if observer.OwnerID == id then + return observer.PlayerName + end + end + return nil +end + +function IsLocallyOwned(slot) + return gameInfo.PlayerOptions[slot].OwnerID == localPlayerID +end + +function IsPlayer(id) + return FindSlotForID(id) ~= nil +end + +function IsObserver(id) + return FindObserverSlotForID(id) ~= nil +end + +function UpdateSlotBackground(slotIndex) + if gameInfo.ClosedSlots[slotIndex] then + GUI.slots[slotIndex].SlotBackground:SetTexture(UIUtil.UIFile('/SLOT/slot-dis.dds')) + else + if gameInfo.PlayerOptions[slotIndex] then + GUI.slots[slotIndex].SlotBackground:SetTexture(UIUtil.UIFile('/SLOT/slot-player.dds')) + else + GUI.slots[slotIndex].SlotBackground:SetTexture(UIUtil.UIFile('/SLOT/slot-player_other.dds')) + end + end +end + +function GetPlayerDisplayName(playerInfo) + local playerName = playerInfo.PlayerName + local displayName = "" + if playerInfo.PlayerClan ~= "" then + return string.format("[%s] %s", playerInfo.PlayerClan, playerInfo.PlayerName) + else + return playerInfo.PlayerName + end +end + +-- Refresh (with a sledgehammer) all the items in the observer list. +local function refreshObserverList() + GUI.observerList:DeleteAllItems() + + -- create the table that will hold the data for displaying team rating information + local teamRatings = {} + local numTeams = 0 + -- calculate/display team ratings if spawns are fixed + if gameInfo.GameOptions['TeamSpawn'] == 'fixed' then + + -- cycle through each player + for i, player in gameInfo.PlayerOptions:pairs() do + + -- get the team number (which is 1 higher on the backend) + local team = player.Team - 1 + -- add the player's rating information if the player is on a team + if team > 0 then + -- make sure the team is included in the teamRatings table + if teamRatings[team] == nil then + -- initialize the team's rating in this table as having 0 mean and 0 deviation, respectively + teamRatings[team] = {0, 0} + end + -- add the player's rating information (mean and deviation) to the its team's totals + teamRatings[team] = {teamRatings[team][1] + player.MEAN, teamRatings[team][2] + player.DEV} + end + end + + for i, team in teamRatings do + numTeams = numTeams + 1 + end + + -- if there are 1 or 2 teams, list them before observers + if numTeams == 1 or numTeams == 2 then + if not lobbyComm:IsHost() then + GUI.observerList:AddItem(LOC('Team Ratings:')) + end + for i, rating in teamRatings do + GUI.observerList:AddItem( + LOCF('Team %d: %d (%d+/-%d)', i, math.round(rating[1] - rating[2] * 3), math.round(rating[1]), math.round(rating[2] * 3)) + ) + end + if not lobbyComm:IsHost() then + GUI.observerList:AddItem('') + end + end + end + + + local observers = false + + for slot, observer in gameInfo.Observers:pairs() do + + if not observers then + observers = true + if not lobbyComm:IsHost() then + GUI.observerList:AddItem(LOC('Observers')..':') + end + end + + observer.ObserverListIndex = GUI.observerList:GetItemCount() -- Pin-head William made this zero-based + + -- Create a label for this observer of the form: + -- PlayerName (R:xxx, P:xxx, C:xxx) + -- Such conciseness is necessary as the field in the UI is rather narrow... + local observer_label = observer.PlayerName .. " (R:" .. observer.PL + + -- Add the ping only if this entry refers to a different client. + if observer and (observer.OwnerID ~= localPlayerID) and observer.ObserverListIndex then + local peer = lobbyComm:GetPeer(observer.OwnerID) + + local ping = 0 + if peer.ping ~= nil then + ping = math.floor(peer.ping) + end + + observer_label = observer_label .. ", P:" .. ping + end + + -- Add the CPU score if one is available. + local score_CPU = CPU_Benchmarks[observer.PlayerName] + if score_CPU then + observer_label = observer_label .. ", C:" .. score_CPU + end + observer_label = observer_label .. ")" + + GUI.observerList:AddItem(observer_label) + end + + -- if there are more than 2 teams (and slots are fixed), list them after observers + if numTeams > 2 then + if not lobbyComm:IsHost() then + GUI.observerList:AddItem('') + GUI.observerList:AddItem(LOC('Team Ratings:')) + end + for i, rating in teamRatings do + GUI.observerList:AddItem( + LOCF('Team %d: %d (%d+/-%d)', i, math.round(rating[1] - rating[2] * 3), math.round(rating[1]), math.round(rating[2] * 3)) + ) + end + end +end + +local WVT = import("/lua/ui/lobby/data/watchedvalue/watchedvaluetable.lua") + +-- update the data in a player slot +-- TODO: With lazyvars, this function should be eliminated. Lazy-value-callbacks should be used +-- instead to incrementaly update things. +function SetSlotInfo(slotNum, playerInfo) + -- Remove the ConnectDialog. It probably makes more sense to do this when we get the game state. + if GUI.connectdialog then + GUI.connectdialog:Close() + GUI.connectdialog = nil + + -- ChangelogDialog, if necessary. + local changelogDialogManager = import("/lua/ui/lobby/changelog/changelogdialog.lua") + if changelogDialogManager.ShouldOpenChangelog() then + changelogDialogManager.CreateChangelogDialog(GetFrame(0)) + end + end + + playerInfo.StartSpot = slotNum + + local slot = GUI.slots[slotNum] + local isHost = lobbyComm:IsHost() + local isLocallyOwned = IsLocallyOwned(slotNum) + + -- Set enabledness of controls according to host privelage etc. + -- Yeah, we set it twice. No, it's not brilliant. Blurgh. + local facColEnabled = isLocallyOwned or (isHost and not playerInfo.Human) + UIUtil.setEnabled(slot.faction, facColEnabled) + UIUtil.setEnabled(slot.color, facColEnabled) + + -- Possibly override it due to the ready box. + if isLocallyOwned then + if playerInfo.Ready and playerInfo.Human then + DisableSlot(slotNum, true) + else + EnableSlot(slotNum) + end + else + DisableSlot(slotNum) + end + + --- Returns true if the team selector for this slot should be enabled. + -- + -- The predicate was getting unpleasantly long to read. + local function teamSelectionEnabled(autoTeams, ready, locallyOwned, isHost) + -- If autoteams has control, no selector for you. + if autoTeams ~= 'none' then + return false + end + + if isHost and not playerInfo.Human then + return true + end + + -- You can control your own one when you're not ready. + if locallyOwned then + return not ready + end + + if isHost then + -- The host can control the team of others, provided he's not ready himself. + local slot = FindSlotForID(localPlayerID) + local is_ready = slot and gameInfo.PlayerOptions[slot].Ready -- could be observer + + return not is_ready + end + end + + -- Disable team selection if "auto teams" is controlling it. Moderatelty ick. + local autoTeams = gameInfo.GameOptions.AutoTeams + UIUtil.setEnabled(slot.team, teamSelectionEnabled(autoTeams, playerInfo.Ready, isLocallyOwned, isHost)) + + local hostKey + if isHost then + hostKey = 'host' + else + hostKey = 'client' + end + + -- These states are used to select the appropriate strings with GetSlotMenuTables. + local slotState + if not playerInfo.Human then + slot.ratingText:Hide() + slotState = 'ai' + elseif not isLocallyOwned then + slotState = 'player' + else + slotState = nil + end + + slot.name:ClearItems() + + if slotState then + slot.name:Enable() + local slotKeys, slotStrings, slotTooltips = GetSlotMenuTables(slotState, hostKey, slotNum) + slot.name.slotKeys = slotKeys + + if not table.empty(slotKeys) then + slot.name:AddItems(slotStrings) + slot.name:Enable() + Tooltip.AddComboTooltip(slot.name, slotTooltips) + else + slot.name.slotKeys = nil + slot.name:Disable() + Tooltip.RemoveComboTooltip(slot.name) + end + else + -- no slotState indicate this must be ourself, and you can't do anything to yourself + slot.name.slotKeys = nil + slot.name:Disable() + end + + slot.ratingText:Show() + slot.ratingText:SetText(playerInfo.PL) + slot.ratingText:SetColor("ffffffff") + + -- dynamic tooltip to show rating and deviation for each player + local tooltipText = {} + tooltipText['text'] = LOC("Rating") + tooltipText['body'] = LOCF("%s's TrueSkill Rating is %s +/- %s", playerInfo.PlayerName, math.round(playerInfo.MEAN), math.ceil(playerInfo.DEV * 3)) + slot.tooltiprating = Tooltip.AddControlTooltip(slot.ratingText, tooltipText) + + slot.numGamesText:Show() + slot.numGamesText:SetText(playerInfo.NG) + + slot.name:Show() + -- Change name colour according to the state of the slot. + if slotState == 'ai' then + slot.name:SetTitleTextColor("dbdbb9") -- Beige Color for AI + slot.name._text:SetFont('Arial Gras', 12) + elseif FindSlotForID(hostID) == slotNum then + slot.name:SetTitleTextColor("ffc726") -- Orange Color for Host + slot.name._text:SetFont('Arial Gras', 15) + elseif slotState == 'player' then + slot.name:SetTitleTextColor("64d264") -- Green Color for Players + slot.name._text:SetFont('Arial Gras', 15) + elseif isLocallyOwned then + slot.name:SetTitleTextColor("6363d2") -- Blue Color for You + slot.name._text:SetFont('Arial Gras', 15) + else + slot.name:SetTitleTextColor(UIUtil.fontColor) -- Normal Color for Other + slot.name._text:SetFont('Arial Gras', 12) + end + + local playerName = playerInfo.PlayerName + if wasConnected(playerInfo.OwnerID) or isLocallyOwned or not playerInfo.Human then + slot.name:SetTitleText(GetPlayerDisplayName(playerInfo)) + slot.name._text:SetFont('Arial Gras', 15) + if not table.find(ConnectionEstablished, playerName) then + if playerInfo.Human and not isLocallyOwned then + AddChatText(LOCF("Connection to %s established.", playerName)) + + table.insert(ConnectionEstablished, playerName) + for k, v in CurrentConnection do + if v == playerName then + CurrentConnection[k] = nil + break + end + end + end + end + else + slot.name:SetTitleText(LOCF('Connecting to %s...', playerName)) + slot.name._text:SetFont('Arial Gras', 11) + end + + slot.faction:Show() + + -- Check if faction is possible for that slot, if not set to random + -- For example: AIs always start with faction 5, so that needs to be adjusted to fit in slot.Faction + if table.getn(slot.AvailableFactions) < playerInfo.Faction then + playerInfo.Faction = table.getn(slot.AvailableFactions) + end + slot.faction:SetItem(playerInfo.Faction) + + slot.color:Show() + Check_Availaible_Color(slotNum) + + slot.team:Show() + slot.team:SetItem(playerInfo.Team) + + -- Send team data to the server + if isHost then + HostUtils.SendPlayerSettingsToServer(slotNum) + end + + UIUtil.setVisible(slot.ready, playerInfo.Human and not singlePlayer) + slot.ready:SetCheck(playerInfo.Ready, true) + + if isLocallyOwned and playerInfo.Human then + Prefs.SetToCurrentProfile('LastColorFAF', playerInfo.PlayerColor) + Prefs.SetToCurrentProfile('LastFaction', playerInfo.Faction) + end + + -- Show the player's nationality + if not playerInfo.Country then + slot.KinderCountry:Hide() + else + slot.KinderCountry:Show() + slot.KinderCountry:SetTexture(UIUtil.UIFile('/countries/'..playerInfo.Country..'.dds')) + + Tooltip.AddControlTooltip(slot.KinderCountry, {text=LOC("Country"), body=LOC(CountryTooltips[playerInfo.Country])}) + end + + UpdateSlotBackground(slotNum) + + -- Set the CPU bar + SetSlotCPUBar(slotNum, playerInfo) + + ShowGameQuality() + RefreshMapPositionForAllControls(slotNum) + + if isHost then + HostUtils.RefreshButtonEnabledness() + end + refreshObserverList() +end + +function ClearSlotInfo(slotIndex) + local slot = GUI.slots[slotIndex] + + local hostKey + if lobbyComm:IsHost() then + GpgNetSend('ClearSlot', slotIndex) + hostKey = 'host' + else + hostKey = 'client' + end + + local stateKey + local stateText + if gameInfo.ClosedSlots[slotIndex] and gameInfo.SpawnMex[slotIndex] and gameInfo.AdaptiveMap then + stateKey = 'closed_spawn_mex' + stateText = slotMenuStrings.closed_spawn_mex + elseif gameInfo.ClosedSlots[slotIndex] then + gameInfo.SpawnMex[slotIndex] = false + stateKey = 'closed' + stateText = slotMenuStrings.closed + else + stateKey = 'open' + stateText = slotMenuStrings.open + end + + local slotKeys, slotStrings, slotTooltips = GetSlotMenuTables(stateKey, hostKey) + + -- set the text appropriately + slot.name:ClearItems() + slot.name:SetTitleText(LOC(stateText)) + if not table.empty(slotKeys) then + slot.name.slotKeys = slotKeys + slot.name:AddItems(slotStrings) + Tooltip.AddComboTooltip(slot.name, slotTooltips) + slot.name:Enable() + else + slot.name.slotKeys = nil + slot.name:Disable() + Tooltip.RemoveComboTooltip(slot.name) + end + + slot.name._text:SetFont('Arial Gras', 12) + if stateKey == 'closed' then + slot.name:SetTitleTextColor("Crimson") + elseif stateKey == 'closed_spawn_mex' then + slot.name:SetTitleTextColor("2c7f33") + else + slot.name:SetTitleTextColor('B9BFB9') + end + + slot:HideControls() + + UpdateSlotBackground(slotIndex) + ShowGameQuality() + RefreshMapPositionForAllControls(slotIndex) + Check_Availaible_Color() + refreshObserverList() +end + +function IsColorFree(colorIndex, currentSlotNumber) + for id, player in gameInfo.PlayerOptions:pairs() do + if player.PlayerColor == colorIndex then + if currentSlotNumber then + if player.StartSpot != currentSlotNumber then + return false + end + else + return false + end + end + end + + return true +end + +function GetPlayerCount() + local numPlayers = 0 + for k,player in gameInfo.PlayerOptions:pairs() do + if player then + numPlayers = numPlayers + 1 + end + end + return numPlayers +end + +local function GetPlayersNotReady() + local notReady = false + for k,v in gameInfo.PlayerOptions:pairs() do + if v.Human and not v.Ready then + if not notReady then + notReady = {} + end + table.insert(notReady, v.PlayerName) + end + end + + return notReady +end + +local function GetRandomFactionIndex(slotNumber) + local randomfaction = nil + local counter = 50 + while counter > 0 do + counter = (counter - 1) + randomfaction = math.random(1, table.getn(GUI.slots[slotNumber].AvailableFactions) - 1) + end + return randomfaction +end + +local function AssignRandomFactions() + for index, player in gameInfo.PlayerOptions do + -- No random if there is only 1 option + if table.getn(GUI.slots[index].AvailableFactions) >= 2 then + local randomFactionID = table.getn(GUI.slots[index].AvailableFactions) + -- note that this doesn't need to be aware if player has supcom or not since they would only be able to select + -- the random faction ID if they have supcom + if player.Faction >= randomFactionID then + player.Faction = GetRandomFactionIndex(index) + end + end + end +end + +-- Convert the local (slot dependend) faction indexes to the global faction indexes +local function FixFactionIndexes() + for index, player in gameInfo.PlayerOptions do + local playerFaction = GUI.slots[index].AvailableFactions[player.Faction] + for i,v in allAvailableFactionsList do + if v == playerFaction then + player.Faction = i + continue + end + end + end + +end + +--------------------------- +-- autobalance functions -- +--------------------------- +local function team_sort_by_sum(t1, t2) + return t1['sum'] < t2['sum'] +end + +local function autobalance_bestworst(players, teams_arg) + local players = table.deepcopy(players) + local result = {} + local best = true + local teams = {} + + for t, slots in teams_arg do + table.insert(teams, {team=t, slots=table.deepcopy(slots), sum=0}) + end + + -- teams first picks best player and then worst player, repeat + while not table.empty(players) do + for i, t in teams do + local team = t['team'] + local slots = t['slots'] + local slot = table.remove(slots, 1) + if not slot then continue end + local player + + if best then + player = table.remove(players, 1) + else + player = table.remove(players) + end + + if not player then break end + + teams[i]['sum'] = teams[i]['sum'] + player['rating'] + table.insert(result, {player=player['pos'], rating=player['rating'], team=team, slot=slot}) + end + + best = not best + if best then + table.sort(teams, team_sort_by_sum) + end + end + + return result +end + +local function autobalance_avg(players, teams_arg) + local players = table.deepcopy(players) + local result = {} + local teams = {} + local max_sum = 0 + + for t, slots in teams_arg do + table.insert(teams, {team=t, slots=table.deepcopy(slots), sum=0}) + end + + while not table.empty(players) do + local first_team = true + for i, t in teams do + local team = t['team'] + local slots = t['slots'] + local slot = table.remove(slots, 1) + if not slot then continue end + local player + local player_key + + for j, p in players do + player_key = j + if first_team or t['sum'] + p['rating'] <= max_sum then + break + end + end + + player = table.remove(players, player_key) + if not player then break end + + teams[i]['sum'] = teams[i]['sum'] + player['rating'] + max_sum = math.max(max_sum, teams[i]['sum']) + table.insert(result, {player=player['pos'], rating=player['rating'], team=team, slot=slot}) + first_team = false + end + + table.sort(teams, team_sort_by_sum) + end + + return result +end + +local function autobalance_rr(players, teams) + local players = table.deepcopy(players) + local teams = table.deepcopy(teams) + local result = {} + + local team_picks = {} + local i = 1 + for team, slots in teams do + table.insert(team_picks, {team=team, sum=i}) + i = i + 1 + end + + while not table.empty(players) do + for i, pick in team_picks do + local slot = table.remove(teams[pick.team], 1) + if not slot then continue end + local player = table.remove(players, 1) + if not player then break end + pick.sum = pick.sum + i + + table.insert(result, {player=player.pos, rating=player.rating, team=pick.team, slot=slot}) + end + + table.sort(team_picks, function(a, b) return a.sum > b.sum end) + end + + return result +end + +local function autobalance_random(players, teams_arg) + local players = table.deepcopy(players) + local result = {} + local teams = {} + + players = table.shuffle(players) + + for t, slots in teams_arg do + table.insert(teams, {team=t, slots=table.deepcopy(slots)}) + end + + while not table.empty(players) do + for _, t in teams do + local team = t['team'] + local slot = table.remove(t['slots'], 1) + if not slot then continue end + local player = table.remove(players, 1) + + if not player then break end + + table.insert(result, {player=player['pos'], rating=player['rating'], team=team, slot=slot}) + end + end + + return result +end + +function autobalance_quality(players) + local teams = nil + local quality = 0 + + for _, p in players do + local i = p['player'] + local team = p['team'] + local playerInfo = gameInfo.PlayerOptions[i] + local player = Player.create(playerInfo.PlayerName, + Rating.create(playerInfo.MEAN or 1500, playerInfo.DEV or 500)) + + if not teams then + teams = Teams.create() + end + + teams:addPlayer(team, player) + end + + if teams and table.getn(teams:getTeams()) > 1 then + quality = Trueskill.computeQuality(teams) + end + + return quality +end + +--- If the game is full, GPGNetSend about it so the client can do a fancy popup if it has focus. +function PossiblyAnnounceGameFull() + -- Search for an empty non-closed slot. + for i = 1, numOpenSlots do + if not gameInfo.ClosedSlots[i] then + if not gameInfo.PlayerOptions[i] then + return + end + end + end + + -- Game is full, let's tell the client. + GpgNetSend("GameFull") +end + +local function AssignRandomStartSpots() + local teamSpawn = gameInfo.GameOptions['TeamSpawn'] + + if teamSpawn == 'fixed' or teamSpawn == 'penguin_autobalance' then + return + end + + function teamsAddSpot(teams, team, spot) + if not teams[team] then + teams[team] = {} + end + table.insert(teams[team], spot) + end + + -- rearrange players according to the provided setup + function rearrangePlayers(data) + gameInfo.GameOptions['Quality'] = data.quality + + -- Copy a reference to each of the PlayerData objects indexed by their original slots. + local orgPlayerOptions = {} + for k, p in gameInfo.PlayerOptions do + orgPlayerOptions[k] = p + end + + local mirrored = string.find(teamSpawn, 'mirrored') + if mirrored then + local rating_cmp = function(a,b) return a.rating > b.rating end + local slot_cmp = function(a,b) return a.slot < b.slot end + + function getMasterOrder(sortedSlots) + local masterOrder = {} + + local slot2nr = {} + for k, p in sortedSlots.byNr do + slot2nr[p.slot] = k + end + + for k, p in sortedSlots.byRating do + table.insert(masterOrder, slot2nr[p.slot]) + end + + return masterOrder + end + + function teamsSameSize(slots) + local size + + for t, sorted in slots do + local s = table.getn(sorted.byNr) + if not size then size = s end + + if size ~= s then return false end + end + + return true + end + + function reorderSlots(sortedSlots, masterOrder) + local newSlots = {} + for i, j in masterOrder do + table.insert(newSlots, sortedSlots.byNr[j].slot) + end + + for i, s in newSlots do + sortedSlots.byRating[i].slot = s + end + end + + local slots = {} + local masterTeam + + for _, p in data.setup do + if not slots[p.team] then slots[p.team] = {} end + if not slots[p.team].byNr then slots[p.team].byNr = {} end + if not slots[p.team].byRating then slots[p.team].byRating = {} end + if not masterTeam then masterTeam = p.team end + + table.binsert(slots[p.team].byNr, p, slot_cmp) + table.binsert(slots[p.team].byRating, p, rating_cmp) + end + + -- abort mirroring if team sizes differ + if not teamsSameSize(slots) then + WARN("Mirroring disabled due to teams not having the same number of players") + else + local masterOrder = getMasterOrder(slots[masterTeam]) + for t, sorted in slots do + reorderSlots(sorted, masterOrder) + end + end + end + + -- Rearrange the players in the slots to match the chosen configuration. The result object + -- maps old slots to new slots, and we use orgPlayerOptions to avoid losing a reference to + -- an object (and because swapping is too much like hard work). + gameInfo.PlayerOptions = {} + for _, r in data.setup do + local playerOptions = orgPlayerOptions[r.player] + playerOptions.Team = r.team + 1 + playerOptions.StartSpot = r.slot + gameInfo.PlayerOptions[r.slot] = playerOptions + + -- Send team data to the server + local playerInfo = gameInfo.PlayerOptions[r.slot] + HostUtils.SendPlayerSettingsToServer(r.slot) + end + end + + local numAvailStartSpots = GetNumAvailStartSpots() + + local AutoTeams = gameInfo.GameOptions.AutoTeams + local positionGroups = {} + local teams = {} + + -- Used to actualise the virtual teams produced by the "Team -" no-team team. + local synthesizedTeamCounter = 9 + for i = 1, numAvailStartSpots do + if not gameInfo.ClosedSlots[i] then + local team = nil + local group = nil + + if AutoTeams == 'lvsr' then + local midLine = GUI.mapView.Left() + (GUI.mapView.Width() / 2) + local markerPos = GUI.mapView.startPositions[i].Left() + + if markerPos < midLine then + team = 2 + else + team = 3 + end + elseif AutoTeams == 'tvsb' then + local midLine = GUI.mapView.Top() + (GUI.mapView.Height() / 2) + local markerPos = GUI.mapView.startPositions[i].Top() + + if markerPos < midLine then + team = 2 + else + team = 3 + end + elseif AutoTeams == 'pvsi' then + if math.mod(i, 2) ~= 0 then + team = 2 + else + team = 3 + end + elseif AutoTeams == 'manual' then + team = gameInfo.AutoTeams[i] + else -- none + team = gameInfo.PlayerOptions[i].Team + group = 1 + end + + group = group or team + if not positionGroups[group] then + positionGroups[group] = {} + end + table.insert(positionGroups[group], i) + + if team ~= nil then + -- Team 1 secretly represents "No team", so give them a real team (but one that + -- nobody else can possibly have) + if team == 1 then + team = synthesizedTeamCounter + synthesizedTeamCounter = synthesizedTeamCounter + 1 + end + teamsAddSpot(teams, team, i) + end + end + end + gameInfo.GameOptions.RandomPositionGroups = positionGroups + + -- shuffle the array for randomness. + for i, team in teams do + teams[i] = table.shuffle(team) + end + teams = table.shuffle(teams) + + local ratingTable = {} + for i = 1, numAvailStartSpots do + local playerInfo = gameInfo.PlayerOptions[i] + if playerInfo then + table.insert(ratingTable, { pos=i, rating = playerInfo.MEAN - playerInfo.DEV * 3 }) + end + end + + if teamSpawn == 'random' or teamSpawn == 'random_reveal' then + s = autobalance_random(ratingTable, teams) + q = autobalance_quality(s) + rearrangePlayers{setup=s, quality=q} + return + end + + ratingTable = table.shuffle(ratingTable) -- random order for people with same rating + table.sort(ratingTable, function(a, b) return a['rating'] > b['rating'] end) + + local setups = {} + local functions = { + rr=autobalance_rr, + bestworst=autobalance_bestworst, + avg=autobalance_avg, + } + + local cmp = function(a, b) return a.quality > b.quality end + local s, q + for fname, f in functions do + s = f(ratingTable, teams) + if s then + q = autobalance_quality(s) + table.binsert(setups, {setup=s, quality=q}, cmp) + end + end + + local n_random = 0 + local frac = (teamSpawn == 'balanced_flex' or teamSpawn == 'balanced_flex_reveal') and 0.95 or 1 + -- add 100 random compositions and keep 3 with at least of best quality + for i=1, 100 do + s = autobalance_random(ratingTable, teams) + q = autobalance_quality(s) + + if q > setups[1].quality * frac then + table.binsert(setups, {setup=s, quality=q}, cmp) + n_random = n_random + 1 + if n_random > 2 then break end + end + end + + if teamSpawn == 'balanced_flex' or teamSpawn == 'balanced_flex_reveal' then + setups = table.shuffle(setups) + end + + best = table.remove(setups, 1) + rearrangePlayers(best) +end + + +local function AssignAutoTeams() + -- A function to take a player index and return the team they should be on. + local getTeam + if gameInfo.GameOptions.AutoTeams == 'lvsr' then + local midLine = GUI.mapView.Left() + (GUI.mapView.Width() / 2) + local startPositions = GUI.mapView.startPositions + + getTeam = function(playerIndex) + local markerPos = startPositions[playerIndex].Left() + if markerPos < midLine then + return 2 + else + return 3 + end + end + elseif gameInfo.GameOptions.AutoTeams == 'tvsb' then + local midLine = GUI.mapView.Top() + (GUI.mapView.Height() / 2) + local startPositions = GUI.mapView.startPositions + + getTeam = function(playerIndex) + local markerPos = startPositions[playerIndex].Top() + if markerPos < midLine then + return 2 + else + return 3 + end + end + elseif gameInfo.GameOptions.AutoTeams == 'pvsi' or gameInfo.GameOptions['RandomMap'] ~= 'Off' then + getTeam = function(playerIndex) + if math.mod(playerIndex, 2) ~= 0 then + return 2 + else + return 3 + end + end + elseif gameInfo.GameOptions.AutoTeams == 'manual' then + getTeam = function(playerIndex) + return gameInfo.AutoTeams[playerIndex] or 1 + end + else + return + end + + for i = 1, LobbyComm.maxPlayerSlots do + if not gameInfo.ClosedSlots[i] and gameInfo.PlayerOptions[i] then + local correctTeam = getTeam(i) + if gameInfo.PlayerOptions[i].Team ~= correctTeam then + SetPlayerOption(i, "Team", correctTeam, true) + SetSlotInfo(i, gameInfo.PlayerOptions[i]) + end + end + end +end + +local function AssignAINames() + local aiNames = import("/lua/ui/lobby/ainames.lua").ainames + local nameSlotsTaken = {} + for index, faction in FactionData.Factions do + nameSlotsTaken[index] = {} + end + for index, player in gameInfo.PlayerOptions do + if not player.Human then + local playerFaction = player.Faction + local factionNames = aiNames[FactionData.Factions[playerFaction].Key] + local ranNum + repeat + ranNum = math.random(1, table.getn(factionNames)) + until nameSlotsTaken[playerFaction][ranNum] == nil + nameSlotsTaken[playerFaction][ranNum] = true + player.PlayerName = factionNames[ranNum] .. " (" .. player.PlayerName .. ")" + end + end +end + + +-- call this whenever the lobby needs to exit and not go in to the game +function ReturnToMenu(reconnect) + if lobbyComm then + lobbyComm:Destroy() + lobbyComm = false + end + + local exitfn = GUI.exitBehavior + + GUI:Destroy() + GUI = false + + if not reconnect then + exitfn() + else + local ipnumber = GetCommandLineArg("/joincustom", 1)[1] + import("/lua/ui/uimain.lua").StartJoinLobbyUI("UDP", ipnumber, localPlayerName) + end +end + +function PrintSystemMessage(id, parameters) + AddChatText(LOCF("Unknown system message. Check localisation file", unpack(parameters))) +end + +function SendSystemMessage(id, ...) + local data = { + Type = "SystemMessage", + Id = id, + Args = arg + } + + lobbyComm:BroadcastData(data) + PrintSystemMessage(id, arg) +end + +function SendPersonalSystemMessage(targetID, id, ...) + if targetID ~= localPlayerID then + local data = { + Type = "SystemMessage", + Id = id, + Args = arg + } + + lobbyComm:SendData(targetID, data) + end +end + +function PublicChat(text) + lobbyComm:BroadcastData( + { + Type = "PublicChat", + Text = text, + } + ) + AddChatText(text, localPlayerID, true) +end + +function PrivateChat(targetID,text) + if targetID ~= localPlayerID then + lobbyComm:SendData( + targetID, + { + Type = 'PrivateChat', + Text = text, + } + ) + end + local targetName = FindNameForID(targetID) + if targetName then + AddChatText("<<"..LOCF("To %s", targetName)..">> " .. text) + end +end + +function UpdateAvailableSlots(numAvailStartSpots, scenario) + if numAvailStartSpots > LobbyComm.maxPlayerSlots then + WARN("Lobby requests " .. numAvailStartSpots .. " but there are only " .. LobbyComm.maxPlayerSlots .. " available") + end + + for i = 1, numAvailStartSpots do + local availableFactionsForSpotI = FACTION_NAMES + if scenario.Configurations.standard.factions then + availableFactionsForSpotI = scenario.Configurations.standard.factions[i] + end + + local factionBmps = {} + local factionTooltips = {} + local factionList = {} + for index, factionKey in availableFactionsForSpotI do + for _, tbl in FactionData.Factions do + if factionKey == tbl.Key then + factionBmps[index] = tbl.SmallIcon + factionTooltips[index] = tbl.TooltipID + factionList[index] = tbl.Key + break + end + end + end + if table.getn(factionBmps) > 1 then + table.insert(factionBmps, "/faction_icon-sm/random_ico.dds") + table.insert(factionTooltips, 'lob_random') + table.insert(factionList, 'random') + end + + local oldAvailableFactions = GUI.slots[i].AvailableFactions + GUI.slots[i].AvailableFactions = factionList + + local diff = table.getn(factionList) ~= table.getn(oldAvailableFactions) + for k = 1,table.getn(factionList) do + if oldAvailableFactions[k] ~= factionList[k] then + diff = true + break + end + end + if not diff then + continue + end + + GUI.slots[i].faction:ChangeBitmapArray(factionBmps) + Tooltip.AddComboTooltip(GUI.slots[i].faction, factionTooltips) + + if gameInfo.PlayerOptions[i] then + local playerFactionIndex = table.getn(factionList) + for index,key in factionList do + if key == oldAvailableFactions[gameInfo.PlayerOptions[i].Faction] then + playerFactionIndex = index + break + end + end + if FindSlotForID(localPlayerID) == i then + local fact = factionList[playerFactionIndex] + for index,value in allAvailableFactionsList do + if fact == value then + GUI.factionSelector:SetSelected(index) + break + end + end + UpdateFactionSelector() + else + GUI.slots[i].faction:SetItem(playerFactionIndex) + gameInfo.PlayerOptions[i].Faction = playerFactionIndex + end + end + end + + -- if number of available slots has changed, update it + if gameInfo.firstUpdateAvailableSlotsDone and numOpenSlots == numAvailStartSpots then + -- Remove closed_spawn_mex if necessary + if not gameInfo.AdaptiveMap then + for i = 1, numAvailStartSpots do + if gameInfo.ClosedSlots[i] and gameInfo.SpawnMex[i] then + ClearSlotInfo(i) + gameInfo.SpawnMex[i] = nil + end + end + end + return + end + + -- reopen slots in case the new map has more startpositions then the previous map. + if numOpenSlots < numAvailStartSpots then + for i = numOpenSlots + 1, numAvailStartSpots do + gameInfo.ClosedSlots[i] = nil + gameInfo.SpawnMex[i] = nil + GUI.slots[i]:Show() + ClearSlotInfo(i) + DisableSlot(i) + end + end + numOpenSlots = numAvailStartSpots + + for i = 1, numAvailStartSpots do + if gameInfo.ClosedSlots[i] then + GUI.slots[i]:Show() + if not gameInfo.PlayerOptions[i] then + ClearSlotInfo(i) + end + if not gameInfo.PlayerOptions[i].Ready then + EnableSlot(i) + end + end + end + + for i = numAvailStartSpots + 1, LobbyComm.maxPlayerSlots do + if lobbyComm:IsHost() and gameInfo.PlayerOptions[i] then + local info = gameInfo.PlayerOptions[i] + if info.Human then + HostUtils.ConvertPlayerToObserver(i) + else + HostUtils.RemoveAI(i) + end + end + DisableSlot(i) + GUI.slots[i]:Hide() + gameInfo.ClosedSlots[i] = true + gameInfo.SpawnMex[i] = nil + end + + gameInfo.firstUpdateAvailableSlotsDone = true +end + +local function TryLaunch(skipNoObserversCheck) + if not singlePlayer then + local notReady = GetPlayersNotReady() + if notReady then + for k,v in notReady do + AddChatText(LOCF("%s isn't ready.",v)) + end + return + end + end + + local teamsPresent = {} + + -- make sure there are some players (could all be observers?) + -- Also count teams. There needs to be at least 2 teams (or all FFA) represented + local numPlayers = 0 + local numHumanPlayers = 0 + local numTeams = 0 + for slot, player in gameInfo.PlayerOptions:pairs() do + if player then + numPlayers = numPlayers + 1 + + if player.Human then + numHumanPlayers = numHumanPlayers + 1 + end + + -- Make sure to increment numTeams for people in the special "-" team, represented by 1. + if not teamsPresent[player.Team] or player.Team == 1 then + teamsPresent[player.Team] = true + numTeams = numTeams + 1 + end + end + end + + -- Ensure, for a non-sandbox game, there are some teams to fight. + if gameInfo.GameOptions['Victory'] ~= 'sandbox' and numTeams < 2 then + --AddChatText(LOC("There must be more than one player or team or the Victory Condition must be set to Sandbox.")) + -- In case we start a game as single player we set the game temporarily to Sandbox mode. This will not change the lobby option itself! + SPEW('GameOptions[\'Victory\'] changed temporarily from "'..gameInfo.GameOptions['Victory']..'" to "sandbox"') + gameInfo.GameOptions['Victory'] = 'sandbox' + end + + if numPlayers == 0 then + AddChatText(LOC("There are no players assigned to player slots, can not continue")) + return + end + + if not gameInfo.GameOptions.AllowObservers then + + -- if observers are not allowed, and team spawn is set to penguin_autobalance, and there are + -- an odd number of players, then make the last player an observer now if human + -- (before the check(s)/prompt(s) for having observer(s) when they're not allowed) + if gameInfo.GameOptions.TeamSpawn == 'penguin_autobalance' then + if math.mod(numPlayers, 2) == 1 then + for i = 16, 1, -1 do + -- this gets the last occupied slot + if gameInfo.PlayerOptions[i] then + LOG(gameInfo.PlayerOptions[i].Human) + if gameInfo.PlayerOptions[i].Human then + HostUtils.ConvertPlayerToObserver(i) + end + break + end + end + end + end + + local hostIsObserver = false + local anyOtherObservers = false + for k, observer in gameInfo.Observers:pairs() do + if observer.OwnerID == localPlayerID then + hostIsObserver = true + else + anyOtherObservers = true + end + end + + if hostIsObserver then + AddChatText(LOC("Cannot launch if the host isn't assigned a slot and observers are not allowed.")) + return + end + + if anyOtherObservers and not skipNoObserversCheck then + UIUtil.QuickDialog(GUI, "Launching will kick observers because \"allow observers\" is disabled. Continue?", + "", function() TryLaunch(true) end, + "", nil, nil, nil, true, + {worldCover = false, enterButton = 1, escapeButton = 2}) + return + end + + HostUtils.KickObservers("GameLaunched") + end + + if not EveryoneHasEstablishedConnections(gameInfo.GameOptions.AllowObservers) then + return + end + + numberOfPlayers = numPlayers + local function LaunchGame() + + if gameInfo.GameOptions.TeamSpawn == 'penguin_autobalance' then + GUI.PenguinAutoBalance.OnClick() + end + + -- These two things must happen before the flattening step, mostly for terrible reasons. + -- This isn't ideal, as it leads to redundant UI repaints :/ + AssignAutoTeams() + + -- Force observers to start with the UEF skin to prevent them from launching as "random". + if IsObserver(localPlayerID) then + UIUtil.SetCurrentSkin("uef") + end + + -- Eliminate the WatchedValue structures. + gameInfo = GameInfo.Flatten(gameInfo) + + if gameInfo.GameOptions['RandomMap'] ~= 'Off' then + autoRandMap = true + autoMap() + end + + SetFrontEndData('NextOpBriefing', nil) + -- assign random factions just as game is launched + AssignRandomFactions() + -- fix faction indexes + FixFactionIndexes() + AssignRandomStartSpots() + AssignAINames() + local allRatings = {} + local clanTags = {} + for k, player in gameInfo.PlayerOptions do + if player.PL then + allRatings[player.PlayerName] = player.PL + clanTags[player.PlayerName] = player.PlayerClan + + if not player.Human then + allRatings[player.PlayerName] = ComputeAIRating(gameInfo.GameOptions, player.AILobbyProperties) + end + end + + if player.OwnerID == localPlayerID then + UIUtil.SetCurrentSkin(FACTION_NAMES[player.Faction]) + end + end + gameInfo.GameOptions['Ratings'] = allRatings + gameInfo.GameOptions['ClanTags'] = clanTags + + scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + + -- Load in the default map options if they are not set manually + + -- Not all maps have options + if scenarioInfo.options then + + -- If we don't validate them first then the people using the default + -- as a value instead of the index of the value will mess us up + MapUtil.ValidateScenarioOptions(scenarioInfo.options) + + -- For every option, if it's not set yet then add its default value + for _, option in scenarioInfo.options do + if not gameInfo.GameOptions[option.key] then + -- When the value data of the option is formatted as: + -- values = { + -- { text = "Easy", help = "We'll have sufficient time to start building up our defense strategy.", key = 1, }, + -- { text = "Normal", help = "There's sufficient time - but we'll need to hurry up.", key = 2, }, + -- { text = "Heroic", help = "There's little time - no space for errors.", key = 3, }, + -- { text = "Legendary", help = "We're being dropped in the middle of it - we knew it was a suicide mission when we signed up for it.", key = 4, }, + -- }, + local keyVersion = option.values[option.default].key + + -- When the value data of the option is formatted as: + -- values = { + -- '1','2','3','4','5','6','7','8','9','10','11','12','13','14','15','16','17','18','19','20' + -- } + local valueVersion = option.values[option.default] + + -- Expect a key version, fall back on a value version + gameInfo.GameOptions[option.key] = keyVersion or valueVersion + + -- Can be removed once this code leaves the develop branch + SPEW("Loading default map option: " .. tostring (option.key) .. " = " .. tostring (gameInfo.GameOptions[option.key])) + end + end + end + + if scenarioInfo.AdaptiveMap then + gameInfo.GameOptions["SpawnMex"] = gameInfo.SpawnMex + end + if gameInfo.GameOptions["CheatsEnabled"] == "true" and singlePlayer then + gameInfo.GameOptions["GameSpeed"] = "adjustable" + end + + HostUtils.SendArmySettingsToServer() + + -- Tell everyone else to launch and then launch ourselves. + -- TODO: Sending gamedata here isn't necessary unless lobbyComm is fucking stupid and allows + -- out-of-order message delivery. + -- Downlord: I use this in clients now to store the rehost preset. So if you're going to remove this, please + -- check if rehosting still works for non-host players. + lobbyComm:BroadcastData({ Type = 'Launch', GameInfo = gameInfo }) + + -- set the mods + gameInfo.GameMods = Mods.GetGameMods(gameInfo.GameMods) + + SetWindowedLobby(false) + + Presets.SaveLastGamePreset() + + -- launch the game + lobbyComm:LaunchGame(gameInfo) + + + end + + LaunchGame() +end + +local function AlertHostMapMissing() + if lobbyComm:IsHost() then + HostUtils.PlayerMissingMapAlert(localPlayerID) + else + lobbyComm:SendData(hostID, {Type = 'MissingMap'}) + end +end + +local function UpdateGame() + -- This allows us to assume the existence of UI elements throughout. + if not GUI.uiCreated then + WARN(debug.traceback(nil, "UpdateGame() pointlessly called before UI creation!")) + return + end + + local scenarioInfo + + if gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= "") then + scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + + -- update AI rating as game settings change + for k = 1, 16 do + local playerOptions = gameInfo.PlayerOptions[k] + if playerOptions then + if not playerOptions.Human then + playerOptions.PL = ComputeAIRating(gameInfo.GameOptions, playerOptions.AILobbyProperties); + playerOptions.MEAN = playerOptions.PL + playerOptions.DEV = 0 + end + end + end + + if scenarioInfo and scenarioInfo.map and scenarioInfo.map ~= '' then + GUI.mapView:SetScenario(scenarioInfo) + ShowMapPositions(GUI.mapView, scenarioInfo) + ConfigureMapListeners(GUI.mapView, scenarioInfo) + + -- Briefing button takes priority over the patch notes if the map has a briefing + if scenarioInfo.hasBriefing then + GUI.briefingButton:Show() + GUI.patchnotesButton:Hide() + else + GUI.briefingButton:Hide() + GUI.patchnotesButton:Show() + end + + -- contains information that is available during blueprint loading + local preGameData = {} + + -- MAP ASSETS LOADING -- + + -- store the (selected) map directory so that we can load individual blueprints from it + preGameData.CurrentMapDir = Dirname(gameInfo.GameOptions.ScenarioFile) + + -- STRATEGIC ICON REPLACEMENT -- + + -- icon replacements + local iconReplacements = { } + + -- retrieve all (selected) mods + local allMods = Mods.AllMods() + local selectedMods = Mods.GetSelectedMods() + + -- loop over selected mods identifiers + for uid, _ in selectedMods do + + -- get the mod, determine path to icon configuration file + local mod = allMods[uid] + + -- check for mod integrity + if not (mod.name and mod.author) then + WARN("Unable to load icons from mod '" .. uid .. "', the mod_info.lua file is not properly defined. It needs a name and author field.") + end + + -- path to configuration file + local iconConfigurationPath = mod.location .. "/mod_icons.lua" + + -- see if it exists + if DiskGetFileInfo(iconConfigurationPath) then + + -- see if we can import it + local ok, msg = pcall( + function() + + -- attempt to load the file + local env = { } + doscript(iconConfigurationPath, env) + + -- syntax errors are caught internally and instead it just returns the table untouched + if not (env.UnitIconAssignments or env.ScriptedIconAssignments) then + error("Lobby.lua - can not import the icon configuration file at '" .. iconConfigurationPath .. "'. This could be due to missing functionality functionality or a parsing error.") + end + end + ) + + -- if it passes this basic check, then continue + if ok then + local info = { } + info.Name = mod.name + info.Author = mod.author + info.Location = mod.location + info.Identifier = string.lower(utils.StringSplit(mod.location, '/')[2]) + info.UID = uid + table.insert(iconReplacements, info) + -- tell us (and then spam the author, not the dev) if it failed + else + WARN("Unable to load icons from mod '" .. tostring(mod.name) .. "' with uid '" .. tostring(uid) .. "'. Please inform the author: " .. tostring(mod.author)) + WARN(msg) + end + end + end + + preGameData.IconReplacements = iconReplacements + + -- try and set the preferences - it may crash when running multiple instances on a single machine that all try and start at the same time. + local ok, msg = pcall( + function() + -- store in preferences so that we can retrieve it during blueprint loading + SetPreference('PreGameData', preGameData) + end + ) + + if not ok then + WARN("Unable to update preferences. Are you running multiple instances on the same machine?" ) + WARN(msg) + end + + -- PREFETCHING -- + + -- Note that the PreGameData is not properly updated, + -- hence we can not rely on mod and / or lobby option + -- changes to be present. + + -- local mods = Mods.GetGameMods(gameInfo.GameMods) + -- PrefetchSession(scenarioInfo.map, mods, true) + + else + AlertHostMapMissing() + GUI.mapView:Clear() + end + end + + local isHost = lobbyComm:IsHost() + + local localPlayerSlot = FindSlotForID(localPlayerID) + if localPlayerSlot then + local playerOptions = gameInfo.PlayerOptions[localPlayerSlot] + + -- Disable some controls if the user is ready. + local notReady = not playerOptions.Ready + + UIUtil.setEnabled(GUI.becomeObserver, notReady) + UIUtil.setEnabled(GUI.briefingButton, notReady) + -- This button is enabled for all non-host players to view the configuration, and for the + -- host to select presets (rather confusingly, one object represents both potential buttons) + UIUtil.setEnabled(GUI.restrictedUnitsOrPresetsBtn, not isHost or notReady) + + UIUtil.setEnabled(GUI.factionSelector, notReady) + if notReady then + UpdateFactionSelector() + end + else + UIUtil.setEnabled(GUI.factionSelector, false) + end + + gameInfo.AdaptiveMap = scenarioInfo.AdaptiveMap + + local numPlayers = GetPlayerCount() + + local numAvailStartSpots = LobbyComm.maxPlayerSlots + if scenarioInfo then + local armyTable = MapUtil.GetArmies(scenarioInfo) + if armyTable then + numAvailStartSpots = table.getn(armyTable) + end + end + + UpdateAvailableSlots(numAvailStartSpots, scenarioInfo) + + -- Update all slots. + for i = 1, LobbyComm.maxPlayerSlots do + if gameInfo.ClosedSlots[i] then + UpdateSlotBackground(i) + else + if gameInfo.PlayerOptions[i] then + SetSlotInfo(i, gameInfo.PlayerOptions[i]) + else + ClearSlotInfo(i) + end + end + end + + if isHost then + HostUtils.RefreshButtonEnabledness() + end + RefreshOptionDisplayData(scenarioInfo) + + -- Update the map background to reflect the possibly-changed map. + if Prefs.GetFromCurrentProfile('LobbyBackground') == 4 then + RefreshLobbyBackground() + end + + -- Set the map name at the top right corner in lobby + if scenarioInfo.name then + GUI.MapNameLabel:StreamText(scenarioInfo.name, 20) + end + + -- Add Tooltip info on Map Name Label + if scenarioInfo then + local TTips_map_version = scenarioInfo.map_version or "1" + local TTips_army = table.getsize(scenarioInfo.Configurations.standard.teams[1].armies) + local TTips_sizeX = scenarioInfo.size[1] / 51.2 + local TTips_sizeY = scenarioInfo.size[2] / 51.2 + + local mapTooltip = { + text = scenarioInfo.name, + body = '- '..LOC("Map version")..' : '..TTips_map_version..'\n '.. + '- '..LOC("Max Players")..' : '..TTips_army..' max'..'\n '.. + '- '..LOC("Map Size")..' : '..TTips_sizeX..'km x '..TTips_sizeY..'km' + } + + Tooltip.AddControlTooltip(GUI.MapNameLabel, mapTooltip) + Tooltip.AddControlTooltip(GUI.GameQualityLabel, mapTooltip) + end + + -- If the large map is shown, update it. + RefreshLargeMap() + + SetRuleTitleText(gameInfo.GameOptions.GameRules or "") + SetGameTitleText(gameInfo.GameOptions.Title or LOC("FAF Game Lobby")) + + if isHost and GUI.autoTeams then + GUI.autoTeams:SetState(gameInfo.GameOptions.AutoTeams,true) + Tooltip.DestroyMouseoverDisplay() + end +end + +--- Update the game quality display +function ShowGameQuality() + GUI.GameQualityLabel:SetText("") + + -- Can't compute a game quality for random spawns! + if gameInfo.GameOptions.TeamSpawn ~= 'fixed' then + return + end + + local teams = Teams.create() + + -- Everything catches fire if the teams aren't numbered sequentially from 1. + -- I hope it is not the case that everything catches fire when there are >2 teams, but in + -- principle that should work... + + -- Start by creating a map from each *used* team to an element from an ascending set of integers. + local tsTeam = 1 + local teamMap = {} + for i = 1, LobbyComm.maxPlayerSlots do + local playerOptions = gameInfo.PlayerOptions[i] + -- Team 1 represents "No team", so these people are all singleton teams. + if playerOptions and (teamMap[playerOptions.Team] == nil or playerOptions.Team == 1) then + teamMap[playerOptions.Team] = tsTeam + tsTeam = tsTeam + 1 + end + end + + -- Now we just use the map to relate real teams to trueSkill teams. + for i = 1, LobbyComm.maxPlayerSlots do + local playerOptions = gameInfo.PlayerOptions[i] + if playerOptions then + -- Can't do it for AI, either, not sensibly. + if not playerOptions.Human and (playerOptions.MEAN or 0) == 0 then + return + end + + local player = Player.create( + playerOptions.PlayerName, + Rating.create(playerOptions.MEAN, playerOptions.DEV) + ) + + teams:addPlayer(teamMap[playerOptions.Team], player) + end + end + + -- Rating only meaningful in games with 2 teams + if table.getsize(teams:getTeams()) ~= 2 then + return + end + + local quality = Trueskill.computeQuality(teams) + + if quality > 0 then + gameInfo.GameOptions.Quality = quality + GUI.GameQualityLabel:StreamText(LOCF("Game quality: %s%%", string.format("%.2f",quality)), 20) + end +end + +-- Holds some utility functions to do with game option management. +local OptionUtils = { + -- Set all game options to their default values. + SetDefaults = function() + local options = {} + for index, option in teamOpts do + options[option.key] = option.values[option.default].key or option.values[option.default] + end + for index, option in globalOpts do + -- Exception to make AllowObservers work because the engine requires + -- the keys to be bool. Custom options should use 'True' or 'False' + if option.key == 'AllowObservers' then + options[option.key] = option.values[option.default].key + else + options[option.key] = option.values[option.default].key or option.values[option.default] + end + end + + for index, option in AIOpts do + options[option.key] = option.values[option.default].key or option.values[option.default] + end + + options.RestrictedCategories = {} + + SetGameOptions(options) + end +} + +-- callback when Mod Manager dialog finishes (modlist==nil on cancel) +-- FIXME: The mod manager should be given a list of game mods set by the host, which +-- clients can look at but not changed, and which don't get saved in our local prefs. +function OnModsChanged(simMods, UIMods, ignoreRefresh) + -- We depend upon ModsManager to not allow the user to change mods they shouldn't be able to + selectedSimMods = simMods + selectedUIMods = UIMods + + Mods.SetSelectedMods(SetUtils.Union(selectedSimMods, selectedUIMods)) + if lobbyComm:IsHost() then + HostUtils.UpdateMods() + end + + if not ignoreRefresh then + -- reload AI types in case we have enable or disable an AI mod. + GetAITypes() + GUI.AIFillCombo:ClearItems() + GUI.AIFillCombo:AddItems(AIStrings) + GUI.AIFillCombo:SetTitleText(LOC('Choose AI for autofilling')) + UpdateGame() + end +end + +function GetAvailableColor() + for i = 1, LobbyComm.maxPlayerSlots do + if IsColorFree(gameColors.LobbyColorOrder[i]) then + return gameColors.LobbyColorOrder[i] + end + end + WARN('Error: No available colors found.') +end + +--- This function is retarded. +-- Unfortunately, we're stuck with it. +-- The game requires both ArmyColor and PlayerColor be set. We don't want to have to write two fields +-- all the time, and the magic that makes PlayerData work precludes adding member functions to it. +-- So, we have this. Tough shit. :P +function SetPlayerColor(playerData, newColor) + playerData.ArmyColor = newColor + playerData.PlayerColor = newColor +end + +function autoMap() + local randomAutoMap + if gameInfo.GameOptions['RandomMap'] == 'Official' then + randomAutoMap = import("/lua/ui/dialogs/mapselect.lua").randomAutoMap(true) + else + randomAutoMap = import("/lua/ui/dialogs/mapselect.lua").randomAutoMap(false) + end +end + +function ClientsMissingMap() + local ret = nil + + for index, player in gameInfo.PlayerOptions:pairs() do + if player.BadMap then + if not ret then ret = {} end + table.insert(ret, player.PlayerName) + end + end + + for index, observer in gameInfo.Observers:pairs() do + if observer.BadMap then + if not ret then ret = {} end + table.insert(ret, observer.PlayerName) + end + end + + return ret +end + +function ClearBadMapFlags() + for index, player in gameInfo.PlayerOptions:pairs() do + player.BadMap = false + end + + for index, observer in gameInfo.Observers:pairs() do + observer.BadMap = false + end +end + +function EnableSlot(slot) + GUI.slots[slot].team:Enable() + GUI.slots[slot].color:Enable() + GUI.slots[slot].faction:Enable() + GUI.slots[slot].ready:Enable() +end + +function DisableSlot(slot, exceptReady) + GUI.slots[slot].team:Disable() + GUI.slots[slot].color:Disable() + GUI.slots[slot].faction:Disable() + if not exceptReady then + GUI.slots[slot].ready:Disable() + end +end + +-- Used for the quick-swap feature +local playersToSwap = false + +-- set up player "slots" which is the line representing a player and player specific options +function CreateSlotsUI(makeLabel) + local Combo = import("/lua/ui/controls/combo.lua").Combo + local BitmapCombo = import("/lua/ui/controls/combo.lua").BitmapCombo + local StatusBar = import("/lua/maui/statusbar.lua").StatusBar + local ColumnLayout = import("/lua/ui/controls/columnlayout.lua").ColumnLayout + + -- The dimensions of the columns used for slot UI controls. + local COLUMN_POSITIONS = {1, 21, 47, 91, 133, 395, 465, 535, 605, 677, 749} + local COLUMN_WIDTHS = {20, 20, 45, 45, 257, 59, 59, 59, 62, 62, 51} + + local labelGroup = ColumnLayout(GUI.playerPanel, COLUMN_POSITIONS, COLUMN_WIDTHS) + + GUI.labelGroup = labelGroup + LayoutHelpers.SetDimensions(labelGroup, 791, 21) + LayoutHelpers.AtLeftTopIn(labelGroup, GUI.playerPanel, 5, 5) + + local slotLabel = makeLabel("--", 14) + labelGroup:AddChild(slotLabel) + + -- No label required for the second column (flag), so skip it. (Even eviler hack) + labelGroup.numChildren = labelGroup.numChildren + 1 + + local ratingLabel = makeLabel("R", 14) + labelGroup:AddChild(ratingLabel) + + local numGamesLabel = makeLabel("G", 14) + labelGroup:AddChild(numGamesLabel) + + local nameLabel = makeLabel(LOC("Nickname"), 14) + labelGroup:AddChild(nameLabel) + + local colorLabel = makeLabel(LOC("Color"), 14) + labelGroup:AddChild(colorLabel) + + local factionLabel = makeLabel(LOC("Faction"), 14) + labelGroup:AddChild(factionLabel) + + local teamLabel = makeLabel(LOC("Team"), 14) + labelGroup:AddChild(teamLabel) + + labelGroup:AddChild(makeLabel(LOC("CPU"), 14)) + + if not singlePlayer then + labelGroup:AddChild(makeLabel(LOC("Ping"), 14)) + labelGroup:AddChild(makeLabel(LOC("Ready"), 14)) + end + + for i= 1, LobbyComm.maxPlayerSlots do + -- Capture the index in the current closure so it's accessible on callbacks + local curRow = i + + -- The background is parented on the GUI so it doesn't vanish when we hide the slot. + local slotBackground = Bitmap(GUI, UIUtil.SkinnableFile("/SLOT/slot-dis.dds")) + + -- Inherit dimensions of the slot control from the background image. + local newSlot = ColumnLayout(GUI.playerPanel, COLUMN_POSITIONS, COLUMN_WIDTHS) + newSlot.Width:Set(slotBackground.Width) + newSlot.Height:Set(slotBackground.Height) + + LayoutHelpers.AtLeftTopIn(slotBackground, newSlot) + newSlot.SlotBackground = slotBackground + + -- Default mouse behaviours for the slot. + local defaultHandler = function(self, event) + if curRow > numOpenSlots then + return + end + + local associatedMarker = GUI.mapView.startPositions[curRow] + if event.Type == 'MouseEnter' then + if gameInfo.GameOptions['TeamSpawn'] == 'fixed' then + associatedMarker.indicator:Play() + end + elseif event.Type == 'MouseExit' then + associatedMarker.indicator:Stop() + elseif event.Type == 'ButtonDClick' then + DoSlotBehavior(curRow, 'occupy', '') + end + + return Group.HandleEvent(self, event) + end + newSlot.HandleEvent = defaultHandler + + -- Slot number + local slotNumber = UIUtil.CreateText(newSlot, tostring(i), 14, 'Arial') + newSlot.slotNumber = slotNumber + LayoutHelpers.SetWidth(slotNumber, COLUMN_WIDTHS[1]) + slotNumber.Height:Set(newSlot.Height) + newSlot:AddChild(slotNumber) + newSlot.tooltipnumber = Tooltip.AddControlTooltip(slotNumber, 'slot_number') + slotNumber.id = i + slotNumber.HandleEvent = function(self,event) + if lobbyComm:IsHost() then + if event.Type == 'ButtonPress' then + if playersToSwap then + --same number clicked + if self.id == playersToSwap then + playersToSwap = false + self:SetColor(UIUtil.fontColor) + elseif gameInfo.PlayerOptions[playersToSwap] then + HostUtils.SwapPlayers(playersToSwap, self.id) + GUI.slots[playersToSwap].slotNumber:SetColor(UIUtil.fontColor) + playersToSwap = false + elseif gameInfo.PlayerOptions[self.id] then + HostUtils.SwapPlayers(self.id, playersToSwap) + GUI.slots[playersToSwap].slotNumber:SetColor(UIUtil.fontColor) + playersToSwap = false + end + else + self:SetColor('ff00ffff') + playersToSwap = self.id + end + end + end + end + -- COUNTRY + -- Added a bitmap on the left of Rating, the bitmap is a Flag of Country + local flag = Bitmap(newSlot, UIUtil.SkinnableFile("/countries/world.dds")) + newSlot.KinderCountry = flag + LayoutHelpers.SetWidth(flag, COLUMN_WIDTHS[2]) + newSlot:AddChild(flag) + + -- TODO: Factorise this boilerplate. + -- Rating + local ratingText = UIUtil.CreateText(newSlot, "", 14, 'Arial') + newSlot.ratingText = ratingText + ratingText:SetColor('B9BFB9') + ratingText:SetDropShadow(true) + newSlot:AddChild(ratingText) + + -- NumGame + local numGamesText = UIUtil.CreateText(newSlot, "", 14, 'Arial') + newSlot.numGamesText = numGamesText + numGamesText:SetColor('B9BFB9') + numGamesText:SetDropShadow(true) + Tooltip.AddControlTooltip(numGamesText, 'num_games') + newSlot:AddChild(numGamesText) + + -- Name + local nameLabel = Combo(newSlot, 14, 16, true, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") + newSlot.name = nameLabel + nameLabel._text:SetFont('Arial Gras', 15) + newSlot:AddChild(nameLabel) + LayoutHelpers.SetWidth(nameLabel, COLUMN_WIDTHS[5]) + -- left deal with name clicks + nameLabel.OnEvent = defaultHandler + nameLabel.OnClick = function(self, index, text) + DoSlotBehavior(curRow, self.slotKeys[index], text) + end + + -- Hide the marker when the dropdown is hidden + nameLabel.OnHide = function() + local associatedMarker = GUI.mapView.startPositions[curRow] + if associatedMarker then + associatedMarker.indicator:Stop() + end + end + + -- Color + local colorSelector = BitmapCombo(newSlot, gameColors.PlayerColors, 1, true, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") + newSlot.color = colorSelector + + newSlot:AddChild(colorSelector) + LayoutHelpers.SetWidth(colorSelector, COLUMN_WIDTHS[6]) + colorSelector.OnClick = function(self, index) + if not lobbyComm:IsHost() then + lobbyComm:SendData(hostID, { Type = 'RequestColor', Color = index }) + SetPlayerColor(gameInfo.PlayerOptions[curRow], index) + UpdateGame() + else + if IsColorFree(index) then + lobbyComm:BroadcastData({ Type = 'SetColor', Color = index, Slot = curRow }) + SetPlayerColor(gameInfo.PlayerOptions[curRow], index) + UpdateGame() + else + self:SetItem(gameInfo.PlayerOptions[curRow].PlayerColor) + end + end + end + colorSelector.OnEvent = defaultHandler + Tooltip.AddControlTooltip(colorSelector, 'lob_color') + + -- Faction + -- builds the faction tables, and then adds random faction icon to the end + local factionBmps = {} + local factionTooltips = {} + local factionList = {} + for index, tbl in FactionData.Factions do + factionBmps[index] = tbl.SmallIcon + factionTooltips[index] = tbl.TooltipID + factionList[index] = tbl.Key + end + table.insert(factionBmps, "/faction_icon-sm/random_ico.dds") + table.insert(factionTooltips, 'lob_random') + table.insert(factionList, 'random') + allAvailableFactionsList = factionList + + local factionSelector = BitmapCombo(newSlot, factionBmps, table.getn(factionBmps), nil, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") + newSlot.faction = factionSelector + newSlot.AvailableFactions = factionList + newSlot:AddChild(factionSelector) + LayoutHelpers.SetWidth(factionSelector, COLUMN_WIDTHS[7]) + factionSelector.OnClick = function(self, index) + SetPlayerOption(curRow, 'Faction', index) + if curRow == FindSlotForID(FindIDForName(localPlayerName)) then + local fact = GUI.slots[FindSlotForID(localPlayerID)].AvailableFactions[index] + for ind,value in allAvailableFactionsList do + if fact == value then + GUI.factionSelector:SetSelected(ind) + break + end + end + end + + Tooltip.DestroyMouseoverDisplay() + end + Tooltip.AddControlTooltip(factionSelector, 'lob_faction') + Tooltip.AddComboTooltip(factionSelector, factionTooltips) + factionSelector.OnEvent = defaultHandler + + -- Team + local teamSelector = Combo(newSlot, 17, 9, nil, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") + teamSelector:AddItems({' - ', ' 1', ' 2', ' 3', ' 4', ' 5', ' 6', ' 7', ' 8'}) + teamSelector._text:SetFont('Arial', 14) + teamSelector._titleColor = 'White' + newSlot.team = teamSelector + newSlot:AddChild(teamSelector) + LayoutHelpers.SetWidth(teamSelector, COLUMN_WIDTHS[8]) + teamSelector.OnClick = function(self, index, text) + Tooltip.DestroyMouseoverDisplay() + SetPlayerOption(curRow, 'Team', index) + end + Tooltip.AddControlTooltip(teamSelector, 'lob_team') + teamSelector.OnEvent = defaultHandler + + -- CPU + local barMax = 450 + local barMin = 0 + local CPUGroup = Group(newSlot) + newSlot.CPUGroup = CPUGroup + LayoutHelpers.SetWidth(CPUGroup, COLUMN_WIDTHS[9]) + CPUGroup.Height:Set(newSlot.Height) + newSlot:AddChild(CPUGroup) + local CPUSpeedBar = StatusBar(CPUGroup, barMin, barMax, false, false, + UIUtil.UIFile('/game/unit_bmp/bar_black_bmp.dds'), + UIUtil.UIFile('/game/unit_bmp/bar_purple_bmp.dds'), + true) + newSlot.CPUSpeedBar = CPUSpeedBar + LayoutHelpers.AtTopIn(CPUSpeedBar, CPUGroup, 7) + LayoutHelpers.AtLeftIn(CPUSpeedBar, CPUGroup, 0) + LayoutHelpers.AtRightIn(CPUSpeedBar, CPUGroup, 0) + CPU_AddControlTooltip(CPUSpeedBar, 0, curRow) + CPUSpeedBar.CPUActualValue = 450 + CPUSpeedBar.barMax = barMax + + -- Ping + barMax = 1000 + barMin = 0 + local pingGroup = Group(newSlot) + newSlot.pingGroup = pingGroup + LayoutHelpers.SetWidth(pingGroup, COLUMN_WIDTHS[10]) + pingGroup.Height:Set(newSlot.Height) + newSlot:AddChild(pingGroup) + local pingStatus = StatusBar(pingGroup, barMin, barMax, false, false, + UIUtil.SkinnableFile('/game/unit_bmp/bar-back_bmp.dds'), + UIUtil.SkinnableFile('/game/unit_bmp/bar-01_bmp.dds'), + true) + newSlot.pingStatus = pingStatus + LayoutHelpers.AtTopIn(pingStatus, pingGroup, 7) + LayoutHelpers.AtLeftIn(pingStatus, pingGroup, 0) + LayoutHelpers.AtRightIn(pingStatus, pingGroup, 0) + Ping_AddControlTooltip(pingStatus, 0, curRow) + + -- Ready Checkbox + local readyBox = UIUtil.CreateCheckbox(newSlot, '/CHECKBOX/') + newSlot.ready = readyBox + newSlot:AddChild(readyBox) + readyBox.OnCheck = function(self, checked) + UIUtil.setEnabled(GUI.becomeObserver, not checked) + if checked then + DisableSlot(curRow, true) + else + EnableSlot(curRow) + end + SetPlayerOption(curRow, 'Ready', checked) + end + + newSlot.HideControls = function() + -- hide these to clear slot of visible data + flag:Hide() + ratingText:Hide() + numGamesText:Hide() + factionSelector:Hide() + colorSelector:Hide() + teamSelector:Hide() + CPUSpeedBar:Hide() + pingStatus:Hide() + readyBox:Hide() + end + newSlot.HideControls() + + if singlePlayer then + -- TODO: Use of groups may allow this to be simplified... + readyBox:Hide() + pingStatus:Hide() + end + + if i == 1 then + LayoutHelpers.Below(newSlot, GUI.labelGroup) + else + LayoutHelpers.Below(newSlot, GUI.slots[i - 1], 3) + end + + GUI.slots[i] = newSlot + end +end + +-- create UI won't typically be called directly by another module +function CreateUI(maxPlayers) + local ResourceMapPreview = import("/lua/ui/controls/resmappreview.lua").ResourceMapPreview + local ItemList = import("/lua/maui/itemlist.lua").ItemList + local Prefs = import("/lua/user/prefs.lua") + local Tooltip = import("/lua/ui/game/tooltip.lua") + local Combo = import("/lua/ui/controls/combo.lua") + + local isHost = lobbyComm:IsHost() + local lastFaction = GetSanitisedLastFaction() + UIUtil.SetCurrentSkin(FACTION_NAMES[lastFaction]) + + --------------------------------------------------------------------------- + -- Set up main control panels + --------------------------------------------------------------------------- + GUI.panel = Bitmap(GUI, UIUtil.SkinnableFile("/scx_menu/lan-game-lobby/lobby.dds")) + LayoutHelpers.AtCenterIn(GUI.panel, GUI) + GUI.panelWideLeft = Bitmap(GUI, UIUtil.SkinnableFile('/scx_menu/lan-game-lobby/wide.dds')) + LayoutHelpers.CenteredLeftOf(GUI.panelWideLeft, GUI.panel) + GUI.panelWideLeft.Left:Set(function() return GUI.Left() end) + GUI.panelWideRight = Bitmap(GUI, UIUtil.SkinnableFile('/scx_menu/lan-game-lobby/wide.dds')) + LayoutHelpers.CenteredRightOf(GUI.panelWideRight, GUI.panel) + GUI.panelWideRight.Right:Set(function() return GUI.Right() end) + + -- Create a label with a given size and initial text + local function makeLabel(text, size) + return UIUtil.CreateText(GUI.panel, text, size, 'Arial Gras', true) + end + + -- Map name label + GUI.MapNameLabel = makeLabel(LOC("Loading..."), 17) + LayoutHelpers.AtRightTopIn(GUI.MapNameLabel, GUI.panel, 5, 45) + + -- Game Quality Label + GUI.GameQualityLabel = makeLabel("", 11) + LayoutHelpers.AtRightTopIn(GUI.GameQualityLabel, GUI.panel, 5, 64) + + -- Title Label + GUI.titleText = makeLabel(LOC("FAF Game Lobby"), 17) + LayoutHelpers.AtLeftTopIn(GUI.titleText, GUI.panel, 5, 20) + + if isHost then + GUI.titleText.HandleEvent = function(self, event) + if event.Type == 'ButtonPress' then + ShowTitleDialog() + end + end + end + + -- Rule Label + local RuleLabel = TextArea(GUI.panel, 350, 34) + GUI.RuleLabel = RuleLabel + RuleLabel:SetFont('Arial Gras', 11) + RuleLabel:SetColors("B9BFB9", "00000000", "B9BFB9", "00000000") + LayoutHelpers.AtLeftTopIn(RuleLabel, GUI.panel, 5, 44) + RuleLabel:DeleteAllItems() + local tmptext + if isHost then + tmptext = LOC("No Rules: Click to add rules") + RuleLabel:SetColors("FFCC00") + else + tmptext = LOC("No rules") + end + + RuleLabel:SetText(tmptext) + if isHost then + RuleLabel.OnClick = function(self) + ShowRuleDialog() + end + end + + -- Mod Label + GUI.ModFeaturedLabel = makeLabel("", 13) + LayoutHelpers.AtLeftTopIn(GUI.ModFeaturedLabel, GUI.panel, 50, 61) + + -- Set the mod name to a value appropriate for the mod in use. + local modLabels = { + ["init_faf.lua"] = "FA Forever", + ["init_blackops.lua"] = "BlackOps", + ["init_coop.lua"] = "COOP", + ["init_balancetesting.lua"] = "Balance Testing", + ["init_gw.lua"] = "Galactic War", + ["init_labwars.lua"] = "Labwars", + ["init_ladder1v1.lua"] = "Ladder 1v1", + ["init_nomads.lua"] = "Nomads Mod", + ["init_phantomx.lua"] = "PhantomX", + ["init_supremedestruction.lua"] = "SupremeDestruction", + ["init_xtremewars.lua"] = "XtremeWars", + + } + GUI.ModFeaturedLabel:StreamText(modLabels[argv.initName] or "", 20) + + -- Lobby options panel + GUI.LobbyOptions = UIUtil.CreateButtonWithDropshadow(GUI.panel, '/BUTTON/medium/', LOC("Settings")) + LayoutHelpers.AtRightTopIn(GUI.LobbyOptions, GUI.panel, 44, 3) + GUI.LobbyOptions.OnClick = function() + ShowLobbyOptionsDialog() + end + Tooltip.AddButtonTooltip(GUI.LobbyOptions, 'lobby_click_Settings') + + -- Logo + GUI.logo = Bitmap(GUI, '/textures/ui/common/scx_menu/lan-game-lobby/logo.dds') + LayoutHelpers.AtLeftTopIn(GUI.logo, GUI, 1, 1) + + local version, gametype, commit = import("/lua/version.lua").GetVersionData() + GUI.gameVersionText = UIUtil.CreateText(GUI.panel, LOC('Game version ') .. version, 9, UIUtil.bodyFont) + GUI.gameVersionText:SetColor('677983') + GUI.gameVersionText:SetDropShadow(true) + + Tooltip.AddControlTooltipManual(GUI.gameVersionText, 'Version control', string.format( + LOC('Game version: %s\nGame type: %s\nCommit hash: %s'), version, gametype, commit:sub(1, 8) + )) + + LayoutHelpers.AtLeftTopIn(GUI.gameVersionText, GUI.panel, 70, 3) + + -- Player Slots + GUI.playerPanel = Group(GUI.panel, "playerPanel") + LayoutHelpers.AtLeftTopIn(GUI.playerPanel, GUI.panel, 6, 70) + LayoutHelpers.SetDimensions(GUI.playerPanel, 706, 307) + + -- Observer section + GUI.observerPanel = Group(GUI.panel, "observerPanel") + + -- Scale the observer panel according to the buttons we are showing. + local obsOffset + local obsHeight + if isHost then + obsHeight = 84--159 + obsOffset = 620--545 + else + obsHeight = 206 + obsOffset = 498 + end + LayoutHelpers.AtLeftTopIn(GUI.observerPanel, GUI.panel, 512, obsOffset) + LayoutHelpers.SetDimensions(GUI.observerPanel, 278, obsHeight) + UIUtil.SurroundWithBorder(GUI.observerPanel, '/scx_menu/lan-game-lobby/frame/') + + -- Chat + GUI.chatPanel = Group(GUI.panel, "chatPanel") + LayoutHelpers.AtLeftTopIn(GUI.chatPanel, GUI.panel, 11, 459) + LayoutHelpers.SetWidth(GUI.chatPanel, 478) + LayoutHelpers.SetHeight(GUI.chatPanel, 245) + UIUtil.SurroundWithBorder(GUI.chatPanel, '/scx_menu/lan-game-lobby/frame/') + + if isHost then + GUI.AIFillPanel = Group(GUI.panel) + GUI.AIFillPanel.Left:Set(GUI.observerPanel.Left) + GUI.AIFillPanel.Top:Set(GUI.chatPanel.Top) + LayoutHelpers.SetHeight(GUI.AIFillPanel, 60) + LayoutHelpers.SetWidth(GUI.AIFillPanel, 278) + UIUtil.SurroundWithBorder(GUI.AIFillPanel, '/scx_menu/lan-game-lobby/frame/') + GUI.AIFillCombo = Combo.Combo(GUI.AIFillPanel, 14, 12, false, nil) + LayoutHelpers.AtHorizontalCenterIn(GUI.AIFillCombo, GUI.AIFillPanel) + LayoutHelpers.AtTopIn(GUI.AIFillCombo, GUI.AIFillPanel, 5) + GUI.AIFillCombo.Width:Set(function() return GUI.AIFillPanel.Width() - LayoutHelpers.ScaleNumber(15) end) + GUI.AIFillCombo:AddItems(AIStrings) + GUI.AIFillCombo:SetTitleText(LOC('Choose AI for autofilling')) + Tooltip.AddComboTooltip(GUI.AIFillCombo, AITooltips) + GUI.AIFillButton = UIUtil.CreateButtonStd(GUI.AIFillCombo, '/BUTTON/medium/', LOC('Fill Slots'), 12) + LayoutHelpers.SetWidth(GUI.AIFillButton, 129) + LayoutHelpers.SetHeight(GUI.AIFillButton, 30) + LayoutHelpers.AtLeftTopIn(GUI.AIFillButton, GUI.AIFillCombo, -10, 20) + GUI.AIClearButton = UIUtil.CreateButtonStd(GUI.AIFillButton, '/BUTTON/medium/', LOC('Clear Slots'), 12) + GUI.AIClearButton.Width:Set(GUI.AIFillButton.Width) + GUI.AIClearButton.Height:Set(GUI.AIFillButton.Height) + LayoutHelpers.RightOf(GUI.AIClearButton, GUI.AIFillButton, -19) + GUI.TeamCountSelector = Combo.BitmapCombo(GUI.AIClearButton, teamIcons, 1, false, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") + LayoutHelpers.SetWidth(GUI.TeamCountSelector, 44) + LayoutHelpers.AtTopIn(GUI.TeamCountSelector, GUI.AIClearButton, 5) + LayoutHelpers.AtRightIn(GUI.TeamCountSelector, GUI.AIFillPanel, 8) + local tooltipText = {} + tooltipText['text'] = 'Teams Count' + tooltipText['body'] = 'On how many teams share players?' + Tooltip.AddControlTooltip(GUI.TeamCountSelector, tooltipText, 0) + local ChangedSlots = {} + GUI.AIFillButton.OnClick = function() + local AIKeyIndex, AIName = GUI.AIFillCombo:GetItem() + if ChangedSlots[1] ~= nil then + for i = 1, table.getn(ChangedSlots) do + HostUtils.AddAI(AIName, AIKeys[AIKeyIndex], ChangedSlots[i]) + end + else + for Slot = 1, GetNumAvailStartSpots() do + if not (gameInfo.PlayerOptions[Slot] or gameInfo.ClosedSlots[Slot]) then + HostUtils.AddAI(AIName, AIKeys[AIKeyIndex], Slot) + table.insert(ChangedSlots, Slot) + end + end + end + if gameInfo.GameOptions.AutoTeams == 'none' then + GUI.TeamCountSelector.OnClick(nil, GUI.TeamCountSelector:GetItem(), nil) + else + AssignAutoTeams() + end + end + GUI.AIClearButton.OnClick = function() + for i = 1, table.getn(ChangedSlots) do + HostUtils.RemoveAI(ChangedSlots[i]) + end + ChangedSlots = {} + end + GUI.TeamCountSelector.OnClick = function(Self, Index, Text) + local OccupiedSlots = 0 + local AvailStartSpots = GetNumAvailStartSpots() + for Slot = 1, AvailStartSpots do + if gameInfo.PlayerOptions[Slot] ~= nil then + OccupiedSlots = OccupiedSlots + 1 + end + end + local PlayersPerTeam = 0 + if Index > 1 then + PlayersPerTeam = math.floor(OccupiedSlots / (Index - 1)) + end + local AssignedTeam = 2 + local Counter = 0 + for Slot = 1, AvailStartSpots do + if gameInfo.PlayerOptions[Slot] then + if AssignedTeam > Index then + SetPlayerOption(Slot, 'Team', 1, true) + else + SetPlayerOption(Slot, 'Team', AssignedTeam, true) + Counter = Counter + 1 + if Counter >= PlayersPerTeam then + AssignedTeam = AssignedTeam + 1 + Counter = 0 + end + end + SetSlotInfo(Slot, gameInfo.PlayerOptions[Slot]) + end + end + end + end + + -- Map Preview + GUI.mapPanel = Group(GUI.panel, "mapPanel") + LayoutHelpers.AtLeftTopIn(GUI.mapPanel, GUI.panel, 813, 88) + LayoutHelpers.SetDimensions(GUI.mapPanel, 198, 198) + LayoutHelpers.DepthOverParent(GUI.mapPanel, GUI.panel, 2) + UIUtil.SurroundWithBorder(GUI.mapPanel, '/scx_menu/lan-game-lobby/frame/') + + -- Map Preview Info Labels + local tooltipText = {} + tooltipText['text'] = LOC("Map Preview") + -- Map Preview Info Labels + if isHost then + tooltipText['body'] = LOCF("%s\n%s", "Left click ACU icon to move yourself or swap players.", "Right click ACU icon to close or open the slot.") + else + tooltipText['body'] = LOC("Left click ACU icon to move yourself.") + end + Tooltip.AddControlTooltip(GUI.mapPanel, tooltipText) + + GUI.optionsPanel = Group(GUI.panel, "optionsPanel") -- ORANGE Square in Screenshoot + LayoutHelpers.AtLeftTopIn(GUI.optionsPanel, GUI.panel, 813, 325) + LayoutHelpers.SetDimensions(GUI.optionsPanel, 198, 337) + LayoutHelpers.DepthOverParent(GUI.optionsPanel, GUI.panel, 2) + UIUtil.SurroundWithBorder(GUI.optionsPanel, '/scx_menu/lan-game-lobby/frame/') + + --------------------------------------------------------------------------- + -- set up map panel + --------------------------------------------------------------------------- + GUI.mapView = ResourceMapPreview(GUI.mapPanel, 200, 3, 5) + LayoutHelpers.AtLeftTopIn(GUI.mapView, GUI.mapPanel, -1, -1) + LayoutHelpers.DepthOverParent(GUI.mapView, GUI.mapPanel, -1) + + GUI.LargeMapPreview = UIUtil.CreateButtonWithDropshadow(GUI.mapPanel, '/BUTTON/zoom/', "") + LayoutHelpers.SetDimensions(GUI.LargeMapPreview, 30, 30) + LayoutHelpers.AtRightIn(GUI.LargeMapPreview, GUI.mapPanel, -1) + LayoutHelpers.AtBottomIn(GUI.LargeMapPreview, GUI.mapPanel, -1) + LayoutHelpers.DepthOverParent(GUI.LargeMapPreview, GUI.mapPanel, 2) + Tooltip.AddButtonTooltip(GUI.LargeMapPreview, 'lob_click_LargeMapPreview') + GUI.LargeMapPreview.OnClick = function() + CreateBigPreview(GUI) + end + + -- Checkbox Show changed Options + local cbox_ShowChangedOption = UIUtil.CreateCheckbox(GUI.optionsPanel, '/CHECKBOX/', LOC("Hide default options"), true, 11) + LayoutHelpers.AtLeftTopIn(cbox_ShowChangedOption, GUI.optionsPanel, 0, -32) + + Tooltip.AddCheckboxTooltip(cbox_ShowChangedOption, {text=LOC("Hide default options"), body=LOC("Show only changed Options and Advanced Map Options")}) + cbox_ShowChangedOption.OnCheck = function(self, checked) + HideDefaultOptions = checked + RefreshOptionDisplayData() + GUI.OptionContainer:ScrollSetTop('Vert', 0) + Prefs.SetToCurrentProfile('LobbyHideDefaultOptions', tostring(checked)) + end + + -- Patchnotes Button + GUI.patchnotesButton = UIUtil.CreateButtonWithDropshadow(GUI.panel, '/Button/medium/', "Patchnotes") + Tooltip.AddButtonTooltip(GUI.patchnotesButton, 'Lobby_patchnotes') + LayoutHelpers.AtBottomIn(GUI.patchnotesButton, GUI.optionsPanel, -51) + LayoutHelpers.AtHorizontalCenterIn(GUI.patchnotesButton, GUI.optionsPanel, -55) + GUI.patchnotesButton.OnClick = function(self, event) + import("/lua/ui/lobby/changelog/changelogdialog.lua").CreateChangelogDialog(GUI) + end + + -- Create mission briefing button + local briefingButton = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/', "Briefing") + GUI.briefingButton = briefingButton + LayoutHelpers.AtBottomIn(GUI.briefingButton, GUI.optionsPanel, -51) + LayoutHelpers.AtHorizontalCenterIn(GUI.briefingButton, GUI.optionsPanel, -55) + briefingButton.OnClick = function(self, modifiers) + GUI.briefing = Group(GUI) + GUI.briefing.Depth:Set(function() return GUI.Depth() + 20 end) + LayoutHelpers.FillParent(GUI.briefing, GUI) + import('/lua/ui/campaign/operationbriefing.lua').CreateUI(GUI.briefing, gameInfo.GameOptions.ScenarioFile) + end + + -- A buton that, for the host, is "game options", but for everyone else shows a ready-only mod + -- manager. + if isHost then + GUI.gameoptionsButton = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/', "") + Tooltip.AddButtonTooltip(GUI.gameoptionsButton, 'lob_select_map') + GUI.gameoptionsButton.OnClick = function(self) + local mapSelectDialog + + autoRandMap = false + local function selectBehavior(selectedScenario, changedOptions, restrictedCategories) + local options = {} + if autoRandMap then + options['ScenarioFile'] = selectedScenario.file + else + mapSelectDialog:Destroy() + GUI.chatEdit:AcquireFocus() + + -- remove old 'Advanced options incase of new map + if gameInfo.GameOptions.ScenarioFile and string.lower(selectedScenario.file) ~= string.lower(gameInfo.GameOptions.ScenarioFile) then + local scenario = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + if scenario.options then + for _,value in scenario.options do + gameInfo.GameOptions[value.key] = nil + end + end + end + + for optionKey, data in changedOptions do + options[optionKey] = data.value + end + options['ScenarioFile'] = selectedScenario.file + options['RestrictedCategories'] = restrictedCategories + + -- every new map, clear the flags, and clients will report if a new map is bad + ClearBadMapFlags() + HostUtils.UpdateMods() + SetGameOptions(options) + end + for optionKey, data in changedOptions do + if optionKey == 'AutoTeams' then + AssignAutoTeams() + end + end + end + + local function exitBehavior() + mapSelectDialog:Close() + GUI.chatEdit:AcquireFocus() + UpdateGame() + end + + GUI.chatEdit:AbandonFocus() + + mapSelectDialog = import("/lua/ui/dialogs/mapselect.lua").CreateDialog( + selectBehavior, + exitBehavior, + GUI, + singlePlayer, + gameInfo.GameOptions.ScenarioFile, + gameInfo.GameOptions, + availableMods, + OnModsChanged + ) + end + else + local modsManagerCallback = function(active_sim_mods, active_ui_mods) + import("/lua/mods.lua").SetSelectedMods(SetUtils.Union(active_sim_mods, active_ui_mods)) + RefreshOptionDisplayData() + GUI.chatEdit:AcquireFocus() + end + GUI.gameoptionsButton = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/', LOC("")) + GUI.gameoptionsButton.OnClick = function(self, modifiers) + import("/lua/ui/lobby/modsmanager.lua").CreateDialog(GUI, false, nil, modsManagerCallback) + end + Tooltip.AddButtonTooltip(GUI.gameoptionsButton, 'Lobby_Mods') + end + + LayoutHelpers.AtBottomIn(GUI.gameoptionsButton, GUI.optionsPanel, -51) + LayoutHelpers.AtHorizontalCenterIn(GUI.gameoptionsButton, GUI.optionsPanel, 53) + + --------------------------------------------------------------------------- + -- set up chat display + --------------------------------------------------------------------------- + + GUI.chatDisplay = import("/lua/ui/lobby/chatarea.lua").ChatArea( + GUI.chatPanel, + function() return GUI.chatPanel.Width() - 20 end, + function() return GUI.chatPanel.Height() - GUI.chatBG.Height() end + ) + LayoutHelpers.AtLeftTopIn(GUI.chatDisplay, GUI.chatPanel, 2) + LayoutHelpers.DepthUnderParent(GUI.chatDisplay, GUI.chatPanel) + + --------------------------------------------------------------------------- + -- set up all .*Scroll* functions for the chat panel + --------------------------------------------------------------------------- + GUI.chatPanel.top = 1 -- using 1-based index scrolling + + -- this function get index of 1st line on the last scroll page (when scroll all the way down) + GUI.chatPanel.GetScrollLastPage = function(self) + return table.getn(GUI.chatDisplay.ChatLines) - self.linesPerScrollPage + end + -- this function gets scrolling max range and current range + GUI.chatPanel.GetScrollValues = function(self, axis) + local max = table.getsize(GUI.chatDisplay.ChatLines) + local bottom = math.min(self.top + self.linesPerScrollPage, max) + return 1, max, self.top, bottom + end + -- this function controls how many lines to scroll when clicking on up/down arrows of the scrollbar + GUI.chatPanel.ScrollLines = function(self, axis, delta) + self:ScrollSetTop(axis, self.top + math.floor(delta)) + end + -- this function controls how many pages to scroll when clicking above/below thumb of the scrollbar + GUI.chatPanel.ScrollPages = function(self, axis, delta) + self:ScrollSetTop(axis, self.top + math.floor(delta) * self.linesPerScrollPage) + end + -- this function controls how to scroll to an item from top index + GUI.chatPanel.ScrollSetTop = function(self, axis, top) + top = math.floor(top) + if top == self.top then return end + local delta = self:GetScrollLastPage() + self.top = math.max(math.min(delta + 1, top), 1) + self.bottom = self.top + self.linesPerScrollPage + GUI.chatDisplay:ShowLines(self.top, self.bottom) + if self.top >= delta + 1 then + GUI.newMessageArrow:Disable() + end + end + -- this function triggers scrolling on mouse wheel event + GUI.chatPanel.HandleEvent = function(self, event) + if event.Type == 'WheelRotation' then + -- scroll chat panel by 1 line in up/down direction + local lines = event.WheelRotation > 0 and -1 or 1 + self:ScrollLines(nil, lines) + end + end + -- this function informs vertical scrollbar that the chat panel can be scrolled + GUI.chatPanel.IsScrollable = function(self, axis) + return true + end + GUI.chatPanel.ScrollToBottom = function(self) + self:ScrollSetTop(nil, self:GetScrollLastPage() + 1) + end + GUI.chatPanel.IsScrolledToBottom = function(self) + return self.top >= self:GetScrollLastPage() + end + -- this function set how many chat lines can fit per scroll page (chatPanel) + GUI.chatPanel.LinesOnPage = import("/lua/lazyvar.lua").Create() + GUI.chatPanel.LinesOnPage.OnDirty = function(var) + GUI.chatPanel.linesPerScrollPage = var() + end + -- --------- Chat Scrolling Functions ----------------------- + + -- this function sets font for all chat lines and re-creates them + GUI.chatPanel.SetFont = function(self, fontFamily, fontSize) + GUI.chatDisplay:SetFont(fontFamily, fontSize) + GUI.chatDisplay:ShowLines(self.top, self.bottom) + end + -- set initial scrolling based on chat font size + local fontSize = tonumber(Prefs.GetFromCurrentProfile('LobbyChatFontSize')) or 14 + + local newMessageArrow = Button(GUI.chatPanel, '/textures/ui/common/lobby/chat_arrow/arrow_up.dds', '/textures/ui/common/lobby/chat_arrow/arrow_down.dds', '/textures/ui/common/lobby/chat_arrow/arrow_down.dds','/textures/ui/common/lobby/chat_arrow/arrow_dis.dds', "UI_Arrow_Click") + GUI.newMessageArrow = newMessageArrow + -- newMessageArrow:SetTexture('/textures/ui/common/FACTIONSELECTOR/aeon/d_up.dds') + LayoutHelpers.AtBottomIn(newMessageArrow, GUI.chatDisplay, 5) + LayoutHelpers.AtRightIn(newMessageArrow, GUI.chatDisplay, 5) + LayoutHelpers.DepthOverParent(newMessageArrow, GUI.chatDisplay, 5) + newMessageArrow.Width:Set(25) + newMessageArrow.Height:Set(25) + GUI.newMessageArrow.OnClick = function(this, modifiers) + GUI.chatPanel:ScrollToBottom() + end + GUI.newMessageArrow:Disable() + + -- Annoying evil extra Bitmap to make chat box have padding inside its background. + local chatBG = Bitmap(GUI.chatPanel) + GUI.chatBG = chatBG + chatBG:SetSolidColor('FF212123') + LayoutHelpers.Below(chatBG, GUI.chatDisplay, 0) + LayoutHelpers.AtLeftIn(chatBG, GUI.chatDisplay, -2) + chatBG.Width:Set(GUI.chatPanel.Width) + LayoutHelpers.SetHeight(chatBG, 24) + + -- Set up the chat edit buttons and functions + setupChatEdit(GUI.chatPanel) + -- finally create chat lines + GUI.chatDisplay:CreateLines() + --------------------------------------------------------------------------- + -- Option display + --------------------------------------------------------------------------- + GUI.OptionContainer = Group(GUI.optionsPanel) + GUI.OptionContainer.Bottom:Set(function() return GUI.optionsPanel.Bottom() end) + + -- Leave space for the scrollbar. + GUI.OptionContainer.Width:Set(function() return GUI.optionsPanel.Width() - LayoutHelpers.ScaleNumber(18) end) + GUI.OptionContainer.top = 0 + LayoutHelpers.AtLeftTopIn(GUI.OptionContainer, GUI.optionsPanel, 1, 1) + LayoutHelpers.DepthOverParent(GUI.OptionContainer, GUI.optionsPanel, -1) + + GUI.OptionDisplay = {} + + function CreateOptionElements() + local function CreateElement(index) + local element = Group(GUI.OptionContainer) + + element.bg = Bitmap(element) + element.bg:SetSolidColor('ff333333') + element.bg.Left:Set(element.Left) + element.bg.Right:Set(element.Right) + element.bg.Bottom:Set(function() return element.value.Bottom() + 2 end) + element.bg.Top:Set(element.Top) + + element.bg2 = Bitmap(element) + element.bg2:SetSolidColor('ff000000') + element.bg2.Left:Set(function() return element.bg.Left() + 1 end) + element.bg2.Right:Set(function() return element.bg.Right() - 1 end) + element.bg2.Bottom:Set(function() return element.bg.Bottom() - 1 end) + element.bg2.Top:Set(function() return element.value.Top() + 0 end) + + LayoutHelpers.SetHeight(element, 36) + element.Width:Set(GUI.OptionContainer.Width) + element:DisableHitTest() + + element.text = UIUtil.CreateText(element, '', 14, "Arial") + element.text:SetColor(UIUtil.fontColor) + element.text:DisableHitTest() + LayoutHelpers.AtLeftTopIn(element.text, element, 5) + + element.value = UIUtil.CreateText(element, '', 14, "Arial") + element.value:SetColor(UIUtil.fontOverColor) + element.value:DisableHitTest() + LayoutHelpers.AtRightTopIn(element.value, element, 5, 16) + + GUI.OptionDisplay[index] = element + end + + CreateElement(1) + LayoutHelpers.AtLeftTopIn(GUI.OptionDisplay[1], GUI.OptionContainer) + + local index = 2 + while index ~= 10 do + CreateElement(index) + LayoutHelpers.Below(GUI.OptionDisplay[index], GUI.OptionDisplay[index-1]) + index = index + 1 + end + end + CreateOptionElements() + + local numLines = function() return table.getsize(GUI.OptionDisplay) end + + local function DataSize() + if HideDefaultOptions then + return table.getn(nonDefaultFormattedOptions) + else + return table.getn(formattedOptions) + end + end + + -- called when the scrollbar for the control requires data to size itself + -- GetScrollValues must return 4 values in this order: + -- rangeMin, rangeMax, visibleMin, visibleMax + -- aixs can be "Vert" or "Horz" + GUI.OptionContainer.GetScrollValues = function(self, axis) + local size = DataSize() + --LOG(size, ":", self.top, ":", math.min(self.top + numLines, size)) + return 0, size, self.top, math.min(self.top + numLines(), size) + end + + -- called when the scrollbar wants to scroll a specific number of lines (negative indicates scroll up) + GUI.OptionContainer.ScrollLines = function(self, axis, delta) + self:ScrollSetTop(axis, self.top + math.floor(delta)) + end + + -- called when the scrollbar wants to scroll a specific number of pages (negative indicates scroll up) + GUI.OptionContainer.ScrollPages = function(self, axis, delta) + self:ScrollSetTop(axis, self.top + math.floor(delta) * numLines()) + end + + -- called when the scrollbar wants to set a new visible top line + GUI.OptionContainer.ScrollSetTop = function(self, axis, top) + top = math.floor(top) + if top == self.top then return end + local size = DataSize() + self.top = math.max(math.min(size - numLines() , top), 0) + self:CalcVisible() + end + + -- called to determine if the control is scrollable on a particular access. Must return true or false. + GUI.OptionContainer.IsScrollable = function(self, axis) + return true + end + -- determines what controls should be visible or not + GUI.OptionContainer.CalcVisible = function(self) + local function SetTextLine(line, data, lineID) + if data.mod then + -- The special label at the top stating the number of mods. + line.text:SetColor('ffff7777') + LayoutHelpers.AtHorizontalCenterIn(line.text, line, 5) + LayoutHelpers.AtHorizontalCenterIn(line.value, line, 5, 16) + LayoutHelpers.ResetRight(line.value) + else + -- Game options. + line.text:SetColor(UIUtil.fontColor) + LayoutHelpers.AtLeftTopIn(line.text, line, 5) + LayoutHelpers.AtRightTopIn(line.value, line, 5, 16) + LayoutHelpers.ResetLeft(line.value) + end + line.text:SetText(LOCF(data.text, data.key)) + line.bg:Show() + line.value:SetText(LOCF(data.value, data.key)) + line.bg2:Show() + line.bg.HandleEvent = Group.HandleEvent + line.bg2.HandleEvent = Bitmap.HandleEvent + if data.tooltip then + Tooltip.AddControlTooltip(line.bg, data.tooltip) + Tooltip.AddControlTooltip(line.bg2, data.valueTooltip) + end + + if data.manualTooltipTitle then + Tooltip.AddControlTooltipManual(line.bg, data.manualTooltipTitle, data.manualTooltipDescription ) + Tooltip.AddControlTooltipManual(line.bg2, data.manualTooltipTitle, data.manualTooltipDescription ) + end + + end + + local optionsToUse + if HideDefaultOptions then + optionsToUse = nonDefaultFormattedOptions + else + optionsToUse = formattedOptions + end + + for i, v in GUI.OptionDisplay do + if optionsToUse[i + self.top] then + SetTextLine(v, optionsToUse[i + self.top], i + self.top) + else + v.text:SetText('') + v.value:SetText('') + v.bg:Hide() + v.bg2:Hide() + end + end + end + + GUI.OptionContainer.HandleEvent = function(self, event) + if event.Type == 'WheelRotation' then + local lines = 1 + if event.WheelRotation > 0 then + lines = -1 + end + self:ScrollLines(nil, lines) + end + end + + RefreshOptionDisplayData() + + GUI.OptionContainerScroll = UIUtil.CreateLobbyVertScrollbar(GUI.OptionContainer, 2) + LayoutHelpers.DepthOverParent(GUI.OptionContainerScroll, GUI.OptionContainer, 2) + + -- Launch Button + local launchGameButton = UIUtil.CreateButtonWithDropshadow(GUI.chatPanel, '/BUTTON/large/', LOC("Launch Game")) + GUI.launchGameButton = launchGameButton + LayoutHelpers.AtHorizontalCenterIn(launchGameButton, GUI) + LayoutHelpers.AtBottomIn(launchGameButton, GUI.panel, -8) + Tooltip.AddButtonTooltip(launchGameButton, 'Lobby_Launch') + UIUtil.setVisible(launchGameButton, isHost) + launchGameButton.OnClick = function(self) + TryLaunch(false) + end + + -- Create skirmish mode's "load game" button. + local loadButton = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/',"Load") + GUI.loadButton = loadButton + UIUtil.setVisible(loadButton, singlePlayer) + LayoutHelpers.AtVerticalCenterIn(GUI.loadButton, launchGameButton, 7) + LayoutHelpers.AtHorizontalCenterIn(GUI.loadButton, GUI.optionsPanel) + loadButton.OnClick = function(self, modifiers) + import("/lua/ui/dialogs/saveload.lua").CreateLoadDialog(GUI) + end + Tooltip.AddButtonTooltip(loadButton, 'Lobby_Load') + + -- Create the "Lobby presets" button for the host. If not the host, the same field is occupied + -- instead by the read-only "Unit Manager" button. + GUI.restrictedUnitsOrPresetsBtn = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/', "") + + if singlePlayer then + GUI.restrictedUnitsOrPresetsBtn:Hide() + elseif isHost then + GUI.restrictedUnitsOrPresetsBtn.label:SetText(LOC("Presets")) + GUI.restrictedUnitsOrPresetsBtn.OnClick = function(self, modifiers) + Presets.CreateUI(GUI) + end + Tooltip.AddButtonTooltip(GUI.restrictedUnitsOrPresetsBtn, 'Lobby_presetDescription') + else + GUI.restrictedUnitsOrPresetsBtn.label:SetText(LOC("Unit Manager")) + GUI.restrictedUnitsOrPresetsBtn.OnClick = function(self, modifiers) + import("/lua/ui/lobby/unitsmanager.lua").CreateDialog(GUI.panel, gameInfo.GameOptions.RestrictedCategories, function() end, function() end, false) + end + Tooltip.AddButtonTooltip(GUI.restrictedUnitsOrPresetsBtn, 'lob_RestrictedUnitsClient') + end + LayoutHelpers.AtVerticalCenterIn(GUI.restrictedUnitsOrPresetsBtn, launchGameButton, 7) + LayoutHelpers.AtHorizontalCenterIn(GUI.restrictedUnitsOrPresetsBtn, GUI.optionsPanel) + + --------------------------------------------------------------------------- + -- Checkbox Show changed Options + --------------------------------------------------------------------------- + cbox_ShowChangedOption:SetCheck(HideDefaultOptions, false) + + --------------------------------------------------------------------------- + -- set up : player grid + --------------------------------------------------------------------------- + + -- For disgusting reasons, we pass the label factory as a parameter. + CreateSlotsUI(makeLabel) + + -- Exit Button + GUI.exitButton = UIUtil.CreateButtonWithDropshadow(GUI.chatPanel, '/BUTTON/medium/', LOC("Exit")) + LayoutHelpers.AtLeftIn(GUI.exitButton, GUI.chatPanel, 33) + LayoutHelpers.AtVerticalCenterIn(GUI.exitButton, launchGameButton, 7) + if HasCommandLineArg("/gpgnet") then + -- Quit to desktop + GUI.exitButton.label:SetText(LOC("")) + Tooltip.AddButtonTooltip(GUI.exitButton, 'esc_exit') + else + -- Back to main menu + GUI.exitButton.label:SetText(LOC("")) + Tooltip.AddButtonTooltip(GUI.exitButton, 'esc_quit') + end + + GUI.exitButton.OnClick = GUI.exitLobbyEscapeHandler + + + -- Small buttons are 100 wide, 44 tall + + -- Default option button + GUI.defaultOptions = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/defaultoption/') + -- If we're the host, position the buttons lower down (and eventually shrink the observer panel) + if not isHost then + GUI.defaultOptions:Hide() + end + LayoutHelpers.AtLeftTopIn(GUI.defaultOptions, GUI.observerPanel, 11, -94) + + Tooltip.AddButtonTooltip(GUI.defaultOptions, 'lob_click_rankedoptions') + if not isHost then + GUI.defaultOptions:Disable() + else + GUI.defaultOptions.OnClick = function() + UIUtil.QuickDialog(GUI, LOC('Are you sure you want to reset to default values?'), + "", function() + -- Return all options to their default values. + OptionUtils.SetDefaults() + lobbyComm:BroadcastData({ Type = "SetAllPlayerNotReady" }) + UpdateGame() + end, + + "", nil, + nil, nil, + true + ) + end + end + + -- RANDOM MAP BUTTON -- + GUI.randMap = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/randommap/') + LayoutHelpers.RightOf(GUI.randMap, GUI.defaultOptions, -19) + Tooltip.AddButtonTooltip(GUI.randMap, 'lob_click_randmap') + if not isHost then + GUI.randMap:Hide() + else + GUI.randMap.OnClick = function() + local randomMap + local mapSelectDialog + + autoRandMap = false + + -- Load the set of all available maps, with a slight evil hack on the mapselect module. + local mapDialog = import("/lua/ui/dialogs/mapselect.lua") + local allMaps = mapDialog.LoadScenarios() -- Result will be cached. + + -- Only include maps which have enough slots for the players we have. + local filteredMaps = table.filter(allMaps, + function(scenInfo) + local supportedPlayers = table.getsize(scenInfo.Configurations.standard.teams[1].armies) + return supportedPlayers >= GetPlayerCount() + end + ) + local mapCount = table.getn(filteredMaps) + local selectedMap = filteredMaps[math.floor(math.random(1, mapCount))] + + -- Set the new map. + SetGameOption('ScenarioFile', selectedMap.file) + ClearBadMapFlags() + UpdateGame() + end + end + + local autoteamButtonStates = { + { + key = 'tvsb', + tooltip = 'lob_auto_tvsb' + }, + { + key = 'lvsr', + tooltip = 'lob_auto_lvsr' + }, + { + key = 'pvsi', + tooltip = 'lob_auto_pvsi' + }, + { + key = 'manual', + tooltip = 'lob_auto_manual' + }, + { + key = 'none', + tooltip = 'lob_auto_none' + }, + } + + local initialState = Prefs.GetFromCurrentProfile("LobbyOpt_AutoTeams") or "none" + GUI.autoTeams = ToggleButton(GUI.observerPanel, '/BUTTON/autoteam/', autoteamButtonStates, initialState) + + LayoutHelpers.RightOf(GUI.autoTeams, GUI.randMap, -19) + if not isHost then + GUI.autoTeams:Hide() + else + GUI.autoTeams.OnStateChanged = function(self, newState) + SetGameOption('AutoTeams', newState) + AssignAutoTeams() + end + end + + -- CLOSE/OPEN EMPTY SLOTS BUTTON -- + GUI.closeEmptySlots = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/closeslots/') + Tooltip.AddButtonTooltip(GUI.closeEmptySlots, 'lob_close_empty_slots') + if not isHost then + GUI.closeEmptySlots:Hide() + LayoutHelpers.AtLeftTopIn(GUI.closeEmptySlots, GUI.defaultOptions, -40, 43) + else + LayoutHelpers.AtLeftTopIn(GUI.closeEmptySlots, GUI.defaultOptions, -31, 43) + GUI.closeEmptySlots.OnClick = function(self, modifiers) + if lobbyComm:IsHost() then + if modifiers.Ctrl then + for slot = 1,numOpenSlots do + HostUtils.SetSlotClosed(slot, false) + end + return + end + local openSpot = false + for slot = 1,numOpenSlots do + openSpot = openSpot or not (gameInfo.PlayerOptions[slot] or gameInfo.ClosedSlots[slot]) + end + if modifiers.Right and gameInfo.AdaptiveMap then + for slot = 1,numOpenSlots do + if openSpot then + if not (gameInfo.PlayerOptions[slot] or gameInfo.ClosedSlots[slot]) then + HostUtils.SetSlotClosedSpawnMex(slot) + end + else + if gameInfo.ClosedSlots[slot] and gameInfo.SpawnMex[slot] then + HostUtils.SetSlotClosed(slot, false) + end + end + end + else + for slot = 1,numOpenSlots do + if not gameInfo.SpawnMex[slot] then + HostUtils.SetSlotClosed(slot, openSpot) + end + end + end + end + end + end + + + -- GO OBSERVER BUTTON -- + GUI.becomeObserver = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/observer/') + LayoutHelpers.RightOf(GUI.becomeObserver, GUI.closeEmptySlots, -25) + Tooltip.AddButtonTooltip(GUI.becomeObserver, 'lob_become_observer') + GUI.becomeObserver.OnClick = function() + if IsPlayer(localPlayerID) then + if isHost then + HostUtils.ConvertPlayerToObserver(FindSlotForID(localPlayerID)) + else + lobbyComm:SendData(hostID, {Type = 'RequestConvertToObserver'}) + end + elseif IsObserver(localPlayerID) then + if isHost then + HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(localPlayerID)) + else + lobbyComm:SendData(hostID, {Type = 'RequestConvertToPlayer'}) + end + end + end + + -- CPU BENCH BUTTON -- + GUI.rerunBenchmark = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/cputest/', '', 11) + LayoutHelpers.RightOf(GUI.rerunBenchmark, GUI.becomeObserver, -25) + Tooltip.AddButtonTooltip(GUI.rerunBenchmark,{text=LOC("Run CPU Benchmark Test"), body=LOC("Recalculates your CPU rating.")}) + GUI.rerunBenchmark.OnClick = function(self, modifiers) + ForkThread(function() UpdateBenchmark(true) end) + end + + -- Autobalance Button -- + GUI.PenguinAutoBalance = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/autobalance/') + LayoutHelpers.RightOf(GUI.PenguinAutoBalance, GUI.rerunBenchmark, -25) + Tooltip.AddButtonTooltip(GUI.PenguinAutoBalance, {text=LOC("Autobalance"), body=LOC("Automatically balance players into 2 equally sized teams")}) + if not isHost then + GUI.PenguinAutoBalance:Hide() + else + -- What this does: it balances all occupied slots into two teams with equal numbers of + -- players. If teams are set manually and half of the occupied slots are set to team 1 + -- and half to team 2, then it balances the players while keeping the team-slot matches. + -- If the teams are set manually, but there is an uneven number of players on teams 1 + -- and 2, then players' teams are changed automatically to be alternating team 1 and team 2. + -- If there are an odd number of occupied slots, the last one is set to team - (no team) + -- and the others are balanced without it. Alternatively, if teams are not set manually, + -- players will be balanced into the slowest available slot numbers on their teams. + -- If there is an odd number of players in that case, the last player will be made an + -- observer if human or removed if AI. + + -- How it balances: this function checks every possible balance combination for making + -- the two teams (while keeping their player counts equal to half the number of occupied + -- slots, rounded down, and not using the last player if there is an odd number of players). + -- To do this, the function sums up all the relevant players' ratings (keeping mean and + -- deviation separate - it balances teams to have similar total ratings, and also similar + -- total uncertainties (grayness)), and then divides by two. That yields the goal values + -- for each team. Any deviation from those values is calculated to help determine a team's + -- imbalance value. Then, the various team combinations are tested, and the one with the + -- lowest imbalance value is used. + + + -- Automatically balance an even number of non-observer players into 2 teams in the lobby + GUI.PenguinAutoBalance.OnClick = function() + + -- make sure spawns are set to fixed or penguin_autobalance + if gameInfo.GameOptions.TeamSpawn ~= 'fixed' and gameInfo.GameOptions.TeamSpawn ~= 'penguin_autobalance' then + gameInfo.GameOptions.TeamSpawn = 'fixed' + -- tell everyone else to set spawns to fixed + lobbyComm:BroadcastData { + Type = 'GameOptions', + Options = {['TeamSpawn'] = 'fixed'} + } + AddChatText(LOC("Enabled fixed spawn locations")) + end + + -- a table of the target mean, target deviation, and the lowest logged imbalance value + local goalValue = {0, 0, 99999} + + local playerCount = 0 + -- a table of the highest occupied slot's slot number, that slot's player's order number + -- in the playerRatings table, and a booleon of whether or not that player is human + local lastSlot = {0, 0, false} + local playerRatings = {} + -- get rating data for each player + for i, player in gameInfo.PlayerOptions:pairs() do + playerRatings[i] = {player.MEAN, player.DEV, player.StartSpot, player.Team - 1} + playerCount = playerCount + 1 + if player.StartSpot > lastSlot[1] then + lastSlot = {player.StartSpot, i, player.Human} + end + goalValue[1] = goalValue[1] + player.MEAN + goalValue[2] = goalValue[2] + player.DEV + end + + -- if there are fewer than 2 players, there is no need to balance + if playerCount < 2 then + UpdateGame() + return + end + + -- if there is an odd number of players, remove the last one from the balancing + if math.mod(playerCount, 2) == 1 then + goalValue[1] = goalValue[1] - playerRatings[lastSlot[2]][1] + goalValue[2] = goalValue[2] - playerRatings[lastSlot[2]][2] + playerRatings[lastSlot[2]] = nil + playerCount = playerCount - 1 + -- set the player to not be on a team if teams are manual and fixed + -- otherwise make the player an observer if human or remove it if AI + if gameInfo.GameOptions.AutoTeams == 'none' and gameInfo.GameOptions.TeamSpawn == 'fixed' then + for i, player in gameInfo.PlayerOptions:pairs() do + if player.StartSpot == lastSlot[1] then + player.Team = 1 -- no team + break + end + end + else + if lastSlot[3] then + HostUtils.ConvertPlayerToObserver(lastSlot[1]) + else + HostUtils.RemoveAI(lastSlot[1]) + end + end + end + + -- the goal value is all of the remaining players' ratings divided by 2 + goalValue[1] = goalValue[1] / 2 + goalValue[2] = goalValue[2] / 2 + + local sortedPlayerRatings = {} + local sortedSlotTeams = {} + local numPlayersTeam1 = 0 + local numPlayersTeam2 = 0 + local sortingValue1 + local sortingValue2 + -- sort the players in a weighted cross between displayed and base rating + -- the order goes from greatest to lowest result of: mean - (deviation * 2.2) + for i, player in playerRatings do + local orderNum = 1 + sortingValue1 = player[1] - (player[2] * 2.2) + for i2, player2 in playerRatings do + sortingValue2 = player2[1] - (player2[2] * 2.2) + if sortingValue1 < sortingValue2 or (sortingValue1 == sortingValue2 and i > i2) then + orderNum = orderNum + 1 + end + end + -- these are sorted in parallel + sortedPlayerRatings[orderNum] = {player[1], player[2]} + sortedSlotTeams[orderNum] = {player[3], player[4]} + if player[4] == 1 then + numPlayersTeam1 = numPlayersTeam1 + 1 + elseif player[4] == 2 then + numPlayersTeam2 = numPlayersTeam2 + 1 + end + end + + + -- the number of players per team + local teamSize = playerCount / 2 + + + -- make the sorted list of slots for each team + local sortedTeam1Slots = {} + local sortedTeam2Slots = {} + local team1OrderNum = 0 + local team2OrderNum = 0 + + local manualTeams + + if gameInfo.GameOptions.AutoTeams == 'pvsi' then -- odd vs even + for i = 1, 16 do + if not gameInfo.ClosedSlots[i] then + if math.mod(i, 2) == 1 then + team1OrderNum = team1OrderNum + 1 + sortedTeam1Slots[team1OrderNum] = i + else + team2OrderNum = team2OrderNum + 1 + sortedTeam2Slots[team2OrderNum] = i + end + end + end + elseif gameInfo.GameOptions.AutoTeams == 'tvsb' then -- top vs bottom + local midLine = GUI.mapView.Top() + (GUI.mapView.Height() / 2) + for i, startPosition in GUI.mapView.startPositions do + if not gameInfo.ClosedSlots[i] then + if startPosition.Top() < midLine then + team1OrderNum = team1OrderNum + 1 + sortedTeam1Slots[team1OrderNum] = i + else + team2OrderNum = team2OrderNum + 1 + sortedTeam2Slots[team2OrderNum] = i + end + end + end + elseif gameInfo.GameOptions.AutoTeams == 'lvsr' then -- left vs right + local midLine = GUI.mapView.Left() + (GUI.mapView.Width() / 2) + for i, startPosition in GUI.mapView.startPositions do + if not gameInfo.ClosedSlots[i] then + if startPosition.Left() < midLine then + team1OrderNum = team1OrderNum + 1 + sortedTeam1Slots[team1OrderNum] = i + else + team2OrderNum = team2OrderNum + 1 + sortedTeam2Slots[team2OrderNum] = i + end + end + end + else + manualTeams = true + end + + -- If the teams were not set properly, set them properly. + -- When teams are set manually, they are not set properly if the number of + -- players on either team does not equal the team size. + -- When teams are not set manually, they are not set properly if the number + -- of slots on either team is less than the team size. + if (manualTeams and (numPlayersTeam1 != teamSize or numPlayersTeam2 != teamSize)) + or (not manualTeams and (table.getn(sortedTeam1Slots) < teamSize or table.getn(sortedTeam2Slots) < teamSize)) then + -- set AutoTeams to none (so, they can be set by slot by this function) + gameInfo.GameOptions.AutoTeams = 'none' + local counter = 0 + for i, player in gameInfo.PlayerOptions:pairs() do + for i2, slotTeam in sortedSlotTeams do + if player.StartSpot == slotTeam[1] then + counter = counter + 1 + -- set the player's team + if math.mod(counter, 2) == 1 then + player.Team = 2 -- team 1 + slotTeam[2] = 1 + else + player.Team = 3 -- team 2 + slotTeam[2] = 2 + end + -- tell everyone else the team number for that slot + lobbyComm:BroadcastData( + { + Type = 'PlayerOptions', + Options = {['Team'] = slotTeam[2] + 1}, -- make team number 1 higher for the backend + Slot = slotTeam[1], + }) + break + end + end + end + end + + -- if teams are set to manual, make the sorted list of slots for each team + if gameInfo.GameOptions.AutoTeams == 'none' then + sortedTeam1Slots = {} + sortedTeam2Slots = {} + for i, slotTeam in sortedSlotTeams do + team1OrderNum = 0 + team2OrderNum = 0 + for i2, slotTeam2 in sortedSlotTeams do + if slotTeam[1] > slotTeam2[1] or (slotTeam[1] == slotTeam2[1] and i >= i2) then + if slotTeam2[2] == 1 then + team1OrderNum = team1OrderNum + 1 + else + team2OrderNum = team2OrderNum + 1 + end + end + end + -- add the slot to its team's table + if slotTeam[2] == 1 then + sortedTeam1Slots[team1OrderNum] = slotTeam[1] + else + sortedTeam2Slots[team2OrderNum] = slotTeam[1] + end + end + end + + + + -- a table of team1's mean, deviation, and imbalance value + local teamValue + -- a table of team members + local team1 = {} + -- a table of the most balanced team + local bestTeam = {} + local choosableCount = playerCount - teamSize + + -- the number of iterations is the number of team combinations to check, which is + -- exactly half of the number of possible teams, which covers every possibility, + -- since the remaining half are just the opposite of what was already checked, + -- which means they have the exact same balance + -- ie: Player A + Player B vs Player C + Player D == Player C + Player D vs Player A + Player B + -- this works because of the order in which the combinations are tested + local numIterations + if teamSize == 2 then + numIterations = 3 + elseif teamSize == 3 then + numIterations = 10 + elseif teamSize == 4 then + numIterations = 35 + elseif teamSize == 5 then + numIterations = 126 + elseif teamSize == 6 then + numIterations = 462 + elseif teamSize == 7 then + numIterations = 1716 + else + numIterations = 6435 + end + + local currentIteration = 0 + + -- test the balance of different combinations of teams, covering balance possibility + -- intended for use with 2 teams of even player counts + -- combinations are iterated starting with the lowest-numbered players on team1 first, + -- and progressively iterating the highest-numbered player on team1 to each higher-numbered + -- possible player, and then repeating the process with the next highest-numbered player + -- increasing by 1... this process continues until every possible balacnce combination + -- of 2 equally sized teams of even player counts has been covered + local function testCombinations(team1MemberNumber, firstPlayerToCheck) + -- check if this player is the last player on the team + local lastPlayer + if team1MemberNumber < teamSize then + lastPlayer = false + else + lastPlayer = true + end + -- iterate through the possible players for this team1MemberNumber + for i = firstPlayerToCheck, choosableCount + team1MemberNumber do + -- when the number of iterations is reached, every possible balance of even player count + -- of the 2 equally sized teams has been checked, and the function ends + if currentIteration >= numIterations then + return + end + team1[team1MemberNumber] = i + if lastPlayer then + -- test this combination of team members + teamValue = {0, 0, 0} + -- add each team member's base rating and devation to the team's values + for i, player in team1 do + teamValue[1] = teamValue[1] + sortedPlayerRatings[player][1] + teamValue[2] = teamValue[2] + sortedPlayerRatings[player][2] + end + -- calculate the team's imbalance value + teamValue[3] = math.abs(teamValue[2] - goalValue[2]) * 1.2 + math.abs(teamValue[1] - goalValue[1]) + -- check if the team's imbalance value is lower than the lowest logged imbalance value + if teamValue[3] < goalValue[3] then + -- if it is lower, then this is the best balance so far, and it is logged over the previous best balance + goalValue[3] = teamValue[3] + -- deepcopy the team's player numbers + for i, player in team1 do + bestTeam[i] = player + end + end + currentIteration = currentIteration + 1 + else + -- test a subset of combinations + testCombinations(team1MemberNumber + 1, i + 1) + end + end + end + + testCombinations(1, 1) + + + -- specify the players on team 2 (aka, the ones not on team 1) + local bestTeam2 = {} + for i = 1, playerCount do + if not table.find(bestTeam, i) then + table.insert(bestTeam2, i) + end + end + + --shuffle player pairs + local random + local temp + for i, slot in bestTeam do + random = Random(1, teamSize) + + --random swap on team 1 + temp = bestTeam[random] + bestTeam[random] = bestTeam[i] + bestTeam[i] = temp + + --mirrored swap on team2 + temp = bestTeam2[random] + bestTeam2[random] = bestTeam2[i] + bestTeam2[i] = temp + end + + -- move players on team1 to the intended slots + local team1OrderNum = 0 + local slotA + local slotB + for i, player in bestTeam do + team1OrderNum = team1OrderNum + 1 + slotA = sortedSlotTeams[player][1] + slotB = sortedTeam1Slots[team1OrderNum] + HostUtils.SwapPlayers(slotA, slotB) + -- keep track of the slot changes in sortedSlotTeams + for i, slotTeam in sortedSlotTeams do + if slotTeam[1] == slotB then + slotTeam[1] = slotA + break + end + end + sortedSlotTeams[player][1] = slotB + end + + -- move players on team2 to the intended slots + local team2OrderNum = 0 + for i, player in bestTeam2 do + team2OrderNum = team2OrderNum + 1 + slotA = sortedSlotTeams[player][1] + slotB = sortedTeam2Slots[team2OrderNum] + HostUtils.SwapPlayers(slotA, slotB) + -- keep track of the slot changes in sortedSlotTeams + for i, slotTeam in sortedSlotTeams do + if slotTeam[1] == slotB then + slotTeam[1] = slotA + break + end + end + sortedSlotTeams[player][1] = slotB + end + UpdateGame() + AddChatText(LOC("Finished autobalancing")) + end + end + + -- Observer List + GUI.observerList = ItemList(GUI.observerPanel) + GUI.observerList:SetFont(UIUtil.bodyFont, 12) + GUI.observerList:SetColors(UIUtil.fontColor, "00000000", UIUtil.fontOverColor, UIUtil.highlightColor, "ffbcfffe") + LayoutHelpers.AtLeftTopIn(GUI.observerList, GUI.observerPanel, 4, 2) + LayoutHelpers.AtRightBottomIn(GUI.observerList, GUI.observerPanel, 15) + GUI.observerList.OnClick = function(self, row, event) + if isHost and event.Modifiers.Right then + -- determine the number of teams (excluding the no team (-) option that equals 1 on the backend) + local teams = {} + local numTeams = 0 + for i, player in gameInfo.PlayerOptions:pairs() do + if not teams[player.Team] and player.Team ~= 1 then + teams[player.Team] = true + numTeams = numTeams + 1 + end + end + + -- adjust index by 1 because 0-based (ItemList rows) vs 1-based (Lua array) indexing + local obsIndex = row + 1 + local maxObsIndex = self:GetItemCount() + + -- adjust index by the number of rows taken up by team ratings. + ---@see refreshObserverList + if gameInfo.GameOptions['TeamSpawn'] == 'fixed' then + if numTeams < 3 then + obsIndex = obsIndex - numTeams + else + -- 3+ teams has ratings at the end of the list, don't allow kicking when clicking those rating rows + maxObsIndex = maxObsIndex - numTeams + end + end + + -- the host can get the kick dialog brought up for observer list rows that are players (aka, they have + -- a positive observer index and thereby aren't team ratings) and that aren't the local player (the host) + -- and that aren't the rows with team ratings when there are 3 or more teams + if obsIndex > 0 and gameInfo.Observers[obsIndex].OwnerID ~= localPlayerID and obsIndex <= maxObsIndex then + UIUtil.QuickDialog(GUI, "Are you sure?", + "Kick Player", function() + SendSystemMessage("lobui_0756", gameInfo.Observers[obsIndex].PlayerName) + lobbyComm:EjectPeer(gameInfo.Observers[obsIndex].OwnerID, "KickedByHost") + end, + "", nil, + nil, nil, + true, + {worldCover = false, enterButton = 1, escapeButton = 2} + ) + end + end + end + UIUtil.CreateLobbyVertScrollbar(GUI.observerList, 0, 0, -1) + + -- Setup large pretty faction selector and set the factional background to its initial value. + local lastFaction = GetSanitisedLastFaction() + CreateUI_Faction_Selector(lastFaction) + + RefreshLobbyBackground(lastFaction) + + GUI.uiCreated = true + + if singlePlayer then + -- observers are always allowed in skirmish games. + SetGameOption("AllowObservers", true) + end + + --------------------------------------------------------------------------- + -- other logic, including lobby callbacks + --------------------------------------------------------------------------- + GUI.posGroup = false + -- get ping times + GUI.pingThread = ForkThread( + function() + while lobbyComm do + for slot, player in gameInfo.PlayerOptions:pairs() do + if player.Human and player.OwnerID ~= localPlayerID then + local peer = lobbyComm:GetPeer(player.OwnerID) + local ping = peer.ping + local connectionStatus = CalcConnectionStatus(peer) + GUI.slots[slot].pingStatus.ConnectionStatus = connectionStatus + if ping then + ping = math.floor(ping) + GUI.slots[slot].pingStatus.PingActualValue = ping + GUI.slots[slot].pingStatus:SetValue(ping) + if ping > 500 then + GUI.slots[slot].pingStatus:Show() + else + GUI.slots[slot].pingStatus:Hide() + end + -- Set the ping bar to a colour representing the status of our connection. + GUI.slots[slot].pingStatus._bar:SetTexture(UIUtil.SkinnableFile('/game/unit_bmp/bar-0' .. connectionStatus .. '_bmp.dds')) + else + GUI.slots[slot].pingStatus:Hide() + end + end + end + WaitSeconds(1) + end + end) + if false then + import("/lua/ui/events/snowflake.lua"). CreateSnowFlakes(GUI) + end +end + +function setupChatEdit(chatPanel) + GUI.chatEdit = Edit(chatPanel) + LayoutHelpers.AtLeftTopIn(GUI.chatEdit, GUI.chatBG, 4, 3) + GUI.chatEdit.Width:Set(GUI.chatBG.Width() - LayoutHelpers.ScaleNumber(4)) + LayoutHelpers.SetHeight(GUI.chatEdit, 22) + GUI.chatEdit:SetFont(UIUtil.bodyFont, 16) + GUI.chatEdit:SetForegroundColor(UIUtil.fontColor) + GUI.chatEdit:ShowBackground(false) + GUI.chatEdit:SetDropShadow(true) + GUI.chatEdit:AcquireFocus() + + GUI.chatDisplayScroll = UIUtil.CreateLobbyVertScrollbar(chatPanel, -15, 25, 0) + + GUI.chatEdit:SetMaxChars(200) + GUI.chatEdit.OnCharPressed = function(self, charcode) + if charcode == UIUtil.VK_TAB then + return true + end + + local charLim = self:GetMaxChars() + if STR_Utf8Len(self:GetText()) >= charLim then + local sound = Sound({Cue = 'UI_Menu_Error_01', Bank = 'Interface',}) + PlaySound(sound) + end + end + + -- We work extremely hard to keep keyboard focus on the chat box, otherwise users can trigger + -- in-game keybindings in the lobby. + -- That would be very bad. We should probably instead just not assign those keybindings yet... + GUI.chatEdit.OnLoseKeyboardFocus = function(self) + self:AcquireFocus() + end + + local commandQueueIndex = 0 + local commandQueue = {} + GUI.chatEdit.OnEnterPressed = function(self, text) + if text:gsub("%s+", "") == '' then -- If the text, trimmed of all space, is equal to '' + return + end + GpgNetSend('Chat', text) + table.insert(commandQueue, 1, text) + commandQueueIndex = 0 + if string.sub(text, 1, 1) == '/' then + local spaceStart = string.find(text, " ") or string.len(text) + 1 + local comKey = string.sub(text, 2, spaceStart - 1) + local params = string.sub(text, spaceStart + 1) + local commandFunc = commands[string.lower(comKey)] + if not commandFunc then + AddChatText(LOCF("Command Not Known: %s", comKey)) + return + end + commandFunc(params) + else + PublicChat(text) + end + end + + GUI.chatEdit.OnEscPressed = function(self, text) + + local changelogDialogManager = import("/lua/ui/lobby/changelog/changelogdialog.lua") + local changelogDialogIsOpen = changelogDialogManager.IsOpen() + + -- The default behaviour buggers up our escape handlers. Just delegate the escape push to + -- the escape handling mechanism. + if HasCommandLineArg("/gpgnet") or changelogDialogIsOpen then + -- Quit to desktop + EscapeHandler.HandleEsc(not changelogDialogIsOpen) + else + -- Back to main menu + GUI.exitButton.OnClick() + end + + -- Don't clear the textbox, either. + return true + end + + --- Handle up/down arrow presses for the chat box. + GUI.chatEdit.OnNonTextKeyPressed = function(self, keyCode) + if AddUnicodeCharToEditText(self, keyCode) then + return + end + if commandQueue and not table.empty(commandQueue) then + if keyCode == 38 then + if commandQueue[commandQueueIndex + 1] then + commandQueueIndex = commandQueueIndex + 1 + self:SetText(commandQueue[commandQueueIndex]) + end + end + if keyCode == 40 then + if commandQueueIndex ~= 1 then + if commandQueue[commandQueueIndex - 1] then + commandQueueIndex = commandQueueIndex - 1 + self:SetText(commandQueue[commandQueueIndex]) + end + else + commandQueueIndex = 0 + self:ClearText() + end + end + end + end + chatPanel.edit = GUI.chatEdit +end + +function RefreshOptionDisplayData(scenarioInfo) + local globalOpts = import("/lua/ui/lobby/lobbyoptions.lua").globalOpts + local teamOptions = import("/lua/ui/lobby/lobbyoptions.lua").teamOptions + local AIOpts = import("/lua/ui/lobby/lobbyoptions.lua").AIOpts + if not scenarioInfo and gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= "") then + scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + end + formattedOptions = {} + nonDefaultFormattedOptions = {} + + -- Show a summary of the number of active mods. + local modStr = false + local modNum = table.getn(Mods.GetGameMods(gameInfo.GameMods)) or 0 + local modNumUI = table.getn(Mods.GetUiMods()) or 0 + if modNum > 0 and modNumUI > 0 then + modStr = modNum..' Mods (and '..modNumUI..' UI Mods)' + if modNum == 1 and modNumUI > 1 then + modStr = modNum..' Mod (and '..modNumUI..' UI Mods)' + elseif modNum > 1 and modNumUI == 1 then + modStr = modNum..' Mods (and '..modNumUI..' UI Mod)' + elseif modNum == 1 and modNumUI == 1 then + modStr = modNum..' Mod (and '..modNumUI..' UI Mod)' + else + modStr = modNum..' Mods (and '..modNumUI..' UI Mods)' + end + elseif modNum > 0 and modNumUI == 0 then + modStr = modNum..' Mods' + if modNum == 1 then + modStr = modNum..' Mod' + end + elseif modNum == 0 and modNumUI > 0 then + modStr = modNumUI..' UI Mods' + if modNum == 1 then + modStr = modNumUI..' UI Mod' + end + end + + local description = "No mods enabled." + + if modNum + modNumUI > 0 then + description = "" + + local descriptionSimMods = { "", } + if modNum > 0 then + table.insert(descriptionSimMods, LOC("The host enabled the following sim mods:")) + for k, mod in Mods.GetGameMods() do + table.insert(descriptionSimMods, "\r\n - " .. tostring(mod.name)) + end + + table.insert(descriptionSimMods, "\r\n") + end + + local descriptionUIMods = { "", } + if modNumUI > 0 then + table.insert(descriptionUIMods, LOC("You have enabled the following UI mods:")) + for k, mod in Mods.GetUiMods() do + table.insert(descriptionUIMods, "\r\n - " .. tostring(mod.name)) + end + end + + descriptionSimMods = tostring(table.concat(descriptionSimMods)) + descriptionUIMods = tostring(table.concat(descriptionUIMods)) + + if modNum > 0 and modNumUI > 0 then + description = tostring(table.concat({descriptionSimMods, "\r\n", descriptionUIMods})) + else + if modNum > 0 then + description = descriptionSimMods + end + + if modNumUI > 0 then + description = descriptionUIMods + end + end + end + + if modStr then + local option = { + text = modStr, + value = LOC('Check Mod Manager'), + mod = true, + + manualTooltipTitle = 'Enabled mods', + manualTooltipDescription = description + } + + table.insert(formattedOptions, option) + table.insert(nonDefaultFormattedOptions, option) + end + + -- Update the unit restrictions display. + if gameInfo.GameOptions.RestrictedCategories ~= nil then + local restrNum = table.getn(gameInfo.GameOptions.RestrictedCategories) + if restrNum ~= 0 then + local restrictLabel + if restrNum == 1 then -- just 1 + restrictLabel = LOC("1 Build Restriction") + else + restrictLabel = LOCF("%d Build Restrictions", restrNum) + end + + local option = { + text = restrictLabel, + value = LOC("Check Unit Manager"), + mod = true, + tooltip = 'Lobby_BuildRestrict_Option', + valueTooltip = 'Lobby_BuildRestrict_Option' + } + + table.insert(formattedOptions, option) + table.insert(nonDefaultFormattedOptions, option) + end + end + + -- Add an option to the formattedOption lists + local function addFormattedOption(optData, gameOption) + -- Don't show multiplayer-only options in single-player + if optData.mponly and singlePlayer then + return + end + + -- Don't bother for options with only one value. These are usually someone trying to do + -- something clever with a mod or such, not "real" options we care about. + if table.getn(optData.values) <= 1 then + return + end + + local option = { + text = optData.label, + tooltip = { text = optData.label, body = optData.help } + } + + -- Options are stored as keys from the values array in optData. We want to display the + -- descriptive string in the UI, so let's go dig it out. + + -- Scan the values array to find the one with the key matching our value for that option. + for k, val in optData.values do + local key = val.key or val + + if key == gameOption then + option.key = key + option.value = val.text or optData.value_text + option.valueTooltip = {text = optData.label, body = val.help or optData.value_help} + + table.insert(formattedOptions, option) + + -- Add this option to the non-default set for the UI. + if k ~= optData.default then + table.insert(nonDefaultFormattedOptions, option) + end + + break + end + end + end + + local function addOptionsFrom(optionObject) + for index, optData in optionObject do + local gameOption = gameInfo.GameOptions[optData.key] + addFormattedOption(optData, gameOption) + end + end + + -- Add the core options to the formatted option lists + addOptionsFrom(globalOpts) + addOptionsFrom(teamOptions) + addOptionsFrom(AIOpts) + + -- Add options from the scenario object, if any are provided. + if scenarioInfo.options then + if not MapUtil.ValidateScenarioOptions(scenarioInfo.options, true) then + AddChatText(LOC('The options included in this map specified invalid defaults. See moholog for details.')) + AddChatText(LOC('An arbitrary option has been selected for now: check the game options screen!')) + end + + for index, optData in scenarioInfo.options do + addFormattedOption(optData, gameInfo.GameOptions[optData.key]) + end + end + + GUI.OptionContainer:CalcVisible() +end + +function wasConnected(peer) + for _,v in pairs(connectedTo) do + if v == peer then + return true + end + end + return false +end + +--- Return a status code representing the status of our connection to a peer. +-- @param peer, native table as returned by lobbyComm:GetPeer() +-- @return A value describing the connectivity to given peer. +-- 1 means no connectivity, 2 means they haven't reported that they can talk to us, 3 means +-- +-- @todo: This function has side effects despite the naming suggesting that it shouldn't. +-- These need to go away. +function CalcConnectionStatus(peer) + if peer.status ~= 'Established' then + return 3 + else + if not wasConnected(peer.id) then + local peerSlot = FindSlotForID(peer.id) + local slot = GUI.slots[peerSlot] + local playerInfo = gameInfo.PlayerOptions[peerSlot] + + slot.name:SetTitleText(GetPlayerDisplayName(playerInfo)) + slot.name._text:SetFont('Arial Gras', 15) + if not table.find(ConnectionEstablished, peer.name) then + if playerInfo.Human and not IsLocallyOwned(peerSlot) then + table.insert(ConnectionEstablished, peer.name) + for k, v in CurrentConnection do -- Remove PlayerName in this Table + if v == peer.name then + CurrentConnection[k] = nil + break + end + end + end + end + + table.insert(connectedTo, peer.id) + end + if not table.find(peer.establishedPeers, lobbyComm:GetLocalPlayerID()) then + -- they haven't reported that they can talk to us? + return 1 + end + + local peers = lobbyComm:GetPeers() + for k,v in peers do + if v.id ~= peer.id and v.status == 'Established' then + if not table.find(peer.establishedPeers, v.id) then + -- they can't talk to someone we can talk to. + return 1 + end + end + end + return 2 + end +end + +function EveryoneHasEstablishedConnections(check_observers) + local important = {} + for slot, player in gameInfo.PlayerOptions:pairs() do + if not table.find(important, player.OwnerID) then + table.insert(important, player.OwnerID) + end + end + + if check_observers then + for slot,observer in gameInfo.Observers:pairs() do + if not table.find(important, observer.OwnerID) then + table.insert(important, observer.OwnerID) + end + end + end + + local result = true + for k, id in important do + if id ~= localPlayerID then + local peer = lobbyComm:GetPeer(id) + for k2, other in important do + if id ~= other and not table.find(peer.establishedPeers, other) then + result = false + AddChatText(LOCF("%s doesn't have an established connection to %s", + peer.name, + lobbyComm:GetPeer(other).name + )) + end + end + end + end + return result +end + +function AddChatText(text, playerID, scrollToBottom) + if not GUI.chatDisplay then + LOG("Can't add chat text -- no chat display") + LOG("text=" .. repr(text)) + return + end + + local chatPlayerColor = Prefs.GetFromCurrentProfile('ChatPlayerColor') + if chatPlayerColor == nil then + chatPlayerColor = true + end + + local scrolledToBottom = GUI.chatPanel:IsScrolledToBottom() or scrollToBottom + local nameColor = "AAAAAA" -- Displaying text in grey by default if the player is observer + local textColor = "AAAAAA" + local nameFont = "Arial Gras" + for id, player in gameInfo.PlayerOptions:pairs() do + if player.OwnerID == playerID and player.Human then + textColor = nil + nameColor = gameColors.PlayerColors[player.PlayerColor] + if not chatPlayerColor then + nameFont = UIUtil.bodyFont + if Prefs.GetOption('faction_font_color') then + nameColor = import("/lua/skins/skins.lua").skins[ FACTION_NAMES[GetLocalPlayerData():AsTable().Faction] ].fontColor + textColor = nameColor + else + nameColor = nil + end + end + break + end + end + local name = FindNameForID(playerID) + + GUI.chatDisplay:PostMessage(text, name, {fontColor = textColor}, {fontColor = nameColor, fontFamily = nameFont}) + if scrolledToBottom then + GUI.chatPanel:ScrollToBottom() + else + GUI.newMessageArrow:Enable() + end +end + +--- Update a slot display in a single map control. +function RefreshMapPosition(mapCtrl, slotIndex) + + local playerInfo = gameInfo.PlayerOptions[slotIndex] + local notFixed = gameInfo.GameOptions['TeamSpawn'] ~= 'fixed' + + -- Evil autoteams voodoo. + if gameInfo.GameOptions.AutoTeams and not gameInfo.AutoTeams[slotIndex] and lobbyComm:IsHost() then + gameInfo.AutoTeams[slotIndex] = 1 + end + + -- The ACUButton instance representing this slot, if any. + local marker = mapCtrl.startPositions[slotIndex] + if marker then + marker:SetClosed(gameInfo.ClosedSlots[slotIndex]) + if gameInfo.ClosedSlots[slotIndex] and gameInfo.SpawnMex[slotIndex] then + marker:SetClosedSpawnMex() + end + end + + mapCtrl:UpdatePlayer(slotIndex, playerInfo, notFixed) + + -- Nothing more for us to do for a closed or missing slot. + if gameInfo.ClosedSlots[slotIndex] or not marker then + return + end + + if gameInfo.GameOptions.AutoTeams then + if gameInfo.GameOptions.AutoTeams == 'lvsr' then + local midLine = mapCtrl.Left() + (mapCtrl.Width() / 2) + if notFixed then + local markerPos = marker.Left() + if markerPos < midLine then + marker:SetTeam(2) + else + marker:SetTeam(3) + end + end + elseif gameInfo.GameOptions.AutoTeams == 'tvsb' then + local midLine = mapCtrl.Top() + (mapCtrl.Height() / 2) + if notFixed then + local markerPos = marker.Top() + if markerPos < midLine then + marker:SetTeam(2) + else + marker:SetTeam(3) + end + end + elseif gameInfo.GameOptions.AutoTeams == 'pvsi' then + if notFixed then + if math.mod(slotIndex, 2) ~= 0 then + marker:SetTeam(2) + else + marker:SetTeam(3) + end + end + elseif gameInfo.GameOptions.AutoTeams == 'manual' and notFixed then + marker:SetTeam(gameInfo.AutoTeams[slotIndex] or 1) + end + end +end + +--- Update a single slot in all displayed map controls. +function RefreshMapPositionForAllControls(slot) + RefreshMapPosition(GUI.mapView, slot) + if LrgMap and not LrgMap.isHidden then + RefreshMapPosition(LrgMap.content.mapPreview, slot) + end +end + +function ShowMapPositions(mapCtrl, scenario) + local playerArmyArray = MapUtil.GetArmies(scenario) + + for inSlot, army in playerArmyArray do + RefreshMapPosition(mapCtrl, inSlot) + end +end + +function ConfigureMapListeners(mapCtrl, scenario) + local playerArmyArray = MapUtil.GetArmies(scenario) + + for inSlot, army in playerArmyArray do + local slot = inSlot -- Closure copy. + + -- The ACUButton instance representing this slot. + local marker = mapCtrl.startPositions[inSlot] + + marker.OnRollover = function(self, state) + local slotName = GUI.slots[slot].name + if state == 'enter' then + slotName:HandleEvent({Type = 'MouseEnter'}) + elseif state == 'exit' then + slotName:HandleEvent({Type = 'MouseExit'}) + end + end + + marker.OnClick = function(self) + if gameInfo.GameOptions['TeamSpawn'] == 'fixed' then + if FindSlotForID(localPlayerID) ~= slot and gameInfo.PlayerOptions[slot] == nil then + if IsPlayer(localPlayerID) then + if lobbyComm:IsHost() then + HostUtils.MovePlayerToEmptySlot(FindSlotForID(localPlayerID), slot) + else + lobbyComm:SendData(hostID, {Type = 'MovePlayer', RequestedSlot = slot}) + end + -- if first click is a not empty slot and second click is a empty slot: reset vars + if mapPreviewSlotSwap == true then + mapPreviewSlotSwap = false + mapPreviewSlotSwapFrom = 0 + end + elseif IsObserver(localPlayerID) then + if lobbyComm:IsHost() then + local requestedFaction = GetSanitisedLastFaction() + HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(localPlayerID), slot) + else + lobbyComm:SendData( + hostID, + { + Type = 'RequestConvertToPlayer', + ObserverSlot = FindObserverSlotForID(localPlayerID), + PlayerSlot = slot + } + ) + end + end + else -- swap players on map preview + if lobbyComm:IsHost() and mapPreviewSlotSwap == false then + mapPreviewSlotSwapFrom = slot + mapPreviewSlotSwap = true + elseif lobbyComm:IsHost() and mapPreviewSlotSwap == true and mapPreviewSlotSwapFrom ~= slot then + mapPreviewSlotSwap = false + DoSlotBehavior(mapPreviewSlotSwapFrom, 'move_player_to_slot' .. slot, '') + mapPreviewSlotSwapFrom = 0 + end + end + else + if gameInfo.GameOptions.AutoTeams and lobbyComm:IsHost() then + -- Handle the manual-mode reassignment of slots to teams. + if gameInfo.GameOptions.AutoTeams == 'manual' then + if not gameInfo.ClosedSlots[slot] and (gameInfo.PlayerOptions[slot] or gameInfo.GameOptions['TeamSpawn'] ~= 'fixed') then + local targetTeam + if gameInfo.AutoTeams[slot] == 7 then + -- 2 here corresponds to team 1, since a team value of 1 represents + -- "no team". Apparently GPG really, really didn't like zero. + targetTeam = 2 + else + targetTeam = gameInfo.AutoTeams[slot] + 1 + end + + marker:SetTeam(targetTeam) + gameInfo.AutoTeams[slot] = targetTeam + + lobbyComm:BroadcastData( + { + Type = 'AutoTeams', + Slot = slot, + Team = gameInfo.AutoTeams[slot], + } + ) + gameInfo.PlayerOptions[slot]['Team'] = gameInfo.AutoTeams[slot] + SetSlotInfo(slot, gameInfo.PlayerOptions[slot]) + UpdateGame() + end + end + end + end + end + + if lobbyComm:IsHost() then + marker.OnRightClick = function(self) + if gameInfo.SpawnMex[slot] then + HostUtils.SetSlotClosed(slot, false) + elseif gameInfo.ClosedSlots[slot] then + if gameInfo.AdaptiveMap then + HostUtils.SetSlotClosedSpawnMex(slot) + else + HostUtils.SetSlotClosed(slot, false) + end + else + HostUtils.SetSlotClosed(slot, true) + end + end + end + end +end + +function SendCompleteGameStateToPeer(peerId) + lobbyComm:SendData(peerId, {Type = 'GameInfo', GameInfo = GameInfo.Flatten(gameInfo)}) +end + +function UpdateClientModStatus(newHostSimMods) + -- Apply the new game mods from the host, but don't touch our UI mod configuration. + selectedSimMods = newHostSimMods + + -- Make sure none of our selected UI mods are blacklisted + local bannedMods = CheckModCompatability() + if not table.empty(bannedMods) then + WarnIncompatibleMods() + + selectedUIMods = SetUtils.Subtract(selectedUIMods, bannedMods) + end + + Mods.SetSelectedMods(SetUtils.Union(selectedSimMods, selectedUIMods)) +end + +local IsFromHost = function(data) return data.SenderID == hostID end +local AmHost = function(data) return lobbyComm:IsHost() end + +local FromSubjectOrHost = function(data) + if IsFromHost(data) then + return true + end + + -- Do ridiculous things to infer the identity of the subject of the message. + -- TODO: A UNIFORM PROTOCOL MAYBE? + local subjectID = data.SenderID + if data.PlayerName then + return data.SenderID == FindIDForName(data.PlayerName) + end + + if data.Slot and gameInfo.PlayerOptions[data.Slot] then + return data.SenderID == FindIDForName(gameInfo.PlayerOptions[data.Slot].PlayerName) + end + + return false +end + +-- +local MessageHandlers = { + -- Update player options. Either the host reconfiguring, or users tweaking their own settings. + PlayerOptions = { + Accept = function(data) + for key, val in data.Options do + -- The host *is* allowed to set options on slots he doesn't own, of course. + if data.SenderID ~= hostID then + if key == 'Team' and gameInfo.GameOptions['AutoTeams'] ~= 'none' then + WARN("Attempt to set Team while Auto Teams are on.") + return false + elseif gameInfo.PlayerOptions[data.Slot].OwnerID ~= data.SenderID then + WARN("Attempt to set option on unowned slot.") + return false + end + end + end + + -- TODO: Players may not change *all* of their own options... + + return true + end, + Handle = function(data) + local options = data.Options + + for key, val in options do + gameInfo.PlayerOptions[data.Slot][key] = val + if lobbyComm:IsHost() then + local playerInfo = gameInfo.PlayerOptions[data.Slot] + if playerInfo.Human then + GpgNetSend('PlayerOption', playerInfo.OwnerID, key, val) + else + GpgNetSend('AIOption', playerInfo.PlayerName, key, val) + end + + -- TODO: This should be a global listener on PlayerData objects, but I'm in too + -- much pain to implement that listener system right now. EVIL HACK TIME + if key == "Ready" then + HostUtils.RefreshButtonEnabledness() + end + -- DONE. + end + end + + SetSlotInfo(data.Slot, gameInfo.PlayerOptions[data.Slot]) + end + }, + + -- SenderName here is inserted by lobbyComm, so validating it is just a gratuitious effort to + -- detect obnoxiousness. + PublicChat = { + Accept = function(data) + return data.SenderName == FindNameForID(data.SenderID) + end, + Handle = function(data) + AddChatText(data.Text, data.SenderID) + end + }, + + PrivateChat = { + Accept = function(data) + return data.SenderName == FindNameForID(data.SenderID) + end, + Handle = function(data) + AddChatText("<<"..LOCF("From %s", data.SenderName)..">> "..data.Text) + end + }, + + CPUBenchmark = { + Accept = FromSubjectOrHost, + Handle = function(data) + local newInfo = false + if data.PlayerName and CPU_Benchmarks[data.PlayerName] ~= data.Result then + newInfo = true + end + + local benchmarks = {} + if data.PlayerName then + benchmarks[data.PlayerName] = data.Result + else + benchmarks = data.Benchmarks + end + + for name, result in benchmarks do + CPU_Benchmarks[name] = result + local id = FindIDForName(name) + local slot = FindSlotForID(id) + if slot then + SetSlotCPUBar(slot, gameInfo.PlayerOptions[slot]) + else + refreshObserverList() + end + end + + -- Host broadcasts new CPU benchmark information to give the info to clients that are not directly connected to data.PlayerName yet. + if lobbyComm:IsHost() and newInfo then + lobbyComm:BroadcastData({Type='CPUBenchmark', Benchmarks=CPU_Benchmarks}) + end + end + }, + + SetPlayerNotReady = { + Accept = FromSubjectOrHost, + Handle = function(data) + + -- allow the user to make changes + EnableSlot(data.Slot) + + -- allow the user to become an observer again + GUI.becomeObserver:Enable() + + -- update player options + SetPlayerOption(data.Slot, 'Ready', false) + + -- update GUI + GUI.slots[data.Slot].ready:SetCheck(false) + end + }, + + AutoTeams = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.AutoTeams[data.Slot] = data.Team + gameInfo.PlayerOptions[data.Slot]['Team'] = data.Team + SetSlotInfo(data.Slot, gameInfo.PlayerOptions[data.Slot]) + UpdateGame() + end + }, + + AddPlayer = { + + ---@class LobbyAddPlayerData + ---@field PlayerOptions PlayerData + ---@field SenderId number + ---@field SenderName string + ---@field Type string + + ---@param data LobbyAddPlayerData + Accept = function(data) + -- we need to do quite a bit of checks to prevent malicious values + if type(data.PlayerOptions.MEAN) != 'number' then + return false + end + + if type (data.PlayerOptions.NG) != 'number' then + return false + end + + if type(data.PlayerOptions.Faction) != 'number' then + return false + end + + if type(data.PlayerOptions.PlayerName) != 'string' then + return false + end + + local charactersInPlayerName = string.len(data.PlayerOptions.PlayerName) + if charactersInPlayerName < 2 or charactersInPlayerName > 32 then + return false + end + + if data.PlayerOptions.PlayerClan then + if type(data.PlayerOptions.PlayerClan) != 'string' then + return false + end + + -- note: there are strange clan names that use more than 1 byte per character + if string.len(data.PlayerOptions.PlayerClan) > 6 then + return false + end + end + + if not data.PlayerOptions.OwnerID then + return false + end + + if not (data.PlayerOptions.OwnerID == data.SenderID) then + return false + end + + if FindNameForID(data.SenderID) then + return false + end + + -- check game version and reject if there is a missmatch + local hostVersion, hostGametype, hostCommit = import("/lua/version.lua").GetVersionData() + local playerVersion, playerGameType, playerCommit = tostring(data.PlayerOptions.Version), tostring(data.PlayerOptions.GameType), tostring(data.PlayerOptions.Commit) + if hostVersion ~= playerVersion or hostGametype ~= playerGameType or hostCommit ~= playerCommit then + local playerName = data.PlayerOptions.PlayerName + AddChatText(LOCF("Game version missmatch detected with %s. \r\n - host: %s (@%s)\r\n - %s: %s (@%s). \r\n\r\nTo prevent desyncs, %s is ejected automatically. It is possible that a new game version is released. If this keeps happening then it is better to rehost.", playerName, hostVersion, hostCommit:sub(1, 8), playerName, playerVersion, playerCommit:sub(1, 8), playerName)) + return false + end + + return lobbyComm:IsHost() + end, + Reject = function(data) + lobbyComm:EjectPeer(data.SenderID, "Game version missmatch or invalid player data.") + end, + Handle = function(data) + -- try to reassign the same slot as in the last game if it's a rehosted game, otherwise give it an empty + -- slot or move it to observer + SendCompleteGameStateToPeer(data.SenderID) + + if argv.isRehost then + local rehostSlot = FindRehostSlotForID(data.SenderID) or 0 + if rehostSlot ~= 0 and gameInfo.PlayerOptions[rehostSlot] then + -- If the slot is occupied, the occupying player will be moved away or to observer. If it's an + -- AI, it will be removed + local occupyingPlayer = gameInfo.PlayerOptions[rehostSlot] + if not occupyingPlayer.Human then + HostUtils.RemoveAI(rehostSlot) + HostUtils.TryAddPlayer(data.SenderID, rehostSlot, PlayerData(data.PlayerOptions)) + else + HostUtils.ConvertPlayerToObserver(rehostSlot, true) + HostUtils.TryAddPlayer(data.SenderID, rehostSlot, PlayerData(data.PlayerOptions)) + HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(occupyingPlayer.OwnerID)) + end + else + HostUtils.TryAddPlayer(data.SenderID, rehostSlot, PlayerData(data.PlayerOptions)) + end + else + HostUtils.TryAddPlayer(data.SenderID, 0, PlayerData(data.PlayerOptions)) + end + if HasCommandLineArg('/gpgnet') then + PlayVoice(Sound{Bank = 'XGG',Cue = 'XGG_Computer__04716'}, true) + end + end + }, + + -- Player requests move. + MovePlayer = { + Accept = AmHost, -- Forgery would require tricking lobbyComm... + Handle = function(data) + local CurrentSlot = FindSlotForID(data.SenderID) + + -- Handle ready-races. + if gameInfo.PlayerOptions[CurrentSlot].Ready then + return + end + + -- Player requests to be moved to a different empty slot. + HostUtils.MovePlayerToEmptySlot(CurrentSlot, data.RequestedSlot) + end + }, + + RequestConvertToObserver = { + Accept = AmHost, + Handle = function(data) + HostUtils.ConvertPlayerToObserver(FindSlotForID(data.SenderID)) + end + + }, + + RequestConvertToPlayer = { + Accept = AmHost, + Handle = function(data) + HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(data.SenderID), data.PlayerSlot) + end + }, + + RequestColor = { + Accept = AmHost, + Handle = function(data) + local TargetSlot = FindSlotForID(data.SenderID) + if IsColorFree(data.Color) then + -- Color is available, let everyone else know + SetPlayerColor(gameInfo.PlayerOptions[TargetSlot], data.Color) + lobbyComm:BroadcastData({ Type = 'SetColor', Color = data.Color, Slot = TargetSlot }) + SetSlotInfo(TargetSlot, gameInfo.PlayerOptions[TargetSlot]) + else + -- Sorry, it's not free. Force the player back to the color we have for him. + lobbyComm:SendData(data.SenderID, { + Type = 'SetColor', + Color = gameInfo.PlayerOptions[TargetSlot].PlayerColor, Slot = TargetSlot + }) + end + end + }, + + -- Sent to the host to advise them of which mods the players have. + SetAvailableMods = { + Accept = AmHost, + Handle = function(data) + availableMods[data.SenderID] = data.Mods + HostUtils.UpdateMods(data.SenderID, data.Name) + end + }, + + -- Sent by a non-host peer when a map is selected that they don't have installed. + MissingMap = { + Accept = AmHost, + Handle = function(data) + HostUtils.PlayerMissingMapAlert(data.SenderID) + end + }, + + -- This is insane. + SystemMessage = { + Handle = function(data) + PrintSystemMessage(data.Id, data.Args) + end + }, + + -- This is very insane and somewhat insecure... + Peer_Really_Disconnected = { + Handle = function(data) + if data.Observ == false then + gameInfo.PlayerOptions[data.Slot] = nil + elseif data.Observ == true then + gameInfo.Observers[data.Slot] = nil + end + AddChatText(LOCF("Lost connection to %s.", data.Options.PlayerName), "Engine0003") + ClearSlotInfo(data.Slot) + UpdateGame() + end + }, + + SetAllPlayerNotReady = { + Accept = IsFromHost, + Handle = function(data) + if not IsPlayer(localPlayerID) then + return + end + local localSlot = FindSlotForID(localPlayerID) + EnableSlot(localSlot) + GUI.becomeObserver:Enable() + SetPlayerOption(localSlot, 'Ready', false) + end + }, + + -- Host telling us about things changing in the game configuration... + + GameOptions = { + Accept = IsFromHost, + Handle = function(data) + for key, value in data.Options do + gameInfo.GameOptions[key] = value + end + + UpdateGame() + end + }, + ClearSlot = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.PlayerOptions[data.Slot] = nil + ClearSlotInfo(data.Slot) + end + }, + ModsChanged = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.GameMods = data.GameMods + + UpdateClientModStatus(data.GameMods) + UpdateGame() + import("/lua/ui/lobby/modsmanager.lua").UpdateClientModStatus(gameInfo.GameMods) + end + }, + SlotClosed = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.ClosedSlots[data.Slot] = data.Closed + gameInfo.SpawnMex[data.Slot] = false + ClearSlotInfo(data.Slot) + PossiblyAnnounceGameFull() + end + }, + SlotClosedSpawnMex = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.ClosedSlots[data.Slot] = data.ClosedSpawnMex + gameInfo.SpawnMex[data.Slot] = data.ClosedSpawnMex + ClearSlotInfo(data.Slot) + PossiblyAnnounceGameFull() + end + }, + GameInfo = { + Accept = IsFromHost, + Handle = function(data) + -- Completely update the game state. To be used exactly once: when first connecting. + local hostFlatInfo = data.GameInfo + gameInfo = GameInfo.CreateGameInfo(LobbyComm.maxPlayerSlots, hostFlatInfo) + + UpdateClientModStatus(gameInfo.GameMods, true) + UpdateGame() + end + }, + SetColor = { + Accept = IsFromHost, + Handle = function(data) + SetPlayerColor(gameInfo.PlayerOptions[data.Slot], data.Color) + SetSlotInfo(data.Slot, gameInfo.PlayerOptions[data.Slot]) + end + }, + ConvertObserverToPlayer = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.Observers[data.OldSlot] = nil + gameInfo.PlayerOptions[data.NewSlot] = PlayerData(data.Options) + SetSlotInfo(data.NewSlot, gameInfo.PlayerOptions[data.NewSlot]) + UpdateFactionSelectorForPlayer(gameInfo.PlayerOptions[data.NewSlot]) + end + }, + + ConvertPlayerToObserver = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.Observers[data.NewSlot] = PlayerData(data.Options) + gameInfo.PlayerOptions[data.OldSlot] = nil + ClearSlotInfo(data.OldSlot) + UpdateFactionSelectorForPlayer(gameInfo.Observers[data.NewSlot]) + end + }, + + SlotAssigned = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.PlayerOptions[data.Slot] = PlayerData(data.Options) + if HasCommandLineArg('/gpgnet') then + PlayVoice(Sound{Bank = 'XGG',Cue = 'XGG_Computer__04716'}, true) + end + SetSlotInfo(data.Slot, gameInfo.PlayerOptions[data.Slot]) + UpdateFactionSelectorForPlayer(gameInfo.PlayerOptions[data.Slot]) + PossiblyAnnounceGameFull() + end + }, + + SlotMove = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.PlayerOptions[data.OldSlot] = nil + gameInfo.PlayerOptions[data.NewSlot] = PlayerData(data.Options) + gameInfo.PlayerOptions[data.NewSlot].Ready = false + ClearSlotInfo(data.OldSlot) + SetSlotInfo(data.NewSlot, gameInfo.PlayerOptions[data.NewSlot]) + UpdateFactionSelectorForPlayer(gameInfo.PlayerOptions[data.NewSlot]) + end + }, + + SwapPlayers = { + Accept = IsFromHost, + Handle = function(data) + DoSlotSwap(data.Slot1, data.Slot2) + end + }, + + ObserverAdded = { + Accept = IsFromHost, + Handle = function(data) + gameInfo.Observers[data.Slot] = PlayerData(data.Options) + refreshObserverList() + end + }, + + -- Start the game! + Launch = { + Accept = IsFromHost, + Handle = function(data) + local info = data.GameInfo + info.GameMods = Mods.GetGameMods(info.GameMods) + SetWindowedLobby(false) + + -- Evil hack to correct the skin for randomfaction players before launch. + for index, player in info.PlayerOptions do + -- Set the skin to the faction you'll be playing as, whatever that may be. (prevents + -- random-faction people from ending up with something retarded) + if player.OwnerID == localPlayerID then + UIUtil.SetCurrentSkin(FACTION_NAMES[player.Faction]) + end + end + + Presets.SaveLastGamePreset() + lobbyComm:LaunchGame(info) + end + }, +} + + +-- LobbyComm Callbacks +function InitLobbyComm(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider) + lobbyComm = LobbyComm.CreateLobbyComm(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider) + + if not lobbyComm then + error('Failed to create lobby using port ' .. tostring(localPort)) + end + + lobbyComm.ConnectionFailed = function(self, reason) + LOG("CONNECTION FAILED " .. reason) + GUI.connectionFailedDialog = UIUtil.ShowInfoDialog(GUI.panel, LOCF(Strings.ConnectionFailed, Strings[reason] or reason), + "", ReturnToMenu) + + lobbyComm:Destroy() + lobbyComm = nil + end + + lobbyComm.LaunchFailed = function(self,reasonKey) + AddChatText(LOC(Strings[reasonKey] or reasonKey)) + end + + lobbyComm.Ejected = function(self,reason) + LOG("EJECTED " .. reason) + + GUI.connectionFailedDialog = UIUtil.ShowInfoDialog(GUI, LOCF(Strings.Ejected, Strings[reason] or reason), "", ReturnToMenu) + lobbyComm:Destroy() + lobbyComm = nil + end + + lobbyComm.ConnectionToHostEstablished = function(self,myID,myName,theHostID) + LOG("CONNECTED TO HOST") + hostID = theHostID + localPlayerID = myID + localPlayerName = myName + + lobbyComm:SendData(hostID, { Type = 'SetAvailableMods', Mods = Mods.GetLocallyAvailableMods(), Name = localPlayerName}) + + lobbyComm:SendData(hostID, + { + Type = 'AddPlayer', + PlayerOptions = GetLocalPlayerData():AsTable() + } + ) + + -- Update, if needed, and broadcast, your CPU benchmark value. + if not singlePlayer then + ForkThread(function() UpdateBenchmark() end) + end + + local function KeepAliveThreadFunc() + local threshold = LobbyComm.quietTimeout + local active = true + local prev = 0 + while lobbyComm do + local host = lobbyComm:GetPeer(hostID) + if active and host.quiet > threshold then + active = false + local function OnRetry() + host = lobbyComm:GetPeer(hostID) + threshold = host.quiet + LobbyComm.quietTimeout + active = true + end + UIUtil.QuickDialog(GUI, "Connection to host timed out.", + "Keep Trying", OnRetry, + "Give Up", ReturnToMenu, + nil, nil, + true, + {worldCover = false, escapeButton = 2}) + elseif host.quiet < prev then + threshold = LobbyComm.quietTimeout + end + prev = host.quiet + WaitSeconds(1) + end + end -- KeepAliveThreadFunc + + GUI.keepAliveThread = ForkThread(KeepAliveThreadFunc) + CreateUI(LobbyComm.maxPlayerSlots) + end + + --- Called by the engine when we receive data from other players. There is no checking to see if the data is legitimate, these need to be done in Lua. + --- + --- Data can be sent via `BroadcastData` and/or `SendData`. + ---@param self UILobbyCommunication + ---@param data UILobbyReceivedMessage + lobbyComm.DataReceived = function(self, data) + -- make it more convenient to debug malicious traffic + SPEW(string.format("Received data of type %s from %s (%s)", tostring(data.Type), tostring(data.SenderID), tostring(data.SenderName))) + + -- Decide if we should just drop the packet. Violations here are usually people using a + -- modified lobby.lua to try to do stupid shit. + if not MessageHandlers[data.Type] then + WARN("Unknown message type: " .. tostring(data.Type)) + return + end + + -- No defined validator is taken to be always-accept. + if not MessageHandlers[data.Type].Accept or MessageHandlers[data.Type].Accept(data) then + MessageHandlers[data.Type].Handle(data) + elseif MessageHandlers[data.Type].Reject then + MessageHandlers[data.Type].Reject(data) + else + WARN("Rejected message of type " .. tostring(data.Type) .. " from " .. tostring(FindNameForID(data.SenderID))) + end + end + + lobbyComm.SystemMessage = function(self, text) + AddChatText(text) + end + + lobbyComm.GameLaunched = function(self) + local player = lobbyComm:GetLocalPlayerID() + for i, v in gameInfo.PlayerOptions do + if v.Human and v.OwnerID == player then + Prefs.SetToCurrentProfile('LoadingFaction', v.Faction) + break + end + end + + GpgNetSend('GameState', 'Launching') + if GUI.pingThread then + KillThread(GUI.pingThread) + end + if GUI.keepAliveThread then + KillThread(GUI.keepAliveThread) + end + GUI:Destroy() + GUI = false + MenuCommon.MenuCleanup() + lobbyComm:Destroy() + lobbyComm = false + + -- determine if cheat keys should be mapped + if not DebugFacilitiesEnabled() then + IN_ClearKeyMap() + IN_AddKeyMapTable(import("/lua/keymap/keymapper.lua").GetKeyMappings(gameInfo.GameOptions['CheatsEnabled']=='true')) + end + end + + lobbyComm.Hosting = function(self) + InitHostUtils() + + localPlayerID = lobbyComm:GetLocalPlayerID() + hostID = localPlayerID + HostUtils.UpdateMods() + + --- Returns true if the given option has the given key as a valid setting. + local function keyIsValidForOption(option, key) + for k, v in option.values do + if v.key == key or v == key then + return true + end + end + return false + end + + -- Given an option key, find the value stored in the profile (if any) and assign either it, + -- or that option's default value, to the current game state. + local setOptionsFromPref = function(option) + local defValue = Prefs.GetFromCurrentProfile("LobbyOpt_" .. option.key) + + -- Do the slightly stupid thing to check if the option we found in the profile is + -- a valid key for this option. Some mods muck about with the possibilities, so we + -- need to make sure we use a sane default if that's happened. + if defValue == nil or not keyIsValidForOption(option, defValue) then + -- Exception to make AllowObservers work because the engine requires + -- the keys to be bool. Custom options should use 'True' or 'False' + if option.key == 'AllowObservers' then + defValue = option.values[option.default].key + else + defValue = option.values[option.default].key or option.values[option.default] + end + end + + SetGameOption(option.key, defValue, true) + end + + -- Give myself the first slot + local myPlayerData = GetLocalPlayerData() + + gameInfo.PlayerOptions[1] = myPlayerData + + -- set default lobby values + for index, option in globalOpts do + setOptionsFromPref(option) + end + + for index, option in teamOpts do + setOptionsFromPref(option) + end + + for index, option in AIOpts do + setOptionsFromPref(option) + end + + -- The key, LastScenario, is referred to from GPG code we don't hook. + if not self.desiredScenario or self.desiredScenario == "" then + self.desiredScenario = Prefs.GetFromCurrentProfile("LastScenario") + end + local scenarioInfo = MapUtil.LoadScenario(self.desiredScenario) + if not scenarioInfo or scenarioInfo.type != UIUtil.requiredType then + self.desiredScenario = UIUtil.defaultScenario + end + SetGameOption('ScenarioFile', self.desiredScenario, true) + + GUI.keepAliveThread = ForkThread( + -- Eject players who haven't sent a heartbeat in a while + function() + while true and lobbyComm do + local peers = lobbyComm:GetPeers() + for k,peer in peers do + if peer.quiet > LobbyComm.quietTimeout then + lobbyComm:EjectPeer(peer.id,'TimedOutToHost') + -- %s timed out. + SendSystemMessage("lobui_0205", peer.name) + + -- Search and Remove the peer disconnected + for k, v in CurrentConnection do + if v == peer.name then + CurrentConnection[k] = nil + break + end + end + for k, v in ConnectionEstablished do + if v == peer.name then + ConnectionEstablished[k] = nil + break + end + end + for k, v in ConnectedWithProxy do + if v == peer.id then + ConnectedWithProxy[k] = nil + break + end + end + end + end + WaitSeconds(1) + end + end + ) + + CreateUI(LobbyComm.maxPlayerSlots) + ForkThread(function() UpdateBenchmark(false) end) + + if argv.isRehost then + local settings = Presets.GetLastGameSettings() + if not settings then + ApplyGameSettings(settings) + end + + local rehostSlot = FindRehostSlotForID(localPlayerID) + if rehostSlot then + HostUtils.MovePlayerToEmptySlot(1, rehostSlot) + end + + for index, playerInfo in ipairs(rehostPlayerOptions) do + if not playerInfo.Human then + HostUtils.AddAI(playerInfo.PlayerName, playerInfo.AIPersonality, playerInfo.StartSpot) + end + end + end + + UpdateGame() + end + + lobbyComm.PeerDisconnected = function(self,peerName,peerID) + + -- Search and Remove the peer disconnected + for k, v in CurrentConnection do + if v == peerName then + CurrentConnection[k] = nil + break + end + end + for k, v in ConnectionEstablished do + if v == peerName then + ConnectionEstablished[k] = nil + break + end + end + for k, v in ConnectedWithProxy do + if v == peerID then + ConnectedWithProxy[k] = nil + break + end + end + + if IsPlayer(peerID) then + local slot = FindSlotForID(peerID) + if slot and lobbyComm:IsHost() then + if HasCommandLineArg('/gpgnet') then + PlayVoice(Sound{Bank = 'XGG',Cue = 'XGG_Computer__04717'}, true) + end + lobbyComm:BroadcastData( + { + Type = 'Peer_Really_Disconnected', + Options = gameInfo.PlayerOptions[slot]:AsTable(), + Slot = slot, + Observ = false, + } + ) + ClearSlotInfo(slot) + gameInfo.PlayerOptions[slot] = nil + UpdateGame() + end + elseif IsObserver(peerID) then + local slot2 = FindObserverSlotForID(peerID) + if slot2 and lobbyComm:IsHost() then + lobbyComm:BroadcastData( + { + Type = 'Peer_Really_Disconnected', + Options = gameInfo.Observers[slot2]:AsTable(), + Slot = slot2, + Observ = true, + } + ) + gameInfo.Observers[slot2] = nil + UpdateGame() + end + end + + availableMods[peerID] = nil + if HostUtils.UpdateMods then + HostUtils.UpdateMods() + end + end + + lobbyComm.GameConfigRequested = function(self) + return { + Options = gameInfo.GameOptions, + HostedBy = localPlayerName, + PlayerCount = GetPlayerCount(), + GameName = gameName, + ProductCode = import("/lua/productcode.lua").productCode, + } + end +end + +function SetPlayerOptions(slot, options, ignoreRefresh) + if not IsLocallyOwned(slot) and not lobbyComm:IsHost() then + WARN("Hey you can't set a player option on a slot you don't own:") + for key, val in options do + WARN("(slot:"..tostring(slot).." / key:"..tostring(key).." / val:"..tostring(val)..")") + end + return + end + + for key, val in options do + gameInfo.PlayerOptions[slot][key] = val + end + + lobbyComm:BroadcastData( + { + Type = 'PlayerOptions', + Options = options, + Slot = slot, + }) + + if not ignoreRefresh then + UpdateGame() + end +end + +function SetPlayerOption(slot, key, val, ignoreRefresh) + local options = {} + options[key] = val + SetPlayerOptions(slot, options, ignoreRefresh) + refreshObserverList() +end + +function SetGameOptions(options, ignoreRefresh) + if not lobbyComm:IsHost() then + WARN('Attempt to set game option by a non-host') + return + end + + for key, val in options do + Prefs.SetToCurrentProfile('LobbyOpt_' .. key, val) + gameInfo.GameOptions[key] = val + + -- don't want to send all restricted categories to gpgnet, so just send bool + -- note if more things need to be translated to gpgnet, a translation table would be a better implementation + -- but since there's only one, we'll call it out here + if key == 'RestrictedCategories' then + local restrictionsEnabled = false + if val ~= nil then + if not table.empty(val) then + restrictionsEnabled = true + end + end + GpgNetSend('GameOption', key, restrictionsEnabled) + elseif key == 'ScenarioFile' then + -- Special-snowflake the LastScenario key (used by GPG code). + Prefs.SetToCurrentProfile('LastScenario', val) + GpgNetSend('GameOption', key, val) + if gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= '') then + -- Warn about attempts to load nonexistent maps. + if not DiskGetFileInfo(gameInfo.GameOptions.ScenarioFile) then + AddChatText(LOC('The selected map does not exist.')) + else + local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + if scenarioInfo and scenarioInfo.map and (scenarioInfo.map ~= '') then + GpgNetSend('GameOption', 'Slots', table.getsize(scenarioInfo.Configurations.standard.teams[1].armies)) + end + end + end + else + GpgNetSend('GameOption', key, val) + end + end + + lobbyComm:BroadcastData { + Type = 'GameOptions', + Options = options + } + + if not ignoreRefresh then + UpdateGame() + end +end + +function SetGameOption(key, val, ignoreRefresh) + local options = {} + options[key] = val + SetGameOptions(options, ignoreRefresh) +end + +function DebugDump() + if lobbyComm then + lobbyComm:DebugDump() + end +end + +-- Perform one-time setup of the large map preview +function CreateBigPreview(parent) + local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + if scenarioInfo.hidePreviewMarkers then + return + end + + if LrgMap then + LrgMap.isHidden = false + RefreshLargeMap() + LrgMap:Show() + return + end + + -- Size of the map preview to generate. + local MAP_PREVIEW_SIZE = 721 + + -- The size of the mass/hydrocarbon icons + local HYDROCARBON_ICON_SIZE = 14 + local MASS_ICON_SIZE = 10 + + local dialogContent = Group(parent) + LayoutHelpers.SetDimensions(dialogContent, MAP_PREVIEW_SIZE + 10, MAP_PREVIEW_SIZE + 10) + + LrgMap = Popup(parent, dialogContent) + + -- The LrgMap shouldn't be destroyed due to issues related to texture pooling. Evil hack ensues. + local onTryMapClose = function() + LrgMap:Hide() + LrgMap.isHidden = true + end + LrgMap.OnEscapePressed = onTryMapClose + LrgMap.OnShadowClicked = onTryMapClose + + -- Create the map preview + local mapPreview = ResourceMapPreview(dialogContent, MAP_PREVIEW_SIZE, MASS_ICON_SIZE, HYDROCARBON_ICON_SIZE) + dialogContent.mapPreview = mapPreview + LayoutHelpers.AtCenterIn(mapPreview, dialogContent) + + local closeBtn = UIUtil.CreateButtonStd(dialogContent, '/dialogs/close_btn/close') + LayoutHelpers.AtRightTopIn(closeBtn, dialogContent, 1, 1) + closeBtn.OnClick = onTryMapClose + + -- Keep the close button on top of the border (which is itself on top of the map preview) + LayoutHelpers.DepthOverParent(closeBtn, mapPreview, 2) + + RefreshLargeMap() +end + +-- Refresh the large map preview (so it can update if something changes while it's open) +function RefreshLargeMap() + if not LrgMap or LrgMap.isHidden then + return + end + + local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + LrgMap.content.mapPreview:SetScenario(scenarioInfo, true) + ConfigureMapListeners(LrgMap.content.mapPreview, scenarioInfo) + ShowMapPositions(LrgMap.content.mapPreview, scenarioInfo) +end + +-------------------------------------------------- +-- Ping GUI Functions +-------------------------------------------------- + +local ConnectionStatusInfo = { + 'Player is not connected to someone', + 'Connected', + 'Not Connected', + 'No connection info available', +} + +function Ping_AddControlTooltip(control, delay, slotNumber) + --This function creates the Ping tooltip for a slot along with necessary mouseover function. + --It is called during the UI creation. + -- control: The control to which the tooltip is to be added. + -- delay: Amount of time to delay before showing tooltip. See Tooltip.CreateMouseoverDisplay for info. + -- slotNumber: The slot number associated with the control. + local pingText = function() + local pingInfo + if GUI.slots[slotNumber].pingStatus.PingActualValue then + pingInfo = GUI.slots[slotNumber].pingStatus.PingActualValue + else + pingInfo = LOC('UnKnown') + end + return LOC('Ping: ') .. pingInfo + end + local pingBody = function() + local conInfo + if GUI.slots[slotNumber].pingStatus.ConnectionStatus then + conInfo = GUI.slots[slotNumber].pingStatus.ConnectionStatus + else + conInfo = 4 + end + return LOC('Only shows when > 500') .. '\n\n' .. LOC(ConnectionStatusInfo[conInfo]) + end + Tooltip.AddAutoUpdatedControlTooltip(control, pingText, pingBody, delay) +end + +--CPU Status Bar Configuration +local greenBarMax = 300 +local yellowBarMax = 375 +local scoreSkew1 = 0 --Skews all CPU scores up or down by the amount specified (0 = no skew) +local scoreSkew2 = 1.0 --Skews all CPU scores specified coefficient (1.0 = no skew) + +--Variables for CPU Test +local BenchTime + +-------------------------------------------------- +-- CPU Benchmarking Functions +-------------------------------------------------- +function CPUBenchmark() + --This function gives the CPU some busy work to do. + --CPU score is determined by how quickly the work is completed. + local totalTime = 0 + local lastTime + local currTime + local countTime = 0 + --Make everything a local variable + --This is necessary because we don't want LUA searching through the globals as part of the benchmark + local TableInsert = table.insert + local TableRemove = table.remove + local h + local i + local j + local k + local l + local m + local n = {} + for h = 1, 24, 1 do + -- If the need for the benchmark no longer exists, abort it now. + if not lobbyComm then + return + end + + lastTime = GetSystemTimeSeconds() + for i = 1.0, 30.4, 0.0008 do + --This instruction set should cover most LUA operators + j = i + i --Addition + k = i * i --Multiplication + l = k / j --Division + m = j - i --Subtraction + j = math.pow(i, 4) --Power + l = -i --Negation + m = {'1234567890', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', true} --Create Table + TableInsert(m, '1234567890') --Insert Table Value + k = i < j --Less Than + k = i == j --Equality + k = i <= j --Less Than or Equal to + k = not k + n[tostring(i)] = m + end + currTime = GetSystemTimeSeconds() + totalTime = totalTime + currTime - lastTime + + if totalTime > countTime then + --This is necessary in order to make this 'thread' yield so other things can be done. + countTime = totalTime + .125 + coroutine.yield(1) + end + end + BenchTime = math.ceil(totalTime * 100) +end + +-------------------------------------------------- +-- CPU GUI Functions +-------------------------------------------------- +function CPU_AddControlTooltip(control, delay, slotNumber) + --This function creates the benchmark tooltip for a slot along with necessary mouseover function. + --It is called during the UI creation. + -- control: The control to which the tooltip is to be added. + -- delay: Amount of time to delay before showing tooltip. See Tooltip.CreateMouseoverDisplay for info. + -- slotNumber: The slot number associated with the control. + local CPUText = function() + local CPUInfo + if GUI.slots[slotNumber].CPUSpeedBar.CPUActualValue then + CPUInfo = GUI.slots[slotNumber].CPUSpeedBar.CPUActualValue + else + CPUInfo = LOC('UnKnown') + end + return LOC('CPU Rating: ') .. CPUInfo + end + local CPUBody = function() + return LOC('0=Fastest, 450=Slowest') + end + Tooltip.AddAutoUpdatedControlTooltip(control, CPUText, CPUBody, delay) +end + +--- Get the CPU benchmark score for the local machine. +-- If a previously-calculated benchmark score is found in the profile, it is returned immediately. +-- Otherwise, a fresh score is calculated and stored (enjoy the lag!). +-- +-- @param force If truthy, a fresh score is always calculated. +-- @return A benchmark score between 0 and 450, or nil if StressCPU returned nil (which occurs iff +-- the lobby is exited before the calculation is complete. Callers should abort gracefully +-- in this situation). +-- @see StressCPU +function GetBenchmarkScore(force) + local wait = 10 + local benchmark + + if force then + wait = 0 + else + benchmark = GetPreference('CPUBenchmark') + end + + if not benchmark then + -- We defer the calculation by 10s here because, often, non-forced requests are occurring on + -- startup, and we want to give other tasks, such as connection negotiation, a fighting + -- chance of completing before we ruin everything. + -- + -- Benchmark scores are associated with the machine, not the profile: hence SetPreference. + + benchmark = StressCPU(wait) + SetPreference('CPUBenchmark', benchmark) + end + + return benchmark +end + +--- Updates the displayed benchmark score for the local player. +-- +-- @param force Passed as the `force` parameter to GetBenchmarkScore. +-- @see GetBenchmarkScore +function UpdateBenchmark(force) + local benchmark = GetBenchmarkScore(force) + + if benchmark then + CPU_Benchmarks[localPlayerName] = benchmark + lobbyComm:BroadcastData({ Type = 'CPUBenchmark', PlayerName = localPlayerName, Result = benchmark }) + if FindObserverSlotForID(localPlayerID) then + refreshObserverList() + else + UpdateCPUBar(localPlayerName) + end + end +end + +-- This function instructs the PC to do a CPU score benchmark. +-- It handles the necessary UI updates during the benchmark, sends +-- the benchmark result to other players when finished, and it updates the local +-- user's UI with their new result. +-- waitTime: The delay in seconds that this function should wait before starting the benchmark. +function StressCPU(waitTime) + GUI.rerunBenchmark:Disable() + for i = waitTime, 1, -1 do + GUI.rerunBenchmark.label:SetText(i..'s') + WaitSeconds(1) + + -- lobbyComm is destroyed when the lobby is exited. If the user left the lobby, we no longer + -- want to run the benchmark (it just introduces lag as the user is trying to do something + -- else). + if not lobbyComm then + return + end + end + + --Get our last benchmark (if there was one) + local currentBestBenchmark = 10000 + + GUI.rerunBenchmark.label:SetText('. . .') + + --Run three benchmarks and keep the best one + for i=1, 3, 1 do + BenchTime = 0 + CPUBenchmark() + + BenchTime = scoreSkew2 * BenchTime + scoreSkew1 + + -- The bench might have yeilded to a launcher, so we verify the lobbyComm is available when + -- we need it in a moment here (as well as aborting if we're wasting our time more than usual) + if not lobbyComm then + return + end + + --If this benchmark was better than our best so far... + if BenchTime < currentBestBenchmark then + currentBestBenchmark = BenchTime + end + end + + --Reset Button UI + GUI.rerunBenchmark:Enable() + GUI.rerunBenchmark.label:SetText('') + + return currentBestBenchmark +end + +function UpdateCPUBar(playerName) + --This function updates the UI with a CPU benchmark bar for the specified playerName. + -- playerName: The name of the player whose benchmark should be updated. + local playerId = FindIDForName(playerName) + local playerSlot = FindSlotForID(playerId) + if playerSlot ~= nil then + SetSlotCPUBar(playerSlot, gameInfo.PlayerOptions[playerSlot]) + end +end + +function SetSlotCPUBar(slot, playerInfo) + --This function updates the UI with a CPU benchmark bar for the specified slot/playerInfo. + -- slot: a numbered slot (1-however many slots there are for this map) + -- playerInfo: The corresponding playerInfo object from gameInfo.PlayerOptions[slot]. + + + if GUI.slots[slot].CPUSpeedBar then + GUI.slots[slot].CPUSpeedBar:Hide() + if playerInfo.Human then + local b = CPU_Benchmarks[playerInfo.PlayerName] + if b then + -- For display purposes, the bar has a higher minimum that the actual barMin value. + -- This is to ensure that the bar is visible for very small values + + -- Lock values to the largest possible result. + if b > GUI.slots[slot].CPUSpeedBar.barMax then + b = GUI.slots[slot].CPUSpeedBar.barMax + end + + GUI.slots[slot].CPUSpeedBar:SetValue(b) + GUI.slots[slot].CPUSpeedBar.CPUActualValue = b + + GUI.slots[slot].CPUSpeedBar:Show() + end + end + end +end + +function SetGameTitleText(title) + GUI.titleText:SetColor("B9BFB9") + if title == '' then + title = LOC("FAF Game Lobby") + end + GUI.titleText:SetText(title) +end + +function ShowTitleDialog() + CreateInputDialog(GUI, "Game Title", + function(self, text) + -- remove new lines from the text + text = text:gsub("\r", "") + text = text:gsub("\n", "") + + SetGameOption("Title", text, true) + SetGameTitleText(text) + end, gameInfo.GameOptions.Title +) +end + +-- Rule title +function SetRuleTitleText(rule) + GUI.RuleLabel:SetColors("B9BFB9") + if rule == '' then + if lobbyComm:IsHost() then + GUI.RuleLabel:SetColors("FFCC00") + rule = LOC("No Rules: Click to add rules") + else + rule = "No rules." + end + end + + GUI.RuleLabel:SetText(rule) +end + +-- Show the rule change dialog. +function ShowRuleDialog() + CreateInputDialog(GUI, "Game Rules", + function(self, text) + SetGameOption("GameRules", text, true) + SetRuleTitleText(text) + end, gameInfo.GameOptions.GameRules +) +end + +-- Faction selector +function CreateUI_Faction_Selector(lastFaction) + -- Build a list of button objects from the list of defined factions. Each faction will use the + -- faction key as its RadioButton texture path offset. + local buttons = {} + for i, faction in FactionData.Factions do + buttons[i] = { + texturePath = faction.Key + } + end + + -- Special-snowflaking for the random faction. + table.insert(buttons, { + texturePath = "random" + }) + + local factionSelector = RadioButton(GUI.panel, "/factionselector/", buttons, lastFaction, true) + GUI.factionSelector = factionSelector + LayoutHelpers.AtLeftTopIn(factionSelector, GUI.panel, 407, 20) + factionSelector.OnChoose = function(self, targetFaction, key) + local localSlot = FindSlotForID(localPlayerID) + local slotFactionIndex = GetSlotFactionIndex(targetFaction) + Prefs.SetToCurrentProfile('LastFaction', targetFaction) + GUI.slots[localSlot].faction:SetItem(slotFactionIndex) + SetPlayerOption(localSlot, 'Faction', slotFactionIndex) + gameInfo.PlayerOptions[localSlot].Faction = slotFactionIndex + + RefreshLobbyBackground(targetFaction) + UIUtil.SetCurrentSkin(FACTION_NAMES[targetFaction]) + end + + -- Only enable all buttons incase all the buttons are disabled, to avoid overriding partially disabling of the buttons + factionSelector.Enable = function(self) + for k, v in self.mButtons do + if v._controlState == "up" then + return + end + end + for k, v in self.mButtons do + v:Enable() + end + end + + factionSelector.SetCheck = function(self, index) + for i,button in self.mButtons do + if index ==i then + button:SetCheck(true) + else + button:SetCheck(false) + end + end + self.mCurSelection = index + end + + factionSelector.EnableSpecificButtons = function(self, specificButtons) + for i,button in self.mButtons do + if specificButtons[i] then + button:Enable() + else + button:Disable() + end + end + end +end + +function UpdateFactionSelectorForPlayer(playerInfo) + if playerInfo.OwnerID == localPlayerID then + UpdateFactionSelector() + end +end + +function UpdateFactionSelector() + local playerSlotID = FindSlotForID(localPlayerID) + local playerSlot = GUI.slots[playerSlotID] + if not playerSlot or not playerSlot.AvailableFactions or gameInfo.PlayerOptions[playerSlotID].Ready then + UIUtil.setEnabled(GUI.factionSelector, false) + return + end + local enabledList = {} + for index,button in GUI.factionSelector.mButtons do + enabledList[index] = false + for i,value in playerSlot.AvailableFactions do + if value == allAvailableFactionsList[index] then + if gameInfo.PlayerOptions[playerSlotID].Faction == i then + GUI.factionSelector:SetCheck(index) + end + enabledList[index] = true + break + end + end + end + GUI.factionSelector:EnableSpecificButtons(enabledList) +end + +function GetSlotFactionIndex(factionIndex) + local localSlot = GUI.slots[FindSlotForID(localPlayerID)] + local actualFaction = allAvailableFactionsList[factionIndex] + for index,value in localSlot.AvailableFactions do + if value == actualFaction then + return index + end + end +end + +function RefreshLobbyBackground(faction) + local LobbyBackground = Prefs.GetFromCurrentProfile('LobbyBackground') or 1 + if GUI.background then + GUI.background:Destroy() + end + if LobbyBackground == 1 then -- Factions + faction = faction or GetSanitisedLastFaction() + if FACTION_NAMES[faction] then + GUI.background = Bitmap(GUI, "/textures/ui/common/BACKGROUND/faction/faction-background-paint_" .. FACTION_NAMES[faction] .. "_bmp.dds") + else + return + end + elseif LobbyBackground == 2 then -- Concept art + GUI.background = Bitmap(GUI, "/textures/ui/common/BACKGROUND/art/art-background-paint0" .. math.random(1, 5) .. "_bmp.dds") + elseif LobbyBackground == 3 then -- Screenshot + GUI.background = Bitmap(GUI, "/textures/ui/common/BACKGROUND/scrn/scrn-background-paint" .. math.random(1, 14) .. "_bmp.dds") + elseif LobbyBackground == 4 then -- Map + local MapPreview = import("/lua/ui/controls/mappreview.lua").MapPreview + GUI.background = MapPreview(GUI) -- Background map + if gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= '') then + local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) + if scenarioInfo and scenarioInfo.map and (scenarioInfo.map ~= '') and scenarioInfo.preview then + if not GUI.background:SetTexture(scenarioInfo.preview) then + GUI.background:SetTextureFromMap(scenarioInfo.map) + end + end + end + elseif LobbyBackground == 5 then -- None + return + end + + local LobbyBackgroundStretch = Prefs.GetFromCurrentProfile('LobbyBackgroundStretch') or 'true' + LayoutHelpers.AtCenterIn(GUI.background, GUI) + LayoutHelpers.DepthUnderParent(GUI.background, GUI.panel) + if LobbyBackgroundStretch == 'true' then + LayoutHelpers.FillParent(GUI.background, GUI) + else + LayoutHelpers.FillParentPreserveAspectRatio(GUI.background, GUI) + end +end + +function ShowLobbyOptionsDialog() + local dialogContent = Group(GUI) + LayoutHelpers.SetDimensions(dialogContent, 420, 310) + + local dialog = Popup(GUI, dialogContent) + GUI.lobbyOptionsDialog = dialog + + local buttons = { + { + label = LOC("") -- Factions + }, + { + label = LOC("") -- Concept art + }, + { + label = LOC("") -- Screenshot + }, + { + label = LOC("") -- Map + }, + { + label = LOC("") -- None + } + } + + -- Label shown above the background mode selection radiobutton. + local backgroundLabel = UIUtil.CreateText(dialogContent, LOC(" "), 16, 'Arial', true) + local selectedBackgroundState = Prefs.GetFromCurrentProfile("LobbyBackground") or 1 + local backgroundRadiobutton = RadioButton(dialogContent, '/RADIOBOX/', buttons, selectedBackgroundState, false, true) + + LayoutHelpers.AtLeftTopIn(backgroundLabel, dialogContent, 15, 15) + LayoutHelpers.AtLeftTopIn(backgroundRadiobutton, dialogContent, 15, 40) + + backgroundRadiobutton.OnChoose = function(self, index, key) + Prefs.SetToCurrentProfile("LobbyBackground", index) + RefreshLobbyBackground() + end + -- label for displaying chat font size + local currentFontSize = Prefs.GetFromCurrentProfile('LobbyChatFontSize') or 14 + local slider_Chat_SizeFont_TEXT = UIUtil.CreateText(dialogContent, LOC(" ").. currentFontSize, 14, 'Arial', true) + LayoutHelpers.AtRightTopIn(slider_Chat_SizeFont_TEXT, dialogContent, 27, 162) + + -- slider for changing chat font size + local slider_Chat_SizeFont = Slider(dialogContent, false, 9, 20, + UIUtil.SkinnableFile('/slider02/slider_btn_up.dds'), + UIUtil.SkinnableFile('/slider02/slider_btn_over.dds'), + UIUtil.SkinnableFile('/slider02/slider_btn_down.dds'), + UIUtil.SkinnableFile('/slider02/slider-back_bmp.dds')) + LayoutHelpers.AtRightTopIn(slider_Chat_SizeFont, dialogContent, 20, 182) + slider_Chat_SizeFont:SetValue(currentFontSize) + slider_Chat_SizeFont.OnValueChanged = function(self, newValue) + local isScrolledDown = GUI.chatPanel:IsScrolledToBottom() + + local sliderValue = math.floor(self._currentValue()) + slider_Chat_SizeFont_TEXT:SetText(LOC(" ").. sliderValue) + + Prefs.SetToCurrentProfile('LobbyChatFontSize', sliderValue) + -- updating chat panel with new font size + GUI.chatPanel:SetFont(nil, sliderValue) + + if isScrolledDown then + GUI.chatPanel:ScrollToBottom() + end + end + if true then + --snowflakes count + local currentSnowFlakesCount = Prefs.GetFromCurrentProfile('SnowFlakesCount') or 100 + local slider_SnowFlakes_Count_TEXT = UIUtil.CreateText(dialogContent, LOC("Snowflakes count").. currentSnowFlakesCount, 14, 'Arial', true) + LayoutHelpers.AtRightTopIn(slider_SnowFlakes_Count_TEXT, dialogContent, 27, 202) + + -- slider for changing chat font size + local slider_SnowFlakes_Count = Slider(dialogContent, false, 100, 1000, + UIUtil.SkinnableFile('/slider02/slider_btn_up.dds'), + UIUtil.SkinnableFile('/slider02/slider_btn_over.dds'), + UIUtil.SkinnableFile('/slider02/slider_btn_down.dds'), + UIUtil.SkinnableFile('/slider02/slider-back_bmp.dds')) + LayoutHelpers.AtRightTopIn(slider_SnowFlakes_Count, dialogContent, 20, 222) + slider_SnowFlakes_Count:SetValue(currentSnowFlakesCount) + slider_SnowFlakes_Count.OnValueChanged = function(self, newValue) + local sliderValue = math.floor(newValue) + slider_SnowFlakes_Count_TEXT:SetText(LOC("Snowflakes count").. sliderValue) + Prefs.SetToCurrentProfile('SnowFlakesCount', sliderValue) + import("/lua/ui/events/snowflake.lua").Clear() + import("/lua/ui/events/snowflake.lua").CreateSnowFlakes(GUI, sliderValue) + end + end + + + -- + local cbox_WindowedLobby = UIUtil.CreateCheckbox(dialogContent, '/CHECKBOX/', LOC("Windowed mode")) + LayoutHelpers.AtRightTopIn(cbox_WindowedLobby, dialogContent, 20, 42) + Tooltip.AddCheckboxTooltip(cbox_WindowedLobby, {text=LOC('Windowed mode'), body=LOC("")}) + cbox_WindowedLobby.OnCheck = function(self, checked) + local option + if checked then + option = 'true' + else + option = 'false' + end + Prefs.SetToCurrentProfile('WindowedLobby', option) + SetWindowedLobby(checked) + end + -- + local cbox_StretchBG = UIUtil.CreateCheckbox(dialogContent, '/CHECKBOX/', LOC("Stretch Background")) + LayoutHelpers.AtRightTopIn(cbox_StretchBG, dialogContent, 20, 68) + Tooltip.AddCheckboxTooltip(cbox_StretchBG, {text=LOC('Stretch Background'), body=LOC("")}) + cbox_StretchBG.OnCheck = function(self, checked) + if checked then + Prefs.SetToCurrentProfile('LobbyBackgroundStretch', 'true') + else + Prefs.SetToCurrentProfile('LobbyBackgroundStretch', 'false') + end + RefreshLobbyBackground() + end + + local cbox_FactionFontColor = UIUtil.CreateCheckbox(dialogContent, '/CHECKBOX/', LOC("Faction Font Color")) + LayoutHelpers.AtRightTopIn(cbox_FactionFontColor, dialogContent, 20, 94) + cbox_FactionFontColor.OnCheck = function(self, checked) + if checked then + Prefs.SetOption('faction_font_color', true) + else + Prefs.SetOption('faction_font_color', false) + end + UIUtil.UpdateCurrentSkin() + end + + local cbox_ChatPlayerColor = UIUtil.CreateCheckbox(dialogContent, '/CHECKBOX/', LOC("Player color in chat")) + LayoutHelpers.AtRightTopIn(cbox_ChatPlayerColor, dialogContent, 20, 120) + cbox_ChatPlayerColor.OnCheck = function(self, checked) + if checked then + Prefs.SetToCurrentProfile('ChatPlayerColor', true) + else + Prefs.SetToCurrentProfile('ChatPlayerColor', false) + end + end + + -- Quit button + local QuitButton = UIUtil.CreateButtonWithDropshadow(dialogContent, '/BUTTON/medium/', LOC("Close")) + LayoutHelpers.AtHorizontalCenterIn(QuitButton, dialogContent, 0) + LayoutHelpers.AtBottomIn(QuitButton, dialogContent, 10) + + QuitButton.OnClick = function(self) + dialog:Hide() + end + -- + local WindowedLobby = Prefs.GetFromCurrentProfile('WindowedLobby') or 'true' + cbox_WindowedLobby:SetCheck(WindowedLobby == 'true', true) + if defaultMode == 'windowed' then + -- Already set Windowed in Game + cbox_WindowedLobby:Disable() + end + -- + local lobbyBackgroundStretch = Prefs.GetFromCurrentProfile('LobbyBackgroundStretch') or 'true' + cbox_StretchBG:SetCheck(lobbyBackgroundStretch == 'true', true) + -- + local factionFontColor = Prefs.GetOption('faction_font_color') + cbox_FactionFontColor:SetCheck(factionFontColor == true, true) + + local chatPlayerColor = Prefs.GetFromCurrentProfile('ChatPlayerColor') + cbox_ChatPlayerColor:SetCheck(chatPlayerColor == true or chatPlayerColor == nil, true) +end + +---Applies new game settings, including map, mods +---@param settings WatchedGameData +function ApplyGameSettings(settings) + SetGameOptions(settings.GameOptions, true) + + rehostPlayerOptions = settings.PlayerOptions + selectedSimMods = settings.GameMods + HostUtils.UpdateMods() + + UpdateGame() +end +---Returns the current game settings +---@return WatchedGameData +function GetGameSettings() + return gameInfo +end + +--- Delegate to UIUtil's CreateInputDialog, adding the ridiculus chatEdit hack. +function CreateInputDialog(parent, title, listener, str) + UIUtil.CreateInputDialog(parent, title, listener, GUI.chatEdit, str) +end + +-- Update the combobox for the given slot so it correctly shows the set of available colours. +-- causes a new availableColours to be populated for each occupied slot +-- if a slot is specified, that slot's displayed color will be made consistent with its coded color +function Check_Availaible_Color(slot) + -- generate a table of used colors + local UsedColours = {} + for i = 1, LobbyComm.maxPlayerSlots do + -- Skips empty slots. + if gameInfo.PlayerOptions[i] then + table.insert(UsedColours, gameInfo.PlayerOptions[i].PlayerColor) + end + end + + -- generate a table of unused colors + local unusedColours = {} + for i, color in gameColors.PlayerColors do + if not table.find(UsedColours, i) then + unusedColours[i] = color + end + end + + for i = 1, LobbyComm.maxPlayerSlots do + -- Skips empty slots. + if gameInfo.PlayerOptions[i] then + local availableColours = {} + -- deepcopy the unused colors because using them by reference causes problems with the displayed color list/ChangeBitmapArray + for c, color in unusedColours do + availableColours[c] = color + end + -- add this slot's color to its availableColours (you get a nil lazyvar error if it's not included) + availableColours[ gameInfo.PlayerOptions[i].PlayerColor] = gameColors.PlayerColors[gameInfo.PlayerOptions[i].PlayerColor] + -- set the list of available colors for this slot + GUI.slots[i].color:ChangeBitmapArray(availableColours, true) + end + end + + -- if a slot was entered, set the displayed color for that slot to the coded color for that slot + if slot then + GUI.slots[slot].color:SetItem(gameInfo.PlayerOptions[slot].PlayerColor) + end +end + +function CheckModCompatability() + local blacklistedMods = {} + for modId, _ in SetUtils.Union(selectedSimMods, selectedUIMods) do + if ModBlacklist[modId] then + blacklistedMods[modId] = ModBlacklist[modId] + end + end + + return blacklistedMods +end + +function WarnIncompatibleMods() + UIUtil.QuickDialog(GUI, + "Some of your enabled mods are known to cause malfunctions with FAF, so have been disabled. See the mod manager for details - some mods may have newer versions which work.", + "") +end + +function DoSlotSwap(slot1, slot2) + + -- retrieve player info + local player1 = gameInfo.PlayerOptions[slot1] + local player2 = gameInfo.PlayerOptions[slot2] + + -- unready players in the player options + player1.Ready = false + player2.Ready = false + + -- swap teams + local team_bucket = player1.Team + player1.Team = player2.Team + player2.Team = team_bucket + + --Handle faction availability + KeepSameFactionOrRandom(slot1, slot2, player1) + KeepSameFactionOrRandom(slot2, slot1, player2) + + -- swap the slots + gameInfo.PlayerOptions[slot2] = player1 + gameInfo.PlayerOptions[slot1] = player2 + + -- update slot info + SetSlotInfo(slot2, player1) + SetSlotInfo(slot1, player2) + + -- update faction selector + UpdateFactionSelectorForPlayer(player1) + UpdateFactionSelectorForPlayer(player2) + + UpdateGame() +end + +function KeepSameFactionOrRandom(slotFrom, slotTo, player) + local playerFactionKey = GUI.slots[slotFrom].AvailableFactions[player.Faction] + --intialize to random, incase oldFaction isn't available + player.Faction = table.getn(GUI.slots[slotTo].AvailableFactions) + for index,faction in GUI.slots[slotTo].AvailableFactions do + if faction == playerFactionKey then + player.Faction = index + end + end +end + +local function SendPlayerOption(playerInfo, key, value) + if playerInfo.Human then + GpgNetSend('PlayerOption', playerInfo.OwnerID, key, value) + else + GpgNetSend('AIOption', playerInfo.PlayerName, key, value) + end +end + +--- Create the HostUtils object, containing host-only functions. By not assigning this for non-host +-- players, we ensure a hard crash should a non-host somehow end up trying to call them, simplifying +-- debugging somewhat (as well as reducing the number of toplevel definitions a fair bit). +-- This is clearly not a security feature. +function InitHostUtils() + if not lobbyComm:IsHost() then + WARN(debug.traceback(nil, "Attempt to create HostUtils by non-host.")) + return + end + + HostUtils = { + --- Cause a player's ready box to become unchecked. + -- + -- @param slot The slot number of the target player. + SetPlayerNotReady = function(slot) + local slotOptions = gameInfo.PlayerOptions[slot] + if slotOptions.Ready then + if not IsLocallyOwned(slot) then + lobbyComm:SendData(slotOptions.OwnerID, {Type = 'SetPlayerNotReady', Slot = slot}) + end + slotOptions.Ready = false + end + end, + + SetSlotClosed = function(slot, closed) + -- Don't close an occupied slot. + if gameInfo.PlayerOptions[slot] then + return + end + + lobbyComm:BroadcastData( + { + Type = 'SlotClosed', + Slot = slot, + Closed = closed + } + ) + + gameInfo.ClosedSlots[slot] = closed + gameInfo.SpawnMex[slot] = false + ClearSlotInfo(slot) + PossiblyAnnounceGameFull() + end, + + SetSlotClosedSpawnMex = function(slot) + -- Don't close an occupied slot. + if gameInfo.PlayerOptions[slot] then + return + end + + lobbyComm:BroadcastData( + { + Type = 'SlotClosedSpawnMex', + Slot = slot, + ClosedSpawnMex = true + } + ) + + gameInfo.ClosedSlots[slot] = true + gameInfo.SpawnMex[slot] = true + ClearSlotInfo(slot) + PossiblyAnnounceGameFull() + end, + + ConvertPlayerToObserver = function(playerSlot, ignoreMsg) + -- make sure player exists + if not gameInfo.PlayerOptions[playerSlot] then + WARN("HostUtils.ConvertPlayerToObserver for nonexistent player in slot " .. tostring(playerSlot)) + return + end + + -- find a free observer slot + local index = 1 + while gameInfo.Observers[index] do + index = index + 1 + end + + HostUtils.SetPlayerNotReady(playerSlot) + local ownerID = gameInfo.PlayerOptions[playerSlot].OwnerID + gameInfo.Observers[index] = gameInfo.PlayerOptions[playerSlot] + gameInfo.PlayerOptions[playerSlot] = nil + + if lobbyComm:IsHost() then + GpgNetSend('PlayerOption', ownerID, 'Team', -1) + GpgNetSend('PlayerOption', ownerID, 'Army', -1) + GpgNetSend('PlayerOption', ownerID, 'StartSpot', -index) + end + + ClearSlotInfo(playerSlot) + + -- TODO: can probably avoid transmitting the options map here. The slot number should be enough. + lobbyComm:BroadcastData( + { + Type = 'ConvertPlayerToObserver', + OldSlot = playerSlot, + NewSlot = index, + Options = gameInfo.Observers[index]:AsTable(), + } + ) + + if not ignoreMsg then + -- %s has switched from a player to an observer. + SendSystemMessage("lobui_0226", gameInfo.Observers[index].PlayerName) + end + + UpdateGame() + end, + + ConvertObserverToPlayer = function(fromObserverSlot, toPlayerSlot, ignoreMsg) + -- If no slot is specified (user clicked "go player" button), select a default. + if not toPlayerSlot or toPlayerSlot < 1 or toPlayerSlot > numOpenSlots then + toPlayerSlot = HostUtils.FindEmptySlot() + + -- If it's still -1 (No slots available) check for AIs and evict the first one + if toPlayerSlot < 1 then + for i = 1, numOpenSlots do + local slot = gameInfo.PlayerOptions[i] + if slot and not slot.Human then + HostUtils.RemoveAI(i) + toPlayerSlot = i + break + end + end + + -- There are no AIs and no slots, so break out with a message + if toPlayerSlot < 1 and gameInfo.Observers[fromObserverSlot] then + SendPersonalSystemMessage(gameInfo.Observers[fromObserverSlot].OwnerID, "lobui_0608") + return + end + end + end + + if not gameInfo.Observers[fromObserverSlot] then -- IF no Observer on the current slot : QUIT + return + elseif gameInfo.PlayerOptions[toPlayerSlot] then -- IF Player is in the target slot : QUIT + return + elseif gameInfo.ClosedSlots[toPlayerSlot] then -- IF target slot is Closed : QUIT + return + end + + local incomingPlayer = gameInfo.Observers[fromObserverSlot] + + -- Give them a default colour if the one they already have isn't free. + if not IsColorFree(incomingPlayer.PlayerColor) then + local newColour = GetAvailableColor() + SetPlayerColor(incomingPlayer, newColour) + end + + gameInfo.PlayerOptions[toPlayerSlot] = incomingPlayer + gameInfo.Observers[fromObserverSlot] = nil + + lobbyComm:BroadcastData( + { + Type = 'ConvertObserverToPlayer', + OldSlot = fromObserverSlot, + NewSlot = toPlayerSlot, + Options = gameInfo.PlayerOptions[toPlayerSlot]:AsTable(), + } + ) + + if not ignoreMsg then + -- %s has switched from an observer to player. + SendSystemMessage("lobui_0227", incomingPlayer.PlayerName) + end + + SetSlotInfo(toPlayerSlot, gameInfo.PlayerOptions[toPlayerSlot]) + + -- This is far from optimally efficient, as it will SetSlotInfo twice when autoteams is enabled. + AssignAutoTeams() + + UpdateFactionSelectorForPlayer(gameInfo.PlayerOptions[toPlayerSlot]) + end, + + RemoveAI = function(slot) + if gameInfo.PlayerOptions[slot].Human then + WARN('Use EjectPlayer to remove humans') + return + end + + gameInfo.PlayerOptions[slot] = nil + ClearSlotInfo(slot) + lobbyComm:BroadcastData( + { + Type = 'ClearSlot', + Slot = slot, + } + ) + end, + + --- Returns false if there's an obvious reason why a slot movement between the two given + -- slots will fail. + -- + -- @param moveFrom Slot number to move from + -- @param moveTo Slot number to move to. + SanityCheckSlotMovement = function(moveFrom, moveTo) + if moveTo == moveFrom then + -- no need to move a slot to its current location + return false + end + + if gameInfo.ClosedSlots[moveTo] then + LOG("HostUtils.MovePlayerToEmptySlot: requested slot " .. moveTo .. " is closed") + return false + end + + if moveTo > numOpenSlots or moveTo < 1 then + LOG("HostUtils.MovePlayerToEmptySlot: requested slot " .. moveTo .. " is out of range") + return false + end + + if moveFrom > numOpenSlots or moveFrom < 1 then + LOG("HostUtils.MovePlayerToEmptySlot: target slot " .. moveFrom .. " is out of range") + return false + end + + return true + end, + + --- Move a player from one slot to another, unoccupied one. Is a no-op if the requested slot + -- is occupied, closed, or out of range. Races over network may cause this to occur during + -- normal operation. + -- + -- @param currentSlot The slot occupied by the player to move + -- @param requestedSlot The slot to move this player to. + MovePlayerToEmptySlot = function(currentSlot, requestedSlot) + -- Bail out early for the stupid cases. + if not HostUtils.SanityCheckSlotMovement(currentSlot, requestedSlot) then + return + end + + -- This one's only specific to moving to an empty slot, naturally. + if gameInfo.PlayerOptions[requestedSlot] then + LOG("HostUtils.MovePlayerToEmptySlot: requested slot " .. requestedSlot .. " already occupied") + return false + end + + local player = gameInfo.PlayerOptions[currentSlot] + + KeepSameFactionOrRandom(currentSlot, requestedSlot, player) + + gameInfo.PlayerOptions[requestedSlot] = gameInfo.PlayerOptions[currentSlot] + gameInfo.PlayerOptions[currentSlot] = nil + ClearSlotInfo(currentSlot) + SetSlotInfo(requestedSlot, gameInfo.PlayerOptions[requestedSlot]) + + lobbyComm:BroadcastData( + { + Type = 'SlotMove', + OldSlot = currentSlot, + NewSlot = requestedSlot, + Options = gameInfo.PlayerOptions[requestedSlot]:AsTable(), + } + ) + + -- This is far from optimally efficient, as it will SetSlotInfo twice when autoteams is enabled. + AssignAutoTeams() + end, + + --- Swap the players in the two given slots. + -- + -- If the target slot is unoccupied, the player in the first slot is simply moved there. + -- If the target slot is closed, this is a no-op. + -- If a player or ai occupies both slots, they are swapped. + SwapPlayers = function(slot1, slot2) + + -- Bail out early for the stupid cases. + if not HostUtils.SanityCheckSlotMovement(slot1, slot2) then + return + end + + local player1 = gameInfo.PlayerOptions[slot1] + local player2 = gameInfo.PlayerOptions[slot2] + + -- If we're moving onto a blank, take the easy way out. + if not player2 then + HostUtils.MovePlayerToEmptySlot(slot1, slot2) + return + end + + -- Do the swap on our end + DoSlotSwap(slot1, slot2) + + -- Tell everyone else to do the swap too + lobbyComm:BroadcastData( + { + Type = 'SwapPlayers', + Slot1 = slot1, + Slot2 = slot2, + } + ) + + -- %s has switched with %s + SendSystemMessage("lobui_0417", player1.PlayerName, player2.PlayerName) + end, + + --- Add an observer + -- + -- @param observerData A PlayerData object representing this observer. + TryAddObserver = function(senderID, observerData) + local index = 1 + while gameInfo.Observers[index] do + index = index + 1 + end + + gameInfo.Observers[index] = observerData + + lobbyComm:BroadcastData( + { + Type = 'ObserverAdded', + Slot = index, + Options = observerData:AsTable(), + } + ) + + -- %s has joined as an observer. + SendSystemMessage("lobui_0202", observerData.PlayerName) + refreshObserverList() + end, + + --- Attempt to add a player to a slot. If none are available, add them as an observer. + -- + -- @param senderID The peer ID of the player we're adding. + -- @param slot The slot to insert the player to. A value of less than 1 indicates "any slot" + -- @param playerData A PlayerData object representing the player to add. + TryAddPlayer = function(senderID, slot, playerData) + -- CPU benchmark code + if playerData.Human and not singlePlayer then + lobbyComm:SendData(senderID, {Type='CPUBenchmark', Benchmarks=CPU_Benchmarks}) + end + + local newSlot = slot + + if not slot or slot < 1 or newSlot > numOpenSlots then + newSlot = HostUtils.FindEmptySlot() + end + + -- if no slot available, and human, try to make them an observer + if newSlot == -1 then + PrivateChat(senderID, LOC("No slots available, attempting to make you an observer")) + if playerData.Human then + HostUtils.TryAddObserver(senderID, playerData) + end + return + end + + -- if a color is requested, attempt to use that color if available, otherwise, assign first available + if not IsColorFree(playerData.PlayerColor, slot) then + SetPlayerColor(playerData, GetAvailableColor()) + end + + gameInfo.PlayerOptions[newSlot] = playerData + lobbyComm:BroadcastData( + { + Type = 'SlotAssigned', + Slot = newSlot, + Options = playerData:AsTable(), + } + ) + + SetSlotInfo(newSlot, gameInfo.PlayerOptions[newSlot]) + -- This is far from optimally efficient, as it will SetSlotInfo twice when autoteams is enabled. + AssignAutoTeams() + PossiblyAnnounceGameFull() + end, + + --- Add an AI to the game in the given slot. + -- + -- @param name The name to use in the player list for this AI. + -- @param personality The "personality" key, such as "adaptive" or "easy", for this AI. + -- @param slot (optional) The slot into which to put this AI. Defaults to the first empty + -- slot from the top of the list. + AddAI = function(name, personality, slot) + HostUtils.TryAddPlayer(hostID, slot, GetAIPlayerData(name, personality, slot)) + end, + + PlayerMissingMapAlert = function(id) + local slot = FindSlotForID(id) + local name + local needMessage = false + if slot then + name = gameInfo.PlayerOptions[slot].PlayerName + if not gameInfo.PlayerOptions[slot].BadMap then + needMessage = true + end + gameInfo.PlayerOptions[slot].BadMap = true + else + slot = FindObserverSlotForID(id) + if slot then + name = gameInfo.Observers[slot].PlayerName + if not gameInfo.Observers[slot].BadMap then + needMessage = true + end + gameInfo.Observers[slot].BadMap = true + end + end + + if needMessage then + -- %s is missing map %s. + SendSystemMessage("lobui_0330", name, gameInfo.GameOptions.ScenarioFile) + LOG('>> '..name..' is missing map '..gameInfo.GameOptions.ScenarioFile) + if name == localPlayerName then + LOG('>> '..gameInfo.GameOptions.ScenarioFile..' replaced with '.. UIUtil.defaultScenario) + SetGameOption('ScenarioFile', UIUtil.defaultScenario) + end + end + end, + + -- This function is needed because army numbers need to be calculated: armies are numbered incrementally in slot order. + -- Call this function once just before game starts + SendArmySettingsToServer = function() + local armyIdx = 1 + local MAXSLOT = 16 + for slotNum = 1, MAXSLOT do + local playerInfo = gameInfo.PlayerOptions[slotNum] + if playerInfo ~= nil then + LOG('>> SendArmySettingsToServer: Setting army '..armyIdx..' for slot '..slotNum) + SendPlayerOption(playerInfo, 'Army', armyIdx) + armyIdx = armyIdx + 1 + else + LOG('>> SendArmySettingsToServer: Slot '..slotNum..' empty') + end + end + end, + + --- Send player settings to the server + SendPlayerSettingsToServer = function(slotNum) + local playerInfo = gameInfo.PlayerOptions[slotNum] + SendPlayerOption(playerInfo, 'Faction', playerInfo.Faction) + SendPlayerOption(playerInfo, 'Color', playerInfo.PlayerColor) + SendPlayerOption(playerInfo, 'Team', playerInfo.Team) + SendPlayerOption(playerInfo, 'StartSpot', slotNum) + end, + + --- Called by the host when someone's readyness state changes to update the enabledness of buttons. + RefreshButtonEnabledness = function() + -- disable options when all players are marked ready + -- Is at least one person not ready? + local playerNotReady = GetPlayersNotReady() ~= false + + -- Host should be able to set game options even if he is observer if all slots are AI + local hostObserves = false + if not playerNotReady and IsObserver(localPlayerID) then + hostObserves = true + end + + local buttonState = hostObserves or playerNotReady + + UIUtil.setEnabled(GUI.gameoptionsButton, buttonState) + UIUtil.setEnabled(GUI.defaultOptions, buttonState) + UIUtil.setEnabled(GUI.randMap, buttonState) + UIUtil.setEnabled(GUI.autoTeams, buttonState) + UIUtil.setEnabled(GUI.restrictedUnitsOrPresetsBtn, buttonState) + + -- Launch button enabled if everyone is ready. + UIUtil.setEnabled(GUI.launchGameButton, singlePlayer or hostObserves or not playerNotReady) + + -- Disable the AutoBalance button if any players are on a tean greater than 2 or buttonState is false + local noOtherTeams = true + for i, player in gameInfo.PlayerOptions:pairs() do + -- Team numbers are 1 higher on the backend + if player.Team > 3 then + noOtherTeams = false + break + end + end + UIUtil.setEnabled(GUI.PenguinAutoBalance, noOtherTeams and buttonState) + end, + + -- Update our local gameInfo.GameMods from selected map name and selected mods, then + -- notify other clients about the change. + UpdateMods = function(newPlayerID, newPlayerName) + local newmods = {} + local missingmods = {} + local blacklistedMods = {} + + -- If any of our active sim mods are blacklisted, warn the user, turn them off, and + -- go through the "mods changed" handler code again with the smaller set. + local bannedMods = CheckModCompatability() + if not table.empty(bannedMods) then + WarnIncompatibleMods() + + selectedSimMods = SetUtils.Subtract(selectedSimMods, bannedMods) + selectedUIMods = SetUtils.Subtract(selectedUIMods, bannedMods) + OnModsChanged(selectedSimMods, selectedUIMods) + + return + end + + for modId, _ in selectedSimMods do + if IsModAvailable(modId) then + newmods[modId] = true + else + table.insert(missingmods, modId) + end + end + + -- We were called to update the sim mod set for the game, and have _really_ made changes + if not table.equal(gameInfo.GameMods, newmods) and not newPlayerID then + gameInfo.GameMods = newmods + lobbyComm:BroadcastData { Type = "ModsChanged", GameMods = gameInfo.GameMods } + local nummods = 0 + local uids = "" + + for k in gameInfo.GameMods do + nummods = nummods + 1 + if uids == "" then + uids = k + else + uids = string.format("%s %s", uids, k) + end + + end + GpgNetSend('GameMods', "activated", nummods) + + if nummods > 0 then + GpgNetSend('GameMods', "uids", uids) + end + elseif not table.equal(gameInfo.GameMods, newmods) and newPlayerID then + local modnames = "" + local totalMissing = table.getn(missingmods) + local totalListed = 0 + if totalMissing > 0 then + for index, id in missingmods do + for k,mod in Mods.GetGameMods() do + if mod.uid == id then + totalListed = totalListed + 1 + if totalMissing == totalListed then + modnames = modnames .. " " .. mod.name + else + modnames = modnames .. " " .. mod.name .. " + " + end + end + end + end + end + local reason = (LOCF('You were automaticly removed from the lobby because you ' .. + 'don\'t have the following mod(s):\n%s \nPlease, install the mod before you join the game lobby', modnames)) + -- TODO: Verify this functionality + if FindNameForID(newPlayerID) then + AddChatText(FindNameForID(newPlayerID)..LOC(' kicked because he does not have this mod: ')..modnames) + else + if newPlayerName then + AddChatText(newPlayerName..LOC(' kicked because he does not have this mod: ')..modnames) + else + AddChatText(LOC('The last player is kicked because he does not have this mod: ')..modnames) + end + end + lobbyComm:EjectPeer(newPlayerID, reason) + end + end, + + --- Find and return the id of an unoccupied slot. + -- + -- @return The id of an empty slot, of -1 if none is available. + FindEmptySlot = function() + for i = 1, numOpenSlots do + if not gameInfo.PlayerOptions[i] and not gameInfo.ClosedSlots[i] then + return i + end + end + + return -1 + end, + + KickObservers = function(reason) + for k,observer in gameInfo.Observers:pairs() do + lobbyComm:EjectPeer(observer.OwnerID, reason or "KickedByHost") + end + gameInfo.Observers = WatchedValueArray(LobbyComm.maxPlayerSlots) + end + } +end diff --git a/lua/ui/lobby/lobby.lua b/lua/ui/lobby/lobby.lua index a76654e3527..0c7bff3b558 100644 --- a/lua/ui/lobby/lobby.lua +++ b/lua/ui/lobby/lobby.lua @@ -1,7386 +1,142 @@ --***************************************************************************** ---* File: lua/modules/ui/lobby/lobby.lua ---* Author: Chris Blackwell ---* Summary: Game selection UI +--* FAF notes: +--* This file is the engine-facing entry point for the custom-game lobby. The +--* original (organically grown) implementation now lives in `lobby-old.lua`; +--* this thin wrapper drives the reactive-MVC rebuild under `customlobby/`. --* ---* Copyright © 2005 Gas Powered Games, Inc. All rights reserved. +--* The engine and the menus call CreateLobby / HostGame / JoinGame / +--* ConnectToPeer / DisconnectFromPeer on this module, exactly as before, so no +--* caller needs to change. See `customlobby/CLAUDE.md` and +--* `TARGET_ARCHITECTURE.md`. --***************************************************************************** -local GameVersion = import("/lua/version.lua").GetVersion -local UIUtil = import("/lua/ui/uiutil.lua") -local MenuCommon = import("/lua/ui/menus/menucommon.lua") -local Prefs = import("/lua/user/prefs.lua") -local MapUtil = import("/lua/ui/maputil.lua") -local Group = import("/lua/maui/group.lua").Group -local RadioButton = import("/lua/ui/controls/radiobutton.lua").RadioButton -local ResourceMapPreview = import("/lua/ui/controls/resmappreview.lua").ResourceMapPreview -local Popup = import("/lua/ui/controls/popups/popup.lua").Popup -local Slider = import("/lua/maui/slider.lua").Slider -local PlayerData = import("/lua/ui/lobby/data/playerdata.lua").PlayerData -local GameInfo = import("/lua/ui/lobby/data/gamedata.lua") -local WatchedValueArray = import("/lua/ui/lobby/data/watchedvalue/watchedvaluearray.lua").WatchedValueArray -local LayoutHelpers = import("/lua/maui/layouthelpers.lua") -local Bitmap = import("/lua/maui/bitmap.lua").Bitmap -local Button = import("/lua/maui/button.lua").Button -local ToggleButton = import("/lua/ui/controls/togglebutton.lua").ToggleButton -local Edit = import("/lua/maui/edit.lua").Edit -local LobbyComm = import("/lua/ui/lobby/lobbycomm.lua") -local Tooltip = import("/lua/ui/game/tooltip.lua") -local Mods = import("/lua/mods.lua") -local FactionData = import("/lua/factions.lua") -local TextArea = import("/lua/ui/controls/textarea.lua").TextArea -local Presets = import("/lua/ui/lobby/presets.lua") -local utils = import("/lua/system/utils.lua") +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** -local Trueskill = import("/lua/ui/lobby/trueskill.lua") -local Player = Trueskill.Player -local Rating = Trueskill.Rating -local ModBlacklist = import("/etc/faf/blacklist.lua").Blacklist -local Teams = Trueskill.Teams +local MenuCommon = import("/lua/ui/menus/menucommon.lua") local EscapeHandler = import("/lua/ui/dialogs/eschandler.lua") -local CountryTooltips = import("/lua/ui/help/tooltips-country.lua").tooltip -local SetUtils = import("/lua/system/setutils.lua") -local JSON = import("/lua/system/dkson.lua").json -local UTF = import("/lua/utf.lua") --- Uveso - aitypes inside aitypes.lua are now also available as a function. -local aitypes -local AIKeys = {} -local AIStrings = {} -local AITooltips = {} - - - -function GetAITypes() - AIKeys = {} - AIStrings = {} - AITooltips = {} - aitypes = import("/lua/ui/lobby/aitypes.lua").GetAItypes() - for _, aidata in aitypes do - table.insert(AIKeys, aidata.key) - table.insert(AIStrings, aidata.name) - table.insert(AITooltips, 'aitype_'..aidata.key) - end -end -GetAITypes() - ---This is a special table that allows us to pass data to blueprints.lua, before the rest of the game is loaded. --- do not use this for anything that doesnt do blueprint modding, use GameOptions for that instead, which will load it into sim. - -local IsSyncReplayServer = false - -local AddUnicodeCharToEditText = import("/lua/utf.lua").AddUnicodeCharToEditText - -if HasCommandLineArg("/syncreplay") and HasCommandLineArg("/gpgnet") then - IsSyncReplayServer = true -end - -local globalOpts = import("/lua/ui/lobby/lobbyoptions.lua").globalOpts -local teamOpts = import("/lua/ui/lobby/lobbyoptions.lua").teamOptions -local AIOpts = import("/lua/ui/lobby/lobbyoptions.lua").AIOpts -local gameColors = import("/lua/gamecolors.lua").GameColors - -local numOpenSlots = LobbyComm.maxPlayerSlots - --- Add lobby options from AI mods -function ImportModAIOptions() - local simMods = import("/lua/mods.lua").AllMods() - local OptionData - local alreadyStored - for Index, ModData in simMods do - if exists(ModData.location..'/lua/AI/LobbyOptions/lobbyoptions.lua') then - OptionData = import(ModData.location..'/lua/AI/LobbyOptions/lobbyoptions.lua').AIOpts - for s, t in OptionData do - -- check, if we have this option already stored - alreadyStored = false - for k, v in AIOpts do - if v.key == t.key then - alreadyStored = true - break - end - end - if not alreadyStored then - table.insert(AIOpts, t) - end - end - end - end -end -ImportModAIOptions() - --- Maps faction identifiers to their names. -local FACTION_NAMES = {[1] = "uef", [2] = "aeon", [3] = "cybran", [4] = "seraphim", [5] = "random" } - -local rehostPlayerOptions = {} -- Player options loaded from preset, used for rehosting - -local formattedOptions = {} -local nonDefaultFormattedOptions = {} -local LrgMap = false - -local HostUtils -local mapPreviewSlotSwapFrom = 0 -local mapPreviewSlotSwap = false - -teamIcons = { - '/lobby/team_icons/team_no_icon.dds', - '/lobby/team_icons/team_1_icon.dds', - '/lobby/team_icons/team_2_icon.dds', - '/lobby/team_icons/team_3_icon.dds', - '/lobby/team_icons/team_4_icon.dds', - '/lobby/team_icons/team_5_icon.dds', - '/lobby/team_icons/team_6_icon.dds', - '/lobby/team_icons/team_7_icon.dds', - '/lobby/team_icons/team_8_icon.dds', -} - -DebugEnabled = Prefs.GetFromCurrentProfile('LobbyDebug') or '' -local HideDefaultOptions = Prefs.GetFromCurrentProfile('LobbyHideDefaultOptions') == 'true' - -local connectedTo = {} -- by UID -CurrentConnection = {} -- by Name -ConnectionEstablished = {} -- by Name -ConnectedWithProxy = {} -- by UID - - -allAvailableFactionsList = {} - -local availableMods = {} -- map from peer ID to set of available mods; each set is a map from "mod id"->true -local selectedSimMods = {} -- Similar map for activated sim mods -local selectedUIMods = {} -- Similar map for activated UI mods - -local CPU_Benchmarks = {} -- Stores CPU benchmark data - -local function parseCommandlineArguments() - -- Set of all possible command line option keys. - -- The client sometimes gives us empty-string as some args, which gets interpreted as that key - -- having as value the name of the next key. This set lets us interpret that case using the - -- default option. - local CMDLINE_ARGUMENT_KEYS = { - ["/init"] = true, - ["/country"] = true, - ["/numgames"] = true, - ["/mean"] = true, - ["/clan"] = true, - ["/deviation"] = true, - ["/joincustom"] = true, - ["/gpgnet"] = true, - } - - local function GetCommandLineArgOrDefault(argname, default) - local arg = GetCommandLineArg(argname, 1) - if arg and not CMDLINE_ARGUMENT_KEYS[arg[1]] then - return arg[1] - end - return default - end - - return { - PrefLanguage = tostring(string.lower(GetCommandLineArgOrDefault("/country", "world"))), - isRehost = HasCommandLineArg("/rehost"), - initName = GetCommandLineArgOrDefault("/init", ""), - numGames = tonumber(GetCommandLineArgOrDefault("/numgames", 0)), - playerMean = tonumber(GetCommandLineArgOrDefault("/mean", 1500)), - playerClan = tostring(GetCommandLineArgOrDefault("/clan", "")), - playerDeviation = tonumber(GetCommandLineArgOrDefault("/deviation", 500)), - debugLobby = HasCommandLineArg("/debugLobby"), -- Used by LaunchFAInstances script to set players as ready by default - } -end -local argv = parseCommandlineArguments() - -local playerRating = math.floor(Trueskill.round2((argv.playerMean - 3 * argv.playerDeviation) / 100.0) * 100) - -local function ParseWhisper(params) - local delimStart = string.find(params, " ") - if delimStart then - local name = string.sub(params, 1, delimStart-1) - local targID = FindIDForName(name) - if targID then - PrivateChat(targID, string.sub(params, delimStart+1)) - else - AddChatText(LOC("Invalid whisper target.")) - end - end -end - -local commands = { - pm = ParseWhisper, - private = ParseWhisper, - w = ParseWhisper, - whisper = ParseWhisper, -} - -local Strings = LobbyComm.Strings - ----@type LobbyComm -local lobbyComm = false -local localPlayerName = "" -local gameName = "" -local hostID = false -local singlePlayer = false ----@type Group -local GUI = false -local localPlayerID = false ----@type GameData | WatchedGameData -local gameInfo = false -local lastKickMessage = UTF.UnescapeString(Prefs.GetFromCurrentProfile('lastKickMessage') or "") - -local defaultMode =(HasCommandLineArg("/windowed") and "windowed") or Prefs.GetFromCurrentProfile('options').primary_adapter -local windowedMode = defaultMode == "windowed" or (HasCommandLineArg("/windowed")) - -function SetWindowedLobby(windowed) - -- Dont change resolution if user already using windowed mode - if windowed == windowedMode or defaultMode == 'windowed' then +local CustomLobbyLaunchModel = import("/lua/ui/lobby/customlobby/models/customlobbylaunchmodel.lua") +local CustomLobbySessionModel = import("/lua/ui/lobby/customlobby/models/customlobbysessionmodel.lua") +local CustomLobbyLocalModel = import("/lua/ui/lobby/customlobby/models/customlobbylocalmodel.lua") +local CustomLobbyInterface = import("/lua/ui/lobby/customlobby/customlobbyinterface.lua") +local CustomLobbySession = import("/lua/ui/lobby/customlobby/customlobbysession.lua") +local CustomLobbyLog = import("/lua/ui/lobby/customlobby/customlobbylog.lua") + +local maxConnections = 16 + +---@type UICustomLobbyInstance | false +local Instance = false + +--- Called by the engine / menus to create a new (unconnected) lobby. +--- Matches the legacy signature so existing callers (uimain, gameselect, +--- gamecreate, onlineprovider) work unchanged. +---@param protocol UILobbyProtocol +---@param localPort number +---@param desiredPlayerName string +---@param localPlayerUID? UILobbyPeerId +---@param natTraversalProvider? userdata +---@param over Control +---@param exitBehavior fun() +function CreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider, over, exitBehavior) + -- The FAF client pokes GPGNet messages in through here with localPort == -1. + -- The barebones lobby doesn't handle those yet. + if localPort == -1 then + WARN("CustomLobby: GPGNet message path (localPort == -1) is not supported yet") return end - if windowed then - ConExecute('SC_PrimaryAdapter windowed') - else - ConExecute('SC_PrimaryAdapter ' .. tostring(defaultMode)) - end - - windowedMode = windowed -end - --- String from which to build the various "Move player to slot" labels. -local slotMenuStrings = { - open = "Open", - close = "Close", - closed = "Closed", - occupy = "Occupy", - pm = "Private Message", - remove_to_kik = "Kick Player", - remove_to_observer = "Move Player to Observer", - close_spawn_mex = "Close - spawn mex", - closed_spawn_mex = "Closed - spawn mex", -} -local slotMenuData = { - open = { - host = { - 'close', - 'occupy', - 'ailist', - }, - client = { - 'occupy', - }, - }, - closed = { - host = { - 'open', - }, - client = { - }, - }, - player = { - host = { - 'pm', - 'remove_to_observer', - 'remove_to_kik', - 'move' - }, - client = { - 'pm', - }, - }, - ai = { - host = { - 'remove_to_kik', - 'ailist', - }, - client = { - }, - }, -} - -function GetNumAvailStartSpots() - local numAvailStartSpots = nil - local scenarioInfo = nil - if gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= "") then - scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - end - if scenarioInfo then - local armyTable = MapUtil.GetArmies(scenarioInfo) - if armyTable then - if gameInfo.GameOptions['RandomMap'] == 'Off' then - numAvailStartSpots = table.getn(armyTable) - else - numAvailStartSpots = numberOfPlayers - end - end - else - WARN("Can't assign random start spots, no scenario selected.") - end - return numAvailStartSpots -end - -local function GetSlotMenuData() - if gameInfo.AdaptiveMap then - if not slotMenuData.closed_spawn_mex then - slotMenuData.closed_spawn_mex = { - host = { - 'open', - 'close', - }, - client = { - }, - } - table.insert(slotMenuData.open.host, 2, 'close_spawn_mex') - table.insert(slotMenuData.closed.host, 2, 'close_spawn_mex') - end - else - if slotMenuData.closed_spawn_mex then - slotMenuData.closed_spawn_mex = nil - table.remove(slotMenuData.open.host, 2) - table.remove(slotMenuData.closed.host, 2) - end - end - return slotMenuData -end - -local function GetSlotMenuTables(stateKey, hostKey, slotNum) - local keys = {} - local strings = {} - local tooltips = {} - - if not GetSlotMenuData()[stateKey] then - WARN("Invalid slot menu state selected: " .. tostring(stateKey)) - return nil - end - - if not GetSlotMenuData()[stateKey][hostKey] then - WARN("Invalid slot menu host key selected: " .. tostring(hostKey)) - return nil - end - - local isPlayerReady = false - local localPlayerSlot = FindSlotForID(localPlayerID) - if localPlayerSlot then - if gameInfo.PlayerOptions[localPlayerSlot].Ready then - isPlayerReady = true - end - end - - for index, key in GetSlotMenuData()[stateKey][hostKey] do - if key == 'ailist' then - if slotNum then - for i = 1, numOpenSlots, 1 do - if i ~= slotNum then - table.insert(keys, 'move_player_to_slot' .. i) - table.insert(strings, LOCF("Move AI to slot %s", i)) - table.insert(tooltips, nil) - end - end - end - table.destructiveCat(keys, AIKeys) - table.destructiveCat(strings, AIStrings) - table.destructiveCat(tooltips, AITooltips) - elseif key == 'move' then - -- Generate the "move player to slot X" entries. - for i = 1, numOpenSlots, 1 do - if i ~= slotNum then - table.insert(keys, 'move_player_to_slot' .. i) - table.insert(strings, LOCF("Move Player to slot %s", i)) - table.insert(tooltips, nil) - end - end - else - if not (isPlayerReady and key == 'occupy') then - table.insert(keys, key) - table.insert(strings, slotMenuStrings[key]) - -- Add a tooltip key here if we ever get any interesting options. - table.insert(tooltips, nil) - end - end - end - - return keys, strings, tooltips -end - ---- Get the value of the LastColor, sanitised in case it's an unsafe value. --- In case a new patch removes a color -function GetSanitisedLastColor() - local lastColor = Prefs.GetFromCurrentProfile('LastColorFAF') or 1 - if lastColor > table.getn(gameColors.PlayerColors) or lastColor < 1 then - lastColor = 1 - end - - return lastColor -end - ---- Get the value of the LastFaction, sanitised in case it's an unsafe value. --- --- This means when some retarded mod (*cough*Nomads*cough*) writes a large number to LastFaction, we --- don't catch fire. -function GetSanitisedLastFaction() - local lastFaction = Prefs.GetFromCurrentProfile('LastFaction') or 1 - if lastFaction > table.getn(FactionData.Factions) + 1 or lastFaction < 1 then - lastFaction = 1 - end - - return lastFaction -end - ---- Get a PlayerData object for the local player, configured using data from their profile. -function GetLocalPlayerData() - - local version, gametype, commit = import("/lua/version.lua").GetVersionData() - - return PlayerData( - { - PlayerName = localPlayerName, - OwnerID = localPlayerID, - Human = true, - PlayerColor = GetSanitisedLastColor(), - Faction = GetSanitisedLastFaction(), - PlayerClan = argv.playerClan, - PL = playerRating, - NG = argv.numGames, - MEAN = argv.playerMean, - DEV = argv.playerDeviation, - Country = argv.PrefLanguage, - - Version = version, - GameType = gametype, - Commit = commit, - - Ready = argv.debugLobby, - } -) -end - ---- Compute an estimation of the rating of the given AI. The values originate from 'aitypes.lua' ----@param gameOptions table ----@param aiLobbyProperties AILobbyProperties ----@return number -function ComputeAIRating(gameOptions, aiLobbyProperties) - - if not aiLobbyProperties then - return 0 - end - - if not aiLobbyProperties.rating then - return 0 - end - - if not gameInfo.GameOptions.ScenarioFile then - return 0 - end - - -- try and take into account map - local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - if not (scenarioInfo and scenarioInfo.size and scenarioInfo.size[1] and scenarioInfo.size[2]) then - return 0 - end - - -- clamp the value - local maparea = math.max(scenarioInfo.size[1], scenarioInfo.size[2]) - if maparea < 256 then - maparea = 256 - elseif maparea > 4096 then - maparea = 4096 - end - - -- process various multipliers to determine rating - local mapMultiplier = aiLobbyProperties.ratingMapMultiplier[maparea] or 1.0 - local cheatBuildMultiplier = (tonumber(gameOptions.BuildMult) or 1.0) - 1.0 - local cheatResourceMultiplier = (tonumber(gameOptions.CheatMult) or 1.0) - 1.0 - - -- compute the rating - local cheatBuildValue = (aiLobbyProperties.ratingBuildMultiplier or 0.0) * cheatBuildMultiplier - local cheatResourceValue = (aiLobbyProperties.ratingCheatMultiplier or 0.0) * cheatResourceMultiplier - local cheatOmniValue = (gameOptions.OmniCheat == 'on' and aiLobbyProperties.ratingOmniBonus) or 0.0 - local rating = mapMultiplier * (aiLobbyProperties.rating + cheatBuildValue + cheatResourceValue + cheatOmniValue) - - -- prevent very low numbers - if rating < aiLobbyProperties.ratingNegativeThreshold then - rating = aiLobbyProperties.ratingNegativeThreshold + (rating - aiLobbyProperties.ratingNegativeThreshold) * 0.2 - end - - return math.floor(rating) -end - -function GetAIPlayerData(name, AIPersonality, slot) - local AIColor - -- gets the color of the player/AI occupying the slot directly prior if available - for i = 1, LobbyComm.maxPlayerSlots do - if gameInfo.PlayerOptions[i].StartSpot == slot then - if IsColorFree(gameInfo.PlayerOptions[i].PlayerColor, slot) then - AIColor = gameInfo.PlayerOptions[i].PlayerColor - end - break - end - end - if not AIColor then - AIColor = GetAvailableColor() - end - - -- retrieve properties from AI table - ---@type AILobbyProperties | nil - local aiLobbyProperties = nil - for k, entry in aitypes do - if entry.key == AIPersonality then - aiLobbyProperties = entry - end - end - local iRating = ComputeAIRating(gameInfo.GameOptions, aiLobbyProperties) - - return PlayerData( - { - OwnerID = hostID, - PlayerName = name, - Ready = true, - Human = false, - AIPersonality = AIPersonality, - PlayerColor = AIColor, - ArmyColor = AIColor, - - PL = iRating, - MEAN = iRating, - DEV = 0, - - -- keep track of the AI lobby properties for easier access - AILobbyProperties = aiLobbyProperties, - } -) -end - -local function DoSlotBehavior(slot, key, name) - if key == 'open' then - HostUtils.SetSlotClosed(slot, false) - elseif key == 'close' then - HostUtils.SetSlotClosed(slot, true) - elseif key == 'close_spawn_mex' then - HostUtils.SetSlotClosedSpawnMex(slot) - elseif key == 'occupy' then - if IsPlayer(localPlayerID) and not gameInfo.PlayerOptions[FindSlotForID(localPlayerID)].Ready then - if lobbyComm:IsHost() then - HostUtils.MovePlayerToEmptySlot(FindSlotForID(localPlayerID), slot) - else - lobbyComm:SendData(hostID, {Type = 'MovePlayer', RequestedSlot = slot}) - end - elseif IsObserver(localPlayerID) then - if lobbyComm:IsHost() then - local requestedFaction = GetSanitisedLastFaction() - HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(localPlayerID), slot) - else - lobbyComm:SendData( - hostID, - { - Type = 'RequestConvertToPlayer', - ObserverSlot = FindObserverSlotForID(localPlayerID), - PlayerSlot = slot - } - ) - end - end - UpdateFactionSelector() - elseif key == 'pm' then - if gameInfo.PlayerOptions[slot].Human then - GUI.chatEdit:SetText(string.format("/whisper %s ", gameInfo.PlayerOptions[slot].PlayerName)) - end - -- Handle the various "Move to slot X" options. - elseif string.sub(key, 1, 19) == 'move_player_to_slot' then - HostUtils.SwapPlayers(slot, tonumber(string.sub(key, 20))) - elseif key == 'remove_to_observer' then - local playerInfo = gameInfo.PlayerOptions[slot] - if playerInfo.Human then - HostUtils.ConvertPlayerToObserver(slot) - end - elseif key == 'remove_to_kik' then - if gameInfo.PlayerOptions[slot].Human then - local kickMessage = function(self, str) - local msg - - msg = "\n Kicked by host. \n Reason: " .. str - - SendSystemMessage("lobui_0756", gameInfo.PlayerOptions[slot].PlayerName) - lobbyComm:EjectPeer(gameInfo.PlayerOptions[slot].OwnerID, msg) - - -- Save message for next time - Prefs.SetToCurrentProfile('lastKickMessage', UTF.EscapeString(str)) - lastKickMessage = str - end - - CreateInputDialog(GUI, "Are you sure?", kickMessage, lastKickMessage) - else - HostUtils.RemoveAI(slot) - end - else - -- We're adding an AI of some sort. - if lobbyComm:IsHost() then - HostUtils.AddAI(name, key, slot) - end - end -end - -local function IsModAvailable(modId) - for k,v in availableMods do - if not v[modId] then - return false - end - end - return true -end - - -function Reset() - lobbyComm = false - localPlayerName = "" - gameName = "" - hostID = false - singlePlayer = false - GUI = false - localPlayerID = false - availableMods = {} - selectedUIMods = Mods.GetSelectedUIMods() - selectedSimMods = Mods.GetSelectedSimMods() - numOpenSlots = LobbyComm.maxPlayerSlots - gameInfo = GameInfo.CreateGameInfo(LobbyComm.maxPlayerSlots) -end - ---- Create a new, unconnected lobby. -function ReallyCreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider, over, exitBehavior, playerHasSupcom) - Reset() - - -- Among other things, this clears uimain's override escape handler, allowing our escape - -- handler manager to work. + -- Hand the front-end's escape handler back so ours can take over. MenuCommon.MenuCleanup() - if GUI then - WARN('CreateLobby called twice for UI construction (Should be unreachable)') - GUI:Destroy() - return - end - - -- Make sure we have a profile - if not GetPreference("profile.current") then - Prefs.CreateProfile("FAF_"..desiredPlayerName) - end - - GUI = UIUtil.CreateScreenGroup(over, "CreateLobby ScreenGroup") - - GUI.exitBehavior = exitBehavior - - GUI.optionControls = {} - GUI.slots = {} - - -- Set up the base escape handler first: want this one at the bottom of the stack. - GUI.exitLobbyEscapeHandler = function() - GUI.chatEdit:AbandonFocus() - local quitDialog = UIUtil.QuickDialog(GUI, - "Exit game lobby?", - "", function() - EscapeHandler.PopEscapeHandler() - if HasCommandLineArg("/gpgnet") then - -- Quit to desktop - EscapeHandler.SafeQuit() - else - -- Back to main menu - ReturnToMenu(false) - end - end, - - -- Fight to keep our focus on the chat input box, to prevent keybinding madness. - "", function() - GUI.chatEdit:AcquireFocus() - end, - nil, nil, - true, - {escapeButton = 2, enterButton = 1, worldCover = true} - ) - end - EscapeHandler.PushEscapeHandler(GUI.exitLobbyEscapeHandler) - - GUI.connectdialog = UIUtil.ShowInfoDialog(GUI, Strings.TryingToConnect, Strings.AbortConnect, ReturnToMenu) - -- Prevent the dialog from being closed due to user action. - GUI.connectdialog.OnEscapePressed = function() end - GUI.connectdialog.OnShadowClicked = function() end - - InitLobbyComm(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider) - - -- Store off the validated playername - localPlayerName = lobbyComm:GetLocalPlayerName() - local Prefs = import("/lua/user/prefs.lua") - local windowed = Prefs.GetFromCurrentProfile('WindowedLobby') or 'false' - SetWindowedLobby(windowed == 'true') - -end - --- A map from message types to functions that process particular message types. -local MESSAGE_HANDLERS = { - -- TODO: Finalise signature and semantics. - ConnectivityState = function() - end -} - ---- Handle an incoming message from the FAF client via the GPGNet protocol. --- --- @param jsonBlob A JSON string containing the message to process. --- Messages are JSON strings containing two fields: --- command_id: A string identifying the type of message. This string is used as a key into --- MESSAGE_HANDLERS to find the function to use to process this message. --- arguments: An array of arguments that should be passed to the handler function. -function HandleGPGNetMessage(jsonBlob) - local jsonObj = JSON.decode(jsonBlob) - table.print(jsonObj) - local handler = MESSAGE_HANDLERS[jsonObj.command_id] - if not handler then - WARN("Incomprehensible JSON message: \n" .. jsonBlob) - return - end - - handler(unpack(jsonObj.arguments)) -end + -- Clean slate: drop any residue from a previous lobby session (e.g. a re-host after leaving) + -- before we build this one. Frees everything registered in the session trash. + CustomLobbySession.Teardown() + + -- Models first, then the view (so its components subscribe to a live model), + -- then the lobby object (whose callbacks write the model). + -- TODO: derive SlotCount from the chosen map instead of a fixed default. + CustomLobbyLaunchModel.SetupSingleton() + CustomLobbySessionModel.SetupSingleton(8) + CustomLobbyLocalModel.SetupSingleton() + CustomLobbyInterface.SetupSingleton() + + Instance = InternalCreateLobby( + import("/lua/ui/lobby/customlobby/customlobbyinstance.lua").CustomLobbyInstance, + protocol, localPort, maxConnections, desiredPlayerName, localPlayerUID, natTraversalProvider + ) ---- Start a synchronous replay session --- --- @param replayID The ID of the replay to download and play. -function StartSyncReplaySession(replayID) - SetFrontEndData('syncreplayid', replayID) - local dl = UIUtil.QuickDialog(GetFrame(0), "Downloading the replay file...") - LaunchReplaySession('gpgnet://' .. GetCommandLineArg('/gpgnet',1)[1] .. '/' .. import("/lua/user/prefs.lua").GetFromCurrentProfile('Name')) - dl:Destroy() - UIUtil.QuickDialog(GetFrame(0), "You dont have this map.", "Exit", function() ExitApplication() end) + -- Minimal leave handler so the user isn't trapped. + EscapeHandler.PushEscapeHandler(function() + EscapeHandler.PopEscapeHandler() + if Instance then + Instance:Destroy() + Instance = false + end + -- Free everything registered in the session trash (the map catalog today; the models, + -- interface and instance follow as they are converted to the same pattern). + CustomLobbySession.Teardown() + -- Wipe the network traffic log so the next lobby starts with a clean feed. + CustomLobbyLog.Clear() + if exitBehavior then + exitBehavior() + end + end) end ---- Create a new unconnected lobby/Entry point for processing messages sent from the FAF lobby. --- --- This function is called exactly once by the game when a new lobby should be created. --- @see ReallyCreateLobby --- --- This function is called whenever the FAF lobby sends a message into the game, with the message --- in the desiredPlayerName parameter as a JSON string with a length no greater than 4061 bytes. --- This madness is justified by this being one of the smallish number of functions we can have --- called from outside. --- @see HandleGPGNetMessage --- --- This function is also called by the sync replay server when a session should be started. (this --- should probably be refactored to use the JSON messenger protocol) --- @see StartSyncReplaySession -function CreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider, over, exitBehavior, playerHasSupcom) - -- Is this an incoming GPGNet message? - if localPort == -1 then - HandleGPGNetMessage(desiredPlayerName) - return - end - - -- Special-casing for sync-replay. - -- TODO: Consider replacing this with a gpgnet message type. - if IsSyncReplayServer then - StartSyncReplaySession(localPlayerUID) +--- Hosts the lobby created by `CreateLobby`. +---@param desiredGameName string +---@param scenarioFileName FileName +---@param inSinglePlayer boolean +function HostGame(desiredGameName, scenarioFileName, inSinglePlayer) + if not Instance then return end - - -- Okay, so we actually are creating a lobby, instead of doing some ridiculous hack. - ReallyCreateLobby(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider, over, exitBehavior, playerHasSupcom) -end - --- create the lobby as a host -function HostGame(desiredGameName, scenarioFileName, inSinglePlayer) - singlePlayer = inSinglePlayer - gameName = lobbyComm:MakeValidGameName(desiredGameName) - lobbyComm.desiredScenario = string.gsub(scenarioFileName, ".v%d%d%d%d_scenario.lua", "_scenario.lua") - lobbyComm:HostGame() + local scenario = string.gsub(scenarioFileName, ".v%d%d%d%d_scenario.lua", "_scenario.lua") --[[@as FileName]] + CustomLobbyLaunchModel.SetScenario(CustomLobbyLaunchModel.GetSingleton(), scenario) + Instance:HostGame() end --- join an already existing lobby +--- Joins the lobby created by `CreateLobby`. +---@param address GPGNetAddress +---@param asObserver boolean +---@param playerName? string +---@param uid? UILobbyPeerId function JoinGame(address, asObserver, playerName, uid) - lobbyComm:JoinGame(address, playerName, uid) -end - -function ConnectToPeer(addressAndPort,name,uid) - if not string.find(addressAndPort, '127.0.0.1') then - LOG("ConnectToPeer (name=" .. name .. ", uid=" .. uid .. ", address=" .. addressAndPort ..")") - else - DisconnectFromPeer(uid) - LOG("ConnectToPeer (name=" .. name .. ", uid=" .. uid .. ", address=" .. addressAndPort ..", USE PROXY)") - table.insert(ConnectedWithProxy, uid) - end - lobbyComm:ConnectToPeer(addressAndPort,name,uid) -end - -function DisconnectFromPeer(uid) - LOG("DisconnectFromPeer (uid=" .. uid ..")") - if wasConnected(uid) then - table.remove(connectedTo, uid) - end - GpgNetSend('Disconnected', string.format("%d", uid)) - lobbyComm:DisconnectFromPeer(uid) -end - -function SetHasSupcom(cmd) - -- TODO: Refactor SyncReplayServer gubbins to use generalised JSON protocol. - if IsSyncReplayServer then - if cmd == 0 then - SessionResume() - elseif cmd == 1 then - SessionRequestPause() - end - end -end - -function SetHasForgedAlliance(speed) - if IsSyncReplayServer then - if GetGameSpeed() ~= speed then - SetGameSpeed(speed) - end - end -end - --- TODO: These functions are dumb. We have these things called "hashmaps". -function FindSlotForID(id) - for k, player in gameInfo.PlayerOptions:pairs() do - if player.OwnerID == id and player.Human then - return k - end - end - return nil -end - -function FindRehostSlotForID(id) - for index, player in ipairs(rehostPlayerOptions) do - if player.OwnerID == id and player.Human then - return player.StartSpot - end + if Instance then + Instance:JoinGame(address, playerName, uid) end - return nil end -function FindNameForID(id) - if (IsObserver(id)) then - return (FindObserverNameForID(id)) - end - - for k, player in gameInfo.PlayerOptions:pairs() do - if player.OwnerID == id and player.Human then - return player.PlayerName - end - end - return nil -end - -function FindIDForName(name) - for k, player in gameInfo.PlayerOptions:pairs() do - if player.PlayerName == name and player.Human then - return player.OwnerID - end - end - return nil -end - -function FindObserverSlotForID(id) - for k, observer in gameInfo.Observers:pairs() do - if observer.OwnerID == id then - return k - end - end - - return nil -end - -function FindObserverNameForID(id) - for k, observer in gameInfo.Observers:pairs() do - if observer.OwnerID == id then - return observer.PlayerName - end - end - return nil -end - -function IsLocallyOwned(slot) - return gameInfo.PlayerOptions[slot].OwnerID == localPlayerID -end - -function IsPlayer(id) - return FindSlotForID(id) ~= nil -end - -function IsObserver(id) - return FindObserverSlotForID(id) ~= nil -end - -function UpdateSlotBackground(slotIndex) - if gameInfo.ClosedSlots[slotIndex] then - GUI.slots[slotIndex].SlotBackground:SetTexture(UIUtil.UIFile('/SLOT/slot-dis.dds')) - else - if gameInfo.PlayerOptions[slotIndex] then - GUI.slots[slotIndex].SlotBackground:SetTexture(UIUtil.UIFile('/SLOT/slot-player.dds')) - else - GUI.slots[slotIndex].SlotBackground:SetTexture(UIUtil.UIFile('/SLOT/slot-player_other.dds')) - end - end -end - -function GetPlayerDisplayName(playerInfo) - local playerName = playerInfo.PlayerName - local displayName = "" - if playerInfo.PlayerClan ~= "" then - return string.format("[%s] %s", playerInfo.PlayerClan, playerInfo.PlayerName) - else - return playerInfo.PlayerName - end -end - --- Refresh (with a sledgehammer) all the items in the observer list. -local function refreshObserverList() - GUI.observerList:DeleteAllItems() - - -- create the table that will hold the data for displaying team rating information - local teamRatings = {} - local numTeams = 0 - -- calculate/display team ratings if spawns are fixed - if gameInfo.GameOptions['TeamSpawn'] == 'fixed' then - - -- cycle through each player - for i, player in gameInfo.PlayerOptions:pairs() do - - -- get the team number (which is 1 higher on the backend) - local team = player.Team - 1 - -- add the player's rating information if the player is on a team - if team > 0 then - -- make sure the team is included in the teamRatings table - if teamRatings[team] == nil then - -- initialize the team's rating in this table as having 0 mean and 0 deviation, respectively - teamRatings[team] = {0, 0} - end - -- add the player's rating information (mean and deviation) to the its team's totals - teamRatings[team] = {teamRatings[team][1] + player.MEAN, teamRatings[team][2] + player.DEV} - end - end - - for i, team in teamRatings do - numTeams = numTeams + 1 - end - - -- if there are 1 or 2 teams, list them before observers - if numTeams == 1 or numTeams == 2 then - if not lobbyComm:IsHost() then - GUI.observerList:AddItem(LOC('Team Ratings:')) - end - for i, rating in teamRatings do - GUI.observerList:AddItem( - LOCF('Team %d: %d (%d+/-%d)', i, math.round(rating[1] - rating[2] * 3), math.round(rating[1]), math.round(rating[2] * 3)) - ) - end - if not lobbyComm:IsHost() then - GUI.observerList:AddItem('') - end - end - end - - - local observers = false - - for slot, observer in gameInfo.Observers:pairs() do - - if not observers then - observers = true - if not lobbyComm:IsHost() then - GUI.observerList:AddItem(LOC('Observers')..':') - end - end - - observer.ObserverListIndex = GUI.observerList:GetItemCount() -- Pin-head William made this zero-based - - -- Create a label for this observer of the form: - -- PlayerName (R:xxx, P:xxx, C:xxx) - -- Such conciseness is necessary as the field in the UI is rather narrow... - local observer_label = observer.PlayerName .. " (R:" .. observer.PL - - -- Add the ping only if this entry refers to a different client. - if observer and (observer.OwnerID ~= localPlayerID) and observer.ObserverListIndex then - local peer = lobbyComm:GetPeer(observer.OwnerID) - - local ping = 0 - if peer.ping ~= nil then - ping = math.floor(peer.ping) - end - - observer_label = observer_label .. ", P:" .. ping - end - - -- Add the CPU score if one is available. - local score_CPU = CPU_Benchmarks[observer.PlayerName] - if score_CPU then - observer_label = observer_label .. ", C:" .. score_CPU - end - observer_label = observer_label .. ")" - - GUI.observerList:AddItem(observer_label) - end - - -- if there are more than 2 teams (and slots are fixed), list them after observers - if numTeams > 2 then - if not lobbyComm:IsHost() then - GUI.observerList:AddItem('') - GUI.observerList:AddItem(LOC('Team Ratings:')) - end - for i, rating in teamRatings do - GUI.observerList:AddItem( - LOCF('Team %d: %d (%d+/-%d)', i, math.round(rating[1] - rating[2] * 3), math.round(rating[1]), math.round(rating[2] * 3)) - ) - end +--- Called by the engine. +function ConnectToPeer(addressAndPort, name, uid) + if Instance then + Instance:ConnectToPeer(addressAndPort, name, uid) end end -local WVT = import("/lua/ui/lobby/data/watchedvalue/watchedvaluetable.lua") - --- update the data in a player slot --- TODO: With lazyvars, this function should be eliminated. Lazy-value-callbacks should be used --- instead to incrementaly update things. -function SetSlotInfo(slotNum, playerInfo) - -- Remove the ConnectDialog. It probably makes more sense to do this when we get the game state. - if GUI.connectdialog then - GUI.connectdialog:Close() - GUI.connectdialog = nil - - -- ChangelogDialog, if necessary. - local changelogDialogManager = import("/lua/ui/lobby/changelog/changelogdialog.lua") - if changelogDialogManager.ShouldOpenChangelog() then - changelogDialogManager.CreateChangelogDialog(GetFrame(0)) - end +--- Called by the engine. +function DisconnectFromPeer(uid) + if Instance then + Instance:DisconnectFromPeer(uid) end - - playerInfo.StartSpot = slotNum - - local slot = GUI.slots[slotNum] - local isHost = lobbyComm:IsHost() - local isLocallyOwned = IsLocallyOwned(slotNum) - - -- Set enabledness of controls according to host privelage etc. - -- Yeah, we set it twice. No, it's not brilliant. Blurgh. - local facColEnabled = isLocallyOwned or (isHost and not playerInfo.Human) - UIUtil.setEnabled(slot.faction, facColEnabled) - UIUtil.setEnabled(slot.color, facColEnabled) - - -- Possibly override it due to the ready box. - if isLocallyOwned then - if playerInfo.Ready and playerInfo.Human then - DisableSlot(slotNum, true) - else - EnableSlot(slotNum) - end - else - DisableSlot(slotNum) - end - - --- Returns true if the team selector for this slot should be enabled. - -- - -- The predicate was getting unpleasantly long to read. - local function teamSelectionEnabled(autoTeams, ready, locallyOwned, isHost) - -- If autoteams has control, no selector for you. - if autoTeams ~= 'none' then - return false - end - - if isHost and not playerInfo.Human then - return true - end - - -- You can control your own one when you're not ready. - if locallyOwned then - return not ready - end - - if isHost then - -- The host can control the team of others, provided he's not ready himself. - local slot = FindSlotForID(localPlayerID) - local is_ready = slot and gameInfo.PlayerOptions[slot].Ready -- could be observer - - return not is_ready - end - end - - -- Disable team selection if "auto teams" is controlling it. Moderatelty ick. - local autoTeams = gameInfo.GameOptions.AutoTeams - UIUtil.setEnabled(slot.team, teamSelectionEnabled(autoTeams, playerInfo.Ready, isLocallyOwned, isHost)) - - local hostKey - if isHost then - hostKey = 'host' - else - hostKey = 'client' - end - - -- These states are used to select the appropriate strings with GetSlotMenuTables. - local slotState - if not playerInfo.Human then - slot.ratingText:Hide() - slotState = 'ai' - elseif not isLocallyOwned then - slotState = 'player' - else - slotState = nil - end - - slot.name:ClearItems() - - if slotState then - slot.name:Enable() - local slotKeys, slotStrings, slotTooltips = GetSlotMenuTables(slotState, hostKey, slotNum) - slot.name.slotKeys = slotKeys - - if not table.empty(slotKeys) then - slot.name:AddItems(slotStrings) - slot.name:Enable() - Tooltip.AddComboTooltip(slot.name, slotTooltips) - else - slot.name.slotKeys = nil - slot.name:Disable() - Tooltip.RemoveComboTooltip(slot.name) - end - else - -- no slotState indicate this must be ourself, and you can't do anything to yourself - slot.name.slotKeys = nil - slot.name:Disable() - end - - slot.ratingText:Show() - slot.ratingText:SetText(playerInfo.PL) - slot.ratingText:SetColor("ffffffff") - - -- dynamic tooltip to show rating and deviation for each player - local tooltipText = {} - tooltipText['text'] = LOC("Rating") - tooltipText['body'] = LOCF("%s's TrueSkill Rating is %s +/- %s", playerInfo.PlayerName, math.round(playerInfo.MEAN), math.ceil(playerInfo.DEV * 3)) - slot.tooltiprating = Tooltip.AddControlTooltip(slot.ratingText, tooltipText) - - slot.numGamesText:Show() - slot.numGamesText:SetText(playerInfo.NG) - - slot.name:Show() - -- Change name colour according to the state of the slot. - if slotState == 'ai' then - slot.name:SetTitleTextColor("dbdbb9") -- Beige Color for AI - slot.name._text:SetFont('Arial Gras', 12) - elseif FindSlotForID(hostID) == slotNum then - slot.name:SetTitleTextColor("ffc726") -- Orange Color for Host - slot.name._text:SetFont('Arial Gras', 15) - elseif slotState == 'player' then - slot.name:SetTitleTextColor("64d264") -- Green Color for Players - slot.name._text:SetFont('Arial Gras', 15) - elseif isLocallyOwned then - slot.name:SetTitleTextColor("6363d2") -- Blue Color for You - slot.name._text:SetFont('Arial Gras', 15) - else - slot.name:SetTitleTextColor(UIUtil.fontColor) -- Normal Color for Other - slot.name._text:SetFont('Arial Gras', 12) - end - - local playerName = playerInfo.PlayerName - if wasConnected(playerInfo.OwnerID) or isLocallyOwned or not playerInfo.Human then - slot.name:SetTitleText(GetPlayerDisplayName(playerInfo)) - slot.name._text:SetFont('Arial Gras', 15) - if not table.find(ConnectionEstablished, playerName) then - if playerInfo.Human and not isLocallyOwned then - AddChatText(LOCF("Connection to %s established.", playerName)) - - table.insert(ConnectionEstablished, playerName) - for k, v in CurrentConnection do - if v == playerName then - CurrentConnection[k] = nil - break - end - end - end - end - else - slot.name:SetTitleText(LOCF('Connecting to %s...', playerName)) - slot.name._text:SetFont('Arial Gras', 11) - end - - slot.faction:Show() - - -- Check if faction is possible for that slot, if not set to random - -- For example: AIs always start with faction 5, so that needs to be adjusted to fit in slot.Faction - if table.getn(slot.AvailableFactions) < playerInfo.Faction then - playerInfo.Faction = table.getn(slot.AvailableFactions) - end - slot.faction:SetItem(playerInfo.Faction) - - slot.color:Show() - Check_Availaible_Color(slotNum) - - slot.team:Show() - slot.team:SetItem(playerInfo.Team) - - -- Send team data to the server - if isHost then - HostUtils.SendPlayerSettingsToServer(slotNum) - end - - UIUtil.setVisible(slot.ready, playerInfo.Human and not singlePlayer) - slot.ready:SetCheck(playerInfo.Ready, true) - - if isLocallyOwned and playerInfo.Human then - Prefs.SetToCurrentProfile('LastColorFAF', playerInfo.PlayerColor) - Prefs.SetToCurrentProfile('LastFaction', playerInfo.Faction) - end - - -- Show the player's nationality - if not playerInfo.Country then - slot.KinderCountry:Hide() - else - slot.KinderCountry:Show() - slot.KinderCountry:SetTexture(UIUtil.UIFile('/countries/'..playerInfo.Country..'.dds')) - - Tooltip.AddControlTooltip(slot.KinderCountry, {text=LOC("Country"), body=LOC(CountryTooltips[playerInfo.Country])}) - end - - UpdateSlotBackground(slotNum) - - -- Set the CPU bar - SetSlotCPUBar(slotNum, playerInfo) - - ShowGameQuality() - RefreshMapPositionForAllControls(slotNum) - - if isHost then - HostUtils.RefreshButtonEnabledness() - end - refreshObserverList() -end - -function ClearSlotInfo(slotIndex) - local slot = GUI.slots[slotIndex] - - local hostKey - if lobbyComm:IsHost() then - GpgNetSend('ClearSlot', slotIndex) - hostKey = 'host' - else - hostKey = 'client' - end - - local stateKey - local stateText - if gameInfo.ClosedSlots[slotIndex] and gameInfo.SpawnMex[slotIndex] and gameInfo.AdaptiveMap then - stateKey = 'closed_spawn_mex' - stateText = slotMenuStrings.closed_spawn_mex - elseif gameInfo.ClosedSlots[slotIndex] then - gameInfo.SpawnMex[slotIndex] = false - stateKey = 'closed' - stateText = slotMenuStrings.closed - else - stateKey = 'open' - stateText = slotMenuStrings.open - end - - local slotKeys, slotStrings, slotTooltips = GetSlotMenuTables(stateKey, hostKey) - - -- set the text appropriately - slot.name:ClearItems() - slot.name:SetTitleText(LOC(stateText)) - if not table.empty(slotKeys) then - slot.name.slotKeys = slotKeys - slot.name:AddItems(slotStrings) - Tooltip.AddComboTooltip(slot.name, slotTooltips) - slot.name:Enable() - else - slot.name.slotKeys = nil - slot.name:Disable() - Tooltip.RemoveComboTooltip(slot.name) - end - - slot.name._text:SetFont('Arial Gras', 12) - if stateKey == 'closed' then - slot.name:SetTitleTextColor("Crimson") - elseif stateKey == 'closed_spawn_mex' then - slot.name:SetTitleTextColor("2c7f33") - else - slot.name:SetTitleTextColor('B9BFB9') - end - - slot:HideControls() - - UpdateSlotBackground(slotIndex) - ShowGameQuality() - RefreshMapPositionForAllControls(slotIndex) - Check_Availaible_Color() - refreshObserverList() -end - -function IsColorFree(colorIndex, currentSlotNumber) - for id, player in gameInfo.PlayerOptions:pairs() do - if player.PlayerColor == colorIndex then - if currentSlotNumber then - if player.StartSpot != currentSlotNumber then - return false - end - else - return false - end - end - end - - return true -end - -function GetPlayerCount() - local numPlayers = 0 - for k,player in gameInfo.PlayerOptions:pairs() do - if player then - numPlayers = numPlayers + 1 - end - end - return numPlayers -end - -local function GetPlayersNotReady() - local notReady = false - for k,v in gameInfo.PlayerOptions:pairs() do - if v.Human and not v.Ready then - if not notReady then - notReady = {} - end - table.insert(notReady, v.PlayerName) - end - end - - return notReady -end - -local function GetRandomFactionIndex(slotNumber) - local randomfaction = nil - local counter = 50 - while counter > 0 do - counter = (counter - 1) - randomfaction = math.random(1, table.getn(GUI.slots[slotNumber].AvailableFactions) - 1) - end - return randomfaction -end - -local function AssignRandomFactions() - for index, player in gameInfo.PlayerOptions do - -- No random if there is only 1 option - if table.getn(GUI.slots[index].AvailableFactions) >= 2 then - local randomFactionID = table.getn(GUI.slots[index].AvailableFactions) - -- note that this doesn't need to be aware if player has supcom or not since they would only be able to select - -- the random faction ID if they have supcom - if player.Faction >= randomFactionID then - player.Faction = GetRandomFactionIndex(index) - end - end - end -end - --- Convert the local (slot dependend) faction indexes to the global faction indexes -local function FixFactionIndexes() - for index, player in gameInfo.PlayerOptions do - local playerFaction = GUI.slots[index].AvailableFactions[player.Faction] - for i,v in allAvailableFactionsList do - if v == playerFaction then - player.Faction = i - continue - end - end - end - -end - ---------------------------- --- autobalance functions -- ---------------------------- -local function team_sort_by_sum(t1, t2) - return t1['sum'] < t2['sum'] -end - -local function autobalance_bestworst(players, teams_arg) - local players = table.deepcopy(players) - local result = {} - local best = true - local teams = {} - - for t, slots in teams_arg do - table.insert(teams, {team=t, slots=table.deepcopy(slots), sum=0}) - end - - -- teams first picks best player and then worst player, repeat - while not table.empty(players) do - for i, t in teams do - local team = t['team'] - local slots = t['slots'] - local slot = table.remove(slots, 1) - if not slot then continue end - local player - - if best then - player = table.remove(players, 1) - else - player = table.remove(players) - end - - if not player then break end - - teams[i]['sum'] = teams[i]['sum'] + player['rating'] - table.insert(result, {player=player['pos'], rating=player['rating'], team=team, slot=slot}) - end - - best = not best - if best then - table.sort(teams, team_sort_by_sum) - end - end - - return result -end - -local function autobalance_avg(players, teams_arg) - local players = table.deepcopy(players) - local result = {} - local teams = {} - local max_sum = 0 - - for t, slots in teams_arg do - table.insert(teams, {team=t, slots=table.deepcopy(slots), sum=0}) - end - - while not table.empty(players) do - local first_team = true - for i, t in teams do - local team = t['team'] - local slots = t['slots'] - local slot = table.remove(slots, 1) - if not slot then continue end - local player - local player_key - - for j, p in players do - player_key = j - if first_team or t['sum'] + p['rating'] <= max_sum then - break - end - end - - player = table.remove(players, player_key) - if not player then break end - - teams[i]['sum'] = teams[i]['sum'] + player['rating'] - max_sum = math.max(max_sum, teams[i]['sum']) - table.insert(result, {player=player['pos'], rating=player['rating'], team=team, slot=slot}) - first_team = false - end - - table.sort(teams, team_sort_by_sum) - end - - return result -end - -local function autobalance_rr(players, teams) - local players = table.deepcopy(players) - local teams = table.deepcopy(teams) - local result = {} - - local team_picks = {} - local i = 1 - for team, slots in teams do - table.insert(team_picks, {team=team, sum=i}) - i = i + 1 - end - - while not table.empty(players) do - for i, pick in team_picks do - local slot = table.remove(teams[pick.team], 1) - if not slot then continue end - local player = table.remove(players, 1) - if not player then break end - pick.sum = pick.sum + i - - table.insert(result, {player=player.pos, rating=player.rating, team=pick.team, slot=slot}) - end - - table.sort(team_picks, function(a, b) return a.sum > b.sum end) - end - - return result -end - -local function autobalance_random(players, teams_arg) - local players = table.deepcopy(players) - local result = {} - local teams = {} - - players = table.shuffle(players) - - for t, slots in teams_arg do - table.insert(teams, {team=t, slots=table.deepcopy(slots)}) - end - - while not table.empty(players) do - for _, t in teams do - local team = t['team'] - local slot = table.remove(t['slots'], 1) - if not slot then continue end - local player = table.remove(players, 1) - - if not player then break end - - table.insert(result, {player=player['pos'], rating=player['rating'], team=team, slot=slot}) - end - end - - return result -end - -function autobalance_quality(players) - local teams = nil - local quality = 0 - - for _, p in players do - local i = p['player'] - local team = p['team'] - local playerInfo = gameInfo.PlayerOptions[i] - local player = Player.create(playerInfo.PlayerName, - Rating.create(playerInfo.MEAN or 1500, playerInfo.DEV or 500)) - - if not teams then - teams = Teams.create() - end - - teams:addPlayer(team, player) - end - - if teams and table.getn(teams:getTeams()) > 1 then - quality = Trueskill.computeQuality(teams) - end - - return quality -end - ---- If the game is full, GPGNetSend about it so the client can do a fancy popup if it has focus. -function PossiblyAnnounceGameFull() - -- Search for an empty non-closed slot. - for i = 1, numOpenSlots do - if not gameInfo.ClosedSlots[i] then - if not gameInfo.PlayerOptions[i] then - return - end - end - end - - -- Game is full, let's tell the client. - GpgNetSend("GameFull") -end - -local function AssignRandomStartSpots() - local teamSpawn = gameInfo.GameOptions['TeamSpawn'] - - if teamSpawn == 'fixed' or teamSpawn == 'penguin_autobalance' then - return - end - - function teamsAddSpot(teams, team, spot) - if not teams[team] then - teams[team] = {} - end - table.insert(teams[team], spot) - end - - -- rearrange players according to the provided setup - function rearrangePlayers(data) - gameInfo.GameOptions['Quality'] = data.quality - - -- Copy a reference to each of the PlayerData objects indexed by their original slots. - local orgPlayerOptions = {} - for k, p in gameInfo.PlayerOptions do - orgPlayerOptions[k] = p - end - - local mirrored = string.find(teamSpawn, 'mirrored') - if mirrored then - local rating_cmp = function(a,b) return a.rating > b.rating end - local slot_cmp = function(a,b) return a.slot < b.slot end - - function getMasterOrder(sortedSlots) - local masterOrder = {} - - local slot2nr = {} - for k, p in sortedSlots.byNr do - slot2nr[p.slot] = k - end - - for k, p in sortedSlots.byRating do - table.insert(masterOrder, slot2nr[p.slot]) - end - - return masterOrder - end - - function teamsSameSize(slots) - local size - - for t, sorted in slots do - local s = table.getn(sorted.byNr) - if not size then size = s end - - if size ~= s then return false end - end - - return true - end - - function reorderSlots(sortedSlots, masterOrder) - local newSlots = {} - for i, j in masterOrder do - table.insert(newSlots, sortedSlots.byNr[j].slot) - end - - for i, s in newSlots do - sortedSlots.byRating[i].slot = s - end - end - - local slots = {} - local masterTeam - - for _, p in data.setup do - if not slots[p.team] then slots[p.team] = {} end - if not slots[p.team].byNr then slots[p.team].byNr = {} end - if not slots[p.team].byRating then slots[p.team].byRating = {} end - if not masterTeam then masterTeam = p.team end - - table.binsert(slots[p.team].byNr, p, slot_cmp) - table.binsert(slots[p.team].byRating, p, rating_cmp) - end - - -- abort mirroring if team sizes differ - if not teamsSameSize(slots) then - WARN("Mirroring disabled due to teams not having the same number of players") - else - local masterOrder = getMasterOrder(slots[masterTeam]) - for t, sorted in slots do - reorderSlots(sorted, masterOrder) - end - end - end - - -- Rearrange the players in the slots to match the chosen configuration. The result object - -- maps old slots to new slots, and we use orgPlayerOptions to avoid losing a reference to - -- an object (and because swapping is too much like hard work). - gameInfo.PlayerOptions = {} - for _, r in data.setup do - local playerOptions = orgPlayerOptions[r.player] - playerOptions.Team = r.team + 1 - playerOptions.StartSpot = r.slot - gameInfo.PlayerOptions[r.slot] = playerOptions - - -- Send team data to the server - local playerInfo = gameInfo.PlayerOptions[r.slot] - HostUtils.SendPlayerSettingsToServer(r.slot) - end - end - - local numAvailStartSpots = GetNumAvailStartSpots() - - local AutoTeams = gameInfo.GameOptions.AutoTeams - local positionGroups = {} - local teams = {} - - -- Used to actualise the virtual teams produced by the "Team -" no-team team. - local synthesizedTeamCounter = 9 - for i = 1, numAvailStartSpots do - if not gameInfo.ClosedSlots[i] then - local team = nil - local group = nil - - if AutoTeams == 'lvsr' then - local midLine = GUI.mapView.Left() + (GUI.mapView.Width() / 2) - local markerPos = GUI.mapView.startPositions[i].Left() - - if markerPos < midLine then - team = 2 - else - team = 3 - end - elseif AutoTeams == 'tvsb' then - local midLine = GUI.mapView.Top() + (GUI.mapView.Height() / 2) - local markerPos = GUI.mapView.startPositions[i].Top() - - if markerPos < midLine then - team = 2 - else - team = 3 - end - elseif AutoTeams == 'pvsi' then - if math.mod(i, 2) ~= 0 then - team = 2 - else - team = 3 - end - elseif AutoTeams == 'manual' then - team = gameInfo.AutoTeams[i] - else -- none - team = gameInfo.PlayerOptions[i].Team - group = 1 - end - - group = group or team - if not positionGroups[group] then - positionGroups[group] = {} - end - table.insert(positionGroups[group], i) - - if team ~= nil then - -- Team 1 secretly represents "No team", so give them a real team (but one that - -- nobody else can possibly have) - if team == 1 then - team = synthesizedTeamCounter - synthesizedTeamCounter = synthesizedTeamCounter + 1 - end - teamsAddSpot(teams, team, i) - end - end - end - gameInfo.GameOptions.RandomPositionGroups = positionGroups - - -- shuffle the array for randomness. - for i, team in teams do - teams[i] = table.shuffle(team) - end - teams = table.shuffle(teams) - - local ratingTable = {} - for i = 1, numAvailStartSpots do - local playerInfo = gameInfo.PlayerOptions[i] - if playerInfo then - table.insert(ratingTable, { pos=i, rating = playerInfo.MEAN - playerInfo.DEV * 3 }) - end - end - - if teamSpawn == 'random' or teamSpawn == 'random_reveal' then - s = autobalance_random(ratingTable, teams) - q = autobalance_quality(s) - rearrangePlayers{setup=s, quality=q} - return - end - - ratingTable = table.shuffle(ratingTable) -- random order for people with same rating - table.sort(ratingTable, function(a, b) return a['rating'] > b['rating'] end) - - local setups = {} - local functions = { - rr=autobalance_rr, - bestworst=autobalance_bestworst, - avg=autobalance_avg, - } - - local cmp = function(a, b) return a.quality > b.quality end - local s, q - for fname, f in functions do - s = f(ratingTable, teams) - if s then - q = autobalance_quality(s) - table.binsert(setups, {setup=s, quality=q}, cmp) - end - end - - local n_random = 0 - local frac = (teamSpawn == 'balanced_flex' or teamSpawn == 'balanced_flex_reveal') and 0.95 or 1 - -- add 100 random compositions and keep 3 with at least of best quality - for i=1, 100 do - s = autobalance_random(ratingTable, teams) - q = autobalance_quality(s) - - if q > setups[1].quality * frac then - table.binsert(setups, {setup=s, quality=q}, cmp) - n_random = n_random + 1 - if n_random > 2 then break end - end - end - - if teamSpawn == 'balanced_flex' or teamSpawn == 'balanced_flex_reveal' then - setups = table.shuffle(setups) - end - - best = table.remove(setups, 1) - rearrangePlayers(best) -end - - -local function AssignAutoTeams() - -- A function to take a player index and return the team they should be on. - local getTeam - if gameInfo.GameOptions.AutoTeams == 'lvsr' then - local midLine = GUI.mapView.Left() + (GUI.mapView.Width() / 2) - local startPositions = GUI.mapView.startPositions - - getTeam = function(playerIndex) - local markerPos = startPositions[playerIndex].Left() - if markerPos < midLine then - return 2 - else - return 3 - end - end - elseif gameInfo.GameOptions.AutoTeams == 'tvsb' then - local midLine = GUI.mapView.Top() + (GUI.mapView.Height() / 2) - local startPositions = GUI.mapView.startPositions - - getTeam = function(playerIndex) - local markerPos = startPositions[playerIndex].Top() - if markerPos < midLine then - return 2 - else - return 3 - end - end - elseif gameInfo.GameOptions.AutoTeams == 'pvsi' or gameInfo.GameOptions['RandomMap'] ~= 'Off' then - getTeam = function(playerIndex) - if math.mod(playerIndex, 2) ~= 0 then - return 2 - else - return 3 - end - end - elseif gameInfo.GameOptions.AutoTeams == 'manual' then - getTeam = function(playerIndex) - return gameInfo.AutoTeams[playerIndex] or 1 - end - else - return - end - - for i = 1, LobbyComm.maxPlayerSlots do - if not gameInfo.ClosedSlots[i] and gameInfo.PlayerOptions[i] then - local correctTeam = getTeam(i) - if gameInfo.PlayerOptions[i].Team ~= correctTeam then - SetPlayerOption(i, "Team", correctTeam, true) - SetSlotInfo(i, gameInfo.PlayerOptions[i]) - end - end - end -end - -local function AssignAINames() - local aiNames = import("/lua/ui/lobby/ainames.lua").ainames - local nameSlotsTaken = {} - for index, faction in FactionData.Factions do - nameSlotsTaken[index] = {} - end - for index, player in gameInfo.PlayerOptions do - if not player.Human then - local playerFaction = player.Faction - local factionNames = aiNames[FactionData.Factions[playerFaction].Key] - local ranNum - repeat - ranNum = math.random(1, table.getn(factionNames)) - until nameSlotsTaken[playerFaction][ranNum] == nil - nameSlotsTaken[playerFaction][ranNum] = true - player.PlayerName = factionNames[ranNum] .. " (" .. player.PlayerName .. ")" - end - end -end - - --- call this whenever the lobby needs to exit and not go in to the game -function ReturnToMenu(reconnect) - if lobbyComm then - lobbyComm:Destroy() - lobbyComm = false - end - - local exitfn = GUI.exitBehavior - - GUI:Destroy() - GUI = false - - if not reconnect then - exitfn() - else - local ipnumber = GetCommandLineArg("/joincustom", 1)[1] - import("/lua/ui/uimain.lua").StartJoinLobbyUI("UDP", ipnumber, localPlayerName) - end -end - -function PrintSystemMessage(id, parameters) - AddChatText(LOCF("Unknown system message. Check localisation file", unpack(parameters))) -end - -function SendSystemMessage(id, ...) - local data = { - Type = "SystemMessage", - Id = id, - Args = arg - } - - lobbyComm:BroadcastData(data) - PrintSystemMessage(id, arg) -end - -function SendPersonalSystemMessage(targetID, id, ...) - if targetID ~= localPlayerID then - local data = { - Type = "SystemMessage", - Id = id, - Args = arg - } - - lobbyComm:SendData(targetID, data) - end -end - -function PublicChat(text) - lobbyComm:BroadcastData( - { - Type = "PublicChat", - Text = text, - } - ) - AddChatText(text, localPlayerID, true) -end - -function PrivateChat(targetID,text) - if targetID ~= localPlayerID then - lobbyComm:SendData( - targetID, - { - Type = 'PrivateChat', - Text = text, - } - ) - end - local targetName = FindNameForID(targetID) - if targetName then - AddChatText("<<"..LOCF("To %s", targetName)..">> " .. text) - end -end - -function UpdateAvailableSlots(numAvailStartSpots, scenario) - if numAvailStartSpots > LobbyComm.maxPlayerSlots then - WARN("Lobby requests " .. numAvailStartSpots .. " but there are only " .. LobbyComm.maxPlayerSlots .. " available") - end - - for i = 1, numAvailStartSpots do - local availableFactionsForSpotI = FACTION_NAMES - if scenario.Configurations.standard.factions then - availableFactionsForSpotI = scenario.Configurations.standard.factions[i] - end - - local factionBmps = {} - local factionTooltips = {} - local factionList = {} - for index, factionKey in availableFactionsForSpotI do - for _, tbl in FactionData.Factions do - if factionKey == tbl.Key then - factionBmps[index] = tbl.SmallIcon - factionTooltips[index] = tbl.TooltipID - factionList[index] = tbl.Key - break - end - end - end - if table.getn(factionBmps) > 1 then - table.insert(factionBmps, "/faction_icon-sm/random_ico.dds") - table.insert(factionTooltips, 'lob_random') - table.insert(factionList, 'random') - end - - local oldAvailableFactions = GUI.slots[i].AvailableFactions - GUI.slots[i].AvailableFactions = factionList - - local diff = table.getn(factionList) ~= table.getn(oldAvailableFactions) - for k = 1,table.getn(factionList) do - if oldAvailableFactions[k] ~= factionList[k] then - diff = true - break - end - end - if not diff then - continue - end - - GUI.slots[i].faction:ChangeBitmapArray(factionBmps) - Tooltip.AddComboTooltip(GUI.slots[i].faction, factionTooltips) - - if gameInfo.PlayerOptions[i] then - local playerFactionIndex = table.getn(factionList) - for index,key in factionList do - if key == oldAvailableFactions[gameInfo.PlayerOptions[i].Faction] then - playerFactionIndex = index - break - end - end - if FindSlotForID(localPlayerID) == i then - local fact = factionList[playerFactionIndex] - for index,value in allAvailableFactionsList do - if fact == value then - GUI.factionSelector:SetSelected(index) - break - end - end - UpdateFactionSelector() - else - GUI.slots[i].faction:SetItem(playerFactionIndex) - gameInfo.PlayerOptions[i].Faction = playerFactionIndex - end - end - end - - -- if number of available slots has changed, update it - if gameInfo.firstUpdateAvailableSlotsDone and numOpenSlots == numAvailStartSpots then - -- Remove closed_spawn_mex if necessary - if not gameInfo.AdaptiveMap then - for i = 1, numAvailStartSpots do - if gameInfo.ClosedSlots[i] and gameInfo.SpawnMex[i] then - ClearSlotInfo(i) - gameInfo.SpawnMex[i] = nil - end - end - end - return - end - - -- reopen slots in case the new map has more startpositions then the previous map. - if numOpenSlots < numAvailStartSpots then - for i = numOpenSlots + 1, numAvailStartSpots do - gameInfo.ClosedSlots[i] = nil - gameInfo.SpawnMex[i] = nil - GUI.slots[i]:Show() - ClearSlotInfo(i) - DisableSlot(i) - end - end - numOpenSlots = numAvailStartSpots - - for i = 1, numAvailStartSpots do - if gameInfo.ClosedSlots[i] then - GUI.slots[i]:Show() - if not gameInfo.PlayerOptions[i] then - ClearSlotInfo(i) - end - if not gameInfo.PlayerOptions[i].Ready then - EnableSlot(i) - end - end - end - - for i = numAvailStartSpots + 1, LobbyComm.maxPlayerSlots do - if lobbyComm:IsHost() and gameInfo.PlayerOptions[i] then - local info = gameInfo.PlayerOptions[i] - if info.Human then - HostUtils.ConvertPlayerToObserver(i) - else - HostUtils.RemoveAI(i) - end - end - DisableSlot(i) - GUI.slots[i]:Hide() - gameInfo.ClosedSlots[i] = true - gameInfo.SpawnMex[i] = nil - end - - gameInfo.firstUpdateAvailableSlotsDone = true -end - -local function TryLaunch(skipNoObserversCheck) - if not singlePlayer then - local notReady = GetPlayersNotReady() - if notReady then - for k,v in notReady do - AddChatText(LOCF("%s isn't ready.",v)) - end - return - end - end - - local teamsPresent = {} - - -- make sure there are some players (could all be observers?) - -- Also count teams. There needs to be at least 2 teams (or all FFA) represented - local numPlayers = 0 - local numHumanPlayers = 0 - local numTeams = 0 - for slot, player in gameInfo.PlayerOptions:pairs() do - if player then - numPlayers = numPlayers + 1 - - if player.Human then - numHumanPlayers = numHumanPlayers + 1 - end - - -- Make sure to increment numTeams for people in the special "-" team, represented by 1. - if not teamsPresent[player.Team] or player.Team == 1 then - teamsPresent[player.Team] = true - numTeams = numTeams + 1 - end - end - end - - -- Ensure, for a non-sandbox game, there are some teams to fight. - if gameInfo.GameOptions['Victory'] ~= 'sandbox' and numTeams < 2 then - --AddChatText(LOC("There must be more than one player or team or the Victory Condition must be set to Sandbox.")) - -- In case we start a game as single player we set the game temporarily to Sandbox mode. This will not change the lobby option itself! - SPEW('GameOptions[\'Victory\'] changed temporarily from "'..gameInfo.GameOptions['Victory']..'" to "sandbox"') - gameInfo.GameOptions['Victory'] = 'sandbox' - end - - if numPlayers == 0 then - AddChatText(LOC("There are no players assigned to player slots, can not continue")) - return - end - - if not gameInfo.GameOptions.AllowObservers then - - -- if observers are not allowed, and team spawn is set to penguin_autobalance, and there are - -- an odd number of players, then make the last player an observer now if human - -- (before the check(s)/prompt(s) for having observer(s) when they're not allowed) - if gameInfo.GameOptions.TeamSpawn == 'penguin_autobalance' then - if math.mod(numPlayers, 2) == 1 then - for i = 16, 1, -1 do - -- this gets the last occupied slot - if gameInfo.PlayerOptions[i] then - LOG(gameInfo.PlayerOptions[i].Human) - if gameInfo.PlayerOptions[i].Human then - HostUtils.ConvertPlayerToObserver(i) - end - break - end - end - end - end - - local hostIsObserver = false - local anyOtherObservers = false - for k, observer in gameInfo.Observers:pairs() do - if observer.OwnerID == localPlayerID then - hostIsObserver = true - else - anyOtherObservers = true - end - end - - if hostIsObserver then - AddChatText(LOC("Cannot launch if the host isn't assigned a slot and observers are not allowed.")) - return - end - - if anyOtherObservers and not skipNoObserversCheck then - UIUtil.QuickDialog(GUI, "Launching will kick observers because \"allow observers\" is disabled. Continue?", - "", function() TryLaunch(true) end, - "", nil, nil, nil, true, - {worldCover = false, enterButton = 1, escapeButton = 2}) - return - end - - HostUtils.KickObservers("GameLaunched") - end - - if not EveryoneHasEstablishedConnections(gameInfo.GameOptions.AllowObservers) then - return - end - - numberOfPlayers = numPlayers - local function LaunchGame() - - if gameInfo.GameOptions.TeamSpawn == 'penguin_autobalance' then - GUI.PenguinAutoBalance.OnClick() - end - - -- These two things must happen before the flattening step, mostly for terrible reasons. - -- This isn't ideal, as it leads to redundant UI repaints :/ - AssignAutoTeams() - - -- Force observers to start with the UEF skin to prevent them from launching as "random". - if IsObserver(localPlayerID) then - UIUtil.SetCurrentSkin("uef") - end - - -- Eliminate the WatchedValue structures. - gameInfo = GameInfo.Flatten(gameInfo) - - if gameInfo.GameOptions['RandomMap'] ~= 'Off' then - autoRandMap = true - autoMap() - end - - SetFrontEndData('NextOpBriefing', nil) - -- assign random factions just as game is launched - AssignRandomFactions() - -- fix faction indexes - FixFactionIndexes() - AssignRandomStartSpots() - AssignAINames() - local allRatings = {} - local clanTags = {} - for k, player in gameInfo.PlayerOptions do - if player.PL then - allRatings[player.PlayerName] = player.PL - clanTags[player.PlayerName] = player.PlayerClan - - if not player.Human then - allRatings[player.PlayerName] = ComputeAIRating(gameInfo.GameOptions, player.AILobbyProperties) - end - end - - if player.OwnerID == localPlayerID then - UIUtil.SetCurrentSkin(FACTION_NAMES[player.Faction]) - end - end - gameInfo.GameOptions['Ratings'] = allRatings - gameInfo.GameOptions['ClanTags'] = clanTags - - scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - - -- Load in the default map options if they are not set manually - - -- Not all maps have options - if scenarioInfo.options then - - -- If we don't validate them first then the people using the default - -- as a value instead of the index of the value will mess us up - MapUtil.ValidateScenarioOptions(scenarioInfo.options) - - -- For every option, if it's not set yet then add its default value - for _, option in scenarioInfo.options do - if not gameInfo.GameOptions[option.key] then - -- When the value data of the option is formatted as: - -- values = { - -- { text = "Easy", help = "We'll have sufficient time to start building up our defense strategy.", key = 1, }, - -- { text = "Normal", help = "There's sufficient time - but we'll need to hurry up.", key = 2, }, - -- { text = "Heroic", help = "There's little time - no space for errors.", key = 3, }, - -- { text = "Legendary", help = "We're being dropped in the middle of it - we knew it was a suicide mission when we signed up for it.", key = 4, }, - -- }, - local keyVersion = option.values[option.default].key - - -- When the value data of the option is formatted as: - -- values = { - -- '1','2','3','4','5','6','7','8','9','10','11','12','13','14','15','16','17','18','19','20' - -- } - local valueVersion = option.values[option.default] - - -- Expect a key version, fall back on a value version - gameInfo.GameOptions[option.key] = keyVersion or valueVersion - - -- Can be removed once this code leaves the develop branch - SPEW("Loading default map option: " .. tostring (option.key) .. " = " .. tostring (gameInfo.GameOptions[option.key])) - end - end - end - - if scenarioInfo.AdaptiveMap then - gameInfo.GameOptions["SpawnMex"] = gameInfo.SpawnMex - end - if gameInfo.GameOptions["CheatsEnabled"] == "true" and singlePlayer then - gameInfo.GameOptions["GameSpeed"] = "adjustable" - end - - HostUtils.SendArmySettingsToServer() - - -- Tell everyone else to launch and then launch ourselves. - -- TODO: Sending gamedata here isn't necessary unless lobbyComm is fucking stupid and allows - -- out-of-order message delivery. - -- Downlord: I use this in clients now to store the rehost preset. So if you're going to remove this, please - -- check if rehosting still works for non-host players. - lobbyComm:BroadcastData({ Type = 'Launch', GameInfo = gameInfo }) - - -- set the mods - gameInfo.GameMods = Mods.GetGameMods(gameInfo.GameMods) - - SetWindowedLobby(false) - - Presets.SaveLastGamePreset() - - -- launch the game - lobbyComm:LaunchGame(gameInfo) - - - end - - LaunchGame() -end - -local function AlertHostMapMissing() - if lobbyComm:IsHost() then - HostUtils.PlayerMissingMapAlert(localPlayerID) - else - lobbyComm:SendData(hostID, {Type = 'MissingMap'}) - end -end - -local function UpdateGame() - -- This allows us to assume the existence of UI elements throughout. - if not GUI.uiCreated then - WARN(debug.traceback(nil, "UpdateGame() pointlessly called before UI creation!")) - return - end - - local scenarioInfo - - if gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= "") then - scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - - -- update AI rating as game settings change - for k = 1, 16 do - local playerOptions = gameInfo.PlayerOptions[k] - if playerOptions then - if not playerOptions.Human then - playerOptions.PL = ComputeAIRating(gameInfo.GameOptions, playerOptions.AILobbyProperties); - playerOptions.MEAN = playerOptions.PL - playerOptions.DEV = 0 - end - end - end - - if scenarioInfo and scenarioInfo.map and scenarioInfo.map ~= '' then - GUI.mapView:SetScenario(scenarioInfo) - ShowMapPositions(GUI.mapView, scenarioInfo) - ConfigureMapListeners(GUI.mapView, scenarioInfo) - - -- Briefing button takes priority over the patch notes if the map has a briefing - if scenarioInfo.hasBriefing then - GUI.briefingButton:Show() - GUI.patchnotesButton:Hide() - else - GUI.briefingButton:Hide() - GUI.patchnotesButton:Show() - end - - -- contains information that is available during blueprint loading - local preGameData = {} - - -- MAP ASSETS LOADING -- - - -- store the (selected) map directory so that we can load individual blueprints from it - preGameData.CurrentMapDir = Dirname(gameInfo.GameOptions.ScenarioFile) - - -- STRATEGIC ICON REPLACEMENT -- - - -- icon replacements - local iconReplacements = { } - - -- retrieve all (selected) mods - local allMods = Mods.AllMods() - local selectedMods = Mods.GetSelectedMods() - - -- loop over selected mods identifiers - for uid, _ in selectedMods do - - -- get the mod, determine path to icon configuration file - local mod = allMods[uid] - - -- check for mod integrity - if not (mod.name and mod.author) then - WARN("Unable to load icons from mod '" .. uid .. "', the mod_info.lua file is not properly defined. It needs a name and author field.") - end - - -- path to configuration file - local iconConfigurationPath = mod.location .. "/mod_icons.lua" - - -- see if it exists - if DiskGetFileInfo(iconConfigurationPath) then - - -- see if we can import it - local ok, msg = pcall( - function() - - -- attempt to load the file - local env = { } - doscript(iconConfigurationPath, env) - - -- syntax errors are caught internally and instead it just returns the table untouched - if not (env.UnitIconAssignments or env.ScriptedIconAssignments) then - error("Lobby.lua - can not import the icon configuration file at '" .. iconConfigurationPath .. "'. This could be due to missing functionality functionality or a parsing error.") - end - end - ) - - -- if it passes this basic check, then continue - if ok then - local info = { } - info.Name = mod.name - info.Author = mod.author - info.Location = mod.location - info.Identifier = string.lower(utils.StringSplit(mod.location, '/')[2]) - info.UID = uid - table.insert(iconReplacements, info) - -- tell us (and then spam the author, not the dev) if it failed - else - WARN("Unable to load icons from mod '" .. tostring(mod.name) .. "' with uid '" .. tostring(uid) .. "'. Please inform the author: " .. tostring(mod.author)) - WARN(msg) - end - end - end - - preGameData.IconReplacements = iconReplacements - - -- try and set the preferences - it may crash when running multiple instances on a single machine that all try and start at the same time. - local ok, msg = pcall( - function() - -- store in preferences so that we can retrieve it during blueprint loading - SetPreference('PreGameData', preGameData) - end - ) - - if not ok then - WARN("Unable to update preferences. Are you running multiple instances on the same machine?" ) - WARN(msg) - end - - -- PREFETCHING -- - - -- Note that the PreGameData is not properly updated, - -- hence we can not rely on mod and / or lobby option - -- changes to be present. - - -- local mods = Mods.GetGameMods(gameInfo.GameMods) - -- PrefetchSession(scenarioInfo.map, mods, true) - - else - AlertHostMapMissing() - GUI.mapView:Clear() - end - end - - local isHost = lobbyComm:IsHost() - - local localPlayerSlot = FindSlotForID(localPlayerID) - if localPlayerSlot then - local playerOptions = gameInfo.PlayerOptions[localPlayerSlot] - - -- Disable some controls if the user is ready. - local notReady = not playerOptions.Ready - - UIUtil.setEnabled(GUI.becomeObserver, notReady) - UIUtil.setEnabled(GUI.briefingButton, notReady) - -- This button is enabled for all non-host players to view the configuration, and for the - -- host to select presets (rather confusingly, one object represents both potential buttons) - UIUtil.setEnabled(GUI.restrictedUnitsOrPresetsBtn, not isHost or notReady) - - UIUtil.setEnabled(GUI.factionSelector, notReady) - if notReady then - UpdateFactionSelector() - end - else - UIUtil.setEnabled(GUI.factionSelector, false) - end - - gameInfo.AdaptiveMap = scenarioInfo.AdaptiveMap - - local numPlayers = GetPlayerCount() - - local numAvailStartSpots = LobbyComm.maxPlayerSlots - if scenarioInfo then - local armyTable = MapUtil.GetArmies(scenarioInfo) - if armyTable then - numAvailStartSpots = table.getn(armyTable) - end - end - - UpdateAvailableSlots(numAvailStartSpots, scenarioInfo) - - -- Update all slots. - for i = 1, LobbyComm.maxPlayerSlots do - if gameInfo.ClosedSlots[i] then - UpdateSlotBackground(i) - else - if gameInfo.PlayerOptions[i] then - SetSlotInfo(i, gameInfo.PlayerOptions[i]) - else - ClearSlotInfo(i) - end - end - end - - if isHost then - HostUtils.RefreshButtonEnabledness() - end - RefreshOptionDisplayData(scenarioInfo) - - -- Update the map background to reflect the possibly-changed map. - if Prefs.GetFromCurrentProfile('LobbyBackground') == 4 then - RefreshLobbyBackground() - end - - -- Set the map name at the top right corner in lobby - if scenarioInfo.name then - GUI.MapNameLabel:StreamText(scenarioInfo.name, 20) - end - - -- Add Tooltip info on Map Name Label - if scenarioInfo then - local TTips_map_version = scenarioInfo.map_version or "1" - local TTips_army = table.getsize(scenarioInfo.Configurations.standard.teams[1].armies) - local TTips_sizeX = scenarioInfo.size[1] / 51.2 - local TTips_sizeY = scenarioInfo.size[2] / 51.2 - - local mapTooltip = { - text = scenarioInfo.name, - body = '- '..LOC("Map version")..' : '..TTips_map_version..'\n '.. - '- '..LOC("Max Players")..' : '..TTips_army..' max'..'\n '.. - '- '..LOC("Map Size")..' : '..TTips_sizeX..'km x '..TTips_sizeY..'km' - } - - Tooltip.AddControlTooltip(GUI.MapNameLabel, mapTooltip) - Tooltip.AddControlTooltip(GUI.GameQualityLabel, mapTooltip) - end - - -- If the large map is shown, update it. - RefreshLargeMap() - - SetRuleTitleText(gameInfo.GameOptions.GameRules or "") - SetGameTitleText(gameInfo.GameOptions.Title or LOC("FAF Game Lobby")) - - if isHost and GUI.autoTeams then - GUI.autoTeams:SetState(gameInfo.GameOptions.AutoTeams,true) - Tooltip.DestroyMouseoverDisplay() - end -end - ---- Update the game quality display -function ShowGameQuality() - GUI.GameQualityLabel:SetText("") - - -- Can't compute a game quality for random spawns! - if gameInfo.GameOptions.TeamSpawn ~= 'fixed' then - return - end - - local teams = Teams.create() - - -- Everything catches fire if the teams aren't numbered sequentially from 1. - -- I hope it is not the case that everything catches fire when there are >2 teams, but in - -- principle that should work... - - -- Start by creating a map from each *used* team to an element from an ascending set of integers. - local tsTeam = 1 - local teamMap = {} - for i = 1, LobbyComm.maxPlayerSlots do - local playerOptions = gameInfo.PlayerOptions[i] - -- Team 1 represents "No team", so these people are all singleton teams. - if playerOptions and (teamMap[playerOptions.Team] == nil or playerOptions.Team == 1) then - teamMap[playerOptions.Team] = tsTeam - tsTeam = tsTeam + 1 - end - end - - -- Now we just use the map to relate real teams to trueSkill teams. - for i = 1, LobbyComm.maxPlayerSlots do - local playerOptions = gameInfo.PlayerOptions[i] - if playerOptions then - -- Can't do it for AI, either, not sensibly. - if not playerOptions.Human and (playerOptions.MEAN or 0) == 0 then - return - end - - local player = Player.create( - playerOptions.PlayerName, - Rating.create(playerOptions.MEAN, playerOptions.DEV) - ) - - teams:addPlayer(teamMap[playerOptions.Team], player) - end - end - - -- Rating only meaningful in games with 2 teams - if table.getsize(teams:getTeams()) ~= 2 then - return - end - - local quality = Trueskill.computeQuality(teams) - - if quality > 0 then - gameInfo.GameOptions.Quality = quality - GUI.GameQualityLabel:StreamText(LOCF("Game quality: %s%%", string.format("%.2f",quality)), 20) - end -end - --- Holds some utility functions to do with game option management. -local OptionUtils = { - -- Set all game options to their default values. - SetDefaults = function() - local options = {} - for index, option in teamOpts do - options[option.key] = option.values[option.default].key or option.values[option.default] - end - for index, option in globalOpts do - -- Exception to make AllowObservers work because the engine requires - -- the keys to be bool. Custom options should use 'True' or 'False' - if option.key == 'AllowObservers' then - options[option.key] = option.values[option.default].key - else - options[option.key] = option.values[option.default].key or option.values[option.default] - end - end - - for index, option in AIOpts do - options[option.key] = option.values[option.default].key or option.values[option.default] - end - - options.RestrictedCategories = {} - - SetGameOptions(options) - end -} - --- callback when Mod Manager dialog finishes (modlist==nil on cancel) --- FIXME: The mod manager should be given a list of game mods set by the host, which --- clients can look at but not changed, and which don't get saved in our local prefs. -function OnModsChanged(simMods, UIMods, ignoreRefresh) - -- We depend upon ModsManager to not allow the user to change mods they shouldn't be able to - selectedSimMods = simMods - selectedUIMods = UIMods - - Mods.SetSelectedMods(SetUtils.Union(selectedSimMods, selectedUIMods)) - if lobbyComm:IsHost() then - HostUtils.UpdateMods() - end - - if not ignoreRefresh then - -- reload AI types in case we have enable or disable an AI mod. - GetAITypes() - GUI.AIFillCombo:ClearItems() - GUI.AIFillCombo:AddItems(AIStrings) - GUI.AIFillCombo:SetTitleText(LOC('Choose AI for autofilling')) - UpdateGame() - end -end - -function GetAvailableColor() - for i = 1, LobbyComm.maxPlayerSlots do - if IsColorFree(gameColors.LobbyColorOrder[i]) then - return gameColors.LobbyColorOrder[i] - end - end - WARN('Error: No available colors found.') -end - ---- This function is retarded. --- Unfortunately, we're stuck with it. --- The game requires both ArmyColor and PlayerColor be set. We don't want to have to write two fields --- all the time, and the magic that makes PlayerData work precludes adding member functions to it. --- So, we have this. Tough shit. :P -function SetPlayerColor(playerData, newColor) - playerData.ArmyColor = newColor - playerData.PlayerColor = newColor -end - -function autoMap() - local randomAutoMap - if gameInfo.GameOptions['RandomMap'] == 'Official' then - randomAutoMap = import("/lua/ui/dialogs/mapselect.lua").randomAutoMap(true) - else - randomAutoMap = import("/lua/ui/dialogs/mapselect.lua").randomAutoMap(false) - end -end - -function ClientsMissingMap() - local ret = nil - - for index, player in gameInfo.PlayerOptions:pairs() do - if player.BadMap then - if not ret then ret = {} end - table.insert(ret, player.PlayerName) - end - end - - for index, observer in gameInfo.Observers:pairs() do - if observer.BadMap then - if not ret then ret = {} end - table.insert(ret, observer.PlayerName) - end - end - - return ret -end - -function ClearBadMapFlags() - for index, player in gameInfo.PlayerOptions:pairs() do - player.BadMap = false - end - - for index, observer in gameInfo.Observers:pairs() do - observer.BadMap = false - end -end - -function EnableSlot(slot) - GUI.slots[slot].team:Enable() - GUI.slots[slot].color:Enable() - GUI.slots[slot].faction:Enable() - GUI.slots[slot].ready:Enable() -end - -function DisableSlot(slot, exceptReady) - GUI.slots[slot].team:Disable() - GUI.slots[slot].color:Disable() - GUI.slots[slot].faction:Disable() - if not exceptReady then - GUI.slots[slot].ready:Disable() - end -end - --- Used for the quick-swap feature -local playersToSwap = false - --- set up player "slots" which is the line representing a player and player specific options -function CreateSlotsUI(makeLabel) - local Combo = import("/lua/ui/controls/combo.lua").Combo - local BitmapCombo = import("/lua/ui/controls/combo.lua").BitmapCombo - local StatusBar = import("/lua/maui/statusbar.lua").StatusBar - local ColumnLayout = import("/lua/ui/controls/columnlayout.lua").ColumnLayout - - -- The dimensions of the columns used for slot UI controls. - local COLUMN_POSITIONS = {1, 21, 47, 91, 133, 395, 465, 535, 605, 677, 749} - local COLUMN_WIDTHS = {20, 20, 45, 45, 257, 59, 59, 59, 62, 62, 51} - - local labelGroup = ColumnLayout(GUI.playerPanel, COLUMN_POSITIONS, COLUMN_WIDTHS) - - GUI.labelGroup = labelGroup - LayoutHelpers.SetDimensions(labelGroup, 791, 21) - LayoutHelpers.AtLeftTopIn(labelGroup, GUI.playerPanel, 5, 5) - - local slotLabel = makeLabel("--", 14) - labelGroup:AddChild(slotLabel) - - -- No label required for the second column (flag), so skip it. (Even eviler hack) - labelGroup.numChildren = labelGroup.numChildren + 1 - - local ratingLabel = makeLabel("R", 14) - labelGroup:AddChild(ratingLabel) - - local numGamesLabel = makeLabel("G", 14) - labelGroup:AddChild(numGamesLabel) - - local nameLabel = makeLabel(LOC("Nickname"), 14) - labelGroup:AddChild(nameLabel) - - local colorLabel = makeLabel(LOC("Color"), 14) - labelGroup:AddChild(colorLabel) - - local factionLabel = makeLabel(LOC("Faction"), 14) - labelGroup:AddChild(factionLabel) - - local teamLabel = makeLabel(LOC("Team"), 14) - labelGroup:AddChild(teamLabel) - - labelGroup:AddChild(makeLabel(LOC("CPU"), 14)) - - if not singlePlayer then - labelGroup:AddChild(makeLabel(LOC("Ping"), 14)) - labelGroup:AddChild(makeLabel(LOC("Ready"), 14)) - end - - for i= 1, LobbyComm.maxPlayerSlots do - -- Capture the index in the current closure so it's accessible on callbacks - local curRow = i - - -- The background is parented on the GUI so it doesn't vanish when we hide the slot. - local slotBackground = Bitmap(GUI, UIUtil.SkinnableFile("/SLOT/slot-dis.dds")) - - -- Inherit dimensions of the slot control from the background image. - local newSlot = ColumnLayout(GUI.playerPanel, COLUMN_POSITIONS, COLUMN_WIDTHS) - newSlot.Width:Set(slotBackground.Width) - newSlot.Height:Set(slotBackground.Height) - - LayoutHelpers.AtLeftTopIn(slotBackground, newSlot) - newSlot.SlotBackground = slotBackground - - -- Default mouse behaviours for the slot. - local defaultHandler = function(self, event) - if curRow > numOpenSlots then - return - end - - local associatedMarker = GUI.mapView.startPositions[curRow] - if event.Type == 'MouseEnter' then - if gameInfo.GameOptions['TeamSpawn'] == 'fixed' then - associatedMarker.indicator:Play() - end - elseif event.Type == 'MouseExit' then - associatedMarker.indicator:Stop() - elseif event.Type == 'ButtonDClick' then - DoSlotBehavior(curRow, 'occupy', '') - end - - return Group.HandleEvent(self, event) - end - newSlot.HandleEvent = defaultHandler - - -- Slot number - local slotNumber = UIUtil.CreateText(newSlot, tostring(i), 14, 'Arial') - newSlot.slotNumber = slotNumber - LayoutHelpers.SetWidth(slotNumber, COLUMN_WIDTHS[1]) - slotNumber.Height:Set(newSlot.Height) - newSlot:AddChild(slotNumber) - newSlot.tooltipnumber = Tooltip.AddControlTooltip(slotNumber, 'slot_number') - slotNumber.id = i - slotNumber.HandleEvent = function(self,event) - if lobbyComm:IsHost() then - if event.Type == 'ButtonPress' then - if playersToSwap then - --same number clicked - if self.id == playersToSwap then - playersToSwap = false - self:SetColor(UIUtil.fontColor) - elseif gameInfo.PlayerOptions[playersToSwap] then - HostUtils.SwapPlayers(playersToSwap, self.id) - GUI.slots[playersToSwap].slotNumber:SetColor(UIUtil.fontColor) - playersToSwap = false - elseif gameInfo.PlayerOptions[self.id] then - HostUtils.SwapPlayers(self.id, playersToSwap) - GUI.slots[playersToSwap].slotNumber:SetColor(UIUtil.fontColor) - playersToSwap = false - end - else - self:SetColor('ff00ffff') - playersToSwap = self.id - end - end - end - end - -- COUNTRY - -- Added a bitmap on the left of Rating, the bitmap is a Flag of Country - local flag = Bitmap(newSlot, UIUtil.SkinnableFile("/countries/world.dds")) - newSlot.KinderCountry = flag - LayoutHelpers.SetWidth(flag, COLUMN_WIDTHS[2]) - newSlot:AddChild(flag) - - -- TODO: Factorise this boilerplate. - -- Rating - local ratingText = UIUtil.CreateText(newSlot, "", 14, 'Arial') - newSlot.ratingText = ratingText - ratingText:SetColor('B9BFB9') - ratingText:SetDropShadow(true) - newSlot:AddChild(ratingText) - - -- NumGame - local numGamesText = UIUtil.CreateText(newSlot, "", 14, 'Arial') - newSlot.numGamesText = numGamesText - numGamesText:SetColor('B9BFB9') - numGamesText:SetDropShadow(true) - Tooltip.AddControlTooltip(numGamesText, 'num_games') - newSlot:AddChild(numGamesText) - - -- Name - local nameLabel = Combo(newSlot, 14, 16, true, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") - newSlot.name = nameLabel - nameLabel._text:SetFont('Arial Gras', 15) - newSlot:AddChild(nameLabel) - LayoutHelpers.SetWidth(nameLabel, COLUMN_WIDTHS[5]) - -- left deal with name clicks - nameLabel.OnEvent = defaultHandler - nameLabel.OnClick = function(self, index, text) - DoSlotBehavior(curRow, self.slotKeys[index], text) - end - - -- Hide the marker when the dropdown is hidden - nameLabel.OnHide = function() - local associatedMarker = GUI.mapView.startPositions[curRow] - if associatedMarker then - associatedMarker.indicator:Stop() - end - end - - -- Color - local colorSelector = BitmapCombo(newSlot, gameColors.PlayerColors, 1, true, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") - newSlot.color = colorSelector - - newSlot:AddChild(colorSelector) - LayoutHelpers.SetWidth(colorSelector, COLUMN_WIDTHS[6]) - colorSelector.OnClick = function(self, index) - if not lobbyComm:IsHost() then - lobbyComm:SendData(hostID, { Type = 'RequestColor', Color = index }) - SetPlayerColor(gameInfo.PlayerOptions[curRow], index) - UpdateGame() - else - if IsColorFree(index) then - lobbyComm:BroadcastData({ Type = 'SetColor', Color = index, Slot = curRow }) - SetPlayerColor(gameInfo.PlayerOptions[curRow], index) - UpdateGame() - else - self:SetItem(gameInfo.PlayerOptions[curRow].PlayerColor) - end - end - end - colorSelector.OnEvent = defaultHandler - Tooltip.AddControlTooltip(colorSelector, 'lob_color') - - -- Faction - -- builds the faction tables, and then adds random faction icon to the end - local factionBmps = {} - local factionTooltips = {} - local factionList = {} - for index, tbl in FactionData.Factions do - factionBmps[index] = tbl.SmallIcon - factionTooltips[index] = tbl.TooltipID - factionList[index] = tbl.Key - end - table.insert(factionBmps, "/faction_icon-sm/random_ico.dds") - table.insert(factionTooltips, 'lob_random') - table.insert(factionList, 'random') - allAvailableFactionsList = factionList - - local factionSelector = BitmapCombo(newSlot, factionBmps, table.getn(factionBmps), nil, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") - newSlot.faction = factionSelector - newSlot.AvailableFactions = factionList - newSlot:AddChild(factionSelector) - LayoutHelpers.SetWidth(factionSelector, COLUMN_WIDTHS[7]) - factionSelector.OnClick = function(self, index) - SetPlayerOption(curRow, 'Faction', index) - if curRow == FindSlotForID(FindIDForName(localPlayerName)) then - local fact = GUI.slots[FindSlotForID(localPlayerID)].AvailableFactions[index] - for ind,value in allAvailableFactionsList do - if fact == value then - GUI.factionSelector:SetSelected(ind) - break - end - end - end - - Tooltip.DestroyMouseoverDisplay() - end - Tooltip.AddControlTooltip(factionSelector, 'lob_faction') - Tooltip.AddComboTooltip(factionSelector, factionTooltips) - factionSelector.OnEvent = defaultHandler - - -- Team - local teamSelector = Combo(newSlot, 17, 9, nil, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") - teamSelector:AddItems({' - ', ' 1', ' 2', ' 3', ' 4', ' 5', ' 6', ' 7', ' 8'}) - teamSelector._text:SetFont('Arial', 14) - teamSelector._titleColor = 'White' - newSlot.team = teamSelector - newSlot:AddChild(teamSelector) - LayoutHelpers.SetWidth(teamSelector, COLUMN_WIDTHS[8]) - teamSelector.OnClick = function(self, index, text) - Tooltip.DestroyMouseoverDisplay() - SetPlayerOption(curRow, 'Team', index) - end - Tooltip.AddControlTooltip(teamSelector, 'lob_team') - teamSelector.OnEvent = defaultHandler - - -- CPU - local barMax = 450 - local barMin = 0 - local CPUGroup = Group(newSlot) - newSlot.CPUGroup = CPUGroup - LayoutHelpers.SetWidth(CPUGroup, COLUMN_WIDTHS[9]) - CPUGroup.Height:Set(newSlot.Height) - newSlot:AddChild(CPUGroup) - local CPUSpeedBar = StatusBar(CPUGroup, barMin, barMax, false, false, - UIUtil.UIFile('/game/unit_bmp/bar_black_bmp.dds'), - UIUtil.UIFile('/game/unit_bmp/bar_purple_bmp.dds'), - true) - newSlot.CPUSpeedBar = CPUSpeedBar - LayoutHelpers.AtTopIn(CPUSpeedBar, CPUGroup, 7) - LayoutHelpers.AtLeftIn(CPUSpeedBar, CPUGroup, 0) - LayoutHelpers.AtRightIn(CPUSpeedBar, CPUGroup, 0) - CPU_AddControlTooltip(CPUSpeedBar, 0, curRow) - CPUSpeedBar.CPUActualValue = 450 - CPUSpeedBar.barMax = barMax - - -- Ping - barMax = 1000 - barMin = 0 - local pingGroup = Group(newSlot) - newSlot.pingGroup = pingGroup - LayoutHelpers.SetWidth(pingGroup, COLUMN_WIDTHS[10]) - pingGroup.Height:Set(newSlot.Height) - newSlot:AddChild(pingGroup) - local pingStatus = StatusBar(pingGroup, barMin, barMax, false, false, - UIUtil.SkinnableFile('/game/unit_bmp/bar-back_bmp.dds'), - UIUtil.SkinnableFile('/game/unit_bmp/bar-01_bmp.dds'), - true) - newSlot.pingStatus = pingStatus - LayoutHelpers.AtTopIn(pingStatus, pingGroup, 7) - LayoutHelpers.AtLeftIn(pingStatus, pingGroup, 0) - LayoutHelpers.AtRightIn(pingStatus, pingGroup, 0) - Ping_AddControlTooltip(pingStatus, 0, curRow) - - -- Ready Checkbox - local readyBox = UIUtil.CreateCheckbox(newSlot, '/CHECKBOX/') - newSlot.ready = readyBox - newSlot:AddChild(readyBox) - readyBox.OnCheck = function(self, checked) - UIUtil.setEnabled(GUI.becomeObserver, not checked) - if checked then - DisableSlot(curRow, true) - else - EnableSlot(curRow) - end - SetPlayerOption(curRow, 'Ready', checked) - end - - newSlot.HideControls = function() - -- hide these to clear slot of visible data - flag:Hide() - ratingText:Hide() - numGamesText:Hide() - factionSelector:Hide() - colorSelector:Hide() - teamSelector:Hide() - CPUSpeedBar:Hide() - pingStatus:Hide() - readyBox:Hide() - end - newSlot.HideControls() - - if singlePlayer then - -- TODO: Use of groups may allow this to be simplified... - readyBox:Hide() - pingStatus:Hide() - end - - if i == 1 then - LayoutHelpers.Below(newSlot, GUI.labelGroup) - else - LayoutHelpers.Below(newSlot, GUI.slots[i - 1], 3) - end - - GUI.slots[i] = newSlot - end -end - --- create UI won't typically be called directly by another module -function CreateUI(maxPlayers) - local ResourceMapPreview = import("/lua/ui/controls/resmappreview.lua").ResourceMapPreview - local ItemList = import("/lua/maui/itemlist.lua").ItemList - local Prefs = import("/lua/user/prefs.lua") - local Tooltip = import("/lua/ui/game/tooltip.lua") - local Combo = import("/lua/ui/controls/combo.lua") - - local isHost = lobbyComm:IsHost() - local lastFaction = GetSanitisedLastFaction() - UIUtil.SetCurrentSkin(FACTION_NAMES[lastFaction]) - - --------------------------------------------------------------------------- - -- Set up main control panels - --------------------------------------------------------------------------- - GUI.panel = Bitmap(GUI, UIUtil.SkinnableFile("/scx_menu/lan-game-lobby/lobby.dds")) - LayoutHelpers.AtCenterIn(GUI.panel, GUI) - GUI.panelWideLeft = Bitmap(GUI, UIUtil.SkinnableFile('/scx_menu/lan-game-lobby/wide.dds')) - LayoutHelpers.CenteredLeftOf(GUI.panelWideLeft, GUI.panel) - GUI.panelWideLeft.Left:Set(function() return GUI.Left() end) - GUI.panelWideRight = Bitmap(GUI, UIUtil.SkinnableFile('/scx_menu/lan-game-lobby/wide.dds')) - LayoutHelpers.CenteredRightOf(GUI.panelWideRight, GUI.panel) - GUI.panelWideRight.Right:Set(function() return GUI.Right() end) - - -- Create a label with a given size and initial text - local function makeLabel(text, size) - return UIUtil.CreateText(GUI.panel, text, size, 'Arial Gras', true) - end - - -- Map name label - GUI.MapNameLabel = makeLabel(LOC("Loading..."), 17) - LayoutHelpers.AtRightTopIn(GUI.MapNameLabel, GUI.panel, 5, 45) - - -- Game Quality Label - GUI.GameQualityLabel = makeLabel("", 11) - LayoutHelpers.AtRightTopIn(GUI.GameQualityLabel, GUI.panel, 5, 64) - - -- Title Label - GUI.titleText = makeLabel(LOC("FAF Game Lobby"), 17) - LayoutHelpers.AtLeftTopIn(GUI.titleText, GUI.panel, 5, 20) - - if isHost then - GUI.titleText.HandleEvent = function(self, event) - if event.Type == 'ButtonPress' then - ShowTitleDialog() - end - end - end - - -- Rule Label - local RuleLabel = TextArea(GUI.panel, 350, 34) - GUI.RuleLabel = RuleLabel - RuleLabel:SetFont('Arial Gras', 11) - RuleLabel:SetColors("B9BFB9", "00000000", "B9BFB9", "00000000") - LayoutHelpers.AtLeftTopIn(RuleLabel, GUI.panel, 5, 44) - RuleLabel:DeleteAllItems() - local tmptext - if isHost then - tmptext = LOC("No Rules: Click to add rules") - RuleLabel:SetColors("FFCC00") - else - tmptext = LOC("No rules") - end - - RuleLabel:SetText(tmptext) - if isHost then - RuleLabel.OnClick = function(self) - ShowRuleDialog() - end - end - - -- Mod Label - GUI.ModFeaturedLabel = makeLabel("", 13) - LayoutHelpers.AtLeftTopIn(GUI.ModFeaturedLabel, GUI.panel, 50, 61) - - -- Set the mod name to a value appropriate for the mod in use. - local modLabels = { - ["init_faf.lua"] = "FA Forever", - ["init_blackops.lua"] = "BlackOps", - ["init_coop.lua"] = "COOP", - ["init_balancetesting.lua"] = "Balance Testing", - ["init_gw.lua"] = "Galactic War", - ["init_labwars.lua"] = "Labwars", - ["init_ladder1v1.lua"] = "Ladder 1v1", - ["init_nomads.lua"] = "Nomads Mod", - ["init_phantomx.lua"] = "PhantomX", - ["init_supremedestruction.lua"] = "SupremeDestruction", - ["init_xtremewars.lua"] = "XtremeWars", - - } - GUI.ModFeaturedLabel:StreamText(modLabels[argv.initName] or "", 20) - - -- Lobby options panel - GUI.LobbyOptions = UIUtil.CreateButtonWithDropshadow(GUI.panel, '/BUTTON/medium/', LOC("Settings")) - LayoutHelpers.AtRightTopIn(GUI.LobbyOptions, GUI.panel, 44, 3) - GUI.LobbyOptions.OnClick = function() - ShowLobbyOptionsDialog() - end - Tooltip.AddButtonTooltip(GUI.LobbyOptions, 'lobby_click_Settings') - - -- Logo - GUI.logo = Bitmap(GUI, '/textures/ui/common/scx_menu/lan-game-lobby/logo.dds') - LayoutHelpers.AtLeftTopIn(GUI.logo, GUI, 1, 1) - - local version, gametype, commit = import("/lua/version.lua").GetVersionData() - GUI.gameVersionText = UIUtil.CreateText(GUI.panel, LOC('Game version ') .. version, 9, UIUtil.bodyFont) - GUI.gameVersionText:SetColor('677983') - GUI.gameVersionText:SetDropShadow(true) - - Tooltip.AddControlTooltipManual(GUI.gameVersionText, 'Version control', string.format( - LOC('Game version: %s\nGame type: %s\nCommit hash: %s'), version, gametype, commit:sub(1, 8) - )) - - LayoutHelpers.AtLeftTopIn(GUI.gameVersionText, GUI.panel, 70, 3) - - -- Player Slots - GUI.playerPanel = Group(GUI.panel, "playerPanel") - LayoutHelpers.AtLeftTopIn(GUI.playerPanel, GUI.panel, 6, 70) - LayoutHelpers.SetDimensions(GUI.playerPanel, 706, 307) - - -- Observer section - GUI.observerPanel = Group(GUI.panel, "observerPanel") - - -- Scale the observer panel according to the buttons we are showing. - local obsOffset - local obsHeight - if isHost then - obsHeight = 84--159 - obsOffset = 620--545 - else - obsHeight = 206 - obsOffset = 498 - end - LayoutHelpers.AtLeftTopIn(GUI.observerPanel, GUI.panel, 512, obsOffset) - LayoutHelpers.SetDimensions(GUI.observerPanel, 278, obsHeight) - UIUtil.SurroundWithBorder(GUI.observerPanel, '/scx_menu/lan-game-lobby/frame/') - - -- Chat - GUI.chatPanel = Group(GUI.panel, "chatPanel") - LayoutHelpers.AtLeftTopIn(GUI.chatPanel, GUI.panel, 11, 459) - LayoutHelpers.SetWidth(GUI.chatPanel, 478) - LayoutHelpers.SetHeight(GUI.chatPanel, 245) - UIUtil.SurroundWithBorder(GUI.chatPanel, '/scx_menu/lan-game-lobby/frame/') - - if isHost then - GUI.AIFillPanel = Group(GUI.panel) - GUI.AIFillPanel.Left:Set(GUI.observerPanel.Left) - GUI.AIFillPanel.Top:Set(GUI.chatPanel.Top) - LayoutHelpers.SetHeight(GUI.AIFillPanel, 60) - LayoutHelpers.SetWidth(GUI.AIFillPanel, 278) - UIUtil.SurroundWithBorder(GUI.AIFillPanel, '/scx_menu/lan-game-lobby/frame/') - GUI.AIFillCombo = Combo.Combo(GUI.AIFillPanel, 14, 12, false, nil) - LayoutHelpers.AtHorizontalCenterIn(GUI.AIFillCombo, GUI.AIFillPanel) - LayoutHelpers.AtTopIn(GUI.AIFillCombo, GUI.AIFillPanel, 5) - GUI.AIFillCombo.Width:Set(function() return GUI.AIFillPanel.Width() - LayoutHelpers.ScaleNumber(15) end) - GUI.AIFillCombo:AddItems(AIStrings) - GUI.AIFillCombo:SetTitleText(LOC('Choose AI for autofilling')) - Tooltip.AddComboTooltip(GUI.AIFillCombo, AITooltips) - GUI.AIFillButton = UIUtil.CreateButtonStd(GUI.AIFillCombo, '/BUTTON/medium/', LOC('Fill Slots'), 12) - LayoutHelpers.SetWidth(GUI.AIFillButton, 129) - LayoutHelpers.SetHeight(GUI.AIFillButton, 30) - LayoutHelpers.AtLeftTopIn(GUI.AIFillButton, GUI.AIFillCombo, -10, 20) - GUI.AIClearButton = UIUtil.CreateButtonStd(GUI.AIFillButton, '/BUTTON/medium/', LOC('Clear Slots'), 12) - GUI.AIClearButton.Width:Set(GUI.AIFillButton.Width) - GUI.AIClearButton.Height:Set(GUI.AIFillButton.Height) - LayoutHelpers.RightOf(GUI.AIClearButton, GUI.AIFillButton, -19) - GUI.TeamCountSelector = Combo.BitmapCombo(GUI.AIClearButton, teamIcons, 1, false, nil, "UI_Tab_Rollover_01", "UI_Tab_Click_01") - LayoutHelpers.SetWidth(GUI.TeamCountSelector, 44) - LayoutHelpers.AtTopIn(GUI.TeamCountSelector, GUI.AIClearButton, 5) - LayoutHelpers.AtRightIn(GUI.TeamCountSelector, GUI.AIFillPanel, 8) - local tooltipText = {} - tooltipText['text'] = 'Teams Count' - tooltipText['body'] = 'On how many teams share players?' - Tooltip.AddControlTooltip(GUI.TeamCountSelector, tooltipText, 0) - local ChangedSlots = {} - GUI.AIFillButton.OnClick = function() - local AIKeyIndex, AIName = GUI.AIFillCombo:GetItem() - if ChangedSlots[1] ~= nil then - for i = 1, table.getn(ChangedSlots) do - HostUtils.AddAI(AIName, AIKeys[AIKeyIndex], ChangedSlots[i]) - end - else - for Slot = 1, GetNumAvailStartSpots() do - if not (gameInfo.PlayerOptions[Slot] or gameInfo.ClosedSlots[Slot]) then - HostUtils.AddAI(AIName, AIKeys[AIKeyIndex], Slot) - table.insert(ChangedSlots, Slot) - end - end - end - if gameInfo.GameOptions.AutoTeams == 'none' then - GUI.TeamCountSelector.OnClick(nil, GUI.TeamCountSelector:GetItem(), nil) - else - AssignAutoTeams() - end - end - GUI.AIClearButton.OnClick = function() - for i = 1, table.getn(ChangedSlots) do - HostUtils.RemoveAI(ChangedSlots[i]) - end - ChangedSlots = {} - end - GUI.TeamCountSelector.OnClick = function(Self, Index, Text) - local OccupiedSlots = 0 - local AvailStartSpots = GetNumAvailStartSpots() - for Slot = 1, AvailStartSpots do - if gameInfo.PlayerOptions[Slot] ~= nil then - OccupiedSlots = OccupiedSlots + 1 - end - end - local PlayersPerTeam = 0 - if Index > 1 then - PlayersPerTeam = math.floor(OccupiedSlots / (Index - 1)) - end - local AssignedTeam = 2 - local Counter = 0 - for Slot = 1, AvailStartSpots do - if gameInfo.PlayerOptions[Slot] then - if AssignedTeam > Index then - SetPlayerOption(Slot, 'Team', 1, true) - else - SetPlayerOption(Slot, 'Team', AssignedTeam, true) - Counter = Counter + 1 - if Counter >= PlayersPerTeam then - AssignedTeam = AssignedTeam + 1 - Counter = 0 - end - end - SetSlotInfo(Slot, gameInfo.PlayerOptions[Slot]) - end - end - end - end - - -- Map Preview - GUI.mapPanel = Group(GUI.panel, "mapPanel") - LayoutHelpers.AtLeftTopIn(GUI.mapPanel, GUI.panel, 813, 88) - LayoutHelpers.SetDimensions(GUI.mapPanel, 198, 198) - LayoutHelpers.DepthOverParent(GUI.mapPanel, GUI.panel, 2) - UIUtil.SurroundWithBorder(GUI.mapPanel, '/scx_menu/lan-game-lobby/frame/') - - -- Map Preview Info Labels - local tooltipText = {} - tooltipText['text'] = LOC("Map Preview") - -- Map Preview Info Labels - if isHost then - tooltipText['body'] = LOCF("%s\n%s", "Left click ACU icon to move yourself or swap players.", "Right click ACU icon to close or open the slot.") - else - tooltipText['body'] = LOC("Left click ACU icon to move yourself.") - end - Tooltip.AddControlTooltip(GUI.mapPanel, tooltipText) - - GUI.optionsPanel = Group(GUI.panel, "optionsPanel") -- ORANGE Square in Screenshoot - LayoutHelpers.AtLeftTopIn(GUI.optionsPanel, GUI.panel, 813, 325) - LayoutHelpers.SetDimensions(GUI.optionsPanel, 198, 337) - LayoutHelpers.DepthOverParent(GUI.optionsPanel, GUI.panel, 2) - UIUtil.SurroundWithBorder(GUI.optionsPanel, '/scx_menu/lan-game-lobby/frame/') - - --------------------------------------------------------------------------- - -- set up map panel - --------------------------------------------------------------------------- - GUI.mapView = ResourceMapPreview(GUI.mapPanel, 200, 3, 5) - LayoutHelpers.AtLeftTopIn(GUI.mapView, GUI.mapPanel, -1, -1) - LayoutHelpers.DepthOverParent(GUI.mapView, GUI.mapPanel, -1) - - GUI.LargeMapPreview = UIUtil.CreateButtonWithDropshadow(GUI.mapPanel, '/BUTTON/zoom/', "") - LayoutHelpers.SetDimensions(GUI.LargeMapPreview, 30, 30) - LayoutHelpers.AtRightIn(GUI.LargeMapPreview, GUI.mapPanel, -1) - LayoutHelpers.AtBottomIn(GUI.LargeMapPreview, GUI.mapPanel, -1) - LayoutHelpers.DepthOverParent(GUI.LargeMapPreview, GUI.mapPanel, 2) - Tooltip.AddButtonTooltip(GUI.LargeMapPreview, 'lob_click_LargeMapPreview') - GUI.LargeMapPreview.OnClick = function() - CreateBigPreview(GUI) - end - - -- Checkbox Show changed Options - local cbox_ShowChangedOption = UIUtil.CreateCheckbox(GUI.optionsPanel, '/CHECKBOX/', LOC("Hide default options"), true, 11) - LayoutHelpers.AtLeftTopIn(cbox_ShowChangedOption, GUI.optionsPanel, 0, -32) - - Tooltip.AddCheckboxTooltip(cbox_ShowChangedOption, {text=LOC("Hide default options"), body=LOC("Show only changed Options and Advanced Map Options")}) - cbox_ShowChangedOption.OnCheck = function(self, checked) - HideDefaultOptions = checked - RefreshOptionDisplayData() - GUI.OptionContainer:ScrollSetTop('Vert', 0) - Prefs.SetToCurrentProfile('LobbyHideDefaultOptions', tostring(checked)) - end - - -- Patchnotes Button - GUI.patchnotesButton = UIUtil.CreateButtonWithDropshadow(GUI.panel, '/Button/medium/', "Patchnotes") - Tooltip.AddButtonTooltip(GUI.patchnotesButton, 'Lobby_patchnotes') - LayoutHelpers.AtBottomIn(GUI.patchnotesButton, GUI.optionsPanel, -51) - LayoutHelpers.AtHorizontalCenterIn(GUI.patchnotesButton, GUI.optionsPanel, -55) - GUI.patchnotesButton.OnClick = function(self, event) - import("/lua/ui/lobby/changelog/changelogdialog.lua").CreateChangelogDialog(GUI) - end - - -- Create mission briefing button - local briefingButton = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/', "Briefing") - GUI.briefingButton = briefingButton - LayoutHelpers.AtBottomIn(GUI.briefingButton, GUI.optionsPanel, -51) - LayoutHelpers.AtHorizontalCenterIn(GUI.briefingButton, GUI.optionsPanel, -55) - briefingButton.OnClick = function(self, modifiers) - GUI.briefing = Group(GUI) - GUI.briefing.Depth:Set(function() return GUI.Depth() + 20 end) - LayoutHelpers.FillParent(GUI.briefing, GUI) - import('/lua/ui/campaign/operationbriefing.lua').CreateUI(GUI.briefing, gameInfo.GameOptions.ScenarioFile) - end - - -- A buton that, for the host, is "game options", but for everyone else shows a ready-only mod - -- manager. - if isHost then - GUI.gameoptionsButton = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/', "") - Tooltip.AddButtonTooltip(GUI.gameoptionsButton, 'lob_select_map') - GUI.gameoptionsButton.OnClick = function(self) - local mapSelectDialog - - autoRandMap = false - local function selectBehavior(selectedScenario, changedOptions, restrictedCategories) - local options = {} - if autoRandMap then - options['ScenarioFile'] = selectedScenario.file - else - mapSelectDialog:Destroy() - GUI.chatEdit:AcquireFocus() - - -- remove old 'Advanced options incase of new map - if gameInfo.GameOptions.ScenarioFile and string.lower(selectedScenario.file) ~= string.lower(gameInfo.GameOptions.ScenarioFile) then - local scenario = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - if scenario.options then - for _,value in scenario.options do - gameInfo.GameOptions[value.key] = nil - end - end - end - - for optionKey, data in changedOptions do - options[optionKey] = data.value - end - options['ScenarioFile'] = selectedScenario.file - options['RestrictedCategories'] = restrictedCategories - - -- every new map, clear the flags, and clients will report if a new map is bad - ClearBadMapFlags() - HostUtils.UpdateMods() - SetGameOptions(options) - end - for optionKey, data in changedOptions do - if optionKey == 'AutoTeams' then - AssignAutoTeams() - end - end - end - - local function exitBehavior() - mapSelectDialog:Close() - GUI.chatEdit:AcquireFocus() - UpdateGame() - end - - GUI.chatEdit:AbandonFocus() - - mapSelectDialog = import("/lua/ui/dialogs/mapselect.lua").CreateDialog( - selectBehavior, - exitBehavior, - GUI, - singlePlayer, - gameInfo.GameOptions.ScenarioFile, - gameInfo.GameOptions, - availableMods, - OnModsChanged - ) - end - else - local modsManagerCallback = function(active_sim_mods, active_ui_mods) - import("/lua/mods.lua").SetSelectedMods(SetUtils.Union(active_sim_mods, active_ui_mods)) - RefreshOptionDisplayData() - GUI.chatEdit:AcquireFocus() - end - GUI.gameoptionsButton = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/', LOC("")) - GUI.gameoptionsButton.OnClick = function(self, modifiers) - import("/lua/ui/lobby/modsmanager.lua").CreateDialog(GUI, false, nil, modsManagerCallback) - end - Tooltip.AddButtonTooltip(GUI.gameoptionsButton, 'Lobby_Mods') - end - - LayoutHelpers.AtBottomIn(GUI.gameoptionsButton, GUI.optionsPanel, -51) - LayoutHelpers.AtHorizontalCenterIn(GUI.gameoptionsButton, GUI.optionsPanel, 53) - - --------------------------------------------------------------------------- - -- set up chat display - --------------------------------------------------------------------------- - - GUI.chatDisplay = import("/lua/ui/lobby/chatarea.lua").ChatArea( - GUI.chatPanel, - function() return GUI.chatPanel.Width() - 20 end, - function() return GUI.chatPanel.Height() - GUI.chatBG.Height() end - ) - LayoutHelpers.AtLeftTopIn(GUI.chatDisplay, GUI.chatPanel, 2) - LayoutHelpers.DepthUnderParent(GUI.chatDisplay, GUI.chatPanel) - - --------------------------------------------------------------------------- - -- set up all .*Scroll* functions for the chat panel - --------------------------------------------------------------------------- - GUI.chatPanel.top = 1 -- using 1-based index scrolling - - -- this function get index of 1st line on the last scroll page (when scroll all the way down) - GUI.chatPanel.GetScrollLastPage = function(self) - return table.getn(GUI.chatDisplay.ChatLines) - self.linesPerScrollPage - end - -- this function gets scrolling max range and current range - GUI.chatPanel.GetScrollValues = function(self, axis) - local max = table.getsize(GUI.chatDisplay.ChatLines) - local bottom = math.min(self.top + self.linesPerScrollPage, max) - return 1, max, self.top, bottom - end - -- this function controls how many lines to scroll when clicking on up/down arrows of the scrollbar - GUI.chatPanel.ScrollLines = function(self, axis, delta) - self:ScrollSetTop(axis, self.top + math.floor(delta)) - end - -- this function controls how many pages to scroll when clicking above/below thumb of the scrollbar - GUI.chatPanel.ScrollPages = function(self, axis, delta) - self:ScrollSetTop(axis, self.top + math.floor(delta) * self.linesPerScrollPage) - end - -- this function controls how to scroll to an item from top index - GUI.chatPanel.ScrollSetTop = function(self, axis, top) - top = math.floor(top) - if top == self.top then return end - local delta = self:GetScrollLastPage() - self.top = math.max(math.min(delta + 1, top), 1) - self.bottom = self.top + self.linesPerScrollPage - GUI.chatDisplay:ShowLines(self.top, self.bottom) - if self.top >= delta + 1 then - GUI.newMessageArrow:Disable() - end - end - -- this function triggers scrolling on mouse wheel event - GUI.chatPanel.HandleEvent = function(self, event) - if event.Type == 'WheelRotation' then - -- scroll chat panel by 1 line in up/down direction - local lines = event.WheelRotation > 0 and -1 or 1 - self:ScrollLines(nil, lines) - end - end - -- this function informs vertical scrollbar that the chat panel can be scrolled - GUI.chatPanel.IsScrollable = function(self, axis) - return true - end - GUI.chatPanel.ScrollToBottom = function(self) - self:ScrollSetTop(nil, self:GetScrollLastPage() + 1) - end - GUI.chatPanel.IsScrolledToBottom = function(self) - return self.top >= self:GetScrollLastPage() - end - -- this function set how many chat lines can fit per scroll page (chatPanel) - GUI.chatPanel.LinesOnPage = import("/lua/lazyvar.lua").Create() - GUI.chatPanel.LinesOnPage.OnDirty = function(var) - GUI.chatPanel.linesPerScrollPage = var() - end - -- --------- Chat Scrolling Functions ----------------------- - - -- this function sets font for all chat lines and re-creates them - GUI.chatPanel.SetFont = function(self, fontFamily, fontSize) - GUI.chatDisplay:SetFont(fontFamily, fontSize) - GUI.chatDisplay:ShowLines(self.top, self.bottom) - end - -- set initial scrolling based on chat font size - local fontSize = tonumber(Prefs.GetFromCurrentProfile('LobbyChatFontSize')) or 14 - - local newMessageArrow = Button(GUI.chatPanel, '/textures/ui/common/lobby/chat_arrow/arrow_up.dds', '/textures/ui/common/lobby/chat_arrow/arrow_down.dds', '/textures/ui/common/lobby/chat_arrow/arrow_down.dds','/textures/ui/common/lobby/chat_arrow/arrow_dis.dds', "UI_Arrow_Click") - GUI.newMessageArrow = newMessageArrow - -- newMessageArrow:SetTexture('/textures/ui/common/FACTIONSELECTOR/aeon/d_up.dds') - LayoutHelpers.AtBottomIn(newMessageArrow, GUI.chatDisplay, 5) - LayoutHelpers.AtRightIn(newMessageArrow, GUI.chatDisplay, 5) - LayoutHelpers.DepthOverParent(newMessageArrow, GUI.chatDisplay, 5) - newMessageArrow.Width:Set(25) - newMessageArrow.Height:Set(25) - GUI.newMessageArrow.OnClick = function(this, modifiers) - GUI.chatPanel:ScrollToBottom() - end - GUI.newMessageArrow:Disable() - - -- Annoying evil extra Bitmap to make chat box have padding inside its background. - local chatBG = Bitmap(GUI.chatPanel) - GUI.chatBG = chatBG - chatBG:SetSolidColor('FF212123') - LayoutHelpers.Below(chatBG, GUI.chatDisplay, 0) - LayoutHelpers.AtLeftIn(chatBG, GUI.chatDisplay, -2) - chatBG.Width:Set(GUI.chatPanel.Width) - LayoutHelpers.SetHeight(chatBG, 24) - - -- Set up the chat edit buttons and functions - setupChatEdit(GUI.chatPanel) - -- finally create chat lines - GUI.chatDisplay:CreateLines() - --------------------------------------------------------------------------- - -- Option display - --------------------------------------------------------------------------- - GUI.OptionContainer = Group(GUI.optionsPanel) - GUI.OptionContainer.Bottom:Set(function() return GUI.optionsPanel.Bottom() end) - - -- Leave space for the scrollbar. - GUI.OptionContainer.Width:Set(function() return GUI.optionsPanel.Width() - LayoutHelpers.ScaleNumber(18) end) - GUI.OptionContainer.top = 0 - LayoutHelpers.AtLeftTopIn(GUI.OptionContainer, GUI.optionsPanel, 1, 1) - LayoutHelpers.DepthOverParent(GUI.OptionContainer, GUI.optionsPanel, -1) - - GUI.OptionDisplay = {} - - function CreateOptionElements() - local function CreateElement(index) - local element = Group(GUI.OptionContainer) - - element.bg = Bitmap(element) - element.bg:SetSolidColor('ff333333') - element.bg.Left:Set(element.Left) - element.bg.Right:Set(element.Right) - element.bg.Bottom:Set(function() return element.value.Bottom() + 2 end) - element.bg.Top:Set(element.Top) - - element.bg2 = Bitmap(element) - element.bg2:SetSolidColor('ff000000') - element.bg2.Left:Set(function() return element.bg.Left() + 1 end) - element.bg2.Right:Set(function() return element.bg.Right() - 1 end) - element.bg2.Bottom:Set(function() return element.bg.Bottom() - 1 end) - element.bg2.Top:Set(function() return element.value.Top() + 0 end) - - LayoutHelpers.SetHeight(element, 36) - element.Width:Set(GUI.OptionContainer.Width) - element:DisableHitTest() - - element.text = UIUtil.CreateText(element, '', 14, "Arial") - element.text:SetColor(UIUtil.fontColor) - element.text:DisableHitTest() - LayoutHelpers.AtLeftTopIn(element.text, element, 5) - - element.value = UIUtil.CreateText(element, '', 14, "Arial") - element.value:SetColor(UIUtil.fontOverColor) - element.value:DisableHitTest() - LayoutHelpers.AtRightTopIn(element.value, element, 5, 16) - - GUI.OptionDisplay[index] = element - end - - CreateElement(1) - LayoutHelpers.AtLeftTopIn(GUI.OptionDisplay[1], GUI.OptionContainer) - - local index = 2 - while index ~= 10 do - CreateElement(index) - LayoutHelpers.Below(GUI.OptionDisplay[index], GUI.OptionDisplay[index-1]) - index = index + 1 - end - end - CreateOptionElements() - - local numLines = function() return table.getsize(GUI.OptionDisplay) end - - local function DataSize() - if HideDefaultOptions then - return table.getn(nonDefaultFormattedOptions) - else - return table.getn(formattedOptions) - end - end - - -- called when the scrollbar for the control requires data to size itself - -- GetScrollValues must return 4 values in this order: - -- rangeMin, rangeMax, visibleMin, visibleMax - -- aixs can be "Vert" or "Horz" - GUI.OptionContainer.GetScrollValues = function(self, axis) - local size = DataSize() - --LOG(size, ":", self.top, ":", math.min(self.top + numLines, size)) - return 0, size, self.top, math.min(self.top + numLines(), size) - end - - -- called when the scrollbar wants to scroll a specific number of lines (negative indicates scroll up) - GUI.OptionContainer.ScrollLines = function(self, axis, delta) - self:ScrollSetTop(axis, self.top + math.floor(delta)) - end - - -- called when the scrollbar wants to scroll a specific number of pages (negative indicates scroll up) - GUI.OptionContainer.ScrollPages = function(self, axis, delta) - self:ScrollSetTop(axis, self.top + math.floor(delta) * numLines()) - end - - -- called when the scrollbar wants to set a new visible top line - GUI.OptionContainer.ScrollSetTop = function(self, axis, top) - top = math.floor(top) - if top == self.top then return end - local size = DataSize() - self.top = math.max(math.min(size - numLines() , top), 0) - self:CalcVisible() - end - - -- called to determine if the control is scrollable on a particular access. Must return true or false. - GUI.OptionContainer.IsScrollable = function(self, axis) - return true - end - -- determines what controls should be visible or not - GUI.OptionContainer.CalcVisible = function(self) - local function SetTextLine(line, data, lineID) - if data.mod then - -- The special label at the top stating the number of mods. - line.text:SetColor('ffff7777') - LayoutHelpers.AtHorizontalCenterIn(line.text, line, 5) - LayoutHelpers.AtHorizontalCenterIn(line.value, line, 5, 16) - LayoutHelpers.ResetRight(line.value) - else - -- Game options. - line.text:SetColor(UIUtil.fontColor) - LayoutHelpers.AtLeftTopIn(line.text, line, 5) - LayoutHelpers.AtRightTopIn(line.value, line, 5, 16) - LayoutHelpers.ResetLeft(line.value) - end - line.text:SetText(LOCF(data.text, data.key)) - line.bg:Show() - line.value:SetText(LOCF(data.value, data.key)) - line.bg2:Show() - line.bg.HandleEvent = Group.HandleEvent - line.bg2.HandleEvent = Bitmap.HandleEvent - if data.tooltip then - Tooltip.AddControlTooltip(line.bg, data.tooltip) - Tooltip.AddControlTooltip(line.bg2, data.valueTooltip) - end - - if data.manualTooltipTitle then - Tooltip.AddControlTooltipManual(line.bg, data.manualTooltipTitle, data.manualTooltipDescription ) - Tooltip.AddControlTooltipManual(line.bg2, data.manualTooltipTitle, data.manualTooltipDescription ) - end - - end - - local optionsToUse - if HideDefaultOptions then - optionsToUse = nonDefaultFormattedOptions - else - optionsToUse = formattedOptions - end - - for i, v in GUI.OptionDisplay do - if optionsToUse[i + self.top] then - SetTextLine(v, optionsToUse[i + self.top], i + self.top) - else - v.text:SetText('') - v.value:SetText('') - v.bg:Hide() - v.bg2:Hide() - end - end - end - - GUI.OptionContainer.HandleEvent = function(self, event) - if event.Type == 'WheelRotation' then - local lines = 1 - if event.WheelRotation > 0 then - lines = -1 - end - self:ScrollLines(nil, lines) - end - end - - RefreshOptionDisplayData() - - GUI.OptionContainerScroll = UIUtil.CreateLobbyVertScrollbar(GUI.OptionContainer, 2) - LayoutHelpers.DepthOverParent(GUI.OptionContainerScroll, GUI.OptionContainer, 2) - - -- Launch Button - local launchGameButton = UIUtil.CreateButtonWithDropshadow(GUI.chatPanel, '/BUTTON/large/', LOC("Launch Game")) - GUI.launchGameButton = launchGameButton - LayoutHelpers.AtHorizontalCenterIn(launchGameButton, GUI) - LayoutHelpers.AtBottomIn(launchGameButton, GUI.panel, -8) - Tooltip.AddButtonTooltip(launchGameButton, 'Lobby_Launch') - UIUtil.setVisible(launchGameButton, isHost) - launchGameButton.OnClick = function(self) - TryLaunch(false) - end - - -- Create skirmish mode's "load game" button. - local loadButton = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/',"Load") - GUI.loadButton = loadButton - UIUtil.setVisible(loadButton, singlePlayer) - LayoutHelpers.AtVerticalCenterIn(GUI.loadButton, launchGameButton, 7) - LayoutHelpers.AtHorizontalCenterIn(GUI.loadButton, GUI.optionsPanel) - loadButton.OnClick = function(self, modifiers) - import("/lua/ui/dialogs/saveload.lua").CreateLoadDialog(GUI) - end - Tooltip.AddButtonTooltip(loadButton, 'Lobby_Load') - - -- Create the "Lobby presets" button for the host. If not the host, the same field is occupied - -- instead by the read-only "Unit Manager" button. - GUI.restrictedUnitsOrPresetsBtn = UIUtil.CreateButtonWithDropshadow(GUI.optionsPanel, '/BUTTON/medium/', "") - - if singlePlayer then - GUI.restrictedUnitsOrPresetsBtn:Hide() - elseif isHost then - GUI.restrictedUnitsOrPresetsBtn.label:SetText(LOC("Presets")) - GUI.restrictedUnitsOrPresetsBtn.OnClick = function(self, modifiers) - Presets.CreateUI(GUI) - end - Tooltip.AddButtonTooltip(GUI.restrictedUnitsOrPresetsBtn, 'Lobby_presetDescription') - else - GUI.restrictedUnitsOrPresetsBtn.label:SetText(LOC("Unit Manager")) - GUI.restrictedUnitsOrPresetsBtn.OnClick = function(self, modifiers) - import("/lua/ui/lobby/unitsmanager.lua").CreateDialog(GUI.panel, gameInfo.GameOptions.RestrictedCategories, function() end, function() end, false) - end - Tooltip.AddButtonTooltip(GUI.restrictedUnitsOrPresetsBtn, 'lob_RestrictedUnitsClient') - end - LayoutHelpers.AtVerticalCenterIn(GUI.restrictedUnitsOrPresetsBtn, launchGameButton, 7) - LayoutHelpers.AtHorizontalCenterIn(GUI.restrictedUnitsOrPresetsBtn, GUI.optionsPanel) - - --------------------------------------------------------------------------- - -- Checkbox Show changed Options - --------------------------------------------------------------------------- - cbox_ShowChangedOption:SetCheck(HideDefaultOptions, false) - - --------------------------------------------------------------------------- - -- set up : player grid - --------------------------------------------------------------------------- - - -- For disgusting reasons, we pass the label factory as a parameter. - CreateSlotsUI(makeLabel) - - -- Exit Button - GUI.exitButton = UIUtil.CreateButtonWithDropshadow(GUI.chatPanel, '/BUTTON/medium/', LOC("Exit")) - LayoutHelpers.AtLeftIn(GUI.exitButton, GUI.chatPanel, 33) - LayoutHelpers.AtVerticalCenterIn(GUI.exitButton, launchGameButton, 7) - if HasCommandLineArg("/gpgnet") then - -- Quit to desktop - GUI.exitButton.label:SetText(LOC("")) - Tooltip.AddButtonTooltip(GUI.exitButton, 'esc_exit') - else - -- Back to main menu - GUI.exitButton.label:SetText(LOC("")) - Tooltip.AddButtonTooltip(GUI.exitButton, 'esc_quit') - end - - GUI.exitButton.OnClick = GUI.exitLobbyEscapeHandler - - - -- Small buttons are 100 wide, 44 tall - - -- Default option button - GUI.defaultOptions = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/defaultoption/') - -- If we're the host, position the buttons lower down (and eventually shrink the observer panel) - if not isHost then - GUI.defaultOptions:Hide() - end - LayoutHelpers.AtLeftTopIn(GUI.defaultOptions, GUI.observerPanel, 11, -94) - - Tooltip.AddButtonTooltip(GUI.defaultOptions, 'lob_click_rankedoptions') - if not isHost then - GUI.defaultOptions:Disable() - else - GUI.defaultOptions.OnClick = function() - UIUtil.QuickDialog(GUI, LOC('Are you sure you want to reset to default values?'), - "", function() - -- Return all options to their default values. - OptionUtils.SetDefaults() - lobbyComm:BroadcastData({ Type = "SetAllPlayerNotReady" }) - UpdateGame() - end, - - "", nil, - nil, nil, - true - ) - end - end - - -- RANDOM MAP BUTTON -- - GUI.randMap = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/randommap/') - LayoutHelpers.RightOf(GUI.randMap, GUI.defaultOptions, -19) - Tooltip.AddButtonTooltip(GUI.randMap, 'lob_click_randmap') - if not isHost then - GUI.randMap:Hide() - else - GUI.randMap.OnClick = function() - local randomMap - local mapSelectDialog - - autoRandMap = false - - -- Load the set of all available maps, with a slight evil hack on the mapselect module. - local mapDialog = import("/lua/ui/dialogs/mapselect.lua") - local allMaps = mapDialog.LoadScenarios() -- Result will be cached. - - -- Only include maps which have enough slots for the players we have. - local filteredMaps = table.filter(allMaps, - function(scenInfo) - local supportedPlayers = table.getsize(scenInfo.Configurations.standard.teams[1].armies) - return supportedPlayers >= GetPlayerCount() - end - ) - local mapCount = table.getn(filteredMaps) - local selectedMap = filteredMaps[math.floor(math.random(1, mapCount))] - - -- Set the new map. - SetGameOption('ScenarioFile', selectedMap.file) - ClearBadMapFlags() - UpdateGame() - end - end - - local autoteamButtonStates = { - { - key = 'tvsb', - tooltip = 'lob_auto_tvsb' - }, - { - key = 'lvsr', - tooltip = 'lob_auto_lvsr' - }, - { - key = 'pvsi', - tooltip = 'lob_auto_pvsi' - }, - { - key = 'manual', - tooltip = 'lob_auto_manual' - }, - { - key = 'none', - tooltip = 'lob_auto_none' - }, - } - - local initialState = Prefs.GetFromCurrentProfile("LobbyOpt_AutoTeams") or "none" - GUI.autoTeams = ToggleButton(GUI.observerPanel, '/BUTTON/autoteam/', autoteamButtonStates, initialState) - - LayoutHelpers.RightOf(GUI.autoTeams, GUI.randMap, -19) - if not isHost then - GUI.autoTeams:Hide() - else - GUI.autoTeams.OnStateChanged = function(self, newState) - SetGameOption('AutoTeams', newState) - AssignAutoTeams() - end - end - - -- CLOSE/OPEN EMPTY SLOTS BUTTON -- - GUI.closeEmptySlots = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/closeslots/') - Tooltip.AddButtonTooltip(GUI.closeEmptySlots, 'lob_close_empty_slots') - if not isHost then - GUI.closeEmptySlots:Hide() - LayoutHelpers.AtLeftTopIn(GUI.closeEmptySlots, GUI.defaultOptions, -40, 43) - else - LayoutHelpers.AtLeftTopIn(GUI.closeEmptySlots, GUI.defaultOptions, -31, 43) - GUI.closeEmptySlots.OnClick = function(self, modifiers) - if lobbyComm:IsHost() then - if modifiers.Ctrl then - for slot = 1,numOpenSlots do - HostUtils.SetSlotClosed(slot, false) - end - return - end - local openSpot = false - for slot = 1,numOpenSlots do - openSpot = openSpot or not (gameInfo.PlayerOptions[slot] or gameInfo.ClosedSlots[slot]) - end - if modifiers.Right and gameInfo.AdaptiveMap then - for slot = 1,numOpenSlots do - if openSpot then - if not (gameInfo.PlayerOptions[slot] or gameInfo.ClosedSlots[slot]) then - HostUtils.SetSlotClosedSpawnMex(slot) - end - else - if gameInfo.ClosedSlots[slot] and gameInfo.SpawnMex[slot] then - HostUtils.SetSlotClosed(slot, false) - end - end - end - else - for slot = 1,numOpenSlots do - if not gameInfo.SpawnMex[slot] then - HostUtils.SetSlotClosed(slot, openSpot) - end - end - end - end - end - end - - - -- GO OBSERVER BUTTON -- - GUI.becomeObserver = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/observer/') - LayoutHelpers.RightOf(GUI.becomeObserver, GUI.closeEmptySlots, -25) - Tooltip.AddButtonTooltip(GUI.becomeObserver, 'lob_become_observer') - GUI.becomeObserver.OnClick = function() - if IsPlayer(localPlayerID) then - if isHost then - HostUtils.ConvertPlayerToObserver(FindSlotForID(localPlayerID)) - else - lobbyComm:SendData(hostID, {Type = 'RequestConvertToObserver'}) - end - elseif IsObserver(localPlayerID) then - if isHost then - HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(localPlayerID)) - else - lobbyComm:SendData(hostID, {Type = 'RequestConvertToPlayer'}) - end - end - end - - -- CPU BENCH BUTTON -- - GUI.rerunBenchmark = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/cputest/', '', 11) - LayoutHelpers.RightOf(GUI.rerunBenchmark, GUI.becomeObserver, -25) - Tooltip.AddButtonTooltip(GUI.rerunBenchmark,{text=LOC("Run CPU Benchmark Test"), body=LOC("Recalculates your CPU rating.")}) - GUI.rerunBenchmark.OnClick = function(self, modifiers) - ForkThread(function() UpdateBenchmark(true) end) - end - - -- Autobalance Button -- - GUI.PenguinAutoBalance = UIUtil.CreateButtonStd(GUI.observerPanel, '/BUTTON/autobalance/') - LayoutHelpers.RightOf(GUI.PenguinAutoBalance, GUI.rerunBenchmark, -25) - Tooltip.AddButtonTooltip(GUI.PenguinAutoBalance, {text=LOC("Autobalance"), body=LOC("Automatically balance players into 2 equally sized teams")}) - if not isHost then - GUI.PenguinAutoBalance:Hide() - else - -- What this does: it balances all occupied slots into two teams with equal numbers of - -- players. If teams are set manually and half of the occupied slots are set to team 1 - -- and half to team 2, then it balances the players while keeping the team-slot matches. - -- If the teams are set manually, but there is an uneven number of players on teams 1 - -- and 2, then players' teams are changed automatically to be alternating team 1 and team 2. - -- If there are an odd number of occupied slots, the last one is set to team - (no team) - -- and the others are balanced without it. Alternatively, if teams are not set manually, - -- players will be balanced into the slowest available slot numbers on their teams. - -- If there is an odd number of players in that case, the last player will be made an - -- observer if human or removed if AI. - - -- How it balances: this function checks every possible balance combination for making - -- the two teams (while keeping their player counts equal to half the number of occupied - -- slots, rounded down, and not using the last player if there is an odd number of players). - -- To do this, the function sums up all the relevant players' ratings (keeping mean and - -- deviation separate - it balances teams to have similar total ratings, and also similar - -- total uncertainties (grayness)), and then divides by two. That yields the goal values - -- for each team. Any deviation from those values is calculated to help determine a team's - -- imbalance value. Then, the various team combinations are tested, and the one with the - -- lowest imbalance value is used. - - - -- Automatically balance an even number of non-observer players into 2 teams in the lobby - GUI.PenguinAutoBalance.OnClick = function() - - -- make sure spawns are set to fixed or penguin_autobalance - if gameInfo.GameOptions.TeamSpawn ~= 'fixed' and gameInfo.GameOptions.TeamSpawn ~= 'penguin_autobalance' then - gameInfo.GameOptions.TeamSpawn = 'fixed' - -- tell everyone else to set spawns to fixed - lobbyComm:BroadcastData { - Type = 'GameOptions', - Options = {['TeamSpawn'] = 'fixed'} - } - AddChatText(LOC("Enabled fixed spawn locations")) - end - - -- a table of the target mean, target deviation, and the lowest logged imbalance value - local goalValue = {0, 0, 99999} - - local playerCount = 0 - -- a table of the highest occupied slot's slot number, that slot's player's order number - -- in the playerRatings table, and a booleon of whether or not that player is human - local lastSlot = {0, 0, false} - local playerRatings = {} - -- get rating data for each player - for i, player in gameInfo.PlayerOptions:pairs() do - playerRatings[i] = {player.MEAN, player.DEV, player.StartSpot, player.Team - 1} - playerCount = playerCount + 1 - if player.StartSpot > lastSlot[1] then - lastSlot = {player.StartSpot, i, player.Human} - end - goalValue[1] = goalValue[1] + player.MEAN - goalValue[2] = goalValue[2] + player.DEV - end - - -- if there are fewer than 2 players, there is no need to balance - if playerCount < 2 then - UpdateGame() - return - end - - -- if there is an odd number of players, remove the last one from the balancing - if math.mod(playerCount, 2) == 1 then - goalValue[1] = goalValue[1] - playerRatings[lastSlot[2]][1] - goalValue[2] = goalValue[2] - playerRatings[lastSlot[2]][2] - playerRatings[lastSlot[2]] = nil - playerCount = playerCount - 1 - -- set the player to not be on a team if teams are manual and fixed - -- otherwise make the player an observer if human or remove it if AI - if gameInfo.GameOptions.AutoTeams == 'none' and gameInfo.GameOptions.TeamSpawn == 'fixed' then - for i, player in gameInfo.PlayerOptions:pairs() do - if player.StartSpot == lastSlot[1] then - player.Team = 1 -- no team - break - end - end - else - if lastSlot[3] then - HostUtils.ConvertPlayerToObserver(lastSlot[1]) - else - HostUtils.RemoveAI(lastSlot[1]) - end - end - end - - -- the goal value is all of the remaining players' ratings divided by 2 - goalValue[1] = goalValue[1] / 2 - goalValue[2] = goalValue[2] / 2 - - local sortedPlayerRatings = {} - local sortedSlotTeams = {} - local numPlayersTeam1 = 0 - local numPlayersTeam2 = 0 - local sortingValue1 - local sortingValue2 - -- sort the players in a weighted cross between displayed and base rating - -- the order goes from greatest to lowest result of: mean - (deviation * 2.2) - for i, player in playerRatings do - local orderNum = 1 - sortingValue1 = player[1] - (player[2] * 2.2) - for i2, player2 in playerRatings do - sortingValue2 = player2[1] - (player2[2] * 2.2) - if sortingValue1 < sortingValue2 or (sortingValue1 == sortingValue2 and i > i2) then - orderNum = orderNum + 1 - end - end - -- these are sorted in parallel - sortedPlayerRatings[orderNum] = {player[1], player[2]} - sortedSlotTeams[orderNum] = {player[3], player[4]} - if player[4] == 1 then - numPlayersTeam1 = numPlayersTeam1 + 1 - elseif player[4] == 2 then - numPlayersTeam2 = numPlayersTeam2 + 1 - end - end - - - -- the number of players per team - local teamSize = playerCount / 2 - - - -- make the sorted list of slots for each team - local sortedTeam1Slots = {} - local sortedTeam2Slots = {} - local team1OrderNum = 0 - local team2OrderNum = 0 - - local manualTeams - - if gameInfo.GameOptions.AutoTeams == 'pvsi' then -- odd vs even - for i = 1, 16 do - if not gameInfo.ClosedSlots[i] then - if math.mod(i, 2) == 1 then - team1OrderNum = team1OrderNum + 1 - sortedTeam1Slots[team1OrderNum] = i - else - team2OrderNum = team2OrderNum + 1 - sortedTeam2Slots[team2OrderNum] = i - end - end - end - elseif gameInfo.GameOptions.AutoTeams == 'tvsb' then -- top vs bottom - local midLine = GUI.mapView.Top() + (GUI.mapView.Height() / 2) - for i, startPosition in GUI.mapView.startPositions do - if not gameInfo.ClosedSlots[i] then - if startPosition.Top() < midLine then - team1OrderNum = team1OrderNum + 1 - sortedTeam1Slots[team1OrderNum] = i - else - team2OrderNum = team2OrderNum + 1 - sortedTeam2Slots[team2OrderNum] = i - end - end - end - elseif gameInfo.GameOptions.AutoTeams == 'lvsr' then -- left vs right - local midLine = GUI.mapView.Left() + (GUI.mapView.Width() / 2) - for i, startPosition in GUI.mapView.startPositions do - if not gameInfo.ClosedSlots[i] then - if startPosition.Left() < midLine then - team1OrderNum = team1OrderNum + 1 - sortedTeam1Slots[team1OrderNum] = i - else - team2OrderNum = team2OrderNum + 1 - sortedTeam2Slots[team2OrderNum] = i - end - end - end - else - manualTeams = true - end - - -- If the teams were not set properly, set them properly. - -- When teams are set manually, they are not set properly if the number of - -- players on either team does not equal the team size. - -- When teams are not set manually, they are not set properly if the number - -- of slots on either team is less than the team size. - if (manualTeams and (numPlayersTeam1 != teamSize or numPlayersTeam2 != teamSize)) - or (not manualTeams and (table.getn(sortedTeam1Slots) < teamSize or table.getn(sortedTeam2Slots) < teamSize)) then - -- set AutoTeams to none (so, they can be set by slot by this function) - gameInfo.GameOptions.AutoTeams = 'none' - local counter = 0 - for i, player in gameInfo.PlayerOptions:pairs() do - for i2, slotTeam in sortedSlotTeams do - if player.StartSpot == slotTeam[1] then - counter = counter + 1 - -- set the player's team - if math.mod(counter, 2) == 1 then - player.Team = 2 -- team 1 - slotTeam[2] = 1 - else - player.Team = 3 -- team 2 - slotTeam[2] = 2 - end - -- tell everyone else the team number for that slot - lobbyComm:BroadcastData( - { - Type = 'PlayerOptions', - Options = {['Team'] = slotTeam[2] + 1}, -- make team number 1 higher for the backend - Slot = slotTeam[1], - }) - break - end - end - end - end - - -- if teams are set to manual, make the sorted list of slots for each team - if gameInfo.GameOptions.AutoTeams == 'none' then - sortedTeam1Slots = {} - sortedTeam2Slots = {} - for i, slotTeam in sortedSlotTeams do - team1OrderNum = 0 - team2OrderNum = 0 - for i2, slotTeam2 in sortedSlotTeams do - if slotTeam[1] > slotTeam2[1] or (slotTeam[1] == slotTeam2[1] and i >= i2) then - if slotTeam2[2] == 1 then - team1OrderNum = team1OrderNum + 1 - else - team2OrderNum = team2OrderNum + 1 - end - end - end - -- add the slot to its team's table - if slotTeam[2] == 1 then - sortedTeam1Slots[team1OrderNum] = slotTeam[1] - else - sortedTeam2Slots[team2OrderNum] = slotTeam[1] - end - end - end - - - - -- a table of team1's mean, deviation, and imbalance value - local teamValue - -- a table of team members - local team1 = {} - -- a table of the most balanced team - local bestTeam = {} - local choosableCount = playerCount - teamSize - - -- the number of iterations is the number of team combinations to check, which is - -- exactly half of the number of possible teams, which covers every possibility, - -- since the remaining half are just the opposite of what was already checked, - -- which means they have the exact same balance - -- ie: Player A + Player B vs Player C + Player D == Player C + Player D vs Player A + Player B - -- this works because of the order in which the combinations are tested - local numIterations - if teamSize == 2 then - numIterations = 3 - elseif teamSize == 3 then - numIterations = 10 - elseif teamSize == 4 then - numIterations = 35 - elseif teamSize == 5 then - numIterations = 126 - elseif teamSize == 6 then - numIterations = 462 - elseif teamSize == 7 then - numIterations = 1716 - else - numIterations = 6435 - end - - local currentIteration = 0 - - -- test the balance of different combinations of teams, covering balance possibility - -- intended for use with 2 teams of even player counts - -- combinations are iterated starting with the lowest-numbered players on team1 first, - -- and progressively iterating the highest-numbered player on team1 to each higher-numbered - -- possible player, and then repeating the process with the next highest-numbered player - -- increasing by 1... this process continues until every possible balacnce combination - -- of 2 equally sized teams of even player counts has been covered - local function testCombinations(team1MemberNumber, firstPlayerToCheck) - -- check if this player is the last player on the team - local lastPlayer - if team1MemberNumber < teamSize then - lastPlayer = false - else - lastPlayer = true - end - -- iterate through the possible players for this team1MemberNumber - for i = firstPlayerToCheck, choosableCount + team1MemberNumber do - -- when the number of iterations is reached, every possible balance of even player count - -- of the 2 equally sized teams has been checked, and the function ends - if currentIteration >= numIterations then - return - end - team1[team1MemberNumber] = i - if lastPlayer then - -- test this combination of team members - teamValue = {0, 0, 0} - -- add each team member's base rating and devation to the team's values - for i, player in team1 do - teamValue[1] = teamValue[1] + sortedPlayerRatings[player][1] - teamValue[2] = teamValue[2] + sortedPlayerRatings[player][2] - end - -- calculate the team's imbalance value - teamValue[3] = math.abs(teamValue[2] - goalValue[2]) * 1.2 + math.abs(teamValue[1] - goalValue[1]) - -- check if the team's imbalance value is lower than the lowest logged imbalance value - if teamValue[3] < goalValue[3] then - -- if it is lower, then this is the best balance so far, and it is logged over the previous best balance - goalValue[3] = teamValue[3] - -- deepcopy the team's player numbers - for i, player in team1 do - bestTeam[i] = player - end - end - currentIteration = currentIteration + 1 - else - -- test a subset of combinations - testCombinations(team1MemberNumber + 1, i + 1) - end - end - end - - testCombinations(1, 1) - - - -- specify the players on team 2 (aka, the ones not on team 1) - local bestTeam2 = {} - for i = 1, playerCount do - if not table.find(bestTeam, i) then - table.insert(bestTeam2, i) - end - end - - --shuffle player pairs - local random - local temp - for i, slot in bestTeam do - random = Random(1, teamSize) - - --random swap on team 1 - temp = bestTeam[random] - bestTeam[random] = bestTeam[i] - bestTeam[i] = temp - - --mirrored swap on team2 - temp = bestTeam2[random] - bestTeam2[random] = bestTeam2[i] - bestTeam2[i] = temp - end - - -- move players on team1 to the intended slots - local team1OrderNum = 0 - local slotA - local slotB - for i, player in bestTeam do - team1OrderNum = team1OrderNum + 1 - slotA = sortedSlotTeams[player][1] - slotB = sortedTeam1Slots[team1OrderNum] - HostUtils.SwapPlayers(slotA, slotB) - -- keep track of the slot changes in sortedSlotTeams - for i, slotTeam in sortedSlotTeams do - if slotTeam[1] == slotB then - slotTeam[1] = slotA - break - end - end - sortedSlotTeams[player][1] = slotB - end - - -- move players on team2 to the intended slots - local team2OrderNum = 0 - for i, player in bestTeam2 do - team2OrderNum = team2OrderNum + 1 - slotA = sortedSlotTeams[player][1] - slotB = sortedTeam2Slots[team2OrderNum] - HostUtils.SwapPlayers(slotA, slotB) - -- keep track of the slot changes in sortedSlotTeams - for i, slotTeam in sortedSlotTeams do - if slotTeam[1] == slotB then - slotTeam[1] = slotA - break - end - end - sortedSlotTeams[player][1] = slotB - end - UpdateGame() - AddChatText(LOC("Finished autobalancing")) - end - end - - -- Observer List - GUI.observerList = ItemList(GUI.observerPanel) - GUI.observerList:SetFont(UIUtil.bodyFont, 12) - GUI.observerList:SetColors(UIUtil.fontColor, "00000000", UIUtil.fontOverColor, UIUtil.highlightColor, "ffbcfffe") - LayoutHelpers.AtLeftTopIn(GUI.observerList, GUI.observerPanel, 4, 2) - LayoutHelpers.AtRightBottomIn(GUI.observerList, GUI.observerPanel, 15) - GUI.observerList.OnClick = function(self, row, event) - if isHost and event.Modifiers.Right then - -- determine the number of teams (excluding the no team (-) option that equals 1 on the backend) - local teams = {} - local numTeams = 0 - for i, player in gameInfo.PlayerOptions:pairs() do - if not teams[player.Team] and player.Team ~= 1 then - teams[player.Team] = true - numTeams = numTeams + 1 - end - end - - -- adjust index by 1 because 0-based (ItemList rows) vs 1-based (Lua array) indexing - local obsIndex = row + 1 - local maxObsIndex = self:GetItemCount() - - -- adjust index by the number of rows taken up by team ratings. - ---@see refreshObserverList - if gameInfo.GameOptions['TeamSpawn'] == 'fixed' then - if numTeams < 3 then - obsIndex = obsIndex - numTeams - else - -- 3+ teams has ratings at the end of the list, don't allow kicking when clicking those rating rows - maxObsIndex = maxObsIndex - numTeams - end - end - - -- the host can get the kick dialog brought up for observer list rows that are players (aka, they have - -- a positive observer index and thereby aren't team ratings) and that aren't the local player (the host) - -- and that aren't the rows with team ratings when there are 3 or more teams - if obsIndex > 0 and gameInfo.Observers[obsIndex].OwnerID ~= localPlayerID and obsIndex <= maxObsIndex then - UIUtil.QuickDialog(GUI, "Are you sure?", - "Kick Player", function() - SendSystemMessage("lobui_0756", gameInfo.Observers[obsIndex].PlayerName) - lobbyComm:EjectPeer(gameInfo.Observers[obsIndex].OwnerID, "KickedByHost") - end, - "", nil, - nil, nil, - true, - {worldCover = false, enterButton = 1, escapeButton = 2} - ) - end - end - end - UIUtil.CreateLobbyVertScrollbar(GUI.observerList, 0, 0, -1) - - -- Setup large pretty faction selector and set the factional background to its initial value. - local lastFaction = GetSanitisedLastFaction() - CreateUI_Faction_Selector(lastFaction) - - RefreshLobbyBackground(lastFaction) - - GUI.uiCreated = true - - if singlePlayer then - -- observers are always allowed in skirmish games. - SetGameOption("AllowObservers", true) - end - - --------------------------------------------------------------------------- - -- other logic, including lobby callbacks - --------------------------------------------------------------------------- - GUI.posGroup = false - -- get ping times - GUI.pingThread = ForkThread( - function() - while lobbyComm do - for slot, player in gameInfo.PlayerOptions:pairs() do - if player.Human and player.OwnerID ~= localPlayerID then - local peer = lobbyComm:GetPeer(player.OwnerID) - local ping = peer.ping - local connectionStatus = CalcConnectionStatus(peer) - GUI.slots[slot].pingStatus.ConnectionStatus = connectionStatus - if ping then - ping = math.floor(ping) - GUI.slots[slot].pingStatus.PingActualValue = ping - GUI.slots[slot].pingStatus:SetValue(ping) - if ping > 500 then - GUI.slots[slot].pingStatus:Show() - else - GUI.slots[slot].pingStatus:Hide() - end - -- Set the ping bar to a colour representing the status of our connection. - GUI.slots[slot].pingStatus._bar:SetTexture(UIUtil.SkinnableFile('/game/unit_bmp/bar-0' .. connectionStatus .. '_bmp.dds')) - else - GUI.slots[slot].pingStatus:Hide() - end - end - end - WaitSeconds(1) - end - end) - if false then - import("/lua/ui/events/snowflake.lua"). CreateSnowFlakes(GUI) - end -end - -function setupChatEdit(chatPanel) - GUI.chatEdit = Edit(chatPanel) - LayoutHelpers.AtLeftTopIn(GUI.chatEdit, GUI.chatBG, 4, 3) - GUI.chatEdit.Width:Set(GUI.chatBG.Width() - LayoutHelpers.ScaleNumber(4)) - LayoutHelpers.SetHeight(GUI.chatEdit, 22) - GUI.chatEdit:SetFont(UIUtil.bodyFont, 16) - GUI.chatEdit:SetForegroundColor(UIUtil.fontColor) - GUI.chatEdit:ShowBackground(false) - GUI.chatEdit:SetDropShadow(true) - GUI.chatEdit:AcquireFocus() - - GUI.chatDisplayScroll = UIUtil.CreateLobbyVertScrollbar(chatPanel, -15, 25, 0) - - GUI.chatEdit:SetMaxChars(200) - GUI.chatEdit.OnCharPressed = function(self, charcode) - if charcode == UIUtil.VK_TAB then - return true - end - - local charLim = self:GetMaxChars() - if STR_Utf8Len(self:GetText()) >= charLim then - local sound = Sound({Cue = 'UI_Menu_Error_01', Bank = 'Interface',}) - PlaySound(sound) - end - end - - -- We work extremely hard to keep keyboard focus on the chat box, otherwise users can trigger - -- in-game keybindings in the lobby. - -- That would be very bad. We should probably instead just not assign those keybindings yet... - GUI.chatEdit.OnLoseKeyboardFocus = function(self) - self:AcquireFocus() - end - - local commandQueueIndex = 0 - local commandQueue = {} - GUI.chatEdit.OnEnterPressed = function(self, text) - if text:gsub("%s+", "") == '' then -- If the text, trimmed of all space, is equal to '' - return - end - GpgNetSend('Chat', text) - table.insert(commandQueue, 1, text) - commandQueueIndex = 0 - if string.sub(text, 1, 1) == '/' then - local spaceStart = string.find(text, " ") or string.len(text) + 1 - local comKey = string.sub(text, 2, spaceStart - 1) - local params = string.sub(text, spaceStart + 1) - local commandFunc = commands[string.lower(comKey)] - if not commandFunc then - AddChatText(LOCF("Command Not Known: %s", comKey)) - return - end - commandFunc(params) - else - PublicChat(text) - end - end - - GUI.chatEdit.OnEscPressed = function(self, text) - - local changelogDialogManager = import("/lua/ui/lobby/changelog/changelogdialog.lua") - local changelogDialogIsOpen = changelogDialogManager.IsOpen() - - -- The default behaviour buggers up our escape handlers. Just delegate the escape push to - -- the escape handling mechanism. - if HasCommandLineArg("/gpgnet") or changelogDialogIsOpen then - -- Quit to desktop - EscapeHandler.HandleEsc(not changelogDialogIsOpen) - else - -- Back to main menu - GUI.exitButton.OnClick() - end - - -- Don't clear the textbox, either. - return true - end - - --- Handle up/down arrow presses for the chat box. - GUI.chatEdit.OnNonTextKeyPressed = function(self, keyCode) - if AddUnicodeCharToEditText(self, keyCode) then - return - end - if commandQueue and not table.empty(commandQueue) then - if keyCode == 38 then - if commandQueue[commandQueueIndex + 1] then - commandQueueIndex = commandQueueIndex + 1 - self:SetText(commandQueue[commandQueueIndex]) - end - end - if keyCode == 40 then - if commandQueueIndex ~= 1 then - if commandQueue[commandQueueIndex - 1] then - commandQueueIndex = commandQueueIndex - 1 - self:SetText(commandQueue[commandQueueIndex]) - end - else - commandQueueIndex = 0 - self:ClearText() - end - end - end - end - chatPanel.edit = GUI.chatEdit -end - -function RefreshOptionDisplayData(scenarioInfo) - local globalOpts = import("/lua/ui/lobby/lobbyoptions.lua").globalOpts - local teamOptions = import("/lua/ui/lobby/lobbyoptions.lua").teamOptions - local AIOpts = import("/lua/ui/lobby/lobbyoptions.lua").AIOpts - if not scenarioInfo and gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= "") then - scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - end - formattedOptions = {} - nonDefaultFormattedOptions = {} - - -- Show a summary of the number of active mods. - local modStr = false - local modNum = table.getn(Mods.GetGameMods(gameInfo.GameMods)) or 0 - local modNumUI = table.getn(Mods.GetUiMods()) or 0 - if modNum > 0 and modNumUI > 0 then - modStr = modNum..' Mods (and '..modNumUI..' UI Mods)' - if modNum == 1 and modNumUI > 1 then - modStr = modNum..' Mod (and '..modNumUI..' UI Mods)' - elseif modNum > 1 and modNumUI == 1 then - modStr = modNum..' Mods (and '..modNumUI..' UI Mod)' - elseif modNum == 1 and modNumUI == 1 then - modStr = modNum..' Mod (and '..modNumUI..' UI Mod)' - else - modStr = modNum..' Mods (and '..modNumUI..' UI Mods)' - end - elseif modNum > 0 and modNumUI == 0 then - modStr = modNum..' Mods' - if modNum == 1 then - modStr = modNum..' Mod' - end - elseif modNum == 0 and modNumUI > 0 then - modStr = modNumUI..' UI Mods' - if modNum == 1 then - modStr = modNumUI..' UI Mod' - end - end - - local description = "No mods enabled." - - if modNum + modNumUI > 0 then - description = "" - - local descriptionSimMods = { "", } - if modNum > 0 then - table.insert(descriptionSimMods, LOC("The host enabled the following sim mods:")) - for k, mod in Mods.GetGameMods() do - table.insert(descriptionSimMods, "\r\n - " .. tostring(mod.name)) - end - - table.insert(descriptionSimMods, "\r\n") - end - - local descriptionUIMods = { "", } - if modNumUI > 0 then - table.insert(descriptionUIMods, LOC("You have enabled the following UI mods:")) - for k, mod in Mods.GetUiMods() do - table.insert(descriptionUIMods, "\r\n - " .. tostring(mod.name)) - end - end - - descriptionSimMods = tostring(table.concat(descriptionSimMods)) - descriptionUIMods = tostring(table.concat(descriptionUIMods)) - - if modNum > 0 and modNumUI > 0 then - description = tostring(table.concat({descriptionSimMods, "\r\n", descriptionUIMods})) - else - if modNum > 0 then - description = descriptionSimMods - end - - if modNumUI > 0 then - description = descriptionUIMods - end - end - end - - if modStr then - local option = { - text = modStr, - value = LOC('Check Mod Manager'), - mod = true, - - manualTooltipTitle = 'Enabled mods', - manualTooltipDescription = description - } - - table.insert(formattedOptions, option) - table.insert(nonDefaultFormattedOptions, option) - end - - -- Update the unit restrictions display. - if gameInfo.GameOptions.RestrictedCategories ~= nil then - local restrNum = table.getn(gameInfo.GameOptions.RestrictedCategories) - if restrNum ~= 0 then - local restrictLabel - if restrNum == 1 then -- just 1 - restrictLabel = LOC("1 Build Restriction") - else - restrictLabel = LOCF("%d Build Restrictions", restrNum) - end - - local option = { - text = restrictLabel, - value = LOC("Check Unit Manager"), - mod = true, - tooltip = 'Lobby_BuildRestrict_Option', - valueTooltip = 'Lobby_BuildRestrict_Option' - } - - table.insert(formattedOptions, option) - table.insert(nonDefaultFormattedOptions, option) - end - end - - -- Add an option to the formattedOption lists - local function addFormattedOption(optData, gameOption) - -- Don't show multiplayer-only options in single-player - if optData.mponly and singlePlayer then - return - end - - -- Don't bother for options with only one value. These are usually someone trying to do - -- something clever with a mod or such, not "real" options we care about. - if table.getn(optData.values) <= 1 then - return - end - - local option = { - text = optData.label, - tooltip = { text = optData.label, body = optData.help } - } - - -- Options are stored as keys from the values array in optData. We want to display the - -- descriptive string in the UI, so let's go dig it out. - - -- Scan the values array to find the one with the key matching our value for that option. - for k, val in optData.values do - local key = val.key or val - - if key == gameOption then - option.key = key - option.value = val.text or optData.value_text - option.valueTooltip = {text = optData.label, body = val.help or optData.value_help} - - table.insert(formattedOptions, option) - - -- Add this option to the non-default set for the UI. - if k ~= optData.default then - table.insert(nonDefaultFormattedOptions, option) - end - - break - end - end - end - - local function addOptionsFrom(optionObject) - for index, optData in optionObject do - local gameOption = gameInfo.GameOptions[optData.key] - addFormattedOption(optData, gameOption) - end - end - - -- Add the core options to the formatted option lists - addOptionsFrom(globalOpts) - addOptionsFrom(teamOptions) - addOptionsFrom(AIOpts) - - -- Add options from the scenario object, if any are provided. - if scenarioInfo.options then - if not MapUtil.ValidateScenarioOptions(scenarioInfo.options, true) then - AddChatText(LOC('The options included in this map specified invalid defaults. See moholog for details.')) - AddChatText(LOC('An arbitrary option has been selected for now: check the game options screen!')) - end - - for index, optData in scenarioInfo.options do - addFormattedOption(optData, gameInfo.GameOptions[optData.key]) - end - end - - GUI.OptionContainer:CalcVisible() -end - -function wasConnected(peer) - for _,v in pairs(connectedTo) do - if v == peer then - return true - end - end - return false -end - ---- Return a status code representing the status of our connection to a peer. --- @param peer, native table as returned by lobbyComm:GetPeer() --- @return A value describing the connectivity to given peer. --- 1 means no connectivity, 2 means they haven't reported that they can talk to us, 3 means --- --- @todo: This function has side effects despite the naming suggesting that it shouldn't. --- These need to go away. -function CalcConnectionStatus(peer) - if peer.status ~= 'Established' then - return 3 - else - if not wasConnected(peer.id) then - local peerSlot = FindSlotForID(peer.id) - local slot = GUI.slots[peerSlot] - local playerInfo = gameInfo.PlayerOptions[peerSlot] - - slot.name:SetTitleText(GetPlayerDisplayName(playerInfo)) - slot.name._text:SetFont('Arial Gras', 15) - if not table.find(ConnectionEstablished, peer.name) then - if playerInfo.Human and not IsLocallyOwned(peerSlot) then - table.insert(ConnectionEstablished, peer.name) - for k, v in CurrentConnection do -- Remove PlayerName in this Table - if v == peer.name then - CurrentConnection[k] = nil - break - end - end - end - end - - table.insert(connectedTo, peer.id) - end - if not table.find(peer.establishedPeers, lobbyComm:GetLocalPlayerID()) then - -- they haven't reported that they can talk to us? - return 1 - end - - local peers = lobbyComm:GetPeers() - for k,v in peers do - if v.id ~= peer.id and v.status == 'Established' then - if not table.find(peer.establishedPeers, v.id) then - -- they can't talk to someone we can talk to. - return 1 - end - end - end - return 2 - end -end - -function EveryoneHasEstablishedConnections(check_observers) - local important = {} - for slot, player in gameInfo.PlayerOptions:pairs() do - if not table.find(important, player.OwnerID) then - table.insert(important, player.OwnerID) - end - end - - if check_observers then - for slot,observer in gameInfo.Observers:pairs() do - if not table.find(important, observer.OwnerID) then - table.insert(important, observer.OwnerID) - end - end - end - - local result = true - for k, id in important do - if id ~= localPlayerID then - local peer = lobbyComm:GetPeer(id) - for k2, other in important do - if id ~= other and not table.find(peer.establishedPeers, other) then - result = false - AddChatText(LOCF("%s doesn't have an established connection to %s", - peer.name, - lobbyComm:GetPeer(other).name - )) - end - end - end - end - return result -end - -function AddChatText(text, playerID, scrollToBottom) - if not GUI.chatDisplay then - LOG("Can't add chat text -- no chat display") - LOG("text=" .. repr(text)) - return - end - - local chatPlayerColor = Prefs.GetFromCurrentProfile('ChatPlayerColor') - if chatPlayerColor == nil then - chatPlayerColor = true - end - - local scrolledToBottom = GUI.chatPanel:IsScrolledToBottom() or scrollToBottom - local nameColor = "AAAAAA" -- Displaying text in grey by default if the player is observer - local textColor = "AAAAAA" - local nameFont = "Arial Gras" - for id, player in gameInfo.PlayerOptions:pairs() do - if player.OwnerID == playerID and player.Human then - textColor = nil - nameColor = gameColors.PlayerColors[player.PlayerColor] - if not chatPlayerColor then - nameFont = UIUtil.bodyFont - if Prefs.GetOption('faction_font_color') then - nameColor = import("/lua/skins/skins.lua").skins[ FACTION_NAMES[GetLocalPlayerData():AsTable().Faction] ].fontColor - textColor = nameColor - else - nameColor = nil - end - end - break - end - end - local name = FindNameForID(playerID) - - GUI.chatDisplay:PostMessage(text, name, {fontColor = textColor}, {fontColor = nameColor, fontFamily = nameFont}) - if scrolledToBottom then - GUI.chatPanel:ScrollToBottom() - else - GUI.newMessageArrow:Enable() - end -end - ---- Update a slot display in a single map control. -function RefreshMapPosition(mapCtrl, slotIndex) - - local playerInfo = gameInfo.PlayerOptions[slotIndex] - local notFixed = gameInfo.GameOptions['TeamSpawn'] ~= 'fixed' - - -- Evil autoteams voodoo. - if gameInfo.GameOptions.AutoTeams and not gameInfo.AutoTeams[slotIndex] and lobbyComm:IsHost() then - gameInfo.AutoTeams[slotIndex] = 1 - end - - -- The ACUButton instance representing this slot, if any. - local marker = mapCtrl.startPositions[slotIndex] - if marker then - marker:SetClosed(gameInfo.ClosedSlots[slotIndex]) - if gameInfo.ClosedSlots[slotIndex] and gameInfo.SpawnMex[slotIndex] then - marker:SetClosedSpawnMex() - end - end - - mapCtrl:UpdatePlayer(slotIndex, playerInfo, notFixed) - - -- Nothing more for us to do for a closed or missing slot. - if gameInfo.ClosedSlots[slotIndex] or not marker then - return - end - - if gameInfo.GameOptions.AutoTeams then - if gameInfo.GameOptions.AutoTeams == 'lvsr' then - local midLine = mapCtrl.Left() + (mapCtrl.Width() / 2) - if notFixed then - local markerPos = marker.Left() - if markerPos < midLine then - marker:SetTeam(2) - else - marker:SetTeam(3) - end - end - elseif gameInfo.GameOptions.AutoTeams == 'tvsb' then - local midLine = mapCtrl.Top() + (mapCtrl.Height() / 2) - if notFixed then - local markerPos = marker.Top() - if markerPos < midLine then - marker:SetTeam(2) - else - marker:SetTeam(3) - end - end - elseif gameInfo.GameOptions.AutoTeams == 'pvsi' then - if notFixed then - if math.mod(slotIndex, 2) ~= 0 then - marker:SetTeam(2) - else - marker:SetTeam(3) - end - end - elseif gameInfo.GameOptions.AutoTeams == 'manual' and notFixed then - marker:SetTeam(gameInfo.AutoTeams[slotIndex] or 1) - end - end -end - ---- Update a single slot in all displayed map controls. -function RefreshMapPositionForAllControls(slot) - RefreshMapPosition(GUI.mapView, slot) - if LrgMap and not LrgMap.isHidden then - RefreshMapPosition(LrgMap.content.mapPreview, slot) - end -end - -function ShowMapPositions(mapCtrl, scenario) - local playerArmyArray = MapUtil.GetArmies(scenario) - - for inSlot, army in playerArmyArray do - RefreshMapPosition(mapCtrl, inSlot) - end -end - -function ConfigureMapListeners(mapCtrl, scenario) - local playerArmyArray = MapUtil.GetArmies(scenario) - - for inSlot, army in playerArmyArray do - local slot = inSlot -- Closure copy. - - -- The ACUButton instance representing this slot. - local marker = mapCtrl.startPositions[inSlot] - - marker.OnRollover = function(self, state) - local slotName = GUI.slots[slot].name - if state == 'enter' then - slotName:HandleEvent({Type = 'MouseEnter'}) - elseif state == 'exit' then - slotName:HandleEvent({Type = 'MouseExit'}) - end - end - - marker.OnClick = function(self) - if gameInfo.GameOptions['TeamSpawn'] == 'fixed' then - if FindSlotForID(localPlayerID) ~= slot and gameInfo.PlayerOptions[slot] == nil then - if IsPlayer(localPlayerID) then - if lobbyComm:IsHost() then - HostUtils.MovePlayerToEmptySlot(FindSlotForID(localPlayerID), slot) - else - lobbyComm:SendData(hostID, {Type = 'MovePlayer', RequestedSlot = slot}) - end - -- if first click is a not empty slot and second click is a empty slot: reset vars - if mapPreviewSlotSwap == true then - mapPreviewSlotSwap = false - mapPreviewSlotSwapFrom = 0 - end - elseif IsObserver(localPlayerID) then - if lobbyComm:IsHost() then - local requestedFaction = GetSanitisedLastFaction() - HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(localPlayerID), slot) - else - lobbyComm:SendData( - hostID, - { - Type = 'RequestConvertToPlayer', - ObserverSlot = FindObserverSlotForID(localPlayerID), - PlayerSlot = slot - } - ) - end - end - else -- swap players on map preview - if lobbyComm:IsHost() and mapPreviewSlotSwap == false then - mapPreviewSlotSwapFrom = slot - mapPreviewSlotSwap = true - elseif lobbyComm:IsHost() and mapPreviewSlotSwap == true and mapPreviewSlotSwapFrom ~= slot then - mapPreviewSlotSwap = false - DoSlotBehavior(mapPreviewSlotSwapFrom, 'move_player_to_slot' .. slot, '') - mapPreviewSlotSwapFrom = 0 - end - end - else - if gameInfo.GameOptions.AutoTeams and lobbyComm:IsHost() then - -- Handle the manual-mode reassignment of slots to teams. - if gameInfo.GameOptions.AutoTeams == 'manual' then - if not gameInfo.ClosedSlots[slot] and (gameInfo.PlayerOptions[slot] or gameInfo.GameOptions['TeamSpawn'] ~= 'fixed') then - local targetTeam - if gameInfo.AutoTeams[slot] == 7 then - -- 2 here corresponds to team 1, since a team value of 1 represents - -- "no team". Apparently GPG really, really didn't like zero. - targetTeam = 2 - else - targetTeam = gameInfo.AutoTeams[slot] + 1 - end - - marker:SetTeam(targetTeam) - gameInfo.AutoTeams[slot] = targetTeam - - lobbyComm:BroadcastData( - { - Type = 'AutoTeams', - Slot = slot, - Team = gameInfo.AutoTeams[slot], - } - ) - gameInfo.PlayerOptions[slot]['Team'] = gameInfo.AutoTeams[slot] - SetSlotInfo(slot, gameInfo.PlayerOptions[slot]) - UpdateGame() - end - end - end - end - end - - if lobbyComm:IsHost() then - marker.OnRightClick = function(self) - if gameInfo.SpawnMex[slot] then - HostUtils.SetSlotClosed(slot, false) - elseif gameInfo.ClosedSlots[slot] then - if gameInfo.AdaptiveMap then - HostUtils.SetSlotClosedSpawnMex(slot) - else - HostUtils.SetSlotClosed(slot, false) - end - else - HostUtils.SetSlotClosed(slot, true) - end - end - end - end -end - -function SendCompleteGameStateToPeer(peerId) - lobbyComm:SendData(peerId, {Type = 'GameInfo', GameInfo = GameInfo.Flatten(gameInfo)}) -end - -function UpdateClientModStatus(newHostSimMods) - -- Apply the new game mods from the host, but don't touch our UI mod configuration. - selectedSimMods = newHostSimMods - - -- Make sure none of our selected UI mods are blacklisted - local bannedMods = CheckModCompatability() - if not table.empty(bannedMods) then - WarnIncompatibleMods() - - selectedUIMods = SetUtils.Subtract(selectedUIMods, bannedMods) - end - - Mods.SetSelectedMods(SetUtils.Union(selectedSimMods, selectedUIMods)) -end - -local IsFromHost = function(data) return data.SenderID == hostID end -local AmHost = function(data) return lobbyComm:IsHost() end - -local FromSubjectOrHost = function(data) - if IsFromHost(data) then - return true - end - - -- Do ridiculous things to infer the identity of the subject of the message. - -- TODO: A UNIFORM PROTOCOL MAYBE? - local subjectID = data.SenderID - if data.PlayerName then - return data.SenderID == FindIDForName(data.PlayerName) - end - - if data.Slot and gameInfo.PlayerOptions[data.Slot] then - return data.SenderID == FindIDForName(gameInfo.PlayerOptions[data.Slot].PlayerName) - end - - return false -end - --- -local MessageHandlers = { - -- Update player options. Either the host reconfiguring, or users tweaking their own settings. - PlayerOptions = { - Accept = function(data) - for key, val in data.Options do - -- The host *is* allowed to set options on slots he doesn't own, of course. - if data.SenderID ~= hostID then - if key == 'Team' and gameInfo.GameOptions['AutoTeams'] ~= 'none' then - WARN("Attempt to set Team while Auto Teams are on.") - return false - elseif gameInfo.PlayerOptions[data.Slot].OwnerID ~= data.SenderID then - WARN("Attempt to set option on unowned slot.") - return false - end - end - end - - -- TODO: Players may not change *all* of their own options... - - return true - end, - Handle = function(data) - local options = data.Options - - for key, val in options do - gameInfo.PlayerOptions[data.Slot][key] = val - if lobbyComm:IsHost() then - local playerInfo = gameInfo.PlayerOptions[data.Slot] - if playerInfo.Human then - GpgNetSend('PlayerOption', playerInfo.OwnerID, key, val) - else - GpgNetSend('AIOption', playerInfo.PlayerName, key, val) - end - - -- TODO: This should be a global listener on PlayerData objects, but I'm in too - -- much pain to implement that listener system right now. EVIL HACK TIME - if key == "Ready" then - HostUtils.RefreshButtonEnabledness() - end - -- DONE. - end - end - - SetSlotInfo(data.Slot, gameInfo.PlayerOptions[data.Slot]) - end - }, - - -- SenderName here is inserted by lobbyComm, so validating it is just a gratuitious effort to - -- detect obnoxiousness. - PublicChat = { - Accept = function(data) - return data.SenderName == FindNameForID(data.SenderID) - end, - Handle = function(data) - AddChatText(data.Text, data.SenderID) - end - }, - - PrivateChat = { - Accept = function(data) - return data.SenderName == FindNameForID(data.SenderID) - end, - Handle = function(data) - AddChatText("<<"..LOCF("From %s", data.SenderName)..">> "..data.Text) - end - }, - - CPUBenchmark = { - Accept = FromSubjectOrHost, - Handle = function(data) - local newInfo = false - if data.PlayerName and CPU_Benchmarks[data.PlayerName] ~= data.Result then - newInfo = true - end - - local benchmarks = {} - if data.PlayerName then - benchmarks[data.PlayerName] = data.Result - else - benchmarks = data.Benchmarks - end - - for name, result in benchmarks do - CPU_Benchmarks[name] = result - local id = FindIDForName(name) - local slot = FindSlotForID(id) - if slot then - SetSlotCPUBar(slot, gameInfo.PlayerOptions[slot]) - else - refreshObserverList() - end - end - - -- Host broadcasts new CPU benchmark information to give the info to clients that are not directly connected to data.PlayerName yet. - if lobbyComm:IsHost() and newInfo then - lobbyComm:BroadcastData({Type='CPUBenchmark', Benchmarks=CPU_Benchmarks}) - end - end - }, - - SetPlayerNotReady = { - Accept = FromSubjectOrHost, - Handle = function(data) - - -- allow the user to make changes - EnableSlot(data.Slot) - - -- allow the user to become an observer again - GUI.becomeObserver:Enable() - - -- update player options - SetPlayerOption(data.Slot, 'Ready', false) - - -- update GUI - GUI.slots[data.Slot].ready:SetCheck(false) - end - }, - - AutoTeams = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.AutoTeams[data.Slot] = data.Team - gameInfo.PlayerOptions[data.Slot]['Team'] = data.Team - SetSlotInfo(data.Slot, gameInfo.PlayerOptions[data.Slot]) - UpdateGame() - end - }, - - AddPlayer = { - - ---@class LobbyAddPlayerData - ---@field PlayerOptions PlayerData - ---@field SenderId number - ---@field SenderName string - ---@field Type string - - ---@param data LobbyAddPlayerData - Accept = function(data) - -- we need to do quite a bit of checks to prevent malicious values - if type(data.PlayerOptions.MEAN) != 'number' then - return false - end - - if type (data.PlayerOptions.NG) != 'number' then - return false - end - - if type(data.PlayerOptions.Faction) != 'number' then - return false - end - - if type(data.PlayerOptions.PlayerName) != 'string' then - return false - end - - local charactersInPlayerName = string.len(data.PlayerOptions.PlayerName) - if charactersInPlayerName < 2 or charactersInPlayerName > 32 then - return false - end - - if data.PlayerOptions.PlayerClan then - if type(data.PlayerOptions.PlayerClan) != 'string' then - return false - end - - -- note: there are strange clan names that use more than 1 byte per character - if string.len(data.PlayerOptions.PlayerClan) > 6 then - return false - end - end - - if not data.PlayerOptions.OwnerID then - return false - end - - if not (data.PlayerOptions.OwnerID == data.SenderID) then - return false - end - - if FindNameForID(data.SenderID) then - return false - end - - -- check game version and reject if there is a missmatch - local hostVersion, hostGametype, hostCommit = import("/lua/version.lua").GetVersionData() - local playerVersion, playerGameType, playerCommit = tostring(data.PlayerOptions.Version), tostring(data.PlayerOptions.GameType), tostring(data.PlayerOptions.Commit) - if hostVersion ~= playerVersion or hostGametype ~= playerGameType or hostCommit ~= playerCommit then - local playerName = data.PlayerOptions.PlayerName - AddChatText(LOCF("Game version missmatch detected with %s. \r\n - host: %s (@%s)\r\n - %s: %s (@%s). \r\n\r\nTo prevent desyncs, %s is ejected automatically. It is possible that a new game version is released. If this keeps happening then it is better to rehost.", playerName, hostVersion, hostCommit:sub(1, 8), playerName, playerVersion, playerCommit:sub(1, 8), playerName)) - return false - end - - return lobbyComm:IsHost() - end, - Reject = function(data) - lobbyComm:EjectPeer(data.SenderID, "Game version missmatch or invalid player data.") - end, - Handle = function(data) - -- try to reassign the same slot as in the last game if it's a rehosted game, otherwise give it an empty - -- slot or move it to observer - SendCompleteGameStateToPeer(data.SenderID) - - if argv.isRehost then - local rehostSlot = FindRehostSlotForID(data.SenderID) or 0 - if rehostSlot ~= 0 and gameInfo.PlayerOptions[rehostSlot] then - -- If the slot is occupied, the occupying player will be moved away or to observer. If it's an - -- AI, it will be removed - local occupyingPlayer = gameInfo.PlayerOptions[rehostSlot] - if not occupyingPlayer.Human then - HostUtils.RemoveAI(rehostSlot) - HostUtils.TryAddPlayer(data.SenderID, rehostSlot, PlayerData(data.PlayerOptions)) - else - HostUtils.ConvertPlayerToObserver(rehostSlot, true) - HostUtils.TryAddPlayer(data.SenderID, rehostSlot, PlayerData(data.PlayerOptions)) - HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(occupyingPlayer.OwnerID)) - end - else - HostUtils.TryAddPlayer(data.SenderID, rehostSlot, PlayerData(data.PlayerOptions)) - end - else - HostUtils.TryAddPlayer(data.SenderID, 0, PlayerData(data.PlayerOptions)) - end - if HasCommandLineArg('/gpgnet') then - PlayVoice(Sound{Bank = 'XGG',Cue = 'XGG_Computer__04716'}, true) - end - end - }, - - -- Player requests move. - MovePlayer = { - Accept = AmHost, -- Forgery would require tricking lobbyComm... - Handle = function(data) - local CurrentSlot = FindSlotForID(data.SenderID) - - -- Handle ready-races. - if gameInfo.PlayerOptions[CurrentSlot].Ready then - return - end - - -- Player requests to be moved to a different empty slot. - HostUtils.MovePlayerToEmptySlot(CurrentSlot, data.RequestedSlot) - end - }, - - RequestConvertToObserver = { - Accept = AmHost, - Handle = function(data) - HostUtils.ConvertPlayerToObserver(FindSlotForID(data.SenderID)) - end - - }, - - RequestConvertToPlayer = { - Accept = AmHost, - Handle = function(data) - HostUtils.ConvertObserverToPlayer(FindObserverSlotForID(data.SenderID), data.PlayerSlot) - end - }, - - RequestColor = { - Accept = AmHost, - Handle = function(data) - local TargetSlot = FindSlotForID(data.SenderID) - if IsColorFree(data.Color) then - -- Color is available, let everyone else know - SetPlayerColor(gameInfo.PlayerOptions[TargetSlot], data.Color) - lobbyComm:BroadcastData({ Type = 'SetColor', Color = data.Color, Slot = TargetSlot }) - SetSlotInfo(TargetSlot, gameInfo.PlayerOptions[TargetSlot]) - else - -- Sorry, it's not free. Force the player back to the color we have for him. - lobbyComm:SendData(data.SenderID, { - Type = 'SetColor', - Color = gameInfo.PlayerOptions[TargetSlot].PlayerColor, Slot = TargetSlot - }) - end - end - }, - - -- Sent to the host to advise them of which mods the players have. - SetAvailableMods = { - Accept = AmHost, - Handle = function(data) - availableMods[data.SenderID] = data.Mods - HostUtils.UpdateMods(data.SenderID, data.Name) - end - }, - - -- Sent by a non-host peer when a map is selected that they don't have installed. - MissingMap = { - Accept = AmHost, - Handle = function(data) - HostUtils.PlayerMissingMapAlert(data.SenderID) - end - }, - - -- This is insane. - SystemMessage = { - Handle = function(data) - PrintSystemMessage(data.Id, data.Args) - end - }, - - -- This is very insane and somewhat insecure... - Peer_Really_Disconnected = { - Handle = function(data) - if data.Observ == false then - gameInfo.PlayerOptions[data.Slot] = nil - elseif data.Observ == true then - gameInfo.Observers[data.Slot] = nil - end - AddChatText(LOCF("Lost connection to %s.", data.Options.PlayerName), "Engine0003") - ClearSlotInfo(data.Slot) - UpdateGame() - end - }, - - SetAllPlayerNotReady = { - Accept = IsFromHost, - Handle = function(data) - if not IsPlayer(localPlayerID) then - return - end - local localSlot = FindSlotForID(localPlayerID) - EnableSlot(localSlot) - GUI.becomeObserver:Enable() - SetPlayerOption(localSlot, 'Ready', false) - end - }, - - -- Host telling us about things changing in the game configuration... - - GameOptions = { - Accept = IsFromHost, - Handle = function(data) - for key, value in data.Options do - gameInfo.GameOptions[key] = value - end - - UpdateGame() - end - }, - ClearSlot = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.PlayerOptions[data.Slot] = nil - ClearSlotInfo(data.Slot) - end - }, - ModsChanged = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.GameMods = data.GameMods - - UpdateClientModStatus(data.GameMods) - UpdateGame() - import("/lua/ui/lobby/modsmanager.lua").UpdateClientModStatus(gameInfo.GameMods) - end - }, - SlotClosed = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.ClosedSlots[data.Slot] = data.Closed - gameInfo.SpawnMex[data.Slot] = false - ClearSlotInfo(data.Slot) - PossiblyAnnounceGameFull() - end - }, - SlotClosedSpawnMex = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.ClosedSlots[data.Slot] = data.ClosedSpawnMex - gameInfo.SpawnMex[data.Slot] = data.ClosedSpawnMex - ClearSlotInfo(data.Slot) - PossiblyAnnounceGameFull() - end - }, - GameInfo = { - Accept = IsFromHost, - Handle = function(data) - -- Completely update the game state. To be used exactly once: when first connecting. - local hostFlatInfo = data.GameInfo - gameInfo = GameInfo.CreateGameInfo(LobbyComm.maxPlayerSlots, hostFlatInfo) - - UpdateClientModStatus(gameInfo.GameMods, true) - UpdateGame() - end - }, - SetColor = { - Accept = IsFromHost, - Handle = function(data) - SetPlayerColor(gameInfo.PlayerOptions[data.Slot], data.Color) - SetSlotInfo(data.Slot, gameInfo.PlayerOptions[data.Slot]) - end - }, - ConvertObserverToPlayer = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.Observers[data.OldSlot] = nil - gameInfo.PlayerOptions[data.NewSlot] = PlayerData(data.Options) - SetSlotInfo(data.NewSlot, gameInfo.PlayerOptions[data.NewSlot]) - UpdateFactionSelectorForPlayer(gameInfo.PlayerOptions[data.NewSlot]) - end - }, - - ConvertPlayerToObserver = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.Observers[data.NewSlot] = PlayerData(data.Options) - gameInfo.PlayerOptions[data.OldSlot] = nil - ClearSlotInfo(data.OldSlot) - UpdateFactionSelectorForPlayer(gameInfo.Observers[data.NewSlot]) - end - }, - - SlotAssigned = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.PlayerOptions[data.Slot] = PlayerData(data.Options) - if HasCommandLineArg('/gpgnet') then - PlayVoice(Sound{Bank = 'XGG',Cue = 'XGG_Computer__04716'}, true) - end - SetSlotInfo(data.Slot, gameInfo.PlayerOptions[data.Slot]) - UpdateFactionSelectorForPlayer(gameInfo.PlayerOptions[data.Slot]) - PossiblyAnnounceGameFull() - end - }, - - SlotMove = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.PlayerOptions[data.OldSlot] = nil - gameInfo.PlayerOptions[data.NewSlot] = PlayerData(data.Options) - gameInfo.PlayerOptions[data.NewSlot].Ready = false - ClearSlotInfo(data.OldSlot) - SetSlotInfo(data.NewSlot, gameInfo.PlayerOptions[data.NewSlot]) - UpdateFactionSelectorForPlayer(gameInfo.PlayerOptions[data.NewSlot]) - end - }, - - SwapPlayers = { - Accept = IsFromHost, - Handle = function(data) - DoSlotSwap(data.Slot1, data.Slot2) - end - }, - - ObserverAdded = { - Accept = IsFromHost, - Handle = function(data) - gameInfo.Observers[data.Slot] = PlayerData(data.Options) - refreshObserverList() - end - }, - - -- Start the game! - Launch = { - Accept = IsFromHost, - Handle = function(data) - local info = data.GameInfo - info.GameMods = Mods.GetGameMods(info.GameMods) - SetWindowedLobby(false) - - -- Evil hack to correct the skin for randomfaction players before launch. - for index, player in info.PlayerOptions do - -- Set the skin to the faction you'll be playing as, whatever that may be. (prevents - -- random-faction people from ending up with something retarded) - if player.OwnerID == localPlayerID then - UIUtil.SetCurrentSkin(FACTION_NAMES[player.Faction]) - end - end - - Presets.SaveLastGamePreset() - lobbyComm:LaunchGame(info) - end - }, -} - - --- LobbyComm Callbacks -function InitLobbyComm(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider) - lobbyComm = LobbyComm.CreateLobbyComm(protocol, localPort, desiredPlayerName, localPlayerUID, natTraversalProvider) - - if not lobbyComm then - error('Failed to create lobby using port ' .. tostring(localPort)) - end - - lobbyComm.ConnectionFailed = function(self, reason) - LOG("CONNECTION FAILED " .. reason) - GUI.connectionFailedDialog = UIUtil.ShowInfoDialog(GUI.panel, LOCF(Strings.ConnectionFailed, Strings[reason] or reason), - "", ReturnToMenu) - - lobbyComm:Destroy() - lobbyComm = nil - end - - lobbyComm.LaunchFailed = function(self,reasonKey) - AddChatText(LOC(Strings[reasonKey] or reasonKey)) - end - - lobbyComm.Ejected = function(self,reason) - LOG("EJECTED " .. reason) - - GUI.connectionFailedDialog = UIUtil.ShowInfoDialog(GUI, LOCF(Strings.Ejected, Strings[reason] or reason), "", ReturnToMenu) - lobbyComm:Destroy() - lobbyComm = nil - end - - lobbyComm.ConnectionToHostEstablished = function(self,myID,myName,theHostID) - LOG("CONNECTED TO HOST") - hostID = theHostID - localPlayerID = myID - localPlayerName = myName - - lobbyComm:SendData(hostID, { Type = 'SetAvailableMods', Mods = Mods.GetLocallyAvailableMods(), Name = localPlayerName}) - - lobbyComm:SendData(hostID, - { - Type = 'AddPlayer', - PlayerOptions = GetLocalPlayerData():AsTable() - } - ) - - -- Update, if needed, and broadcast, your CPU benchmark value. - if not singlePlayer then - ForkThread(function() UpdateBenchmark() end) - end - - local function KeepAliveThreadFunc() - local threshold = LobbyComm.quietTimeout - local active = true - local prev = 0 - while lobbyComm do - local host = lobbyComm:GetPeer(hostID) - if active and host.quiet > threshold then - active = false - local function OnRetry() - host = lobbyComm:GetPeer(hostID) - threshold = host.quiet + LobbyComm.quietTimeout - active = true - end - UIUtil.QuickDialog(GUI, "Connection to host timed out.", - "Keep Trying", OnRetry, - "Give Up", ReturnToMenu, - nil, nil, - true, - {worldCover = false, escapeButton = 2}) - elseif host.quiet < prev then - threshold = LobbyComm.quietTimeout - end - prev = host.quiet - WaitSeconds(1) - end - end -- KeepAliveThreadFunc - - GUI.keepAliveThread = ForkThread(KeepAliveThreadFunc) - CreateUI(LobbyComm.maxPlayerSlots) - end - - --- Called by the engine when we receive data from other players. There is no checking to see if the data is legitimate, these need to be done in Lua. - --- - --- Data can be sent via `BroadcastData` and/or `SendData`. - ---@param self UILobbyCommunication - ---@param data UILobbyReceivedMessage - lobbyComm.DataReceived = function(self, data) - -- make it more convenient to debug malicious traffic - SPEW(string.format("Received data of type %s from %s (%s)", tostring(data.Type), tostring(data.SenderID), tostring(data.SenderName))) - - -- Decide if we should just drop the packet. Violations here are usually people using a - -- modified lobby.lua to try to do stupid shit. - if not MessageHandlers[data.Type] then - WARN("Unknown message type: " .. tostring(data.Type)) - return - end - - -- No defined validator is taken to be always-accept. - if not MessageHandlers[data.Type].Accept or MessageHandlers[data.Type].Accept(data) then - MessageHandlers[data.Type].Handle(data) - elseif MessageHandlers[data.Type].Reject then - MessageHandlers[data.Type].Reject(data) - else - WARN("Rejected message of type " .. tostring(data.Type) .. " from " .. tostring(FindNameForID(data.SenderID))) - end - end - - lobbyComm.SystemMessage = function(self, text) - AddChatText(text) - end - - lobbyComm.GameLaunched = function(self) - local player = lobbyComm:GetLocalPlayerID() - for i, v in gameInfo.PlayerOptions do - if v.Human and v.OwnerID == player then - Prefs.SetToCurrentProfile('LoadingFaction', v.Faction) - break - end - end - - GpgNetSend('GameState', 'Launching') - if GUI.pingThread then - KillThread(GUI.pingThread) - end - if GUI.keepAliveThread then - KillThread(GUI.keepAliveThread) - end - GUI:Destroy() - GUI = false - MenuCommon.MenuCleanup() - lobbyComm:Destroy() - lobbyComm = false - - -- determine if cheat keys should be mapped - if not DebugFacilitiesEnabled() then - IN_ClearKeyMap() - IN_AddKeyMapTable(import("/lua/keymap/keymapper.lua").GetKeyMappings(gameInfo.GameOptions['CheatsEnabled']=='true')) - end - end - - lobbyComm.Hosting = function(self) - InitHostUtils() - - localPlayerID = lobbyComm:GetLocalPlayerID() - hostID = localPlayerID - HostUtils.UpdateMods() - - --- Returns true if the given option has the given key as a valid setting. - local function keyIsValidForOption(option, key) - for k, v in option.values do - if v.key == key or v == key then - return true - end - end - return false - end - - -- Given an option key, find the value stored in the profile (if any) and assign either it, - -- or that option's default value, to the current game state. - local setOptionsFromPref = function(option) - local defValue = Prefs.GetFromCurrentProfile("LobbyOpt_" .. option.key) - - -- Do the slightly stupid thing to check if the option we found in the profile is - -- a valid key for this option. Some mods muck about with the possibilities, so we - -- need to make sure we use a sane default if that's happened. - if defValue == nil or not keyIsValidForOption(option, defValue) then - -- Exception to make AllowObservers work because the engine requires - -- the keys to be bool. Custom options should use 'True' or 'False' - if option.key == 'AllowObservers' then - defValue = option.values[option.default].key - else - defValue = option.values[option.default].key or option.values[option.default] - end - end - - SetGameOption(option.key, defValue, true) - end - - -- Give myself the first slot - local myPlayerData = GetLocalPlayerData() - - gameInfo.PlayerOptions[1] = myPlayerData - - -- set default lobby values - for index, option in globalOpts do - setOptionsFromPref(option) - end - - for index, option in teamOpts do - setOptionsFromPref(option) - end - - for index, option in AIOpts do - setOptionsFromPref(option) - end - - -- The key, LastScenario, is referred to from GPG code we don't hook. - if not self.desiredScenario or self.desiredScenario == "" then - self.desiredScenario = Prefs.GetFromCurrentProfile("LastScenario") - end - local scenarioInfo = MapUtil.LoadScenario(self.desiredScenario) - if not scenarioInfo or scenarioInfo.type != UIUtil.requiredType then - self.desiredScenario = UIUtil.defaultScenario - end - SetGameOption('ScenarioFile', self.desiredScenario, true) - - GUI.keepAliveThread = ForkThread( - -- Eject players who haven't sent a heartbeat in a while - function() - while true and lobbyComm do - local peers = lobbyComm:GetPeers() - for k,peer in peers do - if peer.quiet > LobbyComm.quietTimeout then - lobbyComm:EjectPeer(peer.id,'TimedOutToHost') - -- %s timed out. - SendSystemMessage("lobui_0205", peer.name) - - -- Search and Remove the peer disconnected - for k, v in CurrentConnection do - if v == peer.name then - CurrentConnection[k] = nil - break - end - end - for k, v in ConnectionEstablished do - if v == peer.name then - ConnectionEstablished[k] = nil - break - end - end - for k, v in ConnectedWithProxy do - if v == peer.id then - ConnectedWithProxy[k] = nil - break - end - end - end - end - WaitSeconds(1) - end - end - ) - - CreateUI(LobbyComm.maxPlayerSlots) - ForkThread(function() UpdateBenchmark(false) end) - - if argv.isRehost then - local settings = Presets.GetLastGameSettings() - if not settings then - ApplyGameSettings(settings) - end - - local rehostSlot = FindRehostSlotForID(localPlayerID) - if rehostSlot then - HostUtils.MovePlayerToEmptySlot(1, rehostSlot) - end - - for index, playerInfo in ipairs(rehostPlayerOptions) do - if not playerInfo.Human then - HostUtils.AddAI(playerInfo.PlayerName, playerInfo.AIPersonality, playerInfo.StartSpot) - end - end - end - - UpdateGame() - end - - lobbyComm.PeerDisconnected = function(self,peerName,peerID) - - -- Search and Remove the peer disconnected - for k, v in CurrentConnection do - if v == peerName then - CurrentConnection[k] = nil - break - end - end - for k, v in ConnectionEstablished do - if v == peerName then - ConnectionEstablished[k] = nil - break - end - end - for k, v in ConnectedWithProxy do - if v == peerID then - ConnectedWithProxy[k] = nil - break - end - end - - if IsPlayer(peerID) then - local slot = FindSlotForID(peerID) - if slot and lobbyComm:IsHost() then - if HasCommandLineArg('/gpgnet') then - PlayVoice(Sound{Bank = 'XGG',Cue = 'XGG_Computer__04717'}, true) - end - lobbyComm:BroadcastData( - { - Type = 'Peer_Really_Disconnected', - Options = gameInfo.PlayerOptions[slot]:AsTable(), - Slot = slot, - Observ = false, - } - ) - ClearSlotInfo(slot) - gameInfo.PlayerOptions[slot] = nil - UpdateGame() - end - elseif IsObserver(peerID) then - local slot2 = FindObserverSlotForID(peerID) - if slot2 and lobbyComm:IsHost() then - lobbyComm:BroadcastData( - { - Type = 'Peer_Really_Disconnected', - Options = gameInfo.Observers[slot2]:AsTable(), - Slot = slot2, - Observ = true, - } - ) - gameInfo.Observers[slot2] = nil - UpdateGame() - end - end - - availableMods[peerID] = nil - if HostUtils.UpdateMods then - HostUtils.UpdateMods() - end - end - - lobbyComm.GameConfigRequested = function(self) - return { - Options = gameInfo.GameOptions, - HostedBy = localPlayerName, - PlayerCount = GetPlayerCount(), - GameName = gameName, - ProductCode = import("/lua/productcode.lua").productCode, - } - end -end - -function SetPlayerOptions(slot, options, ignoreRefresh) - if not IsLocallyOwned(slot) and not lobbyComm:IsHost() then - WARN("Hey you can't set a player option on a slot you don't own:") - for key, val in options do - WARN("(slot:"..tostring(slot).." / key:"..tostring(key).." / val:"..tostring(val)..")") - end - return - end - - for key, val in options do - gameInfo.PlayerOptions[slot][key] = val - end - - lobbyComm:BroadcastData( - { - Type = 'PlayerOptions', - Options = options, - Slot = slot, - }) - - if not ignoreRefresh then - UpdateGame() - end -end - -function SetPlayerOption(slot, key, val, ignoreRefresh) - local options = {} - options[key] = val - SetPlayerOptions(slot, options, ignoreRefresh) - refreshObserverList() -end - -function SetGameOptions(options, ignoreRefresh) - if not lobbyComm:IsHost() then - WARN('Attempt to set game option by a non-host') - return - end - - for key, val in options do - Prefs.SetToCurrentProfile('LobbyOpt_' .. key, val) - gameInfo.GameOptions[key] = val - - -- don't want to send all restricted categories to gpgnet, so just send bool - -- note if more things need to be translated to gpgnet, a translation table would be a better implementation - -- but since there's only one, we'll call it out here - if key == 'RestrictedCategories' then - local restrictionsEnabled = false - if val ~= nil then - if not table.empty(val) then - restrictionsEnabled = true - end - end - GpgNetSend('GameOption', key, restrictionsEnabled) - elseif key == 'ScenarioFile' then - -- Special-snowflake the LastScenario key (used by GPG code). - Prefs.SetToCurrentProfile('LastScenario', val) - GpgNetSend('GameOption', key, val) - if gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= '') then - -- Warn about attempts to load nonexistent maps. - if not DiskGetFileInfo(gameInfo.GameOptions.ScenarioFile) then - AddChatText(LOC('The selected map does not exist.')) - else - local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - if scenarioInfo and scenarioInfo.map and (scenarioInfo.map ~= '') then - GpgNetSend('GameOption', 'Slots', table.getsize(scenarioInfo.Configurations.standard.teams[1].armies)) - end - end - end - else - GpgNetSend('GameOption', key, val) - end - end - - lobbyComm:BroadcastData { - Type = 'GameOptions', - Options = options - } - - if not ignoreRefresh then - UpdateGame() - end -end - -function SetGameOption(key, val, ignoreRefresh) - local options = {} - options[key] = val - SetGameOptions(options, ignoreRefresh) -end - -function DebugDump() - if lobbyComm then - lobbyComm:DebugDump() - end -end - --- Perform one-time setup of the large map preview -function CreateBigPreview(parent) - local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - if scenarioInfo.hidePreviewMarkers then - return - end - - if LrgMap then - LrgMap.isHidden = false - RefreshLargeMap() - LrgMap:Show() - return - end - - -- Size of the map preview to generate. - local MAP_PREVIEW_SIZE = 721 - - -- The size of the mass/hydrocarbon icons - local HYDROCARBON_ICON_SIZE = 14 - local MASS_ICON_SIZE = 10 - - local dialogContent = Group(parent) - LayoutHelpers.SetDimensions(dialogContent, MAP_PREVIEW_SIZE + 10, MAP_PREVIEW_SIZE + 10) - - LrgMap = Popup(parent, dialogContent) - - -- The LrgMap shouldn't be destroyed due to issues related to texture pooling. Evil hack ensues. - local onTryMapClose = function() - LrgMap:Hide() - LrgMap.isHidden = true - end - LrgMap.OnEscapePressed = onTryMapClose - LrgMap.OnShadowClicked = onTryMapClose - - -- Create the map preview - local mapPreview = ResourceMapPreview(dialogContent, MAP_PREVIEW_SIZE, MASS_ICON_SIZE, HYDROCARBON_ICON_SIZE) - dialogContent.mapPreview = mapPreview - LayoutHelpers.AtCenterIn(mapPreview, dialogContent) - - local closeBtn = UIUtil.CreateButtonStd(dialogContent, '/dialogs/close_btn/close') - LayoutHelpers.AtRightTopIn(closeBtn, dialogContent, 1, 1) - closeBtn.OnClick = onTryMapClose - - -- Keep the close button on top of the border (which is itself on top of the map preview) - LayoutHelpers.DepthOverParent(closeBtn, mapPreview, 2) - - RefreshLargeMap() -end - --- Refresh the large map preview (so it can update if something changes while it's open) -function RefreshLargeMap() - if not LrgMap or LrgMap.isHidden then - return - end - - local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - LrgMap.content.mapPreview:SetScenario(scenarioInfo, true) - ConfigureMapListeners(LrgMap.content.mapPreview, scenarioInfo) - ShowMapPositions(LrgMap.content.mapPreview, scenarioInfo) -end - --------------------------------------------------- --- Ping GUI Functions --------------------------------------------------- - -local ConnectionStatusInfo = { - 'Player is not connected to someone', - 'Connected', - 'Not Connected', - 'No connection info available', -} - -function Ping_AddControlTooltip(control, delay, slotNumber) - --This function creates the Ping tooltip for a slot along with necessary mouseover function. - --It is called during the UI creation. - -- control: The control to which the tooltip is to be added. - -- delay: Amount of time to delay before showing tooltip. See Tooltip.CreateMouseoverDisplay for info. - -- slotNumber: The slot number associated with the control. - local pingText = function() - local pingInfo - if GUI.slots[slotNumber].pingStatus.PingActualValue then - pingInfo = GUI.slots[slotNumber].pingStatus.PingActualValue - else - pingInfo = LOC('UnKnown') - end - return LOC('Ping: ') .. pingInfo - end - local pingBody = function() - local conInfo - if GUI.slots[slotNumber].pingStatus.ConnectionStatus then - conInfo = GUI.slots[slotNumber].pingStatus.ConnectionStatus - else - conInfo = 4 - end - return LOC('Only shows when > 500') .. '\n\n' .. LOC(ConnectionStatusInfo[conInfo]) - end - Tooltip.AddAutoUpdatedControlTooltip(control, pingText, pingBody, delay) -end - ---CPU Status Bar Configuration -local greenBarMax = 300 -local yellowBarMax = 375 -local scoreSkew1 = 0 --Skews all CPU scores up or down by the amount specified (0 = no skew) -local scoreSkew2 = 1.0 --Skews all CPU scores specified coefficient (1.0 = no skew) - ---Variables for CPU Test -local BenchTime - --------------------------------------------------- --- CPU Benchmarking Functions --------------------------------------------------- -function CPUBenchmark() - --This function gives the CPU some busy work to do. - --CPU score is determined by how quickly the work is completed. - local totalTime = 0 - local lastTime - local currTime - local countTime = 0 - --Make everything a local variable - --This is necessary because we don't want LUA searching through the globals as part of the benchmark - local TableInsert = table.insert - local TableRemove = table.remove - local h - local i - local j - local k - local l - local m - local n = {} - for h = 1, 24, 1 do - -- If the need for the benchmark no longer exists, abort it now. - if not lobbyComm then - return - end - - lastTime = GetSystemTimeSeconds() - for i = 1.0, 30.4, 0.0008 do - --This instruction set should cover most LUA operators - j = i + i --Addition - k = i * i --Multiplication - l = k / j --Division - m = j - i --Subtraction - j = math.pow(i, 4) --Power - l = -i --Negation - m = {'1234567890', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', true} --Create Table - TableInsert(m, '1234567890') --Insert Table Value - k = i < j --Less Than - k = i == j --Equality - k = i <= j --Less Than or Equal to - k = not k - n[tostring(i)] = m - end - currTime = GetSystemTimeSeconds() - totalTime = totalTime + currTime - lastTime - - if totalTime > countTime then - --This is necessary in order to make this 'thread' yield so other things can be done. - countTime = totalTime + .125 - coroutine.yield(1) - end - end - BenchTime = math.ceil(totalTime * 100) -end - --------------------------------------------------- --- CPU GUI Functions --------------------------------------------------- -function CPU_AddControlTooltip(control, delay, slotNumber) - --This function creates the benchmark tooltip for a slot along with necessary mouseover function. - --It is called during the UI creation. - -- control: The control to which the tooltip is to be added. - -- delay: Amount of time to delay before showing tooltip. See Tooltip.CreateMouseoverDisplay for info. - -- slotNumber: The slot number associated with the control. - local CPUText = function() - local CPUInfo - if GUI.slots[slotNumber].CPUSpeedBar.CPUActualValue then - CPUInfo = GUI.slots[slotNumber].CPUSpeedBar.CPUActualValue - else - CPUInfo = LOC('UnKnown') - end - return LOC('CPU Rating: ') .. CPUInfo - end - local CPUBody = function() - return LOC('0=Fastest, 450=Slowest') - end - Tooltip.AddAutoUpdatedControlTooltip(control, CPUText, CPUBody, delay) -end - ---- Get the CPU benchmark score for the local machine. --- If a previously-calculated benchmark score is found in the profile, it is returned immediately. --- Otherwise, a fresh score is calculated and stored (enjoy the lag!). --- --- @param force If truthy, a fresh score is always calculated. --- @return A benchmark score between 0 and 450, or nil if StressCPU returned nil (which occurs iff --- the lobby is exited before the calculation is complete. Callers should abort gracefully --- in this situation). --- @see StressCPU -function GetBenchmarkScore(force) - local wait = 10 - local benchmark - - if force then - wait = 0 - else - benchmark = GetPreference('CPUBenchmark') - end - - if not benchmark then - -- We defer the calculation by 10s here because, often, non-forced requests are occurring on - -- startup, and we want to give other tasks, such as connection negotiation, a fighting - -- chance of completing before we ruin everything. - -- - -- Benchmark scores are associated with the machine, not the profile: hence SetPreference. - - benchmark = StressCPU(wait) - SetPreference('CPUBenchmark', benchmark) - end - - return benchmark -end - ---- Updates the displayed benchmark score for the local player. --- --- @param force Passed as the `force` parameter to GetBenchmarkScore. --- @see GetBenchmarkScore -function UpdateBenchmark(force) - local benchmark = GetBenchmarkScore(force) - - if benchmark then - CPU_Benchmarks[localPlayerName] = benchmark - lobbyComm:BroadcastData({ Type = 'CPUBenchmark', PlayerName = localPlayerName, Result = benchmark }) - if FindObserverSlotForID(localPlayerID) then - refreshObserverList() - else - UpdateCPUBar(localPlayerName) - end - end -end - --- This function instructs the PC to do a CPU score benchmark. --- It handles the necessary UI updates during the benchmark, sends --- the benchmark result to other players when finished, and it updates the local --- user's UI with their new result. --- waitTime: The delay in seconds that this function should wait before starting the benchmark. -function StressCPU(waitTime) - GUI.rerunBenchmark:Disable() - for i = waitTime, 1, -1 do - GUI.rerunBenchmark.label:SetText(i..'s') - WaitSeconds(1) - - -- lobbyComm is destroyed when the lobby is exited. If the user left the lobby, we no longer - -- want to run the benchmark (it just introduces lag as the user is trying to do something - -- else). - if not lobbyComm then - return - end - end - - --Get our last benchmark (if there was one) - local currentBestBenchmark = 10000 - - GUI.rerunBenchmark.label:SetText('. . .') - - --Run three benchmarks and keep the best one - for i=1, 3, 1 do - BenchTime = 0 - CPUBenchmark() - - BenchTime = scoreSkew2 * BenchTime + scoreSkew1 - - -- The bench might have yeilded to a launcher, so we verify the lobbyComm is available when - -- we need it in a moment here (as well as aborting if we're wasting our time more than usual) - if not lobbyComm then - return - end - - --If this benchmark was better than our best so far... - if BenchTime < currentBestBenchmark then - currentBestBenchmark = BenchTime - end - end - - --Reset Button UI - GUI.rerunBenchmark:Enable() - GUI.rerunBenchmark.label:SetText('') - - return currentBestBenchmark -end - -function UpdateCPUBar(playerName) - --This function updates the UI with a CPU benchmark bar for the specified playerName. - -- playerName: The name of the player whose benchmark should be updated. - local playerId = FindIDForName(playerName) - local playerSlot = FindSlotForID(playerId) - if playerSlot ~= nil then - SetSlotCPUBar(playerSlot, gameInfo.PlayerOptions[playerSlot]) - end -end - -function SetSlotCPUBar(slot, playerInfo) - --This function updates the UI with a CPU benchmark bar for the specified slot/playerInfo. - -- slot: a numbered slot (1-however many slots there are for this map) - -- playerInfo: The corresponding playerInfo object from gameInfo.PlayerOptions[slot]. - - - if GUI.slots[slot].CPUSpeedBar then - GUI.slots[slot].CPUSpeedBar:Hide() - if playerInfo.Human then - local b = CPU_Benchmarks[playerInfo.PlayerName] - if b then - -- For display purposes, the bar has a higher minimum that the actual barMin value. - -- This is to ensure that the bar is visible for very small values - - -- Lock values to the largest possible result. - if b > GUI.slots[slot].CPUSpeedBar.barMax then - b = GUI.slots[slot].CPUSpeedBar.barMax - end - - GUI.slots[slot].CPUSpeedBar:SetValue(b) - GUI.slots[slot].CPUSpeedBar.CPUActualValue = b - - GUI.slots[slot].CPUSpeedBar:Show() - end - end - end -end - -function SetGameTitleText(title) - GUI.titleText:SetColor("B9BFB9") - if title == '' then - title = LOC("FAF Game Lobby") - end - GUI.titleText:SetText(title) -end - -function ShowTitleDialog() - CreateInputDialog(GUI, "Game Title", - function(self, text) - -- remove new lines from the text - text = text:gsub("\r", "") - text = text:gsub("\n", "") - - SetGameOption("Title", text, true) - SetGameTitleText(text) - end, gameInfo.GameOptions.Title -) -end - --- Rule title -function SetRuleTitleText(rule) - GUI.RuleLabel:SetColors("B9BFB9") - if rule == '' then - if lobbyComm:IsHost() then - GUI.RuleLabel:SetColors("FFCC00") - rule = LOC("No Rules: Click to add rules") - else - rule = "No rules." - end - end - - GUI.RuleLabel:SetText(rule) -end - --- Show the rule change dialog. -function ShowRuleDialog() - CreateInputDialog(GUI, "Game Rules", - function(self, text) - SetGameOption("GameRules", text, true) - SetRuleTitleText(text) - end, gameInfo.GameOptions.GameRules -) -end - --- Faction selector -function CreateUI_Faction_Selector(lastFaction) - -- Build a list of button objects from the list of defined factions. Each faction will use the - -- faction key as its RadioButton texture path offset. - local buttons = {} - for i, faction in FactionData.Factions do - buttons[i] = { - texturePath = faction.Key - } - end - - -- Special-snowflaking for the random faction. - table.insert(buttons, { - texturePath = "random" - }) - - local factionSelector = RadioButton(GUI.panel, "/factionselector/", buttons, lastFaction, true) - GUI.factionSelector = factionSelector - LayoutHelpers.AtLeftTopIn(factionSelector, GUI.panel, 407, 20) - factionSelector.OnChoose = function(self, targetFaction, key) - local localSlot = FindSlotForID(localPlayerID) - local slotFactionIndex = GetSlotFactionIndex(targetFaction) - Prefs.SetToCurrentProfile('LastFaction', targetFaction) - GUI.slots[localSlot].faction:SetItem(slotFactionIndex) - SetPlayerOption(localSlot, 'Faction', slotFactionIndex) - gameInfo.PlayerOptions[localSlot].Faction = slotFactionIndex - - RefreshLobbyBackground(targetFaction) - UIUtil.SetCurrentSkin(FACTION_NAMES[targetFaction]) - end - - -- Only enable all buttons incase all the buttons are disabled, to avoid overriding partially disabling of the buttons - factionSelector.Enable = function(self) - for k, v in self.mButtons do - if v._controlState == "up" then - return - end - end - for k, v in self.mButtons do - v:Enable() - end - end - - factionSelector.SetCheck = function(self, index) - for i,button in self.mButtons do - if index ==i then - button:SetCheck(true) - else - button:SetCheck(false) - end - end - self.mCurSelection = index - end - - factionSelector.EnableSpecificButtons = function(self, specificButtons) - for i,button in self.mButtons do - if specificButtons[i] then - button:Enable() - else - button:Disable() - end - end - end -end - -function UpdateFactionSelectorForPlayer(playerInfo) - if playerInfo.OwnerID == localPlayerID then - UpdateFactionSelector() - end -end - -function UpdateFactionSelector() - local playerSlotID = FindSlotForID(localPlayerID) - local playerSlot = GUI.slots[playerSlotID] - if not playerSlot or not playerSlot.AvailableFactions or gameInfo.PlayerOptions[playerSlotID].Ready then - UIUtil.setEnabled(GUI.factionSelector, false) - return - end - local enabledList = {} - for index,button in GUI.factionSelector.mButtons do - enabledList[index] = false - for i,value in playerSlot.AvailableFactions do - if value == allAvailableFactionsList[index] then - if gameInfo.PlayerOptions[playerSlotID].Faction == i then - GUI.factionSelector:SetCheck(index) - end - enabledList[index] = true - break - end - end - end - GUI.factionSelector:EnableSpecificButtons(enabledList) -end - -function GetSlotFactionIndex(factionIndex) - local localSlot = GUI.slots[FindSlotForID(localPlayerID)] - local actualFaction = allAvailableFactionsList[factionIndex] - for index,value in localSlot.AvailableFactions do - if value == actualFaction then - return index - end - end -end - -function RefreshLobbyBackground(faction) - local LobbyBackground = Prefs.GetFromCurrentProfile('LobbyBackground') or 1 - if GUI.background then - GUI.background:Destroy() - end - if LobbyBackground == 1 then -- Factions - faction = faction or GetSanitisedLastFaction() - if FACTION_NAMES[faction] then - GUI.background = Bitmap(GUI, "/textures/ui/common/BACKGROUND/faction/faction-background-paint_" .. FACTION_NAMES[faction] .. "_bmp.dds") - else - return - end - elseif LobbyBackground == 2 then -- Concept art - GUI.background = Bitmap(GUI, "/textures/ui/common/BACKGROUND/art/art-background-paint0" .. math.random(1, 5) .. "_bmp.dds") - elseif LobbyBackground == 3 then -- Screenshot - GUI.background = Bitmap(GUI, "/textures/ui/common/BACKGROUND/scrn/scrn-background-paint" .. math.random(1, 14) .. "_bmp.dds") - elseif LobbyBackground == 4 then -- Map - local MapPreview = import("/lua/ui/controls/mappreview.lua").MapPreview - GUI.background = MapPreview(GUI) -- Background map - if gameInfo.GameOptions.ScenarioFile and (gameInfo.GameOptions.ScenarioFile ~= '') then - local scenarioInfo = MapUtil.LoadScenario(gameInfo.GameOptions.ScenarioFile) - if scenarioInfo and scenarioInfo.map and (scenarioInfo.map ~= '') and scenarioInfo.preview then - if not GUI.background:SetTexture(scenarioInfo.preview) then - GUI.background:SetTextureFromMap(scenarioInfo.map) - end - end - end - elseif LobbyBackground == 5 then -- None - return - end - - local LobbyBackgroundStretch = Prefs.GetFromCurrentProfile('LobbyBackgroundStretch') or 'true' - LayoutHelpers.AtCenterIn(GUI.background, GUI) - LayoutHelpers.DepthUnderParent(GUI.background, GUI.panel) - if LobbyBackgroundStretch == 'true' then - LayoutHelpers.FillParent(GUI.background, GUI) - else - LayoutHelpers.FillParentPreserveAspectRatio(GUI.background, GUI) - end -end - -function ShowLobbyOptionsDialog() - local dialogContent = Group(GUI) - LayoutHelpers.SetDimensions(dialogContent, 420, 310) - - local dialog = Popup(GUI, dialogContent) - GUI.lobbyOptionsDialog = dialog - - local buttons = { - { - label = LOC("") -- Factions - }, - { - label = LOC("") -- Concept art - }, - { - label = LOC("") -- Screenshot - }, - { - label = LOC("") -- Map - }, - { - label = LOC("") -- None - } - } - - -- Label shown above the background mode selection radiobutton. - local backgroundLabel = UIUtil.CreateText(dialogContent, LOC(" "), 16, 'Arial', true) - local selectedBackgroundState = Prefs.GetFromCurrentProfile("LobbyBackground") or 1 - local backgroundRadiobutton = RadioButton(dialogContent, '/RADIOBOX/', buttons, selectedBackgroundState, false, true) - - LayoutHelpers.AtLeftTopIn(backgroundLabel, dialogContent, 15, 15) - LayoutHelpers.AtLeftTopIn(backgroundRadiobutton, dialogContent, 15, 40) - - backgroundRadiobutton.OnChoose = function(self, index, key) - Prefs.SetToCurrentProfile("LobbyBackground", index) - RefreshLobbyBackground() - end - -- label for displaying chat font size - local currentFontSize = Prefs.GetFromCurrentProfile('LobbyChatFontSize') or 14 - local slider_Chat_SizeFont_TEXT = UIUtil.CreateText(dialogContent, LOC(" ").. currentFontSize, 14, 'Arial', true) - LayoutHelpers.AtRightTopIn(slider_Chat_SizeFont_TEXT, dialogContent, 27, 162) - - -- slider for changing chat font size - local slider_Chat_SizeFont = Slider(dialogContent, false, 9, 20, - UIUtil.SkinnableFile('/slider02/slider_btn_up.dds'), - UIUtil.SkinnableFile('/slider02/slider_btn_over.dds'), - UIUtil.SkinnableFile('/slider02/slider_btn_down.dds'), - UIUtil.SkinnableFile('/slider02/slider-back_bmp.dds')) - LayoutHelpers.AtRightTopIn(slider_Chat_SizeFont, dialogContent, 20, 182) - slider_Chat_SizeFont:SetValue(currentFontSize) - slider_Chat_SizeFont.OnValueChanged = function(self, newValue) - local isScrolledDown = GUI.chatPanel:IsScrolledToBottom() - - local sliderValue = math.floor(self._currentValue()) - slider_Chat_SizeFont_TEXT:SetText(LOC(" ").. sliderValue) - - Prefs.SetToCurrentProfile('LobbyChatFontSize', sliderValue) - -- updating chat panel with new font size - GUI.chatPanel:SetFont(nil, sliderValue) - - if isScrolledDown then - GUI.chatPanel:ScrollToBottom() - end - end - if true then - --snowflakes count - local currentSnowFlakesCount = Prefs.GetFromCurrentProfile('SnowFlakesCount') or 100 - local slider_SnowFlakes_Count_TEXT = UIUtil.CreateText(dialogContent, LOC("Snowflakes count").. currentSnowFlakesCount, 14, 'Arial', true) - LayoutHelpers.AtRightTopIn(slider_SnowFlakes_Count_TEXT, dialogContent, 27, 202) - - -- slider for changing chat font size - local slider_SnowFlakes_Count = Slider(dialogContent, false, 100, 1000, - UIUtil.SkinnableFile('/slider02/slider_btn_up.dds'), - UIUtil.SkinnableFile('/slider02/slider_btn_over.dds'), - UIUtil.SkinnableFile('/slider02/slider_btn_down.dds'), - UIUtil.SkinnableFile('/slider02/slider-back_bmp.dds')) - LayoutHelpers.AtRightTopIn(slider_SnowFlakes_Count, dialogContent, 20, 222) - slider_SnowFlakes_Count:SetValue(currentSnowFlakesCount) - slider_SnowFlakes_Count.OnValueChanged = function(self, newValue) - local sliderValue = math.floor(newValue) - slider_SnowFlakes_Count_TEXT:SetText(LOC("Snowflakes count").. sliderValue) - Prefs.SetToCurrentProfile('SnowFlakesCount', sliderValue) - import("/lua/ui/events/snowflake.lua").Clear() - import("/lua/ui/events/snowflake.lua").CreateSnowFlakes(GUI, sliderValue) - end - end - - - -- - local cbox_WindowedLobby = UIUtil.CreateCheckbox(dialogContent, '/CHECKBOX/', LOC("Windowed mode")) - LayoutHelpers.AtRightTopIn(cbox_WindowedLobby, dialogContent, 20, 42) - Tooltip.AddCheckboxTooltip(cbox_WindowedLobby, {text=LOC('Windowed mode'), body=LOC("")}) - cbox_WindowedLobby.OnCheck = function(self, checked) - local option - if checked then - option = 'true' - else - option = 'false' - end - Prefs.SetToCurrentProfile('WindowedLobby', option) - SetWindowedLobby(checked) - end - -- - local cbox_StretchBG = UIUtil.CreateCheckbox(dialogContent, '/CHECKBOX/', LOC("Stretch Background")) - LayoutHelpers.AtRightTopIn(cbox_StretchBG, dialogContent, 20, 68) - Tooltip.AddCheckboxTooltip(cbox_StretchBG, {text=LOC('Stretch Background'), body=LOC("")}) - cbox_StretchBG.OnCheck = function(self, checked) - if checked then - Prefs.SetToCurrentProfile('LobbyBackgroundStretch', 'true') - else - Prefs.SetToCurrentProfile('LobbyBackgroundStretch', 'false') - end - RefreshLobbyBackground() - end - - local cbox_FactionFontColor = UIUtil.CreateCheckbox(dialogContent, '/CHECKBOX/', LOC("Faction Font Color")) - LayoutHelpers.AtRightTopIn(cbox_FactionFontColor, dialogContent, 20, 94) - cbox_FactionFontColor.OnCheck = function(self, checked) - if checked then - Prefs.SetOption('faction_font_color', true) - else - Prefs.SetOption('faction_font_color', false) - end - UIUtil.UpdateCurrentSkin() - end - - local cbox_ChatPlayerColor = UIUtil.CreateCheckbox(dialogContent, '/CHECKBOX/', LOC("Player color in chat")) - LayoutHelpers.AtRightTopIn(cbox_ChatPlayerColor, dialogContent, 20, 120) - cbox_ChatPlayerColor.OnCheck = function(self, checked) - if checked then - Prefs.SetToCurrentProfile('ChatPlayerColor', true) - else - Prefs.SetToCurrentProfile('ChatPlayerColor', false) - end - end - - -- Quit button - local QuitButton = UIUtil.CreateButtonWithDropshadow(dialogContent, '/BUTTON/medium/', LOC("Close")) - LayoutHelpers.AtHorizontalCenterIn(QuitButton, dialogContent, 0) - LayoutHelpers.AtBottomIn(QuitButton, dialogContent, 10) - - QuitButton.OnClick = function(self) - dialog:Hide() - end - -- - local WindowedLobby = Prefs.GetFromCurrentProfile('WindowedLobby') or 'true' - cbox_WindowedLobby:SetCheck(WindowedLobby == 'true', true) - if defaultMode == 'windowed' then - -- Already set Windowed in Game - cbox_WindowedLobby:Disable() - end - -- - local lobbyBackgroundStretch = Prefs.GetFromCurrentProfile('LobbyBackgroundStretch') or 'true' - cbox_StretchBG:SetCheck(lobbyBackgroundStretch == 'true', true) - -- - local factionFontColor = Prefs.GetOption('faction_font_color') - cbox_FactionFontColor:SetCheck(factionFontColor == true, true) - - local chatPlayerColor = Prefs.GetFromCurrentProfile('ChatPlayerColor') - cbox_ChatPlayerColor:SetCheck(chatPlayerColor == true or chatPlayerColor == nil, true) -end - ----Applies new game settings, including map, mods ----@param settings WatchedGameData -function ApplyGameSettings(settings) - SetGameOptions(settings.GameOptions, true) - - rehostPlayerOptions = settings.PlayerOptions - selectedSimMods = settings.GameMods - HostUtils.UpdateMods() - - UpdateGame() -end ----Returns the current game settings ----@return WatchedGameData -function GetGameSettings() - return gameInfo -end - ---- Delegate to UIUtil's CreateInputDialog, adding the ridiculus chatEdit hack. -function CreateInputDialog(parent, title, listener, str) - UIUtil.CreateInputDialog(parent, title, listener, GUI.chatEdit, str) -end - --- Update the combobox for the given slot so it correctly shows the set of available colours. --- causes a new availableColours to be populated for each occupied slot --- if a slot is specified, that slot's displayed color will be made consistent with its coded color -function Check_Availaible_Color(slot) - -- generate a table of used colors - local UsedColours = {} - for i = 1, LobbyComm.maxPlayerSlots do - -- Skips empty slots. - if gameInfo.PlayerOptions[i] then - table.insert(UsedColours, gameInfo.PlayerOptions[i].PlayerColor) - end - end - - -- generate a table of unused colors - local unusedColours = {} - for i, color in gameColors.PlayerColors do - if not table.find(UsedColours, i) then - unusedColours[i] = color - end - end - - for i = 1, LobbyComm.maxPlayerSlots do - -- Skips empty slots. - if gameInfo.PlayerOptions[i] then - local availableColours = {} - -- deepcopy the unused colors because using them by reference causes problems with the displayed color list/ChangeBitmapArray - for c, color in unusedColours do - availableColours[c] = color - end - -- add this slot's color to its availableColours (you get a nil lazyvar error if it's not included) - availableColours[ gameInfo.PlayerOptions[i].PlayerColor] = gameColors.PlayerColors[gameInfo.PlayerOptions[i].PlayerColor] - -- set the list of available colors for this slot - GUI.slots[i].color:ChangeBitmapArray(availableColours, true) - end - end - - -- if a slot was entered, set the displayed color for that slot to the coded color for that slot - if slot then - GUI.slots[slot].color:SetItem(gameInfo.PlayerOptions[slot].PlayerColor) - end -end - -function CheckModCompatability() - local blacklistedMods = {} - for modId, _ in SetUtils.Union(selectedSimMods, selectedUIMods) do - if ModBlacklist[modId] then - blacklistedMods[modId] = ModBlacklist[modId] - end - end - - return blacklistedMods -end - -function WarnIncompatibleMods() - UIUtil.QuickDialog(GUI, - "Some of your enabled mods are known to cause malfunctions with FAF, so have been disabled. See the mod manager for details - some mods may have newer versions which work.", - "") -end - -function DoSlotSwap(slot1, slot2) - - -- retrieve player info - local player1 = gameInfo.PlayerOptions[slot1] - local player2 = gameInfo.PlayerOptions[slot2] - - -- unready players in the player options - player1.Ready = false - player2.Ready = false - - -- swap teams - local team_bucket = player1.Team - player1.Team = player2.Team - player2.Team = team_bucket - - --Handle faction availability - KeepSameFactionOrRandom(slot1, slot2, player1) - KeepSameFactionOrRandom(slot2, slot1, player2) - - -- swap the slots - gameInfo.PlayerOptions[slot2] = player1 - gameInfo.PlayerOptions[slot1] = player2 - - -- update slot info - SetSlotInfo(slot2, player1) - SetSlotInfo(slot1, player2) - - -- update faction selector - UpdateFactionSelectorForPlayer(player1) - UpdateFactionSelectorForPlayer(player2) - - UpdateGame() -end - -function KeepSameFactionOrRandom(slotFrom, slotTo, player) - local playerFactionKey = GUI.slots[slotFrom].AvailableFactions[player.Faction] - --intialize to random, incase oldFaction isn't available - player.Faction = table.getn(GUI.slots[slotTo].AvailableFactions) - for index,faction in GUI.slots[slotTo].AvailableFactions do - if faction == playerFactionKey then - player.Faction = index - end - end -end - -local function SendPlayerOption(playerInfo, key, value) - if playerInfo.Human then - GpgNetSend('PlayerOption', playerInfo.OwnerID, key, value) - else - GpgNetSend('AIOption', playerInfo.PlayerName, key, value) - end -end - ---- Create the HostUtils object, containing host-only functions. By not assigning this for non-host --- players, we ensure a hard crash should a non-host somehow end up trying to call them, simplifying --- debugging somewhat (as well as reducing the number of toplevel definitions a fair bit). --- This is clearly not a security feature. -function InitHostUtils() - if not lobbyComm:IsHost() then - WARN(debug.traceback(nil, "Attempt to create HostUtils by non-host.")) - return - end - - HostUtils = { - --- Cause a player's ready box to become unchecked. - -- - -- @param slot The slot number of the target player. - SetPlayerNotReady = function(slot) - local slotOptions = gameInfo.PlayerOptions[slot] - if slotOptions.Ready then - if not IsLocallyOwned(slot) then - lobbyComm:SendData(slotOptions.OwnerID, {Type = 'SetPlayerNotReady', Slot = slot}) - end - slotOptions.Ready = false - end - end, - - SetSlotClosed = function(slot, closed) - -- Don't close an occupied slot. - if gameInfo.PlayerOptions[slot] then - return - end - - lobbyComm:BroadcastData( - { - Type = 'SlotClosed', - Slot = slot, - Closed = closed - } - ) - - gameInfo.ClosedSlots[slot] = closed - gameInfo.SpawnMex[slot] = false - ClearSlotInfo(slot) - PossiblyAnnounceGameFull() - end, - - SetSlotClosedSpawnMex = function(slot) - -- Don't close an occupied slot. - if gameInfo.PlayerOptions[slot] then - return - end - - lobbyComm:BroadcastData( - { - Type = 'SlotClosedSpawnMex', - Slot = slot, - ClosedSpawnMex = true - } - ) - - gameInfo.ClosedSlots[slot] = true - gameInfo.SpawnMex[slot] = true - ClearSlotInfo(slot) - PossiblyAnnounceGameFull() - end, - - ConvertPlayerToObserver = function(playerSlot, ignoreMsg) - -- make sure player exists - if not gameInfo.PlayerOptions[playerSlot] then - WARN("HostUtils.ConvertPlayerToObserver for nonexistent player in slot " .. tostring(playerSlot)) - return - end - - -- find a free observer slot - local index = 1 - while gameInfo.Observers[index] do - index = index + 1 - end - - HostUtils.SetPlayerNotReady(playerSlot) - local ownerID = gameInfo.PlayerOptions[playerSlot].OwnerID - gameInfo.Observers[index] = gameInfo.PlayerOptions[playerSlot] - gameInfo.PlayerOptions[playerSlot] = nil - - if lobbyComm:IsHost() then - GpgNetSend('PlayerOption', ownerID, 'Team', -1) - GpgNetSend('PlayerOption', ownerID, 'Army', -1) - GpgNetSend('PlayerOption', ownerID, 'StartSpot', -index) - end - - ClearSlotInfo(playerSlot) - - -- TODO: can probably avoid transmitting the options map here. The slot number should be enough. - lobbyComm:BroadcastData( - { - Type = 'ConvertPlayerToObserver', - OldSlot = playerSlot, - NewSlot = index, - Options = gameInfo.Observers[index]:AsTable(), - } - ) - - if not ignoreMsg then - -- %s has switched from a player to an observer. - SendSystemMessage("lobui_0226", gameInfo.Observers[index].PlayerName) - end - - UpdateGame() - end, - - ConvertObserverToPlayer = function(fromObserverSlot, toPlayerSlot, ignoreMsg) - -- If no slot is specified (user clicked "go player" button), select a default. - if not toPlayerSlot or toPlayerSlot < 1 or toPlayerSlot > numOpenSlots then - toPlayerSlot = HostUtils.FindEmptySlot() - - -- If it's still -1 (No slots available) check for AIs and evict the first one - if toPlayerSlot < 1 then - for i = 1, numOpenSlots do - local slot = gameInfo.PlayerOptions[i] - if slot and not slot.Human then - HostUtils.RemoveAI(i) - toPlayerSlot = i - break - end - end - - -- There are no AIs and no slots, so break out with a message - if toPlayerSlot < 1 and gameInfo.Observers[fromObserverSlot] then - SendPersonalSystemMessage(gameInfo.Observers[fromObserverSlot].OwnerID, "lobui_0608") - return - end - end - end - - if not gameInfo.Observers[fromObserverSlot] then -- IF no Observer on the current slot : QUIT - return - elseif gameInfo.PlayerOptions[toPlayerSlot] then -- IF Player is in the target slot : QUIT - return - elseif gameInfo.ClosedSlots[toPlayerSlot] then -- IF target slot is Closed : QUIT - return - end - - local incomingPlayer = gameInfo.Observers[fromObserverSlot] - - -- Give them a default colour if the one they already have isn't free. - if not IsColorFree(incomingPlayer.PlayerColor) then - local newColour = GetAvailableColor() - SetPlayerColor(incomingPlayer, newColour) - end - - gameInfo.PlayerOptions[toPlayerSlot] = incomingPlayer - gameInfo.Observers[fromObserverSlot] = nil - - lobbyComm:BroadcastData( - { - Type = 'ConvertObserverToPlayer', - OldSlot = fromObserverSlot, - NewSlot = toPlayerSlot, - Options = gameInfo.PlayerOptions[toPlayerSlot]:AsTable(), - } - ) - - if not ignoreMsg then - -- %s has switched from an observer to player. - SendSystemMessage("lobui_0227", incomingPlayer.PlayerName) - end - - SetSlotInfo(toPlayerSlot, gameInfo.PlayerOptions[toPlayerSlot]) - - -- This is far from optimally efficient, as it will SetSlotInfo twice when autoteams is enabled. - AssignAutoTeams() - - UpdateFactionSelectorForPlayer(gameInfo.PlayerOptions[toPlayerSlot]) - end, - - RemoveAI = function(slot) - if gameInfo.PlayerOptions[slot].Human then - WARN('Use EjectPlayer to remove humans') - return - end - - gameInfo.PlayerOptions[slot] = nil - ClearSlotInfo(slot) - lobbyComm:BroadcastData( - { - Type = 'ClearSlot', - Slot = slot, - } - ) - end, - - --- Returns false if there's an obvious reason why a slot movement between the two given - -- slots will fail. - -- - -- @param moveFrom Slot number to move from - -- @param moveTo Slot number to move to. - SanityCheckSlotMovement = function(moveFrom, moveTo) - if moveTo == moveFrom then - -- no need to move a slot to its current location - return false - end - - if gameInfo.ClosedSlots[moveTo] then - LOG("HostUtils.MovePlayerToEmptySlot: requested slot " .. moveTo .. " is closed") - return false - end - - if moveTo > numOpenSlots or moveTo < 1 then - LOG("HostUtils.MovePlayerToEmptySlot: requested slot " .. moveTo .. " is out of range") - return false - end - - if moveFrom > numOpenSlots or moveFrom < 1 then - LOG("HostUtils.MovePlayerToEmptySlot: target slot " .. moveFrom .. " is out of range") - return false - end - - return true - end, - - --- Move a player from one slot to another, unoccupied one. Is a no-op if the requested slot - -- is occupied, closed, or out of range. Races over network may cause this to occur during - -- normal operation. - -- - -- @param currentSlot The slot occupied by the player to move - -- @param requestedSlot The slot to move this player to. - MovePlayerToEmptySlot = function(currentSlot, requestedSlot) - -- Bail out early for the stupid cases. - if not HostUtils.SanityCheckSlotMovement(currentSlot, requestedSlot) then - return - end - - -- This one's only specific to moving to an empty slot, naturally. - if gameInfo.PlayerOptions[requestedSlot] then - LOG("HostUtils.MovePlayerToEmptySlot: requested slot " .. requestedSlot .. " already occupied") - return false - end - - local player = gameInfo.PlayerOptions[currentSlot] - - KeepSameFactionOrRandom(currentSlot, requestedSlot, player) - - gameInfo.PlayerOptions[requestedSlot] = gameInfo.PlayerOptions[currentSlot] - gameInfo.PlayerOptions[currentSlot] = nil - ClearSlotInfo(currentSlot) - SetSlotInfo(requestedSlot, gameInfo.PlayerOptions[requestedSlot]) - - lobbyComm:BroadcastData( - { - Type = 'SlotMove', - OldSlot = currentSlot, - NewSlot = requestedSlot, - Options = gameInfo.PlayerOptions[requestedSlot]:AsTable(), - } - ) - - -- This is far from optimally efficient, as it will SetSlotInfo twice when autoteams is enabled. - AssignAutoTeams() - end, - - --- Swap the players in the two given slots. - -- - -- If the target slot is unoccupied, the player in the first slot is simply moved there. - -- If the target slot is closed, this is a no-op. - -- If a player or ai occupies both slots, they are swapped. - SwapPlayers = function(slot1, slot2) - - -- Bail out early for the stupid cases. - if not HostUtils.SanityCheckSlotMovement(slot1, slot2) then - return - end - - local player1 = gameInfo.PlayerOptions[slot1] - local player2 = gameInfo.PlayerOptions[slot2] - - -- If we're moving onto a blank, take the easy way out. - if not player2 then - HostUtils.MovePlayerToEmptySlot(slot1, slot2) - return - end - - -- Do the swap on our end - DoSlotSwap(slot1, slot2) - - -- Tell everyone else to do the swap too - lobbyComm:BroadcastData( - { - Type = 'SwapPlayers', - Slot1 = slot1, - Slot2 = slot2, - } - ) - - -- %s has switched with %s - SendSystemMessage("lobui_0417", player1.PlayerName, player2.PlayerName) - end, - - --- Add an observer - -- - -- @param observerData A PlayerData object representing this observer. - TryAddObserver = function(senderID, observerData) - local index = 1 - while gameInfo.Observers[index] do - index = index + 1 - end - - gameInfo.Observers[index] = observerData - - lobbyComm:BroadcastData( - { - Type = 'ObserverAdded', - Slot = index, - Options = observerData:AsTable(), - } - ) - - -- %s has joined as an observer. - SendSystemMessage("lobui_0202", observerData.PlayerName) - refreshObserverList() - end, - - --- Attempt to add a player to a slot. If none are available, add them as an observer. - -- - -- @param senderID The peer ID of the player we're adding. - -- @param slot The slot to insert the player to. A value of less than 1 indicates "any slot" - -- @param playerData A PlayerData object representing the player to add. - TryAddPlayer = function(senderID, slot, playerData) - -- CPU benchmark code - if playerData.Human and not singlePlayer then - lobbyComm:SendData(senderID, {Type='CPUBenchmark', Benchmarks=CPU_Benchmarks}) - end - - local newSlot = slot - - if not slot or slot < 1 or newSlot > numOpenSlots then - newSlot = HostUtils.FindEmptySlot() - end - - -- if no slot available, and human, try to make them an observer - if newSlot == -1 then - PrivateChat(senderID, LOC("No slots available, attempting to make you an observer")) - if playerData.Human then - HostUtils.TryAddObserver(senderID, playerData) - end - return - end - - -- if a color is requested, attempt to use that color if available, otherwise, assign first available - if not IsColorFree(playerData.PlayerColor, slot) then - SetPlayerColor(playerData, GetAvailableColor()) - end - - gameInfo.PlayerOptions[newSlot] = playerData - lobbyComm:BroadcastData( - { - Type = 'SlotAssigned', - Slot = newSlot, - Options = playerData:AsTable(), - } - ) - - SetSlotInfo(newSlot, gameInfo.PlayerOptions[newSlot]) - -- This is far from optimally efficient, as it will SetSlotInfo twice when autoteams is enabled. - AssignAutoTeams() - PossiblyAnnounceGameFull() - end, - - --- Add an AI to the game in the given slot. - -- - -- @param name The name to use in the player list for this AI. - -- @param personality The "personality" key, such as "adaptive" or "easy", for this AI. - -- @param slot (optional) The slot into which to put this AI. Defaults to the first empty - -- slot from the top of the list. - AddAI = function(name, personality, slot) - HostUtils.TryAddPlayer(hostID, slot, GetAIPlayerData(name, personality, slot)) - end, - - PlayerMissingMapAlert = function(id) - local slot = FindSlotForID(id) - local name - local needMessage = false - if slot then - name = gameInfo.PlayerOptions[slot].PlayerName - if not gameInfo.PlayerOptions[slot].BadMap then - needMessage = true - end - gameInfo.PlayerOptions[slot].BadMap = true - else - slot = FindObserverSlotForID(id) - if slot then - name = gameInfo.Observers[slot].PlayerName - if not gameInfo.Observers[slot].BadMap then - needMessage = true - end - gameInfo.Observers[slot].BadMap = true - end - end - - if needMessage then - -- %s is missing map %s. - SendSystemMessage("lobui_0330", name, gameInfo.GameOptions.ScenarioFile) - LOG('>> '..name..' is missing map '..gameInfo.GameOptions.ScenarioFile) - if name == localPlayerName then - LOG('>> '..gameInfo.GameOptions.ScenarioFile..' replaced with '.. UIUtil.defaultScenario) - SetGameOption('ScenarioFile', UIUtil.defaultScenario) - end - end - end, - - -- This function is needed because army numbers need to be calculated: armies are numbered incrementally in slot order. - -- Call this function once just before game starts - SendArmySettingsToServer = function() - local armyIdx = 1 - local MAXSLOT = 16 - for slotNum = 1, MAXSLOT do - local playerInfo = gameInfo.PlayerOptions[slotNum] - if playerInfo ~= nil then - LOG('>> SendArmySettingsToServer: Setting army '..armyIdx..' for slot '..slotNum) - SendPlayerOption(playerInfo, 'Army', armyIdx) - armyIdx = armyIdx + 1 - else - LOG('>> SendArmySettingsToServer: Slot '..slotNum..' empty') - end - end - end, - - --- Send player settings to the server - SendPlayerSettingsToServer = function(slotNum) - local playerInfo = gameInfo.PlayerOptions[slotNum] - SendPlayerOption(playerInfo, 'Faction', playerInfo.Faction) - SendPlayerOption(playerInfo, 'Color', playerInfo.PlayerColor) - SendPlayerOption(playerInfo, 'Team', playerInfo.Team) - SendPlayerOption(playerInfo, 'StartSpot', slotNum) - end, - - --- Called by the host when someone's readyness state changes to update the enabledness of buttons. - RefreshButtonEnabledness = function() - -- disable options when all players are marked ready - -- Is at least one person not ready? - local playerNotReady = GetPlayersNotReady() ~= false - - -- Host should be able to set game options even if he is observer if all slots are AI - local hostObserves = false - if not playerNotReady and IsObserver(localPlayerID) then - hostObserves = true - end - - local buttonState = hostObserves or playerNotReady - - UIUtil.setEnabled(GUI.gameoptionsButton, buttonState) - UIUtil.setEnabled(GUI.defaultOptions, buttonState) - UIUtil.setEnabled(GUI.randMap, buttonState) - UIUtil.setEnabled(GUI.autoTeams, buttonState) - UIUtil.setEnabled(GUI.restrictedUnitsOrPresetsBtn, buttonState) - - -- Launch button enabled if everyone is ready. - UIUtil.setEnabled(GUI.launchGameButton, singlePlayer or hostObserves or not playerNotReady) - - -- Disable the AutoBalance button if any players are on a tean greater than 2 or buttonState is false - local noOtherTeams = true - for i, player in gameInfo.PlayerOptions:pairs() do - -- Team numbers are 1 higher on the backend - if player.Team > 3 then - noOtherTeams = false - break - end - end - UIUtil.setEnabled(GUI.PenguinAutoBalance, noOtherTeams and buttonState) - end, - - -- Update our local gameInfo.GameMods from selected map name and selected mods, then - -- notify other clients about the change. - UpdateMods = function(newPlayerID, newPlayerName) - local newmods = {} - local missingmods = {} - local blacklistedMods = {} - - -- If any of our active sim mods are blacklisted, warn the user, turn them off, and - -- go through the "mods changed" handler code again with the smaller set. - local bannedMods = CheckModCompatability() - if not table.empty(bannedMods) then - WarnIncompatibleMods() - - selectedSimMods = SetUtils.Subtract(selectedSimMods, bannedMods) - selectedUIMods = SetUtils.Subtract(selectedUIMods, bannedMods) - OnModsChanged(selectedSimMods, selectedUIMods) - - return - end - - for modId, _ in selectedSimMods do - if IsModAvailable(modId) then - newmods[modId] = true - else - table.insert(missingmods, modId) - end - end - - -- We were called to update the sim mod set for the game, and have _really_ made changes - if not table.equal(gameInfo.GameMods, newmods) and not newPlayerID then - gameInfo.GameMods = newmods - lobbyComm:BroadcastData { Type = "ModsChanged", GameMods = gameInfo.GameMods } - local nummods = 0 - local uids = "" - - for k in gameInfo.GameMods do - nummods = nummods + 1 - if uids == "" then - uids = k - else - uids = string.format("%s %s", uids, k) - end - - end - GpgNetSend('GameMods', "activated", nummods) - - if nummods > 0 then - GpgNetSend('GameMods', "uids", uids) - end - elseif not table.equal(gameInfo.GameMods, newmods) and newPlayerID then - local modnames = "" - local totalMissing = table.getn(missingmods) - local totalListed = 0 - if totalMissing > 0 then - for index, id in missingmods do - for k,mod in Mods.GetGameMods() do - if mod.uid == id then - totalListed = totalListed + 1 - if totalMissing == totalListed then - modnames = modnames .. " " .. mod.name - else - modnames = modnames .. " " .. mod.name .. " + " - end - end - end - end - end - local reason = (LOCF('You were automaticly removed from the lobby because you ' .. - 'don\'t have the following mod(s):\n%s \nPlease, install the mod before you join the game lobby', modnames)) - -- TODO: Verify this functionality - if FindNameForID(newPlayerID) then - AddChatText(FindNameForID(newPlayerID)..LOC(' kicked because he does not have this mod: ')..modnames) - else - if newPlayerName then - AddChatText(newPlayerName..LOC(' kicked because he does not have this mod: ')..modnames) - else - AddChatText(LOC('The last player is kicked because he does not have this mod: ')..modnames) - end - end - lobbyComm:EjectPeer(newPlayerID, reason) - end - end, - - --- Find and return the id of an unoccupied slot. - -- - -- @return The id of an empty slot, of -1 if none is available. - FindEmptySlot = function() - for i = 1, numOpenSlots do - if not gameInfo.PlayerOptions[i] and not gameInfo.ClosedSlots[i] then - return i - end - end - - return -1 - end, - - KickObservers = function(reason) - for k,observer in gameInfo.Observers:pairs() do - lobbyComm:EjectPeer(observer.OwnerID, reason or "KickedByHost") - end - gameInfo.Observers = WatchedValueArray(LobbyComm.maxPlayerSlots) - end - } end diff --git a/lua/ui/maputil.lua b/lua/ui/maputil.lua index 7a37f1a7560..20bdbc63399 100644 --- a/lua/ui/maputil.lua +++ b/lua/ui/maputil.lua @@ -95,6 +95,8 @@ ---@field norushoffsetX_ARMY_16? number ---@field norushoffsetY_ARMY_16? number ---@field preview? FileName +---@field url? string # optional link to the map's page (vault / repo); shown in the lobby +---@field author? string # optional map author; not standard, shown in the lobby when present ---@field save FileName ---@field script FileName ---@field size {[1]: number, [2]: number} diff --git a/lua/ui/modutilities.lua b/lua/ui/modutilities.lua new file mode 100644 index 00000000000..655cc39d777 --- /dev/null +++ b/lua/ui/modutilities.lua @@ -0,0 +1,376 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- Mod utilities: the UI-facing front door for working with mods, the sibling of `maputil.lua` +-- for maps. It is a thin, *pure* helper layer on top of `/lua/mods.lua` (the engine-facing +-- global that the sim, the blueprint loader and ~30 other modules use, so it stays put). +-- +-- We're slowly moving UI mod logic here, the same way the map work moved off `MapUtil`: +-- * classification (`Classify`) and display formatting (`FormatName` / `FormatVersion` / +-- `FormatAuthor`) — lifted out of the legacy `ModsManager.lua`, which interleaved them +-- with its layout code; +-- * dependency-aware selection edits (`ResolveEnable` / `ResolveDisable`) — the bits of the +-- legacy `ActivateMod` / `DeactivateMod` that are *just set arithmetic*, with the UI side +-- effects removed; +-- * persistence: writing the selection back to the preference file (`SetSelectedMods` and the +-- UI-only / sim-only variants), so a dialog never touches prefs directly; and +-- * **presets** — named selection snapshots stored in prefs, replacing the old "favorites". +-- +-- A picker (e.g. the custom lobby's mod-select dialog) decides *what* the selection is and hands +-- it here to persist; it never reads or writes prefs itself. See +-- `/lua/ui/lobby/customlobby/modselect/`. + +local Mods = import("/lua/mods.lua") +local Prefs = import("/lua/user/prefs.lua") +local SetUtils = import("/lua/system/setutils.lua") +local Blacklist = import("/etc/faf/blacklist.lua").Blacklist + +--- A mod uid set: `{ [uid] = true }`. The shape `mods.lua` and the launch model use. +---@alias UIModSelection table + +--- A normalized, display-ready view of a `ModInfo`. The catalog builds these from +--- `Mods.AllSelectableMods()` + the formatting / classification helpers below. +---@class UILobbyModInfo +---@field uid string +---@field name string # raw mod name (unformatted) +---@field title string # display name (version stripped, title-cased) +---@field versionText string # e.g. "v4", or "" when unversioned +---@field author string # cleaned author (first author, no underscores) +---@field description string +---@field copyright? string +---@field icon FileName +---@field location FileName +---@field ui_only boolean +---@field type ModType # UI | GAME | BLACKLISTED | NO_DEPENDENCY | LOCAL +---@field blacklistReason? LocalizedString +---@field url? string +---@field github? string +---@field requires? UIModSelection # installed uids this mod pulls in +---@field missing? UIModSelection # required uids that aren't installed +---@field conflicts? UIModSelection# installed uids that can't be active alongside this one + +local PresetsPrefsKey = "customlobby_mod_presets" + +------------------------------------------------------------------------------- +--#region Enumeration (thin pass-throughs to mods.lua) + +--- All selectable mods on disk, keyed by uid. Cached by `mods.lua`. +---@return table +function GetSelectableMods() + return Mods.AllSelectableMods() +end + +--- Forces the next `GetSelectableMods` to re-read the disk (mods changed while open). +function Refresh() + Mods.ClearCache() +end + +--- Whether a mod uid is on the FAF blacklist; returns the (localized) reason, or nil. +---@param uid string +---@return LocalizedString | nil +function GetBlacklistReason(uid) + return Blacklist[uid] +end + +--- The installed/missing/conflicting dependency sets for a mod. Pass-through to `mods.lua`. +---@param uid string +---@return {requires: UIModSelection?, missing: UIModSelection?, conflicts: UIModSelection?} +function GetDependencies(uid) + return Mods.GetDependencies(uid) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Classification + formatting (lifted from ModsManager.lua) + +--- Classifies a mod into the type the picker filters/badges by: BLACKLISTED (on the FAF +--- blacklist or disabled), NO_DEPENDENCY (requires a missing or blacklisted mod), else UI / GAME. +--- (The legacy LOCAL "players missing this mod" type needs host-side peer availability — left +--- for the host slice; this is per-peer reference data.) +---@param mod ModInfo +---@return ModType +function Classify(mod) + if Blacklist[mod.uid] or mod.enabled == false then + return "BLACKLISTED" + end + + local dependencies = Mods.GetDependencies(mod.uid) + if dependencies.missing then + return "NO_DEPENDENCY" + end + if dependencies.requires then + for required in dependencies.requires do + if Blacklist[required] then + return "NO_DEPENDENCY" + end + end + end + + if mod.ui_only then + return "UI" + end + return "GAME" +end + +--- Strips an embedded version tag from a mod name and title-cases it — so "my-mod v2" +--- displays as "My Mod". Mirrors the legacy `GetModNameVersion` name handling. +---@param mod ModInfo +---@return string +function FormatName(mod) + local name = mod.name or "" + name = string.gsub(name, '[%[%<%{%(%s]+[vV]+%s*%d+[%.%d]*[%]%>%}%)%s]*', '') + name = StringCapitalize(name) + name = string.gsub(name, "-", "", 1) + return name +end + +--- A short version string ("v4"), or "" when the mod declares no integer version. +---@param mod ModInfo +---@return string +function FormatVersion(mod) + if type(mod.version) == 'number' then + return "v" .. tostring(mod.version) + elseif type(mod.version) == 'string' and mod.version ~= "" then + return "v" .. mod.version + end + return "" +end + +--- The first credited author, cleaned up, or "UNKNOWN". Mirrors the legacy `GetModAuthor`. +---@param mod ModInfo +---@return string +function FormatAuthor(mod) + local author = mod.author + if not author or author == "" then + return "UNKNOWN" + end + if string.len(author) >= 20 then + if string.find(author, ",") then + author = StringSplit(author, ',')[1] + elseif string.find(author, " ") then + author = StringSplit(author, ' ')[1] + end + end + return (string.gsub(author, "_", "", 1)) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Dependency-aware selection edits +-- +-- Pure set arithmetic — given a selection and a uid, return the NEW selection. The legacy +-- ActivateMod/DeactivateMod did this inline with UI prompts and counters; the picker keeps the +-- UI concerns (a conflict confirmation, repaint) and calls these for the set maths. + +--- Adds `uid` to a copy of `selection`, pulling in everything it requires (recursively) and +--- removing any installed mods it conflicts with. Returns the new selection and the conflicts +--- that were turned off (so the caller can surface them). +---@param selection UIModSelection +---@param uid string +---@return UIModSelection selection +---@return string[] disabledConflicts +function ResolveEnable(selection, uid) + local next = table.copy(selection) + local disabled = {} + + local function enable(target) + if next[target] then + return + end + next[target] = true + + local dependencies = Mods.GetDependencies(target) + if dependencies.conflicts then + for conflict in dependencies.conflicts do + if next[conflict] then + next[conflict] = nil + table.insert(disabled, conflict) + end + end + end + if dependencies.requires then + for required in dependencies.requires do + enable(required) + end + end + end + + enable(uid) + return next, disabled +end + +--- Removes `uid` from a copy of `selection`, and removes any selected mods that require it +--- (recursively). Returns the new selection. +---@param selection UIModSelection +---@param uid string +---@return UIModSelection +function ResolveDisable(selection, uid) + local next = table.copy(selection) + + local function disable(target) + if not next[target] then + return + end + next[target] = nil + -- drop anything still selected that requires the mod we're turning off + for selected in next do + local dependencies = Mods.GetDependencies(selected) + if dependencies.requires and dependencies.requires[target] then + disable(selected) + end + end + end + + disable(uid) + return next +end + +--- Drops uids that aren't installed any more (e.g. loading an old preset after uninstalling). +---@param selection UIModSelection +---@return UIModSelection +function PruneMissing(selection) + local installed = Mods.AllMods() + return SetUtils.PredicateFilter(selection, function(uid) + return installed[uid] ~= nil + end) +end + +--- The sim-mod (`not ui_only`) subset of a selection. +---@param selection UIModSelection +---@return UIModSelection +function FilterSimMods(selection) + local installed = Mods.AllMods() + return SetUtils.PredicateFilter(selection, function(uid) + return installed[uid] ~= nil and not installed[uid].ui_only + end) +end + +--- The UI-mod (`ui_only`) subset of a selection. +---@param selection UIModSelection +---@return UIModSelection +function FilterUIMods(selection) + local installed = Mods.AllMods() + return SetUtils.PredicateFilter(selection, function(uid) + return installed[uid] ~= nil and installed[uid].ui_only + end) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Persistence (the "updates the preference file" responsibility) + +--- The player's currently selected mods (sim + UI), from the preference file. +---@return UIModSelection +function GetSelectedMods() + return Mods.GetSelectedMods() +end + +--- The selected sim mods (the set that becomes the launch model's `GameMods`). +---@return UIModSelection +function GetSelectedSimMods() + return Mods.GetSelectedSimMods() +end + +--- The selected UI mods (applied per-player, never on the wire). +---@return UIModSelection +function GetSelectedUIMods() + return Mods.GetSelectedUIMods() +end + +--- Persists the full selection (sim + UI) to prefs and re-applies the active UI mods. Used by +--- the standalone (main-menu) path, where there's no host to dictate sim mods. +---@param selection UIModSelection +function SetSelectedMods(selection) + Mods.SetSelectedMods(selection) +end + +--- Persists only the UI-mod portion of `uiSelection`, keeping the existing sim selection in +--- prefs untouched, then re-applies active UI mods. Used by the lobby path, where sim mods are +--- host-dictated (synced separately) and only UI mods are this player's own choice. +---@param uiSelection UIModSelection +function SetSelectedUIMods(uiSelection) + local merged = GetSelectedSimMods() -- keep my current sim mods as-is + for uid in FilterUIMods(uiSelection) do + merged[uid] = true + end + Mods.SetSelectedMods(merged) +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Presets (named selection snapshots — replacing "favorites") +-- +-- A preset is `{ Name = string, Mods = UIModSelection }`. Stored as an array under one prefs +-- key so the order the user created them in is preserved. + +--- All saved presets, in creation order. +---@return { Name: string, Mods: UIModSelection }[] +function GetPresets() + return Prefs.GetFromCurrentProfile(PresetsPrefsKey) or {} +end + +--- Finds a preset's selection by name, or nil. +---@param name string +---@return UIModSelection | nil +function GetPreset(name) + for _, preset in GetPresets() do + if preset.Name == name then + return preset.Mods + end + end + return nil +end + +--- Saves `selection` under `name`, overwriting an existing preset with the same name. +---@param name string +---@param selection UIModSelection +function SavePreset(name, selection) + local presets = GetPresets() + for _, preset in presets do + if preset.Name == name then + preset.Mods = table.copy(selection) + Prefs.SetToCurrentProfile(PresetsPrefsKey, presets) + return + end + end + table.insert(presets, { Name = name, Mods = table.copy(selection) }) + Prefs.SetToCurrentProfile(PresetsPrefsKey, presets) +end + +--- Removes the preset with the given name (no-op if absent). +---@param name string +function DeletePreset(name) + local presets = GetPresets() + local kept = {} + for _, preset in presets do + if preset.Name ~= name then + table.insert(kept, preset) + end + end + Prefs.SetToCurrentProfile(PresetsPrefsKey, kept) +end + +--#endregion diff --git a/lua/ui/optionutil.lua b/lua/ui/optionutil.lua new file mode 100644 index 00000000000..6d9a227f461 --- /dev/null +++ b/lua/ui/optionutil.lua @@ -0,0 +1,282 @@ +--****************************************************************************************************** +--** Copyright (c) 2026 FAForever +--** +--** Permission is hereby granted, free of charge, to any person obtaining a copy +--** of this software and associated documentation files (the "Software"), to deal +--** in the Software without restriction, including without limitation the rights +--** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +--** copies of the Software, and to permit persons to whom the Software is +--** furnished to do so, subject to the following conditions: +--** +--** The above copyright notice and this permission notice shall be included in all +--** copies or substantial portions of the Software. +--** +--** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +--** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +--** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +--** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +--** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +--** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +--** SOFTWARE. +--****************************************************************************************************** + +-- Option utilities: the UI-facing helper for game options, the sibling of `maputil.lua` (maps) +-- and `modutilities.lua` (mods). Inspired by Penguin5's `optionutil.lua`. +-- +-- A game option is a `ScenarioOption` (see lobbyOptions.lua): `{ key, label, help, default, +-- mponly?, values, value_text?, value_help? }`, where `values` is a list of either +-- `{ key, text, help }` tables or bare values formatted through `value_text`. `default` is a +-- 1-based index into `values`. The *selected* value of an option is stored, by its `key`, in the +-- game-options value table (the launch model's `GameOptions`) as the chosen value's `key`. +-- +-- Options come from three sources — the three columns of the options dialog: +-- * **lobby** — the static base options (team / global / AI) from `lobbyOptions.lua`; +-- * **scenario** — the selected map's `_options.lua` (loaded via MapUtil); +-- * **mods** — each selected sim mod's `/lua/AI/LobbyOptions/lobbyoptions.lua` (`AIOpts`). +-- +-- This module only *gathers and interprets* the schema + values (pure, no UI). Which option +-- value is live, the per-option default, and how a value reads on screen all come from here so the +-- dialog and the controller agree. The schema is reference data (each peer derives it from the +-- synced `ScenarioFile` + `GameMods`); only the *values* are synced. + +local LobbyOptions = import("/lua/ui/lobby/lobbyOptions.lua") +local MapUtil = import("/lua/ui/maputil.lua") +local Mods = import("/lua/mods.lua") + +-- where a sim mod declares its lobby options, relative to the mod's location +local ModOptionsFile = "/lua/AI/LobbyOptions/lobbyoptions.lua" + +------------------------------------------------------------------------------- +--#region Gathering the schema + +--- Appends every entry of `source` to `target` whose `key` isn't already present, so later +--- sources can't silently shadow an earlier option. Returns `target`. +---@param target ScenarioOption[] +---@param source ScenarioOption[] +---@return ScenarioOption[] +local function AppendUniqueByKey(target, source) + local seen = {} + for _, option in target do + seen[option.key] = true + end + for _, option in source do + if option.key and not seen[option.key] then + seen[option.key] = true + table.insert(target, option) + end + end + return target +end + +--- The static lobby options (team + global + AI), as one fresh list. +---@return ScenarioOption[] +function GetLobbyOptions() + local options = {} + AppendUniqueByKey(options, LobbyOptions.teamOptions or {}) + AppendUniqueByKey(options, LobbyOptions.globalOpts or {}) + AppendUniqueByKey(options, LobbyOptions.AIOpts or {}) + return options +end + +--- The selected scenario's own options (its `_options.lua`), validated, or an empty list. +---@param scenarioFile FileName | false +---@return ScenarioOption[] +function GetScenarioOptions(scenarioFile) + if not scenarioFile then + return {} + end + local path = MapUtil.GetPathToScenarioOptions(scenarioFile) + local ok, options = pcall(MapUtil.LoadScenarioOptionsFile, path) + if not ok or not options then + return {} + end + -- ValidateScenarioOptions repairs bad `default` indices *in place* (and returns false when it + -- had to), so we call it for that side-effect and keep the now-sane options either way. It's + -- pcall'd because it indexes `option.values` — untrusted disk data may have a malformed option. + pcall(MapUtil.ValidateScenarioOptions, options) + return options +end + +--- Loads a single mod's lobby options (its `lobbyoptions.lua` `AIOpts`), or nil. Untrusted disk +--- data, so the import + the in-place default repair are both pcall'd. +---@param mod ModInfo +---@return ScenarioOption[] | nil +local function LoadModOptions(mod) + if not (mod and mod.location) then + return nil + end + local path = mod.location .. ModOptionsFile + if not DiskGetFileInfo(path) then + return nil + end + local ok, module = pcall(import, path) + if not (ok and module and module.AIOpts) then + return nil + end + pcall(MapUtil.ValidateScenarioOptions, module.AIOpts) + return module.AIOpts +end + +--- The options contributed by the selected sim mods — each mod's `lobbyoptions.lua` `AIOpts`, +--- merged and de-duplicated by key. +---@param gameMods table # selected sim-mod uid set (the launch model's GameMods) +---@return ScenarioOption[] +function GetModOptions(gameMods) + if not gameMods then + return {} + end + local allMods = Mods.AllMods() + local options = {} + for uid in gameMods do + local opts = LoadModOptions(allMods[uid]) + if opts then + AppendUniqueByKey(options, opts) + end + end + return options +end + +--- The selected sim mods' options grouped by mod, for showing each option's origin. Only mods +--- that actually contribute options appear. +---@param gameMods table +---@return { uid: string, name: string, options: ScenarioOption[] }[] +function GetModOptionsByMod(gameMods) + if not gameMods then + return {} + end + local allMods = Mods.AllMods() + local groups = {} + for uid in gameMods do + local mod = allMods[uid] + local opts = LoadModOptions(mod) + if opts and table.getn(opts) > 0 then + table.insert(groups, { uid = uid, name = mod.name or uid, options = opts }) + end + end + return groups +end + +--#endregion + +------------------------------------------------------------------------------- +--#region Interpreting options + values + +--- The `key` an option value resolves to (a `{key=...}` table's key, or a bare value itself). +---@param value any | ScenarioOptionValue +---@return any +function ValueKeyOf(value) + if type(value) == 'table' then + return value.key + end + return value +end + +--- How an option value reads on screen: a `{text=...}` value's localized text, else the bare +--- value run through the option's `value_text` formatter (defaulting to the value itself). +---@param option ScenarioOption +---@param value any | ScenarioOptionValue +---@return string +function ValueDisplay(option, value) + if type(value) == 'table' then + return LOC(value.text) or tostring(value.key) + end + if option.value_text then + -- pcall: a malformed mod-supplied `value_text` (bad format spec) would otherwise throw + local ok, formatted = pcall(string.format, LOC(option.value_text), tostring(value)) + return ok and formatted or tostring(value) + end + return tostring(value) +end + +--- The default value-key of an option (`values[default]`, resolved through `ValueKeyOf`). +---@param option ScenarioOption +---@return any +function GetDefaultValueKey(option) + -- clamp against a garbage `default` index (untrusted options that skipped validation) + return ValueKeyOf(option.values[option.default] or option.values[1]) +end + +--- The currently selected value-key for an option: the stored value if present, else the default. +---@param option ScenarioOption +---@param values table # key -> selected value-key +---@return any +function GetCurrentValueKey(option, values) + local current = values[option.key] + if current == nil then + return GetDefaultValueKey(option) + end + return current +end + +--- Whether an option is currently at its default value. +---@param option ScenarioOption +---@param values table +---@return boolean +function IsDefault(option, values) + return GetCurrentValueKey(option, values) == GetDefaultValueKey(option) +end + +--- The 1-based index into `option.values` of the given value-key, or the option's default index +--- when it doesn't match any value. +---@param option ScenarioOption +---@param valueKey any +---@return number +function FindValueIndex(option, valueKey) + for i, value in option.values do + if ValueKeyOf(value) == valueKey then + return i + end + end + return option.default +end + +--- The display labels for an option's values, in order (for a dropdown). +---@param option ScenarioOption +---@return string[] +function ValueLabels(option) + local labels = {} + for i, value in option.values do + labels[i] = ValueDisplay(option, value) + end + return labels +end + +--- Counts the options across all three sources (lobby + scenario + selected mods) whose current +--- value differs from its default — i.e. how many options the host has changed. This is the same +--- union the options dialog / Options panel render (mod options are deduped by key); it backs the +--- Options tab's count badge. +---@param scenarioFile FileName | false +---@param gameMods table +---@param values table +---@return number +function CountNonDefault(scenarioFile, gameMods, values) + local count = 0 + local function tally(options) + for _, option in options do + if not IsDefault(option, values) then + count = count + 1 + end + end + end + tally(GetLobbyOptions()) + tally(GetScenarioOptions(scenarioFile)) + tally(GetModOptions(gameMods)) + return count +end + +--- Fills `values` (a copy) with the default for every option in `options` that has no value yet, +--- so the result is a complete set ready to launch with. Returns the copy. +---@param options ScenarioOption[] +---@param values table +---@return table +function SeedDefaults(options, values) + local seeded = table.copy(values or {}) + for _, option in options do + if seeded[option.key] == nil then + seeded[option.key] = GetDefaultValueKey(option) + end + end + return seeded +end + +--#endregion diff --git a/lua/ui/uimain.lua b/lua/ui/uimain.lua index 361f55581cc..52827727185 100644 --- a/lua/ui/uimain.lua +++ b/lua/ui/uimain.lua @@ -76,7 +76,7 @@ function StartHostLobbyUI(protocol, port, playerName, gameName, mapFile, natTrav LOG("Hosting lobby from the command line") local lobby -- auto lobby only works with 2+ players - local autoStart = GetCommandLineArg("/players", 1)[1] >= 2 + local autoStart = (GetCommandLineArg("/players", 1)[1] or 0) >= 2 if autoStart then lobby = import("/lua/ui/lobby/autolobby.lua") else @@ -97,7 +97,7 @@ function StartJoinLobbyUI(protocol, address, playerName, natTraversalProvider) LOG("Joining lobby from the command line") -- can also be from lobby.lua ReturnToMenu(true), but that never gets called local lobby -- auto lobby only works with 2+ players - local autoStart = GetCommandLineArg("/players", 1)[1] >= 2 + local autoStart = (GetCommandLineArg("/players", 1)[1] or 0) >= 2 if autoStart then lobby = import("/lua/ui/lobby/autolobby.lua") else diff --git a/lua/ui/uiutil.lua b/lua/ui/uiutil.lua index 427f5f79ce7..fcbba286cc1 100644 --- a/lua/ui/uiutil.lua +++ b/lua/ui/uiutil.lua @@ -980,6 +980,31 @@ function CreateLobbyVertScrollbar(attachto, offset_right, offset_bottom, offset_ return CreateVertScrollbarFor(attachto, offset_right, "/SCROLLBAR_VERT/", offset_bottom, offset_top) end +--- Makes the mouse wheel scroll a region when the cursor is over its **content**, not just over the +--- scrollbar (the engine only routes wheel events to the bar itself). Forwards `WheelRotation` on +--- `control` to `scrollable:ScrollLines` — `scrollable` is whatever the scrollbar drives (a `Grid`, +--- `ItemList`, `TextArea`, or a custom control implementing `ScrollLines(axis, delta)`); usually the +--- same control. Chains any existing `HandleEvent` so it is safe to add on top of click handling. +---@param control Control # the control whose wheel events to capture (the content / its container) +---@param scrollable Control # the scrollable the scrollbar drives (often `control` itself) +---@param linesPerNotch? number # lines to scroll per wheel notch (default 3) +function ForwardWheelToScroll(control, scrollable, linesPerNotch) + linesPerNotch = linesPerNotch or 3 + local previous = control.HandleEvent + control.HandleEvent = function(c, event) + if event.Type == 'WheelRotation' then + -- pass the explicit "Vert" axis: the native Grid indexes its state by axis (a nil axis + -- errors in grid.lua); single-axis scrollables ignore the argument + scrollable:ScrollLines("Vert", event.WheelRotation > 0 and -linesPerNotch or linesPerNotch) + return true + end + if previous then + return previous(c, event) + end + return false + end +end + -- cause a dialog to get input focus, optional functions to perform when the user hits enter or escape -- functions signature is: function() function MakeInputModal(control, onEnterFunc, onEscFunc) diff --git a/scripts/LaunchCustomLobby.ps1 b/scripts/LaunchCustomLobby.ps1 new file mode 100644 index 00000000000..abe1c91a15e --- /dev/null +++ b/scripts/LaunchCustomLobby.ps1 @@ -0,0 +1,165 @@ +# ****************************************************************************************************** +# ** Copyright (c) 2026 FAForever +# ** +# ** Permission is hereby granted, free of charge, to any person obtaining a copy +# ** of this software and associated documentation files (the "Software"), to deal +# ** in the Software without restriction, including without limitation the rights +# ** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# ** copies of the Software, and to permit persons to whom the Software is +# ** furnished to do so, subject to the following conditions: +# ** +# ** The above copyright notice and this permission notice shall be included in all +# ** copies or substantial portions of the Software. +# ** +# ** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# ** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# ** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# ** AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# ** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# ** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# ** SOFTWARE. +# ****************************************************************************************************** + +# Launches one or more instances straight into the REGULAR custom-game lobby (not the +# autolobby), so the lobby UI can be developed and debugged quickly. +# +# How the lobby is reached (see lua\ui\uimain.lua): +# - host -> /hostgame -> StartHostLobbyUI +# - join -> /joingame
-> StartJoinLobbyUI +# Both pick the AUTOLOBBY only when `/players >= 2` is present. This script deliberately +# omits `/players`, so they fall through to the regular lobby (lua\ui\lobby\lobby.lua). +# +# Quick UI iteration: .\LaunchCustomLobby.ps1 -players 1 (host only, single window) +# Networked lobby test: .\LaunchCustomLobby.ps1 -players 2 (host + 1 joining client) +# +# Style and conventions follow scripts\LaunchBotSession.ps1 and scripts\LaunchFAInstances.ps1. + +param ( + [int]$players = 2, # 1 = host only (fast UI debug); 2+ = host + joining clients + [string]$map = "/maps/scmp_009/SCMP_009_scenario.lua", # default map: Seton's Clutch + [int]$port = 15000 # host listen port +) + +# Base path to the bin directory +$binPath = "C:\ProgramData\FAForever\bin" + +# Paths to the potential executables within the base path +$debuggerExecutable = Join-Path $binPath "FAFDebugger.exe" +$regularExecutable = Join-Path $binPath "ForgedAlliance.exe" + +# Check for the existence of the executables and choose accordingly +if (Test-Path $debuggerExecutable) { + $gameExecutable = $debuggerExecutable + Write-Output "Using debugger executable: $gameExecutable" +} elseif (Test-Path $regularExecutable) { + $gameExecutable = $regularExecutable + Write-Output "Debugger not found, using regular executable: $gameExecutable" +} else { + Write-Output "Neither debugger nor regular executable found in $binPath. Exiting script." + exit 1 +} + +$hostProtocol = "udp" +$hostPlayerName = "HostPlayer_1" +$gameName = "DevLobby" + +# Random pools to draw per-player info from (mirrors scripts\LaunchFAInstances.ps1). +$factions = @("uef", "aeon", "cybran", "seraphim") +$clans = @("Yps", "Nom", "Cly", "Mad", "Gol", "Kur", "Row", "Jip", "Bal", "She") +$countries = @("us", "gb", "de", "fr", "nl", "ru", "ca", "au", "br", "pl") +$divisions = @("bronze", "silver", "gold", "diamond", "master", "grandmaster", "unlisted") +$subdivisions = @("I", "II", "III", "IV", "V") + +# Returns a randomised set of per-player arguments, mirroring what the FAF client passes for a +# real player. The custom lobby reads these when it builds the local player (see +# CustomLobbyController.CreateLocalPlayer). The host preserves them when seating a joining peer. +# * rating: the displayed rating (PL) is derived as mean - 3 * deviation. The deviation is kept low +# (most lobby players are established, deviation ~50-120; a fresh account would be ~500), so PL +# sits close to the mean and stays positive/realistic. +# * faction: a flag arg (/uef, /aeon, …) — no value. +# * team: 2 or 3 (shown as T1 / T2), so the team-aware layouts have a split to render. +# * grandmaster/unlisted have no subdivision (mirrors LaunchFAInstances). +function Get-PlayerArgs { + $mean = Get-Random -Minimum 800 -Maximum 2400 + $deviation = Get-Random -Minimum 25 -Maximum 120 + $numGames = Get-Random -Minimum 0 -Maximum 2000 + $team = Get-Random -Minimum 2 -Maximum 4 + + $playerArgs = @( + "/mean", $mean, "/deviation", $deviation, "/numgames", $numGames, + "/$($factions | Get-Random)", + "/team", $team, + "/clan", ($clans | Get-Random), + "/country", ($countries | Get-Random) + ) + + $division = $divisions | Get-Random + $playerArgs += @("/division", $division) + if ($division -ne "unlisted" -and $division -ne "grandmaster") { + $playerArgs += @("/subdivision", ($subdivisions | Get-Random)) + } + + return $playerArgs +} + +# Arguments shared by every instance. Note: NO `/players` argument — that is what keeps us +# on the regular lobby instead of the autolobby. +$commonArgs = @( + "/init", "init_local_development.lua", + "/nobugreport", + "/EnableDiskWatch", + "/nomovie", + "/showlog" +) + +# Window grid layout (so instances don't stack). The session refuses to launch below 1024x768. +Add-Type -AssemblyName System.Windows.Forms +$screenWidth = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea.Width +$screenHeight = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea.Height +$columns = [math]::Ceiling([math]::Sqrt($players)) +$rows = [math]::Ceiling($players / $columns) +$windowWidth = [math]::Max([math]::Floor($screenWidth / $columns), 1024) +$windowHeight = [math]::Max([math]::Floor($screenHeight / $rows), 768) + +function Launch-LobbyInstance { + param ( + [int]$instanceNumber, + [int]$xPos, + [int]$yPos, + [string[]]$arguments + ) + + $arguments += @("/position", $xPos, $yPos, "/size", $windowWidth, $windowHeight) + + try { + Start-Process -FilePath $gameExecutable -ArgumentList $arguments -NoNewWindow + Write-Host "Launched instance $instanceNumber at ($xPos, $yPos) size ($windowWidth, $windowHeight)" + } catch { + Write-Host "Failed to launch instance ${instanceNumber}: $_" + } +} + +# Host instance (top-left). Positional order for /hostgame is protocol, port, name, gameName, map. +$hostArgs = @( + "/log", "host_lobby_1.log", + "/debug", + "/hostgame", $hostProtocol, $port, $hostPlayerName, $gameName, $map +) + $commonArgs + (Get-PlayerArgs) +Launch-LobbyInstance -instanceNumber 1 -xPos 0 -yPos 0 -arguments $hostArgs + +# Joining client instances. Positional order for /joingame is protocol, address, name. +for ($i = 1; $i -lt $players; $i++) { + $row = [math]::Floor($i / $columns) + $col = $i % $columns + $xPos = $col * $windowWidth + $yPos = $row * $windowHeight + + $clientName = "ClientPlayer_$($i + 1)" + $clientArgs = @( + "/log", "client_lobby_$($i + 1).log", + "/joingame", $hostProtocol, "localhost:$port", $clientName + ) + $commonArgs + (Get-PlayerArgs) + Launch-LobbyInstance -instanceNumber ($i + 1) -xPos $xPos -yPos $yPos -arguments $clientArgs +} + +Write-Host "$players instance(s) launched into the regular custom lobby. Host on port $port." diff --git a/textures/texture-dimensions.csv b/textures/texture-dimensions.csv new file mode 100644 index 00000000000..a0663b64d97 --- /dev/null +++ b/textures/texture-dimensions.csv @@ -0,0 +1,8293 @@ +"RelativePath" , "Width", "Height", "Format" +"coordinateNotify.dds" , "64" , "64" , "dds" +"damage\scorch_01.dds" , "512" , "512" , "dds" +"editor\marker_generic.bmp" , "32" , "32" , "bmp" +"editor\marker_mass.bmp" , "32" , "32" , "bmp" +"effects\AeonBuildSpecular.dds" , "256" , "256" , "dds" +"effects\CybranBuildSpecular.dds" , "64" , "64" , "dds" +"effects\SeraphimBuildSpecular.dds" , "256" , "256" , "dds" +"effects\UEFBuildSpecular.dds" , "64" , "64" , "dds" +"engine\anisotropiclookup.dds" , "256" , "256" , "dds" +"engine\b_fails_to_load.dds" , "64" , "64" , "dds" +"engine\b_placeholder.dds" , "64" , "64" , "dds" +"engine\BlackSkirt.dds" , "16" , "16" , "dds" +"engine\Cloud01.DDS" , "1024" , "1024" , "dds" +"engine\Cloud02.DDS" , "1024" , "1024" , "dds" +"engine\Cloud03.DDS" , "1024" , "1024" , "dds" +"engine\Cloud04.DDS" , "1024" , "1024" , "dds" +"engine\decalMask.dds" , "512" , "512" , "dds" +"engine\editor_gizmo.dds" , "256" , "256" , "dds" +"engine\editor_group_gizmo.dds" , "256" , "256" , "dds" +"engine\FoamTest001.dds" , "512" , "512" , "dds" +"engine\FoamTest002.dds" , "512" , "512" , "dds" +"engine\FoamTest003.dds" , "512" , "512" , "dds" +"engine\FoamTest004.dds" , "512" , "512" , "dds" +"engine\GridTest.DDS" , "512" , "512" , "dds" +"engine\insectlookup.dds" , "256" , "256" , "dds" +"engine\SkyBoxTest01.dds" , "512" , "512" , "dds" +"engine\underConstruction.dds" , "256" , "256" , "dds" +"engine\waterCubemap.dds" , "512" , "512" , "dds" +"engine\waterFresnel.dds" , "256" , "1" , "dds" +"engine\waterramp_desert.dds" , "256" , "4" , "dds" +"engine\waterramp_desert02.dds" , "256" , "4" , "dds" +"engine\waterramp_desert03.dds" , "256" , "4" , "dds" +"engine\waterramp_desert03a.dds" , "256" , "4" , "dds" +"engine\waterramp_eg.dds" , "256" , "4" , "dds" +"engine\waterramp_evergreen02.dds" , "256" , "4" , "dds" +"engine\waterramp_FS2.dds" , "256" , "4" , "dds" +"engine\waterramp_lava.dds" , "256" , "4" , "dds" +"engine\waterramp_redrock.dds" , "256" , "4" , "dds" +"engine\waterramp_redrock01.dds" , "256" , "4" , "dds" +"engine\waterramp_redrock02.dds" , "256" , "4" , "dds" +"engine\waterramp_redrock03.dds" , "256" , "4" , "dds" +"engine\waterramp_tropical.dds" , "256" , "4" , "dds" +"engine\waterramp_tropical02.dds" , "256" , "4" , "dds" +"engine\waterramp_tundra.dds" , "256" , "4" , "dds" +"engine\waterramp.dds" , "256" , "4" , "dds" +"engine\waterramp03_Eop5.dds" , "256" , "4" , "dds" +"engine\waterrampSwamp01.dds" , "256" , "4" , "dds" +"engine\waterrampSwamp02.dds" , "256" , "4" , "dds" +"engine\waves.dds" , "256" , "256" , "dds" +"engine\waves000.dds" , "256" , "256" , "dds" +"engine\waves001.dds" , "256" , "256" , "dds" +"engine\waves6.dds" , "256" , "256" , "dds" +"engine\waves7.dds" , "512" , "512" , "dds" +"engine\wavesbump.dds" , "256" , "256" , "dds" +"environment\blackbackground.dds" , "16" , "16" , "dds" +"environment\BrightCloud.dds" , "512" , "512" , "dds" +"environment\Capella_bmp.dds" , "1024" , "1024" , "dds" +"environment\cirrus000.dds" , "256" , "256" , "dds" +"environment\cirrus001_512.dds" , "512" , "512" , "dds" +"environment\cumulus000.dds" , "1024" , "1024" , "dds" +"environment\cumulusDispersion000.dds" , "256" , "1" , "dds" +"environment\cumulusRamp000.dds" , "256" , "1" , "dds" +"environment\Decal_test_Albedo000.dds" , "256" , "256" , "dds" +"environment\Decal_test_Albedo001.dds" , "256" , "256" , "dds" +"environment\Decal_test_Albedo003.dds" , "512" , "512" , "dds" +"environment\Decal_test_Albedo004.dds" , "512" , "512" , "dds" +"environment\Decal_test_Glow000.dds" , "256" , "256" , "dds" +"environment\Decal_test_Glow001.dds" , "256" , "256" , "dds" +"environment\Decal_test_Glow003.dds" , "512" , "512" , "dds" +"environment\Decal_test_Glow004.dds" , "512" , "512" , "dds" +"environment\DefaultBackground.dds" , "1024" , "1024" , "dds" +"environment\DefaultEnvCube_TEST.dds" , "512" , "512" , "dds" +"environment\DefaultEnvCube.dds" , "512" , "512" , "dds" +"environment\DefaultSkyCube.dds" , "512" , "512" , "dds" +"environment\dissolve.dds" , "256" , "256" , "dds" +"environment\Earth_bmp.dds" , "1024" , "1024" , "dds" +"environment\EnvCube_aeon_aliencrystal.dds" , "128" , "128" , "dds" +"environment\EnvCube_aeon_desert.dds" , "128" , "128" , "dds" +"environment\EnvCube_aeon_Evergreen.dds" , "128" , "128" , "dds" +"environment\EnvCube_aeon_geothermal.dds" , "128" , "128" , "dds" +"environment\EnvCube_aeon_lava.dds" , "128" , "128" , "dds" +"environment\EnvCube_aeon_RedRocks.dds" , "128" , "128" , "dds" +"environment\EnvCube_aeon_tropical.dds" , "128" , "128" , "dds" +"environment\EnvCube_aeon_tundra.dds" , "128" , "128" , "dds" +"environment\EnvCube_Desert01a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Desert02a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Desert03a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Evergreen01a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Evergreen03a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Evergreen05a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Geothermal02a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Lava01a.dds" , "512" , "512" , "dds" +"environment\EnvCube_RedRocks05a.dds" , "512" , "512" , "dds" +"environment\EnvCube_RedRocks06.dds" , "512" , "512" , "dds" +"environment\EnvCube_RedRocks08a.dds" , "512" , "512" , "dds" +"environment\EnvCube_RedRocks09a.dds" , "512" , "512" , "dds" +"environment\EnvCube_RedRocks10.dds" , "512" , "512" , "dds" +"environment\EnvCube_Scx1Proto02.dds" , "512" , "512" , "dds" +"environment\EnvCube_seraphim_aliencrystal.dds" , "128" , "128" , "dds" +"environment\EnvCube_seraphim_desert.dds" , "128" , "128" , "dds" +"environment\EnvCube_seraphim_Evergreen.dds" , "128" , "128" , "dds" +"environment\EnvCube_seraphim_geothermal.dds" , "128" , "128" , "dds" +"environment\EnvCube_seraphim_lava.dds" , "128" , "128" , "dds" +"environment\EnvCube_seraphim_redrocks.dds" , "128" , "128" , "dds" +"environment\EnvCube_seraphim_tropical.dds" , "128" , "128" , "dds" +"environment\EnvCube_seraphim_tundra.dds" , "128" , "128" , "dds" +"environment\EnvCube_Tropical01a.dds" , "512" , "512" , "dds" +"environment\EnvCube_TropicalOp06a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Tundra02a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Tundra03a.dds" , "512" , "512" , "dds" +"environment\EnvCube_Tundra04a.dds" , "512" , "512" , "dds" +"environment\Eridani_bmp.dds" , "1024" , "1024" , "dds" +"environment\Falloff_seraphim_lookup.dds" , "512" , "512" , "dds" +"environment\horizonLookup.dds" , "128" , "4" , "dds" +"environment\Luthien_bmp.dds" , "1024" , "1024" , "dds" +"environment\Matar_bmp.dds" , "1024" , "1024" , "dds" +"environment\Minerva_bmp.dds" , "1024" , "1024" , "dds" +"environment\moonAlbedo000.dds" , "256" , "256" , "dds" +"environment\moonGlow000.dds" , "256" , "256" , "dds" +"environment\Orionis_bmp.dds" , "1024" , "1024" , "dds" +"environment\Pisces IV_bmp.dds" , "1024" , "1024" , "dds" +"environment\Pollux_bmp.dds" , "1024" , "1024" , "dds" +"environment\Procyon_bmp.dds" , "1024" , "1024" , "dds" +"environment\reveal.dds" , "32" , "32" , "dds" +"environment\Rigel_bmp.dds" , "1024" , "1024" , "dds" +"environment\SkyCube_blue.dds" , "512" , "512" , "dds" +"environment\SkyCube_Desert01.dds" , "512" , "512" , "dds" +"environment\SkyCube_Desert01a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Desert02.dds" , "512" , "512" , "dds" +"environment\SkyCube_Desert02a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Desert03a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Evergreen01.dds" , "512" , "512" , "dds" +"environment\SkyCube_Evergreen01a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Evergreen02.dds" , "512" , "512" , "dds" +"environment\SkyCube_Evergreen03.dds" , "512" , "512" , "dds" +"environment\SkyCube_Evergreen03a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Evergreen05a.dds" , "512" , "512" , "dds" +"environment\SkyCube_EvStormy.dds" , "512" , "512" , "dds" +"environment\SkyCube_Geothermal01.dds" , "512" , "512" , "dds" +"environment\SkyCube_Geothermal02.dds" , "512" , "512" , "dds" +"environment\SkyCube_Geothermal02a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Lava01.dds" , "512" , "512" , "dds" +"environment\SkyCube_Lava01a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Leipzig_Demo.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRock03.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks01.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks02.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks02a.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks03.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks04.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks05.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks05a.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks06.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks08a.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks09a.dds" , "512" , "512" , "dds" +"environment\SkyCube_RedRocks10.dds" , "512" , "512" , "dds" +"environment\SkyCube_Scx1Proto01.dds" , "512" , "512" , "dds" +"environment\SkyCube_Scx1Proto02.dds" , "512" , "512" , "dds" +"environment\SkyCube_Tropical01.DDS" , "512" , "512" , "dds" +"environment\SkyCube_Tropical01a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Tropical04.DDS" , "512" , "512" , "dds" +"environment\SkyCube_TropicalOp06.DDS" , "512" , "512" , "dds" +"environment\SkyCube_TropicalOp06a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Tundra01.dds" , "512" , "512" , "dds" +"environment\SkyCube_Tundra02.dds" , "512" , "512" , "dds" +"environment\SkyCube_Tundra02a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Tundra03.dds" , "512" , "512" , "dds" +"environment\SkyCube_Tundra03a.dds" , "512" , "512" , "dds" +"environment\SkyCube_Tundra04a.dds" , "512" , "512" , "dds" +"environment\Thiban_bmp.dds" , "1024" , "1024" , "dds" +"environment\Zeta Canis_bmp.dds" , "1024" , "1024" , "dds" +"highlight_bracket_enemy_sm.dds" , "64" , "64" , "dds" +"highlight_bracket_neutral_sm.dds" , "64" , "64" , "dds" +"highlight_bracket_player_sm.dds" , "64" , "64" , "dds" +"particles\_ramp_firetest_02.dds" , "256" , "16" , "dds" +"particles\_ramp_firetest.dds" , "256" , "16" , "dds" +"particles\_ramp_gatesparks.dds" , "256" , "16" , "dds" +"particles\_ramp_gatesparks2.dds" , "256" , "16" , "dds" +"particles\_swirl01.dds" , "128" , "128" , "dds" +"particles\_swirl02.dds" , "128" , "128" , "dds" +"particles\_swirl03.dds" , "128" , "128" , "dds" +"particles\_swirl04.dds" , "128" , "128" , "dds" +"particles\_swirl05.dds" , "128" , "128" , "dds" +"particles\_test_cloud_nuke03.dds" , "128" , "512" , "dds" +"particles\_test_cloud_smoke_alpha_02.dds" , "256" , "256" , "dds" +"particles\_test_cloud_smoke_alpha_03.dds" , "256" , "256" , "dds" +"particles\a_ramp_gas_test_01.dds" , "256" , "4" , "dds" +"particles\adjacency_aeon_beam_01.dds" , "32" , "32" , "dds" +"particles\adjacency_aeon_beam_02.dds" , "32" , "32" , "dds" +"particles\adjacency_aeon_beam_03.dds" , "32" , "32" , "dds" +"particles\aeon_gunship_chassis_01.dds" , "128" , "128" , "dds" +"particles\aeon_plasma01.dds" , "256" , "256" , "dds" +"particles\aeon_plasma02.dds" , "512" , "512" , "dds" +"particles\aeon_plasma03.dds" , "256" , "256" , "dds" +"particles\aeon_ring_distort_01.dds" , "256" , "256" , "dds" +"particles\air_contrail_01.dds" , "32" , "32" , "dds" +"particles\air_contrail_02.dds" , "32" , "32" , "dds" +"particles\antimatter_01.dds" , "128" , "128" , "dds" +"particles\arc_mirror_01.dds" , "128" , "128" , "dds" +"particles\arc_streak_01.dds" , "256" , "256" , "dds" +"particles\arc_streak_02.dds" , "256" , "256" , "dds" +"particles\ash_alpha_01.dds" , "32" , "128" , "dds" +"particles\backwards_muzzle_flash_add_01.dds" , "128" , "128" , "dds" +"particles\beam_blue_01.dds" , "32" , "128" , "dds" +"particles\beam_blue_02.dds" , "64" , "128" , "dds" +"particles\beam_blue_03.dds" , "128" , "128" , "dds" +"particles\beam_blue_04.dds" , "64" , "128" , "dds" +"particles\beam_cyan_03.dds" , "64" , "128" , "dds" +"particles\beam_data_01.dds" , "128" , "128" , "dds" +"particles\beam_disruptor.dds" , "64" , "64" , "dds" +"particles\beam_exhaust_stream_blue_01.dds" , "64" , "128" , "dds" +"particles\beam_exhaust_stream_green_01.dds" , "64" , "128" , "dds" +"particles\beam_exhaust_stream_purple_01.dds" , "64" , "128" , "dds" +"particles\beam_exhaust_stream_red_01.dds" , "64" , "128" , "dds" +"particles\beam_green_01.dds" , "64" , "128" , "dds" +"particles\beam_green_02.dds" , "64" , "128" , "dds" +"particles\beam_missile_exhaust_01.dds" , "64" , "256" , "dds" +"particles\beam_missile_exhaust_02.dds" , "128" , "128" , "dds" +"particles\beam_missile_exhaust_03.dds" , "64" , "128" , "dds" +"particles\beam_missile_exhaust_04.dds" , "128" , "128" , "dds" +"particles\beam_phason_01.dds" , "128" , "128" , "dds" +"particles\beam_red_01.dds" , "64" , "128" , "dds" +"particles\beam_red_02.dds" , "32" , "128" , "dds" +"particles\beam_red_03.dds" , "32" , "64" , "dds" +"particles\beam_red_04.dds" , "64" , "512" , "dds" +"particles\beam_red_05.dds" , "64" , "512" , "dds" +"particles\beam_red_06.dds" , "128" , "1024" , "dds" +"particles\beam_red_07.dds" , "32" , "64" , "dds" +"particles\beam_shell_01.dds" , "64" , "64" , "dds" +"particles\beam_uef_hiro_01.dds" , "64" , "128" , "dds" +"particles\beam_uef_orbital_01.dds" , "64" , "128" , "dds" +"particles\beam_ultrachrom_01.dds" , "128" , "128" , "dds" +"particles\beam_white_01.dds" , "256" , "256" , "dds" +"particles\beam_white_02.dds" , "64" , "64" , "dds" +"particles\beam_white_03.dds" , "128" , "128" , "dds" +"particles\beam_white_04.dds" , "128" , "128" , "dds" +"particles\beam_white_05.dds" , "128" , "128" , "dds" +"particles\beam_white_06.dds" , "64" , "64" , "dds" +"particles\beam_white_07.dds" , "64" , "64" , "dds" +"particles\beam_yellow_01.dds" , "64" , "128" , "dds" +"particles\beam_zapper_01.dds" , "64" , "512" , "dds" +"particles\blast_cloud_01.dds" , "256" , "256" , "dds" +"particles\blobs_01.dds" , "128" , "128" , "dds" +"particles\blurry_rectangle_add_01.dds" , "64" , "64" , "dds" +"particles\bomb_blue_aeon_add_01.dds" , "128" , "128" , "dds" +"particles\bubble_01.dds" , "128" , "128" , "dds" +"particles\bubble_02.dds" , "128" , "128" , "dds" +"particles\bubble_03.dds" , "128" , "128" , "dds" +"particles\build_laser_add_01.dds" , "128" , "128" , "dds" +"particles\build_paint_spray_01.dds" , "256" , "64" , "dds" +"particles\build_shield_01.dds" , "128" , "128" , "dds" +"particles\circuitboard_01.dds" , "128" , "512" , "dds" +"particles\cirrus_01.dds" , "512" , "512" , "dds" +"particles\cirrus_02.dds" , "512" , "512" , "dds" +"particles\cirrus_03.dds" , "512" , "512" , "dds" +"particles\cirrus_04.dds" , "512" , "512" , "dds" +"particles\cirrus_strip.dds" , "512" , "1024" , "dds" +"particles\cloud_alpha_01.dds" , "256" , "256" , "dds" +"particles\cloud_puff_alpha_01.dds" , "64" , "64" , "dds" +"particles\cloud_smoke_alpha_01.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_02.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_03.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_04.dds" , "128" , "512" , "dds" +"particles\cloud_smoke_alpha_05.dds" , "128" , "512" , "dds" +"particles\cloud_smoke_alpha_08.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_09.dds" , "128" , "128" , "dds" +"particles\cloud_smoke_alpha_10.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_11.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_12_blur.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_12.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_13.dds" , "128" , "128" , "dds" +"particles\cloud_smoke_alpha_14.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_15.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_16.dds" , "256" , "1024" , "dds" +"particles\cloud_smoke_alpha_17.dds" , "256" , "1024" , "dds" +"particles\cloud_smoke_alpha_18.dds" , "256" , "256" , "dds" +"particles\cloud_smoke_alpha_19.dds" , "128" , "512" , "dds" +"particles\cloud_speck_alpha_01.dds" , "64" , "64" , "dds" +"particles\cloud_strip_01.dds" , "256" , "512" , "dds" +"particles\cloud_white_alpha_01.dds" , "256" , "256" , "dds" +"particles\corona_02.dds" , "256" , "256" , "dds" +"particles\corona_03.dds" , "256" , "256" , "dds" +"particles\corona.dds" , "256" , "256" , "dds" +"particles\crystal_debris01.dds" , "256" , "256" , "dds" +"particles\curvetrail_beam_particle_01.dds" , "64" , "64" , "dds" +"particles\dark_purple_01.dds" , "256" , "4" , "dds" +"particles\debris_alpha_01.dds" , "256" , "256" , "dds" +"particles\debris_alpha_02.dds" , "32" , "512" , "dds" +"particles\debris_alpha_03.dds" , "256" , "256" , "dds" +"particles\debris_alpha_04.dds" , "256" , "256" , "dds" +"particles\debris_scorch_alpha_01.dds" , "256" , "256" , "dds" +"particles\debris_scorch_alpha_02.dds" , "256" , "256" , "dds" +"particles\dirt_chunks_02.dds" , "128" , "128" , "dds" +"particles\dirt_chunks_03.dds" , "64" , "64" , "dds" +"particles\dirt_chunks_04.dds" , "128" , "128" , "dds" +"particles\dirt_chunks_05.dds" , "64" , "64" , "dds" +"particles\dirt_chunks_06.dds" , "128" , "128" , "dds" +"particles\dirt_chunks.dds" , "128" , "128" , "dds" +"particles\dirt_debris01.dds" , "256" , "256" , "dds" +"particles\dirt_spray_01.dds" , "256" , "256" , "dds" +"particles\dirt_spray_02.dds" , "256" , "256" , "dds" +"particles\dirt_spray_03.dds" , "256" , "256" , "dds" +"particles\disk_white_01.dds" , "256" , "256" , "dds" +"particles\disk_white_02.dds" , "256" , "256" , "dds" +"particles\disk_white_03.dds" , "256" , "256" , "dds" +"particles\distort_ring_02.dds" , "256" , "256" , "dds" +"particles\distorted_ring.dds" , "256" , "256" , "dds" +"particles\dust_cloud_01.dds" , "128" , "128" , "dds" +"particles\dust_cloud_02.dds" , "128" , "128" , "dds" +"particles\dust_cloud_03.dds" , "128" , "128" , "dds" +"particles\dust_cloud_04.dds" , "64" , "64" , "dds" +"particles\dust_cloud_05.dds" , "128" , "128" , "dds" +"particles\electric_split_01.dds" , "128" , "128" , "dds" +"particles\electricity_01.dds" , "64" , "512" , "dds" +"particles\electricity_02.dds" , "128" , "256" , "dds" +"particles\electricity_03.dds" , "128" , "128" , "dds" +"particles\electricity_04.dds" , "128" , "128" , "dds" +"particles\electricity_05.dds" , "128" , "128" , "dds" +"particles\electricity_06.dds" , "256" , "256" , "dds" +"particles\electricity_07.dds" , "64" , "64" , "dds" +"particles\electricity_08.dds" , "256" , "1024" , "dds" +"particles\electricity_09.dds" , "256" , "512" , "dds" +"particles\electricity_beam_01.dds" , "128" , "512" , "dds" +"particles\electricity_beam_02.dds" , "256" , "512" , "dds" +"particles\electricity_beam_03.dds" , "256" , "512" , "dds" +"particles\electricity_beam_04.dds" , "256" , "512" , "dds" +"particles\electricity_bolts_add_01.dds" , "128" , "512" , "dds" +"particles\electricity_bolts_add_02.dds" , "128" , "512" , "dds" +"particles\electricity_ring_02.dds" , "256" , "256" , "dds" +"particles\electricity_ring.dds" , "128" , "128" , "dds" +"particles\electricity_stream_01.dds" , "128" , "128" , "dds" +"particles\electron_bolter_flash_01.dds" , "128" , "256" , "dds" +"particles\electron_burst_cloud_mod_01.dds" , "256" , "256" , "dds" +"particles\energy_01.dds" , "256" , "256" , "dds" +"particles\explosion_add_01.dds" , "64" , "128" , "dds" +"particles\fake_shadow_01.dds" , "128" , "128" , "dds" +"particles\fake_shadow_napalm_add.dds" , "128" , "128" , "dds" +"particles\fake_shadow_napalm.dds" , "128" , "128" , "dds" +"particles\fire_cloud_add_01.dds" , "64" , "128" , "dds" +"particles\fire_cloud_add_02.dds" , "256" , "256" , "dds" +"particles\fire_cloud_alpha_01.dds" , "128" , "128" , "dds" +"particles\fire_cloud_alpha_02.dds" , "128" , "128" , "dds" +"particles\fire_cloud_alpha_03.dds" , "256" , "256" , "dds" +"particles\fire_cloud_premod_01.dds" , "256" , "256" , "dds" +"particles\fire_cloud_premod_02.dds" , "128" , "256" , "dds" +"particles\fire_cloud_premod_03.dds" , "128" , "128" , "dds" +"particles\fire_cloud_premod_04.dds" , "256" , "256" , "dds" +"particles\fire_cloud_premod_05.dds" , "256" , "256" , "dds" +"particles\fire_explode_01.dds" , "128" , "128" , "dds" +"particles\fire_flame_add_01.dds" , "128" , "128" , "dds" +"particles\fire_flame_add_02.dds" , "128" , "384" , "dds" +"particles\fire_flame_add_03.dds" , "256" , "256" , "dds" +"particles\fire_flame_add_04.dds" , "256" , "256" , "dds" +"particles\fire_flame_add_05.dds" , "256" , "1024" , "dds" +"particles\fire_flame_premod_add_01.dds" , "128" , "128" , "dds" +"particles\fire_multiply_01.dds" , "256" , "256" , "dds" +"particles\fireball.dds" , "1024" , "128" , "dds" +"particles\flare_01.dds" , "256" , "256" , "dds" +"particles\flare_lens_add_01.dds" , "64" , "64" , "dds" +"particles\flare_lens_add_02.dds" , "128" , "128" , "dds" +"particles\flare_lens_add_03.dds" , "128" , "128" , "dds" +"particles\frontal_glow_01.dds" , "64" , "64" , "dds" +"particles\gas_alpha_01.dds" , "256" , "256" , "dds" +"particles\gatling_plasma_munition_01.dds" , "512" , "64" , "dds" +"particles\gatling_plasma_munition_02.dds" , "512" , "64" , "dds" +"particles\gatling_plasma_munition_03.dds" , "512" , "64" , "dds" +"particles\geyser_01.dds" , "256" , "256" , "dds" +"particles\glow_01.dds" , "256" , "256" , "dds" +"particles\glow_02.dds" , "256" , "256" , "dds" +"particles\glow_03.dds" , "128" , "128" , "dds" +"particles\glow_04.dds" , "256" , "256" , "dds" +"particles\glow_05.dds" , "256" , "256" , "dds" +"particles\glow_alpha_01.dds" , "256" , "256" , "dds" +"particles\glow_alpha_03.dds" , "128" , "128" , "dds" +"particles\glow_offset.dds" , "256" , "256" , "dds" +"particles\glow_streak_red.dds" , "32" , "128" , "dds" +"particles\glow_streak.dds" , "128" , "128" , "dds" +"particles\glow.dds" , "128" , "128" , "dds" +"particles\half_moon_01.dds" , "256" , "256" , "dds" +"particles\half_moon_blue_01.dds" , "256" , "256" , "dds" +"particles\halfmoon_gauss_cannon_01.dds" , "256" , "256" , "dds" +"particles\halo.dds" , "256" , "256" , "dds" +"particles\heavy_plasma_shot.dds" , "16" , "256" , "dds" +"particles\heavy_quarnoncannon_muzzle_flash_01.dds" , "128" , "384" , "dds" +"particles\heavy_quarnoncannon_muzzle_flash_02.dds" , "128" , "128" , "dds" +"particles\heavy_quarnoncannon_trail_01.dds" , "256" , "256" , "dds" +"particles\heavy_quarnoncannon_trail_02.dds" , "256" , "256" , "dds" +"particles\heavy_quarnoncannon_trail_03.dds" , "256" , "256" , "dds" +"particles\ice_chunks_01.dds" , "128" , "128" , "dds" +"particles\infected_glow_01.dds" , "128" , "128" , "dds" +"particles\laser_lens_flare_02.dds" , "128" , "128" , "dds" +"particles\line_orange_add_01.dds" , "64" , "64" , "dds" +"particles\line_white_add_01.dds" , "64" , "64" , "dds" +"particles\line_white_add_02.dds" , "128" , "128" , "dds" +"particles\line_white_add_03.dds" , "16" , "16" , "dds" +"particles\line_white_add_04.dds" , "128" , "128" , "dds" +"particles\line_white_add_05.dds" , "256" , "256" , "dds" +"particles\line_white_add_06.dds" , "128" , "128" , "dds" +"particles\line_white_add_07.dds" , "128" , "128" , "dds" +"particles\line_white_add_08.dds" , "256" , "256" , "dds" +"particles\line_white_add_09.dds" , "128" , "128" , "dds" +"particles\line_white_add_10.dds" , "256" , "256" , "dds" +"particles\line_white_add_11.dds" , "128" , "512" , "dds" +"particles\line_white_add_12.dds" , "512" , "512" , "dds" +"particles\line_white_add_13.dds" , "128" , "128" , "dds" +"particles\line_white_add_14.dds" , "128" , "128" , "dds" +"particles\line_white_add_15.dds" , "256" , "256" , "dds" +"particles\line_white_alpha_04.dds" , "128" , "128" , "dds" +"particles\line_white_premod_01.dds" , "128" , "128" , "dds" +"particles\line_white_premod_02.dds" , "64" , "64" , "dds" +"particles\line_white_premod_03.dds" , "128" , "384" , "dds" +"particles\miasma_alpha_01.dds" , "256" , "256" , "dds" +"particles\miasma_mod_01.dds" , "256" , "256" , "dds" +"particles\miasma_mod_02.dds" , "256" , "256" , "dds" +"particles\missile_exhaust_fire_01.dds" , "64" , "128" , "dds" +"particles\molecular_01.dds" , "256" , "256" , "dds" +"particles\molecular_ripper_01.dds" , "64" , "64" , "dds" +"particles\moon_glow.dds" , "128" , "128" , "dds" +"particles\mudpot_bubble_anim_01.dds" , "512" , "64" , "dds" +"particles\muzzle_flash_add_01.dds" , "128" , "128" , "dds" +"particles\muzzle_flash_add_02.dds" , "128" , "128" , "dds" +"particles\muzzle_flash_add_03.dds" , "128" , "128" , "dds" +"particles\muzzle_flash_add_04.dds" , "128" , "128" , "dds" +"particles\muzzle_flash_add_05.dds" , "128" , "128" , "dds" +"particles\muzzle_flash_add_06.dds" , "256" , "256" , "dds" +"particles\muzzle_flash_add_07.dds" , "128" , "128" , "dds" +"particles\muzzle_flash_add_08.dds" , "128" , "128" , "dds" +"particles\muzzle_flash_add_09.dds" , "128" , "128" , "dds" +"particles\muzzle_flash_cool_01.dds" , "64" , "64" , "dds" +"particles\muzzle_flash_strip_add_01.dds" , "128" , "384" , "dds" +"particles\muzzle_flash_strip_add_02.dds" , "128" , "384" , "dds" +"particles\nano_dart_trail.dds" , "32" , "256" , "dds" +"particles\napalm_thick_smoke.dds" , "128" , "128" , "dds" +"particles\neutron_bomb_01.dds" , "128" , "128" , "dds" +"particles\neutron_bomb_02.dds" , "128" , "128" , "dds" +"particles\oilslick_01.dds" , "256" , "256" , "dds" +"particles\outrush01.dds" , "256" , "256" , "dds" +"particles\outrush02.dds" , "256" , "256" , "dds" +"particles\outrush03.dds" , "256" , "256" , "dds" +"particles\phalanx_munition_01.dds" , "512" , "64" , "dds" +"particles\plasma_02.dds" , "256" , "256" , "dds" +"particles\plasma_03.dds" , "256" , "256" , "dds" +"particles\plasma_alpha_01.dds" , "128" , "128" , "dds" +"particles\plasma_alpha_03.dds" , "128" , "128" , "dds" +"particles\plasma_alpha_04.dds" , "128" , "128" , "dds" +"particles\plasma_alpha_05.dds" , "128" , "128" , "dds" +"particles\plasma_alpha_06.dds" , "128" , "128" , "dds" +"particles\plasma_cannon_01.dds" , "32" , "256" , "dds" +"particles\plasma_green_add_01.dds" , "128" , "128" , "dds" +"particles\plasma_green_add_02.dds" , "128" , "128" , "dds" +"particles\plasma_green_add_03.dds" , "128" , "128" , "dds" +"particles\plasma_green_add_04.dds" , "128" , "128" , "dds" +"particles\plasma_green_add_05.dds" , "128" , "128" , "dds" +"particles\plasma_line_add_01.dds" , "324" , "324" , "dds" +"particles\plasma_red_add_01.dds" , "128" , "128" , "dds" +"particles\plasma_red_add_02.dds" , "128" , "128" , "dds" +"particles\plasma_red_add_03.dds" , "256" , "256" , "dds" +"particles\plasma_trail_01.dds" , "128" , "32" , "dds" +"particles\plasma_white_add_01.dds" , "128" , "128" , "dds" +"particles\plasma_white_premod_01.dds" , "128" , "128" , "dds" +"particles\plasma01.dds" , "256" , "256" , "dds" +"particles\proton_cannon_flash_01.dds" , "128" , "256" , "dds" +"particles\proton_red_add_01.dds" , "128" , "128" , "dds" +"particles\proton_yellow_add_01.dds" , "128" , "128" , "dds" +"particles\quantum_beam_01.dds" , "64" , "256" , "dds" +"particles\quantum_beamlines_01.dds" , "128" , "128" , "dds" +"particles\quantum_blue_add_04.dds" , "128" , "128" , "dds" +"particles\quantum_cannon_trail_01.dds" , "64" , "256" , "dds" +"particles\quantum_displacement_mod_01.dds" , "256" , "256" , "dds" +"particles\quantum_displacement_mod_02.dds" , "256" , "256" , "dds" +"particles\quantum_generator_add_01.dds" , "128" , "128" , "dds" +"particles\quantum_generator_add_02.dds" , "128" , "128" , "dds" +"particles\quantum_generator_add_03.dds" , "128" , "128" , "dds" +"particles\quantum_plasma_01.dds" , "128" , "128" , "dds" +"particles\quantum_plasma_ring01.dds" , "128" , "128" , "dds" +"particles\quantum_ring_01.dds" , "256" , "256" , "dds" +"particles\quantum_ring_02.dds" , "256" , "256" , "dds" +"particles\quantum_ring_03.dds" , "128" , "128" , "dds" +"particles\quantum_ring_04.dds" , "128" , "128" , "dds" +"particles\radial_rays_01.dds" , "128" , "128" , "dds" +"particles\radial_rays_bright_01.dds" , "128" , "128" , "dds" +"particles\railgun_flash_01.dds" , "128" , "256" , "dds" +"particles\railgun_trail_01.dds" , "32" , "32" , "dds" +"particles\rainfall_01.dds" , "512" , "512" , "dds" +"particles\ramp_aeon_01.dds" , "256" , "4" , "dds" +"particles\ramp_aeon_02.dds" , "256" , "4" , "dds" +"particles\ramp_antimatter_01.dds" , "256" , "4" , "dds" +"particles\ramp_antimatter_02.dds" , "256" , "4" , "dds" +"particles\ramp_antimatter_mod2x_01.dds" , "256" , "4" , "dds" +"particles\ramp_black_01.dds" , "256" , "4" , "dds" +"particles\ramp_black_02.dds" , "256" , "4" , "dds" +"particles\ramp_black_03.dds" , "256" , "4" , "dds" +"particles\ramp_black_04.dds" , "256" , "4" , "dds" +"particles\ramp_black_05.dds" , "256" , "4" , "dds" +"particles\ramp_black_grey_01.dds" , "256" , "4" , "dds" +"particles\ramp_blue_01.dds" , "256" , "4" , "dds" +"particles\ramp_blue_02.dds" , "256" , "4" , "dds" +"particles\ramp_blue_03.dds" , "128" , "4" , "dds" +"particles\ramp_blue_04.dds" , "256" , "4" , "dds" +"particles\ramp_blue_05.dds" , "256" , "4" , "dds" +"particles\ramp_blue_06.dds" , "256" , "4" , "dds" +"particles\ramp_blue_07.dds" , "256" , "4" , "dds" +"particles\ramp_blue_08.dds" , "256" , "4" , "dds" +"particles\ramp_blue_09.dds" , "256" , "4" , "dds" +"particles\ramp_blue_10.dds" , "256" , "64" , "dds" +"particles\ramp_blue_11.dds" , "256" , "4" , "dds" +"particles\ramp_blue_12.dds" , "256" , "4" , "dds" +"particles\ramp_blue_13.dds" , "256" , "4" , "dds" +"particles\ramp_blue_14.dds" , "256" , "4" , "dds" +"particles\ramp_blue_15.dds" , "256" , "4" , "dds" +"particles\ramp_blue_16.dds" , "256" , "4" , "dds" +"particles\ramp_blue_17.dds" , "256" , "4" , "dds" +"particles\ramp_blue_18.dds" , "512" , "32" , "dds" +"particles\ramp_blue_19.dds" , "256" , "4" , "dds" +"particles\ramp_blue_20.dds" , "256" , "16" , "dds" +"particles\ramp_blue_21.dds" , "256" , "12" , "dds" +"particles\ramp_blue_22.dds" , "256" , "4" , "dds" +"particles\ramp_blue_23.dds" , "256" , "4" , "dds" +"particles\ramp_blue_24.dds" , "256" , "4" , "dds" +"particles\ramp_blue_25.dds" , "256" , "4" , "dds" +"particles\ramp_blue_26.dds" , "256" , "4" , "dds" +"particles\ramp_blue_27.dds" , "256" , "4" , "dds" +"particles\ramp_blue_28.dds" , "256" , "4" , "dds" +"particles\ramp_blue_29.dds" , "256" , "4" , "dds" +"particles\ramp_blue_30.dds" , "256" , "4" , "dds" +"particles\ramp_blue_build_spray.dds" , "64" , "4" , "dds" +"particles\ramp_blue_green.dds" , "64" , "4" , "dds" +"particles\ramp_blue_orange_01.dds" , "256" , "8" , "dds" +"particles\ramp_blue_yellow_01.dds" , "256" , "4" , "dds" +"particles\ramp_blue_yellow_02.dds" , "256" , "4" , "dds" +"particles\ramp_brown_01.dds" , "256" , "4" , "dds" +"particles\ramp_brown_02.dds" , "256" , "4" , "dds" +"particles\ramp_brown_03.dds" , "256" , "4" , "dds" +"particles\ramp_brown_04.dds" , "256" , "4" , "dds" +"particles\ramp_brown_05.dds" , "256" , "4" , "dds" +"particles\ramp_brown_06.dds" , "256" , "4" , "dds" +"particles\ramp_brown_07.dds" , "256" , "4" , "dds" +"particles\ramp_brown_08.dds" , "256" , "4" , "dds" +"particles\ramp_brown_09.dds" , "256" , "4" , "dds" +"particles\ramp_brown_10.dds" , "256" , "4" , "dds" +"particles\ramp_brown_11.dds" , "256" , "4" , "dds" +"particles\ramp_brown_12.dds" , "256" , "4" , "dds" +"particles\ramp_brown_13.dds" , "256" , "4" , "dds" +"particles\ramp_chrono_dampener.dds" , "256" , "4" , "dds" +"particles\ramp_cloud_01.dds" , "128" , "4" , "dds" +"particles\ramp_cloud_02.dds" , "512" , "16" , "dds" +"particles\ramp_cloud_03.dds" , "512" , "16" , "dds" +"particles\ramp_color_build_spray.dds" , "128" , "4" , "dds" +"particles\ramp_contrail_01.dds" , "256" , "4" , "dds" +"particles\ramp_contrail_02.dds" , "256" , "4" , "dds" +"particles\ramp_cyan_build_spray.dds" , "64" , "4" , "dds" +"particles\ramp_cybran_missile_01.dds" , "128" , "16" , "dds" +"particles\ramp_disintegrator_01.dds" , "256" , "32" , "dds" +"particles\ramp_disintegrator_02.dds" , "256" , "16" , "dds" +"particles\ramp_electron_burst_cloud_01.dds" , "256" , "4" , "dds" +"particles\ramp_electron_polytrail_01.dds" , "128" , "16" , "dds" +"particles\ramp_energy_01.dds" , "64" , "4" , "dds" +"particles\ramp_fire_01.dds" , "256" , "4" , "dds" +"particles\ramp_fire_02.dds" , "256" , "4" , "dds" +"particles\ramp_fire_03.dds" , "256" , "4" , "dds" +"particles\ramp_fire_04.dds" , "256" , "4" , "dds" +"particles\ramp_fire_05.dds" , "256" , "4" , "dds" +"particles\ramp_fire_06.dds" , "256" , "4" , "dds" +"particles\ramp_fire_07.dds" , "256" , "4" , "dds" +"particles\ramp_fire_08.dds" , "256" , "4" , "dds" +"particles\ramp_fire_09.dds" , "256" , "4" , "dds" +"particles\ramp_fire_11.dds" , "256" , "4" , "dds" +"particles\ramp_fire_12.dds" , "256" , "16" , "dds" +"particles\ramp_fire_13.dds" , "256" , "4" , "dds" +"particles\ramp_fire_14.dds" , "256" , "16" , "dds" +"particles\ramp_fire_red_01.dds" , "256" , "4" , "dds" +"particles\ramp_fire_smoke_01.dds" , "256" , "4" , "dds" +"particles\ramp_fire_smoke_03.dds" , "256" , "4" , "dds" +"particles\ramp_fire_tall_01.dds" , "4" , "256" , "dds" +"particles\ramp_flare_01.dds" , "256" , "4" , "dds" +"particles\ramp_flare_02.dds" , "256" , "4" , "dds" +"particles\ramp_gate_01.dds" , "128" , "4" , "dds" +"particles\ramp_green_01.dds" , "16" , "4" , "dds" +"particles\ramp_green_02.dds" , "128" , "4" , "dds" +"particles\ramp_green_03.dds" , "128" , "4" , "dds" +"particles\ramp_green_05.dds" , "128" , "16" , "dds" +"particles\ramp_green_06.dds" , "256" , "16" , "dds" +"particles\ramp_green_07.dds" , "256" , "16" , "dds" +"particles\ramp_green_08.dds" , "128" , "4" , "dds" +"particles\ramp_green_09.dds" , "128" , "8" , "dds" +"particles\ramp_green_10.dds" , "128" , "4" , "dds" +"particles\ramp_green_11.dds" , "128" , "16" , "dds" +"particles\ramp_green_12.dds" , "128" , "8" , "dds" +"particles\ramp_green_build_spray.dds" , "64" , "4" , "dds" +"particles\ramp_grey_01.dds" , "256" , "4" , "dds" +"particles\ramp_grey_02.dds" , "256" , "4" , "dds" +"particles\ramp_grey_03.dds" , "256" , "4" , "dds" +"particles\ramp_jammer_01.dds" , "256" , "12" , "dds" +"particles\ramp_lightblue_build_spray.dds" , "64" , "4" , "dds" +"particles\ramp_lightning_01.dds" , "256" , "4" , "dds" +"particles\ramp_miasma_01.dds" , "256" , "4" , "dds" +"particles\ramp_napalm_fire.dds" , "256" , "4" , "dds" +"particles\ramp_napalm_thick_smoke.dds" , "256" , "4" , "dds" +"particles\ramp_nuke_01.dds" , "256" , "4" , "dds" +"particles\ramp_nuke_02.dds" , "256" , "4" , "dds" +"particles\ramp_nuke_03.dds" , "256" , "4" , "dds" +"particles\ramp_nuke_04.dds" , "256" , "4" , "dds" +"particles\ramp_orang_blue_01.dds" , "256" , "8" , "dds" +"particles\ramp_orang_blue_02.dds" , "256" , "8" , "dds" +"particles\ramp_orange_01.dds" , "16" , "4" , "dds" +"particles\ramp_orange_02.dds" , "512" , "4" , "dds" +"particles\ramp_orange_03.dds" , "16" , "4" , "dds" +"particles\ramp_overcharge_modinv.dds" , "256" , "4" , "dds" +"particles\ramp_peach_build_spray.dds" , "64" , "4" , "dds" +"particles\ramp_phalanx_munition_01.dds" , "32" , "256" , "dds" +"particles\ramp_phalanx_premod.dds" , "256" , "4" , "dds" +"particles\ramp_plasma_mod_01.dds" , "256" , "4" , "dds" +"particles\ramp_proton_artillery_01.dds" , "256" , "16" , "dds" +"particles\ramp_proton_artillery_02.dds" , "256" , "16" , "dds" +"particles\ramp_proton_flash_02.dds" , "256" , "4" , "dds" +"particles\ramp_proton_flash.dds" , "256" , "4" , "dds" +"particles\ramp_pulsar_01.dds" , "256" , "4" , "dds" +"particles\ramp_purple_01.dds" , "256" , "4" , "dds" +"particles\ramp_purple_02.dds" , "256" , "4" , "dds" +"particles\ramp_purple_03.dds" , "256" , "4" , "dds" +"particles\ramp_purple_04.dds" , "256" , "4" , "dds" +"particles\ramp_purple_05.dds" , "256" , "4" , "dds" +"particles\ramp_purple_06.dds" , "256" , "4" , "dds" +"particles\ramp_purple_07.dds" , "256" , "4" , "dds" +"particles\ramp_purple_08.dds" , "256" , "4" , "dds" +"particles\ramp_purple_11.dds" , "256" , "4" , "dds" +"particles\ramp_purple_build_spray.dds" , "64" , "4" , "dds" +"particles\ramp_purple_to_white_01.dds" , "256" , "4" , "dds" +"particles\ramp_quantum_01.dds" , "512" , "8" , "dds" +"particles\ramp_quantum_gate.dds" , "64" , "8" , "dds" +"particles\ramp_quantum_warhead_flash_01.dds" , "512" , "4" , "dds" +"particles\ramp_railgun_01.dds" , "256" , "4" , "dds" +"particles\ramp_reacton_01.dds" , "256" , "4" , "dds" +"particles\ramp_reacton_02.dds" , "256" , "4" , "dds" +"particles\ramp_reacton_03.dds" , "256" , "4" , "dds" +"particles\ramp_red_01.dds" , "16" , "4" , "dds" +"particles\ramp_red_02.dds" , "256" , "4" , "dds" +"particles\ramp_red_03.dds" , "256" , "4" , "dds" +"particles\ramp_red_04.dds" , "256" , "16" , "dds" +"particles\ramp_red_05.dds" , "128" , "16" , "dds" +"particles\ramp_red_06.dds" , "256" , "4" , "dds" +"particles\ramp_red_07.dds" , "16" , "4" , "dds" +"particles\ramp_red_08.dds" , "32" , "4" , "dds" +"particles\ramp_red_09.dds" , "256" , "4" , "dds" +"particles\ramp_red_10.dds" , "16" , "4" , "dds" +"particles\ramp_red_11.dds" , "256" , "4" , "dds" +"particles\ramp_red_12.dds" , "256" , "4" , "dds" +"particles\ramp_red_13.dds" , "256" , "4" , "dds" +"particles\ramp_red_14.dds" , "256" , "4" , "dds" +"particles\ramp_red_build_spray.dds" , "64" , "4" , "dds" +"particles\ramp_riotgun_01.dds" , "32" , "256" , "dds" +"particles\ramp_riotgun_02.dds" , "32" , "256" , "dds" +"particles\ramp_riotgun_03.dds" , "32" , "256" , "dds" +"particles\ramp_rust_01.dds" , "256" , "4" , "dds" +"particles\ramp_rust_02.dds" , "256" , "4" , "dds" +"particles\ramp_rust_03.dds" , "256" , "4" , "dds" +"particles\ramp_saint_01.dds" , "256" , "4" , "dds" +"particles\ramp_sand_01.dds" , "256" , "4" , "dds" +"particles\ramp_sand_02.dds" , "256" , "4" , "dds" +"particles\ramp_ser_01_reverse.dds" , "256" , "4" , "dds" +"particles\ramp_ser_01.dds" , "256" , "4" , "dds" +"particles\ramp_ser_02.dds" , "256" , "4" , "dds" +"particles\ramp_ser_03.dds" , "256" , "4" , "dds" +"particles\ramp_ser_04.dds" , "256" , "12" , "dds" +"particles\ramp_ser_05.dds" , "256" , "16" , "dds" +"particles\ramp_ser_06.dds" , "256" , "12" , "dds" +"particles\ramp_ser_07.dds" , "256" , "12" , "dds" +"particles\ramp_ser_08.dds" , "256" , "12" , "dds" +"particles\ramp_ser_09.dds" , "256" , "4" , "dds" +"particles\ramp_ser_10.dds" , "256" , "12" , "dds" +"particles\ramp_ser_11.dds" , "256" , "4" , "dds" +"particles\ramp_ser_12.dds" , "256" , "4" , "dds" +"particles\ramp_ser_13.dds" , "256" , "12" , "dds" +"particles\ramp_smoke_01.dds" , "256" , "4" , "dds" +"particles\ramp_smoke_02.dds" , "256" , "4" , "dds" +"particles\ramp_smoke_03.dds" , "256" , "4" , "dds" +"particles\ramp_smoke_04.dds" , "256" , "4" , "dds" +"particles\ramp_smoke_05.dds" , "256" , "4" , "dds" +"particles\ramp_smoke_exhaust_01.dds" , "256" , "4" , "dds" +"particles\ramp_sonic_pulse_01.dds" , "256" , "16" , "dds" +"particles\ramp_sonic_pulse_02.dds" , "128" , "4" , "dds" +"particles\ramp_tan_01.dds" , "256" , "4" , "dds" +"particles\ramp_tan_02.dds" , "256" , "4" , "dds" +"particles\ramp_tan_03.dds" , "256" , "4" , "dds" +"particles\ramp_tan_04.dds" , "256" , "4" , "dds" +"particles\ramp_test.dds" , "256" , "8" , "dds" +"particles\ramp_trail_01.dds" , "508" , "128" , "dds" +"particles\ramp_trail_02.dds" , "508" , "128" , "dds" +"particles\ramp_trail_03.dds" , "508" , "128" , "dds" +"particles\ramp_trail_04.dds" , "508" , "128" , "dds" +"particles\ramp_trail_05.dds" , "508" , "128" , "dds" +"particles\ramp_trail_06.dds" , "256" , "64" , "dds" +"particles\ramp_trail_07.dds" , "512" , "128" , "dds" +"particles\ramp_trail_08.dds" , "128" , "32" , "dds" +"particles\ramp_trail_09.dds" , "256" , "12" , "dds" +"particles\ramp_trail_10.dds" , "256" , "12" , "dds" +"particles\ramp_trail_11.dds" , "256" , "12" , "dds" +"particles\ramp_trail_12.dds" , "128" , "32" , "dds" +"particles\ramp_trail_13.dds" , "256" , "32" , "dds" +"particles\ramp_trail_14.dds" , "128" , "32" , "dds" +"particles\ramp_trail_15.dds" , "256" , "12" , "dds" +"particles\ramp_trail_16.dds" , "508" , "128" , "dds" +"particles\ramp_trail_17.dds" , "256" , "12" , "dds" +"particles\ramp_trail_18.dds" , "508" , "128" , "dds" +"particles\ramp_trail_aeon_01.dds" , "256" , "64" , "dds" +"particles\ramp_trail_purple_01.dds" , "512" , "128" , "dds" +"particles\ramp_trail_purple_02.dds" , "128" , "32" , "dds" +"particles\ramp_trail_red_01.dds" , "128" , "32" , "dds" +"particles\ramp_trail_ser_01.dds" , "256" , "64" , "dds" +"particles\ramp_trail_ser_02.dds" , "256" , "64" , "dds" +"particles\ramp_trail_ser_03.dds" , "256" , "64" , "dds" +"particles\ramp_water_01.dds" , "256" , "4" , "dds" +"particles\ramp_water_02.dds" , "64" , "4" , "dds" +"particles\ramp_water_03.dds" , "256" , "64" , "dds" +"particles\ramp_water_04.dds" , "256" , "4" , "dds" +"particles\ramp_water_05.dds" , "256" , "4" , "dds" +"particles\ramp_water_06.dds" , "256" , "4" , "dds" +"particles\ramp_water_07.dds" , "256" , "4" , "dds" +"particles\ramp_water_08.dds" , "256" , "4" , "dds" +"particles\ramp_white_01.dds" , "256" , "4" , "dds" +"particles\ramp_white_02.dds" , "256" , "4" , "dds" +"particles\ramp_white_03.dds" , "256" , "4" , "dds" +"particles\ramp_white_04.dds" , "256" , "4" , "dds" +"particles\ramp_white_05.dds" , "256" , "4" , "dds" +"particles\ramp_white_06.dds" , "256" , "4" , "dds" +"particles\ramp_white_07.dds" , "256" , "4" , "dds" +"particles\ramp_white_08.dds" , "256" , "4" , "dds" +"particles\ramp_white_09.dds" , "256" , "4" , "dds" +"particles\ramp_white_10.dds" , "512" , "4" , "dds" +"particles\ramp_white_11.dds" , "512" , "4" , "dds" +"particles\ramp_white_12.dds" , "512" , "64" , "dds" +"particles\ramp_white_13.dds" , "256" , "4" , "dds" +"particles\ramp_white_14.dds" , "256" , "4" , "dds" +"particles\ramp_white_15.dds" , "128" , "4" , "dds" +"particles\ramp_white_19.dds" , "512" , "4" , "dds" +"particles\ramp_white_20.dds" , "256" , "4" , "dds" +"particles\ramp_white_21.dds" , "256" , "16" , "dds" +"particles\ramp_white_22.dds" , "256" , "4" , "dds" +"particles\ramp_white_23.dds" , "256" , "4" , "dds" +"particles\ramp_white_24.dds" , "256" , "4" , "dds" +"particles\ramp_white_25.dds" , "256" , "4" , "dds" +"particles\ramp_white_26.dds" , "256" , "4" , "dds" +"particles\ramp_white_27.dds" , "256" , "4" , "dds" +"particles\ramp_white_28.dds" , "256" , "64" , "dds" +"particles\ramp_white_29.dds" , "256" , "64" , "dds" +"particles\ramp_white_build_spray.dds" , "64" , "4" , "dds" +"particles\ramp_white_mod_01.dds" , "256" , "4" , "dds" +"particles\ramp_white_orange_01.dds" , "256" , "8" , "dds" +"particles\ramp_white_orange_lllf.dds" , "256" , "8" , "dds" +"particles\ramp_white_premod_01.dds" , "256" , "4" , "dds" +"particles\ramp_white_premod_02.dds" , "256" , "4" , "dds" +"particles\ramp_white_premod_03_even.dds" , "256" , "4" , "dds" +"particles\ramp_white_premod_03.dds" , "256" , "4" , "dds" +"particles\ramp_white_to_purple_01.dds" , "256" , "4" , "dds" +"particles\ramp_yellow_01.dds" , "256" , "4" , "dds" +"particles\ramp_yellow_02.dds" , "128" , "16" , "dds" +"particles\ramp_yellow_03.dds" , "128" , "16" , "dds" +"particles\ramp_yellow_04.dds" , "256" , "4" , "dds" +"particles\ramp_yellow_05.dds" , "256" , "4" , "dds" +"particles\ramp_yellow_06.dds" , "256" , "4" , "dds" +"particles\ramp_yellow_blue_01.dds" , "256" , "4" , "dds" +"particles\ramp_yellow_build_spray.dds" , "64" , "4" , "dds" +"particles\ramp_yellow_purple_01.dds" , "256" , "4" , "dds" +"particles\ramp_yellow_purple_03.dds" , "256" , "4" , "dds" +"particles\ramp_yellow_to_purple_01.dds" , "256" , "4" , "dds" +"particles\reacton_cannon_trail_01.dds" , "64" , "256" , "dds" +"particles\reacton_hit_flare_01.dds" , "256" , "256" , "dds" +"particles\ring_01.dds" , "256" , "256" , "dds" +"particles\ring_02.dds" , "256" , "256" , "dds" +"particles\ring_03.dds" , "512" , "512" , "dds" +"particles\ring_04.dds" , "512" , "512" , "dds" +"particles\ring_05.dds" , "512" , "512" , "dds" +"particles\ring_06.dds" , "256" , "256" , "dds" +"particles\ring_07.dds" , "256" , "256" , "dds" +"particles\ring_08.dds" , "256" , "256" , "dds" +"particles\ring_09.dds" , "128" , "128" , "dds" +"particles\ring_10.dds" , "128" , "128" , "dds" +"particles\ring_11.dds" , "512" , "512" , "dds" +"particles\ring_12.dds" , "256" , "256" , "dds" +"particles\ring_13.dds" , "512" , "512" , "dds" +"particles\ring_14.dds" , "512" , "512" , "dds" +"particles\ring_15.dds" , "512" , "512" , "dds" +"particles\ring_16.dds" , "512" , "512" , "dds" +"particles\ring_distort_01.dds" , "256" , "256" , "dds" +"particles\ring_texture.dds" , "256" , "256" , "dds" +"particles\ring_white_01.dds" , "256" , "256" , "dds" +"particles\ring_white_02.dds" , "256" , "256" , "dds" +"particles\ring_white_03.dds" , "256" , "256" , "dds" +"particles\ring_white_04.dds" , "256" , "256" , "dds" +"particles\ring_white_05.dds" , "256" , "256" , "dds" +"particles\ring_white_06.dds" , "256" , "256" , "dds" +"particles\riotgun_beam_01.dds" , "512" , "64" , "dds" +"particles\riotgun_munition_01.dds" , "512" , "64" , "dds" +"particles\riotgun_munition_02.dds" , "512" , "64" , "dds" +"particles\riotgun_munition_03.dds" , "512" , "64" , "dds" +"particles\riotgun_munition_04.dds" , "64" , "64" , "dds" +"particles\riotgun_munition_05.dds" , "512" , "64" , "dds" +"particles\riotgun_munition_06.dds" , "512" , "64" , "dds" +"particles\riotgun_munition_07.dds" , "512" , "64" , "dds" +"particles\riotgun_munition_08.dds" , "128" , "64" , "dds" +"particles\riotgun_munition_09.dds" , "128" , "64" , "dds" +"particles\ser_aireau_munition_01.dds" , "1024" , "128" , "dds" +"particles\ser_aireau_munition_02.dds" , "1024" , "128" , "dds" +"particles\ser_aireau_munition_03.dds" , "1024" , "128" , "dds" +"particles\ser_contrail_01.dds" , "64" , "128" , "dds" +"particles\ser_experimental_beam_01.dds" , "512" , "512" , "dds" +"particles\ser_experimental_beam_02.dds" , "512" , "512" , "dds" +"particles\ser_plasma_trail01.dds" , "128" , "256" , "dds" +"particles\ser_plasma01.dds" , "256" , "256" , "dds" +"particles\ser_plasma02.dds" , "256" , "256" , "dds" +"particles\ser_plasma03.dds" , "256" , "256" , "dds" +"particles\ser_plasma04.dds" , "256" , "256" , "dds" +"particles\ser_plasma05.dds" , "256" , "256" , "dds" +"particles\ser_plasma06.dds" , "128" , "128" , "dds" +"particles\ser_plasma07.dds" , "256" , "256" , "dds" +"particles\ser_plasma08.dds" , "256" , "256" , "dds" +"particles\ser_plasma09.dds" , "256" , "256" , "dds" +"particles\ser_plasma10.dds" , "256" , "256" , "dds" +"particles\ser_plasma11.dds" , "256" , "1024" , "dds" +"particles\ser_plasma12.dds" , "512" , "512" , "dds" +"particles\ser_plasma13.dds" , "256" , "256" , "dds" +"particles\shell_01.dds" , "32" , "32" , "dds" +"particles\shrapnel_01.dds" , "256" , "256" , "dds" +"particles\smoke.dds" , "128" , "128" , "dds" +"particles\smokeramp.dds" , "256" , "16" , "dds" +"particles\sonic_gun.dds" , "128" , "128" , "dds" +"particles\sonic_pulsar.dds" , "128" , "128" , "dds" +"particles\sonic_pulse_01.dds" , "32" , "256" , "dds" +"particles\spark_anim_01.dds" , "256" , "64" , "dds" +"particles\spark_anim_02.dds" , "512" , "64" , "dds" +"particles\sparkle_02.dds" , "256" , "256" , "dds" +"particles\sparkle_03.dds" , "512" , "512" , "dds" +"particles\sparkle_04.dds" , "512" , "512" , "dds" +"particles\sparkle_05.dds" , "512" , "512" , "dds" +"particles\sparkle_06.dds" , "512" , "512" , "dds" +"particles\sparkle_07.dds" , "128" , "128" , "dds" +"particles\sparkle_08.dds" , "128" , "128" , "dds" +"particles\sparkle_09.dds" , "128" , "128" , "dds" +"particles\sparkle_10.dds" , "512" , "512" , "dds" +"particles\sparkle_11.dds" , "256" , "256" , "dds" +"particles\sparkle_blue_add_01.dds" , "128" , "128" , "dds" +"particles\sparkle_blue_add_02.dds" , "64" , "64" , "dds" +"particles\sparkle_blue_add_03.dds" , "128" , "128" , "dds" +"particles\sparkle_blue_add_05.dds" , "256" , "256" , "dds" +"particles\sparkle_gold_alpha_01.dds" , "128" , "128" , "dds" +"particles\sparkle_green_add_01.dds" , "128" , "128" , "dds" +"particles\sparkle_green_add_02.dds" , "128" , "128" , "dds" +"particles\sparkle_purple_add_05.dds" , "256" , "256" , "dds" +"particles\sparkle_red_add_01.dds" , "128" , "128" , "dds" +"particles\sparkle_red_add_02.dds" , "128" , "128" , "dds" +"particles\sparkle_red_add_03.dds" , "128" , "128" , "dds" +"particles\sparkle_red_add_04.dds" , "128" , "128" , "dds" +"particles\sparkle_white_add_01.dds" , "128" , "128" , "dds" +"particles\sparkle_white_add_02.dds" , "128" , "128" , "dds" +"particles\sparkle_white_add_03.dds" , "64" , "64" , "dds" +"particles\sparkle_white_add_04.dds" , "16" , "128" , "dds" +"particles\sparkle_white_add_05.dds" , "128" , "128" , "dds" +"particles\sparkle_white_add_06.dds" , "128" , "128" , "dds" +"particles\sparkle_white_add_07.dds" , "256" , "256" , "dds" +"particles\sparkle_white_add_08.dds" , "128" , "128" , "dds" +"particles\sparkle_white_premod_04.dds" , "16" , "128" , "dds" +"particles\sparkle_yellow_add_01.dds" , "256" , "256" , "dds" +"particles\sparkle_yellow_add_02.dds" , "256" , "256" , "dds" +"particles\sparks_01.dds" , "128" , "256" , "dds" +"particles\temporal_bubble_01.dds" , "256" , "256" , "dds" +"particles\terran_transport_beam_01.dds" , "64" , "128" , "dds" +"particles\terran_transport_beam_02.dds" , "64" , "128" , "dds" +"particles\terran_transport_beam_03.dds" , "64" , "128" , "dds" +"particles\test_marker_01.dds" , "128" , "128" , "dds" +"particles\test_neutron_bomb_01.dds" , "512" , "512" , "dds" +"particles\test_neutron_bomb_premod_01.dds" , "512" , "512" , "dds" +"particles\test_ramp_red_blue_01.dds" , "256" , "16" , "dds" +"particles\testramp.dds" , "256" , "16" , "dds" +"particles\trail_aeon_01.dds" , "128" , "256" , "dds" +"particles\trail_ser_01.dds" , "64" , "256" , "dds" +"particles\trail_ser_02.dds" , "64" , "256" , "dds" +"particles\trail_ser_03.dds" , "64" , "256" , "dds" +"particles\trail_ser_04.dds" , "64" , "256" , "dds" +"particles\trail_ser_05.dds" , "64" , "256" , "dds" +"particles\trail_ser_06.dds" , "64" , "256" , "dds" +"particles\trail_uef_01.dds" , "128" , "256" , "dds" +"particles\trail_white_01.dds" , "32" , "256" , "dds" +"particles\trail_white_02_blankmips.dds" , "32" , "32" , "dds" +"particles\trail_white_02.dds" , "32" , "32" , "dds" +"particles\trail_white_03.dds" , "32" , "256" , "dds" +"particles\trail_white_04.dds" , "32" , "32" , "dds" +"particles\trail_white_05.dds" , "32" , "128" , "dds" +"particles\transport_beam_03.dds" , "32" , "256" , "dds" +"particles\UEF_adjacency_beam_01.dds" , "64" , "64" , "dds" +"particles\uef_plasma_trail_01.dds" , "64" , "256" , "dds" +"particles\under_water_bubble_01.dds" , "128" , "128" , "dds" +"particles\vegetation_kickup_01.dds" , "128" , "128" , "dds" +"particles\wake_back02.dds" , "128" , "128" , "dds" +"particles\water_bubbles.dds" , "32" , "32" , "dds" +"particles\water_churn_alpha_01.dds" , "128" , "128" , "dds" +"particles\water_fire_premod_01.dds" , "128" , "128" , "dds" +"particles\water_plume_alpha_01.dds" , "1024" , "256" , "dds" +"particles\water_plume_alpha_02.dds" , "256" , "256" , "dds" +"particles\water_ripple_alpha_01.dds" , "128" , "128" , "dds" +"particles\water_ripple_alpha_02.dds" , "128" , "128" , "dds" +"particles\water_ripple_alpha_03.dds" , "128" , "128" , "dds" +"particles\water_ripple_alpha_04.dds" , "128" , "128" , "dds" +"particles\water_ripple_alpha_05.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_01.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_02.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_03.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_04.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_05.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_06.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_07.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_08.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_09.dds" , "256" , "256" , "dds" +"particles\water_splash_alpha_10.dds" , "128" , "128" , "dds" +"particles\water_splash_alpha_11.dds" , "256" , "256" , "dds" +"particles\water_splash_alpha_12.dds" , "128" , "128" , "dds" +"particles\wedge_01.dds" , "128" , "128" , "dds" +"particles\white_blast_01.dds" , "256" , "256" , "dds" +"particles\white_blast_02.dds" , "512" , "512" , "dds" +"particles\white_rays_01.dds" , "256" , "256" , "dds" +"quadtest.bmp" , "256" , "256" , "bmp" +"scrollArrows.dds" , "64" , "64" , "dds" +"selection_bracket_enemy_md.dds" , "64" , "64" , "dds" +"selection_bracket_enemy_sm.dds" , "64" , "64" , "dds" +"selection_bracket_player_md.dds" , "64" , "64" , "dds" +"selection_bracket_player_sm.dds" , "64" , "64" , "dds" +"shapesCircle.dds" , "32" , "32" , "dds" +"shapesCircle.png" , "128" , "128" , "png" +"shapesDownTriangle.png" , "128" , "128" , "png" +"shapesSquare.png" , "128" , "128" , "png" +"shapesTriangle.png" , "128" , "128" , "png" +"Snow.dds" , "512" , "512" , "dds" +"strategicPath.dds" , "1024" , "1024" , "dds" +"stratNotify.png" , "64" , "59" , "png" +"test1.png" , "128" , "128" , "png" +"tilesets\_global\n_bloop.dds" , "512" , "512" , "dds" +"tilesets\_global\n_button.dds" , "512" , "512" , "dds" +"tilesets\_global\n_cliff.dds" , "512" , "512" , "dds" +"tilesets\_global\n_crater.dds" , "512" , "512" , "dds" +"tilesets\_global\n_erosion.dds" , "512" , "512" , "dds" +"tilesets\_global\n_erosion001.dds" , "1024" , "1024" , "dds" +"tilesets\_global\n_erosion002.dds" , "1024" , "1024" , "dds" +"tilesets\_global\n_erosion003.dds" , "512" , "512" , "dds" +"tilesets\_global\n_erosion004.dds" , "512" , "512" , "dds" +"tilesets\_global\n_erosion005.dds" , "1024" , "1024" , "dds" +"tilesets\_global\n_erosion006.dds" , "512" , "512" , "dds" +"tilesets\_global\n_erosion007.dds" , "1024" , "1024" , "dds" +"tilesets\_global\n_erosion008.dds" , "512" , "512" , "dds" +"tilesets\_global\n_flat.dds" , "256" , "256" , "dds" +"tilesets\_global\n_fractal.dds" , "1024" , "1024" , "dds" +"tilesets\_global\n_noise.dds" , "1024" , "1024" , "dds" +"tilesets\_global\n_spectest.dds" , "256" , "256" , "dds" +"tilesets\_global\n_thetaxi.dds" , "512" , "512" , "dds" +"tilesets\_global\scorch_mark.dds" , "1024" , "1024" , "dds" +"tilesets\_global\tank_treads.dds" , "256" , "256" , "dds" +"tilesets\_global\up.dds" , "128" , "128" , "dds" +"tilesets\dunes\base\black_n.dds" , "64" , "64" , "dds" +"tilesets\dunes\base\black.dds" , "64" , "64" , "dds" +"tilesets\dunes\base\grass000_n.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\grass000.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\grass001_N.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\grass001.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\Ice001_N.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\Ice001.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\Ice002_n.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\Ice002.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\macrotexture000.dds" , "256" , "256" , "dds" +"tilesets\dunes\base\macrotexture001.dds" , "256" , "256" , "dds" +"tilesets\dunes\base\RockLight_n.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\RockLight.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\RockMed_N.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\RockMed.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\SandDark_N.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\SandDark.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\SandLight_N.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\SandLight.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\SandMed_N.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\SandMed.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\snow001_N.dds" , "512" , "512" , "dds" +"tilesets\dunes\base\snow001.dds" , "512" , "512" , "dds" +"tilesets\dunes\fill\_atlas_n.dds" , "2048" , "2048" , "dds" +"tilesets\dunes\fill\_atlas.dds" , "2048" , "2048" , "dds" +"tilesets\dunes\fill\D_RDL001_N.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_RDL001.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_RLL001_N.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_RLL001.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_SDL001_N.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_SDL001.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_SDM001_N.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\D_SDM001.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\D_SDS001_N.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\D_SDS001.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\D_SIL001_N.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_SIL001.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_SIL002.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_SIM001_N.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\D_SIM001.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\D_SIM002_N.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\D_SIM002.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\D_SIS001_N.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\D_SIS001.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\D_SLL001_N.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_SLL001.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\D_SLM001_N.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\D_SLM001.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\D_SLS001_N.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\D_SLS001.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\P_BM001_N.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\P_BM001.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\P_BM002_N.dds" , "1024" , "1024" , "dds" +"tilesets\dunes\fill\P_BM002.dds" , "1024" , "1024" , "dds" +"tilesets\dunes\fill\P_BM003_N.dds" , "512" , "512" , "dds" +"tilesets\dunes\fill\P_BM003.dds" , "512" , "512" , "dds" +"tilesets\dunes\fill\P_BM004_N.dds" , "512" , "512" , "dds" +"tilesets\dunes\fill\P_BM004.dds" , "512" , "512" , "dds" +"tilesets\dunes\fill\P_BM005_N.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\P_BM005.dds" , "64" , "64" , "dds" +"tilesets\dunes\fill\P_BM006_N.dds" , "32" , "32" , "dds" +"tilesets\dunes\fill\P_BM006.dds" , "32" , "32" , "dds" +"tilesets\dunes\fill\P_BS001_N.dds" , "16" , "16" , "dds" +"tilesets\dunes\fill\P_BS001.dds" , "16" , "16" , "dds" +"tilesets\dunes\fill\P_DDL001_N.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\P_DDL001.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\P_DDM001_N.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\P_DDM001.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\P_GDL001_N.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\P_GDL001.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\P_GDL002_N.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\P_GDL002.dds" , "256" , "256" , "dds" +"tilesets\dunes\fill\P_GDM001_N.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\P_GDM001.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\P_GDM002_N.dds" , "128" , "128" , "dds" +"tilesets\dunes\fill\P_GDM002.dds" , "128" , "128" , "dds" +"tilesets\Evergreen\base\Dirt001_n.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\Dirt001.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\grass000_n.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\grass000.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\grass001_N.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\grass001.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\macrotexture000.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\base\macrotexture001.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\base\RockLight_n.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\RockLight.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\RockMed_N.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\RockMed.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\SandLight_N.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\SandLight.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\SandLight002_n.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\base\SandLight002.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\base\SandRock_n.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\base\SandRock.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\base\Sandwet_n.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\Sandwet.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\snow001_N.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\base\snow001.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\edge\nice000.dds" , "512" , "256" , "dds" +"tilesets\Evergreen\fill\_atlas_n.dds" , "2048" , "2048" , "dds" +"tilesets\Evergreen\fill\_atlas.dds" , "2048" , "2048" , "dds" +"tilesets\Evergreen\fill\E_Bush001.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_Bush002.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_Bush003.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_Bush004.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_Bush005.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_Bush006.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_DL001_N.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_DL001.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_MUD001.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\fill\E_MUD002.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\fill\E_MUD003.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\fill\E_Tarmac_Road.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\fill\E_Tarmac04.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_TATR001.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_TATR002.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_TATR003.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_TATR004.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_TATR005.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_TATR006.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\E_TATR007.dds" , "1024" , "1024" , "dds" +"tilesets\Evergreen\fill\E_TATR008.dds" , "1024" , "1024" , "dds" +"tilesets\Evergreen\fill\E_TATR009.dds" , "1024" , "1024" , "dds" +"tilesets\Evergreen\fill\E_TDR001.dds" , "1024" , "1024" , "dds" +"tilesets\Evergreen\fill\E_TL001.dds" , "2048" , "2048" , "dds" +"tilesets\Evergreen\fill\E_TM001.dds" , "1024" , "1024" , "dds" +"tilesets\Evergreen\fill\E_TRW001.dds" , "1024" , "1024" , "dds" +"tilesets\Evergreen\fill\Evergreen_Runway01.dds" , "2048" , "2048" , "dds" +"tilesets\Evergreen\fill\Evergreen_Tarmac_Round01.dds" , "2048" , "2048" , "dds" +"tilesets\Evergreen\fill\Evergreen_Tarmac_ThreeSquares.dds" , "1024" , "1024" , "dds" +"tilesets\Evergreen\fill\Evergreen_Tarmac.dds" , "2048" , "2048" , "dds" +"tilesets\Evergreen\fill\Evergreen_Tarmac02.dds" , "2048" , "2048" , "dds" +"tilesets\Evergreen\fill\P_BM002_N.dds" , "1024" , "1024" , "dds" +"tilesets\Evergreen\fill\P_BM002.dds" , "1024" , "1024" , "dds" +"tilesets\Evergreen\fill\P_BM003_N.dds" , "128" , "128" , "dds" +"tilesets\Evergreen\fill\P_BM003.dds" , "128" , "128" , "dds" +"tilesets\Evergreen\fill\P_BM004_N.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\fill\P_BM004.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\fill\P_BM005_N.dds" , "64" , "64" , "dds" +"tilesets\Evergreen\fill\P_BM005.dds" , "64" , "64" , "dds" +"tilesets\Evergreen\fill\P_BM006_N.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\P_BM006.dds" , "512" , "512" , "dds" +"tilesets\Evergreen\fill\P_BS001_N.dds" , "16" , "16" , "dds" +"tilesets\Evergreen\fill\P_BS001.dds" , "16" , "16" , "dds" +"tilesets\Evergreen\WaterNormal.dds" , "256" , "256" , "dds" +"tilesets\Evergreen\WaterTint.dds" , "256" , "256" , "dds" +"tilesets\ice\base\Ice001_N.dds" , "512" , "512" , "dds" +"tilesets\ice\base\Ice001.dds" , "512" , "512" , "dds" +"tilesets\ice\base\Ice002_n.dds" , "512" , "512" , "dds" +"tilesets\ice\base\Ice002.dds" , "512" , "512" , "dds" +"tilesets\ice\base\macrotexture000.dds" , "256" , "256" , "dds" +"tilesets\ice\base\macrotexture001.dds" , "256" , "256" , "dds" +"tilesets\ice\base\snow001_N.dds" , "512" , "512" , "dds" +"tilesets\ice\base\snow001.dds" , "512" , "512" , "dds" +"tilesets\ice\fill\_atlas_n.dds" , "2048" , "2048" , "dds" +"tilesets\ice\fill\_atlas.dds" , "2048" , "2048" , "dds" +"tilesets\paradise\base\black_n.dds" , "64" , "64" , "dds" +"tilesets\paradise\base\black.dds" , "64" , "64" , "dds" +"tilesets\paradise\base\devtest_n.dds" , "128" , "128" , "dds" +"tilesets\paradise\base\devtest.dds" , "128" , "128" , "dds" +"tilesets\paradise\base\dirt000_n.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\dirt000.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\grass000_n.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\grass000.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\grass001_n.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\grass001.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\Ice001_N.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\Ice001.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\Ice002_n.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\Ice002.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\macrotexture000.dds" , "256" , "256" , "dds" +"tilesets\paradise\base\macrotexture001.dds" , "256" , "256" , "dds" +"tilesets\paradise\base\Rock001_n.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\Rock001.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\sand000_n.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\sand000.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\SandMed_n.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\SandMed.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\snow001_N.dds" , "512" , "512" , "dds" +"tilesets\paradise\base\snow001.dds" , "512" , "512" , "dds" +"tilesets\paradise\fill\_atlas_n.dds" , "2048" , "2048" , "dds" +"tilesets\paradise\fill\_atlas.dds" , "2048" , "2048" , "dds" +"tilesets\paradise\fill\P_BM001_N.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_BM001.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_BM002_N.dds" , "1024" , "1024" , "dds" +"tilesets\paradise\fill\P_BM002.dds" , "1024" , "1024" , "dds" +"tilesets\paradise\fill\P_BM003_N.dds" , "512" , "512" , "dds" +"tilesets\paradise\fill\P_BM003.dds" , "512" , "512" , "dds" +"tilesets\paradise\fill\P_BM004_N.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_BM004.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_BM005_N.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_BM005.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_BM006_N.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_BM006.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_BS001_N.dds" , "16" , "16" , "dds" +"tilesets\paradise\fill\P_BS001.dds" , "16" , "16" , "dds" +"tilesets\paradise\fill\P_DDL001_N.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_DDL001.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_DDM001_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_DDM001.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_DDS001_N.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_DDS001.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_DLL001_N.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_DLL001.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_DLM001_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_DLM001.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_DLS001_N.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_DLS001.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_GDL001_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GDL001.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GDL002_N.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_GDL002.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_GDM001_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GDM001.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GDM002_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GDM002.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GDS001_N.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_GDS001.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_GDS002_N.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_GDS002.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_GLL001_N.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_GLL001.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_GLL002_N.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_GLL002.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_GLM001_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GLM001.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GLM002_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GLM002.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GLM003_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GLM003.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_GLS001_N.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_GLS001.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_GLS002_N.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_GLS002.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_RLL001_N.dds" , "512" , "512" , "dds" +"tilesets\paradise\fill\P_RLL001.dds" , "1024" , "1024" , "dds" +"tilesets\paradise\fill\P_RLL002.dds" , "512" , "512" , "dds" +"tilesets\paradise\fill\P_SIL001_N.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_SIL001.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_SIM001_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_SIM001.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_SIS001_N.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_SIS001.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_SLL001_N.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_SLL001.dds" , "256" , "256" , "dds" +"tilesets\paradise\fill\P_SLM001_N.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_SLM001.dds" , "128" , "128" , "dds" +"tilesets\paradise\fill\P_SLS001_N.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_SLS001.dds" , "32" , "32" , "dds" +"tilesets\paradise\fill\P_SLS002_N.dds" , "64" , "64" , "dds" +"tilesets\paradise\fill\P_SLS002.dds" , "64" , "64" , "dds" +"ui\aeon\dialogs\menu-btns\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\close_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\close_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\close_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\config_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\config_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\config_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\config_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\default_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\default_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\default_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\default_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\pin_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\pin_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\pin_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\pin_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\menu-btns\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\dialogs\time-units-tabs\energy_bmp.dds" , "16" , "28" , "dds" +"ui\aeon\dialogs\time-units-tabs\panel-tracking_bmp_d.dds" , "12" , "44" , "dds" +"ui\aeon\dialogs\time-units-tabs\panel-tracking_bmp_l.dds" , "24" , "44" , "dds" +"ui\aeon\dialogs\time-units-tabs\panel-tracking_bmp_m.dds" , "4" , "44" , "dds" +"ui\aeon\dialogs\time-units-tabs\panel-tracking_bmp_r.dds" , "24" , "44" , "dds" +"ui\aeon\dialogs\time-units-tabs\tracking-icon_bmp.dds" , "24" , "20" , "dds" +"ui\aeon\game\avatar-arrow_btn\tab-close_btn_dis.dds" , "12" , "32" , "dds" +"ui\aeon\game\avatar-arrow_btn\tab-close_btn_down.dds" , "12" , "32" , "dds" +"ui\aeon\game\avatar-arrow_btn\tab-close_btn_over.dds" , "12" , "32" , "dds" +"ui\aeon\game\avatar-arrow_btn\tab-close_btn_up.dds" , "12" , "32" , "dds" +"ui\aeon\game\avatar-arrow_btn\tab-open_btn_dis.dds" , "12" , "32" , "dds" +"ui\aeon\game\avatar-arrow_btn\tab-open_btn_down.dds" , "12" , "32" , "dds" +"ui\aeon\game\avatar-arrow_btn\tab-open_btn_over.dds" , "12" , "32" , "dds" +"ui\aeon\game\avatar-arrow_btn\tab-open_btn_up.dds" , "12" , "32" , "dds" +"ui\aeon\game\avatar-engineers-panel\bracket_bmp.dds" , "28" , "52" , "dds" +"ui\aeon\game\avatar-engineers-panel\panel-eng_bmp_b.dds" , "92" , "12" , "dds" +"ui\aeon\game\avatar-engineers-panel\panel-eng_bmp_m.dds" , "92" , "44" , "dds" +"ui\aeon\game\avatar-engineers-panel\panel-eng_bmp_t.dds" , "92" , "12" , "dds" +"ui\aeon\game\avatar-engineers-panel\tech-1_bmp.dds" , "28" , "28" , "dds" +"ui\aeon\game\avatar-engineers-panel\tech-2_bmp.dds" , "28" , "28" , "dds" +"ui\aeon\game\avatar-engineers-panel\tech-3_bmp.dds" , "28" , "28" , "dds" +"ui\aeon\game\avatar-factory-panel\avatar-s-e-f_bmp.dds" , "48" , "52" , "dds" +"ui\aeon\game\avatar-factory-panel\bracket_bmp.dds" , "24" , "48" , "dds" +"ui\aeon\game\avatar-factory-panel\factory-panel_bmp.dds" , "188" , "164" , "dds" +"ui\aeon\game\avatar-factory-panel\factory-panel_brd_horz_um.dds" , "44" , "20" , "dds" +"ui\aeon\game\avatar-factory-panel\factory-panel_brd_ll.dds" , "40" , "56" , "dds" +"ui\aeon\game\avatar-factory-panel\factory-panel_brd_lm.dds" , "44" , "56" , "dds" +"ui\aeon\game\avatar-factory-panel\factory-panel_brd_lr.dds" , "64" , "56" , "dds" +"ui\aeon\game\avatar-factory-panel\factory-panel_brd_ul.dds" , "40" , "20" , "dds" +"ui\aeon\game\avatar-factory-panel\factory-panel_brd_ur.dds" , "64" , "20" , "dds" +"ui\aeon\game\avatar-factory-panel\factory-panel_brd_vert_l.dds" , "40" , "44" , "dds" +"ui\aeon\game\avatar-factory-panel\factory-panel_brd_vert_r.dds" , "64" , "44" , "dds" +"ui\aeon\game\avatar-factory-panel\strat-icon-air_bmp.dds" , "8" , "8" , "dds" +"ui\aeon\game\avatar-factory-panel\strat-icon-land_bmp.dds" , "8" , "8" , "dds" +"ui\aeon\game\avatar-factory-panel\strat-icon-sea_bmp.dds" , "8" , "8" , "dds" +"ui\aeon\game\avatar-factory-panel\tech-1_bmp.dds" , "20" , "24" , "dds" +"ui\aeon\game\avatar-factory-panel\tech-2_bmp.dds" , "20" , "24" , "dds" +"ui\aeon\game\avatar-factory-panel\tech-3_bmp.dds" , "20" , "20" , "dds" +"ui\aeon\game\avatar\avatar_bmp.dds" , "64" , "68" , "dds" +"ui\aeon\game\avatar\avatar-control-group_bmp.dds" , "48" , "36" , "dds" +"ui\aeon\game\avatar\avatar-s-e-f_bmp.dds" , "56" , "52" , "dds" +"ui\aeon\game\avatar\health-bar-back_bmp.dds" , "52" , "16" , "dds" +"ui\aeon\game\avatar\health-bar.dds" , "44" , "12" , "dds" +"ui\aeon\game\avatar\pulse-bars_bmp.dds" , "64" , "64" , "dds" +"ui\aeon\game\bracket-left-energy\bracket_bmp_b.dds" , "20" , "16" , "dds" +"ui\aeon\game\bracket-left-energy\bracket_bmp_m.dds" , "12" , "4" , "dds" +"ui\aeon\game\bracket-left-energy\bracket_bmp_t.dds" , "20" , "20" , "dds" +"ui\aeon\game\bracket-left-energy\bracket_bmp.dds" , "20" , "68" , "dds" +"ui\aeon\game\bracket-left\bracket_bmp_b.dds" , "32" , "28" , "dds" +"ui\aeon\game\bracket-left\bracket_bmp_m.dds" , "16" , "4" , "dds" +"ui\aeon\game\bracket-left\bracket_bmp_t.dds" , "32" , "68" , "dds" +"ui\aeon\game\bracket-left\bracket_bmp.dds" , "32" , "116" , "dds" +"ui\aeon\game\bracket-min-small\bracket-sm-left_bmp.dds" , "24" , "72" , "dds" +"ui\aeon\game\bracket-min-small\bracket-sm-right_bmp.dds" , "24" , "68" , "dds" +"ui\aeon\game\bracket-right-energy\bracket_bmp_b.dds" , "20" , "16" , "dds" +"ui\aeon\game\bracket-right-energy\bracket_bmp_m.dds" , "12" , "4" , "dds" +"ui\aeon\game\bracket-right-energy\bracket_bmp_t.dds" , "20" , "20" , "dds" +"ui\aeon\game\bracket-right-energy\bracket_bmp.dds" , "20" , "68" , "dds" +"ui\aeon\game\bracket-right\bracket_bmp_b.dds" , "32" , "28" , "dds" +"ui\aeon\game\bracket-right\bracket_bmp_m.dds" , "16" , "4" , "dds" +"ui\aeon\game\bracket-right\bracket_bmp_t.dds" , "32" , "68" , "dds" +"ui\aeon\game\bracket-right\bracket_bmp.dds" , "32" , "116" , "dds" +"ui\aeon\game\c-q-e-panel\arrow_bmp.dds" , "28" , "16" , "dds" +"ui\aeon\game\c-q-e-panel\arrow_vert_bmp.dds" , "16" , "24" , "dds" +"ui\aeon\game\c-q-e-panel\construct-panel_bmp_l.dds" , "84" , "60" , "dds" +"ui\aeon\game\c-q-e-panel\divider_bmp.dds" , "12" , "56" , "dds" +"ui\aeon\game\c-q-e-panel\divider_horizontal_bmp.dds" , "64" , "12" , "dds" +"ui\aeon\game\camera-btn\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\game\camera-btn\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\game\camera-btn\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\game\camera-btn\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\game\chat_brd\chat_brd_horz_um.dds" , "8" , "48" , "dds" +"ui\aeon\game\chat_brd\chat_brd_ll.dds" , "44" , "48" , "dds" +"ui\aeon\game\chat_brd\chat_brd_lm.dds" , "8" , "48" , "dds" +"ui\aeon\game\chat_brd\chat_brd_lr.dds" , "44" , "48" , "dds" +"ui\aeon\game\chat_brd\chat_brd_m.dds" , "8" , "8" , "dds" +"ui\aeon\game\chat_brd\chat_brd_ul.dds" , "44" , "48" , "dds" +"ui\aeon\game\chat_brd\chat_brd_ur.dds" , "44" , "48" , "dds" +"ui\aeon\game\chat_brd\chat_brd_vert_l.dds" , "44" , "8" , "dds" +"ui\aeon\game\chat_brd\chat_brd_vert_r.dds" , "44" , "8" , "dds" +"ui\aeon\game\chat_brd\drop-box_brd_horz_um.dds" , "8" , "16" , "dds" +"ui\aeon\game\chat_brd\drop-box_brd_ll.dds" , "16" , "12" , "dds" +"ui\aeon\game\chat_brd\drop-box_brd_lm.dds" , "8" , "12" , "dds" +"ui\aeon\game\chat_brd\drop-box_brd_lr.dds" , "16" , "12" , "dds" +"ui\aeon\game\chat_brd\drop-box_brd_m.dds" , "8" , "8" , "dds" +"ui\aeon\game\chat_brd\drop-box_brd_ul.dds" , "16" , "16" , "dds" +"ui\aeon\game\chat_brd\drop-box_brd_ur.dds" , "16" , "16" , "dds" +"ui\aeon\game\chat_brd\drop-box_brd_vert_l.dds" , "16" , "8" , "dds" +"ui\aeon\game\chat_brd\drop-box_brd_vert_r.dds" , "16" , "8" , "dds" +"ui\aeon\game\chat-box_btn\radio_btn_dis.dds" , "28" , "28" , "dds" +"ui\aeon\game\chat-box_btn\radio_btn_down.dds" , "28" , "28" , "dds" +"ui\aeon\game\chat-box_btn\radio_btn_over.dds" , "28" , "28" , "dds" +"ui\aeon\game\chat-box_btn\radio_btn_up.dds" , "28" , "28" , "dds" +"ui\aeon\game\construct-panel_vert\construct-panel_bmp_b.dds" , "172" , "12" , "dds" +"ui\aeon\game\construct-panel_vert\construct-panel_bmp_m.dds" , "172" , "8" , "dds" +"ui\aeon\game\construct-panel_vert\construct-panel_bmp_t.dds" , "172" , "64" , "dds" +"ui\aeon\game\construct-panel_vert\que-panel_bmp_b.dds" , "60" , "8" , "dds" +"ui\aeon\game\construct-panel_vert\que-panel_bmp_m.dds" , "60" , "8" , "dds" +"ui\aeon\game\construct-panel_vert\que-panel_bmp_t.dds" , "60" , "8" , "dds" +"ui\aeon\game\construct-panel\construct-panel_bmp_l.dds" , "84" , "140" , "dds" +"ui\aeon\game\construct-panel\construct-panel_bmp_m1.dds" , "8" , "140" , "dds" +"ui\aeon\game\construct-panel\construct-panel_bmp_m2.dds" , "16" , "140" , "dds" +"ui\aeon\game\construct-panel\construct-panel_bmp_m3.dds" , "8" , "112" , "dds" +"ui\aeon\game\construct-panel\construct-panel_bmp_r.dds" , "16" , "112" , "dds" +"ui\aeon\game\construct-panel\construct-panel_s_bmp_l.dds" , "16" , "112" , "dds" +"ui\aeon\game\construct-panel\construct-panel_s_bmp_m.dds" , "8" , "112" , "dds" +"ui\aeon\game\construct-panel\construct-panel_s_bmp_r.dds" , "16" , "112" , "dds" +"ui\aeon\game\construct-panel\que-panel_bmp_l.dds" , "12" , "52" , "dds" +"ui\aeon\game\construct-panel\que-panel_bmp_m.dds" , "8" , "52" , "dds" +"ui\aeon\game\construct-panel\que-panel_bmp_r.dds" , "12" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\back_off.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\back_on.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\fforward_off.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\fforward_on.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\forward_off.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\forward_on.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\infinite_off.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\infinite_on.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\left_btn_dis.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\left_btn_over.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\left_btn_up.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\mid_btn_dis.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\mid_btn_over.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\mid_btn_selected.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\mid_btn_up.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\pause_off.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\pause_on.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\rewind_off.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\rewind_on.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\right_btn_dis.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\right_btn_over.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\right_btn_up.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\template_off.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_btn\template_on.dds" , "24" , "52" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\infinite_off.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\infinite_on.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\pause_off.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\pause_on.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\que_btn_dis.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\que_btn_over.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\que_btn_selected.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\que_btn_up.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\template_off.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_horiz_btn\template_on.dds" , "60" , "28" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\bottom_btn_dis.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\bottom_btn_over.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\bottom_btn_up.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\down_off.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\down_on.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\end_off.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\end_on.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\home_off.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\home_on.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\mid_btn_dis.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\mid_btn_over.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\mid_btn_up.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\top_btn_dis.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\top_btn_over.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\top_btn_up.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\up_off.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-sm_nav_horiz_btn\up_on.dds" , "60" , "16" , "dds" +"ui\aeon\game\construct-tab_btn\bot_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\bot_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\bot_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\bot_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\bot_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\mid_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\mid_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\mid_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\mid_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\mid_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\top_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\top_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\top_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\top_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_btn\top_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\aeon\game\construct-tab_top_btn\bot_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\bot_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\bot_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\bot_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\bot_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\mid_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\mid_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\mid_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\mid_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\mid_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\top_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\top_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\top_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\top_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tab_top_btn\top_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\left_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\left_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\left_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\left_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\left_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\m_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\m_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\m_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\m_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\m_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\r_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\r_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\r_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\r_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\r_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t1_btn_dis.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t1_btn_down.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t1_btn_over.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t1_btn_selected.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t1_btn_up.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t2_btn_dis.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t2_btn_down.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t2_btn_over.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t2_btn_selected.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t2_btn_up.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t3_btn_dis.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t3_btn_down.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t3_btn_over.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t3_btn_selected.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t3_btn_up.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t4_btn_dis.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t4_btn_down.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t4_btn_over.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t4_btn_selected.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\t4_btn_up.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\template_btn_dis.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\template_btn_down.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\template_btn_over.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\template_btn_selected.dds" , "32" , "32" , "dds" +"ui\aeon\game\construct-tech_btn\template_btn_up.dds" , "32" , "32" , "dds" +"ui\aeon\game\construction-pause_btn\pause_btn_dis.dds" , "32" , "36" , "dds" +"ui\aeon\game\construction-pause_btn\pause_btn_down.dds" , "32" , "36" , "dds" +"ui\aeon\game\construction-pause_btn\pause_btn_over.dds" , "32" , "36" , "dds" +"ui\aeon\game\construction-pause_btn\pause_btn_up.dds" , "32" , "36" , "dds" +"ui\aeon\game\construction-tab_btn\enhance-back_icon_bmp.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\enhance-back_icon_bmp.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\enhance-l-arm_icon_bmp.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\enhance-l-arm_icon_bmp.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\enhance-r-arm_icon_bmp.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\enhance-r-arm_icon_bmp.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\experimental_icon_bmp.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\experimental_icon_bmp.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\tech-level-1_icon_bmp.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\tech-level-1_icon_bmp.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\tech-level-2_icon_bmp.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\tech-level-2_icon_bmp.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\tech-level-3_icon_bmp.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\tech-level-3_icon_bmp.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_dis.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_dis.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_down.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_down.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_over.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_over.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_selected.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_selected.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_up.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\tech-tab_btn_up.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\units-attached_icon_bmp.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\units-attached_icon_bmp.png" , "56" , "32" , "png" +"ui\aeon\game\construction-tab_btn\units-select_icon_bmp.dds" , "56" , "32" , "dds" +"ui\aeon\game\construction-tab_btn\units-select_icon_bmp.png" , "56" , "32" , "png" +"ui\aeon\game\control-group-bracket\panel-score_bmp_b.dds" , "72" , "24" , "dds" +"ui\aeon\game\control-group-bracket\panel-score_bmp_m.dds" , "76" , "4" , "dds" +"ui\aeon\game\control-group-bracket\panel-score_bmp_t.dds" , "76" , "48" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ll_btn_dis.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ll_btn_down.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ll_btn_over.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ll_btn_up.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-lr_btn_dis.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-lr_btn_down.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-lr_btn_over.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-lr_btn_up.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ul_btn_dis.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ul_btn_down.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ul_btn_over.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ul_btn_up.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ur_btn_dis.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ur_btn_down.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ur_btn_over.dds" , "80" , "96" , "dds" +"ui\aeon\game\drag-handle\drag-handle-ur_btn_up.dds" , "80" , "96" , "dds" +"ui\aeon\game\economic-overlay\econ_bmp_l.dds" , "24" , "32" , "dds" +"ui\aeon\game\economic-overlay\econ_bmp_m.dds" , "4" , "32" , "dds" +"ui\aeon\game\economic-overlay\econ_bmp_r.dds" , "12" , "32" , "dds" +"ui\aeon\game\filter-ping-list-panel\energy-bar_bmp.dds" , "28" , "16" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_bmp_b.dds" , "128" , "24" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_bmp_m.dds" , "128" , "8" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_bmp_t.dds" , "128" , "24" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_brd_horz_um.dds" , "8" , "20" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_brd_ll.dds" , "24" , "24" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_brd_lm.dds" , "8" , "20" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_brd_lr.dds" , "24" , "24" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_brd_ul.dds" , "24" , "24" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_brd_ur.dds" , "24" , "24" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\aeon\game\filter-ping-list-panel\panel_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\aeon\game\filter-ping-panel\bracket-energy-l_bmp.dds" , "24" , "72" , "dds" +"ui\aeon\game\filter-ping-panel\bracket-energy-r_bmp.dds" , "24" , "72" , "dds" +"ui\aeon\game\filter-ping-panel\bracket-left_bmp.dds" , "28" , "68" , "dds" +"ui\aeon\game\filter-ping-panel\filter-ping-panel02_bmp.dds" , "180" , "72" , "dds" +"ui\aeon\game\infinite_btn\infinite_btn_dis.dds" , "32" , "36" , "dds" +"ui\aeon\game\infinite_btn\infinite_btn_down.dds" , "32" , "36" , "dds" +"ui\aeon\game\infinite_btn\infinite_btn_over.dds" , "32" , "36" , "dds" +"ui\aeon\game\infinite_btn\infinite_btn_up.dds" , "32" , "36" , "dds" +"ui\aeon\game\medium-btn\medium_btn_dis.dds" , "296" , "72" , "dds" +"ui\aeon\game\medium-btn\medium_btn_down.dds" , "296" , "72" , "dds" +"ui\aeon\game\medium-btn\medium_btn_glow.dds" , "296" , "72" , "dds" +"ui\aeon\game\medium-btn\medium_btn_over.dds" , "296" , "72" , "dds" +"ui\aeon\game\medium-btn\medium_btn_up.dds" , "296" , "72" , "dds" +"ui\aeon\game\medium-btn\medium-btn_dis.dds" , "296" , "72" , "dds" +"ui\aeon\game\medium-btn\medium-btn_down.dds" , "296" , "72" , "dds" +"ui\aeon\game\medium-btn\medium-btn_glow.dds" , "296" , "72" , "dds" +"ui\aeon\game\medium-btn\medium-btn_over.dds" , "296" , "72" , "dds" +"ui\aeon\game\medium-btn\medium-btn_up.dds" , "296" , "72" , "dds" +"ui\aeon\game\menu-btns\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\close_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\close_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\close_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\config_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\config_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\config_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\config_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\default_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\default_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\default_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\default_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\pin_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\pin_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\pin_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\pin_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\aeon\game\menu-btns\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\aeon\game\mfd_btn\control_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\control_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\control_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\control_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\defenses_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\defenses_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\defenses_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\defenses_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\economy_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\economy_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\economy_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\economy_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\intelligence_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\intelligence_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\intelligence_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\intelligence_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\military_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\military_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\military_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\military_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\military-radar_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\military-radar_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\military-radar_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\military-radar_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-alert_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-alert_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-alert_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-alert_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-attack_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-attack_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-attack_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-attack_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-marker_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-marker_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-marker_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-marker_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-move_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-move_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-move_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\ping-move_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\team-color_btn_dis.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\team-color_btn_down.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\team-color_btn_over.dds" , "44" , "32" , "dds" +"ui\aeon\game\mfd_btn\team-color_btn_up.dds" , "44" , "32" , "dds" +"ui\aeon\game\min-control-tab-btn\tab-close_btn_dis.dds" , "44" , "24" , "dds" +"ui\aeon\game\min-control-tab-btn\tab-close_btn_down.dds" , "44" , "24" , "dds" +"ui\aeon\game\min-control-tab-btn\tab-close_btn_over.dds" , "44" , "24" , "dds" +"ui\aeon\game\min-control-tab-btn\tab-close_btn_up.dds" , "44" , "24" , "dds" +"ui\aeon\game\min-control-tab-btn\tab-open_btn_dis.dds" , "44" , "24" , "dds" +"ui\aeon\game\min-control-tab-btn\tab-open_btn_down.dds" , "44" , "24" , "dds" +"ui\aeon\game\min-control-tab-btn\tab-open_btn_over.dds" , "44" , "24" , "dds" +"ui\aeon\game\min-control-tab-btn\tab-open_btn_up.dds" , "44" , "24" , "dds" +"ui\aeon\game\mini-map-brd\mini-map_brd_horz_um.dds" , "12" , "36" , "dds" +"ui\aeon\game\mini-map-brd\mini-map_brd_ll.dds" , "16" , "12" , "dds" +"ui\aeon\game\mini-map-brd\mini-map_brd_lm.dds" , "12" , "12" , "dds" +"ui\aeon\game\mini-map-brd\mini-map_brd_lr.dds" , "16" , "12" , "dds" +"ui\aeon\game\mini-map-brd\mini-map_brd_m.dds" , "12" , "12" , "dds" +"ui\aeon\game\mini-map-brd\mini-map_brd_ul.dds" , "36" , "36" , "dds" +"ui\aeon\game\mini-map-brd\mini-map_brd_ur.dds" , "36" , "36" , "dds" +"ui\aeon\game\mini-map-brd\mini-map_brd_vert_l.dds" , "16" , "12" , "dds" +"ui\aeon\game\mini-map-brd\mini-map_brd_vert_r.dds" , "16" , "12" , "dds" +"ui\aeon\game\mini-map-brd\mini-map-glow_bmp.dds" , "208" , "208" , "dds" +"ui\aeon\game\mini-map-glow-brd\mini-map-glow_brd_horz_um.dds" , "8" , "24" , "dds" +"ui\aeon\game\mini-map-glow-brd\mini-map-glow_brd_ll copy.dds" , "40" , "40" , "dds" +"ui\aeon\game\mini-map-glow-brd\mini-map-glow_brd_ll.dds" , "40" , "40" , "dds" +"ui\aeon\game\mini-map-glow-brd\mini-map-glow_brd_lm.dds" , "8" , "24" , "dds" +"ui\aeon\game\mini-map-glow-brd\mini-map-glow_brd_lr.dds" , "40" , "40" , "dds" +"ui\aeon\game\mini-map-glow-brd\mini-map-glow_brd_ul.dds" , "40" , "40" , "dds" +"ui\aeon\game\mini-map-glow-brd\mini-map-glow_brd_ur.dds" , "40" , "40" , "dds" +"ui\aeon\game\mini-map-glow-brd\mini-map-glow_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\aeon\game\mini-map-glow-brd\mini-map-glow_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\aeon\game\objective-icons\panel-icon_bmp.dds" , "60" , "68" , "dds" +"ui\aeon\game\objective-icons\primary-ring_bmp.dds" , "52" , "48" , "dds" +"ui\aeon\game\objective-icons\secondary-ring_bmp.dds" , "52" , "48" , "dds" +"ui\aeon\game\options_tab\diplomacy_btn_dis.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\diplomacy_btn_down.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\diplomacy_btn_over.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\diplomacy_btn_selected.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\diplomacy_btn_up.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\glow_bmp.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\menu_btn_dis.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\menu_btn_down.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\menu_btn_over.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\menu_btn_selected.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\menu_btn_up.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\objectives_btn_dis.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\objectives_btn_down.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\objectives_btn_over.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\objectives_btn_selected.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\objectives_btn_up.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\pause_btn_dis.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\pause_btn_down.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\pause_btn_over.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\pause_btn_selected.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\pause_btn_up.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\play_btn_dis.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\play_btn_down.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\play_btn_over.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\play_btn_selected.dds" , "56" , "48" , "dds" +"ui\aeon\game\options_tab\play_btn_up.dds" , "56" , "48" , "dds" +"ui\aeon\game\options-panel\options_brd_horz_lm.dds" , "4" , "28" , "dds" +"ui\aeon\game\options-panel\options_brd_horz_um.dds" , "80" , "36" , "dds" +"ui\aeon\game\options-panel\options_brd_horz_uml.dds" , "4" , "28" , "dds" +"ui\aeon\game\options-panel\options_brd_horz_umr.dds" , "4" , "28" , "dds" +"ui\aeon\game\options-panel\options_brd_ll.dds" , "44" , "36" , "dds" +"ui\aeon\game\options-panel\options_brd_lr.dds" , "44" , "36" , "dds" +"ui\aeon\game\options-panel\options_brd_m.dds" , "4" , "8" , "dds" +"ui\aeon\game\options-panel\options_brd_ul.dds" , "44" , "36" , "dds" +"ui\aeon\game\options-panel\options_brd_ur.dds" , "44" , "36" , "dds" +"ui\aeon\game\options-panel\options_brd_vert_l.dds" , "36" , "8" , "dds" +"ui\aeon\game\options-panel\options_brd_vert_ll.dds" , "40" , "24" , "dds" +"ui\aeon\game\options-panel\options_brd_vert_lr.dds" , "40" , "24" , "dds" +"ui\aeon\game\options-panel\options_brd_vert_r.dds" , "36" , "8" , "dds" +"ui\aeon\game\options-panel\options_brd_vert_ul.dds" , "40" , "24" , "dds" +"ui\aeon\game\options-panel\options_brd_vert_ur.dds" , "40" , "24" , "dds" +"ui\aeon\game\orders-panel_vert\bracket_bmp.dds" , "36" , "228" , "dds" +"ui\aeon\game\orders-panel_vert\order-panel_bmp.dds" , "188" , "224" , "dds" +"ui\aeon\game\orders-panel\bracket_bmp.dds" , "36" , "124" , "dds" +"ui\aeon\game\orders-panel\no-parking_bmp.dds" , "56" , "56" , "dds" +"ui\aeon\game\orders-panel\order-panel_bmp.dds" , "332" , "120" , "dds" +"ui\aeon\game\orders-panel\question-mark_bmp.dds" , "16" , "24" , "dds" +"ui\aeon\game\panel\panel_brd_horz_um.dds" , "8" , "28" , "dds" +"ui\aeon\game\panel\panel_brd_ll.dds" , "28" , "28" , "dds" +"ui\aeon\game\panel\panel_brd_lm.dds" , "8" , "28" , "dds" +"ui\aeon\game\panel\panel_brd_lr.dds" , "28" , "28" , "dds" +"ui\aeon\game\panel\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\aeon\game\panel\panel_brd_ul.dds" , "28" , "28" , "dds" +"ui\aeon\game\panel\panel_brd_ur.dds" , "28" , "28" , "dds" +"ui\aeon\game\panel\panel_brd_vert_l.dds" , "28" , "8" , "dds" +"ui\aeon\game\panel\panel_brd_vert_r.dds" , "28" , "8" , "dds" +"ui\aeon\game\pause_btn\glow_bmp.dds" , "56" , "48" , "dds" +"ui\aeon\game\pause_btn\pause_btn_dis.dds" , "56" , "48" , "dds" +"ui\aeon\game\pause_btn\pause_btn_down.dds" , "56" , "48" , "dds" +"ui\aeon\game\pause_btn\pause_btn_over.dds" , "56" , "48" , "dds" +"ui\aeon\game\pause_btn\pause_btn_up.dds" , "56" , "48" , "dds" +"ui\aeon\game\pause_btn\play_btn_dis.dds" , "56" , "48" , "dds" +"ui\aeon\game\pause_btn\play_btn_down.dds" , "56" , "48" , "dds" +"ui\aeon\game\pause_btn\play_btn_over.dds" , "56" , "48" , "dds" +"ui\aeon\game\pause_btn\play_btn_up.dds" , "56" , "48" , "dds" +"ui\aeon\game\pause-indicator\bottom.dds" , "20" , "20" , "dds" +"ui\aeon\game\pause-indicator\left.dds" , "20" , "20" , "dds" +"ui\aeon\game\pause-indicator\right.dds" , "20" , "20" , "dds" +"ui\aeon\game\pause-indicator\top.dds" , "20" , "20" , "dds" +"ui\aeon\game\pda-panel\bracket-right_bmp.dds" , "32" , "156" , "dds" +"ui\aeon\game\pda-panel\panel-objectives_bmp_l.dds" , "20" , "76" , "dds" +"ui\aeon\game\pda-panel\panel-objectives_bmp_m.dds" , "4" , "72" , "dds" +"ui\aeon\game\pda-panel\panel-objectives_bmp_r.dds" , "24" , "72" , "dds" +"ui\aeon\game\pda-panel\panel-ping_bmp_l.dds" , "20" , "60" , "dds" +"ui\aeon\game\pda-panel\panel-ping_bmp_m.dds" , "4" , "56" , "dds" +"ui\aeon\game\pda-panel\panel-ping_bmp_r.dds" , "24" , "56" , "dds" +"ui\aeon\game\pda-panel\panel-time-units_bmp_l.dds" , "20" , "28" , "dds" +"ui\aeon\game\pda-panel\panel-time-units_bmp_m.dds" , "4" , "24" , "dds" +"ui\aeon\game\pda-panel\panel-time-units_bmp_r.dds" , "24" , "24" , "dds" +"ui\aeon\game\pda-panel\title-bar_bmp.dds" , "136" , "20" , "dds" +"ui\aeon\game\pda-panel\video-panel_bmp.dds" , "168" , "152" , "dds" +"ui\aeon\game\ping-icons\panel-icon_bmp.dds" , "56" , "56" , "dds" +"ui\aeon\game\ping-icons\panel-icon-ring_bmp.dds" , "56" , "56" , "dds" +"ui\aeon\game\resource-bars\mini-energy-bar_bmp.dds" , "160" , "16" , "dds" +"ui\aeon\game\resource-bars\mini-energy-bar-back_bmp.dds" , "160" , "16" , "dds" +"ui\aeon\game\resource-bars\mini-mass-bar_bmp.dds" , "160" , "16" , "dds" +"ui\aeon\game\resource-bars\mini-mass-bar-back_bmp.dds" , "160" , "16" , "dds" +"ui\aeon\game\resource-panel\alert-caution_bmp.dds" , "48" , "44" , "dds" +"ui\aeon\game\resource-panel\alert-energy-panel_bmp.dds" , "316" , "32" , "dds" +"ui\aeon\game\resource-panel\alert-icon_bmp.dds" , "28" , "36" , "dds" +"ui\aeon\game\resource-panel\alert-mass-panel_bmp.dds" , "316" , "32" , "dds" +"ui\aeon\game\resource-panel\alert-no-parking_bmp.dds" , "52" , "52" , "dds" +"ui\aeon\game\resource-panel\bracket-energy-l_bmp.dds" , "20" , "68" , "dds" +"ui\aeon\game\resource-panel\bracket-energy-r_bmp.dds" , "20" , "68" , "dds" +"ui\aeon\game\resource-panel\bracket-left_bmp.dds" , "32" , "80" , "dds" +"ui\aeon\game\resource-panel\bracket-right_bmp.dds" , "28" , "68" , "dds" +"ui\aeon\game\resource-panel\caution-energy-panel_bmp.dds" , "316" , "32" , "dds" +"ui\aeon\game\resource-panel\caution-mass-panel_bmp.dds" , "316" , "32" , "dds" +"ui\aeon\game\resource-panel\resources_panel_bmp.dds" , "324" , "72" , "dds" +"ui\aeon\game\score-panel\icon-square_bmp.dds" , "24" , "24" , "dds" +"ui\aeon\game\score-panel\panel-score_bmp_b.dds" , "232" , "20" , "dds" +"ui\aeon\game\score-panel\panel-score_bmp_m.dds" , "232" , "4" , "dds" +"ui\aeon\game\score-panel\panel-score_bmp_t.dds" , "232" , "44" , "dds" +"ui\aeon\game\tab-l-btn\tab-close_btn_dis.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-l-btn\tab-close_btn_down.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-l-btn\tab-close_btn_over.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-l-btn\tab-close_btn_up.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-l-btn\tab-open_btn_dis.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-l-btn\tab-open_btn_down.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-l-btn\tab-open_btn_over.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-l-btn\tab-open_btn_up.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-r-btn\tab-close_btn_dis.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-r-btn\tab-close_btn_down.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-r-btn\tab-close_btn_over.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-r-btn\tab-close_btn_up.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-r-btn\tab-open_btn_dis.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-r-btn\tab-open_btn_down.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-r-btn\tab-open_btn_over.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-r-btn\tab-open_btn_up.dds" , "20" , "28" , "dds" +"ui\aeon\game\tab-t-btn\tab-close_btn_dis.dds" , "28" , "20" , "dds" +"ui\aeon\game\tab-t-btn\tab-close_btn_down.dds" , "28" , "20" , "dds" +"ui\aeon\game\tab-t-btn\tab-close_btn_over.dds" , "28" , "20" , "dds" +"ui\aeon\game\tab-t-btn\tab-close_btn_up.dds" , "28" , "20" , "dds" +"ui\aeon\game\tab-t-btn\tab-open_btn_dis.dds" , "28" , "20" , "dds" +"ui\aeon\game\tab-t-btn\tab-open_btn_down.dds" , "28" , "20" , "dds" +"ui\aeon\game\tab-t-btn\tab-open_btn_over.dds" , "28" , "20" , "dds" +"ui\aeon\game\tab-t-btn\tab-open_btn_up.dds" , "28" , "20" , "dds" +"ui\aeon\game\temp_textures\checkmark_dark.dds" , "16" , "16" , "dds" +"ui\aeon\game\temp_textures\checkmark.dds" , "16" , "16" , "dds" +"ui\aeon\game\temp_textures\combo_sel.dds" , "20" , "20" , "dds" +"ui\aeon\game\temp_textures\combo_up.dds" , "20" , "20" , "dds" +"ui\aeon\game\transmission\title-bar_bmp.dds" , "192" , "20" , "dds" +"ui\aeon\game\transmission\video-brackets.dds" , "224" , "216" , "dds" +"ui\aeon\game\transmission\video-panel_bmp.dds" , "236" , "216" , "dds" +"ui\aeon\game\transmission\video-panel.dds" , "208" , "208" , "dds" +"ui\aeon\game\unit-build-over-panel\bracket-build_bmp.dds" , "32" , "116" , "dds" +"ui\aeon\game\unit-build-over-panel\bracket-unit_bmp.dds" , "32" , "116" , "dds" +"ui\aeon\game\unit-build-over-panel\build-over-back_bmp.dds" , "332" , "116" , "dds" +"ui\aeon\game\unit-build-over-panel\unit-over-back_bmp.dds" , "332" , "116" , "dds" +"ui\aeon\small-vert_scroll\arrow-down_scr_dis.dds" , "36" , "28" , "dds" +"ui\aeon\small-vert_scroll\arrow-down_scr_down.dds" , "36" , "28" , "dds" +"ui\aeon\small-vert_scroll\arrow-down_scr_over.dds" , "36" , "28" , "dds" +"ui\aeon\small-vert_scroll\arrow-down_scr_up.dds" , "36" , "28" , "dds" +"ui\aeon\small-vert_scroll\arrow-up_scr_dis.dds" , "36" , "28" , "dds" +"ui\aeon\small-vert_scroll\arrow-up_scr_down.dds" , "36" , "28" , "dds" +"ui\aeon\small-vert_scroll\arrow-up_scr_over.dds" , "36" , "28" , "dds" +"ui\aeon\small-vert_scroll\arrow-up_scr_up.dds" , "36" , "28" , "dds" +"ui\aeon\small-vert_scroll\back_scr_bot.dds" , "28" , "12" , "dds" +"ui\aeon\small-vert_scroll\back_scr_mid.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\back_scr_top.dds" , "28" , "12" , "dds" +"ui\aeon\small-vert_scroll\bar-bot_scr_dis.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-bot_scr_down.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-bot_scr_over.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-bot_scr_up.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-mid_scr_dis.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-mid_scr_down.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-mid_scr_over.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-mid_scr_up.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-top_scr_dis.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-top_scr_down.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-top_scr_over.dds" , "36" , "8" , "dds" +"ui\aeon\small-vert_scroll\bar-top_scr_up.dds" , "36" , "8" , "dds" +"ui\aeon\widgets02\small_btn_dis.dds" , "148" , "36" , "dds" +"ui\aeon\widgets02\small_btn_down.dds" , "148" , "36" , "dds" +"ui\aeon\widgets02\small_btn_over.dds" , "148" , "36" , "dds" +"ui\aeon\widgets02\small_btn_up.dds" , "148" , "36" , "dds" +"ui\common\aeon_load.dds" , "1280" , "1024" , "dds" +"ui\common\aeon-btn-med\medium_btn_dis.dds" , "296" , "88" , "dds" +"ui\common\aeon-btn-med\medium_btn_down.dds" , "296" , "88" , "dds" +"ui\common\aeon-btn-med\medium_btn_over.dds" , "296" , "88" , "dds" +"ui\common\aeon-btn-med\medium_btn_up.dds" , "296" , "88" , "dds" +"ui\common\aeon-btn-small-op\small_btn_dis.dds" , "196" , "76" , "dds" +"ui\common\aeon-btn-small-op\small_btn_down.dds" , "196" , "76" , "dds" +"ui\common\aeon-btn-small-op\small_btn_over.dds" , "196" , "76" , "dds" +"ui\common\aeon-btn-small-op\small_btn_up.dds" , "196" , "76" , "dds" +"ui\common\aeon-btn-small-stretch\small_btn_dis.dds" , "220" , "48" , "dds" +"ui\common\aeon-btn-small-stretch\small_btn_down.dds" , "220" , "48" , "dds" +"ui\common\aeon-btn-small-stretch\small_btn_over.dds" , "220" , "48" , "dds" +"ui\common\aeon-btn-small-stretch\small_btn_up.dds" , "220" , "48" , "dds" +"ui\common\aeon-btn-small\small_btn_dis.dds" , "196" , "76" , "dds" +"ui\common\aeon-btn-small\small_btn_down.dds" , "196" , "76" , "dds" +"ui\common\aeon-btn-small\small_btn_over.dds" , "196" , "76" , "dds" +"ui\common\aeon-btn-small\small_btn_up.dds" , "196" , "76" , "dds" +"ui\common\campaign\campaign-select-border\back_brd_horz_lml.dds" , "8" , "68" , "dds" +"ui\common\campaign\campaign-select-border\back_brd_horz_um.dds" , "928" , "68" , "dds" +"ui\common\campaign\campaign-select-border\back_brd_horz_uml.dds" , "8" , "56" , "dds" +"ui\common\campaign\campaign-select-border\back_brd_horz_umr.dds" , "8" , "56" , "dds" +"ui\common\campaign\campaign-select-border\back_brd_ll.dds" , "200" , "140" , "dds" +"ui\common\campaign\campaign-select-border\back_brd_lr.dds" , "816" , "144" , "dds" +"ui\common\campaign\campaign-select-border\back_brd_ul.dds" , "40" , "120" , "dds" +"ui\common\campaign\campaign-select-border\back_brd_ur.dds" , "40" , "120" , "dds" +"ui\common\campaign\campaign-select-border\icon-video-black_bmp.dds" , "64" , "36" , "dds" +"ui\common\campaign\campaign-select-border\icon-video-white_bmp.dds" , "64" , "36" , "dds" +"ui\common\campaign\logo-btn\logo-aeon__btn_dis.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-aeon__btn_up.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-aeon_btn_dis.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-aeon_btn_over_sel.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-aeon_btn_over.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-aeon_btn_sel.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-aeon_btn_up.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-cybran_btn_dis.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-cybran_btn_over_sel.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-cybran_btn_over.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-cybran_btn_sel.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-cybran_btn_up.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-uef_btn_dis.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-uef_btn_over_sel.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-uef_btn_over.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-uef_btn_sel.dds" , "108" , "112" , "dds" +"ui\common\campaign\logo-btn\logo-uef_btn_up.dds" , "108" , "112" , "dds" +"ui\common\campaign\op-select-btn\op-select_btn_dis.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select_btn_down.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select_btn_over.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select_btn_up.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-aeon_btn_dis.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-aeon_btn_down.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-aeon_btn_over.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-aeon_btn_up.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-cybran_btn_dis.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-cybran_btn_down.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-cybran_btn_over.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-cybran_btn_up.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-uef_btn_dis.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-uef_btn_down.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-uef_btn_over.dds" , "288" , "60" , "dds" +"ui\common\campaign\op-select-btn\op-select-uef_btn_up.dds" , "288" , "60" , "dds" +"ui\common\campaign\operations-02\arrow-off_bmp.dds" , "24" , "16" , "dds" +"ui\common\campaign\operations-02\arrow-on_bmp.dds" , "24" , "16" , "dds" +"ui\common\campaign\operations-02\back_brd_horz_lm.dds" , "656" , "188" , "dds" +"ui\common\campaign\operations-02\back_brd_horz_lml.dds" , "16" , "144" , "dds" +"ui\common\campaign\operations-02\back_brd_horz_lmr.dds" , "16" , "168" , "dds" +"ui\common\campaign\operations-02\back_brd_horz_um.dds" , "656" , "96" , "dds" +"ui\common\campaign\operations-02\back_brd_horz_uml.dds" , "16" , "80" , "dds" +"ui\common\campaign\operations-02\back_brd_horz_umr.dds" , "16" , "80" , "dds" +"ui\common\campaign\operations-02\back_brd_ll.dds" , "168" , "500" , "dds" +"ui\common\campaign\operations-02\back_brd_lr.dds" , "168" , "500" , "dds" +"ui\common\campaign\operations-02\back_brd_ul.dds" , "168" , "260" , "dds" +"ui\common\campaign\operations-02\back_brd_ur.dds" , "168" , "260" , "dds" +"ui\common\campaign\operations-02\back_brd_vert_l.dds" , "84" , "8" , "dds" +"ui\common\campaign\operations-02\back_brd_vert_r.dds" , "84" , "8" , "dds" +"ui\common\campaign\operations-02\back-back_bmp.dds" , "168" , "52" , "dds" +"ui\common\campaign\operations-02\background_bmp.dds" , "1024" , "768" , "dds" +"ui\common\campaign\operations-02\border_bmp.dds" , "1024" , "768" , "dds" +"ui\common\campaign\operations-02\l-teleport-text.dds" , "28" , "8" , "dds" +"ui\common\campaign\operations-02\large02_btn_up.dds" , "252" , "52" , "dds" +"ui\common\campaign\operations-02\launch-back_bmp.dds" , "288" , "72" , "dds" +"ui\common\campaign\operations-02\launch-line_bmp.dds" , "292" , "20" , "dds" +"ui\common\campaign\operations-02\launch04_btn_dis.dds" , "156" , "120" , "dds" +"ui\common\campaign\operations-02\launch04_btn_down.dds" , "156" , "128" , "dds" +"ui\common\campaign\operations-02\launch04_btn_over.dds" , "156" , "128" , "dds" +"ui\common\campaign\operations-02\launch04_btn_up.dds" , "156" , "128" , "dds" +"ui\common\campaign\operations-02\lights-blue_bmp.dds" , "96" , "104" , "dds" +"ui\common\campaign\operations-02\lights-white_bmp.dds" , "264" , "208" , "dds" +"ui\common\campaign\operations-02\nav-back_btn_dis.dds" , "36" , "28" , "dds" +"ui\common\campaign\operations-02\nav-back_btn_down.dds" , "40" , "32" , "dds" +"ui\common\campaign\operations-02\nav-back_btn_over.dds" , "44" , "36" , "dds" +"ui\common\campaign\operations-02\nav-back_btn_up.dds" , "36" , "32" , "dds" +"ui\common\campaign\operations-02\nav-chapter_btn_dis.dds" , "36" , "28" , "dds" +"ui\common\campaign\operations-02\nav-chapter_btn_down.dds" , "44" , "36" , "dds" +"ui\common\campaign\operations-02\nav-chapter_btn_over.dds" , "36" , "28" , "dds" +"ui\common\campaign\operations-02\nav-chapter_btn_sel.dds" , "36" , "28" , "dds" +"ui\common\campaign\operations-02\nav-chapter_btn_up.dds" , "36" , "28" , "dds" +"ui\common\campaign\operations-02\nav-end_btn_dis.dds" , "36" , "28" , "dds" +"ui\common\campaign\operations-02\nav-end_btn_down.dds" , "44" , "36" , "dds" +"ui\common\campaign\operations-02\nav-end_btn_over.dds" , "44" , "36" , "dds" +"ui\common\campaign\operations-02\nav-end_btn_up.dds" , "44" , "36" , "dds" +"ui\common\campaign\operations-02\nav-ff_btn_dis.dds" , "36" , "28" , "dds" +"ui\common\campaign\operations-02\nav-ff_btn_down.dds" , "40" , "36" , "dds" +"ui\common\campaign\operations-02\nav-ff_btn_over.dds" , "40" , "36" , "dds" +"ui\common\campaign\operations-02\nav-ff_btn_up.dds" , "36" , "32" , "dds" +"ui\common\campaign\operations-02\nav-pause_btn_dis.dds" , "36" , "28" , "dds" +"ui\common\campaign\operations-02\nav-pause_btn_down.dds" , "36" , "36" , "dds" +"ui\common\campaign\operations-02\nav-pause_btn_over.dds" , "36" , "36" , "dds" +"ui\common\campaign\operations-02\nav-pause_btn_up.dds" , "36" , "36" , "dds" +"ui\common\campaign\operations-02\nav-play_btn_dis.dds" , "36" , "28" , "dds" +"ui\common\campaign\operations-02\nav-play_btn_down.dds" , "36" , "36" , "dds" +"ui\common\campaign\operations-02\nav-play_btn_over.dds" , "36" , "36" , "dds" +"ui\common\campaign\operations-02\nav-play_btn_up.dds" , "36" , "36" , "dds" +"ui\common\campaign\operations-02\teleport_bmp.dds" , "128" , "104" , "dds" +"ui\common\campaign\operations-02\teleport-100_bmp.dds" , "128" , "104" , "dds" +"ui\common\campaign\operations-02\teleport-25_bmp.dds" , "64" , "48" , "dds" +"ui\common\campaign\operations-02\teleport-50_bmp.dds" , "64" , "104" , "dds" +"ui\common\campaign\operations-02\teleport-75_bmp.dds" , "128" , "104" , "dds" +"ui\common\campaign\operations-02\teleport-empty_bmp.dds" , "112" , "92" , "dds" +"ui\common\campaign\operations-02\text-panel_bmp.dds" , "700" , "120" , "dds" +"ui\common\campaign\starfield.dds" , "1600" , "1200" , "dds" +"ui\common\cybran_load.dds" , "1280" , "1024" , "dds" +"ui\common\cybran-btn-med\medium_btn_dis.dds" , "296" , "88" , "dds" +"ui\common\cybran-btn-med\medium_btn_down.dds" , "296" , "88" , "dds" +"ui\common\cybran-btn-med\medium_btn_over.dds" , "296" , "88" , "dds" +"ui\common\cybran-btn-med\medium_btn_up.dds" , "296" , "88" , "dds" +"ui\common\cybran-btn-small-stretch\small_btn_dis.dds" , "220" , "48" , "dds" +"ui\common\cybran-btn-small-stretch\small_btn_down.dds" , "220" , "48" , "dds" +"ui\common\cybran-btn-small-stretch\small_btn_over.dds" , "220" , "48" , "dds" +"ui\common\cybran-btn-small-stretch\small_btn_up.dds" , "220" , "48" , "dds" +"ui\common\cybran-btn-small\small_btn_dis.dds" , "196" , "76" , "dds" +"ui\common\cybran-btn-small\small_btn_down.dds" , "196" , "76" , "dds" +"ui\common\cybran-btn-small\small_btn_over.dds" , "196" , "76" , "dds" +"ui\common\cybran-btn-small\small_btn_up.dds" , "196" , "76" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-bracket01_bmp.dds" , "320" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-bracket02_bmp.dds" , "320" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-bracket03_bmp.dds" , "320" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-energy_bmp.dds" , "280" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-energy01_bmp.dds" , "320" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-energy02_bmp.dds" , "320" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-energy03_bmp.dds" , "320" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-mass_bmp.dds" , "280" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-mass01_bmp.dds" , "320" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-mass02_bmp.dds" , "320" , "80" , "dds" +"ui\common\dialogs\alert-mass-energy\alert-mass03_bmp.dds" , "320" , "80" , "dds" +"ui\common\dialogs\check-box_btn\radio-d_btn_dis.dds" , "28" , "28" , "dds" +"ui\common\dialogs\check-box_btn\radio-d_btn_down.dds" , "28" , "28" , "dds" +"ui\common\dialogs\check-box_btn\radio-d_btn_over.dds" , "28" , "28" , "dds" +"ui\common\dialogs\check-box_btn\radio-d_btn_up.dds" , "28" , "28" , "dds" +"ui\common\dialogs\check-box_btn\radio-s_btn_dis.dds" , "28" , "28" , "dds" +"ui\common\dialogs\check-box_btn\radio-s_btn_down.dds" , "28" , "28" , "dds" +"ui\common\dialogs\check-box_btn\radio-s_btn_over.dds" , "28" , "28" , "dds" +"ui\common\dialogs\check-box_btn\radio-s_btn_up.dds" , "28" , "28" , "dds" +"ui\common\dialogs\close_btn\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\common\dialogs\close_btn\close_btn_down.dds" , "24" , "24" , "dds" +"ui\common\dialogs\close_btn\close_btn_over.dds" , "24" , "24" , "dds" +"ui\common\dialogs\close_btn\close_btn_up.dds" , "24" , "24" , "dds" +"ui\common\dialogs\close_btn02\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\common\dialogs\close_btn02\close_btn_down.dds" , "24" , "24" , "dds" +"ui\common\dialogs\close_btn02\close_btn_over.dds" , "24" , "24" , "dds" +"ui\common\dialogs\close_btn02\close_btn_up.dds" , "24" , "24" , "dds" +"ui\common\dialogs\config_btn\config_btn_dis.dds" , "24" , "24" , "dds" +"ui\common\dialogs\config_btn\config_btn_down.dds" , "24" , "24" , "dds" +"ui\common\dialogs\config_btn\config_btn_over.dds" , "24" , "24" , "dds" +"ui\common\dialogs\config_btn\config_btn_up.dds" , "24" , "24" , "dds" +"ui\common\dialogs\dialog_02\panel_bmp_b.dds" , "656" , "76" , "dds" +"ui\common\dialogs\dialog_02\panel_bmp_m.dds" , "656" , "8" , "dds" +"ui\common\dialogs\dialog_02\panel_bmp_T.dds" , "656" , "28" , "dds" +"ui\common\dialogs\dialog_02\panel_bmp.dds" , "664" , "168" , "dds" +"ui\common\dialogs\dialog_02\title-text_bmp.dds" , "392" , "36" , "dds" +"ui\common\dialogs\dialog\bracket_bmp.dds" , "440" , "176" , "dds" +"ui\common\dialogs\dialog\bracket_brd_ll.dds" , "44" , "24" , "dds" +"ui\common\dialogs\dialog\bracket_brd_lr.dds" , "44" , "24" , "dds" +"ui\common\dialogs\dialog\bracket_brd_ul.dds" , "44" , "24" , "dds" +"ui\common\dialogs\dialog\bracket_brd_ur.dds" , "44" , "24" , "dds" +"ui\common\dialogs\dialog\bracket_brd_vert_l.dds" , "20" , "36" , "dds" +"ui\common\dialogs\dialog\bracket_brd_vert_r.dds" , "20" , "36" , "dds" +"ui\common\dialogs\dialog\bracket-w_brd_ll.dds" , "68" , "36" , "dds" +"ui\common\dialogs\dialog\bracket-w_brd_lr.dds" , "64" , "36" , "dds" +"ui\common\dialogs\dialog\bracket-w_brd_ul.dds" , "68" , "36" , "dds" +"ui\common\dialogs\dialog\bracket-w_brd_ur.dds" , "64" , "36" , "dds" +"ui\common\dialogs\dialog\bracket-w_brd_vert_l.dds" , "52" , "36" , "dds" +"ui\common\dialogs\dialog\bracket-w_brd_vert_r.dds" , "52" , "36" , "dds" +"ui\common\dialogs\dialog\panel_bmp_alt_b.dds" , "424" , "28" , "dds" +"ui\common\dialogs\dialog\panel_bmp_b.dds" , "424" , "76" , "dds" +"ui\common\dialogs\dialog\panel_bmp_m.dds" , "424" , "8" , "dds" +"ui\common\dialogs\dialog\panel_bmp_t.dds" , "424" , "28" , "dds" +"ui\common\dialogs\dialog\panel_bmp.dds" , "432" , "168" , "dds" +"ui\common\dialogs\dialog\title-text_bmp.dds" , "392" , "36" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-all_btn_dis.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-all_btn_down.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-all_btn_over.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-all_btn_up.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-allies_btn_dis.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-allies_btn_down.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-allies_btn_over.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-allies_btn_up.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-observer_btn_dis.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-observer_btn_down.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-observer_btn_over.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\chat-observer_btn_up.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\give-resource_btn_dis.dds" , "36" , "32" , "dds" +"ui\common\dialogs\diplomacy_btn\give-resource_btn_down.dds" , "36" , "32" , "dds" +"ui\common\dialogs\diplomacy_btn\give-resource_btn_over.dds" , "36" , "32" , "dds" +"ui\common\dialogs\diplomacy_btn\give-resource_btn_up.dds" , "36" , "32" , "dds" +"ui\common\dialogs\diplomacy_btn\give-unit_btn_dis.dds" , "36" , "32" , "dds" +"ui\common\dialogs\diplomacy_btn\give-unit_btn_down.dds" , "36" , "32" , "dds" +"ui\common\dialogs\diplomacy_btn\give-unit_btn_over.dds" , "36" , "32" , "dds" +"ui\common\dialogs\diplomacy_btn\give-unit_btn_up.dds" , "36" , "32" , "dds" +"ui\common\dialogs\diplomacy_btn\set-sharing_btn_dis.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\set-sharing_btn_down.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\set-sharing_btn_over.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy_btn\set-sharing_btn_up.dds" , "48" , "40" , "dds" +"ui\common\dialogs\diplomacy-resources_options\diplomacy-panel_bmp.dds" , "456" , "296" , "dds" +"ui\common\dialogs\diplomacy-resources_options\energy_btn_up.dds" , "48" , "48" , "dds" +"ui\common\dialogs\diplomacy-resources_options\energy-bar_bmp.dds" , "248" , "12" , "dds" +"ui\common\dialogs\diplomacy-resources_options\energy-bar-back_bmp.dds" , "248" , "12" , "dds" +"ui\common\dialogs\diplomacy-resources_options\mass_btn_up.dds" , "48" , "48" , "dds" +"ui\common\dialogs\diplomacy-resources_options\mass-bar_bmp.dds" , "248" , "12" , "dds" +"ui\common\dialogs\diplomacy-resources_options\mass-bar-back_bmp.dds" , "248" , "12" , "dds" +"ui\common\dialogs\diplomacy-resources_options\slider-text_bmp.dds" , "60" , "28" , "dds" +"ui\common\dialogs\diplomacy-team-alliance\team-panel_bmp.dds" , "456" , "196" , "dds" +"ui\common\dialogs\diplomacy-team-alliance\team-panel-noback_bmp.dds" , "456" , "236" , "dds" +"ui\common\dialogs\diplomacy-unit-transfer\team-panel_bmp.png" , "456" , "192" , "png" +"ui\common\dialogs\diplomacy\diplomacy-bar_bmp.dds" , "204" , "28" , "dds" +"ui\common\dialogs\diplomacy\diplomacy-bar02_bmp.dds" , "264" , "28" , "dds" +"ui\common\dialogs\diplomacy\diplomacy-panel_bmp.dds" , "456" , "344" , "dds" +"ui\common\dialogs\drop-box_brd\drop-box_brd_horz_um.dds" , "8" , "8" , "dds" +"ui\common\dialogs\drop-box_brd\drop-box_brd_ll.dds" , "8" , "8" , "dds" +"ui\common\dialogs\drop-box_brd\drop-box_brd_lm.dds" , "8" , "8" , "dds" +"ui\common\dialogs\drop-box_brd\drop-box_brd_lr.dds" , "8" , "8" , "dds" +"ui\common\dialogs\drop-box_brd\drop-box_brd_m.dds" , "8" , "8" , "dds" +"ui\common\dialogs\drop-box_brd\drop-box_brd_ul.dds" , "8" , "8" , "dds" +"ui\common\dialogs\drop-box_brd\drop-box_brd_ur.dds" , "8" , "8" , "dds" +"ui\common\dialogs\drop-box_brd\drop-box_brd_vert_l.dds" , "8" , "8" , "dds" +"ui\common\dialogs\drop-box_brd\drop-box_brd_vert_r.dds" , "8" , "8" , "dds" +"ui\common\dialogs\drop-box_btn\drop-box_btn_dis.dds" , "28" , "28" , "dds" +"ui\common\dialogs\drop-box_btn\drop-box_btn_down.dds" , "28" , "28" , "dds" +"ui\common\dialogs\drop-box_btn\drop-box_btn_over.dds" , "28" , "28" , "dds" +"ui\common\dialogs\drop-box_btn\drop-box_btn_up.dds" , "28" , "28" , "dds" +"ui\common\dialogs\enhance-panel_btn\infinite_btn_dis.dds" , "36" , "64" , "dds" +"ui\common\dialogs\enhance-panel_btn\infinite_btn_down.dds" , "36" , "64" , "dds" +"ui\common\dialogs\enhance-panel_btn\infinite_btn_glow.dds" , "36" , "64" , "dds" +"ui\common\dialogs\enhance-panel_btn\infinite_btn_over.dds" , "36" , "64" , "dds" +"ui\common\dialogs\enhance-panel_btn\infinite_btn_up.dds" , "36" , "64" , "dds" +"ui\common\dialogs\enhance-panel-small_btn\infinite_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\dialogs\enhance-panel-small_btn\infinite_btn_down.dds" , "36" , "36" , "dds" +"ui\common\dialogs\enhance-panel-small_btn\infinite_btn_glow.dds" , "36" , "36" , "dds" +"ui\common\dialogs\enhance-panel-small_btn\infinite_btn_over.dds" , "36" , "36" , "dds" +"ui\common\dialogs\enhance-panel-small_btn\infinite_btn_up.dds" , "36" , "36" , "dds" +"ui\common\dialogs\enhance-panel\panel_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-b_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-b_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-b_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-b_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-la_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-la_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-la_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-la_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-ra_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-ra_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-ra_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0001-ra_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-b_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-b_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-b_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-b_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-la_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-la_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-la_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-la_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-ra_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-ra_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-ra_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\ual0301-ra_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-b_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-b_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-b_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-b_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-la_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-la_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-la_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-la_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-ra_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-ra_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-ra_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0001-ra_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-b_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-b_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-b_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-b_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-la_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-la_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-la_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-la_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-ra_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-ra_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-ra_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\uel0301-ra_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\urb4202_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\urb4202-ch_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\urb4202-ch_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\urb4202-ch_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\urb4202-ch_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-b_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-b_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-b_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-b_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-la_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-la_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-la_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-la_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-ra_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-ra_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-ra_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0001-ra_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-b_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-b_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-b_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-b_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-la_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-la_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-la_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-la_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-ra_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-ra_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-ra_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\url0301-ra_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-b_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-b_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-b_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-b_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-la_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-la_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-la_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-la_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-ra_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-ra_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-ra_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0001-ra_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301_bmp.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-b_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-b_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-b_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-b_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-la_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-la_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-la_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-la_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-ra_btn_down.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-ra_btn_over.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-ra_btn_sel.dds" , "360" , "240" , "dds" +"ui\common\dialogs\enhance-panel\xsl0301-ra_btn_up.dds" , "360" , "240" , "dds" +"ui\common\dialogs\exit-dialog\brackets_bmp.dds" , "380" , "488" , "dds" +"ui\common\dialogs\exit-dialog\glow-bar_bmp.dds" , "176" , "516" , "dds" +"ui\common\dialogs\exit-dialog\graphic_bmp.dds" , "588" , "512" , "dds" +"ui\common\dialogs\exit-dialog\panel_bmp.dds" , "324" , "348" , "dds" +"ui\common\dialogs\exit-dialog\panel-white_bmp.dds" , "316" , "492" , "dds" +"ui\common\dialogs\exit-dialog\panel02_bmp.dds" , "324" , "348" , "dds" +"ui\common\dialogs\exit-dialog\panel03_bmp.dds" , "324" , "500" , "dds" +"ui\common\dialogs\faction_ico\aeon_ico.dds" , "24" , "24" , "dds" +"ui\common\dialogs\faction_ico\cybran_ico.dds" , "24" , "24" , "dds" +"ui\common\dialogs\faction_ico\uef_ico.dds" , "24" , "24" , "dds" +"ui\common\dialogs\game-select-faction-panel\panel_bmp.dds" , "312" , "172" , "dds" +"ui\common\dialogs\help\help-sm_btn.dds" , "44" , "40" , "dds" +"ui\common\dialogs\help\objectives-d_bmp.dds" , "596" , "308" , "dds" +"ui\common\dialogs\help\panel_bmp.dds" , "888" , "540" , "dds" +"ui\common\dialogs\help\text-over_bmp.dds" , "412" , "20" , "dds" +"ui\common\dialogs\load\panel_bmp.dds" , "732" , "600" , "dds" +"ui\common\dialogs\logo-btn\logo-aeon_btn_dis.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-aeon_btn_over_sel.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-aeon_btn_over.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-aeon_btn_sel.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-aeon_btn_up.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-cybran_btn_dis.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-cybran_btn_over_sel.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-cybran_btn_over.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-cybran_btn_sel.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-cybran_btn_up.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-uef_btn_dis.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-uef_btn_over_sel.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-uef_btn_over.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-uef_btn_sel.dds" , "80" , "80" , "dds" +"ui\common\dialogs\logo-btn\logo-uef_btn_up.dds" , "80" , "80" , "dds" +"ui\common\dialogs\mapselect02\commander_alpha.dds" , "16" , "16" , "dds" +"ui\common\dialogs\mapselect02\commander.dds" , "16" , "16" , "dds" +"ui\common\dialogs\mapselect02\drop-down-w_bmp_b.dds" , "116" , "16" , "dds" +"ui\common\dialogs\mapselect02\drop-down-w_bmp_m.dds" , "116" , "8" , "dds" +"ui\common\dialogs\mapselect02\drop-down-w_bmp_t.dds" , "116" , "16" , "dds" +"ui\common\dialogs\mapselect02\drop-down-w_bmp.dds" , "124" , "44" , "dds" +"ui\common\dialogs\mapselect02\highlight_bmp.dds" , "112" , "20" , "dds" +"ui\common\dialogs\mapselect02\map-panel-glow_bmp.dds" , "332" , "332" , "dds" +"ui\common\dialogs\mapselect02\panel_bmp.dds" , "856" , "628" , "dds" +"ui\common\dialogs\mapselect02\square-w_btn_dis.dds" , "124" , "32" , "dds" +"ui\common\dialogs\mapselect02\square-w_btn_down.dds" , "124" , "32" , "dds" +"ui\common\dialogs\mapselect02\square-w_btn_over.dds" , "124" , "32" , "dds" +"ui\common\dialogs\mapselect02\square-w_btn_up.dds" , "124" , "32" , "dds" +"ui\common\dialogs\mapselect02\text-over_bmp.dds" , "328" , "20" , "dds" +"ui\common\dialogs\mapselect03\drop-down-w_bmp_b.dds" , "116" , "16" , "dds" +"ui\common\dialogs\mapselect03\drop-down-w_bmp_m.dds" , "116" , "8" , "dds" +"ui\common\dialogs\mapselect03\drop-down-w_bmp_t.dds" , "116" , "16" , "dds" +"ui\common\dialogs\mapselect03\drop-down-w_bmp.dds" , "124" , "44" , "dds" +"ui\common\dialogs\mapselect03\filter-panel-bar_bmp.dds" , "312" , "24" , "dds" +"ui\common\dialogs\mapselect03\highlight_bmp.dds" , "112" , "20" , "dds" +"ui\common\dialogs\mapselect03\map-panel-glow_bmp.dds" , "332" , "332" , "dds" +"ui\common\dialogs\mapselect03\options-panel-bar_bmp.dds" , "260" , "24" , "dds" +"ui\common\dialogs\mapselect03\panel_bmp.dds" , "1016" , "768" , "dds" +"ui\common\dialogs\mapselect03\square-w_btn_dis.dds" , "124" , "32" , "dds" +"ui\common\dialogs\mapselect03\square-w_btn_down.dds" , "124" , "32" , "dds" +"ui\common\dialogs\mapselect03\square-w_btn_over.dds" , "124" , "32" , "dds" +"ui\common\dialogs\mapselect03\square-w_btn_up.dds" , "124" , "32" , "dds" +"ui\common\dialogs\mapselect03\text-over_bmp.dds" , "328" , "20" , "dds" +"ui\common\dialogs\mission\panel_bmp_l.dds" , "140" , "136" , "dds" +"ui\common\dialogs\mission\panel_bmp_m.dds" , "52" , "136" , "dds" +"ui\common\dialogs\mission\panel_bmp_r.dds" , "140" , "136" , "dds" +"ui\common\dialogs\mission\panel_bmp.dds" , "328" , "140" , "dds" +"ui\common\dialogs\mission\panel-white_bmp_l.dds" , "140" , "136" , "dds" +"ui\common\dialogs\mission\panel-white_bmp_m.dds" , "52" , "136" , "dds" +"ui\common\dialogs\mission\panel-white_bmp_r.dds" , "140" , "136" , "dds" +"ui\common\dialogs\mission\panel-white_bmp.dds" , "328" , "136" , "dds" +"ui\common\dialogs\mod_btn\mod-d_btn_up.dds" , "640" , "76" , "dds" +"ui\common\dialogs\mod_btn\mod-s_btn_up.dds" , "640" , "76" , "dds" +"ui\common\dialogs\mod-manager\generic-icon_bmp.dds" , "56" , "68" , "dds" +"ui\common\dialogs\mod-manager\panel_bmp.dds" , "760" , "600" , "dds" +"ui\common\dialogs\movie-control\nav-back_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-back_btn_down.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-back_btn_over.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-back_btn_up.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-end_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-end_btn_down.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-end_btn_over.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-end_btn_up.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-ff_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-ff_btn_down.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-ff_btn_over.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-ff_btn_up.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-pause_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-pause_btn_down.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-pause_btn_over.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-pause_btn_up.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-play_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-play_btn_down.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-play_btn_over.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-play_btn_up.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-rr_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-rr_btn_down.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-rr_btn_over.dds" , "36" , "36" , "dds" +"ui\common\dialogs\movie-control\nav-rr_btn_up.dds" , "36" , "36" , "dds" +"ui\common\dialogs\networkwindow\panel_bmp_b.dds" , "448" , "80" , "dds" +"ui\common\dialogs\networkwindow\panel_bmp_m.dds" , "448" , "60" , "dds" +"ui\common\dialogs\networkwindow\panel_bmp_t.dds" , "448" , "52" , "dds" +"ui\common\dialogs\objective-log-02\bar_btn_over.dds" , "580" , "72" , "dds" +"ui\common\dialogs\objective-log-02\bar_btn_select.dds" , "584" , "72" , "dds" +"ui\common\dialogs\objective-log-02\bar_btn_up.dds" , "580" , "72" , "dds" +"ui\common\dialogs\objective-log-02\bar-bottom_btn_over.dds" , "580" , "72" , "dds" +"ui\common\dialogs\objective-log-02\bar-bottom_btn_select.dds" , "580" , "72" , "dds" +"ui\common\dialogs\objective-log-02\bar-bottom_btn_up.dds" , "580" , "72" , "dds" +"ui\common\dialogs\objective-log-02\panel_bmp_b.dds" , "692" , "220" , "dds" +"ui\common\dialogs\objective-log-02\panel_bmp_m.dds" , "692" , "52" , "dds" +"ui\common\dialogs\objective-log-02\panel_bmp_t.dds" , "692" , "100" , "dds" +"ui\common\dialogs\objective-log-02\tab_bmp.dds" , "584" , "60" , "dds" +"ui\common\dialogs\objective-log-btn-bar\bar_btn_over.dds" , "580" , "64" , "dds" +"ui\common\dialogs\objective-log-btn-bar\bar_btn_select.dds" , "580" , "64" , "dds" +"ui\common\dialogs\objective-log-btn-bar\bar_btn_up.dds" , "580" , "64" , "dds" +"ui\common\dialogs\objective-log-btn-bar\bar-bottom_btn_over.dds" , "580" , "64" , "dds" +"ui\common\dialogs\objective-log-btn-bar\bar-bottom_btn_select.dds" , "580" , "64" , "dds" +"ui\common\dialogs\objective-log-btn-bar\bar-bottom_btn_up.dds" , "580" , "64" , "dds" +"ui\common\dialogs\objective-log-btn-bar\tab_bmp.dds" , "580" , "64" , "dds" +"ui\common\dialogs\objective-unit\help-lg_bmp_down.dds" , "76" , "76" , "dds" +"ui\common\dialogs\objective-unit\help-lg_bmp_over.dds" , "76" , "76" , "dds" +"ui\common\dialogs\objective-unit\help-lg_bmp.dds" , "76" , "76" , "dds" +"ui\common\dialogs\objective-unit\help-lg-graphics_bmp.dds" , "92" , "92" , "dds" +"ui\common\dialogs\objective-unit\help-sm_bmp.dds" , "44" , "40" , "dds" +"ui\common\dialogs\objective-unit\panel_bmp_b.dds" , "484" , "220" , "dds" +"ui\common\dialogs\objective-unit\panel_bmp_m.dds" , "484" , "20" , "dds" +"ui\common\dialogs\objective-unit\panel_bmp_t.dds" , "484" , "32" , "dds" +"ui\common\dialogs\objective-unit\panel_bmp.dds" , "416" , "200" , "dds" +"ui\common\dialogs\objective-unit\panel-icon_bmp.dds" , "112" , "112" , "dds" +"ui\common\dialogs\objective-unit\panel-icon-sm_bmp.dds" , "104" , "104" , "dds" +"ui\common\dialogs\objective-unit\panel02_bmp.dds" , "492" , "276" , "dds" +"ui\common\dialogs\options-02\content-box_bmp.dds" , "604" , "36" , "dds" +"ui\common\dialogs\options-02\content-btn-line_bmp.dds" , "232" , "4" , "dds" +"ui\common\dialogs\options-02\gameplay_bmp.dds" , "604" , "372" , "dds" +"ui\common\dialogs\options-02\panel_bmp.dds" , "732" , "608" , "dds" +"ui\common\dialogs\options-02\slider-back_bmp.dds" , "200" , "24" , "dds" +"ui\common\dialogs\options\content-box_bmp.dds" , "468" , "36" , "dds" +"ui\common\dialogs\options\content-btn-line_bmp.dds" , "244" , "4" , "dds" +"ui\common\dialogs\options\gameplay_bmp.dds" , "468" , "276" , "dds" +"ui\common\dialogs\options\panel-front-end_bmp.dds" , "612" , "488" , "dds" +"ui\common\dialogs\options\panel-ingame_bmp.dds" , "604" , "488" , "dds" +"ui\common\dialogs\options\slider-back_bmp.dds" , "196" , "20" , "dds" +"ui\common\dialogs\options\sound_bmp.dds" , "468" , "284" , "dds" +"ui\common\dialogs\options\video_bmp.dds" , "468" , "296" , "dds" +"ui\common\dialogs\pause\brackets_bmp_b.dds" , "312" , "68" , "dds" +"ui\common\dialogs\pause\brackets_bmp_t.dds" , "312" , "68" , "dds" +"ui\common\dialogs\pause\brackets_bmp.dds" , "312" , "148" , "dds" +"ui\common\dialogs\pause\panel_bmp_b.dds" , "300" , "104" , "dds" +"ui\common\dialogs\pause\panel_bmp_m.dds" , "300" , "8" , "dds" +"ui\common\dialogs\pause\panel_bmp_t.dds" , "300" , "32" , "dds" +"ui\common\dialogs\pause\panel_bmp.dds" , "304" , "148" , "dds" +"ui\common\dialogs\pin\pin_dis.dds" , "24" , "24" , "dds" +"ui\common\dialogs\pin\pin_down.dds" , "24" , "24" , "dds" +"ui\common\dialogs\pin\pin_over.dds" , "24" , "24" , "dds" +"ui\common\dialogs\pin\pin_up.dds" , "24" , "24" , "dds" +"ui\common\dialogs\pin\pinned_dis.dds" , "24" , "24" , "dds" +"ui\common\dialogs\pin\pinned_down.dds" , "24" , "24" , "dds" +"ui\common\dialogs\pin\pinned_over.dds" , "24" , "24" , "dds" +"ui\common\dialogs\pin\pinned_up.dds" , "24" , "24" , "dds" +"ui\common\dialogs\profile02\panel_bmp.dds" , "536" , "460" , "dds" +"ui\common\dialogs\profile02\text-over_bmp.dds" , "412" , "20" , "dds" +"ui\common\dialogs\radio_btn\radio-d_btn_dis.dds" , "28" , "28" , "dds" +"ui\common\dialogs\radio_btn\radio-d_btn_down.dds" , "28" , "28" , "dds" +"ui\common\dialogs\radio_btn\radio-d_btn_over.dds" , "28" , "28" , "dds" +"ui\common\dialogs\radio_btn\radio-d_btn_up.dds" , "28" , "28" , "dds" +"ui\common\dialogs\radio_btn\radio-s_btn_dis.dds" , "28" , "28" , "dds" +"ui\common\dialogs\radio_btn\radio-s_btn_down.dds" , "28" , "28" , "dds" +"ui\common\dialogs\radio_btn\radio-s_btn_over.dds" , "28" , "28" , "dds" +"ui\common\dialogs\radio_btn\radio-s_btn_up.dds" , "28" , "28" , "dds" +"ui\common\dialogs\rect-box_btn\rect-box-d_btn_dis.dds" , "104" , "52" , "dds" +"ui\common\dialogs\rect-box_btn\rect-box-d_btn_down.dds" , "104" , "52" , "dds" +"ui\common\dialogs\rect-box_btn\rect-box-d_btn_over.dds" , "104" , "52" , "dds" +"ui\common\dialogs\rect-box_btn\rect-box-d_btn_up.dds" , "104" , "52" , "dds" +"ui\common\dialogs\rect-box_btn\rect-box-s_btn_dis.dds" , "104" , "52" , "dds" +"ui\common\dialogs\rect-box_btn\rect-box-s_btn_down.dds" , "104" , "52" , "dds" +"ui\common\dialogs\rect-box_btn\rect-box-s_btn_over.dds" , "104" , "52" , "dds" +"ui\common\dialogs\rect-box_btn\rect-box-s_btn_up.dds" , "104" , "52" , "dds" +"ui\common\dialogs\score_btn\chart_btn_dis.dds" , "68" , "52" , "dds" +"ui\common\dialogs\score_btn\chart_btn_down.dds" , "68" , "52" , "dds" +"ui\common\dialogs\score_btn\chart_btn_over.dds" , "68" , "52" , "dds" +"ui\common\dialogs\score_btn\chart_btn_up.dds" , "68" , "52" , "dds" +"ui\common\dialogs\score_btn\grid_btn_dis.dds" , "68" , "52" , "dds" +"ui\common\dialogs\score_btn\grid_btn_down.dds" , "68" , "52" , "dds" +"ui\common\dialogs\score_btn\grid_btn_over.dds" , "68" , "52" , "dds" +"ui\common\dialogs\score_btn\grid_btn_up.dds" , "68" , "52" , "dds" +"ui\common\dialogs\score-aeon\back_brd_horz_lml.dds" , "8" , "68" , "dds" +"ui\common\dialogs\score-aeon\back_brd_horz_um.dds" , "880" , "64" , "dds" +"ui\common\dialogs\score-aeon\back_brd_horz_uml.dds" , "8" , "16" , "dds" +"ui\common\dialogs\score-aeon\back_brd_horz_umr.dds" , "8" , "16" , "dds" +"ui\common\dialogs\score-aeon\back_brd_ll.dds" , "312" , "220" , "dds" +"ui\common\dialogs\score-aeon\back_brd_lr.dds" , "704" , "212" , "dds" +"ui\common\dialogs\score-aeon\back_brd_ul.dds" , "64" , "140" , "dds" +"ui\common\dialogs\score-aeon\back_brd_ur.dds" , "64" , "156" , "dds" +"ui\common\dialogs\score-aeon\background_bmp.dds" , "1024" , "768" , "dds" +"ui\common\dialogs\score-aeon\icon_experimental.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-aeon\icon-air_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-aeon\icon-cdr_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-aeon\icon-energy_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-aeon\icon-kill-death_bmp.dds" , "24" , "24" , "dds" +"ui\common\dialogs\score-aeon\icon-kills_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-aeon\icon-land_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-aeon\icon-loss_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-aeon\icon-mass_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-aeon\icon-naval_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-aeon\icon-structure_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-aeon\panels_bmp.dds" , "964" , "628" , "dds" +"ui\common\dialogs\score-aeon\text-box_bmp_b.dds" , "628" , "16" , "dds" +"ui\common\dialogs\score-aeon\text-box_bmp_m.dds" , "628" , "8" , "dds" +"ui\common\dialogs\score-aeon\text-box_bmp_T.dds" , "628" , "36" , "dds" +"ui\common\dialogs\score-aeon\text-box_bmp.dds" , "636" , "240" , "dds" +"ui\common\dialogs\score-cybran\back_brd_horz_lml.dds" , "8" , "76" , "dds" +"ui\common\dialogs\score-cybran\back_brd_horz_um.dds" , "880" , "104" , "dds" +"ui\common\dialogs\score-cybran\back_brd_horz_uml.dds" , "8" , "32" , "dds" +"ui\common\dialogs\score-cybran\back_brd_horz_umr.dds" , "8" , "32" , "dds" +"ui\common\dialogs\score-cybran\back_brd_ll.dds" , "312" , "116" , "dds" +"ui\common\dialogs\score-cybran\back_brd_lr.dds" , "704" , "116" , "dds" +"ui\common\dialogs\score-cybran\back_brd_ul.dds" , "64" , "104" , "dds" +"ui\common\dialogs\score-cybran\back_brd_ur.dds" , "64" , "104" , "dds" +"ui\common\dialogs\score-cybran\background_bmp.dds" , "1024" , "768" , "dds" +"ui\common\dialogs\score-cybran\icon_experimental.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-cybran\icon-air_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-cybran\icon-cdr_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-cybran\icon-energy_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-cybran\icon-kill-death_bmp.dds" , "24" , "24" , "dds" +"ui\common\dialogs\score-cybran\icon-kills_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-cybran\icon-land_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-cybran\icon-loss_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-cybran\icon-mass_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-cybran\icon-naval_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-cybran\icon-structure_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-cybran\panel_bmp.dds" , "956" , "620" , "dds" +"ui\common\dialogs\score-cybran\panels_bmp.dds" , "964" , "628" , "dds" +"ui\common\dialogs\score-cybran\text-box_bmp_b.dds" , "628" , "16" , "dds" +"ui\common\dialogs\score-cybran\text-box_bmp_m.dds" , "628" , "8" , "dds" +"ui\common\dialogs\score-cybran\text-box_bmp_T.dds" , "628" , "36" , "dds" +"ui\common\dialogs\score-cybran\text-box_bmp.dds" , "636" , "240" , "dds" +"ui\common\dialogs\score-overlay\clock_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-overlay\factional-logo-aeon_bmp.dds" , "16" , "20" , "dds" +"ui\common\dialogs\score-overlay\factional-logo-cybran_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-overlay\factional-logo-uef_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-overlay\panel_bmp.dds" , "428" , "260" , "dds" +"ui\common\dialogs\score-overlay\score_brd_horz_um.dds" , "8" , "44" , "dds" +"ui\common\dialogs\score-overlay\score_brd_ll.dds" , "48" , "44" , "dds" +"ui\common\dialogs\score-overlay\score_brd_lm.dds" , "8" , "44" , "dds" +"ui\common\dialogs\score-overlay\score_brd_lr.dds" , "48" , "44" , "dds" +"ui\common\dialogs\score-overlay\score_brd_m.dds" , "8" , "24" , "dds" +"ui\common\dialogs\score-overlay\score_brd_ul.dds" , "48" , "44" , "dds" +"ui\common\dialogs\score-overlay\score_brd_ur.dds" , "48" , "44" , "dds" +"ui\common\dialogs\score-overlay\score_brd_vert_l.dds" , "48" , "24" , "dds" +"ui\common\dialogs\score-overlay\score_brd_vert_r.dds" , "24" , "24" , "dds" +"ui\common\dialogs\score-overlay\tank_bmp.dds" , "40" , "20" , "dds" +"ui\common\dialogs\score-uef\back_brd_horz_lm.dds" , "880" , "120" , "dds" +"ui\common\dialogs\score-uef\back_brd_horz_lml.dds" , "8" , "88" , "dds" +"ui\common\dialogs\score-uef\back_brd_horz_lmr.dds" , "8" , "112" , "dds" +"ui\common\dialogs\score-uef\back_brd_horz_um.dds" , "880" , "76" , "dds" +"ui\common\dialogs\score-uef\back_brd_horz_uml.dds" , "8" , "60" , "dds" +"ui\common\dialogs\score-uef\back_brd_horz_umr.dds" , "8" , "60" , "dds" +"ui\common\dialogs\score-uef\back_brd_ll.dds" , "312" , "120" , "dds" +"ui\common\dialogs\score-uef\back_brd_lr.dds" , "704" , "120" , "dds" +"ui\common\dialogs\score-uef\back_brd_ul.dds" , "64" , "92" , "dds" +"ui\common\dialogs\score-uef\back_brd_ur.dds" , "64" , "92" , "dds" +"ui\common\dialogs\score-uef\background_bmp.dds" , "1024" , "768" , "dds" +"ui\common\dialogs\score-uef\icon_experimental.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-uef\icon-air_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-uef\icon-cdr_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-uef\icon-energy_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-uef\icon-kill-death_bmp.dds" , "24" , "24" , "dds" +"ui\common\dialogs\score-uef\icon-kills_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-uef\icon-land_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-uef\icon-loss_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-uef\icon-mass_bmp.dds" , "20" , "20" , "dds" +"ui\common\dialogs\score-uef\icon-naval_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-uef\icon-structure_bmp.dds" , "28" , "28" , "dds" +"ui\common\dialogs\score-uef\panels_bmp.dds" , "964" , "628" , "dds" +"ui\common\dialogs\score-uef\text-box_bmp_b.dds" , "628" , "16" , "dds" +"ui\common\dialogs\score-uef\text-box_bmp_m.dds" , "628" , "8" , "dds" +"ui\common\dialogs\score-uef\text-box_bmp_T.dds" , "628" , "36" , "dds" +"ui\common\dialogs\score-uef\text-box_bmp.dds" , "636" , "240" , "dds" +"ui\common\dialogs\score-victory-defeat-border\back_brd_horz_lml.dds" , "8" , "84" , "dds" +"ui\common\dialogs\score-victory-defeat-border\back_brd_horz_um.dds" , "928" , "68" , "dds" +"ui\common\dialogs\score-victory-defeat-border\back_brd_horz_uml.dds" , "8" , "56" , "dds" +"ui\common\dialogs\score-victory-defeat-border\back_brd_horz_umr.dds" , "8" , "56" , "dds" +"ui\common\dialogs\score-victory-defeat-border\back_brd_ll.dds" , "372" , "140" , "dds" +"ui\common\dialogs\score-victory-defeat-border\back_brd_lr.dds" , "644" , "140" , "dds" +"ui\common\dialogs\score-victory-defeat-border\back_brd_ul.dds" , "40" , "120" , "dds" +"ui\common\dialogs\score-victory-defeat-border\back_brd_ur.dds" , "40" , "120" , "dds" +"ui\common\dialogs\score-victory-defeat-border\background_bmp.dds" , "1024" , "768" , "dds" +"ui\common\dialogs\score-victory-defeat\histograph-back_bmp.dds" , "976" , "336" , "dds" +"ui\common\dialogs\score-victory-defeat\panel_bmp.dds" , "1024" , "608" , "dds" +"ui\common\dialogs\score-victory-defeat\player-back_bmp.dds" , "956" , "36" , "dds" +"ui\common\dialogs\score-victory-defeat\player-back02_bmp.dds" , "264" , "220" , "dds" +"ui\common\dialogs\score-victory-defeat\rect_bmp.dds" , "84" , "36" , "dds" +"ui\common\dialogs\score-victory-defeat\totals-back_bmp.dds" , "392" , "48" , "dds" +"ui\common\dialogs\slider_btn\energy-bar-edge_btn_dis.dds" , "64" , "84" , "dds" +"ui\common\dialogs\slider_btn\energy-bar-edge_btn_down.dds" , "64" , "84" , "dds" +"ui\common\dialogs\slider_btn\energy-bar-edge_btn_over.dds" , "64" , "84" , "dds" +"ui\common\dialogs\slider_btn\energy-bar-edge_btn_up.dds" , "16" , "56" , "dds" +"ui\common\dialogs\slider_btn\mass-bar-edge_btn_dis.dds" , "64" , "84" , "dds" +"ui\common\dialogs\slider_btn\mass-bar-edge_btn_down.dds" , "64" , "84" , "dds" +"ui\common\dialogs\slider_btn\mass-bar-edge_btn_over.dds" , "64" , "84" , "dds" +"ui\common\dialogs\slider_btn\mass-bar-edge_btn_up.dds" , "16" , "56" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_dis_l.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_dis_m.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_dis_r.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_down_l.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_down_m.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_down_r.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_over_l.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_over_m.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_over_r.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_up_l.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_up_m.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort_btn_up_r.dds" , "12" , "24" , "dds" +"ui\common\dialogs\sort_btn\sort-arrow-down_bmp.dds" , "24" , "16" , "dds" +"ui\common\dialogs\sort_btn\sort-arrow-up_bmp.dds" , "24" , "16" , "dds" +"ui\common\dialogs\standard_btn\standard_btn_dis.dds" , "112" , "48" , "dds" +"ui\common\dialogs\standard_btn\standard_btn_down.dds" , "112" , "48" , "dds" +"ui\common\dialogs\standard_btn\standard_btn_over.dds" , "112" , "48" , "dds" +"ui\common\dialogs\standard_btn\standard_btn_up.dds" , "112" , "48" , "dds" +"ui\common\dialogs\standard-small_btn\standard-small_btn_dis.dds" , "96" , "32" , "dds" +"ui\common\dialogs\standard-small_btn\standard-small_btn_down.dds" , "96" , "32" , "dds" +"ui\common\dialogs\standard-small_btn\standard-small_btn_over.dds" , "96" , "32" , "dds" +"ui\common\dialogs\standard-small_btn\standard-small_btn_up.dds" , "96" , "32" , "dds" +"ui\common\dialogs\sub-tab_btn\sub-tab_btn_dis.dds" , "120" , "28" , "dds" +"ui\common\dialogs\sub-tab_btn\sub-tab_btn_down.dds" , "120" , "28" , "dds" +"ui\common\dialogs\sub-tab_btn\sub-tab_btn_over.dds" , "120" , "28" , "dds" +"ui\common\dialogs\sub-tab_btn\sub-tab_btn_selected.dds" , "120" , "28" , "dds" +"ui\common\dialogs\sub-tab_btn\sub-tab_btn_up.dds" , "120" , "28" , "dds" +"ui\common\dialogs\tab_btn\campaign_btn_dis.dds" , "120" , "48" , "dds" +"ui\common\dialogs\tab_btn\campaign_btn_down.dds" , "120" , "48" , "dds" +"ui\common\dialogs\tab_btn\campaign_btn_over.dds" , "120" , "48" , "dds" +"ui\common\dialogs\tab_btn\campaign_btn_selected.dds" , "120" , "48" , "dds" +"ui\common\dialogs\tab_btn\campaign_btn_up.dds" , "120" , "48" , "dds" +"ui\common\dialogs\tab_btn\general_btn_dis.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\general_btn_down.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\general_btn_over.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\general_btn_selected.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\general_btn_up.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\resources_btn_dis.dds" , "148" , "32" , "dds" +"ui\common\dialogs\tab_btn\resources_btn_down.dds" , "148" , "32" , "dds" +"ui\common\dialogs\tab_btn\resources_btn_over.dds" , "148" , "32" , "dds" +"ui\common\dialogs\tab_btn\resources_btn_selected.dds" , "148" , "32" , "dds" +"ui\common\dialogs\tab_btn\resources_btn_up.dds" , "148" , "32" , "dds" +"ui\common\dialogs\tab_btn\tab_btn_dis.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\tab_btn_down.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\tab_btn_over.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\tab_btn_selected.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\tab_btn_up.dds" , "148" , "40" , "dds" +"ui\common\dialogs\tab_btn\units_btn_dis.dds" , "120" , "48" , "dds" +"ui\common\dialogs\tab_btn\units_btn_down.dds" , "120" , "48" , "dds" +"ui\common\dialogs\tab_btn\units_btn_over.dds" , "120" , "48" , "dds" +"ui\common\dialogs\tab_btn\units_btn_selected.dds" , "120" , "48" , "dds" +"ui\common\dialogs\tab_btn\units_btn_up.dds" , "120" , "48" , "dds" +"ui\common\dialogs\time-units-tabs\energy_bmp.dds" , "12" , "24" , "dds" +"ui\common\dialogs\time-units-tabs\panel-time_bmp.dds" , "208" , "48" , "dds" +"ui\common\dialogs\time-units-tabs\panel-tracking_bmp_d.dds" , "12" , "44" , "dds" +"ui\common\dialogs\time-units-tabs\panel-tracking_bmp_l.dds" , "24" , "44" , "dds" +"ui\common\dialogs\time-units-tabs\panel-tracking_bmp_m.dds" , "4" , "44" , "dds" +"ui\common\dialogs\time-units-tabs\panel-tracking_bmp_r.dds" , "24" , "44" , "dds" +"ui\common\dialogs\time-units-tabs\panel-tracking_bmp.dds" , "120" , "48" , "dds" +"ui\common\dialogs\time-units-tabs\panel-units_bmp.dds" , "208" , "48" , "dds" +"ui\common\dialogs\time-units-tabs\tracking-icon_bmp.dds" , "24" , "20" , "dds" +"ui\common\dialogs\toggle_btn\toggle-d_btn_dis.dds" , "84" , "28" , "dds" +"ui\common\dialogs\toggle_btn\toggle-d_btn_down.dds" , "84" , "28" , "dds" +"ui\common\dialogs\toggle_btn\toggle-d_btn_over.dds" , "84" , "28" , "dds" +"ui\common\dialogs\toggle_btn\toggle-d_btn_up.dds" , "84" , "28" , "dds" +"ui\common\dialogs\toggle_btn\toggle-s_btn_dis.dds" , "84" , "28" , "dds" +"ui\common\dialogs\toggle_btn\toggle-s_btn_down.dds" , "84" , "28" , "dds" +"ui\common\dialogs\toggle_btn\toggle-s_btn_over.dds" , "84" , "28" , "dds" +"ui\common\dialogs\toggle_btn\toggle-s_btn_up.dds" , "84" , "28" , "dds" +"ui\common\dialogs\transmision-log\panel_bmp.dds" , "888" , "532" , "dds" +"ui\common\dialogs\transmission-incoming\panel_bmp_b.dds" , "404" , "24" , "dds" +"ui\common\dialogs\transmission-incoming\panel_bmp_l.dds" , "136" , "136" , "dds" +"ui\common\dialogs\transmission-incoming\panel_bmp_m.dds" , "404" , "20" , "dds" +"ui\common\dialogs\transmission-incoming\panel_bmp_t.dds" , "404" , "40" , "dds" +"ui\common\dialogs\transmission-incoming\panel_bmp.dds" , "356" , "120" , "dds" +"ui\common\dialogs\transmission-incoming\panel-white_bmp.dds" , "356" , "120" , "dds" +"ui\common\dialogs\unit-naming\unit-name_bmp.dds" , "340" , "88" , "dds" +"ui\common\dialogs\zoom_btn\zoom_btn_dis.dds" , "24" , "24" , "dds" +"ui\common\dialogs\zoom_btn\zoom_btn_down.dds" , "24" , "24" , "dds" +"ui\common\dialogs\zoom_btn\zoom_btn_over.dds" , "24" , "24" , "dds" +"ui\common\dialogs\zoom_btn\zoom_btn_up.dds" , "24" , "24" , "dds" +"ui\common\faction_icon-lg\aeon_ico.dds" , "60" , "60" , "dds" +"ui\common\faction_icon-lg\cybran_ico.dds" , "60" , "60" , "dds" +"ui\common\faction_icon-lg\seraphim_ico.dds" , "60" , "60" , "dds" +"ui\common\faction_icon-lg\uef_ico.dds" , "60" , "60" , "dds" +"ui\common\faction_icon-sm\aeon_ico.dds" , "24" , "24" , "dds" +"ui\common\faction_icon-sm\cybran_ico.dds" , "24" , "24" , "dds" +"ui\common\faction_icon-sm\random_ico.dds" , "24" , "24" , "dds" +"ui\common\faction_icon-sm\seraphim_ico.dds" , "24" , "24" , "dds" +"ui\common\faction_icon-sm\uef_ico.dds" , "24" , "24" , "dds" +"ui\common\filters\strategic-filters\filter_defense-dashed.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter_defense.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter_economy-dashed.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter_economy.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter_intel-dashed.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter_intel.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter_military-dashed.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter_military.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter-dashed -black.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter-dashed.dds" , "8" , "8" , "dds" +"ui\common\filters\strategic-filters\filter-solid-black.dds" , "16" , "16" , "dds" +"ui\common\filters\strategic-filters\filter-solid.dds" , "16" , "8" , "dds" +"ui\common\game\aeon-enhancements\aes_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\aes_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\aes_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\aes_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\cba_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\cba_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\cba_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\cba_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\cd_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\cd_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\cd_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\cd_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ees_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ees_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ees_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ees_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\efm_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\efm_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\efm_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\efm_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\eras_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\eras_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\eras_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\eras_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ess_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ess_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ess_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ess_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\hsa_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\hsa_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\hsa_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\hsa_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\htsg_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\htsg_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\htsg_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\htsg_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\phtsg_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\phtsg_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\phtsg_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\phtsg_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\pqt_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\pqt_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\pqt_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\pqt_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ptsg_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ptsg_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ptsg_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ptsg_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ras_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ras_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ras_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ras_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\sic_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\sic_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\sic_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\sic_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\sp_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\sp_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\sp_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\sp_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ss_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ss_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ss_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\ss_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\tsg_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\tsg_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\tsg_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\aeon-enhancements\tsg_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\AreaTargetDecal\nuke_icon_small.dds" , "400" , "400" , "dds" +"ui\common\game\AreaTargetDecal\weapon_icon_small.dds" , "128" , "128" , "dds" +"ui\common\game\avatar-tab-btn\tab-close_btn_dis.dds" , "24" , "44" , "dds" +"ui\common\game\avatar-tab-btn\tab-close_btn_down.dds" , "24" , "44" , "dds" +"ui\common\game\avatar-tab-btn\tab-close_btn_over.dds" , "24" , "44" , "dds" +"ui\common\game\avatar-tab-btn\tab-close_btn_up.dds" , "24" , "44" , "dds" +"ui\common\game\avatar-tab-btn\tab-open_btn_dis.dds" , "24" , "44" , "dds" +"ui\common\game\avatar-tab-btn\tab-open_btn_down.dds" , "24" , "44" , "dds" +"ui\common\game\avatar-tab-btn\tab-open_btn_over.dds" , "24" , "44" , "dds" +"ui\common\game\avatar-tab-btn\tab-open_btn_up.dds" , "24" , "44" , "dds" +"ui\common\game\avatar\avatar_bmp.dds" , "76" , "88" , "dds" +"ui\common\game\avatar\health-bar-back_bmp.dds" , "64" , "16" , "dds" +"ui\common\game\avatar\health-bar-green.dds" , "56" , "12" , "dds" +"ui\common\game\avatar\health-bar-red.dds" , "56" , "8" , "dds" +"ui\common\game\avatar\health-bar-yellow.dds" , "56" , "8" , "dds" +"ui\common\game\avatar\health-bar.dds" , "56" , "12" , "dds" +"ui\common\game\avatar\pulse-bars_bmp.dds" , "80" , "80" , "dds" +"ui\common\game\avatar\tech-tab_bmp.dds" , "32" , "28" , "dds" +"ui\common\game\beacons\beacon-aeon-transport_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\beacons\beacon-quantum-gate_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\border_bottom\back_brd_b-os.dds" , "4" , "12" , "dds" +"ui\common\game\border_bottom\back_brd_b.dds" , "4" , "216" , "dds" +"ui\common\game\border_bottom\back_brd_bl.dds" , "48" , "48" , "dds" +"ui\common\game\border_bottom\back_brd_bl02.dds" , "492" , "96" , "dds" +"ui\common\game\border_bottom\back_brd_br.dds" , "48" , "48" , "dds" +"ui\common\game\border_bottom\back_brd_l-os.dds" , "8" , "4" , "dds" +"ui\common\game\border_bottom\back_brd_l.dds" , "4" , "4" , "dds" +"ui\common\game\border_bottom\back_brd_r-os.dds" , "8" , "4" , "dds" +"ui\common\game\border_bottom\back_brd_r.dds" , "4" , "4" , "dds" +"ui\common\game\border_bottom\back_brd_t-os.dds" , "4" , "8" , "dds" +"ui\common\game\border_bottom\back_brd_t.dds" , "4" , "44" , "dds" +"ui\common\game\border_bottom\back_brd_tl.dds" , "48" , "48" , "dds" +"ui\common\game\border_bottom\back_brd_tr.dds" , "48" , "48" , "dds" +"ui\common\game\border_left\back_brd_b-os.dds" , "4" , "8" , "dds" +"ui\common\game\border_left\back_brd_b.dds" , "4" , "4" , "dds" +"ui\common\game\border_left\back_brd_bl.dds" , "48" , "48" , "dds" +"ui\common\game\border_left\back_brd_br.dds" , "48" , "48" , "dds" +"ui\common\game\border_left\back_brd_l-os.dds" , "8" , "4" , "dds" +"ui\common\game\border_left\back_brd_l.dds" , "244" , "4" , "dds" +"ui\common\game\border_left\back_brd_r-os.dds" , "8" , "4" , "dds" +"ui\common\game\border_left\back_brd_r.dds" , "4" , "4" , "dds" +"ui\common\game\border_left\back_brd_t-os.dds" , "4" , "8" , "dds" +"ui\common\game\border_left\back_brd_t.dds" , "4" , "44" , "dds" +"ui\common\game\border_left\back_brd_tl.dds" , "48" , "48" , "dds" +"ui\common\game\border_left\back_brd_tr.dds" , "48" , "48" , "dds" +"ui\common\game\border_right\back_brd_b-os.dds" , "4" , "8" , "dds" +"ui\common\game\border_right\back_brd_b.dds" , "4" , "4" , "dds" +"ui\common\game\border_right\back_brd_bl.dds" , "48" , "48" , "dds" +"ui\common\game\border_right\back_brd_br.dds" , "48" , "48" , "dds" +"ui\common\game\border_right\back_brd_l-os.dds" , "8" , "4" , "dds" +"ui\common\game\border_right\back_brd_l.dds" , "4" , "4" , "dds" +"ui\common\game\border_right\back_brd_r-os.dds" , "8" , "4" , "dds" +"ui\common\game\border_right\back_brd_r.dds" , "244" , "4" , "dds" +"ui\common\game\border_right\back_brd_t-os.dds" , "4" , "8" , "dds" +"ui\common\game\border_right\back_brd_t.dds" , "4" , "44" , "dds" +"ui\common\game\border_right\back_brd_tl.dds" , "48" , "48" , "dds" +"ui\common\game\border_right\back_brd_tr.dds" , "48" , "48" , "dds" +"ui\common\game\build-ui-02\abilities_bmp_b.dds" , "136" , "12" , "dds" +"ui\common\game\build-ui-02\abilities_bmp_m.dds" , "136" , "12" , "dds" +"ui\common\game\build-ui-02\abilities_bmp_t.dds" , "136" , "36" , "dds" +"ui\common\game\build-ui-02\abilities-panel_bmp.dds" , "144" , "92" , "dds" +"ui\common\game\build-ui-02\bar01_bmp.dds" , "76" , "8" , "dds" +"ui\common\game\build-ui-02\build-over_bmp_l.dds" , "284" , "96" , "dds" +"ui\common\game\build-ui-02\build-over_bmp_m.dds" , "12" , "96" , "dds" +"ui\common\game\build-ui-02\build-over_bmp_r.dds" , "200" , "96" , "dds" +"ui\common\game\build-ui-02\build-over_bmp.dds" , "492" , "100" , "dds" +"ui\common\game\build-ui-02\build-over-abilities_bmp _m.dds" , "408" , "8" , "dds" +"ui\common\game\build-ui-02\build-over-abilities_bmp _t.dds" , "408" , "24" , "dds" +"ui\common\game\build-ui-02\build-over-abilities_bmp_b.dds" , "408" , "12" , "dds" +"ui\common\game\build-ui-02\build-over-abilities_bmp.dds" , "416" , "64" , "dds" +"ui\common\game\build-ui-02\description_bmp_b.dds" , "344" , "40" , "dds" +"ui\common\game\build-ui-02\description_bmp_m.dds" , "344" , "12" , "dds" +"ui\common\game\build-ui-02\description_bmp_t.dds" , "344" , "36" , "dds" +"ui\common\game\build-ui-02\description-panel_bmp.dds" , "352" , "92" , "dds" +"ui\common\game\build-ui-02\health-bars-back-1_bmp.dds" , "76" , "8" , "dds" +"ui\common\game\build-ui-02\icon-clock_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\build-ui-02\icon-energy_bmp.dds" , "20" , "20" , "dds" +"ui\common\game\build-ui-02\icon-health_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\build-ui-02\icon-mass_bmp.dds" , "20" , "20" , "dds" +"ui\common\game\build-ui\abilities_bmp_b.dds" , "136" , "12" , "dds" +"ui\common\game\build-ui\abilities_bmp_l.dds" , "44" , "88" , "dds" +"ui\common\game\build-ui\abilities_bmp_m.dds" , "136" , "12" , "dds" +"ui\common\game\build-ui\abilities_bmp_r.dds" , "80" , "88" , "dds" +"ui\common\game\build-ui\abilities_bmp_t.dds" , "136" , "36" , "dds" +"ui\common\game\build-ui\abilities-panel_bmp.dds" , "144" , "92" , "dds" +"ui\common\game\build-ui\bar01_bmp.dds" , "76" , "8" , "dds" +"ui\common\game\build-ui\build-over_bmp_l.dds" , "280" , "88" , "dds" +"ui\common\game\build-ui\build-over_bmp_m.dds" , "12" , "88" , "dds" +"ui\common\game\build-ui\build-over_bmp_r.dds" , "132" , "88" , "dds" +"ui\common\game\build-ui\build-over_bmp.dds" , "428" , "96" , "dds" +"ui\common\game\build-ui\description_bmp_b.dds" , "344" , "12" , "dds" +"ui\common\game\build-ui\description_bmp_m.dds" , "344" , "12" , "dds" +"ui\common\game\build-ui\description_bmp_t.dds" , "344" , "36" , "dds" +"ui\common\game\build-ui\description-panel_bmp.dds" , "352" , "92" , "dds" +"ui\common\game\build-ui\health-bars-back-1_bmp.dds" , "76" , "8" , "dds" +"ui\common\game\build-ui\icon-clock_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\build-ui\icon-energy_bmp.dds" , "20" , "20" , "dds" +"ui\common\game\build-ui\icon-health_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\build-ui\icon-mass_bmp.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd\chat_brd_horz_um.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd\chat_brd_ll.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd\chat_brd_lm.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd\chat_brd_lr.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd\chat_brd_m.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd\chat_brd_ul.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd\chat_brd_ur.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd\chat_brd_vert_l.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd\chat_brd_vert_r.dds" , "20" , "20" , "dds" +"ui\common\game\chat_brd05\chat_brd_horz_um.dds" , "12" , "44" , "dds" +"ui\common\game\chat_brd05\chat_brd_ll.dds" , "120" , "68" , "dds" +"ui\common\game\chat_brd05\chat_brd_lm.dds" , "12" , "68" , "dds" +"ui\common\game\chat_brd05\chat_brd_lr.dds" , "44" , "68" , "dds" +"ui\common\game\chat_brd05\chat_brd_m.dds" , "12" , "12" , "dds" +"ui\common\game\chat_brd05\chat_brd_ul.dds" , "44" , "44" , "dds" +"ui\common\game\chat_brd05\chat_brd_ur.dds" , "44" , "44" , "dds" +"ui\common\game\chat_brd05\chat_brd_vert_l.dds" , "44" , "12" , "dds" +"ui\common\game\chat_brd05\chat_brd_vert_r.dds" , "44" , "12" , "dds" +"ui\common\game\cursors\attack_coordinated.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-10.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-11.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-coordinated-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-coordinated.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\attack08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\capture.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-10.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-11.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-12.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-13.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\construct02-14.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\ferry-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\ferry.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-10.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\guard.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\launch-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\launch.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-10.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-11.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-12.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-13.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-14.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\load.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-10.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\message-11.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move_window.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-10.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-11.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-12.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\move.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\n_s.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\ne_sw.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\nw_se.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\overcharge-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\overcharge-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\overcharge-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\overcharge-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\overcharge-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\overcharge-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\overcharge-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\overcharge-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\patrol-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\patrol-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\patrol-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\patrol-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\patrol-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\patrol-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\patrol.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-10.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-11.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-12.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-13.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-14.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-15.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-16.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-17.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-18.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-19.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-20.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-21.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-22.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\reclaim02-23.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\repair-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\repair-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\repair-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\repair-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\repair-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\repair-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\repair-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\repair-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\repair.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-10.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-11.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-12.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice-13.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\sacrifice.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\selectable-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\selectable-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\selectable-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\selectable-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\selectable-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\selectable-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\selectable-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\selectable-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\selectable.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\transport-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\transport.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-01.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-02.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-03.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-04.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-05.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-06.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-07.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-08.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-09.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-10.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-11.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-12.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-13.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-14.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload-invalid.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\unload.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\w_e.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\waypoint-drag.dds" , "32" , "32" , "dds" +"ui\common\game\cursors\waypoint-hover.dds" , "32" , "32" , "dds" +"ui\common\game\cybran-enhancements\acu_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\acu_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\acu_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\acu_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\aes_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\aes_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\aes_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\aes_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\cfs_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\cfs_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\cfs_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\cfs_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ees_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ees_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ees_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ees_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\emp_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\emp_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\emp_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\emp_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\eras_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\eras_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\eras_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\eras_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\fc_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\fc_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\fc_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\fc_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\mlg_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\mlg_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\mlg_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\mlg_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\nms_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\nms_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\nms_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\nms_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ntt_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ntt_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ntt_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ntt_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\pcg_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\pcg_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\pcg_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\pcg_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\pqt_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\pqt_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\pqt_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\pqt_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\psg_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\psg_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\psg_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\psg_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ras_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ras_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ras_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ras_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ses_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ses_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ses_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\ses_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\sfs_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\sfs_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\sfs_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\sfs_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\srs_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\srs_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\srs_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\cybran-enhancements\srs_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\economic-overlay\econ_bmp_l.dds" , "24" , "32" , "dds" +"ui\common\game\economic-overlay\econ_bmp_m.dds" , "4" , "32" , "dds" +"ui\common\game\economic-overlay\econ_bmp_r.dds" , "12" , "32" , "dds" +"ui\common\game\generic_brd\generic_brd_horz_lm.dds" , "8" , "8" , "dds" +"ui\common\game\generic_brd\generic_brd_horz_um.dds" , "8" , "8" , "dds" +"ui\common\game\generic_brd\generic_brd_ll.dds" , "8" , "8" , "dds" +"ui\common\game\generic_brd\generic_brd_lr.dds" , "8" , "8" , "dds" +"ui\common\game\generic_brd\generic_brd_m.dds" , "8" , "8" , "dds" +"ui\common\game\generic_brd\generic_brd_ul.dds" , "8" , "8" , "dds" +"ui\common\game\generic_brd\generic_brd_ur.dds" , "8" , "8" , "dds" +"ui\common\game\generic_brd\generic_brd_vert_l.dds" , "8" , "8" , "dds" +"ui\common\game\generic_brd\generic_brd_vert_r.dds" , "8" , "8" , "dds" +"ui\common\game\icon_tech-level\tech-level_open.dds" , "8" , "8" , "dds" +"ui\common\game\icon_tech-level\tech-level_solid.dds" , "8" , "8" , "dds" +"ui\common\game\icons\icon-trash_btn_dis.dds" , "12" , "12" , "dds" +"ui\common\game\icons\icon-trash_btn_dis.png" , "12" , "12" , "png" +"ui\common\game\icons\icon-trash_btn_down.dds" , "16" , "16" , "dds" +"ui\common\game\icons\icon-trash_btn_down.png" , "16" , "16" , "png" +"ui\common\game\icons\icon-trash_btn_over.dds" , "20" , "24" , "dds" +"ui\common\game\icons\icon-trash_btn_over.png" , "20" , "24" , "png" +"ui\common\game\icons\icon-trash_btn_up.dds" , "20" , "20" , "dds" +"ui\common\game\icons\icon-trash_btn_up.png" , "20" , "20" , "png" +"ui\common\game\icons\icon-trash-lg_btn_dis.dds" , "44" , "56" , "dds" +"ui\common\game\icons\icon-trash-lg_btn_dis.png" , "44" , "56" , "png" +"ui\common\game\icons\icon-trash-lg_btn_down.dds" , "56" , "64" , "dds" +"ui\common\game\icons\icon-trash-lg_btn_down.png" , "56" , "64" , "png" +"ui\common\game\icons\icon-trash-lg_btn_over.dds" , "56" , "64" , "dds" +"ui\common\game\icons\icon-trash-lg_btn_over.png" , "56" , "64" , "png" +"ui\common\game\icons\icon-trash-lg_btn_up.dds" , "52" , "64" , "dds" +"ui\common\game\icons\icon-trash-lg_btn_up.png" , "52" , "64" , "png" +"ui\common\game\idle_mini_icon\idle_icon.dds" , "28" , "24" , "dds" +"ui\common\game\infinite_btn\infinite_btn_dis.dds" , "32" , "36" , "dds" +"ui\common\game\infinite_btn\infinite_btn_down.dds" , "32" , "36" , "dds" +"ui\common\game\infinite_btn\infinite_btn_over.dds" , "32" , "36" , "dds" +"ui\common\game\infinite_btn\infinite_btn_up.dds" , "32" , "36" , "dds" +"ui\common\game\large-side_scr\arrow-left_scr_dis.dds" , "64" , "64" , "dds" +"ui\common\game\large-side_scr\arrow-left_scr_down.dds" , "64" , "64" , "dds" +"ui\common\game\large-side_scr\arrow-left_scr_over.dds" , "64" , "64" , "dds" +"ui\common\game\large-side_scr\arrow-left_scr_up.dds" , "64" , "64" , "dds" +"ui\common\game\large-side_scr\arrow-right_scr_dis.dds" , "64" , "64" , "dds" +"ui\common\game\large-side_scr\arrow-right_scr_down.dds" , "64" , "64" , "dds" +"ui\common\game\large-side_scr\arrow-right_scr_over.dds" , "64" , "64" , "dds" +"ui\common\game\large-side_scr\arrow-right_scr_up.dds" , "64" , "64" , "dds" +"ui\common\game\lg-vert_scr\arrow-down_scr_dis.dds" , "36" , "36" , "dds" +"ui\common\game\lg-vert_scr\arrow-down_scr_down.dds" , "36" , "36" , "dds" +"ui\common\game\lg-vert_scr\arrow-down_scr_over.dds" , "36" , "36" , "dds" +"ui\common\game\lg-vert_scr\arrow-down_scr_up.dds" , "36" , "36" , "dds" +"ui\common\game\lg-vert_scr\arrow-up_scr_dis.dds" , "36" , "36" , "dds" +"ui\common\game\lg-vert_scr\arrow-up_scr_down.dds" , "36" , "36" , "dds" +"ui\common\game\lg-vert_scr\arrow-up_scr_over.dds" , "36" , "36" , "dds" +"ui\common\game\lg-vert_scr\arrow-up_scr_up.dds" , "36" , "36" , "dds" +"ui\common\game\marker\ring_blue02-blur.dds" , "200" , "200" , "dds" +"ui\common\game\marker\ring_blue02.dds" , "200" , "200" , "dds" +"ui\common\game\marker\ring_red02-blur.dds" , "200" , "200" , "dds" +"ui\common\game\marker\ring_red02.dds" , "200" , "200" , "dds" +"ui\common\game\marker\ring_yellow02-blur.dds" , "200" , "200" , "dds" +"ui\common\game\marker\ring_yellow02.dds" , "200" , "200" , "dds" +"ui\common\game\mfd_bmp\mfd_bmp.dds" , "220" , "220" , "dds" +"ui\common\game\mfd_bmp\mfd_button_bmp.dds" , "68" , "220" , "dds" +"ui\common\game\mfd_bmp\mfd_glow_bmp.dds" , "160" , "164" , "dds" +"ui\common\game\mfd_bmp\mfd_glow.dds" , "224" , "220" , "dds" +"ui\common\game\mfd_btn\all-airforce_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-airforce_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-airforce_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-airforce_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-army_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-army_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-army_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-army_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-navy_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-navy_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-navy_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\all-navy_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\control_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\control_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\control_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\control_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\defenses_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\defenses_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\defenses_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\defenses_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\economy_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\economy_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\economy_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\economy_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\idle-engineer_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\idle-engineer_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\idle-engineer_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\idle-engineer_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\idle-factory_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\idle-factory_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\idle-factory_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\idle-factory_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\intelligence_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\intelligence_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\intelligence_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\intelligence_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\mfd_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\mfd_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\mfd_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\mfd_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\military_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\military_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\military_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\military_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-alert_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-alert_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-alert_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-alert_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-attack_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-attack_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-attack_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-attack_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-marker_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-marker_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-marker_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-marker_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-move_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-move_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-move_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\ping-move_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\team-color_btn_dis.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\team-color_btn_down.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\team-color_btn_over.dds" , "76" , "48" , "dds" +"ui\common\game\mfd_btn\team-color_btn_up.dds" , "76" , "48" , "dds" +"ui\common\game\mfd-vertical_bmp\mfd_bmp.dds" , "244" , "220" , "dds" +"ui\common\game\mfd-vertical_bmp\mfd_button_bmp.dds" , "244" , "56" , "dds" +"ui\common\game\mfd-vertical_bmp\mfd_glow.dds" , "224" , "224" , "dds" +"ui\common\game\min-control-tab-btn\tab-close_btn_dis.dds" , "44" , "24" , "dds" +"ui\common\game\min-control-tab-btn\tab-close_btn_down.dds" , "44" , "24" , "dds" +"ui\common\game\min-control-tab-btn\tab-close_btn_over.dds" , "44" , "24" , "dds" +"ui\common\game\min-control-tab-btn\tab-close_btn_up.dds" , "44" , "24" , "dds" +"ui\common\game\min-control-tab-btn\tab-open_btn_dis.dds" , "44" , "24" , "dds" +"ui\common\game\min-control-tab-btn\tab-open_btn_down.dds" , "44" , "24" , "dds" +"ui\common\game\min-control-tab-btn\tab-open_btn_over.dds" , "44" , "24" , "dds" +"ui\common\game\min-control-tab-btn\tab-open_btn_up.dds" , "44" , "24" , "dds" +"ui\common\game\mini-filter-back_bmp\min-filter-back_bmp.dds" , "188" , "32" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_horz_um.dds" , "12" , "36" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_horz_um02.dds" , "12" , "40" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_ll.dds" , "16" , "16" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_lm.dds" , "12" , "16" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_lr.dds" , "16" , "16" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_m.dds" , "12" , "12" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_ul.dds" , "36" , "36" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_ul02.dds" , "40" , "40" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_ur.dds" , "36" , "36" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_ur02.dds" , "80" , "40" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_vert_l.dds" , "16" , "12" , "dds" +"ui\common\game\mini-map-brd01\mini-map_brd_vert_r.dds" , "16" , "12" , "dds" +"ui\common\game\mini-map-brd01\mini-map-glow_bmp.dds" , "208" , "208" , "dds" +"ui\common\game\mini-map-glow-brd\mini-map-glow_brd_horz_um.dds" , "8" , "24" , "dds" +"ui\common\game\mini-map-glow-brd\mini-map-glow_brd_ll.dds" , "40" , "40" , "dds" +"ui\common\game\mini-map-glow-brd\mini-map-glow_brd_lm.dds" , "8" , "24" , "dds" +"ui\common\game\mini-map-glow-brd\mini-map-glow_brd_lr.dds" , "40" , "40" , "dds" +"ui\common\game\mini-map-glow-brd\mini-map-glow_brd_ul.dds" , "40" , "40" , "dds" +"ui\common\game\mini-map-glow-brd\mini-map-glow_brd_ur.dds" , "40" , "40" , "dds" +"ui\common\game\mini-map-glow-brd\mini-map-glow_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\common\game\mini-map-glow-brd\mini-map-glow_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\common\game\mini-options-control-bar\mini-options-control-bar_bmp.dds" , "288" , "24" , "dds" +"ui\common\game\mini-resource-back_bmp\center_bmp_l.dds" , "156" , "52" , "dds" +"ui\common\game\mini-resource-back_bmp\center_bmp_m.dds" , "8" , "52" , "dds" +"ui\common\game\mini-resource-back_bmp\center_bmp_r.dds" , "156" , "52" , "dds" +"ui\common\game\mini-scroll\arrow-left_scr_dis.dds" , "24" , "48" , "dds" +"ui\common\game\mini-scroll\arrow-left_scr_down.dds" , "24" , "48" , "dds" +"ui\common\game\mini-scroll\arrow-left_scr_over.dds" , "24" , "48" , "dds" +"ui\common\game\mini-scroll\arrow-left_scr_up.dds" , "24" , "48" , "dds" +"ui\common\game\mini-scroll\arrow-right_scr_dis.dds" , "24" , "48" , "dds" +"ui\common\game\mini-scroll\arrow-right_scr_down.dds" , "24" , "48" , "dds" +"ui\common\game\mini-scroll\arrow-right_scr_over.dds" , "24" , "48" , "dds" +"ui\common\game\mini-scroll\arrow-right_scr_up.dds" , "24" , "48" , "dds" +"ui\common\game\mini-ui-construct-que-back\back_bmp_l.dds" , "72" , "56" , "dds" +"ui\common\game\mini-ui-construct-que-back\back_bmp_m.dds" , "8" , "56" , "dds" +"ui\common\game\mini-ui-construct-que-back\back_bmp_r.dds" , "20" , "56" , "dds" +"ui\common\game\mini-ui-construct-que-back\unit-construct-que-back_bmp.dds" , "340" , "56" , "dds" +"ui\common\game\mini-ui-orders-back\unit-over-back_bmp.dds" , "328" , "120" , "dds" +"ui\common\game\mini-ui-unit-over\build-over-back_bmp.dds" , "324" , "120" , "dds" +"ui\common\game\mini-ui-unit-over\unit-over-back_bmp.dds" , "324" , "104" , "dds" +"ui\common\game\options_brd\line-long_bmp.dds" , "200" , "4" , "dds" +"ui\common\game\options_brd\line-short_bmp.dds" , "228" , "4" , "dds" +"ui\common\game\options_brd\options_brd_horz_um.dds" , "12" , "44" , "dds" +"ui\common\game\options_brd\options_brd_ll.dds" , "44" , "44" , "dds" +"ui\common\game\options_brd\options_brd_lm.dds" , "12" , "44" , "dds" +"ui\common\game\options_brd\options_brd_lr.dds" , "44" , "44" , "dds" +"ui\common\game\options_brd\options_brd_m.dds" , "12" , "12" , "dds" +"ui\common\game\options_brd\options_brd_ul.dds" , "44" , "44" , "dds" +"ui\common\game\options_brd\options_brd_ur.dds" , "44" , "44" , "dds" +"ui\common\game\options_brd\options_brd_vert_l.dds" , "44" , "12" , "dds" +"ui\common\game\options_brd\options_brd_vert_r.dds" , "44" , "12" , "dds" +"ui\common\game\options_btn\diplomacy_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\diplomacy_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\diplomacy_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\diplomacy_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\glow_bmp.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\inbox_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\inbox_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\inbox_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\inbox_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\menu_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\menu_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\menu_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\menu_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\objectives_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\objectives_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\objectives_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\objectives_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\pause_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\pause_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\pause_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\pause_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\play_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\play_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\play_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\play_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\score_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\score_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\score_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\options_btn\score_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orderline\orderline_arrow-big.dds" , "64" , "64" , "dds" +"ui\common\game\orderline\orderline_arrow.dds" , "61" , "59" , "dds" +"ui\common\game\orderline\orderline_arrow02.dds" , "64" , "64" , "dds" +"ui\common\game\orderline\orderline_arrow03.dds" , "32" , "32" , "dds" +"ui\common\game\orderline\orderline_arrow04.dds" , "64" , "64" , "dds" +"ui\common\game\orderline\orderline_attack.dds" , "64" , "64" , "dds" +"ui\common\game\orderline\orderline_attack02.dds" , "64" , "64" , "dds" +"ui\common\game\orderline\orderline_generic.dds" , "64" , "64" , "dds" +"ui\common\game\orderline\orderline_move.dds" , "64" , "64" , "dds" +"ui\common\game\orderline\orderline_patrol-ferry.dds" , "60" , "40" , "dds" +"ui\common\game\orderline\orderline_patrol.dds" , "64" , "64" , "dds" +"ui\common\game\orderline\orderline_standard.dds" , "60" , "64" , "dds" +"ui\common\game\orderline\orderline_teleport.dds" , "64" , "48" , "dds" +"ui\common\game\orderline\orderline.dds" , "64" , "64" , "dds" +"ui\common\game\orders_bmp\orders_bmp.dds" , "160" , "220" , "dds" +"ui\common\game\orders-vertical_bmp\orders-vertical_bmp.dds" , "244" , "168" , "dds" +"ui\common\game\orders\activate-weapon_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\activate-weapon_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\activate-weapon_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\activate-weapon_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\activate-weapon_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\activate-weapon_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\activate-weapon_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\advanced-empty_bmp.dds" , "48" , "48" , "dds" +"ui\common\game\orders\advanced-empty_btn_slot.dds" , "48" , "48" , "dds" +"ui\common\game\orders\area-assist_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\area-assist_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\area-assist_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\area-assist_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\area-assist_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\area-assist_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\attack_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\attack_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\attack_btn_down .dds" , "48" , "48" , "dds" +"ui\common\game\orders\attack_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\attack_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\attack_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\attack_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\attack_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\autocast_bmp.dds" , "48" , "48" , "dds" +"ui\common\game\orders\basic-empty_bmp.dds" , "48" , "48" , "dds" +"ui\common\game\orders\basic-empty_btn_slot.dds" , "48" , "48" , "dds" +"ui\common\game\orders\convert_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\convert_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\convert_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\convert_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\convert_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\convert_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\convert_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\deploy_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\deploy_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\deploy_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\deploy_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\deploy_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\deploy_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dive_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dive_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dive_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dive_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dive_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dive_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dive_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dock_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dock_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dock_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dock_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dock_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\dock_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\drone_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\drone_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\drone_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\drone_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\drone_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\drone_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\enhancement_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\enhancement_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\enhancement_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\enhancement_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\enhancement_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\enhancement_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\enhancement_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\ferry_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\ferry_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\ferry_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\ferry_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\ferry_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\ferry_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\ferry_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\glow-02_bmp.dds" , "48" , "48" , "dds" +"ui\common\game\orders\guard_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\guard_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\guard_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\guard_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\guard_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\guard_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\guard_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\hold-fire_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\hold-fire_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\hold-fire_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\hold-fire_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\hold-fire_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\hold-fire_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\hold-fire_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter-disabled_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter-disabled_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter-disabled_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter-disabled_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter-disabled_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter-disabled_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-counter-disabled_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-disabled_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-disabled_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-disabled_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-disabled_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-disabled_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-disabled_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\intel-disabled_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\jamming_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\jamming_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\jamming_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\jamming_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\jamming_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\jamming_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\jamming_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-nuke_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-nuke_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-nuke_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-nuke_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-nuke_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-nuke_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-nuke_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-tactical_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-tactical_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-tactical_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-tactical_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-tactical_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-tactical_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\launch-tactical_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\load_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\load_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\load_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\load_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\load_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\load_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\load_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\move_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\move_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\move_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\move_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\move_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\move_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\move_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\nuke_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\nuke_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\nuke_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\nuke_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\nuke_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\nuke_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\nuke_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\omni_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\omni_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\omni_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\omni_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\omni_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\omni_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\omni_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\overcharge_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\overcharge_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\overcharge_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\overcharge_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\overcharge_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\overcharge_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\overcharge_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\patrol_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\patrol_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\patrol_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\patrol_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\patrol_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\patrol_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\patrol_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\pause_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\pause_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\pause_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\pause_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\pause_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\pause_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\pause_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production-disabled_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production-disabled_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production-disabled_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production-disabled_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production-disabled_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production-disabled_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\production-disabled_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\radar_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\radar_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\radar_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\radar_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\radar_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\radar_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\radar_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\reclaim_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\reclaim_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\reclaim_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\reclaim_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\reclaim_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\reclaim_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\reclaim_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\repair_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\repair_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\repair_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\repair_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\repair_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\repair_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\repair_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\retaliate_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\retaliate_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\retaliate_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\retaliate_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\retaliate_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\retaliate_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\retaliate_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\return-fire_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\return-fire_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\return-fire_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\return-fire_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\return-fire_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\return-fire_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\return-fire_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sacrifice_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sacrifice_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sacrifice_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sacrifice_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sacrifice_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sacrifice_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sacrifice_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-disable_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-disable_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-disable_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-disable_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-disable_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-disable_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-disable_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-dome_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-dome_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-dome_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-dome_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-dome_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-dome_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-personal_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-personal_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-personal_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-personal_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-personal_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\shield-personal_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-nuke_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-nuke_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-nuke_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-nuke_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-nuke_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-nuke_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-nuke_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-tactical_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-tactical_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-tactical_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-tactical_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-tactical_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-tactical_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\silo-build-tactical_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-target_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-target_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-target_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-target_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-target_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-target_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-toggle_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-toggle_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-toggle_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-toggle_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-toggle_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\skry-toggle_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sonar_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sonar_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sonar_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sonar_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sonar_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sonar_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\sonar_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stand-ground_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stand-ground_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stand-ground_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stand-ground_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stand-ground_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stand-ground_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stand-ground_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-field_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-field_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-field_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-field_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-field_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-field_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-personal_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-personal_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-personal_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-personal_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-personal_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stealth-personal_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stop_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stop_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stop_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stop_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stop_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stop_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\stop_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\tactical_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\tactical_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\tactical_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\tactical_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\tactical_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\tactical_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\tactical_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\teleport_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\teleport_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\teleport_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\teleport_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\teleport_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\teleport_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\teleport_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon_btn_down_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-aa_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-aa_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-aa_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-aa_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-aa_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-aa_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-aa_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-air_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-air_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-air_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-air_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-air_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-air_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-direct_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-direct_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-direct_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-direct_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-direct_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-direct_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\toggle-weapon-direct_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload02_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload02_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload02_btn_down.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload02_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload02_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload02_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\unload02_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\orders\vision_btn_dis_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\vision_btn_dis.dds" , "48" , "48" , "dds" +"ui\common\game\orders\vision_btn_over_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\vision_btn_over.dds" , "48" , "48" , "dds" +"ui\common\game\orders\vision_btn_up_sel.dds" , "48" , "48" , "dds" +"ui\common\game\orders\vision_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\pause_btn\pause_btn_dis.dds" , "32" , "36" , "dds" +"ui\common\game\pause_btn\pause_btn_down.dds" , "32" , "36" , "dds" +"ui\common\game\pause_btn\pause_btn_over.dds" , "32" , "36" , "dds" +"ui\common\game\pause_btn\pause_btn_up.dds" , "32" , "36" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_b_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_b_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_b_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_bl_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_bl_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_bl_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_br_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_br_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_br_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_glow.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_l_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_l_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_l_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_r_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_r_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_r_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_t_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_t_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_t_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_tl_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_tl_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_tl_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_tr_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_tr_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_blue_tr_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_b_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_b_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_b_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_bl_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_bl_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_bl_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_br_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_br_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_br_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_glow.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_l_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_l_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_l_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_r_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_r_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_r_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_t_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_t_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_t_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_tl_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_tl_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_tl_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_tr_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_tr_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_red_tr_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_b_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_b_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_b_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_bl_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_bl_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_bl_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_br_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_br_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_br_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_glow.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_l_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_l_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_l_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_r_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_r_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_r_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_t_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_t_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_t_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_tl_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_tl_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_tl_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_tr_down.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_tr_over.dds" , "52" , "52" , "dds" +"ui\common\game\ping_edge\ping_edge_yellow_tr_up.dds" , "52" , "52" , "dds" +"ui\common\game\ping_marker\ping_marker-01.dds" , "20" , "20" , "dds" +"ui\common\game\ping_marker\ping_marker-02.dds" , "20" , "20" , "dds" +"ui\common\game\ping-info-panel\bg-left.dds" , "24" , "36" , "dds" +"ui\common\game\ping-info-panel\bg-mid.dds" , "32" , "40" , "dds" +"ui\common\game\ping-info-panel\bg-right.dds" , "24" , "36" , "dds" +"ui\common\game\ping-info-panel\bg-stretch.dds" , "8" , "36" , "dds" +"ui\common\game\pinggroup\border-group.dds" , "100" , "100" , "dds" +"ui\common\game\que_bmp\que_bmp_l.dds" , "80" , "80" , "dds" +"ui\common\game\que_bmp\que_bmp_m.dds" , "80" , "80" , "dds" +"ui\common\game\que_bmp\que_bmp_r.dds" , "80" , "80" , "dds" +"ui\common\game\que_bmp\que-02_bmp_l.dds" , "80" , "80" , "dds" +"ui\common\game\que_bmp\que-02_bmp_m.dds" , "80" , "80" , "dds" +"ui\common\game\que_bmp\que-02_bmp_r.dds" , "80" , "80" , "dds" +"ui\common\game\que_bmp\scroll-back_bmp.dds" , "64" , "32" , "dds" +"ui\common\game\que-vertical_bmp\que_bmp_b.dds" , "236" , "16" , "dds" +"ui\common\game\que-vertical_bmp\que_bmp_m.dds" , "236" , "16" , "dds" +"ui\common\game\que-vertical_bmp\que_bmp_t.dds" , "236" , "16" , "dds" +"ui\common\game\que-vertical_bmp\que-02_bmp_b.dds" , "236" , "16" , "dds" +"ui\common\game\que-vertical_bmp\que-02_bmp_m.dds" , "236" , "16" , "dds" +"ui\common\game\que-vertical_bmp\que-02_bmp_t.dds" , "236" , "16" , "dds" +"ui\common\game\que-vertical_bmp\scroll-back_bmp.dds" , "60" , "28" , "dds" +"ui\common\game\replay-vertical\panel_bmp.dds" , "220" , "160" , "dds" +"ui\common\game\replay\game-speed-bmp.dds" , "116" , "20" , "dds" +"ui\common\game\replay\panel_bmp.dds" , "160" , "220" , "dds" +"ui\common\game\replay\slider-ticks_bmp.dds" , "104" , "8" , "dds" +"ui\common\game\resource-mini-bars\mini-energy-bar_bmp.dds" , "76" , "16" , "dds" +"ui\common\game\resource-mini-bars\mini-energy-bar-back_bmp.dds" , "76" , "16" , "dds" +"ui\common\game\resource-mini-bars\mini-mass-bar_bmp.dds" , "76" , "16" , "dds" +"ui\common\game\resource-mini-bars\mini-mass-bar-back_bmp.dds" , "76" , "16" , "dds" +"ui\common\game\resource-tutorial\resource_tutorial_bmp.dds" , "664" , "140" , "dds" +"ui\common\game\resources\center_bmp_l.dds" , "8" , "36" , "dds" +"ui\common\game\resources\center_bmp_m.dds" , "268" , "44" , "dds" +"ui\common\game\resources\center_bmp_r.dds" , "8" , "36" , "dds" +"ui\common\game\resources\center02_bmp_l.dds" , "4" , "36" , "dds" +"ui\common\game\resources\center02_bmp_m.dds" , "276" , "48" , "dds" +"ui\common\game\resources\center02_bmp_r.dds" , "4" , "36" , "dds" +"ui\common\game\resources\energy_btn_dis.dds" , "60" , "60" , "dds" +"ui\common\game\resources\energy_btn_down.dds" , "72" , "64" , "dds" +"ui\common\game\resources\energy_btn_over.dds" , "68" , "60" , "dds" +"ui\common\game\resources\energy_btn_up.dds" , "56" , "56" , "dds" +"ui\common\game\resources\energy-advanced_bmp.dds" , "48" , "36" , "dds" +"ui\common\game\resources\energy-back_bmp.dds" , "392" , "60" , "dds" +"ui\common\game\resources\energy-bar_bmp.dds" , "192" , "12" , "dds" +"ui\common\game\resources\energy-bar-back_bmp.dds" , "192" , "12" , "dds" +"ui\common\game\resources\energy-bar-edge_bmp.dds" , "140" , "40" , "dds" +"ui\common\game\resources\mass_btn_dis.dds" , "64" , "56" , "dds" +"ui\common\game\resources\mass_btn_down.dds" , "68" , "60" , "dds" +"ui\common\game\resources\mass_btn_over.dds" , "68" , "60" , "dds" +"ui\common\game\resources\mass_btn_up.dds" , "72" , "60" , "dds" +"ui\common\game\resources\mass-advanced_bmp.dds" , "48" , "36" , "dds" +"ui\common\game\resources\mass-advanced_over_bmp.dds" , "52" , "36" , "dds" +"ui\common\game\resources\mass-back_bmp.dds" , "396" , "48" , "dds" +"ui\common\game\resources\mass-bar_bmp.dds" , "192" , "12" , "dds" +"ui\common\game\resources\mass-bar-back_bmp.dds" , "192" , "12" , "dds" +"ui\common\game\resources\mass-bar-edge_bmp.dds" , "52" , "40" , "dds" +"ui\common\game\resources\resource_rate_bmp.dds" , "72" , "40" , "dds" +"ui\common\game\resources\resource_rate_over_bmp.dds" , "72" , "40" , "dds" +"ui\common\game\selection-bracket\selection-bracket_bmp.dds" , "48" , "44" , "dds" +"ui\common\game\selection-bracket\selection-bracket-glow_bmp.dds" , "64" , "64" , "dds" +"ui\common\game\selection-bracket\selection-bracket-white_bmp.dds" , "48" , "44" , "dds" +"ui\common\game\selection\selection_brackets_enemy_highlighted.dds" , "128" , "128" , "dds" +"ui\common\game\selection\selection_brackets_enemy.dds" , "128" , "128" , "dds" +"ui\common\game\selection\selection_brackets_neutral_highlighted.dds" , "128" , "128" , "dds" +"ui\common\game\selection\selection_brackets_neutral.dds" , "128" , "128" , "dds" +"ui\common\game\selection\selection_brackets_player_highlighted.dds" , "128" , "128" , "dds" +"ui\common\game\selection\selection_brackets_player.dds" , "128" , "128" , "dds" +"ui\common\game\selection\selection.dds" , "100" , "100" , "dds" +"ui\common\game\seraphim-enhancements\adss_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\adss_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\adss_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\adss_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\aes_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\aes_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\aes_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\aes_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\anrf_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\anrf_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\anrf_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\anrf_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\cba_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\cba_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\cba_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\cba_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\dss_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\dss_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\dss_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\dss_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ees_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ees_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ees_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ees_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\efm_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\efm_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\efm_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\efm_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\eras_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\eras_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\eras_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\eras_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\hsa_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\hsa_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\hsa_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\hsa_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\nrf_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\nrf_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\nrf_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\nrf_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\oc_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\oc_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\oc_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\oc_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\pqt_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\pqt_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\pqt_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\pqt_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ras_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ras_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ras_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ras_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\sp_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\sp_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\sp_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\sp_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\sre_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\sre_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\sre_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\sre_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ss_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ss_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ss_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\ss_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\tml_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\tml_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\tml_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\tml_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\tmu_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\tmu_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\tmu_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\seraphim-enhancements\tmu_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\small-side_scr\arrow-left_scr_dis.dds" , "36" , "36" , "dds" +"ui\common\game\small-side_scr\arrow-left_scr_down.dds" , "36" , "36" , "dds" +"ui\common\game\small-side_scr\arrow-left_scr_over.dds" , "36" , "36" , "dds" +"ui\common\game\small-side_scr\arrow-left_scr_up.dds" , "36" , "36" , "dds" +"ui\common\game\small-side_scr\arrow-right_scr_dis.dds" , "36" , "36" , "dds" +"ui\common\game\small-side_scr\arrow-right_scr_down.dds" , "36" , "36" , "dds" +"ui\common\game\small-side_scr\arrow-right_scr_over.dds" , "36" , "36" , "dds" +"ui\common\game\small-side_scr\arrow-right_scr_up.dds" , "36" , "36" , "dds" +"ui\common\game\small-vert_scr\arrow-down_scr_dis.dds" , "36" , "36" , "dds" +"ui\common\game\small-vert_scr\arrow-down_scr_down.dds" , "36" , "36" , "dds" +"ui\common\game\small-vert_scr\arrow-down_scr_over.dds" , "36" , "36" , "dds" +"ui\common\game\small-vert_scr\arrow-down_scr_up.dds" , "36" , "36" , "dds" +"ui\common\game\small-vert_scr\arrow-up_scr_dis.dds" , "36" , "36" , "dds" +"ui\common\game\small-vert_scr\arrow-up_scr_down.dds" , "36" , "36" , "dds" +"ui\common\game\small-vert_scr\arrow-up_scr_over.dds" , "36" , "36" , "dds" +"ui\common\game\small-vert_scr\arrow-up_scr_up.dds" , "36" , "36" , "dds" +"ui\common\game\strategicicons\icon_bomber_antinavy_over.dds" , "20" , "12" , "dds" +"ui\common\game\strategicicons\icon_bomber_antinavy_rest.dds" , "20" , "12" , "dds" +"ui\common\game\strategicicons\icon_bomber_antinavy_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber_antinavy_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber_directfire_over.dds" , "20" , "12" , "dds" +"ui\common\game\strategicicons\icon_bomber_directfire_rest.dds" , "20" , "12" , "dds" +"ui\common\game\strategicicons\icon_bomber_directfire_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber_directfire_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber_generic_over.dds" , "20" , "12" , "dds" +"ui\common\game\strategicicons\icon_bomber_generic_rest.dds" , "20" , "12" , "dds" +"ui\common\game\strategicicons\icon_bomber_generic_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber_generic_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_antinavy_over.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_antinavy_rest.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_antinavy_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_antinavy_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_directfire_over.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_directfire_rest.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_directfire_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_directfire_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_generic_over.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_generic_rest.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_generic_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber1_generic_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_antinavy_over.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_antinavy_rest.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_antinavy_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_antinavy_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_directfire_over.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_directfire_rest.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_directfire_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_directfire_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_generic_over.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_generic_rest.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_generic_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber2_generic_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_antinavy_over.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_antinavy_rest.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_antinavy_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_antinavy_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_directfire_over.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_directfire_rest.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_directfire_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_directfire_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_generic_over.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_generic_rest.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_generic_selected.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bomber3_generic_selectedover.dds" , "24" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_antiair_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_antiair_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_artillery_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_artillery_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_artillery_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_artillery_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_engineer_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_engineer_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_engineer_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_engineer_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_intel_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_intel_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_antiair_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_antiair_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_artillery_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_artillery_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_artillery_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_artillery_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_engineer_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_engineer_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_engineer_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_engineer_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_intel_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_intel_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot1_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot1_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_antiair_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_antiair_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_artillery_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_artillery_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_artillery_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_artillery_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_engineer_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_engineer_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_engineer_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_engineer_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_intel_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_intel_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot2_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot2_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_antiair_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_antiair_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_artillery_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_artillery_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_artillery_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_artillery_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_engineer_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_engineer_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_engineer_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_engineer_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_intel_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_intel_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_bot3_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_bot3_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_commander_generic_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_commander_generic_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_commander_generic_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_commander_generic_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_experimental_generic_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_experimental_generic_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_experimental_generic_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_experimental_generic_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory_air_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory_air_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory_air_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory_air_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory_land_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory_land_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory_land_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory_land_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory_naval_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory_naval_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory_naval_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory_naval_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory1_air_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory1_air_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory1_air_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory1_air_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory1_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory1_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory1_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory1_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory1_land_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory1_land_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory1_land_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory1_land_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory1_naval_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory1_naval_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory1_naval_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory1_naval_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory2_air_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory2_air_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory2_air_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory2_air_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory2_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory2_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory2_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory2_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory2_land_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory2_land_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory2_land_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory2_land_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory2_naval_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory2_naval_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory2_naval_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory2_naval_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory3_air_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory3_air_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory3_air_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory3_air_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory3_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory3_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory3_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory3_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory3_land_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory3_land_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory3_land_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory3_land_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory3_naval_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory3_naval_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_factory3_naval_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_factory3_naval_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_antiair_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_antiair_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_antiair_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_antiair_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_bomb_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_bomb_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_bomb_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_bomb_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_directfire_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_directfire_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_generic_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_generic_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_intel_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_intel_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_fighter_missile_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter_missile_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_antiair_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_antiair_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_antiair_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_antiair_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_bomb_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_bomb_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_bomb_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_bomb_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_directfire_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_directfire_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_directfire_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_directfire_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_generic_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_generic_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_generic_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_generic_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_intel_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_intel_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_intel_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_intel_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_missile_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_missile_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter1_missile_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter1_missile_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_antiair_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_antiair_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_antiair_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_antiair_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_bomb_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_bomb_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_bomb_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_bomb_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_directfire_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_directfire_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_directfire_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_directfire_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_generic_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_generic_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_generic_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_generic_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_intel_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_intel_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_intel_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_intel_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_missile_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_missile_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter2_missile_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter2_missile_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_antiair_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_antiair_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_antiair_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_antiair_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_bomb_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_bomb_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_bomb_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_bomb_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_directfire_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_directfire_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_directfire_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_directfire_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_generic_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_generic_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_generic_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_generic_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_intel_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_intel_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_intel_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_intel_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_missile_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_missile_rest.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_fighter3_missile_selected.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_fighter3_missile_selectedover.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_gunship_antiair_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship_antiair_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship_transport_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship_transport_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship_transport_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship_transport_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship1_antiair_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship1_antiair_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship1_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship1_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship1_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship1_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship1_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship1_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship1_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship1_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship1_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship1_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship1_transport_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship1_transport_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship1_transport_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship1_transport_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship2_antiair_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship2_antiair_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship2_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship2_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship2_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship2_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship2_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship2_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship2_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship2_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship2_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship2_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship2_transport_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship2_transport_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship2_transport_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship2_transport_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship3_antiair_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship3_antiair_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship3_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship3_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship3_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship3_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship3_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship3_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship3_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship3_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship3_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship3_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship3_transport_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship3_transport_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_gunship3_transport_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_gunship3_transport_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_antiair_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_antiair_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_antiair_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_antiair_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_antishield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_antishield_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_antishield_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_antishield_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_artillery_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_artillery_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_artillery_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_artillery_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_bomb_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_bomb_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_bomb_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_bomb_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_counterintel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_counterintel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_counterintel_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_counterintel_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_directfire_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_directfire_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_engineer_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_engineer_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_engineer_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_engineer_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_generic_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_generic_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_intel_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_intel_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_missile_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_missile_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_shield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_shield_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land_shield_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land_shield_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_antiair_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_antiair_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_antiair_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_antiair_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_antishield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_antishield_rest.dds" , "16" , "24" , "dds" +"ui\common\game\strategicicons\icon_land1_antishield_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_antishield_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_artillery_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_artillery_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_artillery_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_artillery_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_bomb_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_bomb_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_bomb_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_bomb_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_counterintel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_counterintel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_counterintel_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_counterintel_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_directfire_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_directfire_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_engineer_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_engineer_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_engineer_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_engineer_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_generic_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_generic_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_intel_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_intel_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_missile_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_missile_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_shield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_shield_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land1_shield_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land1_shield_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_antiair_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_antiair_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_antiair_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_antiair_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_antishield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_antishield_rest.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_land2_antishield_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_antishield_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_artillery_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_artillery_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_artillery_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_artillery_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_bomb_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_bomb_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_bomb_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_bomb_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_counterintel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_counterintel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_counterintel_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_counterintel_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_directfire_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_directfire_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_engineer_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_engineer_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_engineer_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_engineer_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_generic_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_generic_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_intel_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_intel_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_missile_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_missile_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_shield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_shield_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land2_shield_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land2_shield_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_antiair_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_antiair_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_antiair_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_antiair_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_antishield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_antishield_rest.dds" , "20" , "24" , "dds" +"ui\common\game\strategicicons\icon_land3_antishield_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_antishield_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_artillery_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_artillery_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_artillery_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_artillery_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_bomb_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_bomb_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_bomb_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_bomb_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_counterintel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_counterintel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_counterintel_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_counterintel_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_directfire_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_directfire_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_engineer_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_engineer_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_engineer_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_engineer_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_generic_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_generic_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_intel_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_intel_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_missile_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_missile_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_shield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_shield_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_land3_shield_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_land3_shield_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_objective_over.dds" , "32" , "32" , "dds" +"ui\common\game\strategicicons\icon_objective_primary_over.dds" , "28" , "28" , "dds" +"ui\common\game\strategicicons\icon_objective_primary_rest.dds" , "28" , "28" , "dds" +"ui\common\game\strategicicons\icon_objective_primary_selected.dds" , "28" , "28" , "dds" +"ui\common\game\strategicicons\icon_objective_primary_selectedover.dds" , "28" , "28" , "dds" +"ui\common\game\strategicicons\icon_objective_rest.dds" , "32" , "32" , "dds" +"ui\common\game\strategicicons\icon_objective_secondary_over.dds" , "28" , "28" , "dds" +"ui\common\game\strategicicons\icon_objective_secondary_rest.dds" , "28" , "28" , "dds" +"ui\common\game\strategicicons\icon_objective_secondary_selected.dds" , "28" , "28" , "dds" +"ui\common\game\strategicicons\icon_objective_secondary_selectedover.dds" , "28" , "28" , "dds" +"ui\common\game\strategicicons\icon_objective_selected.dds" , "32" , "32" , "dds" +"ui\common\game\strategicicons\icon_objective_selectedover.dds" , "32" , "32" , "dds" +"ui\common\game\strategicicons\icon_ship_air_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_air_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_air_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_air_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_antiair_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_antiair_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_antinavy_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_antinavy_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_antinavy_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_antinavy_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_counterintel_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_counterintel_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_counterintel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_counterintel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_intel_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_intel_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_missile_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_missile_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_missile_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_missile_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_shield_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_shield_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_ship_shield_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship_shield_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_air_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_air_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_air_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_air_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_antiair_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_antiair_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_antinavy_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_antinavy_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_antinavy_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_antinavy_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_counterintel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_counterintel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_counterintel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_counterintel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_missile_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_missile_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_shield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_shield_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_shield_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship1_shield_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_air_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_air_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_air_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_air_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_antiair_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_antiair_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_antinavy_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_antinavy_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_antinavy_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_antinavy_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_counterintel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_counterintel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_counterintel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_counterintel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_missile_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_missile_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_shield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_shield_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_shield_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship2_shield_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_air_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_air_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_air_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_air_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_antiair_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_antiair_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_antiair_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_antiair_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_antinavy_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_antinavy_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_antinavy_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_antinavy_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_counterintel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_counterintel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_counterintel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_counterintel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_missile_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_missile_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_shield_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_shield_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_shield_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_ship3_shield_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_strategic_antinuke.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_strategic_artillery.dds" , "4" , "4" , "dds" +"ui\common\game\strategicicons\icon_strategic_ferrypoint_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_strategic_ferrypoint_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_strategic_ferrypoint_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_strategic_ferrypoint_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_strategic_nuclear.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_strategic_nuke.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure_air_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_air_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_air_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_air_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_antiair_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_antiair_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_antiair_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_antiair_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_antiartillery_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_antiartillery_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_antiartillery_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_antiartillery_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_antimissile_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_antimissile_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_antimissile_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_antimissile_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_antinavy_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_antinavy_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_antinavy_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_antinavy_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_artillery_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_artillery_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_artillery_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_artillery_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_counterintel_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_counterintel_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_counterintel_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_counterintel_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_directfire_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_directfire_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_directfire_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_directfire_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_energy_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_energy_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_energy_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_energy_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_engineer_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_engineer_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_engineer_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_engineer_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_generic_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_generic_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_generic_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_generic_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_intel_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_intel_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_intel_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_intel_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_land_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_land_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_land_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_land_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_mass_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_mass_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_mass_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_mass_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_missile_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_missile_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_missile_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_missile_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_naval_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_naval_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_naval_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_naval_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_shield_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_shield_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_shield_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_shield_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_transport_over.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_transport_rest.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_transport_selected.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_transport_selectedover.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure_wall_over.dds" , "8" , "8" , "dds" +"ui\common\game\strategicicons\icon_structure_wall_rest.dds" , "8" , "8" , "dds" +"ui\common\game\strategicicons\icon_structure_wall_selected.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure_wall_selectedover.dds" , "12" , "12" , "dds" +"ui\common\game\strategicicons\icon_structure1_air_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_air_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_air_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_air_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_antiair_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_antiair_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_antiair_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_antiair_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_antiartillery_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_antiartillery_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_antiartillery_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_antiartillery_selectedover.dds", "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_antimissile_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_antimissile_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_antimissile_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_antimissile_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_antinavy_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_antinavy_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_antinavy_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_antinavy_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_artillery_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_artillery_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_artillery_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_artillery_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_counterintel_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_counterintel_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_counterintel_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_counterintel_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_directfire_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_directfire_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_directfire_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_directfire_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_energy_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_energy_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_energy_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_energy_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_engineer_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_engineer_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_engineer_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_engineer_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_generic_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_generic_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_generic_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_generic_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_intel_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_intel_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_intel_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_intel_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_land_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_land_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_land_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_land_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_mass_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_mass_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_mass_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_mass_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_missile_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_missile_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_missile_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_missile_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_naval_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_naval_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_naval_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_naval_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_shield_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_shield_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_shield_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_shield_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_transport_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_transport_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure1_transport_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure1_transport_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_air_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_air_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_air_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_air_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_antiair_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_antiair_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_antiair_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_antiair_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_antiartillery_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_antiartillery_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_antiartillery_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_antiartillery_selectedover.dds", "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_antimissile_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_antimissile_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_antimissile_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_antimissile_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_antinavy_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_antinavy_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_antinavy_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_antinavy_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_artillery_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_artillery_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_artillery_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_artillery_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_counterintel_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_counterintel_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_counterintel_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_counterintel_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_directfire_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_directfire_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_directfire_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_directfire_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_energy_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_energy_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_energy_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_energy_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_engineer_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_engineer_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_engineer_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_engineer_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_generic_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_generic_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_generic_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_generic_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_intel_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_intel_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_intel_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_intel_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_land_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_land_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_land_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_land_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_mass_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_mass_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_mass_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_mass_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_missile_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_missile_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_missile_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_missile_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_naval_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_naval_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_naval_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_naval_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_shield_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_shield_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_shield_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_shield_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_transport_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_transport_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure2_transport_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure2_transport_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_air_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_air_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_air_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_air_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_antiair_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_antiair_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_antiair_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_antiair_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_antiartillery_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_antiartillery_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_antiartillery_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_antiartillery_selectedover.dds", "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_antimissile_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_antimissile_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_antimissile_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_antimissile_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_antinavy_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_antinavy_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_antinavy_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_antinavy_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_artillery_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_artillery_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_artillery_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_artillery_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_counterintel_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_counterintel_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_counterintel_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_counterintel_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_directfire_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_directfire_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_directfire_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_directfire_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_energy_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_energy_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_energy_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_energy_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_engineer_over.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_engineer_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_engineer_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_engineer_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_generic_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_generic_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_generic_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_generic_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_intel_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_intel_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_intel_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_intel_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_land_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_land_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_land_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_land_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_mass_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_mass_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_mass_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_mass_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_missile_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_missile_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_missile_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_missile_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_naval_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_naval_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_naval_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_naval_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_shield_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_shield_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_shield_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_shield_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_transport_over.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_transport_rest.dds" , "12" , "16" , "dds" +"ui\common\game\strategicicons\icon_structure3_transport_selected.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_structure3_transport_selectedover.dds" , "16" , "20" , "dds" +"ui\common\game\strategicicons\icon_sub_antinavy_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_antinavy_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_antinavy_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub_antinavy_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub_directfire_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_directfire_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub_generic_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_generic_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub_intel_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_intel_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub_missile_over.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_missile_rest.dds" , "16" , "12" , "dds" +"ui\common\game\strategicicons\icon_sub_missile_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub_missile_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_antinavy_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_antinavy_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_antinavy_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_antinavy_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_missile_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub1_missile_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_antinavy_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_antinavy_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_antinavy_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_antinavy_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_missile_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub2_missile_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_antinavy_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_antinavy_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_antinavy_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_antinavy_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_directfire_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_directfire_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_directfire_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_directfire_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_generic_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_generic_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_generic_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_generic_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_intel_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_intel_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_intel_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_intel_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_missile_over.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_missile_rest.dds" , "16" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_missile_selected.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\icon_sub3_missile_selectedover.dds" , "20" , "16" , "dds" +"ui\common\game\strategicicons\pause_rest.dds" , "32" , "32" , "dds" +"ui\common\game\strategicicons\strat_alert_ping_over.dds" , "12" , "20" , "dds" +"ui\common\game\strategicicons\strat_alert_ping_rest.dds" , "12" , "20" , "dds" +"ui\common\game\strategicicons\strat_alert_ping_selected.dds" , "12" , "20" , "dds" +"ui\common\game\strategicicons\strat_alert_ping_selectedover.dds" , "12" , "20" , "dds" +"ui\common\game\strategicicons\strat_attack_ping_over.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\strat_attack_ping_rest.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\strat_attack_ping_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\strat_attack_ping_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\strat_move_ping_over.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\strat_move_ping_rest.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\strat_move_ping_selected.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\strat_move_ping_selectedover.dds" , "20" , "20" , "dds" +"ui\common\game\strategicicons\stunned_rest.dds" , "32" , "32" , "dds" +"ui\common\game\target-area\target-area_bmp.dds" , "64" , "64" , "dds" +"ui\common\game\tech-icons-sm_btn\experimental_icon_btn_dis.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\experimental_icon_btn_down.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\experimental_icon_btn_over.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\experimental_icon_btn_up.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-1_icon_btn_dis.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-1_icon_btn_down.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-1_icon_btn_over.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-1_icon_btn_up.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-2_icon_btn_dis.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-2_icon_btn_down.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-2_icon_btn_over.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-2_icon_btn_up.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-3_icon_btn_dis.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-3_icon_btn_down.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-3_icon_btn_over.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\tech-level-3_icon_btn_up.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\transport-icon_btn_dis.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\transport-icon_btn_down.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\transport-icon_btn_over.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\transport-icon_btn_up.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\upgrade-icon_btn_dis.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\upgrade-icon_btn_down.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\upgrade-icon_btn_over.dds" , "32" , "32" , "dds" +"ui\common\game\tech-icons-sm_btn\upgrade-icon_btn_up.dds" , "32" , "32" , "dds" +"ui\common\game\tech-tabs_btn\experimental_icon_bmp.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-level-1_icon_bmp.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-level-2_icon_bmp.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-level-3_icon_bmp.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab_btn_dis.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab_btn_down.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab_btn_over.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab_btn_selected.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab_btn_up.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab02_btn_dis.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab02_btn_down.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab02_btn_over.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab02_btn_selected.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\tech-tab02_btn_up.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\transport-icon_bmp.dds" , "72" , "44" , "dds" +"ui\common\game\tech-tabs_btn\upgrade-icon_bmp.dds" , "72" , "44" , "dds" +"ui\common\game\timer\clock_bmp.dds" , "32" , "32" , "dds" +"ui\common\game\timer\glow-02_bmp.dds" , "36" , "36" , "dds" +"ui\common\game\timer\timer-panel_bmp.dds" , "148" , "44" , "dds" +"ui\common\game\uef-enhancements\acu_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\acu_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\acu_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\acu_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\aes_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\aes_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\aes_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\aes_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\dsu_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\dsu_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\dsu_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\dsu_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\ed_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\ed_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\ed_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\ed_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\ees_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\ees_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\ees_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\ees_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\hamc_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\hamc_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\hamc_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\hamc_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\heo_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\heo_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\heo_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\heo_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\isb_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\isb_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\isb_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\isb_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\led_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\led_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\led_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\led_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\pqt_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\pqt_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\pqt_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\pqt_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\psg_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\psg_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\psg_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\psg_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\red_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\red_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\red_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\red_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\rj_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\rj_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\rj_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\rj_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\sgf_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\sgf_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\sgf_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\sgf_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\sre_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\sre_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\sre_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\sre_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\srtn_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\srtn_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\srtn_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\srtn_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\tm_btn_down.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\tm_btn_over.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\tm_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\game\uef-enhancements\tm_btn_up.dds" , "64" , "64" , "dds" +"ui\common\game\unit_bmp\bar-01_bmp.dds" , "148" , "8" , "dds" +"ui\common\game\unit_bmp\bar-02_bmp.dds" , "148" , "8" , "dds" +"ui\common\game\unit_bmp\bar-03_bmp.dds" , "148" , "8" , "dds" +"ui\common\game\unit_bmp\bar-back_bmp.dds" , "148" , "8" , "dds" +"ui\common\game\unit_bmp\bar-info-back_bmp.dds" , "152" , "12" , "dds" +"ui\common\game\unit_bmp\bar02_bmp.dds" , "148" , "8" , "dds" +"ui\common\game\unit_bmp\focus_arrow.dds" , "20" , "48" , "dds" +"ui\common\game\unit_bmp\unit-over_bmp.dds" , "160" , "220" , "dds" +"ui\common\game\unit_bmp\veteran-logo_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\unit_unselected\panel_bmp_l.dds" , "304" , "212" , "dds" +"ui\common\game\unit_unselected\panel_bmp_m.dds" , "12" , "212" , "dds" +"ui\common\game\unit_unselected\panel_bmp_r.dds" , "264" , "212" , "dds" +"ui\common\game\unit_view_icons\attached.dds" , "20" , "20" , "dds" +"ui\common\game\unit_view_icons\build.dds" , "24" , "20" , "dds" +"ui\common\game\unit_view_icons\energy.dds" , "20" , "20" , "dds" +"ui\common\game\unit_view_icons\fuel.dds" , "20" , "20" , "dds" +"ui\common\game\unit_view_icons\idle.dds" , "48" , "48" , "dds" +"ui\common\game\unit_view_icons\kills.dds" , "20" , "20" , "dds" +"ui\common\game\unit_view_icons\mass.dds" , "20" , "20" , "dds" +"ui\common\game\unit_view_icons\missiles.dds" , "20" , "20" , "dds" +"ui\common\game\unit_view_icons\redcross.dds" , "16" , "16" , "dds" +"ui\common\game\unit_view_icons\shield.dds" , "20" , "20" , "dds" +"ui\common\game\unit_view_icons\tactical.dds" , "20" , "20" , "dds" +"ui\common\game\unit_view_icons\time.dds" , "20" , "20" , "dds" +"ui\common\game\unit_view_icons\unidentified.dds" , "48" , "48" , "dds" +"ui\common\game\unit-over-abilites_bmp\build-over-abilities_bmp _m.dds" , "396" , "12" , "dds" +"ui\common\game\unit-over-abilites_bmp\build-over-abilities_bmp _t.dds" , "396" , "20" , "dds" +"ui\common\game\unit-over-abilites_bmp\build-over-abilities_bmp_b.dds" , "396" , "24" , "dds" +"ui\common\game\unit-over-abilites_bmp\unit-over-abilities_bmp _m.dds" , "404" , "12" , "dds" +"ui\common\game\unit-over-abilites_bmp\unit-over-abilities_bmp _t.dds" , "404" , "60" , "dds" +"ui\common\game\unit-over-abilites_bmp\unit-over-abilities_bmp_b.dds" , "412" , "24" , "dds" +"ui\common\game\unit-over\_icon-mass-text.dds" , "28" , "8" , "dds" +"ui\common\game\unit-over\bar01_bmp.dds" , "76" , "8" , "dds" +"ui\common\game\unit-over\bar02_bmp.dds" , "76" , "8" , "dds" +"ui\common\game\unit-over\health-bars-back_bmp.dds" , "76" , "16" , "dds" +"ui\common\game\unit-over\health-bars-back-1_bmp.dds" , "76" , "8" , "dds" +"ui\common\game\unit-over\icon-clock_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\unit-over\icon-clock_large_bmp.dds" , "40" , "40" , "dds" +"ui\common\game\unit-over\icon-energy_bmp.dds" , "20" , "20" , "dds" +"ui\common\game\unit-over\icon-mass_bmp.dds" , "20" , "20" , "dds" +"ui\common\game\unit-over\icon-nuke_bmp.dds" , "20" , "20" , "dds" +"ui\common\game\unit-over\icon-skull_bmp.dds" , "24" , "24" , "dds" +"ui\common\game\unit-over\unit-bar_bmp.dds" , "192" , "16" , "dds" +"ui\common\game\unit-over\unit-over_bmp.dds" , "460" , "64" , "dds" +"ui\common\game\unit-over\unit-over-build_bmp.dds" , "344" , "52" , "dds" +"ui\common\game\unit-over\unit-over02_bmp.dds" , "492" , "100" , "dds" +"ui\common\game\unit-unselected-vertical\panel_bmp_b.dds" , "236" , "76" , "dds" +"ui\common\game\unit-unselected-vertical\panel_bmp_m.dds" , "236" , "8" , "dds" +"ui\common\game\unit-unselected-vertical\panel_bmp_t.dds" , "236" , "128" , "dds" +"ui\common\game\unit-vertical_bmp\bar-01_bmp.dds" , "148" , "8" , "dds" +"ui\common\game\unit-vertical_bmp\bar-back_bmp.dds" , "148" , "8" , "dds" +"ui\common\game\unit-vertical_bmp\bar-info-back01_bmp.dds" , "72" , "24" , "dds" +"ui\common\game\unit-vertical_bmp\bar-info-back02_bmp.dds" , "72" , "36" , "dds" +"ui\common\game\unit-vertical_bmp\bar02_bmp.dds" , "148" , "8" , "dds" +"ui\common\game\unit-vertical_bmp\unit-over-vertical_bmp.dds" , "244" , "168" , "dds" +"ui\common\game\unit-vertical_bmp\veteran-logo_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\units_bmp\avatar-glow.dds" , "100" , "100" , "dds" +"ui\common\game\units_bmp\glow.dds" , "64" , "64" , "dds" +"ui\common\game\units_bmp\scroll-back_bmp.dds" , "64" , "112" , "dds" +"ui\common\game\units_bmp\unit_bmp_l.dds" , "80" , "128" , "dds" +"ui\common\game\units_bmp\unit_bmp_m.dds" , "80" , "128" , "dds" +"ui\common\game\units_bmp\unit_bmp_r.dds" , "80" , "128" , "dds" +"ui\common\game\units_bmp\unit-02_bmp_l.dds" , "80" , "132" , "dds" +"ui\common\game\units_bmp\unit-02_bmp_m.dds" , "80" , "132" , "dds" +"ui\common\game\units_bmp\unit-02_bmp_r.dds" , "80" , "132" , "dds" +"ui\common\game\units-vertical_bmp\scroll-back_bmp.dds" , "28" , "60" , "dds" +"ui\common\game\units-vertical_bmp\unit_bmp_b.dds" , "236" , "16" , "dds" +"ui\common\game\units-vertical_bmp\unit_bmp_m.dds" , "236" , "16" , "dds" +"ui\common\game\units-vertical_bmp\unit_bmp_t.dds" , "236" , "16" , "dds" +"ui\common\game\units-vertical_bmp\unit-02_bmp_b.dds" , "236" , "16" , "dds" +"ui\common\game\units-vertical_bmp\unit-02_bmp_m.dds" , "236" , "16" , "dds" +"ui\common\game\units-vertical_bmp\unit-02_bmp_t.dds" , "236" , "16" , "dds" +"ui\common\game\veteran-logo_bmp\aeon-veteran_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\veteran-logo_bmp\cybran-veteran_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\veteran-logo_bmp\seraphim-veteran_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\veteran-logo_bmp\uef-veteran_bmp.dds" , "16" , "16" , "dds" +"ui\common\game\waypoints\attack_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\attack_move_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\convert_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\ferry_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\guard_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\load_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\move_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\nuke_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\patrol_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\production_btn_up.dds" , "48" , "48" , "dds" +"ui\common\game\waypoints\reclaim_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\repair_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\return-fire_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\sacrifice_btn_up.dds" , "126" , "123" , "dds" +"ui\common\game\waypoints\stop_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\teleport_btn_up.dds" , "128" , "128" , "dds" +"ui\common\game\waypoints\unload_btn_up.dds" , "128" , "128" , "dds" +"ui\common\gen-tab_btn\gen-tab_btn_dis.dds" , "64" , "32" , "dds" +"ui\common\gen-tab_btn\gen-tab_btn_down.dds" , "64" , "32" , "dds" +"ui\common\gen-tab_btn\gen-tab_btn_over.dds" , "64" , "32" , "dds" +"ui\common\gen-tab_btn\gen-tab_btn_up.dds" , "64" , "32" , "dds" +"ui\common\icons\comm_aeon.dds" , "112" , "112" , "dds" +"ui\common\icons\comm_allied.dds" , "112" , "112" , "dds" +"ui\common\icons\comm_cybran.dds" , "112" , "112" , "dds" +"ui\common\icons\comm_seraphim.dds" , "112" , "112" , "dds" +"ui\common\icons\comm_uef.dds" , "112" , "112" , "dds" +"ui\common\icons\units\air_down.dds" , "64" , "64" , "dds" +"ui\common\icons\units\air_over.dds" , "64" , "64" , "dds" +"ui\common\icons\units\air_up.dds" , "64" , "64" , "dds" +"ui\common\icons\units\amph_down.dds" , "64" , "64" , "dds" +"ui\common\icons\units\amph_over.dds" , "64" , "64" , "dds" +"ui\common\icons\units\amph_up.dds" , "64" , "64" , "dds" +"ui\common\icons\units\cons_bar.dds" , "48" , "48" , "dds" +"ui\common\icons\units\daa0206_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\dab2102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\dal0310_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\dea0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\deb4303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\default_icon.dds" , "48" , "48" , "dds" +"ui\common\icons\units\del0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\dra0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\drl0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\drs0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\land_down.dds" , "64" , "64" , "dds" +"ui\common\icons\units\land_over.dds" , "64" , "64" , "dds" +"ui\common\icons\units\land_up.dds" , "64" , "64" , "dds" +"ui\common\icons\units\opc2002_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\ope2003_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\ope3001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\ope6001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\ope6003_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\sea_down.dds" , "64" , "64" , "dds" +"ui\common\icons\units\sea_over.dds" , "64" , "64" , "dds" +"ui\common\icons\units\sea_up.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0107_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAA0310_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uab1101_over.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1106_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB1303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2108_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2109_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB2305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB3101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB3102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB3104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB3201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB3202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB4201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB4202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB4203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB4301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB4302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB5101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAB5102_icon.dds" , "32" , "32" , "dds" +"ui\common\icons\units\UAB5103_icon.dds" , "72" , "72" , "dds" +"ui\common\icons\units\UAB5202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uac1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uac1201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uac1301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uac1401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uac1501_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uac1901_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0106_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0111_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0208_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0307_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0309_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAL0401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAS0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAS0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAS0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAS0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uas0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAS0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAS0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAS0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAS0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UAS0401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uea0001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uea0003_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0107_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEA0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1106_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB1303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2108_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2109_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB2401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB3101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB3102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB3103_icon.dds" , "72" , "72" , "dds" +"ui\common\icons\units\UEB3104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB3201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB3202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB4201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB4202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB4203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB4301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB4302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB5101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEB5102_icon.dds" , "32" , "32" , "dds" +"ui\common\icons\units\UEB5103_icon.dds" , "72" , "72" , "dds" +"ui\common\icons\units\UEB5202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec0001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1501_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1901_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1902_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1903_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1904_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1905_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1906_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\uec1907_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0106_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0111_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0208_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0307_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0309_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UEL0401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UES0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UES0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UES0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UES0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UES0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UES0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UES0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\UES0401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0001_icon.dds" , "72" , "72" , "dds" +"ui\common\icons\units\URA0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0107_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URA0401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1106_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB1303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2108_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2109_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB2305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB3101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB3102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB3104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB3201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB3202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB3302_icon.dds" , "72" , "72" , "dds" +"ui\common\icons\units\URB4201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB4202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB4203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB4204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB4205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB4206_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB4207_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB4302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB5101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URB5102_icon.dds" , "32" , "32" , "dds" +"ui\common\icons\units\URB5103_icon.dds" , "72" , "72" , "dds" +"ui\common\icons\units\URB5202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\urc0001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\urc1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\urc1201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\urc1301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\urc1302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\urc1401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\urc1501_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\urc1901_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\urc1902_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0106_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0107_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0111_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0204_Icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0208_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0306_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0309_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URL0402_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URS0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URS0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URS0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URS0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URS0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URS0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URS0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\URS0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xaa0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xaa0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xaa0306_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xab1401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xab2307_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xab3301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xac0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xac1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xac1401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xac2101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xac2201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xal0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xal0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xas0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xas0306_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xea0002_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xea0306_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xea3204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xeb0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xeb0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xeb2306_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xeb2402_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec1301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec1401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec1501_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8002_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8003_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8004_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8005_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8006_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8007_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8008_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8009_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8010_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8011_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8012_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8013_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8014_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8015_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8016_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8017_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8018_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8019_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xec8020_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xel0209_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xel0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xel0306_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xes0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xes0205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xes0307_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xra0105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xra0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrb0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrb0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrb0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrb2308_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrb3301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc1502_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc2201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8002_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8003_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8004_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8005_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8006_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8007_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8008_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8009_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8010_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8011_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8012_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8013_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8014_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8015_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8016_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8017_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8018_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8019_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8020_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8106_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8107_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8108_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8109_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8110_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8111_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8112_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8113_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8114_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8115_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8116_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8117_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8118_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8119_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrc8120_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XRL0002_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrl0003_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XRL0004_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XRL0005_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrl0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrl0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrl0403_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrs0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xrs0205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0107_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSA0402_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsb1106_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB1303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2108_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2109_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2204_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB2305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsb2401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB3101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB3102_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB3104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB3201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB3202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB4201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB4202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB4203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB4301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsb4302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB5101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSB5102_icon.dds" , "32" , "32" , "dds" +"ui\common\icons\units\XSB5202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC0001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC1101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC1201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC1301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC1401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC1501_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC1901_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC1902_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc2201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8002_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8003_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8004_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8005_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8006_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8007_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8008_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8009_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8010_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8011_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\xsc8012_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC9001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC9002_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSC9003_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0001_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0101_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0104_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0105_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0111_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0205_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0208_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0301_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0304_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0305_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0307_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0309_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0401_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0402_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0403_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSL0404_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSS0103_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSS0201_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSS0202_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSS0203_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSS0302_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSS0303_icon.dds" , "64" , "64" , "dds" +"ui\common\icons\units\XSS0304_icon.dds" , "64" , "64" , "dds" +"ui\common\load\background-portal_bmp.dds" , "1024" , "768" , "dds" +"ui\common\load\load-bar_bmp.dds" , "264" , "24" , "dds" +"ui\common\load\load-bar-bg_bmp.dds" , "264" , "24" , "dds" +"ui\common\load\panel_bmp.dds" , "404" , "352" , "dds" +"ui\common\lobby\aeon_ico.dds" , "24" , "24" , "dds" +"ui\common\lobby\cybran_ico.dds" , "24" , "24" , "dds" +"ui\common\lobby\direct-ip-connect\panel_bmp.dds" , "456" , "320" , "dds" +"ui\common\lobby\gamecreate\panel_bmp.dds" , "456" , "320" , "dds" +"ui\common\lobby\gameselect\panel_bmp.dds" , "944" , "608" , "dds" +"ui\common\lobby\gameselect02\panel_bmp.dds" , "888" , "548" , "dds" +"ui\common\lobby\gameselect02\text-over_bmp.dds" , "412" , "20" , "dds" +"ui\common\lobby\gameselect03\gameselect03_ip_cover.dds" , "396" , "28" , "dds" +"ui\common\lobby\gameselect03\gameselect03_ip_hlite.dds" , "396" , "28" , "dds" +"ui\common\lobby\gameselect03\gameselect03_lan_cover.dds" , "784" , "256" , "dds" +"ui\common\lobby\gameselect03\gameselect03_lan_hlite.dds" , "396" , "256" , "dds" +"ui\common\lobby\gameselect03\gameselect03_panel.dds" , "848" , "504" , "dds" +"ui\common\lobby\gameselect03\map-pane-border_bmp.dds" , "332" , "332" , "dds" +"ui\common\lobby\gameselect03\name_btn_dis.dds" , "320" , "40" , "dds" +"ui\common\lobby\gameselect03\name_btn_down.dds" , "320" , "40" , "dds" +"ui\common\lobby\gameselect03\name_btn_over.dds" , "320" , "40" , "dds" +"ui\common\lobby\gameselect03\name_btn_up.dds" , "320" , "40" , "dds" +"ui\common\lobby\gameselect03\panel_bmp.dds" , "1016" , "768" , "dds" +"ui\common\lobby\gameselect03\temp-game-sel-bg.dds" , "1008" , "768" , "dds" +"ui\common\lobby\lan-game-lobby\border-l_bmp.dds" , "304" , "396" , "dds" +"ui\common\lobby\lan-game-lobby\border-r_bmp.dds" , "304" , "396" , "dds" +"ui\common\lobby\lan-game-lobby\combo_btn_dis.dds" , "24" , "16" , "dds" +"ui\common\lobby\lan-game-lobby\combo_btn_down.dds" , "24" , "16" , "dds" +"ui\common\lobby\lan-game-lobby\combo_btn_over.dds" , "24" , "16" , "dds" +"ui\common\lobby\lan-game-lobby\combo_btn_up.dds" , "24" , "16" , "dds" +"ui\common\lobby\lan-game-lobby\drop-down_bmp_b.dds" , "52" , "16" , "dds" +"ui\common\lobby\lan-game-lobby\drop-down_bmp_m.dds" , "52" , "8" , "dds" +"ui\common\lobby\lan-game-lobby\drop-down_bmp_t.dds" , "52" , "16" , "dds" +"ui\common\lobby\lan-game-lobby\drop-down_bmp.dds" , "60" , "44" , "dds" +"ui\common\lobby\lan-game-lobby\drop-down-w_bmp_b.dds" , "116" , "16" , "dds" +"ui\common\lobby\lan-game-lobby\drop-down-w_bmp_m.dds" , "116" , "8" , "dds" +"ui\common\lobby\lan-game-lobby\drop-down-w_bmp_t.dds" , "116" , "16" , "dds" +"ui\common\lobby\lan-game-lobby\drop-down-w_bmp.dds" , "124" , "44" , "dds" +"ui\common\lobby\lan-game-lobby\highlight_bmp.dds" , "48" , "20" , "dds" +"ui\common\lobby\lan-game-lobby\large_btn_dis.dds" , "240" , "72" , "dds" +"ui\common\lobby\lan-game-lobby\large_btn_down.dds" , "240" , "72" , "dds" +"ui\common\lobby\lan-game-lobby\large_btn_over.dds" , "240" , "72" , "dds" +"ui\common\lobby\lan-game-lobby\large_btn_up.dds" , "240" , "72" , "dds" +"ui\common\lobby\lan-game-lobby\map-pane-border_bmp.dds" , "240" , "244" , "dds" +"ui\common\lobby\lan-game-lobby\panel_bmp.dds" , "1016" , "768" , "dds" +"ui\common\lobby\lan-game-lobby\panel-skirmish_bmp.dds" , "1016" , "768" , "dds" +"ui\common\lobby\lan-game-lobby\player_btn_dis.dds" , "368" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\player_btn_down.dds" , "368" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\player_btn_over.dds" , "368" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\player_btn_up.dds" , "368" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\player-drop_b.dds" , "364" , "28" , "dds" +"ui\common\lobby\lan-game-lobby\player-drop_m.dds" , "360" , "20" , "dds" +"ui\common\lobby\lan-game-lobby\player-drop_t.dds" , "360" , "20" , "dds" +"ui\common\lobby\lan-game-lobby\player-drop.dds" , "368" , "96" , "dds" +"ui\common\lobby\lan-game-lobby\player-text-highlight_bmp.dds" , "348" , "20" , "dds" +"ui\common\lobby\lan-game-lobby\small_btn_dis.dds" , "152" , "40" , "dds" +"ui\common\lobby\lan-game-lobby\small_btn_down.dds" , "152" , "40" , "dds" +"ui\common\lobby\lan-game-lobby\small_btn_over.dds" , "152" , "40" , "dds" +"ui\common\lobby\lan-game-lobby\small_btn_up.dds" , "152" , "40" , "dds" +"ui\common\lobby\lan-game-lobby\small-back_btn_dis.dds" , "168" , "40" , "dds" +"ui\common\lobby\lan-game-lobby\small-back_btn_down.dds" , "168" , "40" , "dds" +"ui\common\lobby\lan-game-lobby\small-back_btn_over.dds" , "168" , "40" , "dds" +"ui\common\lobby\lan-game-lobby\small-back_btn_up.dds" , "168" , "40" , "dds" +"ui\common\lobby\lan-game-lobby\square_btn_dis.dds" , "60" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\square_btn_down.dds" , "60" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\square_btn_over.dds" , "60" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\square_btn_up.dds" , "60" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\square-w_btn_dis.dds" , "124" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\square-w_btn_down.dds" , "124" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\square-w_btn_over.dds" , "124" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\square-w_btn_up.dds" , "124" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\toggle_btn_dis.dds" , "120" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\toggle_btn_down.dds" , "120" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\toggle_btn_over.dds" , "120" , "32" , "dds" +"ui\common\lobby\lan-game-lobby\toggle_btn_up.dds" , "120" , "32" , "dds" +"ui\common\lobby\multiplayer-select\panel_bmp.dds" , "476" , "360" , "dds" +"ui\common\lobby\multiplayer-select\panel02_bmp.dds" , "456" , "488" , "dds" +"ui\common\lobby\not-ready_ico.dds" , "24" , "24" , "dds" +"ui\common\lobby\observer_ico.dds" , "24" , "24" , "dds" +"ui\common\lobby\ready_ico.dds" , "24" , "24" , "dds" +"ui\common\lobby\team_icons\team_1_icon.dds" , "16" , "16" , "dds" +"ui\common\lobby\team_icons\team_2_icon.dds" , "16" , "16" , "dds" +"ui\common\lobby\team_icons\team_3_icon.dds" , "16" , "16" , "dds" +"ui\common\lobby\team_icons\team_4_icon.dds" , "16" , "16" , "dds" +"ui\common\lobby\team_icons\team_no_icon.dds" , "16" , "16" , "dds" +"ui\common\lobby\uef_ico.dds" , "24" , "24" , "dds" +"ui\common\logos\gpgnet_logo.dds" , "156" , "40" , "dds" +"ui\common\marketing\end_demo_1.dds" , "1920" , "1080" , "dds" +"ui\common\marketing\splash.dds" , "1024" , "768" , "dds" +"ui\common\medium-aeon-btn\medium02_btn_dis.dds" , "276" , "72" , "dds" +"ui\common\medium-aeon-btn\medium02_btn_down.dds" , "276" , "72" , "dds" +"ui\common\medium-aeon-btn\medium02_btn_glow.dds" , "276" , "72" , "dds" +"ui\common\medium-aeon-btn\medium02_btn_over.dds" , "276" , "72" , "dds" +"ui\common\medium-aeon-btn\medium02_btn_up.dds" , "276" , "72" , "dds" +"ui\common\medium-cybran-btn\medium02_btn_dis.dds" , "276" , "72" , "dds" +"ui\common\medium-cybran-btn\medium02_btn_down.dds" , "276" , "72" , "dds" +"ui\common\medium-cybran-btn\medium02_btn_glow.dds" , "276" , "72" , "dds" +"ui\common\medium-cybran-btn\medium02_btn_over.dds" , "276" , "72" , "dds" +"ui\common\medium-cybran-btn\medium02_btn_up.dds" , "276" , "72" , "dds" +"ui\common\medium-uef-btn\medium02_btn_dis.dds" , "276" , "72" , "dds" +"ui\common\medium-uef-btn\medium02_btn_down.dds" , "276" , "72" , "dds" +"ui\common\medium-uef-btn\medium02_btn_glow.dds" , "276" , "72" , "dds" +"ui\common\medium-uef-btn\medium02_btn_over.dds" , "276" , "72" , "dds" +"ui\common\medium-uef-btn\medium02_btn_up.dds" , "276" , "72" , "dds" +"ui\common\menus\background_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background-colossus_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background-colossus.dds" , "1024" , "768" , "dds" +"ui\common\menus\background-colossus02.dds" , "1024" , "768" , "dds" +"ui\common\menus\background-paint01_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background-paint02_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background-paint02-640x480_bmp.dds" , "640" , "480" , "dds" +"ui\common\menus\background-paint02-640x480_bmp.png" , "640" , "480" , "png" +"ui\common\menus\background-paint03_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background-paint04_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background-paint05_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background02_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background03_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background04_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\background05_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\blast .dds" , "640" , "680" , "dds" +"ui\common\menus\borde02r-l_bmp.dds" , "348" , "412" , "dds" +"ui\common\menus\border_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\border-l_bmp.dds" , "308" , "400" , "dds" +"ui\common\menus\border-r_bmp.dds" , "308" , "400" , "dds" +"ui\common\menus\border02_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\border02-r_bmp.dds" , "340" , "180" , "dds" +"ui\common\menus\border03_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\gpgnet-logo\gpgnet-logo_bmp.dds" , "160" , "28" , "dds" +"ui\common\menus\lights_left.dds" , "36" , "324" , "dds" +"ui\common\menus\lights_right.dds" , "40" , "324" , "dds" +"ui\common\menus\main02-1600-1200\sc-logo_bmp.dds" , "728" , "136" , "dds" +"ui\common\menus\main02-1600-1200\scan-lines_bmp.dds" , "1028" , "776" , "dds" +"ui\common\menus\main02-1600-1200\texture-grime_bmp.dds" , "1600" , "1200" , "dds" +"ui\common\menus\main02\borde02-l_bmp.dds" , "624" , "136" , "dds" +"ui\common\menus\main02\border02-r_bmp.dds" , "296" , "420" , "dds" +"ui\common\menus\main02\border02-t_bmp.dds" , "340" , "176" , "dds" +"ui\common\menus\main02\large_btn_dis.dds" , "296" , "64" , "dds" +"ui\common\menus\main02\large_btn_down.dds" , "296" , "64" , "dds" +"ui\common\menus\main02\large_btn_over.dds" , "296" , "64" , "dds" +"ui\common\menus\main02\large_btn_up.dds" , "296" , "64" , "dds" +"ui\common\menus\main02\multiplayer beta.dds" , "604" , "224" , "dds" +"ui\common\menus\main02\multiplayer_beta.dds" , "604" , "224" , "dds" +"ui\common\menus\main02\panel-bottom_bmp.dds" , "336" , "72" , "dds" +"ui\common\menus\main02\panel-profile02_bmp.dds" , "336" , "76" , "dds" +"ui\common\menus\main02\panel-top_bmp.dds" , "336" , "84" , "dds" +"ui\common\menus\main02\profile-edit_btn_dis.dds" , "304" , "44" , "dds" +"ui\common\menus\main02\profile-edit_btn_down.dds" , "304" , "44" , "dds" +"ui\common\menus\main02\profile-edit_btn_over.dds" , "304" , "44" , "dds" +"ui\common\menus\main02\profile-edit_btn_up.dds" , "304" , "44" , "dds" +"ui\common\menus\main02\quick-panel_t.dds" , "292" , "20" , "dds" +"ui\common\menus\main02\quick-panel-b.dds" , "292" , "16" , "dds" +"ui\common\menus\main02\quick-panel-m.dds" , "292" , "8" , "dds" +"ui\common\menus\main02\sc-logo-lg_bmp.dds" , "508" , "160" , "dds" +"ui\common\menus\main02\texture-grime_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus\main02\thin_btn_dis.dds" , "260" , "32" , "dds" +"ui\common\menus\main02\thin_btn_down.dds" , "260" , "32" , "dds" +"ui\common\menus\main02\thin_btn_over.dds" , "260" , "32" , "dds" +"ui\common\menus\main02\thin_btn_up.dds" , "260" , "32" , "dds" +"ui\common\menus\main03\back_brd_horz_lm.dds" , "452" , "76" , "dds" +"ui\common\menus\main03\back_brd_horz_lml.dds" , "20" , "60" , "dds" +"ui\common\menus\main03\back_brd_horz_lmr.dds" , "20" , "60" , "dds" +"ui\common\menus\main03\back_brd_horz_um.dds" , "452" , "52" , "dds" +"ui\common\menus\main03\back_brd_horz_uml.dds" , "20" , "56" , "dds" +"ui\common\menus\main03\back_brd_horz_umr.dds" , "20" , "56" , "dds" +"ui\common\menus\main03\back_brd_ll.dds" , "40" , "132" , "dds" +"ui\common\menus\main03\back_brd_lr.dds" , "40" , "132" , "dds" +"ui\common\menus\main03\back_brd_ul.dds" , "40" , "128" , "dds" +"ui\common\menus\main03\back_brd_ur.dds" , "40" , "128" , "dds" +"ui\common\menus\main03\large_btn_dis.dds" , "296" , "64" , "dds" +"ui\common\menus\main03\large_btn_down.dds" , "296" , "64" , "dds" +"ui\common\menus\main03\large_btn_glow.dds" , "316" , "84" , "dds" +"ui\common\menus\main03\large_btn_over.dds" , "296" , "64" , "dds" +"ui\common\menus\main03\large_btn_up.dds" , "296" , "64" , "dds" +"ui\common\menus\main03\panel-bottom_bmp.dds" , "336" , "72" , "dds" +"ui\common\menus\main03\panel-top_bmp.dds" , "336" , "84" , "dds" +"ui\common\menus\main03\press_build.dds" , "604" , "224" , "dds" +"ui\common\menus\main03\profile-edit_btn_dis.dds" , "336" , "76" , "dds" +"ui\common\menus\main03\profile-edit_btn_down.dds" , "304" , "44" , "dds" +"ui\common\menus\main03\profile-edit_btn_over.dds" , "336" , "76" , "dds" +"ui\common\menus\main03\profile-edit_btn_up.dds" , "304" , "44" , "dds" +"ui\common\menus\multiplayer\panel_bmp.dds" , "484" , "460" , "dds" +"ui\common\menus\panel-profile_bmp.dds" , "188" , "36" , "dds" +"ui\common\menus\profile-select_btn_dis.dds" , "136" , "28" , "dds" +"ui\common\menus\profile-select_btn_down.dds" , "136" , "28" , "dds" +"ui\common\menus\profile-select_btn_over.dds" , "136" , "28" , "dds" +"ui\common\menus\profile-select_btn_up.dds" , "136" , "28" , "dds" +"ui\common\menus\pulse-ray_bmp.dds" , "684" , "676" , "dds" +"ui\common\menus\smoke_bmp.dds" , "936" , "704" , "dds" +"ui\common\menus\smoke-02_bmp.dds" , "936" , "704" , "dds" +"ui\common\menus\smoke.dds" , "900" , "684" , "dds" +"ui\common\menus\smoke02 .dds" , "900" , "684" , "dds" +"ui\common\menus02\background_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background-colossus_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background-paint01_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background-paint02_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background-paint03_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background-paint04_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background-paint05_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background02_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background03_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background04_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\background05_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\borde02-l_bmp.dds" , "676" , "160" , "dds" +"ui\common\menus02\borde02b-l_bmp.dds" , "132" , "160" , "dds" +"ui\common\menus02\border_bmp.dds" , "1024" , "768" , "dds" +"ui\common\menus02\border-l_bmp.dds" , "364" , "400" , "dds" +"ui\common\menus02\border-r_bmp.dds" , "364" , "400" , "dds" +"ui\common\menus02\border02-r_bmp.dds" , "296" , "420" , "dds" +"ui\common\menus02\border02-t_bmp.dds" , "340" , "176" , "dds" +"ui\common\menus02\panel-profile_bmp.dds" , "336" , "76" , "dds" +"ui\common\menus02\profile-edit_btn_dis.dds" , "304" , "44" , "dds" +"ui\common\menus02\profile-edit_btn_down.dds" , "304" , "44" , "dds" +"ui\common\menus02\profile-edit_btn_over.dds" , "296" , "36" , "dds" +"ui\common\menus02\profile-edit_btn_up.dds" , "304" , "44" , "dds" +"ui\common\menus02\profile-select_btn_dis.dds" , "72" , "32" , "dds" +"ui\common\menus02\profile-select_btn_down.dds" , "72" , "32" , "dds" +"ui\common\menus02\profile-select_btn_over.dds" , "72" , "32" , "dds" +"ui\common\menus02\profile-select_btn_up.dds" , "72" , "32" , "dds" +"ui\common\menus02\pulse-ray_bmp.dds" , "684" , "676" , "dds" +"ui\common\menus02\smoke_bmp.dds" , "936" , "704" , "dds" +"ui\common\menus02\smoke-02_bmp.dds" , "936" , "704" , "dds" +"ui\common\portal02.dds" , "1024" , "768" , "dds" +"ui\common\scx_menu\bracket-left\bracket_bmp_b.dds" , "84" , "288" , "dds" +"ui\common\scx_menu\bracket-left\bracket_bmp_m.dds" , "28" , "12" , "dds" +"ui\common\scx_menu\bracket-left\bracket_bmp_t.dds" , "64" , "192" , "dds" +"ui\common\scx_menu\bracket-right\bracket_bmp_b.dds" , "84" , "288" , "dds" +"ui\common\scx_menu\bracket-right\bracket_bmp_m.dds" , "28" , "12" , "dds" +"ui\common\scx_menu\bracket-right\bracket_bmp_t.dds" , "64" , "192" , "dds" +"ui\common\scx_menu\bracket-tube-vertical\bracket-tube-v_bmp_b.dds" , "44" , "84" , "dds" +"ui\common\scx_menu\bracket-tube-vertical\bracket-tube-v_bmp_m.dds" , "40" , "4" , "dds" +"ui\common\scx_menu\bracket-tube-vertical\bracket-tube-v_bmp_t.dds" , "104" , "36" , "dds" +"ui\common\scx_menu\campaign-select\bg.dds" , "1824" , "1024" , "dds" +"ui\common\scx_menu\campaign-select\border-console-bot_bmp.dds" , "1020" , "96" , "dds" +"ui\common\scx_menu\campaign-select\border-console-bot-center_bmp.dds" , "952" , "140" , "dds" +"ui\common\scx_menu\campaign-select\border-console-top_bmp.dds" , "596" , "72" , "dds" +"ui\common\scx_menu\campaign-select\icon-video_bmp.dds" , "28" , "24" , "dds" +"ui\common\scx_menu\campaign-select\icon-video-dis_bmp.dds" , "28" , "24" , "dds" +"ui\common\scx_menu\campaign-select\large-emitter_bmp.dds" , "36" , "68" , "dds" +"ui\common\scx_menu\campaign-select\panel_bmp.dds" , "428" , "332" , "dds" +"ui\common\scx_menu\campaign-select\panel-02_bmp.dds" , "252" , "228" , "dds" +"ui\common\scx_menu\campaign-select\panel-list_bmp.dds" , "424" , "568" , "dds" +"ui\common\scx_menu\campaign-select\select_bmp.dds" , "20" , "32" , "dds" +"ui\common\scx_menu\campaign-select\small-emitter_bmp.dds" , "28" , "56" , "dds" +"ui\common\scx_menu\campaign-select\small-emitter_left_bmp.dds" , "28" , "56" , "dds" +"ui\common\scx_menu\collapse_btn\collapse_btn_dis.dds" , "24" , "24" , "dds" +"ui\common\scx_menu\collapse_btn\collapse_btn_down.dds" , "24" , "24" , "dds" +"ui\common\scx_menu\collapse_btn\collapse_btn_over.dds" , "24" , "24" , "dds" +"ui\common\scx_menu\collapse_btn\collapse_btn_up.dds" , "24" , "24" , "dds" +"ui\common\scx_menu\eula\eula.dds" , "720" , "600" , "dds" +"ui\common\scx_menu\game-select-faction-panel\panel_bmp.dds" , "332" , "184" , "dds" +"ui\common\scx_menu\game-settings\map-panel-glow_bmp.dds" , "328" , "328" , "dds" +"ui\common\scx_menu\game-settings\options-panel-bar_bmp.dds" , "260" , "48" , "dds" +"ui\common\scx_menu\game-settings\panel_bmp.dds" , "980" , "732" , "dds" +"ui\common\scx_menu\gamecreate\panel_bmp.dds" , "420" , "264" , "dds" +"ui\common\scx_menu\gamecreate\panel-brackets_bmp.dds" , "480" , "320" , "dds" +"ui\common\scx_menu\gameselect\map-panel_bmp.dds" , "596" , "292" , "dds" +"ui\common\scx_menu\gameselect\map-panel-glow_bmp.dds" , "260" , "260" , "dds" +"ui\common\scx_menu\gameselect\map-slot_bmp.dds" , "76" , "76" , "dds" +"ui\common\scx_menu\gameselect\panel_bmp.dds" , "976" , "736" , "dds" +"ui\common\scx_menu\gameselect\slot_bmp.dds" , "652" , "76" , "dds" +"ui\common\scx_menu\lan-game-lobby\panel_bmp.dds" , "1024" , "768" , "dds" +"ui\common\scx_menu\lan-game-lobby\panel-skirmish_bmp.dds" , "1024" , "768" , "dds" +"ui\common\scx_menu\large-btn-brackets\bracket-left.dds" , "36" , "68" , "dds" +"ui\common\scx_menu\large-btn-brackets\bracket-right.dds" , "40" , "68" , "dds" +"ui\common\scx_menu\large-btn-brackets\energy-spike-left_bmp.dds" , "20" , "16" , "dds" +"ui\common\scx_menu\large-btn-brackets\energy-spike-right_bmp.dds" , "20" , "16" , "dds" +"ui\common\scx_menu\large-btn\energy-spikes_bmp.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-btn\large_btn_dis.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-btn\large_btn_down.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-btn\large_btn_glow.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-btn\large_btn_over.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-btn\large_btn_up.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-no-bracket-btn\large_btn_dis.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-no-bracket-btn\large_btn_down.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-no-bracket-btn\large_btn_glow.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-no-bracket-btn\large_btn_over.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\large-no-bracket-btn\large_btn_up.dds" , "360" , "72" , "dds" +"ui\common\scx_menu\load\panel_bmp.dds" , "704" , "588" , "dds" +"ui\common\scx_menu\logo-btn\logo-aeon_btn_dis.dds" , "64" , "64" , "dds" +"ui\common\scx_menu\logo-btn\logo-aeon_btn_over_sel.dds" , "64" , "64" , "dds" +"ui\common\scx_menu\logo-btn\logo-aeon_btn_over.dds" , "64" , "64" , "dds" +"ui\common\scx_menu\logo-btn\logo-aeon_btn_sel.dds" , "64" , "64" , "dds" +"ui\common\scx_menu\logo-btn\logo-aeon_btn_up.dds" , "64" , "64" , "dds" +"ui\common\scx_menu\logo-btn\logo-cybran_btn_dis.dds" , "64" , "64" , "dds" +"ui\common\scx_menu\logo-btn\logo-cybran_btn_over.dds" , "64" , "64" , "dds" +"ui\common\scx_menu\logo\logo.dds" , "500" , "148" , "dds" +"ui\common\scx_menu\main-menu\border-bot-left.dds" , "56" , "40" , "dds" +"ui\common\scx_menu\main-menu\border-bot-mid.dds" , "16" , "28" , "dds" +"ui\common\scx_menu\main-menu\border-bot-right.dds" , "56" , "40" , "dds" +"ui\common\scx_menu\main-menu\border-console-botton_bmp.dds" , "1024" , "100" , "dds" +"ui\common\scx_menu\main-menu\border-console-top_bmp.dds" , "528" , "156" , "dds" +"ui\common\scx_menu\main-menu\bracket_bmp_left.dds" , "48" , "76" , "dds" +"ui\common\scx_menu\main-menu\bracket_bmp_right.dds" , "48" , "76" , "dds" +"ui\common\scx_menu\main-menu\bracket-left_bmp.dds" , "84" , "592" , "dds" +"ui\common\scx_menu\main-menu\bracket-left-energy_bmp.dds" , "60" , "580" , "dds" +"ui\common\scx_menu\main-menu\bracket-lg_bmp_left.dds" , "36" , "96" , "dds" +"ui\common\scx_menu\main-menu\bracket-lg_bmp_right.dds" , "36" , "96" , "dds" +"ui\common\scx_menu\main-menu\bracket-right_bmp.dds" , "84" , "592" , "dds" +"ui\common\scx_menu\main-menu\bracket-right-energy_bmp.dds" , "60" , "580" , "dds" +"ui\common\scx_menu\main-menu\bracket-tube-h_bmp.dds" , "368" , "32" , "dds" +"ui\common\scx_menu\main-menu\bracket-tube-v_bmp.dds" , "108" , "152" , "dds" +"ui\common\scx_menu\main-menu\panel-top_bmp.dds" , "304" , "52" , "dds" +"ui\common\scx_menu\main-menu\profile-edit_btn_up.dds" , "304" , "44" , "dds" +"ui\common\scx_menu\main-menu\tickerborder.dds" , "896" , "40" , "dds" +"ui\common\scx_menu\medium-no-br-btn\medium-uef_btn_dis.dds" , "296" , "72" , "dds" +"ui\common\scx_menu\medium-no-br-btn\medium-uef_btn_down.dds" , "296" , "72" , "dds" +"ui\common\scx_menu\medium-no-br-btn\medium-uef_btn_glow.dds" , "296" , "72" , "dds" +"ui\common\scx_menu\medium-no-br-btn\medium-uef_btn_over.dds" , "296" , "72" , "dds" +"ui\common\scx_menu\medium-no-br-btn\medium-uef_btn_up.dds" , "296" , "72" , "dds" +"ui\common\scx_menu\mod-manager\panel_bmp.dds" , "716" , "588" , "dds" +"ui\common\scx_menu\movie-control\nav-back_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-back_btn_down.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-back_btn_over.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-back_btn_up.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-end_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-end_btn_down.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-end_btn_over.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-end_btn_up.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-ff_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-ff_btn_down.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-ff_btn_over.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-ff_btn_up.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-pause_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-pause_btn_down.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-pause_btn_over.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-pause_btn_up.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-play_btn_dis.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-play_btn_down.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-play_btn_over.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\movie-control\nav-play_btn_up.dds" , "36" , "36" , "dds" +"ui\common\scx_menu\operation-briefing\border-console-bot_bmp.dds" , "1020" , "96" , "dds" +"ui\common\scx_menu\operation-briefing\border-console-top_bmp.dds" , "596" , "72" , "dds" +"ui\common\scx_menu\operation-briefing\emiter-bar_bmp.dds" , "872" , "96" , "dds" +"ui\common\scx_menu\operation-briefing\emiter-bar-aeon_bmp.dds" , "920" , "56" , "dds" +"ui\common\scx_menu\operation-briefing\emiter-bar-cybran_bmp.dds" , "872" , "116" , "dds" +"ui\common\scx_menu\operation-briefing\emiter-bar-uef_bmp.dds" , "872" , "64" , "dds" +"ui\common\scx_menu\operation-briefing\large-emitter_bmp.dds" , "36" , "68" , "dds" +"ui\common\scx_menu\operation-briefing\popup_btn_dis.dds" , "100" , "36" , "dds" +"ui\common\scx_menu\operation-briefing\popup_btn_down.dds" , "100" , "36" , "dds" +"ui\common\scx_menu\operation-briefing\popup_btn_over.dds" , "100" , "36" , "dds" +"ui\common\scx_menu\operation-briefing\popup_btn_up.dds" , "100" , "36" , "dds" +"ui\common\scx_menu\operation-briefing\small-emitter_bmp.dds" , "28" , "56" , "dds" +"ui\common\scx_menu\operation-briefing\status-bar_bmp.dds" , "360" , "16" , "dds" +"ui\common\scx_menu\operation-briefing\status-bar-back_bmp.dds" , "348" , "12" , "dds" +"ui\common\scx_menu\operation-briefing\subtitle_btn_off_over.dds" , "176" , "32" , "dds" +"ui\common\scx_menu\operation-briefing\subtitle_btn_off.dds" , "176" , "32" , "dds" +"ui\common\scx_menu\operation-briefing\subtitle_btn_on_over.dds" , "176" , "32" , "dds" +"ui\common\scx_menu\operation-briefing\subtitle_btn_on.dds" , "176" , "32" , "dds" +"ui\common\scx_menu\operation-briefing\text-panel_bmp.dds" , "844" , "192" , "dds" +"ui\common\scx_menu\operation-briefing\text-panel-aeon_bmp.dds" , "852" , "204" , "dds" +"ui\common\scx_menu\operation-briefing\text-panel-cybran_bmp.dds" , "844" , "192" , "dds" +"ui\common\scx_menu\operation-briefing\text-panel-uef_bmp.dds" , "844" , "192" , "dds" +"ui\common\scx_menu\options\content-box_bmp.dds" , "604" , "36" , "dds" +"ui\common\scx_menu\options\content-btn-line_bmp.dds" , "232" , "4" , "dds" +"ui\common\scx_menu\options\panel_bmp.dds" , "700" , "600" , "dds" +"ui\common\scx_menu\options\slots_bmp.dds" , "640" , "380" , "dds" +"ui\common\scx_menu\panel-brackets-small\bracket-glow-ll_bmp.dds" , "180" , "120" , "dds" +"ui\common\scx_menu\panel-brackets-small\bracket-glow-lr_bmp.dds" , "180" , "120" , "dds" +"ui\common\scx_menu\panel-brackets-small\bracket-glow-ul_bmp.dds" , "180" , "120" , "dds" +"ui\common\scx_menu\panel-brackets-small\bracket-glow-ur_bmp.dds" , "180" , "120" , "dds" +"ui\common\scx_menu\panel-brackets-small\bracket-ll_bmp.dds" , "180" , "120" , "dds" +"ui\common\scx_menu\panel-brackets-small\bracket-lr_bmp.dds" , "180" , "120" , "dds" +"ui\common\scx_menu\panel-brackets-small\bracket-ul_bmp.dds" , "180" , "120" , "dds" +"ui\common\scx_menu\panel-brackets-small\bracket-ur_bmp.dds" , "180" , "120" , "dds" +"ui\common\scx_menu\panel-brackets\bracket-glow-ll_bmp.dds" , "120" , "180" , "dds" +"ui\common\scx_menu\panel-brackets\bracket-glow-lr_bmp.dds" , "120" , "180" , "dds" +"ui\common\scx_menu\panel-brackets\bracket-glow-ul_bmp.dds" , "120" , "180" , "dds" +"ui\common\scx_menu\panel-brackets\bracket-glow-ur_bmp.dds" , "120" , "180" , "dds" +"ui\common\scx_menu\panel-brackets\bracket-ll_bmp.dds" , "120" , "180" , "dds" +"ui\common\scx_menu\panel-brackets\bracket-lr_bmp.dds" , "120" , "180" , "dds" +"ui\common\scx_menu\panel-brackets\bracket-ul_bmp.dds" , "120" , "180" , "dds" +"ui\common\scx_menu\panel-brackets\bracket-ur_bmp.dds" , "120" , "180" , "dds" +"ui\common\scx_menu\panel-brd\conn-bg.dds" , "396" , "60" , "dds" +"ui\common\scx_menu\panel-brd\panel_brd_horz_um.dds" , "8" , "68" , "dds" +"ui\common\scx_menu\panel-brd\panel_brd_ll.dds" , "76" , "72" , "dds" +"ui\common\scx_menu\panel-brd\panel_brd_lm.dds" , "8" , "68" , "dds" +"ui\common\scx_menu\panel-brd\panel_brd_lr.dds" , "76" , "72" , "dds" +"ui\common\scx_menu\panel-brd\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\common\scx_menu\panel-brd\panel_brd_ul.dds" , "76" , "72" , "dds" +"ui\common\scx_menu\panel-brd\panel_brd_ur.dds" , "76" , "72" , "dds" +"ui\common\scx_menu\panel-brd\panel_brd_vert_l.dds" , "76" , "8" , "dds" +"ui\common\scx_menu\panel-brd\panel_brd_vert_r.dds" , "76" , "8" , "dds" +"ui\common\scx_menu\popup_btn\popup_btn_dis.dds" , "100" , "32" , "dds" +"ui\common\scx_menu\popup_btn\popup_btn_down.dds" , "100" , "32" , "dds" +"ui\common\scx_menu\popup_btn\popup_btn_over.dds" , "100" , "32" , "dds" +"ui\common\scx_menu\popup_btn\popup_btn_up.dds" , "100" , "32" , "dds" +"ui\common\scx_menu\profile-brackets\bracket-lg_bmp_left.dds" , "36" , "96" , "dds" +"ui\common\scx_menu\profile-brackets\bracket-lg_bmp_right.dds" , "36" , "96" , "dds" +"ui\common\scx_menu\profile-edit_btn\profile-edit_btn_dis.dds" , "304" , "44" , "dds" +"ui\common\scx_menu\profile-edit_btn\profile-edit_btn_down.dds" , "304" , "44" , "dds" +"ui\common\scx_menu\profile-edit_btn\profile-edit_btn_glow.dds" , "304" , "44" , "dds" +"ui\common\scx_menu\profile-edit_btn\profile-edit_btn_over.dds" , "304" , "44" , "dds" +"ui\common\scx_menu\profile-edit_btn\profile-edit_btn_up.dds" , "304" , "44" , "dds" +"ui\common\scx_menu\profile\panel_bmp.dds" , "580" , "448" , "dds" +"ui\common\scx_menu\replay\panel_bmp.dds" , "704" , "588" , "dds" +"ui\common\scx_menu\restrict_units\bg_over.dds" , "368" , "32" , "dds" +"ui\common\scx_menu\restrict_units\bg_sel_over.dds" , "368" , "32" , "dds" +"ui\common\scx_menu\restrict_units\bg_sel_up.dds" , "368" , "32" , "dds" +"ui\common\scx_menu\restrict_units\bg_up.dds" , "368" , "32" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel_bmp.dds" , "972" , "588" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-campaign_bmp.dds" , "904" , "300" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-campaign_brd_ll.dds" , "8" , "8" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-campaign_brd_lm.dds" , "8" , "8" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-campaign_brd_lr.dds" , "8" , "8" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-campaign_brd_ul.dds" , "8" , "8" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-campaign_brd_um.dds" , "8" , "8" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-campaign_brd_ur.dds" , "8" , "8" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-campaign_brd_vert_l.dds" , "8" , "8" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-campaign_brd_vert_r.dds" , "8" , "8" , "dds" +"ui\common\scx_menu\score-victory-defeat\panel-line_bmp.dds" , "920" , "4" , "dds" +"ui\common\scx_menu\score-victory-defeat\player-back_bmp.dds" , "912" , "36" , "dds" +"ui\common\scx_menu\score-victory-defeat\totals-back_bmp.dds" , "284" , "40" , "dds" +"ui\common\scx_menu\score-victory-defeat\video-frame_bmp.dds" , "336" , "200" , "dds" +"ui\common\scx_menu\small-btn\small_btn_dis.dds" , "200" , "72" , "dds" +"ui\common\scx_menu\small-btn\small_btn_down.dds" , "200" , "72" , "dds" +"ui\common\scx_menu\small-btn\small_btn_glow.dds" , "200" , "72" , "dds" +"ui\common\scx_menu\small-btn\small_btn_over.dds" , "200" , "72" , "dds" +"ui\common\scx_menu\small-btn\small_btn_up.dds" , "200" , "72" , "dds" +"ui\common\scx_menu\small-short-btn\small-btn_dis.dds" , "172" , "52" , "dds" +"ui\common\scx_menu\small-short-btn\small-btn_down.dds" , "172" , "52" , "dds" +"ui\common\scx_menu\small-short-btn\small-btn_glow.dds" , "172" , "52" , "dds" +"ui\common\scx_menu\small-short-btn\small-btn_over.dds" , "172" , "52" , "dds" +"ui\common\scx_menu\small-short-btn\small-btn_up.dds" , "172" , "52" , "dds" +"ui\common\scx_menu\small-wide-btn\small-btn_dis.dds" , "260" , "72" , "dds" +"ui\common\scx_menu\small-wide-btn\small-btn_down.dds" , "260" , "72" , "dds" +"ui\common\scx_menu\small-wide-btn\small-btn_glow.dds" , "260" , "72" , "dds" +"ui\common\scx_menu\small-wide-btn\small-btn_over.dds" , "260" , "72" , "dds" +"ui\common\scx_menu\small-wide-btn\small-btn_up.dds" , "260" , "72" , "dds" +"ui\common\scx_menu\subtitle_btn\subtitles_btn_off_over.dds" , "176" , "36" , "dds" +"ui\common\scx_menu\subtitle_btn\subtitles_btn_off.dds" , "176" , "36" , "dds" +"ui\common\scx_menu\subtitle_btn\subtitles_btn_on_over.dds" , "176" , "36" , "dds" +"ui\common\scx_menu\subtitle_btn\subtitles_btn_on.dds" , "176" , "36" , "dds" +"ui\common\scx_menu\tab_btn\tab_btn_dis.dds" , "180" , "60" , "dds" +"ui\common\scx_menu\tab_btn\tab_btn_down.dds" , "180" , "60" , "dds" +"ui\common\scx_menu\tab_btn\tab_btn_over.dds" , "180" , "60" , "dds" +"ui\common\scx_menu\tab_btn\tab_btn_selected.dds" , "180" , "60" , "dds" +"ui\common\scx_menu\tab_btn\tab_btn_selected02.dds" , "180" , "60" , "dds" +"ui\common\scx_menu\tab_btn\tab_btn_up.dds" , "180" , "60" , "dds" +"ui\common\scx_menu\toggle-lg_btn\toggle-d_btn_dis.dds" , "112" , "48" , "dds" +"ui\common\scx_menu\toggle-lg_btn\toggle-d_btn_down.dds" , "112" , "48" , "dds" +"ui\common\scx_menu\toggle-lg_btn\toggle-d_btn_over.dds" , "112" , "48" , "dds" +"ui\common\scx_menu\toggle-lg_btn\toggle-d_btn_up.dds" , "112" , "48" , "dds" +"ui\common\scx_menu\toggle-lg_btn\toggle-s_btn_dis.dds" , "112" , "48" , "dds" +"ui\common\scx_menu\toggle-lg_btn\toggle-s_btn_down.dds" , "112" , "48" , "dds" +"ui\common\scx_menu\toggle-lg_btn\toggle-s_btn_over.dds" , "112" , "48" , "dds" +"ui\common\scx_menu\toggle-lg_btn\toggle-s_btn_up.dds" , "112" , "48" , "dds" +"ui\common\seraphim_load.dds" , "1280" , "1024" , "dds" +"ui\common\slider\slider_btn_dis.dds" , "32" , "24" , "dds" +"ui\common\slider\slider_btn_down.dds" , "32" , "24" , "dds" +"ui\common\slider\slider_btn_over.dds" , "32" , "24" , "dds" +"ui\common\slider\slider_btn_up.dds" , "32" , "24" , "dds" +"ui\common\slider\slider-back_bmp.dds" , "200" , "20" , "dds" +"ui\common\slider\slider-bar-green_bmp.dds" , "196" , "16" , "dds" +"ui\common\slider\slider-bar-orange_bmp.dds" , "196" , "16" , "dds" +"ui\common\slider02\slider_btn_dis.dds" , "32" , "24" , "dds" +"ui\common\slider02\slider_btn_down.dds" , "32" , "28" , "dds" +"ui\common\slider02\slider_btn_over.dds" , "32" , "24" , "dds" +"ui\common\slider02\slider_btn_up.dds" , "32" , "24" , "dds" +"ui\common\slider02\slider-back_bmp.dds" , "200" , "24" , "dds" +"ui\common\slider02\slider-bar-green_bmp.dds" , "196" , "16" , "dds" +"ui\common\slider02\slider-bar-orange_bmp.dds" , "196" , "16" , "dds" +"ui\common\small_wide_btn\small_wide_btn_dis.dds" , "204" , "48" , "dds" +"ui\common\small_wide_btn\small_wide_btn_dis.png" , "204" , "48" , "png" +"ui\common\small_wide_btn\small_wide_btn_down.dds" , "204" , "48" , "dds" +"ui\common\small_wide_btn\small_wide_btn_down.png" , "204" , "48" , "png" +"ui\common\small_wide_btn\small_wide_btn_over.dds" , "204" , "48" , "dds" +"ui\common\small_wide_btn\small_wide_btn_over.png" , "204" , "48" , "png" +"ui\common\small_wide_btn\small_wide_btn_up.dds" , "204" , "48" , "dds" +"ui\common\small_wide_btn\small_wide_btn_up.png" , "204" , "48" , "png" +"ui\common\small-stretch_btn\small_btn_dis_l.dds" , "32" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_dis_m.dds" , "8" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_dis_r.dds" , "32" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_down_l.dds" , "32" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_down_m.dds" , "8" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_down_r.dds" , "32" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_over_l.dds" , "32" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_over_m.dds" , "8" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_over_r.dds" , "32" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_up_l.dds" , "32" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_up_m.dds" , "8" , "36" , "dds" +"ui\common\small-stretch_btn\small_btn_up_r.dds" , "32" , "36" , "dds" +"ui\common\small-vert_scroll-aeon\arrow-down_scr_dis.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-aeon\arrow-down_scr_down.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-aeon\arrow-down_scr_over.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-aeon\arrow-down_scr_up.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-aeon\arrow-up_scr_dis.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-aeon\arrow-up_scr_down.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-aeon\arrow-up_scr_over.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-aeon\arrow-up_scr_up.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-aeon\back_scr_bot.dds" , "28" , "12" , "dds" +"ui\common\small-vert_scroll-aeon\back_scr_mid.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\back_scr_top.dds" , "28" , "12" , "dds" +"ui\common\small-vert_scroll-aeon\bar-bot_scr_dis.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-bot_scr_down.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-bot_scr_over.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-bot_scr_up.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-mid_scr_dis.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-mid_scr_down.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-mid_scr_over.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-mid_scr_up.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-top_scr_dis.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-top_scr_down.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-top_scr_over.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-aeon\bar-top_scr_up.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-cybran\arrow-down_scr_dis.dds" , "36" , "32" , "dds" +"ui\common\small-vert_scroll-cybran\arrow-down_scr_down.dds" , "36" , "32" , "dds" +"ui\common\small-vert_scroll-cybran\arrow-down_scr_over.dds" , "36" , "32" , "dds" +"ui\common\small-vert_scroll-cybran\arrow-down_scr_up.dds" , "36" , "32" , "dds" +"ui\common\small-vert_scroll-cybran\arrow-up_scr_dis.dds" , "36" , "32" , "dds" +"ui\common\small-vert_scroll-cybran\arrow-up_scr_down.dds" , "36" , "32" , "dds" +"ui\common\small-vert_scroll-cybran\arrow-up_scr_over.dds" , "36" , "32" , "dds" +"ui\common\small-vert_scroll-cybran\arrow-up_scr_up.dds" , "36" , "32" , "dds" +"ui\common\small-vert_scroll-cybran\back_scr_mid.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-cybran\bar-bot_scr_up.dds" , "36" , "12" , "dds" +"ui\common\small-vert_scroll-cybran\bar-mid_scr_up.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-cybran\bar-top_scr_up.dds" , "36" , "12" , "dds" +"ui\common\small-vert_scroll-uef\arrow-down_scr_dis.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-uef\arrow-down_scr_down.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-uef\arrow-down_scr_over.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-uef\arrow-down_scr_up.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-uef\arrow-up_scr_dis.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-uef\arrow-up_scr_down.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-uef\arrow-up_scr_over.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-uef\arrow-up_scr_up.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll-uef\back_scr_mid.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-uef\bar-bot_scr_up.dds" , "36" , "12" , "dds" +"ui\common\small-vert_scroll-uef\bar-mid_scr_up.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll-uef\bar-top_scr_up.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\arrow-down_scr_dis.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll\arrow-down_scr_down.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll\arrow-down_scr_over.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll\arrow-down_scr_up.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll\arrow-up_scr_dis.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll\arrow-up_scr_down.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll\arrow-up_scr_over.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll\arrow-up_scr_up.dds" , "36" , "28" , "dds" +"ui\common\small-vert_scroll\back_scr_bot.dds" , "36" , "36" , "dds" +"ui\common\small-vert_scroll\back_scr_mid.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\back_scr_top.dds" , "28" , "12" , "dds" +"ui\common\small-vert_scroll\bar-bot_scr_dis.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-bot_scr_down.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-bot_scr_over.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-bot_scr_up.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-mid_scr_dis.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-mid_scr_down.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-mid_scr_over.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-mid_scr_up.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-top_scr_dis.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-top_scr_down.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-top_scr_over.dds" , "36" , "8" , "dds" +"ui\common\small-vert_scroll\bar-top_scr_up.dds" , "36" , "8" , "dds" +"ui\common\text_hotspot\text-edge_bmp.dds" , "16" , "84" , "dds" +"ui\common\text_hotspot\text-edge-glow_bmp.dds" , "16" , "84" , "dds" +"ui\common\UEF_load.dds" , "1280" , "1024" , "dds" +"ui\common\uef-btn-small\small_btn_dis.dds" , "196" , "76" , "dds" +"ui\common\uef-btn-small\small_btn_down.dds" , "196" , "76" , "dds" +"ui\common\uef-btn-small\small_btn_over.dds" , "196" , "76" , "dds" +"ui\common\uef-btn-small\small_btn_up.dds" , "196" , "76" , "dds" +"ui\common\widgets-cybran\large_btn_dis.dds" , "392" , "92" , "dds" +"ui\common\widgets-cybran\large_btn_down.dds" , "392" , "92" , "dds" +"ui\common\widgets-cybran\large_btn_over.dds" , "392" , "92" , "dds" +"ui\common\widgets-cybran\large_btn_up.dds" , "392" , "92" , "dds" +"ui\common\widgets-cybran\medium_btn_dis.dds" , "244" , "48" , "dds" +"ui\common\widgets-cybran\medium_btn_down.dds" , "244" , "48" , "dds" +"ui\common\widgets-cybran\medium_btn_over.dds" , "244" , "48" , "dds" +"ui\common\widgets-cybran\medium_btn_up.dds" , "244" , "48" , "dds" +"ui\common\widgets-cybran\panel-profile_bmp.dds" , "188" , "40" , "dds" +"ui\common\widgets-cybran\profile-select_btn_dis.dds" , "136" , "28" , "dds" +"ui\common\widgets-cybran\profile-select_btn_down.dds" , "136" , "28" , "dds" +"ui\common\widgets-cybran\profile-select_btn_over.dds" , "136" , "28" , "dds" +"ui\common\widgets-cybran\profile-select_btn_up.dds" , "136" , "28" , "dds" +"ui\common\widgets-cybran\small_btn_dis.dds" , "152" , "44" , "dds" +"ui\common\widgets-cybran\small_btn_down.dds" , "152" , "44" , "dds" +"ui\common\widgets-cybran\small_btn_over.dds" , "152" , "44" , "dds" +"ui\common\widgets-cybran\small_btn_up.dds" , "152" , "44" , "dds" +"ui\common\widgets\64x32_btn_dis.dds" , "64" , "32" , "dds" +"ui\common\widgets\64x32_btn_down.dds" , "64" , "32" , "dds" +"ui\common\widgets\64x32_btn_over.dds" , "64" , "32" , "dds" +"ui\common\widgets\64x32_btn_up.dds" , "64" , "32" , "dds" +"ui\common\widgets\64x64_btn_dis.dds" , "64" , "64" , "dds" +"ui\common\widgets\64x64_btn_down.dds" , "64" , "64" , "dds" +"ui\common\widgets\64x64_btn_over.dds" , "64" , "64" , "dds" +"ui\common\widgets\64x64_btn_up.dds" , "64" , "64" , "dds" +"ui\common\widgets\back_scr\back_scr_bot.dds" , "32" , "8" , "dds" +"ui\common\widgets\back_scr\back_scr_mid.dds" , "32" , "8" , "dds" +"ui\common\widgets\back_scr\back_scr_top.dds" , "32" , "8" , "dds" +"ui\common\widgets\back-small_scr\back-small_scr_bot.dds" , "24" , "8" , "dds" +"ui\common\widgets\back-small_scr\back-small_scr_mid.dds" , "24" , "8" , "dds" +"ui\common\widgets\back-small_scr\back-small_scr_top.dds" , "24" , "8" , "dds" +"ui\common\widgets\bar_sbr.dds" , "32" , "32" , "dds" +"ui\common\widgets\bg_sbr.dds" , "32" , "32" , "dds" +"ui\common\widgets\drop-down\drop_btn_dis_l.dds" , "20" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_dis_m.dds" , "8" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_dis_r.dds" , "40" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_down_l.dds" , "20" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_down_m.dds" , "8" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_down_r.dds" , "40" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_over_l.dds" , "20" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_over_m.dds" , "8" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_over_r.dds" , "40" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_up_l.dds" , "20" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_up_m.dds" , "8" , "20" , "dds" +"ui\common\widgets\drop-down\drop_btn_up_r.dds" , "40" , "20" , "dds" +"ui\common\widgets\drop-down\drop-box_brd_horz_um.dds" , "12" , "12" , "dds" +"ui\common\widgets\drop-down\drop-box_brd_ll.dds" , "12" , "12" , "dds" +"ui\common\widgets\drop-down\drop-box_brd_lm.dds" , "12" , "12" , "dds" +"ui\common\widgets\drop-down\drop-box_brd_lr.dds" , "12" , "12" , "dds" +"ui\common\widgets\drop-down\drop-box_brd_m.dds" , "12" , "12" , "dds" +"ui\common\widgets\drop-down\drop-box_brd_ul.dds" , "12" , "12" , "dds" +"ui\common\widgets\drop-down\drop-box_brd_ur.dds" , "12" , "12" , "dds" +"ui\common\widgets\drop-down\drop-box_brd_vert_l.dds" , "12" , "12" , "dds" +"ui\common\widgets\drop-down\drop-box_brd_vert_r.dds" , "12" , "12" , "dds" +"ui\common\widgets\drop-down\player-text-highlight_bmp.dds" , "12" , "16" , "dds" +"ui\common\widgets\faction-icons-alpha_bmp\aeon_ico.dds" , "24" , "24" , "dds" +"ui\common\widgets\faction-icons-alpha_bmp\cybran_ico.dds" , "24" , "24" , "dds" +"ui\common\widgets\faction-icons-alpha_bmp\observer_ico.dds" , "24" , "24" , "dds" +"ui\common\widgets\faction-icons-alpha_bmp\seraphim_ico.dds" , "24" , "24" , "dds" +"ui\common\widgets\faction-icons-alpha_bmp\uef_ico.dds" , "24" , "24" , "dds" +"ui\common\widgets\gen_brd_horz.dds" , "4" , "4" , "dds" +"ui\common\widgets\gen_brd_ll.dds" , "16" , "16" , "dds" +"ui\common\widgets\gen_brd_lr.dds" , "16" , "16" , "dds" +"ui\common\widgets\gen_brd_ul.dds" , "16" , "16" , "dds" +"ui\common\widgets\gen_brd_ur.dds" , "16" , "16" , "dds" +"ui\common\widgets\gen_brd_vert.dds" , "4" , "4" , "dds" +"ui\common\widgets\gen-tab_btn_dis.dds" , "44" , "16" , "dds" +"ui\common\widgets\gen-tab_btn_down.dds" , "44" , "16" , "dds" +"ui\common\widgets\gen-tab_btn_over.dds" , "44" , "16" , "dds" +"ui\common\widgets\gen-tab_btn_up.dds" , "44" , "16" , "dds" +"ui\common\widgets\generic03_brd\generic_brd_horz_lm.dds" , "12" , "12" , "dds" +"ui\common\widgets\generic03_brd\generic_brd_horz_um.dds" , "12" , "12" , "dds" +"ui\common\widgets\generic03_brd\generic_brd_ll.dds" , "12" , "12" , "dds" +"ui\common\widgets\generic03_brd\generic_brd_lr.dds" , "12" , "12" , "dds" +"ui\common\widgets\generic03_brd\generic_brd_m.dds" , "12" , "12" , "dds" +"ui\common\widgets\generic03_brd\generic_brd_ul.dds" , "12" , "12" , "dds" +"ui\common\widgets\generic03_brd\generic_brd_ur.dds" , "12" , "12" , "dds" +"ui\common\widgets\generic03_brd\generic_brd_vert_l.dds" , "12" , "12" , "dds" +"ui\common\widgets\generic03_brd\generic_brd_vert_r.dds" , "12" , "12" , "dds" +"ui\common\widgets\help_btn\help-sm_btn_dis.dds" , "40" , "40" , "dds" +"ui\common\widgets\help_btn\help-sm_btn_down.dds" , "40" , "40" , "dds" +"ui\common\widgets\help_btn\help-sm_btn_over.dds" , "40" , "40" , "dds" +"ui\common\widgets\help_btn\help-sm_btn_up.dds" , "40" , "40" , "dds" +"ui\common\widgets\large_btn_dis.dds" , "392" , "92" , "dds" +"ui\common\widgets\large_btn_down.dds" , "392" , "92" , "dds" +"ui\common\widgets\large_btn_over.dds" , "392" , "92" , "dds" +"ui\common\widgets\large_btn_up.dds" , "392" , "92" , "dds" +"ui\common\widgets\large_scr\arrow-down_scr_dis.dds" , "32" , "24" , "dds" +"ui\common\widgets\large_scr\arrow-down_scr_down.dds" , "40" , "28" , "dds" +"ui\common\widgets\large_scr\arrow-down_scr_over.dds" , "40" , "28" , "dds" +"ui\common\widgets\large_scr\arrow-down_scr_up.dds" , "40" , "28" , "dds" +"ui\common\widgets\large_scr\arrow-up_scr_dis.dds" , "32" , "24" , "dds" +"ui\common\widgets\large_scr\arrow-up_scr_down.dds" , "40" , "28" , "dds" +"ui\common\widgets\large_scr\arrow-up_scr_over.dds" , "40" , "28" , "dds" +"ui\common\widgets\large_scr\arrow-up_scr_up.dds" , "40" , "28" , "dds" +"ui\common\widgets\large_scr\bar-bot_scr_dis.dds" , "28" , "12" , "dds" +"ui\common\widgets\large_scr\bar-bot_scr_down.dds" , "40" , "12" , "dds" +"ui\common\widgets\large_scr\bar-bot_scr_over.dds" , "40" , "12" , "dds" +"ui\common\widgets\large_scr\bar-bot_scr_up.dds" , "28" , "12" , "dds" +"ui\common\widgets\large_scr\bar-mid_scr_dis.dds" , "28" , "28" , "dds" +"ui\common\widgets\large_scr\bar-mid_scr_down.dds" , "40" , "28" , "dds" +"ui\common\widgets\large_scr\bar-mid_scr_over.dds" , "40" , "28" , "dds" +"ui\common\widgets\large_scr\bar-mid_scr_up.dds" , "28" , "28" , "dds" +"ui\common\widgets\large_scr\bar-top_scr_dis.dds" , "28" , "12" , "dds" +"ui\common\widgets\large_scr\bar-top_scr_down.dds" , "40" , "12" , "dds" +"ui\common\widgets\large_scr\bar-top_scr_over.dds" , "40" , "12" , "dds" +"ui\common\widgets\large_scr\bar-top_scr_up.dds" , "28" , "12" , "dds" +"ui\common\widgets\large-h_scr\arrow-left_scr_dis.dds" , "24" , "32" , "dds" +"ui\common\widgets\large-h_scr\arrow-left_scr_down.dds" , "28" , "40" , "dds" +"ui\common\widgets\large-h_scr\arrow-left_scr_over.dds" , "28" , "40" , "dds" +"ui\common\widgets\large-h_scr\arrow-left_scr_up.dds" , "28" , "40" , "dds" +"ui\common\widgets\large-h_scr\arrow-right_scr_dis.dds" , "24" , "32" , "dds" +"ui\common\widgets\large-h_scr\arrow-right_scr_down.dds" , "28" , "40" , "dds" +"ui\common\widgets\large-h_scr\arrow-right_scr_over.dds" , "28" , "40" , "dds" +"ui\common\widgets\large-h_scr\arrow-right_scr_up.dds" , "28" , "40" , "dds" +"ui\common\widgets\large-h_scr\bar-left_scr_dis.dds" , "12" , "28" , "dds" +"ui\common\widgets\large-h_scr\bar-left_scr_down.dds" , "12" , "40" , "dds" +"ui\common\widgets\large-h_scr\bar-left_scr_over.dds" , "12" , "40" , "dds" +"ui\common\widgets\large-h_scr\bar-left_scr_up.dds" , "12" , "28" , "dds" +"ui\common\widgets\large-h_scr\bar-mid_scr_dis.dds" , "28" , "28" , "dds" +"ui\common\widgets\large-h_scr\bar-mid_scr_down.dds" , "28" , "40" , "dds" +"ui\common\widgets\large-h_scr\bar-mid_scr_over.dds" , "28" , "40" , "dds" +"ui\common\widgets\large-h_scr\bar-mid_scr_up.dds" , "28" , "28" , "dds" +"ui\common\widgets\large-h_scr\bar-right_scr_dis.dds" , "12" , "28" , "dds" +"ui\common\widgets\large-h_scr\bar-right_scr_down.dds" , "12" , "40" , "dds" +"ui\common\widgets\large-h_scr\bar-right_scr_over.dds" , "12" , "40" , "dds" +"ui\common\widgets\large-h_scr\bar-right_scr_up.dds" , "12" , "28" , "dds" +"ui\common\widgets\large02_btn_dis.dds" , "240" , "72" , "dds" +"ui\common\widgets\large02_btn_down.dds" , "240" , "72" , "dds" +"ui\common\widgets\large02_btn_over.dds" , "240" , "72" , "dds" +"ui\common\widgets\large02_btn_up.dds" , "240" , "72" , "dds" +"ui\common\widgets\medium_btn_dis.dds" , "276" , "72" , "dds" +"ui\common\widgets\medium_btn_down.dds" , "280" , "72" , "dds" +"ui\common\widgets\medium_btn_over.dds" , "276" , "72" , "dds" +"ui\common\widgets\medium_btn_up.dds" , "276" , "72" , "dds" +"ui\common\widgets\rad_down.dds" , "32" , "32" , "dds" +"ui\common\widgets\rad_over.dds" , "32" , "32" , "dds" +"ui\common\widgets\rad_sel.dds" , "32" , "32" , "dds" +"ui\common\widgets\rad_un.dds" , "32" , "32" , "dds" +"ui\common\widgets\radio02\rad_btn_disabled.dds" , "24" , "24" , "dds" +"ui\common\widgets\radio02\rad_btn_enabled.dds" , "24" , "24" , "dds" +"ui\common\widgets\small_btn_dis.dds" , "152" , "40" , "dds" +"ui\common\widgets\small_btn_down.dds" , "152" , "40" , "dds" +"ui\common\widgets\small_btn_over.dds" , "152" , "40" , "dds" +"ui\common\widgets\small_btn_up.dds" , "152" , "40" , "dds" +"ui\common\widgets\small_scr\arrow-down_scr_dis.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\arrow-down_scr_down.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\arrow-down_scr_over.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\arrow-down_scr_up.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\arrow-up_scr_dis.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\arrow-up_scr_down.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\arrow-up_scr_over.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\arrow-up_scr_up.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-bot_scr_dis.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-bot_scr_down.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-bot_scr_over.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-bot_scr_up.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-mid_scr_dis.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-mid_scr_down.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-mid_scr_over.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-mid_scr_up.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-top_scr_dis.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-top_scr_down.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-top_scr_over.dds" , "28" , "20" , "dds" +"ui\common\widgets\small_scr\bar-top_scr_up.dds" , "28" , "20" , "dds" +"ui\common\widgets\small02_btn_dis.dds" , "152" , "40" , "dds" +"ui\common\widgets\small02_btn_down.dds" , "152" , "40" , "dds" +"ui\common\widgets\small02_btn_over.dds" , "152" , "40" , "dds" +"ui\common\widgets\small02_btn_up.dds" , "152" , "40" , "dds" +"ui\common\widgets\text_box.dds" , "624" , "60" , "dds" +"ui\common\widgets\text_over.dds" , "340" , "16" , "dds" +"ui\common\widgets\text-entry-brd\text-entry_brd_left.dds" , "8" , "20" , "dds" +"ui\common\widgets\text-entry-brd\text-entry_brd_mid.dds" , "4" , "20" , "dds" +"ui\common\widgets\text-entry-brd\text-entry_brd_right.dds" , "8" , "20" , "dds" +"ui\common\widgets\text-entry-brd02\text-entry_brd_left.dds" , "12" , "24" , "dds" +"ui\common\widgets\text-entry-brd02\text-entry_brd_mid.dds" , "12" , "24" , "dds" +"ui\common\widgets\text-entry-brd02\text-entry_brd_right.dds" , "12" , "24" , "dds" +"ui\common\widgets\toggle_btn_dis.dds" , "120" , "32" , "dds" +"ui\common\widgets\toggle_btn_down.dds" , "120" , "32" , "dds" +"ui\common\widgets\toggle_btn_over.dds" , "120" , "32" , "dds" +"ui\common\widgets\toggle_btn_up.dds" , "120" , "32" , "dds" +"ui\common\widgets02\large_btn_dis.dds" , "296" , "64" , "dds" +"ui\common\widgets02\large_btn_down.dds" , "296" , "64" , "dds" +"ui\common\widgets02\large_btn_over.dds" , "296" , "64" , "dds" +"ui\common\widgets02\large_btn_up.dds" , "296" , "64" , "dds" +"ui\common\widgets02\large02_btn_dis.dds" , "240" , "72" , "dds" +"ui\common\widgets02\large02_btn_down.dds" , "240" , "72" , "dds" +"ui\common\widgets02\large02_btn_over.dds" , "240" , "72" , "dds" +"ui\common\widgets02\large02_btn_up.dds" , "240" , "72" , "dds" +"ui\common\widgets02\medium_btn_dis.dds" , "276" , "72" , "dds" +"ui\common\widgets02\medium_btn_down.dds" , "280" , "72" , "dds" +"ui\common\widgets02\medium_btn_over.dds" , "276" , "72" , "dds" +"ui\common\widgets02\medium_btn_up.dds" , "276" , "72" , "dds" +"ui\common\widgets02\panel-profile02_bmp.dds" , "336" , "76" , "dds" +"ui\common\widgets02\profile-select_btn_dis.dds" , "72" , "32" , "dds" +"ui\common\widgets02\profile-select_btn_down.dds" , "72" , "32" , "dds" +"ui\common\widgets02\profile-select_btn_over.dds" , "72" , "32" , "dds" +"ui\common\widgets02\profile-select_btn_up.dds" , "72" , "32" , "dds" +"ui\common\widgets02\rad_sel.dds" , "32" , "32" , "dds" +"ui\common\widgets02\rad_un.dds" , "32" , "32" , "dds" +"ui\common\widgets02\small_btn_dis.dds" , "148" , "36" , "dds" +"ui\common\widgets02\small_btn_down.dds" , "148" , "36" , "dds" +"ui\common\widgets02\small_btn_over.dds" , "148" , "36" , "dds" +"ui\common\widgets02\small_btn_up.dds" , "148" , "36" , "dds" +"ui\common\widgets02\small-back_btn_dis.dds" , "168" , "40" , "dds" +"ui\common\widgets02\small-back_btn_down.dds" , "168" , "40" , "dds" +"ui\common\widgets02\small-back_btn_over.dds" , "168" , "40" , "dds" +"ui\common\widgets02\small-back_btn_up.dds" , "168" , "40" , "dds" +"ui\common\widgets02\small-back-trans_btn_dis.dds" , "164" , "36" , "dds" +"ui\common\widgets02\small-back-trans_btn_down.dds" , "164" , "36" , "dds" +"ui\common\widgets02\small-back-trans_btn_over.dds" , "164" , "36" , "dds" +"ui\common\widgets02\small-back-trans_btn_up.dds" , "164" , "36" , "dds" +"ui\common\widgets02\thin_btn_dis.dds" , "260" , "32" , "dds" +"ui\common\widgets02\thin_btn_down.dds" , "260" , "32" , "dds" +"ui\common\widgets02\thin_btn_over.dds" , "260" , "32" , "dds" +"ui\common\widgets02\thin_btn_up.dds" , "260" , "32" , "dds" +"ui\common\widgets02\toggle_btn_dis.dds" , "120" , "32" , "dds" +"ui\common\widgets02\toggle_btn_down.dds" , "120" , "32" , "dds" +"ui\common\widgets02\toggle_btn_over.dds" , "120" , "32" , "dds" +"ui\common\widgets02\toggle_btn_up.dds" , "120" , "32" , "dds" +"ui\cybran\dialogs\menu-btns\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\close_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\close_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\close_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\config_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\config_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\config_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\config_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\default_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\default_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\default_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\default_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\pin_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\pin_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\pin_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\pin_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\menu-btns\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\dialogs\time-units-tabs\energy_bmp.dds" , "16" , "28" , "dds" +"ui\cybran\dialogs\time-units-tabs\panel-tracking_bmp_d.dds" , "12" , "44" , "dds" +"ui\cybran\dialogs\time-units-tabs\panel-tracking_bmp_l.dds" , "24" , "44" , "dds" +"ui\cybran\dialogs\time-units-tabs\panel-tracking_bmp_m.dds" , "4" , "44" , "dds" +"ui\cybran\dialogs\time-units-tabs\panel-tracking_bmp_r.dds" , "24" , "44" , "dds" +"ui\cybran\dialogs\time-units-tabs\tracking-icon_bmp.dds" , "24" , "20" , "dds" +"ui\cybran\game\avatar-arrow_btn\tab-close_btn_dis.dds" , "12" , "32" , "dds" +"ui\cybran\game\avatar-arrow_btn\tab-close_btn_down.dds" , "12" , "32" , "dds" +"ui\cybran\game\avatar-arrow_btn\tab-close_btn_over.dds" , "12" , "32" , "dds" +"ui\cybran\game\avatar-arrow_btn\tab-close_btn_up.dds" , "12" , "32" , "dds" +"ui\cybran\game\avatar-arrow_btn\tab-open_btn_dis.dds" , "12" , "32" , "dds" +"ui\cybran\game\avatar-arrow_btn\tab-open_btn_down.dds" , "12" , "32" , "dds" +"ui\cybran\game\avatar-arrow_btn\tab-open_btn_over.dds" , "12" , "32" , "dds" +"ui\cybran\game\avatar-arrow_btn\tab-open_btn_up.dds" , "12" , "32" , "dds" +"ui\cybran\game\avatar-engineers-panel\bracket_bmp.dds" , "28" , "48" , "dds" +"ui\cybran\game\avatar-engineers-panel\panel-eng_bmp_b.dds" , "84" , "8" , "dds" +"ui\cybran\game\avatar-engineers-panel\panel-eng_bmp_m.dds" , "84" , "44" , "dds" +"ui\cybran\game\avatar-engineers-panel\panel-eng_bmp_t.dds" , "84" , "8" , "dds" +"ui\cybran\game\avatar-engineers-panel\tech-1_bmp.dds" , "28" , "28" , "dds" +"ui\cybran\game\avatar-engineers-panel\tech-2_bmp.dds" , "28" , "28" , "dds" +"ui\cybran\game\avatar-engineers-panel\tech-3_bmp.dds" , "28" , "28" , "dds" +"ui\cybran\game\avatar-factory-panel\bracket_bmp.dds" , "28" , "48" , "dds" +"ui\cybran\game\avatar-factory-panel\factory-panel_bmp.dds" , "184" , "160" , "dds" +"ui\cybran\game\avatar\avatar_bmp.dds" , "64" , "68" , "dds" +"ui\cybran\game\avatar\avatar-control-group_bmp.dds" , "48" , "36" , "dds" +"ui\cybran\game\avatar\avatar-s-e-f_bmp.dds" , "56" , "48" , "dds" +"ui\cybran\game\avatar\health-bar-back_bmp.dds" , "52" , "16" , "dds" +"ui\cybran\game\avatar\health-bar.dds" , "40" , "8" , "dds" +"ui\cybran\game\avatar\pulse-bars_bmp.dds" , "64" , "64" , "dds" +"ui\cybran\game\bracket-left-energy\bracket_bmp_b.dds" , "20" , "16" , "dds" +"ui\cybran\game\bracket-left-energy\bracket_bmp_m.dds" , "12" , "4" , "dds" +"ui\cybran\game\bracket-left-energy\bracket_bmp_t.dds" , "20" , "20" , "dds" +"ui\cybran\game\bracket-left-energy\bracket_bmp.dds" , "20" , "68" , "dds" +"ui\cybran\game\bracket-left\bracket_bmp_b.dds" , "32" , "28" , "dds" +"ui\cybran\game\bracket-left\bracket_bmp_m.dds" , "16" , "4" , "dds" +"ui\cybran\game\bracket-left\bracket_bmp_t.dds" , "32" , "68" , "dds" +"ui\cybran\game\bracket-left\bracket_bmp.dds" , "32" , "128" , "dds" +"ui\cybran\game\bracket-right-energy\bracket_bmp_b.dds" , "20" , "16" , "dds" +"ui\cybran\game\bracket-right-energy\bracket_bmp_m.dds" , "12" , "4" , "dds" +"ui\cybran\game\bracket-right-energy\bracket_bmp_t.dds" , "20" , "20" , "dds" +"ui\cybran\game\bracket-right-energy\bracket_bmp.dds" , "20" , "68" , "dds" +"ui\cybran\game\bracket-right\bracket_bmp_b.dds" , "32" , "28" , "dds" +"ui\cybran\game\bracket-right\bracket_bmp_m.dds" , "16" , "4" , "dds" +"ui\cybran\game\bracket-right\bracket_bmp_t.dds" , "32" , "68" , "dds" +"ui\cybran\game\bracket-right\bracket_bmp.dds" , "32" , "128" , "dds" +"ui\cybran\game\c-q-e-panel\arrow_bmp.dds" , "28" , "16" , "dds" +"ui\cybran\game\c-q-e-panel\arrow_vert_bmp.dds" , "16" , "24" , "dds" +"ui\cybran\game\c-q-e-panel\construct-panel_bmp_l.dds" , "84" , "60" , "dds" +"ui\cybran\game\c-q-e-panel\divider_bmp.dds" , "12" , "56" , "dds" +"ui\cybran\game\c-q-e-panel\divider_horizontal_bmp.dds" , "64" , "12" , "dds" +"ui\cybran\game\camera-btn\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\game\camera-btn\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\game\camera-btn\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\game\camera-btn\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\game\chat_brd\chat_brd_horz_um.dds" , "8" , "48" , "dds" +"ui\cybran\game\chat_brd\chat_brd_ll.dds" , "44" , "48" , "dds" +"ui\cybran\game\chat_brd\chat_brd_lm.dds" , "8" , "48" , "dds" +"ui\cybran\game\chat_brd\chat_brd_lr.dds" , "44" , "48" , "dds" +"ui\cybran\game\chat_brd\chat_brd_m.dds" , "8" , "8" , "dds" +"ui\cybran\game\chat_brd\chat_brd_ul.dds" , "44" , "48" , "dds" +"ui\cybran\game\chat_brd\chat_brd_ur.dds" , "44" , "48" , "dds" +"ui\cybran\game\chat_brd\chat_brd_vert_l.dds" , "44" , "8" , "dds" +"ui\cybran\game\chat_brd\chat_brd_vert_r.dds" , "44" , "8" , "dds" +"ui\cybran\game\chat_brd\drop-box_brd_horz_um.dds" , "8" , "16" , "dds" +"ui\cybran\game\chat_brd\drop-box_brd_ll.dds" , "16" , "12" , "dds" +"ui\cybran\game\chat_brd\drop-box_brd_lm.dds" , "8" , "12" , "dds" +"ui\cybran\game\chat_brd\drop-box_brd_lr.dds" , "16" , "12" , "dds" +"ui\cybran\game\chat_brd\drop-box_brd_m.dds" , "8" , "8" , "dds" +"ui\cybran\game\chat_brd\drop-box_brd_ul.dds" , "16" , "16" , "dds" +"ui\cybran\game\chat_brd\drop-box_brd_ur.dds" , "16" , "16" , "dds" +"ui\cybran\game\chat_brd\drop-box_brd_vert_l.dds" , "16" , "8" , "dds" +"ui\cybran\game\chat_brd\drop-box_brd_vert_r.dds" , "16" , "8" , "dds" +"ui\cybran\game\chat-box_btn\radio_btn_dis.dds" , "28" , "28" , "dds" +"ui\cybran\game\chat-box_btn\radio_btn_down.dds" , "28" , "28" , "dds" +"ui\cybran\game\chat-box_btn\radio_btn_over.dds" , "28" , "28" , "dds" +"ui\cybran\game\chat-box_btn\radio_btn_up.dds" , "28" , "28" , "dds" +"ui\cybran\game\construct-panel_vert\construct-panel_bmp_b.dds" , "172" , "12" , "dds" +"ui\cybran\game\construct-panel_vert\construct-panel_bmp_m.dds" , "172" , "8" , "dds" +"ui\cybran\game\construct-panel_vert\construct-panel_bmp_t.dds" , "172" , "64" , "dds" +"ui\cybran\game\construct-panel_vert\que-panel_bmp_b.dds" , "60" , "8" , "dds" +"ui\cybran\game\construct-panel_vert\que-panel_bmp_m.dds" , "60" , "8" , "dds" +"ui\cybran\game\construct-panel_vert\que-panel_bmp_t.dds" , "60" , "8" , "dds" +"ui\cybran\game\construct-panel\construct-panel_bmp_l.dds" , "84" , "140" , "dds" +"ui\cybran\game\construct-panel\construct-panel_bmp_m1.dds" , "8" , "140" , "dds" +"ui\cybran\game\construct-panel\construct-panel_bmp_m2.dds" , "16" , "140" , "dds" +"ui\cybran\game\construct-panel\construct-panel_bmp_m3.dds" , "8" , "112" , "dds" +"ui\cybran\game\construct-panel\construct-panel_bmp_r.dds" , "16" , "112" , "dds" +"ui\cybran\game\construct-panel\construct-panel_s_bmp_l.dds" , "16" , "112" , "dds" +"ui\cybran\game\construct-panel\construct-panel_s_bmp_m.dds" , "8" , "112" , "dds" +"ui\cybran\game\construct-panel\construct-panel_s_bmp_r.dds" , "16" , "112" , "dds" +"ui\cybran\game\construct-panel\que-panel_bmp_l.dds" , "12" , "52" , "dds" +"ui\cybran\game\construct-panel\que-panel_bmp_m.dds" , "8" , "52" , "dds" +"ui\cybran\game\construct-panel\que-panel_bmp_r.dds" , "12" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\back_off.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\back_on.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\fforward_off.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\fforward_on.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\forward_off.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\forward_on.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\infinite_off.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\infinite_on.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\left_btn_dis.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\left_btn_over.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\left_btn_up.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\mid_btn_dis.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\mid_btn_over.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\mid_btn_selected.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\mid_btn_up.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\pause_off.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\pause_on.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\rewind_off.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\rewind_on.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\right_btn_dis.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\right_btn_over.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\right_btn_up.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\template_off.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_btn\template_on.dds" , "24" , "52" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\infinite_off.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\infinite_on.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\pause_off.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\pause_on.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\que_btn_dis.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\que_btn_over.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\que_btn_selected.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\que_btn_up.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\template_off.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_horiz_btn\template_on.dds" , "60" , "28" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\bottom_btn_dis.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\bottom_btn_over.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\bottom_btn_up.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\down_off.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\down_on.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\end_off.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\end_on.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\home_off.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\home_on.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\mid_btn_dis.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\mid_btn_over.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\mid_btn_up.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\top_btn_dis.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\top_btn_over.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\top_btn_up.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\up_off.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-sm_nav_horiz_btn\up_on.dds" , "60" , "16" , "dds" +"ui\cybran\game\construct-tab_btn\bot_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\bot_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\bot_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\bot_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\bot_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\mid_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\mid_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\mid_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\mid_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\mid_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\top_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\top_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\top_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\top_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_btn\top_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\cybran\game\construct-tab_top_btn\bot_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\bot_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\bot_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\bot_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\bot_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\mid_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\mid_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\mid_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\mid_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\mid_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\top_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\top_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\top_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\top_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tab_top_btn\top_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\left_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\left_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\left_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\left_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\left_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\m_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\m_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\m_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\m_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\m_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\r_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\r_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\r_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\r_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\r_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t1_btn_dis.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t1_btn_down.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t1_btn_over.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t1_btn_selected.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t1_btn_up.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t2_btn_dis.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t2_btn_down.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t2_btn_over.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t2_btn_selected.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t2_btn_up.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t3_btn_dis.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t3_btn_down.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t3_btn_over.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t3_btn_selected.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t3_btn_up.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t4_btn_dis.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t4_btn_down.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t4_btn_over.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t4_btn_selected.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\t4_btn_up.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\template_btn_dis.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\template_btn_down.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\template_btn_over.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\template_btn_selected.dds" , "32" , "32" , "dds" +"ui\cybran\game\construct-tech_btn\template_btn_up.dds" , "32" , "32" , "dds" +"ui\cybran\game\control-group-bracket\panel-control_bmp_b.dds" , "64" , "28" , "dds" +"ui\cybran\game\control-group-bracket\panel-control_bmp_m.dds" , "60" , "4" , "dds" +"ui\cybran\game\control-group-bracket\panel-control_bmp_t.dds" , "68" , "52" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ll_btn_dis.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ll_btn_down.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ll_btn_over.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ll_btn_up.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-lr_btn_dis.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-lr_btn_down.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-lr_btn_over.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-lr_btn_up.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ul_btn_dis.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ul_btn_down.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ul_btn_over.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ul_btn_up.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ur_btn_dis.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ur_btn_down.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ur_btn_over.dds" , "80" , "96" , "dds" +"ui\cybran\game\drag-handle\drag-handle-ur_btn_up.dds" , "80" , "96" , "dds" +"ui\cybran\game\economic-overlay\econ_bmp_l.dds" , "24" , "32" , "dds" +"ui\cybran\game\economic-overlay\econ_bmp_m.dds" , "4" , "32" , "dds" +"ui\cybran\game\economic-overlay\econ_bmp_r.dds" , "12" , "32" , "dds" +"ui\cybran\game\filter-arrow_btn\tab-close_btn_dis.dds" , "44" , "32" , "dds" +"ui\cybran\game\filter-arrow_btn\tab-close_btn_down.dds" , "44" , "32" , "dds" +"ui\cybran\game\filter-arrow_btn\tab-close_btn_over.dds" , "44" , "32" , "dds" +"ui\cybran\game\filter-arrow_btn\tab-close_btn_up.dds" , "44" , "32" , "dds" +"ui\cybran\game\filter-arrow_btn\tab-open_btn_dis.dds" , "44" , "32" , "dds" +"ui\cybran\game\filter-arrow_btn\tab-open_btn_down.dds" , "44" , "32" , "dds" +"ui\cybran\game\filter-arrow_btn\tab-open_btn_over.dds" , "44" , "32" , "dds" +"ui\cybran\game\filter-arrow_btn\tab-open_btn_up.dds" , "44" , "32" , "dds" +"ui\cybran\game\filter-ping-list-panel\energy-bar_bmp.dds" , "28" , "16" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_bmp_b.dds" , "128" , "24" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_bmp_m.dds" , "128" , "8" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_bmp_t.dds" , "128" , "24" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_brd_horz_um.dds" , "8" , "20" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_brd_ll.dds" , "24" , "24" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_brd_lm.dds" , "8" , "20" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_brd_lr.dds" , "24" , "24" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_brd_ul.dds" , "24" , "24" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_brd_ur.dds" , "24" , "24" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\cybran\game\filter-ping-list-panel\panel_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\cybran\game\filter-ping-panel\bracket-energy-l_bmp.dds" , "20" , "72" , "dds" +"ui\cybran\game\filter-ping-panel\bracket-energy-r_bmp.dds" , "20" , "72" , "dds" +"ui\cybran\game\filter-ping-panel\bracket-left_bmp.dds" , "28" , "68" , "dds" +"ui\cybran\game\filter-ping-panel\filter-ping-panel02_bmp.dds" , "180" , "72" , "dds" +"ui\cybran\game\medium-btn\medium_btn_dis.dds" , "296" , "72" , "dds" +"ui\cybran\game\medium-btn\medium_btn_down.dds" , "296" , "72" , "dds" +"ui\cybran\game\medium-btn\medium_btn_glow.dds" , "296" , "72" , "dds" +"ui\cybran\game\medium-btn\medium_btn_over.dds" , "296" , "72" , "dds" +"ui\cybran\game\medium-btn\medium_btn_up.dds" , "296" , "72" , "dds" +"ui\cybran\game\menu-btns\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\close_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\close_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\close_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\config_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\config_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\config_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\config_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\default_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\default_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\default_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\default_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\pin_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\pin_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\pin_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\pin_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\cybran\game\menu-btns\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\cybran\game\mini-map-brd\mini-map_brd_horz_um.dds" , "12" , "36" , "dds" +"ui\cybran\game\mini-map-brd\mini-map_brd_ll.dds" , "12" , "16" , "dds" +"ui\cybran\game\mini-map-brd\mini-map_brd_lm.dds" , "12" , "16" , "dds" +"ui\cybran\game\mini-map-brd\mini-map_brd_lr.dds" , "16" , "16" , "dds" +"ui\cybran\game\mini-map-brd\mini-map_brd_m.dds" , "12" , "12" , "dds" +"ui\cybran\game\mini-map-brd\mini-map_brd_ul.dds" , "32" , "36" , "dds" +"ui\cybran\game\mini-map-brd\mini-map_brd_ur.dds" , "36" , "36" , "dds" +"ui\cybran\game\mini-map-brd\mini-map_brd_vert_l.dds" , "12" , "12" , "dds" +"ui\cybran\game\mini-map-brd\mini-map_brd_vert_r.dds" , "16" , "12" , "dds" +"ui\cybran\game\mini-map-brd\mini-map-glow_bmp.dds" , "204" , "204" , "dds" +"ui\cybran\game\mini-map-glow-brd\mini-map-glow_brd_horz_um.dds" , "8" , "24" , "dds" +"ui\cybran\game\mini-map-glow-brd\mini-map-glow_brd_ll.dds" , "40" , "40" , "dds" +"ui\cybran\game\mini-map-glow-brd\mini-map-glow_brd_lm.dds" , "8" , "24" , "dds" +"ui\cybran\game\mini-map-glow-brd\mini-map-glow_brd_lr.dds" , "40" , "40" , "dds" +"ui\cybran\game\mini-map-glow-brd\mini-map-glow_brd_ul.dds" , "40" , "40" , "dds" +"ui\cybran\game\mini-map-glow-brd\mini-map-glow_brd_ur.dds" , "40" , "40" , "dds" +"ui\cybran\game\mini-map-glow-brd\mini-map-glow_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\cybran\game\mini-map-glow-brd\mini-map-glow_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\cybran\game\objective-icons\panel-icon_bmp.dds" , "60" , "68" , "dds" +"ui\cybran\game\objective-icons\primary-ring_bmp.dds" , "52" , "48" , "dds" +"ui\cybran\game\objective-icons\secondary-ring_bmp.dds" , "52" , "48" , "dds" +"ui\cybran\game\options_tab\diplomacy_btn_dis.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\diplomacy_btn_down.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\diplomacy_btn_over.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\diplomacy_btn_selected.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\diplomacy_btn_up.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\glow_bmp.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\menu_btn_dis.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\menu_btn_down.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\menu_btn_over.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\menu_btn_selected.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\menu_btn_up.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\objectives_btn_dis.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\objectives_btn_down.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\objectives_btn_over.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\objectives_btn_selected.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\objectives_btn_up.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\pause_btn_dis.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\pause_btn_down.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\pause_btn_over.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\pause_btn_selected.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\pause_btn_up.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\play_btn_dis.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\play_btn_down.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\play_btn_over.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\play_btn_selected.dds" , "56" , "48" , "dds" +"ui\cybran\game\options_tab\play_btn_up.dds" , "56" , "48" , "dds" +"ui\cybran\game\options-panel\options_brd_horz_lm.dds" , "4" , "28" , "dds" +"ui\cybran\game\options-panel\options_brd_horz_um.dds" , "80" , "36" , "dds" +"ui\cybran\game\options-panel\options_brd_horz_uml.dds" , "4" , "28" , "dds" +"ui\cybran\game\options-panel\options_brd_horz_umr.dds" , "4" , "28" , "dds" +"ui\cybran\game\options-panel\options_brd_ll.dds" , "44" , "36" , "dds" +"ui\cybran\game\options-panel\options_brd_lr.dds" , "44" , "36" , "dds" +"ui\cybran\game\options-panel\options_brd_m.dds" , "4" , "8" , "dds" +"ui\cybran\game\options-panel\options_brd_ul.dds" , "44" , "36" , "dds" +"ui\cybran\game\options-panel\options_brd_ur.dds" , "44" , "36" , "dds" +"ui\cybran\game\options-panel\options_brd_vert_l.dds" , "36" , "8" , "dds" +"ui\cybran\game\options-panel\options_brd_vert_ll.dds" , "40" , "24" , "dds" +"ui\cybran\game\options-panel\options_brd_vert_lr.dds" , "40" , "24" , "dds" +"ui\cybran\game\options-panel\options_brd_vert_r.dds" , "36" , "8" , "dds" +"ui\cybran\game\options-panel\options_brd_vert_ul.dds" , "40" , "24" , "dds" +"ui\cybran\game\options-panel\options_brd_vert_ur.dds" , "40" , "24" , "dds" +"ui\cybran\game\orders-panel_vert\order-panel_bmp.dds" , "188" , "224" , "dds" +"ui\cybran\game\orders-panel\bracket_bmp.dds" , "32" , "128" , "dds" +"ui\cybran\game\orders-panel\no-parking_bmp.dds" , "56" , "56" , "dds" +"ui\cybran\game\orders-panel\order-panel_bmp.dds" , "332" , "120" , "dds" +"ui\cybran\game\orders-panel\question-mark_bmp.dds" , "16" , "24" , "dds" +"ui\cybran\game\panel\panel_brd_horz_um.dds" , "8" , "24" , "dds" +"ui\cybran\game\panel\panel_brd_ll.dds" , "24" , "24" , "dds" +"ui\cybran\game\panel\panel_brd_lm.dds" , "8" , "24" , "dds" +"ui\cybran\game\panel\panel_brd_lr.dds" , "24" , "24" , "dds" +"ui\cybran\game\panel\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\cybran\game\panel\panel_brd_ul.dds" , "24" , "24" , "dds" +"ui\cybran\game\panel\panel_brd_ur.dds" , "24" , "24" , "dds" +"ui\cybran\game\panel\panel_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\cybran\game\panel\panel_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\cybran\game\pause_btn\glow_bmp.dds" , "56" , "48" , "dds" +"ui\cybran\game\pause_btn\pause_btn_dis.dds" , "56" , "48" , "dds" +"ui\cybran\game\pause_btn\pause_btn_down.dds" , "56" , "48" , "dds" +"ui\cybran\game\pause_btn\pause_btn_over.dds" , "56" , "48" , "dds" +"ui\cybran\game\pause_btn\pause_btn_up.dds" , "56" , "48" , "dds" +"ui\cybran\game\pause_btn\play_btn_dis.dds" , "56" , "48" , "dds" +"ui\cybran\game\pause_btn\play_btn_down.dds" , "56" , "48" , "dds" +"ui\cybran\game\pause_btn\play_btn_over.dds" , "56" , "48" , "dds" +"ui\cybran\game\pause_btn\play_btn_up.dds" , "56" , "48" , "dds" +"ui\cybran\game\pause-indicator\bottom.dds" , "20" , "20" , "dds" +"ui\cybran\game\pause-indicator\left.dds" , "20" , "20" , "dds" +"ui\cybran\game\pause-indicator\right.dds" , "20" , "20" , "dds" +"ui\cybran\game\pause-indicator\top.dds" , "20" , "20" , "dds" +"ui\cybran\game\pda-panel\bracket-right_bmp.dds" , "32" , "156" , "dds" +"ui\cybran\game\pda-panel\panel-objectives_bmp_l.dds" , "20" , "76" , "dds" +"ui\cybran\game\pda-panel\panel-objectives_bmp_m.dds" , "4" , "72" , "dds" +"ui\cybran\game\pda-panel\panel-objectives_bmp_r.dds" , "24" , "72" , "dds" +"ui\cybran\game\pda-panel\panel-ping_bmp_l.dds" , "20" , "60" , "dds" +"ui\cybran\game\pda-panel\panel-ping_bmp_m.dds" , "4" , "56" , "dds" +"ui\cybran\game\pda-panel\panel-ping_bmp_r.dds" , "24" , "56" , "dds" +"ui\cybran\game\pda-panel\panel-time-units_bmp_l.dds" , "20" , "28" , "dds" +"ui\cybran\game\pda-panel\panel-time-units_bmp_m.dds" , "4" , "24" , "dds" +"ui\cybran\game\pda-panel\panel-time-units_bmp_r.dds" , "24" , "24" , "dds" +"ui\cybran\game\pda-panel\title-bar_bmp.dds" , "136" , "20" , "dds" +"ui\cybran\game\pda-panel\video-panel_bmp.dds" , "168" , "148" , "dds" +"ui\cybran\game\ping-icons\panel-icon_bmp.dds" , "56" , "56" , "dds" +"ui\cybran\game\ping-icons\panel-icon-ring_bmp.dds" , "56" , "56" , "dds" +"ui\cybran\game\resource-bars\mini-energy-bar_bmp.dds" , "160" , "16" , "dds" +"ui\cybran\game\resource-bars\mini-energy-bar-back_bmp.dds" , "160" , "16" , "dds" +"ui\cybran\game\resource-bars\mini-mass-bar_bmp.dds" , "160" , "16" , "dds" +"ui\cybran\game\resource-bars\mini-mass-bar-back_bmp.dds" , "160" , "16" , "dds" +"ui\cybran\game\resource-panel\alert-caution_bmp.dds" , "48" , "44" , "dds" +"ui\cybran\game\resource-panel\alert-energy-panel_bmp.dds" , "316" , "32" , "dds" +"ui\cybran\game\resource-panel\alert-icon_bmp.dds" , "28" , "36" , "dds" +"ui\cybran\game\resource-panel\alert-mass-panel_bmp.dds" , "316" , "32" , "dds" +"ui\cybran\game\resource-panel\alert-no-parking_bmp.dds" , "52" , "52" , "dds" +"ui\cybran\game\resource-panel\bracket-energy-l_bmp.dds" , "20" , "72" , "dds" +"ui\cybran\game\resource-panel\bracket-energy-r_bmp.dds" , "20" , "72" , "dds" +"ui\cybran\game\resource-panel\bracket-left_bmp.dds" , "28" , "68" , "dds" +"ui\cybran\game\resource-panel\bracket-right_bmp.dds" , "28" , "68" , "dds" +"ui\cybran\game\resource-panel\caution-energy-panel_bmp.dds" , "316" , "32" , "dds" +"ui\cybran\game\resource-panel\caution-mass-panel_bmp.dds" , "316" , "32" , "dds" +"ui\cybran\game\resource-panel\resources_panel_bmp.dds" , "324" , "72" , "dds" +"ui\cybran\game\score-panel\icon-square_bmp.dds" , "24" , "24" , "dds" +"ui\cybran\game\score-panel\panel-score_bmp_b.dds" , "236" , "20" , "dds" +"ui\cybran\game\score-panel\panel-score_bmp_m.dds" , "236" , "4" , "dds" +"ui\cybran\game\score-panel\panel-score_bmp_t.dds" , "236" , "44" , "dds" +"ui\cybran\game\tab-l-btn\tab-close_btn_dis.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-l-btn\tab-close_btn_down.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-l-btn\tab-close_btn_over.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-l-btn\tab-close_btn_up.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-l-btn\tab-open_btn_dis.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-l-btn\tab-open_btn_down.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-l-btn\tab-open_btn_over.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-l-btn\tab-open_btn_up.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-r-btn\tab-close_btn_dis.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-r-btn\tab-close_btn_down.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-r-btn\tab-close_btn_over.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-r-btn\tab-close_btn_up.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-r-btn\tab-open_btn_dis.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-r-btn\tab-open_btn_down.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-r-btn\tab-open_btn_over.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-r-btn\tab-open_btn_up.dds" , "20" , "28" , "dds" +"ui\cybran\game\tab-t-btn\tab-close_btn_dis.dds" , "28" , "20" , "dds" +"ui\cybran\game\tab-t-btn\tab-close_btn_down.dds" , "28" , "20" , "dds" +"ui\cybran\game\tab-t-btn\tab-close_btn_over.dds" , "28" , "20" , "dds" +"ui\cybran\game\tab-t-btn\tab-close_btn_up.dds" , "28" , "20" , "dds" +"ui\cybran\game\tab-t-btn\tab-open_btn_dis.dds" , "28" , "20" , "dds" +"ui\cybran\game\tab-t-btn\tab-open_btn_down.dds" , "28" , "20" , "dds" +"ui\cybran\game\tab-t-btn\tab-open_btn_over.dds" , "28" , "20" , "dds" +"ui\cybran\game\tab-t-btn\tab-open_btn_up.dds" , "28" , "20" , "dds" +"ui\cybran\game\temp_textures\checkmark_dark.dds" , "16" , "16" , "dds" +"ui\cybran\game\temp_textures\checkmark.dds" , "16" , "16" , "dds" +"ui\cybran\game\temp_textures\combo_sel.dds" , "20" , "20" , "dds" +"ui\cybran\game\temp_textures\combo_up.dds" , "20" , "20" , "dds" +"ui\cybran\game\transmission\title-bar_bmp.dds" , "188" , "20" , "dds" +"ui\cybran\game\transmission\video-brackets.dds" , "220" , "212" , "dds" +"ui\cybran\game\transmission\video-panel_bmp.dds" , "248" , "240" , "dds" +"ui\cybran\game\transmission\video-panel.dds" , "208" , "208" , "dds" +"ui\cybran\game\unit-build-over-panel\bracket-build_bmp.dds" , "32" , "120" , "dds" +"ui\cybran\game\unit-build-over-panel\bracket-unit_bmp.dds" , "32" , "120" , "dds" +"ui\cybran\game\unit-build-over-panel\build-over-back_bmp.dds" , "332" , "116" , "dds" +"ui\cybran\game\unit-build-over-panel\fuelbar.dds" , "188" , "4" , "dds" +"ui\cybran\game\unit-build-over-panel\healthbar_bg.dds" , "188" , "20" , "dds" +"ui\cybran\game\unit-build-over-panel\healthbar_green.dds" , "192" , "24" , "dds" +"ui\cybran\game\unit-build-over-panel\healthbar_red.dds" , "192" , "24" , "dds" +"ui\cybran\game\unit-build-over-panel\healthbar_yellow.dds" , "192" , "24" , "dds" +"ui\cybran\game\unit-build-over-panel\shieldbar.dds" , "188" , "4" , "dds" +"ui\cybran\game\unit-build-over-panel\unit-over-back_bmp.dds" , "332" , "116" , "dds" +"ui\cybran\small-vert_scroll\arrow-down_scr_dis.dds" , "36" , "32" , "dds" +"ui\cybran\small-vert_scroll\arrow-down_scr_down.dds" , "36" , "32" , "dds" +"ui\cybran\small-vert_scroll\arrow-down_scr_over.dds" , "36" , "32" , "dds" +"ui\cybran\small-vert_scroll\arrow-down_scr_up.dds" , "36" , "32" , "dds" +"ui\cybran\small-vert_scroll\arrow-up_scr_dis.dds" , "36" , "32" , "dds" +"ui\cybran\small-vert_scroll\arrow-up_scr_down.dds" , "36" , "32" , "dds" +"ui\cybran\small-vert_scroll\arrow-up_scr_over.dds" , "36" , "32" , "dds" +"ui\cybran\small-vert_scroll\arrow-up_scr_up.dds" , "36" , "32" , "dds" +"ui\cybran\small-vert_scroll\back_scr_mid.dds" , "36" , "8" , "dds" +"ui\cybran\small-vert_scroll\bar-bot_scr_up.dds" , "36" , "8" , "dds" +"ui\cybran\small-vert_scroll\bar-mid_scr_over.dds" , "36" , "8" , "dds" +"ui\cybran\small-vert_scroll\bar-mid_scr_up.dds" , "36" , "8" , "dds" +"ui\cybran\small-vert_scroll\bar-top_scr_up.dds" , "36" , "12" , "dds" +"ui\cybran\widgets02\small_btn_dis.dds" , "148" , "36" , "dds" +"ui\cybran\widgets02\small_btn_down.dds" , "148" , "36" , "dds" +"ui\cybran\widgets02\small_btn_over.dds" , "148" , "36" , "dds" +"ui\cybran\widgets02\small_btn_up.dds" , "148" , "36" , "dds" +"ui\frontend\panel-campaign_brd\panel-campaign_brd_ll.dds" , "4" , "4" , "dds" +"ui\frontend\panel-campaign_brd\panel-campaign_brd_lm.dds" , "4" , "4" , "dds" +"ui\frontend\panel-campaign_brd\panel-campaign_brd_lr.dds" , "4" , "4" , "dds" +"ui\frontend\panel-campaign_brd\panel-campaign_brd_ul.dds" , "4" , "4" , "dds" +"ui\frontend\panel-campaign_brd\panel-campaign_brd_um.dds" , "4" , "4" , "dds" +"ui\frontend\panel-campaign_brd\panel-campaign_brd_ur.dds" , "4" , "4" , "dds" +"ui\frontend\panel-campaign_brd\panel-campaign_brd_vert_l.dds" , "4" , "4" , "dds" +"ui\frontend\panel-campaign_brd\panel-campaign_brd_vert_r.dds" , "4" , "4" , "dds" +"ui\frontend\score-victory-defeat\panel_bmp.dds" , "972" , "588" , "dds" +"ui\frontend\score-victory-defeat\panel-line_bmp.dds" , "920" , "4" , "dds" +"ui\frontend\score-victory-defeat\player-back_bmp.dds" , "912" , "36" , "dds" +"ui\frontend\score-victory-defeat\totals-back_bmp.dds" , "284" , "40" , "dds" +"ui\seraphim\dialogs\menu-btns\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\close_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\close_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\close_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\config_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\config_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\config_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\config_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\default_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\default_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\default_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\default_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\pin_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\pin_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\pin_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\pin_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\menu-btns\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\dialogs\time-units-tabs\energy_bmp.dds" , "16" , "28" , "dds" +"ui\seraphim\dialogs\time-units-tabs\panel-tracking_bmp_d.dds" , "12" , "44" , "dds" +"ui\seraphim\dialogs\time-units-tabs\panel-tracking_bmp_l.dds" , "24" , "44" , "dds" +"ui\seraphim\dialogs\time-units-tabs\panel-tracking_bmp_m.dds" , "4" , "44" , "dds" +"ui\seraphim\dialogs\time-units-tabs\panel-tracking_bmp_r.dds" , "24" , "44" , "dds" +"ui\seraphim\dialogs\time-units-tabs\tracking-icon_bmp.dds" , "24" , "20" , "dds" +"ui\seraphim\game\avatar-arrow_btn\tab-close_btn_dis.dds" , "12" , "32" , "dds" +"ui\seraphim\game\avatar-arrow_btn\tab-close_btn_down.dds" , "12" , "32" , "dds" +"ui\seraphim\game\avatar-arrow_btn\tab-close_btn_over.dds" , "12" , "32" , "dds" +"ui\seraphim\game\avatar-arrow_btn\tab-close_btn_up.dds" , "12" , "32" , "dds" +"ui\seraphim\game\avatar-arrow_btn\tab-open_btn_dis.dds" , "12" , "32" , "dds" +"ui\seraphim\game\avatar-arrow_btn\tab-open_btn_down.dds" , "12" , "32" , "dds" +"ui\seraphim\game\avatar-arrow_btn\tab-open_btn_over.dds" , "12" , "32" , "dds" +"ui\seraphim\game\avatar-arrow_btn\tab-open_btn_up.dds" , "12" , "32" , "dds" +"ui\seraphim\game\avatar-engineers-panel\bracket_bmp.dds" , "28" , "48" , "dds" +"ui\seraphim\game\avatar-engineers-panel\panel-eng_bmp_b.dds" , "88" , "12" , "dds" +"ui\seraphim\game\avatar-engineers-panel\panel-eng_bmp_m.dds" , "88" , "44" , "dds" +"ui\seraphim\game\avatar-engineers-panel\panel-eng_bmp_t.dds" , "88" , "12" , "dds" +"ui\seraphim\game\avatar-engineers-panel\tech-1_bmp.dds" , "28" , "28" , "dds" +"ui\seraphim\game\avatar-engineers-panel\tech-2_bmp.dds" , "28" , "28" , "dds" +"ui\seraphim\game\avatar-engineers-panel\tech-3_bmp.dds" , "28" , "28" , "dds" +"ui\seraphim\game\avatar-factory-panel\bracket_bmp.dds" , "20" , "40" , "dds" +"ui\seraphim\game\avatar-factory-panel\factory-panel_bmp.dds" , "188" , "164" , "dds" +"ui\seraphim\game\avatar\avatar_bmp.dds" , "60" , "68" , "dds" +"ui\seraphim\game\avatar\avatar-control-group_bmp.dds" , "48" , "36" , "dds" +"ui\seraphim\game\avatar\avatar-s-e-f_bmp.dds" , "56" , "52" , "dds" +"ui\seraphim\game\avatar\health-bar-back_bmp.dds" , "52" , "16" , "dds" +"ui\seraphim\game\avatar\health-bar.dds" , "44" , "12" , "dds" +"ui\seraphim\game\avatar\pulse-bars_bmp.dds" , "64" , "64" , "dds" +"ui\seraphim\game\bracket-left-energy\bracket_bmp_b.dds" , "20" , "16" , "dds" +"ui\seraphim\game\bracket-left-energy\bracket_bmp_m.dds" , "12" , "4" , "dds" +"ui\seraphim\game\bracket-left-energy\bracket_bmp_t.dds" , "20" , "20" , "dds" +"ui\seraphim\game\bracket-left-energy\bracket_bmp.dds" , "20" , "68" , "dds" +"ui\seraphim\game\bracket-left\bracket_bmp_b.dds" , "32" , "28" , "dds" +"ui\seraphim\game\bracket-left\bracket_bmp_m.dds" , "16" , "4" , "dds" +"ui\seraphim\game\bracket-left\bracket_bmp_t.dds" , "32" , "68" , "dds" +"ui\seraphim\game\bracket-left\bracket_bmp.dds" , "32" , "120" , "dds" +"ui\seraphim\game\bracket-right-energy\bracket_bmp_b.dds" , "20" , "16" , "dds" +"ui\seraphim\game\bracket-right-energy\bracket_bmp_m.dds" , "12" , "4" , "dds" +"ui\seraphim\game\bracket-right-energy\bracket_bmp_t.dds" , "20" , "20" , "dds" +"ui\seraphim\game\bracket-right-energy\bracket_bmp.dds" , "20" , "68" , "dds" +"ui\seraphim\game\bracket-right\bracket_bmp_b.dds" , "32" , "28" , "dds" +"ui\seraphim\game\bracket-right\bracket_bmp_m.dds" , "16" , "4" , "dds" +"ui\seraphim\game\bracket-right\bracket_bmp_t.dds" , "32" , "68" , "dds" +"ui\seraphim\game\bracket-right\bracket_bmp.dds" , "32" , "120" , "dds" +"ui\seraphim\game\c-q-e-panel\construct-panel_bmp_l.dds" , "84" , "60" , "dds" +"ui\seraphim\game\camera-btn\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\game\camera-btn\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\game\camera-btn\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\game\camera-btn\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\game\chat_brd\chat_brd_horz_um.dds" , "8" , "48" , "dds" +"ui\seraphim\game\chat_brd\chat_brd_ll.dds" , "44" , "48" , "dds" +"ui\seraphim\game\chat_brd\chat_brd_lm.dds" , "8" , "48" , "dds" +"ui\seraphim\game\chat_brd\chat_brd_lr.dds" , "44" , "48" , "dds" +"ui\seraphim\game\chat_brd\chat_brd_m.dds" , "8" , "8" , "dds" +"ui\seraphim\game\chat_brd\chat_brd_ul.dds" , "44" , "48" , "dds" +"ui\seraphim\game\chat_brd\chat_brd_ur.dds" , "44" , "48" , "dds" +"ui\seraphim\game\chat_brd\chat_brd_vert_l.dds" , "44" , "8" , "dds" +"ui\seraphim\game\chat_brd\chat_brd_vert_r.dds" , "44" , "8" , "dds" +"ui\seraphim\game\chat_brd\drop-box_brd_horz_um.dds" , "8" , "16" , "dds" +"ui\seraphim\game\chat_brd\drop-box_brd_ll.dds" , "16" , "12" , "dds" +"ui\seraphim\game\chat_brd\drop-box_brd_lm.dds" , "8" , "12" , "dds" +"ui\seraphim\game\chat_brd\drop-box_brd_lr.dds" , "16" , "12" , "dds" +"ui\seraphim\game\chat_brd\drop-box_brd_m.dds" , "8" , "8" , "dds" +"ui\seraphim\game\chat_brd\drop-box_brd_ul.dds" , "16" , "16" , "dds" +"ui\seraphim\game\chat_brd\drop-box_brd_ur.dds" , "16" , "16" , "dds" +"ui\seraphim\game\chat_brd\drop-box_brd_vert_l.dds" , "16" , "8" , "dds" +"ui\seraphim\game\chat_brd\drop-box_brd_vert_r.dds" , "16" , "8" , "dds" +"ui\seraphim\game\chat-box_btn\radio_btn_dis.dds" , "28" , "28" , "dds" +"ui\seraphim\game\chat-box_btn\radio_btn_down.dds" , "28" , "28" , "dds" +"ui\seraphim\game\chat-box_btn\radio_btn_over.dds" , "28" , "28" , "dds" +"ui\seraphim\game\chat-box_btn\radio_btn_up.dds" , "28" , "28" , "dds" +"ui\seraphim\game\construct-panel_vert\construct-panel_bmp_b.dds" , "172" , "12" , "dds" +"ui\seraphim\game\construct-panel_vert\construct-panel_bmp_m.dds" , "172" , "8" , "dds" +"ui\seraphim\game\construct-panel_vert\construct-panel_bmp_t.dds" , "172" , "64" , "dds" +"ui\seraphim\game\construct-panel_vert\que-panel_bmp_b.dds" , "60" , "8" , "dds" +"ui\seraphim\game\construct-panel_vert\que-panel_bmp_m.dds" , "60" , "8" , "dds" +"ui\seraphim\game\construct-panel_vert\que-panel_bmp_t.dds" , "60" , "8" , "dds" +"ui\seraphim\game\construct-panel\construct-panel_bmp_l.dds" , "84" , "140" , "dds" +"ui\seraphim\game\construct-panel\construct-panel_bmp_m1.dds" , "8" , "140" , "dds" +"ui\seraphim\game\construct-panel\construct-panel_bmp_m2.dds" , "16" , "140" , "dds" +"ui\seraphim\game\construct-panel\construct-panel_bmp_m3.dds" , "8" , "112" , "dds" +"ui\seraphim\game\construct-panel\construct-panel_bmp_r.dds" , "16" , "112" , "dds" +"ui\seraphim\game\construct-panel\construct-panel_s_bmp_l.dds" , "16" , "112" , "dds" +"ui\seraphim\game\construct-panel\construct-panel_s_bmp_m.dds" , "8" , "112" , "dds" +"ui\seraphim\game\construct-panel\construct-panel_s_bmp_r.dds" , "16" , "112" , "dds" +"ui\seraphim\game\construct-panel\que-panel_bmp_l.dds" , "12" , "52" , "dds" +"ui\seraphim\game\construct-panel\que-panel_bmp_m.dds" , "8" , "52" , "dds" +"ui\seraphim\game\construct-panel\que-panel_bmp_r.dds" , "12" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\back_off.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\back_on.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\fforward_off.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\fforward_on.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\forward_off.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\forward_on.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\infinite_off.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\infinite_on.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\left_btn_dis.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\left_btn_over.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\left_btn_up.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\mid_btn_dis.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\mid_btn_over.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\mid_btn_selected.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\mid_btn_up.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\pause_off.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\pause_on.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\rewind_off.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\rewind_on.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\right_btn_dis.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\right_btn_over.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\right_btn_up.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\template_off.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_btn\template_on.dds" , "24" , "52" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\infinite_off.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\infinite_on.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\pause_off.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\pause_on.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\que_btn_dis.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\que_btn_over.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\que_btn_selected.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\que_btn_up.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\template_off.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_horiz_btn\template_on.dds" , "60" , "28" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\bottom_btn_dis.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\bottom_btn_over.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\bottom_btn_up.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\down_off.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\down_on.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\end_off.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\end_on.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\home_off.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\home_on.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\mid_btn_dis.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\mid_btn_over.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\mid_btn_up.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\top_btn_dis.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\top_btn_over.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\top_btn_up.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\up_off.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-sm_nav_horiz_btn\up_on.dds" , "60" , "16" , "dds" +"ui\seraphim\game\construct-tab_btn\bot_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\bot_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\bot_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\bot_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\bot_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\mid_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\mid_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\mid_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\mid_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\mid_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\top_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\top_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\top_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\top_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_btn\top_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\seraphim\game\construct-tab_top_btn\bot_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\bot_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\bot_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\bot_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\bot_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\mid_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\mid_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\mid_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\mid_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\mid_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\top_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\top_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\top_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\top_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tab_top_btn\top_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\left_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\left_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\left_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\left_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\left_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\m_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\m_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\m_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\m_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\m_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\r_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\r_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\r_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\r_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\r_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t1_btn_dis.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t1_btn_down.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t1_btn_over.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t1_btn_selected.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t1_btn_up.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t2_btn_dis.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t2_btn_down.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t2_btn_over.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t2_btn_selected.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t2_btn_up.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t3_btn_dis.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t3_btn_down.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t3_btn_over.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t3_btn_selected.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t3_btn_up.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t4_btn_dis.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t4_btn_down.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t4_btn_over.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t4_btn_selected.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\t4_btn_up.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\template_btn_dis.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\template_btn_down.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\template_btn_over.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\template_btn_selected.dds" , "32" , "32" , "dds" +"ui\seraphim\game\construct-tech_btn\template_btn_up.dds" , "32" , "32" , "dds" +"ui\seraphim\game\control-group-bracket\panel-control_bmp_b.dds" , "68" , "24" , "dds" +"ui\seraphim\game\control-group-bracket\panel-control_bmp_m.dds" , "60" , "4" , "dds" +"ui\seraphim\game\control-group-bracket\panel-control_bmp_t.dds" , "68" , "48" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ll_btn_dis.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ll_btn_down.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ll_btn_over.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ll_btn_up.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-lr_btn_dis.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-lr_btn_down.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-lr_btn_over.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-lr_btn_up.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ul_btn_dis.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ul_btn_down.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ul_btn_over.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ul_btn_up.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ur_btn_dis.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ur_btn_down.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ur_btn_over.dds" , "80" , "96" , "dds" +"ui\seraphim\game\drag-handle\drag-handle-ur_btn_up.dds" , "80" , "96" , "dds" +"ui\seraphim\game\economic-overlay\econ_bmp_l.dds" , "24" , "32" , "dds" +"ui\seraphim\game\economic-overlay\econ_bmp_m.dds" , "4" , "32" , "dds" +"ui\seraphim\game\economic-overlay\econ_bmp_r.dds" , "12" , "32" , "dds" +"ui\seraphim\game\filter-ping-list-panel\energy-bar_bmp.dds" , "28" , "16" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_bmp_b.dds" , "128" , "24" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_bmp_m.dds" , "128" , "8" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_bmp_t.dds" , "128" , "24" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_brd_horz_um.dds" , "8" , "20" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_brd_ll.dds" , "24" , "24" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_brd_lm.dds" , "8" , "20" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_brd_lr.dds" , "24" , "24" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_brd_ul.dds" , "24" , "24" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_brd_ur.dds" , "24" , "24" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\seraphim\game\filter-ping-list-panel\panel_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\seraphim\game\filter-ping-panel\bracket-energy-l_bmp.dds" , "20" , "72" , "dds" +"ui\seraphim\game\filter-ping-panel\bracket-energy-r_bmp.dds" , "20" , "72" , "dds" +"ui\seraphim\game\filter-ping-panel\bracket-left_bmp.dds" , "28" , "68" , "dds" +"ui\seraphim\game\filter-ping-panel\filter-ping-panel02_bmp.dds" , "180" , "72" , "dds" +"ui\seraphim\game\medium-btn\medium_btn_dis.dds" , "296" , "72" , "dds" +"ui\seraphim\game\medium-btn\medium_btn_down.dds" , "296" , "72" , "dds" +"ui\seraphim\game\medium-btn\medium_btn_glow.dds" , "296" , "72" , "dds" +"ui\seraphim\game\medium-btn\medium_btn_over.dds" , "296" , "72" , "dds" +"ui\seraphim\game\medium-btn\medium_btn_up.dds" , "296" , "72" , "dds" +"ui\seraphim\game\menu-btns\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\close_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\close_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\close_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\config_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\config_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\config_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\config_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\default_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\default_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\default_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\default_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\pin_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\pin_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\pin_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\pin_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\seraphim\game\menu-btns\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\seraphim\game\mfd_btn\control_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\control_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\control_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\control_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\defenses_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\defenses_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\defenses_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\defenses_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\economy_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\economy_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\economy_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\economy_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\intelligence_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\intelligence_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\intelligence_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\intelligence_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\military_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\military_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\military_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\military_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\military-radar_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\military-radar_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\military-radar_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\military-radar_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-alert_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-alert_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-alert_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-alert_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-attack_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-attack_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-attack_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-attack_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-marker_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-marker_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-marker_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-marker_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-move_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-move_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-move_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\ping-move_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\team-color_btn_dis.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\team-color_btn_down.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\team-color_btn_over.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mfd_btn\team-color_btn_up.dds" , "44" , "32" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map_brd_horz_um.dds" , "12" , "36" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map_brd_ll.dds" , "12" , "16" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map_brd_lm.dds" , "12" , "16" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map_brd_lr.dds" , "16" , "16" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map_brd_m.dds" , "12" , "12" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map_brd_ul.dds" , "32" , "36" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map_brd_ur.dds" , "36" , "36" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map_brd_vert_l.dds" , "12" , "12" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map_brd_vert_r.dds" , "16" , "12" , "dds" +"ui\seraphim\game\mini-map-brd\mini-map-glow_bmp.dds" , "204" , "204" , "dds" +"ui\seraphim\game\mini-map-glow-brd\mini-map-glow_brd_horz_um.dds" , "8" , "24" , "dds" +"ui\seraphim\game\mini-map-glow-brd\mini-map-glow_brd_ll.dds" , "40" , "40" , "dds" +"ui\seraphim\game\mini-map-glow-brd\mini-map-glow_brd_lm.dds" , "8" , "24" , "dds" +"ui\seraphim\game\mini-map-glow-brd\mini-map-glow_brd_lr.dds" , "40" , "40" , "dds" +"ui\seraphim\game\mini-map-glow-brd\mini-map-glow_brd_ul.dds" , "40" , "40" , "dds" +"ui\seraphim\game\mini-map-glow-brd\mini-map-glow_brd_ur.dds" , "40" , "40" , "dds" +"ui\seraphim\game\mini-map-glow-brd\mini-map-glow_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\seraphim\game\mini-map-glow-brd\mini-map-glow_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\seraphim\game\objective-icons\panel-icon_bmp.dds" , "60" , "68" , "dds" +"ui\seraphim\game\options_tab\diplomacy_btn_dis.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\diplomacy_btn_down.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\diplomacy_btn_over.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\diplomacy_btn_selected.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\diplomacy_btn_up.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\glow_bmp.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\menu_btn_dis.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\menu_btn_down.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\menu_btn_over.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\menu_btn_selected.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\menu_btn_up.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\objectives_btn_dis.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\objectives_btn_down.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\objectives_btn_over.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\objectives_btn_selected.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\objectives_btn_up.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\pause_btn_dis.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\pause_btn_down.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\pause_btn_over.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\pause_btn_selected.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\pause_btn_up.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\play_btn_dis.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\play_btn_down.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\play_btn_over.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\play_btn_selected.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options_tab\play_btn_up.dds" , "56" , "48" , "dds" +"ui\seraphim\game\options-panel\options_brd_horz_lm.dds" , "4" , "28" , "dds" +"ui\seraphim\game\options-panel\options_brd_horz_um.dds" , "80" , "36" , "dds" +"ui\seraphim\game\options-panel\options_brd_horz_uml.dds" , "4" , "28" , "dds" +"ui\seraphim\game\options-panel\options_brd_horz_umr.dds" , "4" , "28" , "dds" +"ui\seraphim\game\options-panel\options_brd_ll.dds" , "44" , "36" , "dds" +"ui\seraphim\game\options-panel\options_brd_lr.dds" , "44" , "36" , "dds" +"ui\seraphim\game\options-panel\options_brd_m.dds" , "4" , "8" , "dds" +"ui\seraphim\game\options-panel\options_brd_ul.dds" , "44" , "36" , "dds" +"ui\seraphim\game\options-panel\options_brd_ur.dds" , "44" , "36" , "dds" +"ui\seraphim\game\options-panel\options_brd_vert_l.dds" , "36" , "8" , "dds" +"ui\seraphim\game\options-panel\options_brd_vert_ll.dds" , "40" , "24" , "dds" +"ui\seraphim\game\options-panel\options_brd_vert_lr.dds" , "40" , "24" , "dds" +"ui\seraphim\game\options-panel\options_brd_vert_r.dds" , "36" , "8" , "dds" +"ui\seraphim\game\options-panel\options_brd_vert_ul.dds" , "40" , "24" , "dds" +"ui\seraphim\game\options-panel\options_brd_vert_ur.dds" , "40" , "24" , "dds" +"ui\seraphim\game\orders-panel_vert\bracket_bmp.dds" , "32" , "224" , "dds" +"ui\seraphim\game\orders-panel_vert\order-panel_bmp.dds" , "188" , "224" , "dds" +"ui\seraphim\game\orders-panel\bracket_bmp.dds" , "32" , "120" , "dds" +"ui\seraphim\game\orders-panel\no-parking_bmp.dds" , "56" , "56" , "dds" +"ui\seraphim\game\orders-panel\order-panel_bmp.dds" , "332" , "120" , "dds" +"ui\seraphim\game\orders-panel\question-mark_bmp.dds" , "16" , "24" , "dds" +"ui\seraphim\game\panel\panel_brd_horz_um.dds" , "8" , "28" , "dds" +"ui\seraphim\game\panel\panel_brd_ll.dds" , "28" , "28" , "dds" +"ui\seraphim\game\panel\panel_brd_lm.dds" , "8" , "28" , "dds" +"ui\seraphim\game\panel\panel_brd_lr.dds" , "28" , "28" , "dds" +"ui\seraphim\game\panel\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\seraphim\game\panel\panel_brd_ul.dds" , "28" , "28" , "dds" +"ui\seraphim\game\panel\panel_brd_ur.dds" , "28" , "28" , "dds" +"ui\seraphim\game\panel\panel_brd_vert_l.dds" , "28" , "8" , "dds" +"ui\seraphim\game\panel\panel_brd_vert_r.dds" , "28" , "8" , "dds" +"ui\seraphim\game\pause_btn\glow_bmp.dds" , "56" , "48" , "dds" +"ui\seraphim\game\pause_btn\pause_btn_dis.dds" , "56" , "48" , "dds" +"ui\seraphim\game\pause_btn\pause_btn_down.dds" , "56" , "48" , "dds" +"ui\seraphim\game\pause_btn\pause_btn_over.dds" , "56" , "48" , "dds" +"ui\seraphim\game\pause_btn\pause_btn_up.dds" , "56" , "48" , "dds" +"ui\seraphim\game\pause_btn\play_btn_dis.dds" , "56" , "48" , "dds" +"ui\seraphim\game\pause_btn\play_btn_down.dds" , "56" , "48" , "dds" +"ui\seraphim\game\pause_btn\play_btn_over.dds" , "56" , "48" , "dds" +"ui\seraphim\game\pause_btn\play_btn_up.dds" , "56" , "48" , "dds" +"ui\seraphim\game\pause-indicator\bottom.dds" , "20" , "20" , "dds" +"ui\seraphim\game\pause-indicator\left.dds" , "20" , "20" , "dds" +"ui\seraphim\game\pause-indicator\right.dds" , "20" , "20" , "dds" +"ui\seraphim\game\pause-indicator\top.dds" , "20" , "20" , "dds" +"ui\seraphim\game\pda-panel\bracket-right_bmp.dds" , "32" , "156" , "dds" +"ui\seraphim\game\pda-panel\panel-objectives_bmp_l.dds" , "20" , "76" , "dds" +"ui\seraphim\game\pda-panel\panel-objectives_bmp_m.dds" , "4" , "72" , "dds" +"ui\seraphim\game\pda-panel\panel-objectives_bmp_r.dds" , "24" , "72" , "dds" +"ui\seraphim\game\pda-panel\panel-ping_bmp_l.dds" , "20" , "60" , "dds" +"ui\seraphim\game\pda-panel\panel-ping_bmp_m.dds" , "4" , "56" , "dds" +"ui\seraphim\game\pda-panel\panel-ping_bmp_r.dds" , "24" , "56" , "dds" +"ui\seraphim\game\pda-panel\panel-time-units_bmp_l.dds" , "20" , "28" , "dds" +"ui\seraphim\game\pda-panel\panel-time-units_bmp_m.dds" , "4" , "24" , "dds" +"ui\seraphim\game\pda-panel\panel-time-units_bmp_r.dds" , "24" , "24" , "dds" +"ui\seraphim\game\pda-panel\title-bar_bmp.dds" , "136" , "24" , "dds" +"ui\seraphim\game\pda-panel\video-panel_bmp.dds" , "168" , "164" , "dds" +"ui\seraphim\game\ping-icons\panel-icon_bmp.dds" , "56" , "56" , "dds" +"ui\seraphim\game\ping-icons\panel-icon-ring_bmp.dds" , "56" , "56" , "dds" +"ui\seraphim\game\resource-panel\alert-caution_bmp.dds" , "48" , "44" , "dds" +"ui\seraphim\game\resource-panel\alert-energy-panel_bmp.dds" , "316" , "32" , "dds" +"ui\seraphim\game\resource-panel\alert-icon_bmp.dds" , "28" , "36" , "dds" +"ui\seraphim\game\resource-panel\alert-mass-panel_bmp.dds" , "316" , "32" , "dds" +"ui\seraphim\game\resource-panel\alert-no-parking_bmp.dds" , "52" , "52" , "dds" +"ui\seraphim\game\resource-panel\bracket-energy-l_bmp.dds" , "20" , "72" , "dds" +"ui\seraphim\game\resource-panel\bracket-energy-r_bmp.dds" , "20" , "72" , "dds" +"ui\seraphim\game\resource-panel\bracket-left_bmp.dds" , "32" , "68" , "dds" +"ui\seraphim\game\resource-panel\bracket-right_bmp.dds" , "32" , "68" , "dds" +"ui\seraphim\game\resource-panel\caution-energy-panel_bmp.dds" , "316" , "32" , "dds" +"ui\seraphim\game\resource-panel\caution-mass-panel_bmp.dds" , "316" , "32" , "dds" +"ui\seraphim\game\resource-panel\resources_panel_bmp.dds" , "324" , "72" , "dds" +"ui\seraphim\game\score-panel\icon-square_bmp.dds" , "24" , "24" , "dds" +"ui\seraphim\game\score-panel\panel-score_bmp_b.dds" , "236" , "20" , "dds" +"ui\seraphim\game\score-panel\panel-score_bmp_m.dds" , "236" , "4" , "dds" +"ui\seraphim\game\score-panel\panel-score_bmp_t.dds" , "236" , "44" , "dds" +"ui\seraphim\game\tab-l-btn\tab-close_btn_dis.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-l-btn\tab-close_btn_down.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-l-btn\tab-close_btn_over.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-l-btn\tab-close_btn_up.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-l-btn\tab-open_btn_dis.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-l-btn\tab-open_btn_down.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-l-btn\tab-open_btn_over.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-l-btn\tab-open_btn_up.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-r-btn\tab-close_btn_dis.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-r-btn\tab-close_btn_down.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-r-btn\tab-close_btn_over.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-r-btn\tab-close_btn_up.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-r-btn\tab-open_btn_dis.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-r-btn\tab-open_btn_down.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-r-btn\tab-open_btn_over.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-r-btn\tab-open_btn_up.dds" , "20" , "28" , "dds" +"ui\seraphim\game\tab-t-btn\tab-close_btn_dis.dds" , "28" , "20" , "dds" +"ui\seraphim\game\tab-t-btn\tab-close_btn_down.dds" , "28" , "20" , "dds" +"ui\seraphim\game\tab-t-btn\tab-close_btn_over.dds" , "28" , "20" , "dds" +"ui\seraphim\game\tab-t-btn\tab-close_btn_up.dds" , "28" , "20" , "dds" +"ui\seraphim\game\tab-t-btn\tab-open_btn_dis.dds" , "28" , "20" , "dds" +"ui\seraphim\game\tab-t-btn\tab-open_btn_down.dds" , "28" , "20" , "dds" +"ui\seraphim\game\tab-t-btn\tab-open_btn_over.dds" , "28" , "20" , "dds" +"ui\seraphim\game\tab-t-btn\tab-open_btn_up.dds" , "28" , "20" , "dds" +"ui\seraphim\game\temp_textures\checkmark_dark.dds" , "16" , "16" , "dds" +"ui\seraphim\game\temp_textures\checkmark.dds" , "16" , "16" , "dds" +"ui\seraphim\game\temp_textures\combo_sel.dds" , "20" , "20" , "dds" +"ui\seraphim\game\temp_textures\combo_up.dds" , "20" , "20" , "dds" +"ui\seraphim\game\transmission\title-bar_bmp.dds" , "192" , "20" , "dds" +"ui\seraphim\game\transmission\video-brackets.dds" , "224" , "216" , "dds" +"ui\seraphim\game\transmission\video-panel_bmp.dds" , "232" , "216" , "dds" +"ui\seraphim\game\transmission\video-panel.dds" , "208" , "208" , "dds" +"ui\seraphim\game\unit-build-over-panel\bracket-build_bmp.dds" , "28" , "116" , "dds" +"ui\seraphim\game\unit-build-over-panel\bracket-unit_bmp.dds" , "28" , "116" , "dds" +"ui\seraphim\game\unit-build-over-panel\build-over-back_bmp.dds" , "332" , "116" , "dds" +"ui\seraphim\game\unit-build-over-panel\fuelbar.dds" , "188" , "4" , "dds" +"ui\seraphim\game\unit-build-over-panel\healthbar_bg.dds" , "188" , "20" , "dds" +"ui\seraphim\game\unit-build-over-panel\healthbar_green.dds" , "192" , "24" , "dds" +"ui\seraphim\game\unit-build-over-panel\healthbar_red.dds" , "192" , "24" , "dds" +"ui\seraphim\game\unit-build-over-panel\healthbar_yellow.dds" , "192" , "24" , "dds" +"ui\seraphim\game\unit-build-over-panel\shieldbar.dds" , "188" , "4" , "dds" +"ui\seraphim\game\unit-build-over-panel\unit-over-back_bmp.dds" , "332" , "116" , "dds" +"ui\seraphim\small-vert_scroll\arrow-down_scr_dis.dds" , "36" , "28" , "dds" +"ui\seraphim\small-vert_scroll\arrow-down_scr_down.dds" , "36" , "28" , "dds" +"ui\seraphim\small-vert_scroll\arrow-down_scr_over.dds" , "36" , "28" , "dds" +"ui\seraphim\small-vert_scroll\arrow-down_scr_up.dds" , "36" , "28" , "dds" +"ui\seraphim\small-vert_scroll\arrow-up_scr_dis.dds" , "36" , "28" , "dds" +"ui\seraphim\small-vert_scroll\arrow-up_scr_down.dds" , "36" , "28" , "dds" +"ui\seraphim\small-vert_scroll\arrow-up_scr_over.dds" , "36" , "28" , "dds" +"ui\seraphim\small-vert_scroll\arrow-up_scr_up.dds" , "36" , "28" , "dds" +"ui\seraphim\small-vert_scroll\back_scr_bot.dds" , "28" , "12" , "dds" +"ui\seraphim\small-vert_scroll\back_scr_mid.dds" , "36" , "8" , "dds" +"ui\seraphim\small-vert_scroll\back_scr_top.dds" , "28" , "12" , "dds" +"ui\seraphim\small-vert_scroll\bar-bot_scr_dis.dds" , "36" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-bot_scr_down.dds" , "20" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-bot_scr_over.dds" , "36" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-bot_scr_up.dds" , "36" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-mid_scr_dis.dds" , "36" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-mid_scr_down.dds" , "20" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-mid_scr_over.dds" , "36" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-mid_scr_up.dds" , "36" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-top_scr_dis.dds" , "36" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-top_scr_down.dds" , "20" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-top_scr_over.dds" , "36" , "8" , "dds" +"ui\seraphim\small-vert_scroll\bar-top_scr_up.dds" , "36" , "8" , "dds" +"ui\seraphim\widgets02\small_btn_dis.dds" , "148" , "36" , "dds" +"ui\seraphim\widgets02\small_btn_down.dds" , "148" , "36" , "dds" +"ui\seraphim\widgets02\small_btn_over.dds" , "148" , "36" , "dds" +"ui\seraphim\widgets02\small_btn_up.dds" , "148" , "36" , "dds" +"ui\uef\dialogs\menu-btns\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\close_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\close_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\close_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\config_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\config_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\config_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\config_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\default_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\default_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\default_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\default_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\pin_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\pin_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\pin_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\pin_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\dialogs\menu-btns\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\game\ability_brd\chat_brd_horz_um.dds" , "20" , "20" , "dds" +"ui\uef\game\ability_brd\chat_brd_ll.dds" , "20" , "20" , "dds" +"ui\uef\game\ability_brd\chat_brd_lm.dds" , "20" , "20" , "dds" +"ui\uef\game\ability_brd\chat_brd_lr.dds" , "20" , "20" , "dds" +"ui\uef\game\ability_brd\chat_brd_m.dds" , "20" , "20" , "dds" +"ui\uef\game\ability_brd\chat_brd_ul.dds" , "20" , "20" , "dds" +"ui\uef\game\ability_brd\chat_brd_ur.dds" , "20" , "20" , "dds" +"ui\uef\game\ability_brd\chat_brd_vert_l.dds" , "20" , "20" , "dds" +"ui\uef\game\ability_brd\chat_brd_vert_r.dds" , "20" , "20" , "dds" +"ui\uef\game\avatar-arrow_btn\tab-close_btn_dis.dds" , "12" , "32" , "dds" +"ui\uef\game\avatar-arrow_btn\tab-close_btn_down.dds" , "12" , "32" , "dds" +"ui\uef\game\avatar-arrow_btn\tab-close_btn_over.dds" , "12" , "32" , "dds" +"ui\uef\game\avatar-arrow_btn\tab-close_btn_up.dds" , "12" , "32" , "dds" +"ui\uef\game\avatar-arrow_btn\tab-open_btn_dis.dds" , "12" , "32" , "dds" +"ui\uef\game\avatar-arrow_btn\tab-open_btn_down.dds" , "12" , "32" , "dds" +"ui\uef\game\avatar-arrow_btn\tab-open_btn_over.dds" , "12" , "32" , "dds" +"ui\uef\game\avatar-arrow_btn\tab-open_btn_up.dds" , "12" , "32" , "dds" +"ui\uef\game\avatar-engineers-panel\bracket_bmp.dds" , "28" , "48" , "dds" +"ui\uef\game\avatar-engineers-panel\panel_bmp.dds" , "108" , "188" , "dds" +"ui\uef\game\avatar-engineers-panel\panel-eng_bmp_b.dds" , "88" , "12" , "dds" +"ui\uef\game\avatar-engineers-panel\panel-eng_bmp_m.dds" , "88" , "44" , "dds" +"ui\uef\game\avatar-engineers-panel\panel-eng_bmp_t.dds" , "88" , "12" , "dds" +"ui\uef\game\avatar-engineers-panel\tech-1_bmp.dds" , "28" , "28" , "dds" +"ui\uef\game\avatar-engineers-panel\tech-2_bmp.dds" , "28" , "28" , "dds" +"ui\uef\game\avatar-engineers-panel\tech-3_bmp.dds" , "28" , "28" , "dds" +"ui\uef\game\avatar-factory-panel\avatar-s-e-f_bmp.dds" , "52" , "52" , "dds" +"ui\uef\game\avatar-factory-panel\bracket_bmp.dds" , "28" , "48" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_bmp.dds" , "184" , "160" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_brd_horz_um.dds" , "44" , "12" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_brd_ll.dds" , "36" , "52" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_brd_lm.dds" , "44" , "48" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_brd_lr.dds" , "60" , "52" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_brd_ul copy.dds" , "36" , "16" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_brd_ul.dds" , "36" , "16" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_brd_ur.dds" , "60" , "16" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_brd_vert_l.dds" , "36" , "44" , "dds" +"ui\uef\game\avatar-factory-panel\factory-panel_brd_vert_r.dds" , "60" , "44" , "dds" +"ui\uef\game\avatar-factory-panel\panel_bmp.dds" , "200" , "152" , "dds" +"ui\uef\game\avatar-factory-panel\strat-icon-air_bmp.dds" , "8" , "8" , "dds" +"ui\uef\game\avatar-factory-panel\strat-icon-land_bmp.dds" , "8" , "8" , "dds" +"ui\uef\game\avatar-factory-panel\strat-icon-sea_bmp.dds" , "8" , "8" , "dds" +"ui\uef\game\avatar-factory-panel\tech-1_bmp.dds" , "28" , "28" , "dds" +"ui\uef\game\avatar-factory-panel\tech-2_bmp.dds" , "28" , "28" , "dds" +"ui\uef\game\avatar-factory-panel\tech-3_bmp.dds" , "28" , "28" , "dds" +"ui\uef\game\avatar\avatar_bmp.dds" , "64" , "68" , "dds" +"ui\uef\game\avatar\avatar-control-group_bmp.dds" , "48" , "36" , "dds" +"ui\uef\game\avatar\avatar-s-e-f_bmp.dds" , "56" , "52" , "dds" +"ui\uef\game\avatar\health-bar-back_bmp.dds" , "52" , "16" , "dds" +"ui\uef\game\avatar\health-bar.dds" , "40" , "8" , "dds" +"ui\uef\game\avatar\pulse-bars_bmp.dds" , "64" , "64" , "dds" +"ui\uef\game\bracket-left-energy\bracket_bmp_b.dds" , "20" , "16" , "dds" +"ui\uef\game\bracket-left-energy\bracket_bmp_m.dds" , "12" , "4" , "dds" +"ui\uef\game\bracket-left-energy\bracket_bmp_t.dds" , "20" , "20" , "dds" +"ui\uef\game\bracket-left-energy\bracket_bmp.dds" , "20" , "68" , "dds" +"ui\uef\game\bracket-left\bracket_bmp_b.dds" , "32" , "28" , "dds" +"ui\uef\game\bracket-left\bracket_bmp_m.dds" , "16" , "4" , "dds" +"ui\uef\game\bracket-left\bracket_bmp_t.dds" , "32" , "68" , "dds" +"ui\uef\game\bracket-left\bracket_bmp.dds" , "32" , "120" , "dds" +"ui\uef\game\bracket-min-small\bracket-sm-left_bmp.dds" , "24" , "80" , "dds" +"ui\uef\game\bracket-min-small\bracket-sm-right_bmp.dds" , "24" , "80" , "dds" +"ui\uef\game\bracket-right-energy\bracket_bmp_b.dds" , "20" , "16" , "dds" +"ui\uef\game\bracket-right-energy\bracket_bmp_m.dds" , "12" , "4" , "dds" +"ui\uef\game\bracket-right-energy\bracket_bmp_t.dds" , "20" , "20" , "dds" +"ui\uef\game\bracket-right-energy\bracket_bmp.dds" , "20" , "68" , "dds" +"ui\uef\game\bracket-right\bracket_bmp_b.dds" , "32" , "28" , "dds" +"ui\uef\game\bracket-right\bracket_bmp_m.dds" , "16" , "4" , "dds" +"ui\uef\game\bracket-right\bracket_bmp_t.dds" , "32" , "68" , "dds" +"ui\uef\game\bracket-right\bracket_bmp.dds" , "32" , "120" , "dds" +"ui\uef\game\c-q-e-panel\arrow_bmp.dds" , "28" , "16" , "dds" +"ui\uef\game\c-q-e-panel\arrow_vert_bmp.dds" , "16" , "24" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-b_bmp_l.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-b_bmp_m.dds" , "8" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-b_bmp_r.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-e_bmp_l.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-e_bmp_m.dds" , "8" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-e_bmp_r.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-g_bmp_l.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-g_bmp_m.dds" , "8" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-g_bmp_r.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-o_bmp_l.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-o_bmp_m.dds" , "8" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-o_bmp_r.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-p_bmp_l.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-p_bmp_m.dds" , "8" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-p_bmp_r.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-r_bmp_l.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-r_bmp_m.dds" , "8" , "52" , "dds" +"ui\uef\game\c-q-e-panel\c-icon-group-r_bmp_r.dds" , "24" , "52" , "dds" +"ui\uef\game\c-q-e-panel\construct-panel_bmp_l.dds" , "84" , "60" , "dds" +"ui\uef\game\c-q-e-panel\construct-panel_bmp_m.dds" , "8" , "60" , "dds" +"ui\uef\game\c-q-e-panel\construct-panel_bmp_r.dds" , "16" , "60" , "dds" +"ui\uef\game\c-q-e-panel\divider_bmp.dds" , "12" , "56" , "dds" +"ui\uef\game\c-q-e-panel\divider_horizontal_bmp.dds" , "64" , "12" , "dds" +"ui\uef\game\c-q-e-panel\divider_horz_bmp.dds" , "52" , "8" , "dds" +"ui\uef\game\c-q-e-panel\e-icon-group_bmp_l.dds" , "24" , "44" , "dds" +"ui\uef\game\c-q-e-panel\e-icon-group_bmp_m.dds" , "8" , "44" , "dds" +"ui\uef\game\c-q-e-panel\e-icon-group_bmp_r.dds" , "24" , "44" , "dds" +"ui\uef\game\c-q-e-panel\enhance-panel_bmp_l.dds" , "60" , "60" , "dds" +"ui\uef\game\c-q-e-panel\enhance-panel_bmp_m.dds" , "8" , "60" , "dds" +"ui\uef\game\c-q-e-panel\enhance-panel_bmp_r.dds" , "16" , "60" , "dds" +"ui\uef\game\c-q-e-panel\icon-group_bmp.dds" , "52" , "52" , "dds" +"ui\uef\game\c-q-e-panel\que-panel_bmp_l.dds" , "104" , "60" , "dds" +"ui\uef\game\c-q-e-panel\que-panel_bmp_m.dds" , "8" , "60" , "dds" +"ui\uef\game\c-q-e-panel\que-panel_bmp_r.dds" , "16" , "60" , "dds" +"ui\uef\game\camera-btn\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\game\camera-btn\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\game\camera-btn\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\game\camera-btn\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\game\chat_brd\chat_brd_horz_um.dds" , "8" , "48" , "dds" +"ui\uef\game\chat_brd\chat_brd_ll.dds" , "44" , "48" , "dds" +"ui\uef\game\chat_brd\chat_brd_lm.dds" , "8" , "48" , "dds" +"ui\uef\game\chat_brd\chat_brd_lr.dds" , "44" , "48" , "dds" +"ui\uef\game\chat_brd\chat_brd_m.dds" , "8" , "8" , "dds" +"ui\uef\game\chat_brd\chat_brd_ul.dds" , "44" , "48" , "dds" +"ui\uef\game\chat_brd\chat_brd_ur.dds" , "44" , "48" , "dds" +"ui\uef\game\chat_brd\chat_brd_vert_l.dds" , "44" , "8" , "dds" +"ui\uef\game\chat_brd\chat_brd_vert_r.dds" , "44" , "8" , "dds" +"ui\uef\game\chat_brd\drop-box_brd_horz_um.dds" , "8" , "16" , "dds" +"ui\uef\game\chat_brd\drop-box_brd_ll.dds" , "16" , "12" , "dds" +"ui\uef\game\chat_brd\drop-box_brd_lm.dds" , "8" , "12" , "dds" +"ui\uef\game\chat_brd\drop-box_brd_lr.dds" , "16" , "12" , "dds" +"ui\uef\game\chat_brd\drop-box_brd_m.dds" , "8" , "8" , "dds" +"ui\uef\game\chat_brd\drop-box_brd_ul.dds" , "16" , "16" , "dds" +"ui\uef\game\chat_brd\drop-box_brd_ur.dds" , "16" , "16" , "dds" +"ui\uef\game\chat_brd\drop-box_brd_vert_l.dds" , "16" , "8" , "dds" +"ui\uef\game\chat_brd\drop-box_brd_vert_r.dds" , "16" , "8" , "dds" +"ui\uef\game\chat-box_btn\radio_btn_dis.dds" , "28" , "28" , "dds" +"ui\uef\game\chat-box_btn\radio_btn_down.dds" , "28" , "28" , "dds" +"ui\uef\game\chat-box_btn\radio_btn_over.dds" , "28" , "28" , "dds" +"ui\uef\game\chat-box_btn\radio_btn_up.dds" , "28" , "28" , "dds" +"ui\uef\game\construct-panel_vert\construct-panel_bmp_b.dds" , "172" , "12" , "dds" +"ui\uef\game\construct-panel_vert\construct-panel_bmp_m.dds" , "172" , "8" , "dds" +"ui\uef\game\construct-panel_vert\construct-panel_bmp_t.dds" , "172" , "64" , "dds" +"ui\uef\game\construct-panel_vert\que-panel_bmp_b.dds" , "60" , "8" , "dds" +"ui\uef\game\construct-panel_vert\que-panel_bmp_m.dds" , "60" , "8" , "dds" +"ui\uef\game\construct-panel_vert\que-panel_bmp_t.dds" , "60" , "8" , "dds" +"ui\uef\game\construct-panel\construct-panel_bmp_l.dds" , "84" , "140" , "dds" +"ui\uef\game\construct-panel\construct-panel_bmp_m1.dds" , "8" , "140" , "dds" +"ui\uef\game\construct-panel\construct-panel_bmp_m2.dds" , "16" , "140" , "dds" +"ui\uef\game\construct-panel\construct-panel_bmp_m3.dds" , "8" , "112" , "dds" +"ui\uef\game\construct-panel\construct-panel_bmp_r.dds" , "16" , "112" , "dds" +"ui\uef\game\construct-panel\construct-panel_s_bmp_l.dds" , "16" , "112" , "dds" +"ui\uef\game\construct-panel\construct-panel_s_bmp_m.dds" , "8" , "112" , "dds" +"ui\uef\game\construct-panel\construct-panel_s_bmp_r.dds" , "16" , "112" , "dds" +"ui\uef\game\construct-panel\que-panel_bmp_l.dds" , "12" , "52" , "dds" +"ui\uef\game\construct-panel\que-panel_bmp_m.dds" , "8" , "52" , "dds" +"ui\uef\game\construct-panel\que-panel_bmp_r.dds" , "12" , "52" , "dds" +"ui\uef\game\construct-sm_btn\back_off.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\back_on.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\fforward_off.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\fforward_on.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\forward_off.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\forward_on.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\infinite_off.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\infinite_on.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\left_btn_dis.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\left_btn_over.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\left_btn_up.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\mid_btn_dis.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\mid_btn_over.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\mid_btn_selected.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\mid_btn_up.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\pause_off.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\pause_on.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\rewind_off.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\rewind_on.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\right_btn_dis.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\right_btn_over.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\right_btn_up.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\template_off.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_btn\template_on.dds" , "24" , "52" , "dds" +"ui\uef\game\construct-sm_horiz_btn\infinite_off.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_horiz_btn\infinite_on.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_horiz_btn\pause_off.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_horiz_btn\pause_on.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_horiz_btn\que_btn_dis.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_horiz_btn\que_btn_over.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_horiz_btn\que_btn_selected.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_horiz_btn\que_btn_up.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_horiz_btn\template_off.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_horiz_btn\template_on.dds" , "60" , "28" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\bottom_btn_dis.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\bottom_btn_over.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\bottom_btn_up.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\down_off.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\down_on.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\end_off.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\end_on.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\home_off.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\home_on.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\mid_btn_dis.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\mid_btn_over.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\mid_btn_up.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\top_btn_dis.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\top_btn_over.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\top_btn_up.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\up_off.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-sm_nav_horiz_btn\up_on.dds" , "60" , "16" , "dds" +"ui\uef\game\construct-tab_btn\bot_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\bot_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\bot_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\bot_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\bot_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\mid_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\mid_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\mid_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\mid_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\mid_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\top_tab_btn_dis_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\top_tab_btn_down_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\top_tab_btn_over_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\top_tab_btn_sel_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_btn\top_tab_btn_up_bmp.dds" , "76" , "56" , "dds" +"ui\uef\game\construct-tab_top_btn\bot_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\bot_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\bot_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\bot_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\bot_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\mid_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\mid_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\mid_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\mid_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\mid_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\top_tab_btn_dis_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\top_tab_btn_down_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\top_tab_btn_over_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\top_tab_btn_sel_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tab_top_btn\top_tab_btn_up_bmp.dds" , "60" , "32" , "dds" +"ui\uef\game\construct-tech_btn\left_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\left_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\left_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\left_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\left_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\m_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\m_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\m_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\m_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\m_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\r_upgrade_btn_dis.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\r_upgrade_btn_down.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\r_upgrade_btn_over.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\r_upgrade_btn_selected.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\r_upgrade_btn_up.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t1_btn_dis.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t1_btn_down.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t1_btn_over.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t1_btn_selected.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t1_btn_up.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t2_btn_dis.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t2_btn_down.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t2_btn_over.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t2_btn_selected.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t2_btn_up.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t3_btn_dis.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t3_btn_down.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t3_btn_over.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t3_btn_selected.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t3_btn_up.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t4_btn_dis.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t4_btn_down.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t4_btn_over.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t4_btn_selected.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\t4_btn_up.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\template_btn_dis.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\template_btn_down.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\template_btn_over.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\template_btn_selected.dds" , "32" , "32" , "dds" +"ui\uef\game\construct-tech_btn\template_btn_up.dds" , "32" , "32" , "dds" +"ui\uef\game\construction-pause_btn\pause_btn_dis.dds" , "32" , "36" , "dds" +"ui\uef\game\construction-pause_btn\pause_btn_down.dds" , "32" , "36" , "dds" +"ui\uef\game\construction-pause_btn\pause_btn_over.dds" , "32" , "36" , "dds" +"ui\uef\game\construction-pause_btn\pause_btn_up.dds" , "32" , "36" , "dds" +"ui\uef\game\construction-tab_btn\construction_dis.dds" , "28" , "20" , "dds" +"ui\uef\game\construction-tab_btn\construction.dds" , "28" , "20" , "dds" +"ui\uef\game\construction-tab_btn\enhance-back_icon_bmp_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\enhance-back_icon_bmp.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\enhance-l-arm_icon_bmp_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\enhance-l-arm_icon_bmp.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\enhance-r-arm_icon_bmp_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\enhance-r-arm_icon_bmp.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\experimental_icon_bmp_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\experimental_icon_bmp.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-level-1_icon_bmp_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-level-1_icon_bmp.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-level-2_icon_bmp_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-level-2_icon_bmp.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-level-3_icon_bmp_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-level-3_icon_bmp.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-tab_btn_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-tab_btn_down.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-tab_btn_over_sel.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-tab_btn_over.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-tab_btn_selected.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-tab_btn_up_sel.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\tech-tab_btn_up.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\units-attached_icon_bmp_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\units-attached_icon_bmp.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\units-select_icon_bmp_dis.dds" , "56" , "32" , "dds" +"ui\uef\game\construction-tab_btn\units-select_icon_bmp.dds" , "56" , "32" , "dds" +"ui\uef\game\control-group-bracket\panel-control_bmp_b.dds" , "68" , "24" , "dds" +"ui\uef\game\control-group-bracket\panel-control_bmp_m.dds" , "60" , "4" , "dds" +"ui\uef\game\control-group-bracket\panel-control_bmp_t.dds" , "68" , "48" , "dds" +"ui\uef\game\drag-handle\drag-handle-ll_btn_dis.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ll_btn_down.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ll_btn_over.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ll_btn_up.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-lr_btn_dis.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-lr_btn_down.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-lr_btn_over.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-lr_btn_up.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ul_btn_dis.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ul_btn_down.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ul_btn_over.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ul_btn_up.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ur_btn_dis.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ur_btn_down.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ur_btn_over.dds" , "80" , "96" , "dds" +"ui\uef\game\drag-handle\drag-handle-ur_btn_up.dds" , "80" , "96" , "dds" +"ui\uef\game\filter-arrow_btn\tab-close_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\filter-arrow_btn\tab-close_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\filter-arrow_btn\tab-close_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\filter-arrow_btn\tab-close_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\filter-arrow_btn\tab-open_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\filter-arrow_btn\tab-open_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\filter-arrow_btn\tab-open_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\filter-arrow_btn\tab-open_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\filter-ping-list-panel\energy-bar_bmp.dds" , "28" , "16" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_bmp_b.dds" , "128" , "24" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_bmp_m.dds" , "128" , "8" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_bmp_t.dds" , "128" , "24" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_brd_horz_um.dds" , "8" , "20" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_brd_ll.dds" , "24" , "24" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_brd_lm.dds" , "8" , "20" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_brd_lr.dds" , "24" , "24" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_brd_ul.dds" , "24" , "24" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_brd_ur.dds" , "24" , "24" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\uef\game\filter-ping-list-panel\panel_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\uef\game\filter-ping-panel\bracket-energy-l_bmp.dds" , "20" , "72" , "dds" +"ui\uef\game\filter-ping-panel\bracket-energy-r_bmp.dds" , "20" , "72" , "dds" +"ui\uef\game\filter-ping-panel\bracket-left_bmp.dds" , "28" , "68" , "dds" +"ui\uef\game\filter-ping-panel\filter-ping-panel_bmp.dds" , "236" , "72" , "dds" +"ui\uef\game\filter-ping-panel\filter-ping-panel02_bmp.dds" , "180" , "72" , "dds" +"ui\uef\game\infinite_btn\infinite_btn_dis.dds" , "32" , "36" , "dds" +"ui\uef\game\infinite_btn\infinite_btn_down.dds" , "32" , "36" , "dds" +"ui\uef\game\infinite_btn\infinite_btn_over.dds" , "32" , "36" , "dds" +"ui\uef\game\infinite_btn\infinite_btn_up.dds" , "32" , "36" , "dds" +"ui\uef\game\map-options_brd\bracket_bmp.dds" , "48" , "28" , "dds" +"ui\uef\game\map-options_brd\energy-bar_bmp.dds" , "16" , "28" , "dds" +"ui\uef\game\map-options_brd\panel_brd_horz_um.dds" , "8" , "44" , "dds" +"ui\uef\game\map-options_brd\panel_brd_ll.dds" , "24" , "24" , "dds" +"ui\uef\game\map-options_brd\panel_brd_lm.dds" , "8" , "24" , "dds" +"ui\uef\game\map-options_brd\panel_brd_lr.dds" , "24" , "24" , "dds" +"ui\uef\game\map-options_brd\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\uef\game\map-options_brd\panel_brd_ul.dds" , "24" , "44" , "dds" +"ui\uef\game\map-options_brd\panel_brd_ur.dds" , "24" , "44" , "dds" +"ui\uef\game\map-options_brd\panel_brd_vert_l.dds" , "20" , "8" , "dds" +"ui\uef\game\map-options_brd\panel_brd_vert_r.dds" , "20" , "8" , "dds" +"ui\uef\game\medium-btn\medium_btn_dis.dds" , "296" , "72" , "dds" +"ui\uef\game\medium-btn\medium_btn_down.dds" , "296" , "72" , "dds" +"ui\uef\game\medium-btn\medium_btn_glow.dds" , "296" , "72" , "dds" +"ui\uef\game\medium-btn\medium_btn_over.dds" , "296" , "72" , "dds" +"ui\uef\game\medium-btn\medium_btn_up.dds" , "296" , "72" , "dds" +"ui\uef\game\menu-btns\close_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\close_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\close_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\close_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\config_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\config_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\config_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\config_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\default_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\default_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\default_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\default_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\pin_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\pin_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\pin_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\pin_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\pinned_btn_dis.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\pinned_btn_down.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\pinned_btn_over.dds" , "24" , "24" , "dds" +"ui\uef\game\menu-btns\pinned_btn_up.dds" , "24" , "24" , "dds" +"ui\uef\game\mfd_btn\control_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\control_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\control_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\control_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\defenses_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\defenses_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\defenses_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\defenses_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\economy_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\economy_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\economy_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\economy_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\intelligence_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\intelligence_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\intelligence_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\intelligence_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\military_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\military_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\military_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\military_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\military-radar_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\military-radar_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\military-radar_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\military-radar_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-alert_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-alert_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-alert_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-alert_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-attack_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-attack_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-attack_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-attack_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-marker_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-marker_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-marker_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-marker_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-move_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-move_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-move_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\ping-move_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\team-color_btn_dis.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\team-color_btn_down.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\team-color_btn_over.dds" , "44" , "32" , "dds" +"ui\uef\game\mfd_btn\team-color_btn_up.dds" , "44" , "32" , "dds" +"ui\uef\game\mini-map-brd\mini-map_brd_horz_um.dds" , "12" , "36" , "dds" +"ui\uef\game\mini-map-brd\mini-map_brd_ll.dds" , "12" , "16" , "dds" +"ui\uef\game\mini-map-brd\mini-map_brd_lm.dds" , "12" , "16" , "dds" +"ui\uef\game\mini-map-brd\mini-map_brd_lr.dds" , "16" , "16" , "dds" +"ui\uef\game\mini-map-brd\mini-map_brd_m.dds" , "12" , "12" , "dds" +"ui\uef\game\mini-map-brd\mini-map_brd_ul.dds" , "32" , "36" , "dds" +"ui\uef\game\mini-map-brd\mini-map_brd_ur.dds" , "36" , "36" , "dds" +"ui\uef\game\mini-map-brd\mini-map_brd_vert_l.dds" , "12" , "12" , "dds" +"ui\uef\game\mini-map-brd\mini-map_brd_vert_r.dds" , "16" , "12" , "dds" +"ui\uef\game\mini-map-brd\mini-map-glow_bmp.dds" , "204" , "204" , "dds" +"ui\uef\game\mini-map-glow-brd\mini-map-glow_brd_horz_um.dds" , "8" , "24" , "dds" +"ui\uef\game\mini-map-glow-brd\mini-map-glow_brd_ll.dds" , "40" , "40" , "dds" +"ui\uef\game\mini-map-glow-brd\mini-map-glow_brd_lm.dds" , "8" , "24" , "dds" +"ui\uef\game\mini-map-glow-brd\mini-map-glow_brd_lr.dds" , "40" , "40" , "dds" +"ui\uef\game\mini-map-glow-brd\mini-map-glow_brd_ul.dds" , "40" , "40" , "dds" +"ui\uef\game\mini-map-glow-brd\mini-map-glow_brd_ur.dds" , "40" , "40" , "dds" +"ui\uef\game\mini-map-glow-brd\mini-map-glow_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\uef\game\mini-map-glow-brd\mini-map-glow_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\uef\game\mini-scroll\arrow-left_scr_dis.dds" , "24" , "48" , "dds" +"ui\uef\game\mini-scroll\arrow-left_scr_down.dds" , "24" , "48" , "dds" +"ui\uef\game\mini-scroll\arrow-left_scr_over.dds" , "24" , "48" , "dds" +"ui\uef\game\mini-scroll\arrow-left_scr_up.dds" , "24" , "48" , "dds" +"ui\uef\game\mini-scroll\arrow-right_scr_dis.dds" , "24" , "48" , "dds" +"ui\uef\game\mini-scroll\arrow-right_scr_down.dds" , "24" , "48" , "dds" +"ui\uef\game\mini-scroll\arrow-right_scr_over.dds" , "24" , "48" , "dds" +"ui\uef\game\mini-scroll\arrow-right_scr_up.dds" , "24" , "48" , "dds" +"ui\uef\game\mini-vertical-scroll\arrow-down_scr_dis.dds" , "48" , "24" , "dds" +"ui\uef\game\mini-vertical-scroll\arrow-down_scr_down.dds" , "48" , "24" , "dds" +"ui\uef\game\mini-vertical-scroll\arrow-down_scr_over.dds" , "48" , "24" , "dds" +"ui\uef\game\mini-vertical-scroll\arrow-down_scr_up.dds" , "48" , "24" , "dds" +"ui\uef\game\mini-vertical-scroll\arrow-up_scr_dis.dds" , "48" , "24" , "dds" +"ui\uef\game\mini-vertical-scroll\arrow-up_scr_down.dds" , "48" , "24" , "dds" +"ui\uef\game\mini-vertical-scroll\arrow-up_scr_over.dds" , "48" , "24" , "dds" +"ui\uef\game\mini-vertical-scroll\arrow-up_scr_up.dds" , "48" , "24" , "dds" +"ui\uef\game\objective-icons\panel-icon_bmp.dds" , "60" , "68" , "dds" +"ui\uef\game\objective-icons\primary-ring_bmp.dds" , "52" , "48" , "dds" +"ui\uef\game\objective-icons\secondary-ring_bmp.dds" , "52" , "48" , "dds" +"ui\uef\game\options_brd\line-long_bmp.dds" , "200" , "4" , "dds" +"ui\uef\game\options_brd\line-short_bmp.dds" , "228" , "4" , "dds" +"ui\uef\game\options_brd\options_brd_horz_um.dds" , "8" , "48" , "dds" +"ui\uef\game\options_brd\options_brd_ll.dds" , "44" , "48" , "dds" +"ui\uef\game\options_brd\options_brd_lm.dds" , "8" , "48" , "dds" +"ui\uef\game\options_brd\options_brd_lr.dds" , "44" , "48" , "dds" +"ui\uef\game\options_brd\options_brd_m.dds" , "8" , "8" , "dds" +"ui\uef\game\options_brd\options_brd_ul.dds" , "44" , "48" , "dds" +"ui\uef\game\options_brd\options_brd_ur.dds" , "44" , "48" , "dds" +"ui\uef\game\options_brd\options_brd_vert_l.dds" , "44" , "8" , "dds" +"ui\uef\game\options_brd\options_brd_vert_r.dds" , "44" , "8" , "dds" +"ui\uef\game\options_tab\diplomacy_btn_dis.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\diplomacy_btn_down.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\diplomacy_btn_over.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\diplomacy_btn_selected.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\diplomacy_btn_up.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\glow_bmp.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\menu_btn_dis.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\menu_btn_down.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\menu_btn_over.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\menu_btn_selected.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\menu_btn_up.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\objectives_btn_dis.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\objectives_btn_down.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\objectives_btn_over.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\objectives_btn_selected.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\objectives_btn_up.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\pause_btn_dis.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\pause_btn_down.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\pause_btn_over.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\pause_btn_selected.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\pause_btn_up.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\play_btn_dis.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\play_btn_down.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\play_btn_over.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\play_btn_selected.dds" , "56" , "48" , "dds" +"ui\uef\game\options_tab\play_btn_up.dds" , "56" , "48" , "dds" +"ui\uef\game\options-diplomacy-panel\icon-ai_bmp.dds" , "20" , "16" , "dds" +"ui\uef\game\options-diplomacy-panel\icon-person_bmp.dds" , "16" , "20" , "dds" +"ui\uef\game\options-diplomacy-panel\line-allies_bmp.dds" , "260" , "4" , "dds" +"ui\uef\game\options-diplomacy-panel\line-enemies_bmp.dds" , "260" , "4" , "dds" +"ui\uef\game\options-diplomacy-panel\panel-allies_bmp_b.dds" , "268" , "20" , "dds" +"ui\uef\game\options-diplomacy-panel\panel-allies_bmp_m.dds" , "268" , "8" , "dds" +"ui\uef\game\options-diplomacy-panel\panel-allies_bmp_t.dds" , "268" , "40" , "dds" +"ui\uef\game\options-diplomacy-panel\panel-enemy_bmp_b.dds" , "268" , "20" , "dds" +"ui\uef\game\options-diplomacy-panel\panel-enemy_bmp_m.dds" , "268" , "8" , "dds" +"ui\uef\game\options-diplomacy-panel\panel-enemy_bmp_t.dds" , "268" , "40" , "dds" +"ui\uef\game\options-panel\option-panel-inner.dds" , "264" , "388" , "dds" +"ui\uef\game\options-panel\options_brd_horz_lm.dds" , "4" , "28" , "dds" +"ui\uef\game\options-panel\options_brd_horz_um.dds" , "80" , "36" , "dds" +"ui\uef\game\options-panel\options_brd_horz_uml.dds" , "4" , "28" , "dds" +"ui\uef\game\options-panel\options_brd_horz_umr.dds" , "4" , "28" , "dds" +"ui\uef\game\options-panel\options_brd_ll.dds" , "44" , "36" , "dds" +"ui\uef\game\options-panel\options_brd_lr.dds" , "44" , "36" , "dds" +"ui\uef\game\options-panel\options_brd_m.dds" , "4" , "8" , "dds" +"ui\uef\game\options-panel\options_brd_ul.dds" , "44" , "36" , "dds" +"ui\uef\game\options-panel\options_brd_ur.dds" , "44" , "36" , "dds" +"ui\uef\game\options-panel\options_brd_vert_l.dds" , "36" , "8" , "dds" +"ui\uef\game\options-panel\options_brd_vert_ll.dds" , "40" , "24" , "dds" +"ui\uef\game\options-panel\options_brd_vert_lr.dds" , "40" , "24" , "dds" +"ui\uef\game\options-panel\options_brd_vert_r.dds" , "36" , "8" , "dds" +"ui\uef\game\options-panel\options_brd_vert_ul.dds" , "40" , "24" , "dds" +"ui\uef\game\options-panel\options_brd_vert_ur.dds" , "40" , "24" , "dds" +"ui\uef\game\options-panel\options-inner_bmp_b.dds" , "256" , "20" , "dds" +"ui\uef\game\options-panel\options-inner_bmp_m.dds" , "256" , "8" , "dds" +"ui\uef\game\options-panel\options-inner_bmp_t.dds" , "256" , "8" , "dds" +"ui\uef\game\orders-panel_vert\bracket_bmp.dds" , "36" , "228" , "dds" +"ui\uef\game\orders-panel_vert\order-panel_bmp.dds" , "188" , "224" , "dds" +"ui\uef\game\orders-panel\bracket_bmp.dds" , "32" , "120" , "dds" +"ui\uef\game\orders-panel\no-parking_bmp.dds" , "56" , "56" , "dds" +"ui\uef\game\orders-panel\order-panel_bmp.dds" , "332" , "120" , "dds" +"ui\uef\game\orders-panel\question-mark_bmp.dds" , "16" , "24" , "dds" +"ui\uef\game\panel\panel_brd_horz_um.dds" , "8" , "24" , "dds" +"ui\uef\game\panel\panel_brd_ll.dds" , "24" , "24" , "dds" +"ui\uef\game\panel\panel_brd_lm.dds" , "8" , "24" , "dds" +"ui\uef\game\panel\panel_brd_lr.dds" , "24" , "24" , "dds" +"ui\uef\game\panel\panel_brd_m.dds" , "8" , "8" , "dds" +"ui\uef\game\panel\panel_brd_ul.dds" , "24" , "24" , "dds" +"ui\uef\game\panel\panel_brd_ur.dds" , "24" , "24" , "dds" +"ui\uef\game\panel\panel_brd_vert_l.dds" , "24" , "8" , "dds" +"ui\uef\game\panel\panel_brd_vert_r.dds" , "24" , "8" , "dds" +"ui\uef\game\pause_btn\glow_bmp.dds" , "56" , "48" , "dds" +"ui\uef\game\pause_btn\pause_btn_dis.dds" , "56" , "48" , "dds" +"ui\uef\game\pause_btn\pause_btn_down.dds" , "56" , "48" , "dds" +"ui\uef\game\pause_btn\pause_btn_over.dds" , "56" , "48" , "dds" +"ui\uef\game\pause_btn\pause_btn_up.dds" , "56" , "48" , "dds" +"ui\uef\game\pause_btn\play_btn_dis.dds" , "56" , "48" , "dds" +"ui\uef\game\pause_btn\play_btn_down.dds" , "56" , "48" , "dds" +"ui\uef\game\pause_btn\play_btn_over.dds" , "56" , "48" , "dds" +"ui\uef\game\pause_btn\play_btn_up.dds" , "56" , "48" , "dds" +"ui\uef\game\pause-indicator\bottom.dds" , "20" , "20" , "dds" +"ui\uef\game\pause-indicator\left.dds" , "20" , "20" , "dds" +"ui\uef\game\pause-indicator\right.dds" , "20" , "20" , "dds" +"ui\uef\game\pause-indicator\top.dds" , "20" , "20" , "dds" +"ui\uef\game\pda-panel\bracket-right_bmp.dds" , "32" , "156" , "dds" +"ui\uef\game\pda-panel\bracket-right.dds" , "32" , "156" , "dds" +"ui\uef\game\pda-panel\panel-objectives_bmp_l.dds" , "20" , "76" , "dds" +"ui\uef\game\pda-panel\panel-objectives_bmp_m.dds" , "4" , "72" , "dds" +"ui\uef\game\pda-panel\panel-objectives_bmp_r.dds" , "24" , "72" , "dds" +"ui\uef\game\pda-panel\panel-ping_bmp_l.dds" , "20" , "60" , "dds" +"ui\uef\game\pda-panel\panel-ping_bmp_m.dds" , "4" , "56" , "dds" +"ui\uef\game\pda-panel\panel-ping_bmp_r.dds" , "24" , "56" , "dds" +"ui\uef\game\pda-panel\panel-time-units_bmp_l.dds" , "20" , "28" , "dds" +"ui\uef\game\pda-panel\panel-time-units_bmp_m.dds" , "4" , "24" , "dds" +"ui\uef\game\pda-panel\panel-time-units_bmp_r.dds" , "24" , "24" , "dds" +"ui\uef\game\pda-panel\title-bar_bmp.dds" , "136" , "20" , "dds" +"ui\uef\game\pda-panel\video-panel_bmp.dds" , "168" , "148" , "dds" +"ui\uef\game\ping-icons\panel-icon_bmp.dds" , "60" , "60" , "dds" +"ui\uef\game\ping-icons\panel-icon-ring_bmp.dds" , "60" , "60" , "dds" +"ui\uef\game\resource-bars\mini-energy-bar_bmp.dds" , "160" , "16" , "dds" +"ui\uef\game\resource-bars\mini-energy-bar-back_bmp.dds" , "160" , "16" , "dds" +"ui\uef\game\resource-bars\mini-mass-bar_bmp.dds" , "160" , "16" , "dds" +"ui\uef\game\resource-bars\mini-mass-bar-back_bmp.dds" , "160" , "16" , "dds" +"ui\uef\game\resource-panel\alert-caution_bmp.dds" , "48" , "44" , "dds" +"ui\uef\game\resource-panel\alert-energy-panel_bmp.dds" , "316" , "32" , "dds" +"ui\uef\game\resource-panel\alert-icon_bmp.dds" , "28" , "36" , "dds" +"ui\uef\game\resource-panel\alert-mass-panel_bmp.dds" , "316" , "32" , "dds" +"ui\uef\game\resource-panel\alert-no-parking_bmp.dds" , "52" , "52" , "dds" +"ui\uef\game\resource-panel\bracket-energy-l_bmp.dds" , "20" , "72" , "dds" +"ui\uef\game\resource-panel\bracket-energy-r_bmp.dds" , "20" , "72" , "dds" +"ui\uef\game\resource-panel\bracket-left_bmp.dds" , "28" , "68" , "dds" +"ui\uef\game\resource-panel\bracket-right_bmp.dds" , "28" , "68" , "dds" +"ui\uef\game\resource-panel\caution-energy-panel_bmp.dds" , "316" , "32" , "dds" +"ui\uef\game\resource-panel\caution-mass-panel_bmp.dds" , "316" , "32" , "dds" +"ui\uef\game\resource-panel\resources_panel_bmp.dds" , "324" , "72" , "dds" +"ui\uef\game\score-panel\icon-square_bmp.dds" , "24" , "24" , "dds" +"ui\uef\game\score-panel\panel-score_bmp_b.dds" , "236" , "20" , "dds" +"ui\uef\game\score-panel\panel-score_bmp_m.dds" , "236" , "4" , "dds" +"ui\uef\game\score-panel\panel-score_bmp_t.dds" , "236" , "44" , "dds" +"ui\uef\game\slider-btn\slider_btn_dis.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider_btn_down.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider_btn_over.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider_btn_up.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider-energy_btn_dis.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider-energy_btn_down.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider-energy_btn_over.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider-energy_btn_up.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider-mass_btn_dis.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider-mass_btn_down.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider-mass_btn_over.dds" , "28" , "28" , "dds" +"ui\uef\game\slider-btn\slider-mass_btn_up.dds" , "28" , "28" , "dds" +"ui\uef\game\slider\slider-back_bmp.dds" , "200" , "20" , "dds" +"ui\uef\game\slider\slider-bar-green_bmp.dds" , "196" , "16" , "dds" +"ui\uef\game\slider\slider-bar-orange_bmp.dds" , "196" , "16" , "dds" +"ui\uef\game\tab-btn\tab-close_btn_dis.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-btn\tab-close_btn_down.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-btn\tab-close_btn_over.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-btn\tab-close_btn_up.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-btn\tab-open_btn_dis.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-btn\tab-open_btn_down.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-btn\tab-open_btn_over.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-btn\tab-open_btn_up.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-dock-left-btn\tab-dock-l_btn_dis.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-left-btn\tab-dock-l_btn_down.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-left-btn\tab-dock-l_btn_over.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-left-btn\tab-dock-l_btn_up.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-left-btn\tab-dock-r_btn_dis.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-left-btn\tab-dock-r_btn_down.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-left-btn\tab-dock-r_btn_over.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-left-btn\tab-dock-r_btn_up.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-right-btn\tab-dock-l_btn_dis.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-right-btn\tab-dock-l_btn_down.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-right-btn\tab-dock-l_btn_over.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-right-btn\tab-dock-l_btn_up.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-right-btn\tab-dock-r_btn_dis.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-right-btn\tab-dock-r_btn_down.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-right-btn\tab-dock-r_btn_over.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-dock-right-btn\tab-dock-r_btn_up.dds" , "24" , "44" , "dds" +"ui\uef\game\tab-l-btn\tab-close_btn_dis.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-l-btn\tab-close_btn_dis.png" , "20" , "28" , "png" +"ui\uef\game\tab-l-btn\tab-close_btn_down.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-l-btn\tab-close_btn_down.png" , "20" , "28" , "png" +"ui\uef\game\tab-l-btn\tab-close_btn_over.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-l-btn\tab-close_btn_over.png" , "20" , "28" , "png" +"ui\uef\game\tab-l-btn\tab-close_btn_up.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-l-btn\tab-close_btn_up.png" , "20" , "28" , "png" +"ui\uef\game\tab-l-btn\tab-open_btn_dis.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-l-btn\tab-open_btn_dis.png" , "20" , "28" , "png" +"ui\uef\game\tab-l-btn\tab-open_btn_down.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-l-btn\tab-open_btn_down.png" , "20" , "28" , "png" +"ui\uef\game\tab-l-btn\tab-open_btn_over.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-l-btn\tab-open_btn_over.png" , "20" , "28" , "png" +"ui\uef\game\tab-l-btn\tab-open_btn_up.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-l-btn\tab-open_btn_up.png" , "20" , "28" , "png" +"ui\uef\game\tab-r-btn\tab-close_btn_dis.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-r-btn\tab-close_btn_dis.png" , "20" , "28" , "png" +"ui\uef\game\tab-r-btn\tab-close_btn_down.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-r-btn\tab-close_btn_down.png" , "20" , "28" , "png" +"ui\uef\game\tab-r-btn\tab-close_btn_over.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-r-btn\tab-close_btn_over.png" , "20" , "28" , "png" +"ui\uef\game\tab-r-btn\tab-close_btn_up.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-r-btn\tab-close_btn_up.png" , "20" , "28" , "png" +"ui\uef\game\tab-r-btn\tab-open_btn_dis.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-r-btn\tab-open_btn_dis.png" , "20" , "28" , "png" +"ui\uef\game\tab-r-btn\tab-open_btn_down.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-r-btn\tab-open_btn_down.png" , "20" , "28" , "png" +"ui\uef\game\tab-r-btn\tab-open_btn_over.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-r-btn\tab-open_btn_over.png" , "20" , "28" , "png" +"ui\uef\game\tab-r-btn\tab-open_btn_up.dds" , "20" , "28" , "dds" +"ui\uef\game\tab-r-btn\tab-open_btn_up.png" , "20" , "28" , "png" +"ui\uef\game\tab-t-btn\tab-close_btn_dis.dds" , "28" , "20" , "dds" +"ui\uef\game\tab-t-btn\tab-close_btn_dis.png" , "28" , "20" , "png" +"ui\uef\game\tab-t-btn\tab-close_btn_down.dds" , "28" , "20" , "dds" +"ui\uef\game\tab-t-btn\tab-close_btn_down.png" , "28" , "20" , "png" +"ui\uef\game\tab-t-btn\tab-close_btn_over.dds" , "28" , "20" , "dds" +"ui\uef\game\tab-t-btn\tab-close_btn_over.png" , "28" , "20" , "png" +"ui\uef\game\tab-t-btn\tab-close_btn_up.dds" , "28" , "20" , "dds" +"ui\uef\game\tab-t-btn\tab-close_btn_up.png" , "28" , "20" , "png" +"ui\uef\game\tab-t-btn\tab-open_btn_dis.dds" , "28" , "20" , "dds" +"ui\uef\game\tab-t-btn\tab-open_btn_dis.png" , "28" , "20" , "png" +"ui\uef\game\tab-t-btn\tab-open_btn_down.dds" , "28" , "20" , "dds" +"ui\uef\game\tab-t-btn\tab-open_btn_down.png" , "28" , "20" , "png" +"ui\uef\game\tab-t-btn\tab-open_btn_over.dds" , "28" , "20" , "dds" +"ui\uef\game\tab-t-btn\tab-open_btn_over.png" , "28" , "20" , "png" +"ui\uef\game\tab-t-btn\tab-open_btn_up.dds" , "28" , "20" , "dds" +"ui\uef\game\tab-t-btn\tab-open_btn_up.png" , "28" , "20" , "png" +"ui\uef\game\temp_textures\checkmark_dark.dds" , "16" , "16" , "dds" +"ui\uef\game\temp_textures\checkmark.dds" , "16" , "16" , "dds" +"ui\uef\game\temp_textures\combo_sel.dds" , "20" , "20" , "dds" +"ui\uef\game\temp_textures\combo_up.dds" , "20" , "20" , "dds" +"ui\uef\game\temp_textures\mouse.dds" , "52" , "52" , "dds" +"ui\uef\game\toggle_btn\icon-allied-victory_bmp.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle_btn\icon-shared-resources_bmp.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle_btn\toggle-d_btn_dis.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle_btn\toggle-d_btn_down.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle_btn\toggle-d_btn_over.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle_btn\toggle-d_btn_up.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle_btn\toggle-s_btn_dis.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle_btn\toggle-s_btn_down.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle_btn\toggle-s_btn_over.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle_btn\toggle-s_btn_up.dds" , "84" , "28" , "dds" +"ui\uef\game\toggle-lg_btn\toggle-d_btn_dis.dds" , "112" , "28" , "dds" +"ui\uef\game\toggle-lg_btn\toggle-d_btn_down.dds" , "112" , "28" , "dds" +"ui\uef\game\toggle-lg_btn\toggle-d_btn_over.dds" , "112" , "28" , "dds" +"ui\uef\game\toggle-lg_btn\toggle-d_btn_up.dds" , "112" , "28" , "dds" +"ui\uef\game\toggle-lg_btn\toggle-s_btn_dis.dds" , "112" , "28" , "dds" +"ui\uef\game\toggle-lg_btn\toggle-s_btn_down.dds" , "112" , "28" , "dds" +"ui\uef\game\toggle-lg_btn\toggle-s_btn_over.dds" , "112" , "28" , "dds" +"ui\uef\game\toggle-lg_btn\toggle-s_btn_up.dds" , "112" , "28" , "dds" +"ui\uef\game\transmission\title-bar_bmp.dds" , "188" , "20" , "dds" +"ui\uef\game\transmission\video-brackets.dds" , "244" , "232" , "dds" +"ui\uef\game\transmission\video-panel_bmp.dds" , "248" , "240" , "dds" +"ui\uef\game\transmission\video-panel.dds" , "208" , "208" , "dds" +"ui\uef\game\unit-build-over-panel\bracket-build_bmp.dds" , "344" , "132" , "dds" +"ui\uef\game\unit-build-over-panel\bracket-unit_bmp.dds" , "32" , "120" , "dds" +"ui\uef\game\unit-build-over-panel\build-over-back_bmp.dds" , "332" , "116" , "dds" +"ui\uef\game\unit-build-over-panel\fuelbar.dds" , "188" , "4" , "dds" +"ui\uef\game\unit-build-over-panel\healthbar_bg.dds" , "188" , "20" , "dds" +"ui\uef\game\unit-build-over-panel\healthbar_green.dds" , "192" , "24" , "dds" +"ui\uef\game\unit-build-over-panel\healthbar_red.dds" , "192" , "24" , "dds" +"ui\uef\game\unit-build-over-panel\healthbar_yellow.dds" , "192" , "24" , "dds" +"ui\uef\game\unit-build-over-panel\shieldbar.dds" , "188" , "4" , "dds" +"ui\uef\game\unit-build-over-panel\unit-over-back_bmp.dds" , "332" , "116" , "dds" +"warButton.png" , "60" , "61" , "png"