diff --git a/Gameleap/mw4/Content/ShellScripts/Multiplayer/ConLobbyMission.script b/Gameleap/mw4/Content/ShellScripts/Multiplayer/ConLobbyMission.script index 9d5e388c..532315c8 100644 --- a/Gameleap/mw4/Content/ShellScripts/Multiplayer/ConLobbyMission.script +++ b/Gameleap/mw4/Content/ShellScripts/Multiplayer/ConLobbyMission.script @@ -39,8 +39,8 @@ #define TIME_LIST_DEFAULT $$g_nTimeList_Index$$ // 5 // 7 minutes #define TIME_VALUE_DEFAULT $$g_nTimeList_Value$$ // 7 // 7 minutes #else // !DEMO_CODE -#define TIME_LIST_COUNT 18 // MSL // expanded 1-15, 20, 25, 30 min -#define TIME_LIST_DEFAULT $$g_nTimeList_Index$$ // 3 // 7 minutes +#define TIME_LIST_COUNT 23 // MSL // expanded 1-15, 20, 25, 30, 45, 60, 120, 180, 240 min +#define TIME_LIST_DEFAULT 6 // index of "7"; the C++ g_nTimeList_Index (3) indexed the old 9-entry list #define TIME_VALUE_DEFAULT $$g_nTimeList_Value$$ // 7 // 7 minutes #endif // DEMO_CODE @@ -485,6 +485,11 @@ main o_game_options[i].list_item[15] = "20" o_game_options[i].list_item[16] = "25" o_game_options[i].list_item[17] = "30" + o_game_options[i].list_item[18] = "45" + o_game_options[i].list_item[19] = "60" + o_game_options[i].list_item[20] = "120" + o_game_options[i].list_item[21] = "180" + o_game_options[i].list_item[22] = "240" if callback($$GetLocalNetworkMissionParamater$$, time_limit) == 0 o_game_options[i].nselected = TIME_LIST_DEFAULT diff --git a/Gameleap/mw4/Content/ShellScripts/Multiplayer/HostLobbyMission.script b/Gameleap/mw4/Content/ShellScripts/Multiplayer/HostLobbyMission.script index beb72cdf..1f3fe41d 100644 --- a/Gameleap/mw4/Content/ShellScripts/Multiplayer/HostLobbyMission.script +++ b/Gameleap/mw4/Content/ShellScripts/Multiplayer/HostLobbyMission.script @@ -110,7 +110,7 @@ main drop_list_size[2] = 2 drop_list_size[3] = 5 drop_list_size[4] = 2 - drop_list_size[5] = 9 + drop_list_size[5] = 23 // [LYLT] extended time list drop_list_size[6] = 10 drop_list_size[7] = 10 drop_list_size[8] = 2 @@ -202,13 +202,20 @@ main o_game_options[i].offsetLabel = drop_name_loc[i] o_game_options[i].arrowHeight = 15 - if i == 11 + if i == 5 // extended time list -> scroll instead of overflowing the panel { - o_game_options[i].max_displayed = 4 + o_game_options[i].max_displayed = 10 } else { - o_game_options[i].max_displayed = 16 + if i == 11 + { + o_game_options[i].max_displayed = 4 + } + else + { + o_game_options[i].max_displayed = 16 + } } } else @@ -545,24 +552,38 @@ main { o_game_options[i].list_item[0] = "1" o_game_options[i].list_item[1] = "2" - o_game_options[i].list_item[2] = "5" - o_game_options[i].list_item[3] = "10" - o_game_options[i].list_item[4] = "15" - o_game_options[i].list_item[5] = "30" - o_game_options[i].list_item[6] = "60" - o_game_options[i].list_item[7] = "90" - o_game_options[i].list_item[8] = "120" + o_game_options[i].list_item[2] = "3" + o_game_options[i].list_item[3] = "4" + o_game_options[i].list_item[4] = "5" + o_game_options[i].list_item[5] = "6" + o_game_options[i].list_item[6] = "7" + o_game_options[i].list_item[7] = "8" + o_game_options[i].list_item[8] = "9" + o_game_options[i].list_item[9] = "10" + o_game_options[i].list_item[10] = "11" + o_game_options[i].list_item[11] = "12" + o_game_options[i].list_item[12] = "13" + o_game_options[i].list_item[13] = "14" + o_game_options[i].list_item[14] = "15" + o_game_options[i].list_item[15] = "20" + o_game_options[i].list_item[16] = "25" + o_game_options[i].list_item[17] = "30" + o_game_options[i].list_item[18] = "45" + o_game_options[i].list_item[19] = "60" + o_game_options[i].list_item[20] = "120" + o_game_options[i].list_item[21] = "180" + o_game_options[i].list_item[22] = "240" if callback($$GetLocalNetworkMissionParamater$$, time_limit) == 0 - o_game_options[i].nselected = 3 + o_game_options[i].nselected = 6 // [LYLT] index of "7" else { - for int doh = 0; doh< 9; doh++ + for int doh = 0; doh< 23; doh++ { if makeint(o_game_options[i].list_item[doh]) >= callback($$GetLocalNetworkMissionParamater$$, time_limit) { o_game_options[i].nselected = doh - doh = 9 + doh = 23 } } } @@ -784,15 +805,15 @@ main //------------------------------------------------------------------------ i = 5 if callback($$GetLocalNetworkMissionParamater$$, time_limit) == 0 - o_game_options[i].nselected = 3 + o_game_options[i].nselected = 6 // [LYLT] index of "7" else { - for int doh = 0; doh< 9; doh++ + for int doh = 0; doh< 23; doh++ { if makeint(o_game_options[i].list_item[doh]) >= callback($$GetLocalNetworkMissionParamater$$, time_limit) { o_game_options[i].nselected = doh - doh = 9 + doh = 23 } } } diff --git a/MISSION-TIME-LIMITS.md b/MISSION-TIME-LIMITS.md new file mode 100644 index 00000000..d12833b2 --- /dev/null +++ b/MISSION-TIME-LIMITS.md @@ -0,0 +1,159 @@ +# Mission time limits — how long a match can be set for + +Reference for the MP mission time limit (`m_gameLength`): what the operator can pick, +what the engine can carry, why "No Limit" is not currently usable, and what it would +take to go past 255 minutes. Written alongside the **LYLT** branch, which extended the +pick lists to the engine's existing ceiling. + +## The short version + +| | Value | +|---|---| +| Console / PC lobby list (after LYLT) | 1-15, 20, 25, 30, 45, 60, 120, 180, **240** min | +| Wire ceiling, no code change | **255 min** (4 h 15 m) | +| Default | 7 min | +| `0` on the wire | "no time limit" — **do not ship; see below** | +| Beyond 255 | needs a wire-format change — see [Plan B](#plan-b--going-past-255-minutes) | + +## How the value flows + +1. The operator picks minutes from a dropdown in + [`ConLobbyMission.script`](Gameleap/mw4/Content/ShellScripts/Multiplayer/ConLobbyMission.script) + (venue console) or + [`HostLobbyMission.script`](Gameleap/mw4/Content/ShellScripts/Multiplayer/HostLobbyMission.script) + (PC host lobby). FSConsole reads the same list from `venue/fsconsole/content/FSConfig.xml`. +2. The script calls `SetNetworkMissionParamater(GAME_LENGTH_PARAMETER, minutes)` → + `params->m_gameLength` ([`MW4Shell.cpp:2066`](Gameleap/code/mw4/Code/MW4/MW4Shell.cpp#L2066)). + **No clamp** anywhere on this path. +3. The value ships to the pods inside the NMP blob as **8 bits**: + `stream->WriteBits(&m_gameLength, 8); // 256 minutes` + ([`MWApplication.cpp:790`](Gameleap/code/mw4/Code/MW4/MWApplication.cpp#L790), read back at `:910`). + That is the entire ceiling: **0-255**. +4. At mission start the pod does + `mission->SetEndMissionTime(m_gameLength * 60.0f)` + ([`MWApplication.cpp:3537`](Gameleap/code/mw4/Code/MW4/MWApplication.cpp#L3537)) — and only + when `m_gameLength > 0` — plus `CTCL_SetGameTime(m_gameLength * 60)` for the console clock + (int32 seconds, no limit of its own). + +Nothing between the dropdown and the mission timer cares how large the number is. The +mission timer itself is `double` (`Stuff::Time`, [`Scalar.hpp:225`](Gameleap/code/mw4/Libraries/stuff/Scalar.hpp#L225)), +so long matches do not lose time accuracy; only the `Scalar` (float) countdown *view* does, +and only by ~8 ms at the 24-hour mark. + +### Displays are already wide enough + +| Where | Format | At 240 min | +|---|---|---| +| MP scoreboard clock, [`hudscore.cpp:443`](Gameleap/code/mw4/Code/MW4/hudscore.cpp#L443) | `%d:%02d` | `240:00` | +| Radar/shell HUD, [`GUIRadarManager.cpp:1981`](Gameleap/code/mw4/Code/MW4/GUIRadarManager.cpp#L1981) | `%02d:%02d` | `240:00` (6 glyphs in a slot laid out for 5 — watch for clipping) | +| Script `GetGameTime`, [`MW4Shell.cpp:3910`](Gameleap/code/mw4/Code/MW4/MW4Shell.cpp#L3910) | `%02d:%02d:%02d` | `04:00:00` | +| FSConsole pane, `FSGamePane.cs` | `:00` | `240:00` | + +## What LYLT changed + +Content and console only — **no `MW4.exe` rebuild**: + +- `ConLobbyMission.script`: `TIME_LIST_COUNT` 9 → 23, the 23 list entries, and + `max_displayed = 10` for the time dropdown so it scrolls instead of overflowing the panel. + `TIME_LIST_DEFAULT` was `$$g_nTimeList_Index$$` (the C++ global `g_nTimeList_Index = 3`, + [`MW4Shell.cpp:159`](Gameleap/code/mw4/Code/MW4/MW4Shell.cpp#L159)) — index 3 is `"7"` in the + OLD list but `"4"` in the new one, so it is now the literal `6`, the index of `"7"`. + Changing the C++ global instead would have forced an exe rebuild for no other gain. + The "reset to defaults" path is unaffected: it searches the list **by value** for + `TIME_VALUE_DEFAULT` = `g_nTimeList_Value` = 7. +- `HostLobbyMission.script`: same list, `drop_list_size[5]` 9 → 23, `max_displayed = 10`, and + the two selection loops that hardcoded `9` (and the `nselected = 3` fallback → `6`). +- FSConsole: the 23 entries in `catalog/generate_fsconfig.py` (regenerated into + `content/FSConfig.xml`), `MaxDropDownItems = 10` on the pane's combo, and a **range guard** + in `NmpBuilder` — it used to write `TimeLimitMinutes & 0xFF`, which silently turned 256 into + 0. It now throws outside 1-255. `SelfTest.CheckTimeLimits()` proves every catalog entry + encodes to the right value at the right bit, and that 0/256/1440 are refused. + +Anything already in flight is unaffected: a stored value picks the first list entry `>=` it, +so old saved settings still resolve. + +**The branch carries sources only.** The repacked `resource\*.mw4` packages, their `.dep`s, the +deployed `MW4\resource` copies and the staged `MW4pro.exe` are build output and are not +tracked on it — they get rebuilt from `main` once this merges. Until you repack, a checkout +still shows the old 9-entry list in game: + +```powershell +build-env\build-resources.ps1 # rebuilds core.mw4 + props.mw4 +copy Gameleap\mw4\Resource\props.mw4 MW4\resource\ # and core.mw4 +``` + +## The "No Limit" trap — why 0 is not shipped + +`SetEndMissionTime` is only called when `m_gameLength > 0`, so `0` looks like a free +"unlimited match" option. It is not usable as the code stands: + +- With the end timer stopped, `GetEndMissionTime()` returns **0** + ([`MWMission.hpp:602-608`](Gameleap/code/mw4/Code/MW4/MWMission.hpp#L602-L608)), so + `CheckEndMissionTime()` returns 0 too. +- The networked fade-out reads that as "under 3 seconds left" and fades the screen to + full black three seconds into the mission + ([`MWGUIManager.cpp:1633-1649`](Gameleap/code/mw4/Code/MW4/MWGUIManager.cpp#L1633-L1649)). + +Nothing sets 0 today (`CTCL_DefaultHostSetup` forces 7, +[`MW4Shell.cpp:13332`](Gameleap/code/mw4/Code/MW4/MW4Shell.cpp#L13332)), which is consistent +with the path never having been exercised. **Read from code, not yet reproduced in game.** + +To make "No Limit" real: guard that fade block on `EndMissionTimerRunning()`, then audit every +other consumer that treats a 0 countdown as "expired" (`TestForGameShutDown`, the 60 s/30 s +voice cues at [`MWMission.cpp:1263-1288`](Gameleap/code/mw4/Code/MW4/MWMission.cpp#L1263-L1288), +scoreboard and mission-review). That is an `MW4.exe` rebuild plus a pod redeploy — cheap in +code, but it needs the same lockstep rollout discipline as Plan B, and the venue gets most of +the benefit from 240 minutes anyway. + +## Plan B — going past 255 minutes + +Everything above is configuration. Past 255 it becomes a **wire-format change**, which is a +different risk class: the NMP has no version field, so a pod on a different build mis-parses +every field after `m_gameLength` (map name, MapClientCRC) and refuses the lobby. + +**B1 — widen the field.** `WriteBits`/`ReadBits` for `m_gameLength` +([`MWApplication.cpp:790`](Gameleap/code/mw4/Code/MW4/MWApplication.cpp#L790) and `:910`). +Use **11 bits** (2047 min): the blob is 3777 fixed bits with 7 pad bits before +`WriteByteAlign`, so up to 15 extra bits are absorbed without changing the **473-byte** fixed +length, and FSConsole's length assert keeps working. The bit layout still shifts — that is +what forces B3. + +**B2 — update every mirror, identically.** +- [`mw4dummy.cpp:433/552`](Gameleap/code/mw4/Code/mw4print/mw4dummy.cpp#L433) — + `mw4print` carries a full duplicate of the serialization for score printouts. Miss it and + printouts garble silently. +- `venue/fsconsole/protocol/NmpBuilder.cs` (+ `testdata/vectors/expected.json`, the golden + cross-validation vectors, and the `CheckTimeLimits` bit offset). +- Rebuild `MW4.exe` **Release and Profile** (Profile is the resource builder). + +**B3 — lockstep deployment.** All pods, both consoles (legacy `MW4.exe` console and +FSConsole) and `mw4print` ship together. The `…A7` legacy-console rollback path is only valid +against equally rolled-back pods. + +**B4 — display formats.** `%d:%02d` and `%02d:%02d` print `1440:00`; both want an h:mm:ss +form above 60 minutes, with column and clip re-checks. FSConsole's `:00` likewise. +`MW4Shell::GetGameTime` already emits hh:mm:ss. + +**B5 — the actual unknown: a day-long match.** The format change is a day's work; proving the +engine survives 24 hours is the project. In order: memory growth and heap fragmentation +(debris, effects, chat, net-stat buffers); the `.mr` mission-review recording, which +accumulates per frame — establish whether it has a cap or rollover before assuming a 24 h +recording is even writable, and what that does to the printout pipeline; DirectPlay session +stability; score and stat counters; and the 8-bit sibling timers — `m_joinInProgressCutOffTime` +is also 8-bit minutes (the game writes 1000 and wires **232**), so join-in-progress cutoff +cannot express a long match either. + +**B6 — test ladder.** vpod bench → 4 h bay run → 12 h → 24 h, sampling process memory and +confirming end-of-mission, review and printout at each rung. Expect the failures in B5, not B1. + +## Verifying a time-limit change + +1. `python venue\fsconsole\catalog\generate_fsconfig.py` — regenerates `FSConfig.xml` and + fails loudly if the authored blocks drift from the sources. +2. `dotnet publish venue\fsconsole\FSConsole.csproj -c Release -o dist\App`, then + `FSConsole.exe -selftest -mission -site bench.ini` against + `python C:\VWE\showrunner\tools\vpod_ctcl.py --pods 2 --cam --write-ini bench.ini`. + `CheckTimeLimits` runs in every self-test mode, network or not. +3. `build-env\build-resources.ps1` to repack `props.mw4`, then copy it to `MW4\resource`. +4. In game: pick the longest entry, confirm the lobby dropdown scrolls, the HUD clock reads + 3-digit minutes without clipping, the mission ends on time, and the debriefing prints.