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) <noreply@anthropic.com>
Co-authored-by: GitHub Copilot <copilot@github.com>
This commit is contained in:
2026-08-06 14:51:01 -05:00
co-authored by Claude Opus 5 GitHub Copilot
parent 020dc04612
commit 136e2ff05c
3 changed files with 204 additions and 19 deletions
@@ -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
@@ -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
}
}
}
+159
View File
@@ -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` | `<min>: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 `<min>: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.