From 136e2ff05c64001544df6c9bc2c8c2d4f527e396 Mon Sep 17 00:00:00 2001 From: RT Date: Thu, 6 Aug 2026 14:51:01 -0500 Subject: [PATCH] Extend MP mission time limit to 240 min (from LYLT 3514acf6) Time-limit pick lists go to 23 entries: 1-15, 20, 25, 30, 45, 60, 120, 180, 240. Content + console only, no MW4.exe rebuild -- nothing between the dropdown and the mission timer clamps the value, and m_gameLength is 8 bits on the wire (MWApplication.cpp:790), so 255 was always the ceiling. Hand-applied rather than merged. LYLT reverted aa500be7 (9aa317ea) and built on the original 9-entry list, while this branch carries the fixed 18-entry version from 456e1978, so the two sides have different bases. Merging the branch would also drag in that revert of the Battlemaster and Behemoth loadout work. Two deliberate deviations from 3514acf6: - Its max_displayed restructure is NOT taken. Both sides independently reached the same logic (i==5 -> 10, i==6 -> 4, else 16), but LYLT wrote the outer else without braces. That bare else-then-if is the construct behind the null-reference console lobby crash in the aa500be7 regression; 456e1978 already fixed it here. Ours is kept. - HostLobbyMission.script was untouched on this branch so LYLT's version is taken wholesale, but its else block (i==5 / i==11) is re-braced for the same reason before it can bite the PC host lobby. Fixes a real bug this branch already had: TIME_LIST_DEFAULT was still the C++ global g_nTimeList_Index (3), which indexed "7" in the old 9-entry list but indexes "4" in the expanded one -- the default mission time had silently become 4 minutes. Now the literal 6, which is "7" in both the 18- and 23-entry lists. Kept as a literal so the script stays independent of the exe; the reset-to-defaults path matches by value (g_nTimeList_Value = 7) and needed no change. The rest scales on its own: drop_list_size[5], the doh loop bounds and the nselected fallbacks in ConLobbyMission all already used TIME_LIST_COUNT / TIME_LIST_DEFAULT symbolically. The DEMO_CODE list (11 entries, default 5) is untouched. Verified 23 contiguous entries and balanced braces in both files, no unbraced else-if left, CRLF and us-ascii preserved. Not yet repacked: these live in props.mw4, so run build-env\build-resources.ps1 on the Windows box, deleting resource\props.mw4 + props.dep first to force a full repack (incremental carries stale entries forward). Not run on a real bay. Co-authored-by: Claude Opus 5 (Anthropic) Co-authored-by: GitHub Copilot --- .../Multiplayer/ConLobbyMission.script | 9 +- .../Multiplayer/HostLobbyMission.script | 55 ++++-- MISSION-TIME-LIMITS.md | 159 ++++++++++++++++++ 3 files changed, 204 insertions(+), 19 deletions(-) create mode 100644 MISSION-TIME-LIMITS.md 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.