From 557c0b486c3c695b8c20a465247b836d3951a491 Mon Sep 17 00:00:00 2001 From: codi Date: Mon, 28 Sep 2026 17:52:12 -0600 Subject: [PATCH 01/51] Build the Android port on macOS hosts configure.py hardcoded the NDK's linux-x86_64 toolchain folder, so the Android build could not find the NDK on a Mac. Pick the host's folder (darwin-x86_64, windows-x86_64 or linux-x86_64), or the only one the NDK has. On a case-insensitive file system, the game's shim found itself again through its own inclusion and recursed. Use include_next, as the port's other header shims do. --- port/linux/include/StdDef.h | 6 ++++-- tools/android_build.py | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/port/linux/include/StdDef.h b/port/linux/include/StdDef.h index 3465d162..be8aa2b4 100644 --- a/port/linux/include/StdDef.h +++ b/port/linux/include/StdDef.h @@ -1,2 +1,4 @@ -/* the game includes ; Linux file names are case sensitive */ -#include +/* the game includes ; Linux file names are case sensitive. On a +case-insensitive file system (macOS) a plain would find this file +again; include_next goes to the real one, as the other shims do. */ +#include_next diff --git a/tools/android_build.py b/tools/android_build.py index db97763b..3d6288c9 100644 --- a/tools/android_build.py +++ b/tools/android_build.py @@ -213,7 +213,23 @@ def generate_android_build(n: Writer, sln: Any) -> None: import json config: Dict[str, Any] = json.loads(config_path.read_text(encoding="utf-8")) - toolchain = ndk / "toolchains" / "llvm" / "prebuilt" / "linux-x86_64" + prebuilt = ndk / "toolchains" / "llvm" / "prebuilt" + _host_tag = os.environ.get("ANDROID_NDK_HOST_TAG", "") + if not _host_tag: + if sys.platform == "darwin": + _host_tag = "darwin-x86_64" + elif os.name == "nt": + _host_tag = "windows-x86_64" + else: + _host_tag = "linux-x86_64" + if not (prebuilt / _host_tag).is_dir(): + # an NDK that names its host folder differently: take the one there is + _tags = sorted(entry.name for entry in prebuilt.iterdir() if entry.is_dir()) if prebuilt.is_dir() else [] + if not _tags: + n.comment("Android build: the NDK has no LLVM toolchain") + return + _host_tag = _tags[0] + toolchain = prebuilt / _host_tag sysroot_include = toolchain / "sysroot" / "usr" / "include" host_cc = toolchain / "bin" / f"aarch64-linux-android{ANDROID_API}-clang" ndk_bin = toolchain / "bin" From 61623e68d5ba381b3358bf956f02e68bc94fa9b9 Mon Sep 17 00:00:00 2001 From: saulo benigno Date: Sat, 3 Oct 2026 11:41:49 -0300 Subject: [PATCH 02/51] Fix texture bindings after first upload --- port/linux/src/d3d8_gl.c | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/port/linux/src/d3d8_gl.c b/port/linux/src/d3d8_gl.c index 43c924d2..c3b92a04 100644 --- a/port/linux/src/d3d8_gl.c +++ b/port/linux/src/d3d8_gl.c @@ -2186,6 +2186,10 @@ static GLuint mip_composite_get(const struct xgpu_texture_description *descripti static void bind_textures(struct nv2a_pixel_shader_key *key, float texture_scale[4][4]) { + /* Bind only after resolving every stage, since texture uploads can + overwrite the active unit's binding. */ + GLenum gl_targets[D3DTSS_MAXSTAGES]; + GLuint gl_textures[D3DTSS_MAXSTAGES]; int stage; for (stage = 0; stage < D3DTSS_MAXSTAGES; stage++) @@ -2197,7 +2201,8 @@ static void bind_textures(struct nv2a_pixel_shader_key *key, float texture_scale texture_scale[stage][2] = texture_scale[stage][3] = 1.0f; if (!texture || !texture->Data || mode == 0 || mode == 0x04 || mode == 0x05 || mode == 0x11) { - state_texture(stage, GL_TEXTURE_2D, 0); + gl_targets[stage] = GL_TEXTURE_2D; + gl_textures[stage] = 0; key->sampler_type[stage] = mode == 0x11 ? _xgpu_sampler_2d : _xgpu_sampler_none; continue; } @@ -2235,7 +2240,8 @@ static void bind_textures(struct nv2a_pixel_shader_key *key, float texture_scale texture_scale[stage][1] = 1.0f / (float)description.height; } } - state_texture(stage, gl_target, gl_texture); + gl_targets[stage] = gl_target; + gl_textures[stage] = gl_texture; state_sampler(stage, device.samplers[stage]); configure_sampler(stage, description.levels > 1, description.hires); if (stage == 0) @@ -2244,6 +2250,8 @@ static void bind_textures(struct nv2a_pixel_shader_key *key, float texture_scale gl_target == GL_TEXTURE_3D ? _xgpu_sampler_3d : _xgpu_sampler_2d; } } + for (stage = 0; stage < D3DTSS_MAXSTAGES; stage++) + state_texture(stage, gl_targets[stage], gl_textures[stage]); } static GLenum stencil_operation(DWORD operation) From f148dbd6d91b8cbe5c2e1e9a03371e9b4cbaca93 Mon Sep 17 00:00:00 2001 From: Paul Hontz Date: Sat, 3 Oct 2026 22:26:20 -0400 Subject: [PATCH 03/51] Release builds of Windows without a console window The release halo.exe was a console-subsystem PE, so Windows and Wine open an empty console window next to the game. Under Proton in gamescope the console can take focus and leave a black screen. Nothing reads the console (the log is debug.txt), so release builds now link as GUI with mainCRTStartup; debug builds keep the console. --- tools/windows_build.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/windows_build.py b/tools/windows_build.py index 9aa7576f..c8f3af41 100644 --- a/tools/windows_build.py +++ b/tools/windows_build.py @@ -308,8 +308,13 @@ def generate_windows_build(n: Writer, sln: Any) -> None: # 32-bit address space "-Wl,/LARGEADDRESSAWARE", "-Wl,/STACK:0x800000", - "-Wl,/SUBSYSTEM:CONSOLE", ] + if getattr(sln, "port_release", False): + # no console window: the log is debug.txt, and under Wine (Proton, + # gamescope) the console window can hide the game's window + base_ldflags += ["-Wl,/SUBSYSTEM:WINDOWS", "-Wl,/ENTRY:mainCRTStartup"] + else: + base_ldflags += ["-Wl,/SUBSYSTEM:CONSOLE"] def emit(obj_dir: Path, output: Path, extra_cflags: List[str], extra_ldflags: List[str], extra_objects: List[Path], implicit_inputs: List[Path]) -> None: From cd747c23c58c0d81ec175fb21b88da9f3abc3a70 Mon Sep 17 00:00:00 2001 From: Paul Hontz Date: Sat, 3 Oct 2026 22:43:14 -0400 Subject: [PATCH 04/51] Log of the port to halo.log without a console The release build no longer has a console, and the platform layer's log (platform_log to stderr: invite links, the data root, crash reports) went only there. When stderr goes nowhere, halo.log next to halo.exe gets it instead. --- port/windows/README.md | 1 + port/windows/src/win32_posix.c | 18 ++++++++++++++++++ tools/windows_build.py | 5 +++-- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/port/windows/README.md b/port/windows/README.md index 7cbb2978..d3a7d4db 100644 --- a/port/windows/README.md +++ b/port/windows/README.md @@ -52,6 +52,7 @@ The game finds the game data as on Linux. Refer to "Start the game" in | Settings | `config.toml` next to `halo.exe` | | Saved games | `%APPDATA%\halo`, or `paths.saves` in `config.toml` | | Log | `debug.txt` in the data root (the folder that contains `maps\`) | +| Log of the port | The console. The release build has no console: `halo.log` next to `halo.exe` | ## How the port operates diff --git a/port/windows/src/win32_posix.c b/port/windows/src/win32_posix.c index f042bbe0..66bee9b6 100644 --- a/port/windows/src/win32_posix.c +++ b/port/windows/src/win32_posix.c @@ -37,6 +37,24 @@ static void windows_startup(void) /* Sleep() waits in 1 ms steps rather than the default 15.6 ms: the game's frame pacing sleeps for short intervals */ timeBeginPeriod(1); + /* a release build has no console (/SUBSYSTEM:WINDOWS), so the platform + layer's log (platform_log's stderr: invite links, the data root, crash + reports) goes to halo.log next to halo.exe, unless stderr already goes + somewhere (a pipe or a file the game was started with) */ + if (_fileno(stderr) < 0) + { + char path[MAX_PATH]; + DWORD length = GetModuleFileNameA(NULL, path, sizeof(path)); + char *slash; + + if (length && length < sizeof(path) && (slash = strrchr(path, '\\')) && + (size_t)(slash + 1 - path) + sizeof("halo.log") <= sizeof(path)) + { + strcpy(slash + 1, "halo.log"); + if (freopen(path, "w", stderr)) + setvbuf(stderr, NULL, _IONBF, 0); + } + } } static int errno_from_windows_error(DWORD error) diff --git a/tools/windows_build.py b/tools/windows_build.py index c8f3af41..5ad9005a 100644 --- a/tools/windows_build.py +++ b/tools/windows_build.py @@ -310,8 +310,9 @@ def generate_windows_build(n: Writer, sln: Any) -> None: "-Wl,/STACK:0x800000", ] if getattr(sln, "port_release", False): - # no console window: the log is debug.txt, and under Wine (Proton, - # gamescope) the console window can hide the game's window + # no console window (the port's log goes to halo.log instead, + # win32_posix.c): under Wine (Proton, gamescope) the console window + # can hide the game's window base_ldflags += ["-Wl,/SUBSYSTEM:WINDOWS", "-Wl,/ENTRY:mainCRTStartup"] else: base_ldflags += ["-Wl,/SUBSYSTEM:CONSOLE"] From 3d2c04d6754b0f3a59d318dbe7c9f03184c09fc7 Mon Sep 17 00:00:00 2001 From: Fadi Derouiche Date: Sun, 4 Oct 2026 19:27:35 +0100 Subject: [PATCH 05/51] Fix vertices exploding to the screen centre near the camera plane The screen-space conversion that the Xbox's vertex shaders do is undone by multiplying the screen position by w again, which is lossy near the camera plane: the conversion takes the reciprocal of w (rcc), which clamps there, and 1/w rounds differently on each GPU. Worse, where w is zero or not a number the reconstruction leaves the vertex at the clip-space origin. That point is inside the frustum, so nothing clips the triangle away, and OpenGL's zero divided by zero puts the vertex in the middle of the screen: the triangle is then drawn from wherever its other vertices are out to there. On the first-person weapon, whose pose follows the camera and so reaches the camera plane, that is a streak across the whole view. The Xbox's own divide sends such a vertex to infinity and its clipper discards the triangle. Compute the final position from the clip-space position that the shader keeps whenever it takes the reciprocal of w, so that nothing is reconstructed by dividing; and where w is zero or not a number, put the vertex behind the camera (w = -1), which the clipper also discards, as the Xbox did. Both paths were behind the Android build's #ifdef, so desktop GL only ever used the lossy reconstruction. The y flip and depth range that follow remain per platform. Co-Authored-By: Claude Opus 5 (1M context) --- port/linux/src/nv2a_vsh.c | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/port/linux/src/nv2a_vsh.c b/port/linux/src/nv2a_vsh.c index bfb1a7b1..ec9a956b 100644 --- a/port/linux/src/nv2a_vsh.c +++ b/port/linux/src/nv2a_vsh.c @@ -232,9 +232,7 @@ char *nv2a_vertex_shader_to_glsl(const DWORD *instructions, unsigned long instru "\tvec4 oFog = vec4(1.0), oPts = vec4(point_size), oUnused = vec4(0.0);\n" "\tint a0 = 0;\n" "\tvec4 A, B, C, mac, ilu;\n"); -#ifdef HALO_ANDROID xgpu_text_append(&text, "\tvec4 clip_position = vec4(0.0);\n\tbool clip_captured = false;\n"); -#endif for (index = 0; index < instruction_count; index++) { @@ -286,7 +284,6 @@ char *nv2a_vertex_shader_to_glsl(const DWORD *instructions, unsigned long instru case _ilu_lit: xgpu_text_append(&text, "\tilu = nv2a_lit(C);\n"); break; default: xgpu_text_append(&text, "\tilu = vec4(0.0);\n"); break; } -#ifdef HALO_ANDROID /* the screen-space conversion takes the reciprocal of the clip-space position's w (rcc of r12.w); keep the position it converts */ if (ilu == _ilu_rcc && field(instruction, 3, 28, 2) == _mux_temporary && @@ -294,7 +291,6 @@ char *nv2a_vertex_shader_to_glsl(const DWORD *instructions, unsigned long instru { xgpu_text_append(&text, "\tclip_position = oPos;\n\tclip_captured = true;\n"); } -#endif /* results are written only after both units have read their inputs */ if (mac == _mac_arl) @@ -340,21 +336,33 @@ char *nv2a_vertex_shader_to_glsl(const DWORD *instructions, unsigned long instru /* Direct3D 8 puts pixel centres on integer screen coordinates (the game offsets its screen-space quads by -0.5 to match), OpenGL on half-integers */ -#ifdef HALO_ANDROID /* The conversion is screen = clip * c[-38] * rcc(w) + c[-37]; undoing it by multiplying by w again is lossy near the camera plane, where rcc clamps and 1/w rounds differently on each GPU (Mali put vertices of the first-person weapon at the vanishing point). Where the clip position was kept, the same result is computed without dividing. */ + "\tvec4 position;\n" "\tif (clip_captured)\n" - "\t\tgl_Position = vec4((clip_position.xyz * c[%d].xyz + (c[%d].xyz + vec3(0.5 + screen_offset, 0.5, 0.0)\n" + "\t\tposition = vec4((clip_position.xyz * c[%d].xyz + (c[%d].xyz + vec3(0.5 + screen_offset, 0.5, 0.0)\n" "\t\t\t- viewport_offset.xyz) * clip_position.w) / scale, clip_position.w);\n" "\telse\n" - "\t\tgl_Position = vec4((vec3(oPos.xy + vec2(0.5 + screen_offset, 0.5), oPos.z) - viewport_offset.xyz) / scale * oPos.w, oPos.w);\n" -#else - "\tvec3 ndc = (vec3(oPos.xy + vec2(0.5 + screen_offset, 0.5), oPos.z) - viewport_offset.xyz) / scale;\n" - "\tgl_Position = vec4(ndc * oPos.w, oPos.w);\n" -#endif + "\t{\n" + "\t\tvec3 ndc = (vec3(oPos.xy + vec2(0.5 + screen_offset, 0.5), oPos.z) - viewport_offset.xyz) / scale;\n" + "\t\tposition = vec4(ndc * oPos.w, oPos.w);\n" + "\t}\n" + /* A position whose w is zero, or is not a number, is the clip-space + origin: the screen conversion's reciprocal is clamped rather than + infinite, so a large position times a w of zero is exactly zero, and + the origin is inside the frustum. Nothing then clips the triangle + away and OpenGL divides zero by zero there: the vertex lands on the + middle of the screen and the triangle is drawn out to it from the + first-person weapon, whose pose follows the camera and so reaches the + camera plane. The divide on the Xbox sends such a vertex to infinity + and the clipper takes the triangle; put it behind the camera instead, + which the clipper also takes. */ + "\tif (!(abs(position.w) > 0.0))\n" + "\t\tposition = vec4(0.0, 0.0, 0.0, -1.0);\n" + "\tgl_Position = position;\n" #ifdef HALO_ANDROID /* what glClipControl(GL_UPPER_LEFT, GL_ZERO_TO_ONE) does on desktop GL: rows from the top, depth 0..1 */ @@ -371,10 +379,8 @@ char *nv2a_vertex_shader_to_glsl(const DWORD *instructions, unsigned long instru "\txT2 = oT2;\n" "\txT3 = oT3;\n" "\txFog = oFog.x;\n" - "}\n" -#ifdef HALO_ANDROID - , XGPU_VERTEX_CONSTANT_BIAS - 38, XGPU_VERTEX_CONSTANT_BIAS - 37 -#endif + "}\n", + XGPU_VERTEX_CONSTANT_BIAS - 38, XGPU_VERTEX_CONSTANT_BIAS - 37 ); return text.buffer; } From fc7271fd5298b7e473cbfe378eebbb9cfff1af4c Mon Sep 17 00:00:00 2001 From: Fadi Derouiche Date: Sun, 4 Oct 2026 19:45:28 +0100 Subject: [PATCH 06/51] Step the antenna chain in the tick and draw the frames between the ticks An antenna's chain is a simulation of the Xbox's own step, an update a tick, and the port was stepping it once a frame with the frame's own time. Its springs, its carry and the points' physics then acted against the frames' movement of it, so how far the chain moved per update depended on the frame rate, and a vehicle at speed swung it far wider than a tick's step does. - game.c steps the chain in the tick, beside the effects, which are already stepped there. - antenna.c records the shape each tick leaves, relative to the point the vehicle's marker carries the base by. A frame drawn between two ticks is the part of the way between those two shapes that it is, carried to where the marker is now, so the chain stays on the vehicle it hangs from instead of lagging a tick behind it. The gate that left a chain that had not been drawn for five updates unstepped counted frames; it counts ticks now, which is what it counted on the Xbox. - widgets.c no longer steps the chain from the widget update, which ran once a frame. - game_time.c draws the per-frame effects and widgets that hang off objects (game_frame) with the pose between the last two ticks, the pose the renderer draws with, so what they place on an object is drawn where they put it. - objects.c hands markers the matrices the renderer draws with, so an effect, a particle system or a contrail attached to a moving object follows it between the ticks too. Co-Authored-By: Claude Opus 5 (1M context) --- source/game/game.c | 7 ++ source/game/game_time.c | 7 ++ source/objects/objects.c | 8 +- source/objects/widgets/antenna.c | 140 +++++++++++++++++++++++++++++-- source/objects/widgets/widgets.c | 4 +- 5 files changed, 154 insertions(+), 12 deletions(-) diff --git a/source/game/game.c b/source/game/game.c index 0549cc04..52fe8a5f 100644 --- a/source/game/game.c +++ b/source/game/game.c @@ -162,6 +162,7 @@ struct game_options; #include "networking/network_messages.h" #include "networking/telnet_console.h" #include "objects/objects.h" +#include "objects/widgets/antenna.h" #include "objects/widgets/widgets.h" #include "physics/breakable_surfaces.h" #include "physics/collision_usage.h" @@ -331,6 +332,12 @@ void game_tick( ? 1.0f / (2 * TICKS_PER_SECOND) : 1.0f / TICKS_PER_SECOND; effects_update(seconds_per_tick); + /* An antenna's chain is a simulation of the Xbox's own step, an update a + tick: stepped a frame at a time, its springs, its carry and its points' + physics act against the frames' own movement of it, and a vehicle at + speed swings it far wider than a tick's step does. The frames between the + ticks are drawn from what each tick leaves (antenna.c). */ + antennas_update(seconds_per_tick); lock_global_random_seed(); rumble_update(); first_person_weapons_update(); diff --git a/source/game/game_time.c b/source/game/game_time.c index e3454dd5..739418be 100644 --- a/source/game/game_time.c +++ b/source/game/game_time.c @@ -482,7 +482,14 @@ void game_time_update( } } + /* The effects and the widgets that move every frame (game_frame) hang + off objects: they read the pose between the last two ticks, as the + renderer does, and the ticks above have taken their snapshots already + (port/linux/game/render_interpolation.c). The frames of one tick share + the fraction, so what they hang off is drawn where they put it. */ + render_interpolation_frame_begin(); game_frame(game_time_get_speed()*time_delta_sec); + render_interpolation_frame_end(); } else { diff --git a/source/objects/objects.c b/source/objects/objects.c index 3ffb5ab7..cfb31232 100644 --- a/source/objects/objects.c +++ b/source/objects/objects.c @@ -1857,9 +1857,11 @@ short object_get_marker_by_name( struct object_datum const *object = object_get(object_index); struct object_definition const *object_definition = object_definition_get(object->definition_index); - struct object_datum *matrix_object = object_get(object_index); - real_matrix4x3 const *matrices = (real_matrix4x3 *)object_header_block_get(object_index, - &matrix_object->object.node_matrices); + /* port: the matrices the renderer draws with, so a marker an effect, a + particle system or a contrail hangs off moves as the object does, between + the ticks too (port/linux/game/render_interpolation.c). While a tick runs, + which is when the game asks for markers, this is the same array. */ + real_matrix4x3 const *matrices = object_get_node_matrices(object_index); marker = model_get_marker_by_name( object_definition->object.model.index, diff --git a/source/objects/widgets/antenna.c b/source/objects/widgets/antenna.c index 00185605..0ffb0d81 100644 --- a/source/objects/widgets/antenna.c +++ b/source/objects/widgets/antenna.c @@ -51,6 +51,18 @@ symbols in this file: /* ---------- structures */ +/* A chain's shape after each of the last two ticks, relative to the point +its object's marker carries the base by: a frame between the ticks is drawn +with the part of the way between those two shapes that it is, as an object is +drawn between its own (port/linux/game/render_interpolation.c). */ +struct antenna_shape +{ + short count; + boolean has_previous; + real_point3d previous[MAXIMUM_ANTENNA_VERTICES + 1]; + real_point3d latest[MAXIMUM_ANTENNA_VERTICES + 1]; +}; + /* ---------- prototypes */ struct bitmap_data *bitmap_group_try_and_get_bitmap( @@ -63,7 +75,12 @@ static void antenna_update_attachment( struct location *attachment_location, real_point3d *attachment_point, real_vector3d *attachment_vector); +static void antenna_record_shape( + long antenna_index, + struct antenna_datum *antenna, + short count); static void antenna_render_proper( + long antenna_index, struct antenna_datum *antenna, struct antenna_definition *definition); static void antenna_update( @@ -74,6 +91,7 @@ static void antenna_update( /* ---------- globals */ struct data_array *antenna_data; +static struct antenna_shape antenna_shapes[MAXIMUM_ANTENNAS]; /* ---------- public code */ @@ -247,7 +265,7 @@ void antenna_render( } antenna->updates_since_last_render = 0; - antenna_render_proper(antenna, definition); + antenna_render_proper(antenna_index, antenna, definition); } return; @@ -269,7 +287,19 @@ void antennas_update( { antenna->updates_since_last_render++; if (antenna->object_index != NONE && antenna->updates_since_last_render < 5) + { + /* The chain is a simulation of the Xbox's own step, an update + a tick: its springs, its carry and its points' physics are + per-update quantities (game.c steps it in the tick, not in + game_frame with the frames' own movement). */ antenna_update(antenna, definition, MIN(delta, 1.0f / 15.0f)); + /* what this tick leaves a frame between ticks to be drawn in + (antenna_render_proper) */ + antenna_record_shape( + antenna_index, + antenna, + (short)(definition->vertices.count + 1)); + } } } @@ -324,7 +354,51 @@ static void antenna_update_attachment( return; } +/* the shape this tick leaves the chain in, relative to the point the base +was put at (antenna_update_attachment): a tick on from the last one, so a +frame between the two is drawn from both (antenna_render_proper) */ +static void antenna_record_shape( + long antenna_index, + struct antenna_datum *antenna, + short count) +{ + long absolute_index = DATUM_INDEX_TO_ABSOLUTE_INDEX(antenna_index); + struct antenna_shape *shape; + real_point3d const *base = &antenna->vertices[0].position; + short vertex_index; + + if (count <= 0 || count > MAXIMUM_ANTENNA_VERTICES + 1 || + absolute_index < 0 || absolute_index >= MAXIMUM_ANTENNAS) + { + return; + } + + shape = &antenna_shapes[absolute_index]; + if (shape->count == count) + { + csmemcpy(shape->previous, shape->latest, sizeof(real_point3d) * count); + shape->has_previous = TRUE; + } + else + { + shape->count = count; + shape->has_previous = FALSE; + } + + for (vertex_index = 0; vertex_index < count; vertex_index++) + { + real_point3d const *position = &antenna->vertices[vertex_index].position; + + shape->latest[vertex_index].x = position->x - base->x; + shape->latest[vertex_index].y = position->y - base->y; + shape->latest[vertex_index].z = position->z - base->z; + } + + return; +} + static void antenna_render_proper( + long antenna_index, struct antenna_datum *antenna, struct antenna_definition *definition) { @@ -336,6 +410,12 @@ static void antenna_render_proper( real falloff_scale = (100.0f - definition->cutoff_pixels) / (definition->falloff_pixels - definition->cutoff_pixels); + struct antenna_shape const *shape = + &antenna_shapes[DATUM_INDEX_TO_ABSOLUTE_INDEX(antenna_index)]; + real_point3d chain[MAXIMUM_ANTENNA_VERTICES + 1]; + real fraction = render_interpolation_fraction(); + real_point3d base = antenna->vertices[0].position; + short chain_count = (short)(vertices->count + 1); short vertex_index; if (falloff_scale < 0.0f) @@ -343,6 +423,52 @@ static void antenna_render_proper( else if (falloff_scale > 1.0f) falloff_scale = 1.0f; + if (chain_count > MAXIMUM_ANTENNA_VERTICES + 1) + chain_count = MAXIMUM_ANTENNA_VERTICES + 1; + + /* The frames between the ticks (port/linux/game/render_interpolation.c): + the ticks left the chain's shape, a frame is drawn the part of the way + between the last two of them that it is, and the point its vehicle's + marker is at now carries the whole of it. The ticks' own base is what + the shape was recorded against, so with no such record the shape the + last tick left is drawn instead. */ + if (antenna->object_index != NONE) + { + struct object_marker marker; + + object_get_marker_by_name( + antenna->object_index, + definition->attachment_marker, + &marker, + 1); + base = marker.matrix.position; + } + + for (vertex_index = 0; vertex_index < chain_count; vertex_index++) + { + real_point3d offset; + + if (shape->count == chain_count && shape->has_previous) + { + offset.x = shape->previous[vertex_index].x + + (shape->latest[vertex_index].x - shape->previous[vertex_index].x) * fraction; + offset.y = shape->previous[vertex_index].y + + (shape->latest[vertex_index].y - shape->previous[vertex_index].y) * fraction; + offset.z = shape->previous[vertex_index].z + + (shape->latest[vertex_index].z - shape->previous[vertex_index].z) * fraction; + } + else + { + offset.x = antenna->vertices[vertex_index].position.x - antenna->vertices[0].position.x; + offset.y = antenna->vertices[vertex_index].position.y - antenna->vertices[0].position.y; + offset.z = antenna->vertices[vertex_index].position.z - antenna->vertices[0].position.z; + } + + chain[vertex_index].x = base.x + offset.x; + chain[vertex_index].y = base.y + offset.y; + chain[vertex_index].z = base.z + offset.z; + } + build_sprites_begin( &sprite_data, (short)vertices->count, @@ -351,12 +477,10 @@ static void antenna_render_proper( 0); for (vertex_index = 0; - vertex_index < vertices->count; + vertex_index < chain_count - 1; vertex_index = (short)(vertex_index + 1)) { struct antenna_vertex_datum *vertex = &antenna->vertices[vertex_index]; - struct antenna_vertex_datum *next_vertex = - &antenna->vertices[vertex_index + 1]; struct antenna_vertex_definition *definition_vertex = TAG_BLOCK_GET_ELEMENT( vertices, vertex_index, @@ -364,9 +488,9 @@ static void antenna_render_proper( real_vector3d direction; real_argb_color color; - direction.i = next_vertex->position.x - vertex->position.x; - direction.j = next_vertex->position.y - vertex->position.y; - direction.k = next_vertex->position.z - vertex->position.z; + direction.i = chain[vertex_index + 1].x - chain[vertex_index].x; + direction.j = chain[vertex_index + 1].y - chain[vertex_index].y; + direction.k = chain[vertex_index + 1].z - chain[vertex_index].z; color = definition_vertex->color; if (vertex->sprite_scale != 0.0f && falloff_scale > 0.0f) @@ -376,7 +500,7 @@ static void antenna_render_proper( 1, definition_vertex->sequence_index, 0, - &vertex->position, + &chain[vertex_index], &direction, 0.0f, vertex->sprite_scale, diff --git a/source/objects/widgets/widgets.c b/source/objects/widgets/widgets.c index 3fd0caf0..7c04f7e5 100644 --- a/source/objects/widgets/widgets.c +++ b/source/objects/widgets/widgets.c @@ -102,7 +102,9 @@ static struct widget_type_definition data_0030b2b0[NUMBER_OF_WIDGET_TYPES] = antennas_dispose, antenna_new, antenna_delete, - antennas_update, + /* the chain simulates at the Xbox's own rate, an update a tick, so + the tick steps it rather than the frame (game.c; antenna.c) */ + NULL, antenna_render }, { From 20861c06d297cd7f6f083357c57363e2277cb5d7 Mon Sep 17 00:00:00 2001 From: Addison Bair Date: Sun, 4 Oct 2026 04:09:19 +0000 Subject: [PATCH 07/51] Count a fullscreen window that gamescope never confirms as fullscreen gamescope (the Steam Deck's Game Mode) makes the window the size of the display but never confirms the fullscreen request, so SDL leaves SDL_WINDOW_FULLSCREEN unset. platform_screen_mode then reported a window, the game drew the Xbox's 640x480, and gamescope stretched it to the screen, soft and 4:3. A window that was asked to be fullscreen and covers its display now counts as fullscreen too, so the game draws at the display's resolution and shape (1280x800 on the Deck). --- port/linux/src/sdl_platform.c | 48 +++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/port/linux/src/sdl_platform.c b/port/linux/src/sdl_platform.c index e7f7e102..56bf2212 100644 --- a/port/linux/src/sdl_platform.c +++ b/port/linux/src/sdl_platform.c @@ -396,6 +396,38 @@ static void platform_fullscreen_kind_apply(void) exclusive && display ? SDL_GetDesktopDisplayMode(display) : NULL); } +/* whether the game last asked for the window to be fullscreen */ +static BOOL platform_fullscreen_requested = FALSE; + +static void platform_window_set_fullscreen(BOOL fullscreen) +{ + platform_fullscreen_requested = fullscreen; + SDL_SetWindowFullscreen(platform_window, fullscreen ? true : false); +} + +/* whether the window is fullscreen. SDL sets its flag when the window +manager confirms the request, and some never do: gamescope (the Steam +Deck's Game Mode) makes the window the size of the display but leaves the +flag unset, so the game drew 640x480 and gamescope stretched it. A window +that was asked to be fullscreen and covers its display counts too. */ +static BOOL platform_window_fullscreen(void) +{ + SDL_DisplayID display; + const SDL_DisplayMode *mode; + int width, height; + + if (SDL_GetWindowFlags(platform_window) & SDL_WINDOW_FULLSCREEN) + return TRUE; + if (!platform_fullscreen_requested) + return FALSE; + display = SDL_GetDisplayForWindow(platform_window); + mode = display ? SDL_GetDesktopDisplayMode(display) : NULL; + if (!mode || !SDL_GetWindowSizeInPixels(platform_window, &width, &height)) + return FALSE; + return width >= (int)(mode->w * mode->pixel_density + 0.5f) && + height >= (int)(mode->h * mode->pixel_density + 0.5f); +} + /* whether the game is, or is to be, fullscreen, and if so the size in pixels of the display it fills (d3d8_gl.c draws at that resolution) */ BOOL platform_screen_mode(long *width, long *height) @@ -403,7 +435,7 @@ BOOL platform_screen_mode(long *width, long *height) SDL_DisplayID display; const SDL_DisplayMode *mode; - if (platform_window ? !(SDL_GetWindowFlags(platform_window) & SDL_WINDOW_FULLSCREEN) : + if (platform_window ? !platform_window_fullscreen() : !platform_fullscreen_setting() || !platform_sdl_initialize()) { return FALSE; @@ -476,6 +508,7 @@ BOOL platform_video_initialize(unsigned long width, unsigned long height) return FALSE; } #ifndef HALO_ANDROID + platform_fullscreen_requested = platform_fullscreen_setting(); platform_fullscreen_kind_apply(); #endif platform_gl_context = SDL_GL_CreateContext(platform_window); @@ -517,8 +550,8 @@ void platform_display_apply(void) if (!platform_window) return; platform_fullscreen_kind_apply(); - if (((SDL_GetWindowFlags(platform_window) & SDL_WINDOW_FULLSCREEN) != 0) != (fullscreen != FALSE)) - SDL_SetWindowFullscreen(platform_window, fullscreen ? true : false); + if (platform_window_fullscreen() != (fullscreen != FALSE)) + platform_window_set_fullscreen(fullscreen); if (scale != platform_window_scale && scale >= 1) { platform_window_scale = scale; @@ -866,13 +899,13 @@ static void platform_show_pending_message(void) #else { /* (a box cannot show above a fullscreen game) */ - int fullscreen = (SDL_GetWindowFlags(platform_window) & SDL_WINDOW_FULLSCREEN) != 0; + BOOL fullscreen = platform_window_fullscreen(); if (fullscreen) - SDL_SetWindowFullscreen(platform_window, false); + platform_window_set_fullscreen(FALSE); SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_WARNING, title, text, platform_window); if (fullscreen) - SDL_SetWindowFullscreen(platform_window, true); + platform_window_set_fullscreen(TRUE); } #endif } @@ -984,8 +1017,7 @@ void platform_pump_events(void) window's size and place while fullscreen) */ if (event.key.down && !event.key.repeat && event.key.scancode == SDL_SCANCODE_F11) { - SDL_SetWindowFullscreen(platform_window, - (SDL_GetWindowFlags(platform_window) & SDL_WINDOW_FULLSCREEN) ? false : true); + platform_window_set_fullscreen(!platform_window_fullscreen()); } #endif break; From e3389991ac334ee9772dc04aa2f25d3c6631c629 Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:18:40 +1300 Subject: [PATCH 08/51] Online split screen --- port/assets/menus/NON_HANDDRAWN.md | 2 +- port/assets/menus/ce/bitmaps.xml | 3 + ...ain_menu.multiplayer_type_select.lobby.xml | 126 +++++++-- .../lobby/header_add_player.png | Bin 0 -> 63575 bytes port/assets/menus/menus.json | 1 + .../lobby/header_add_player.svg | 13 + port/linux/README.md | 21 +- port/linux/game/menu_functions.c | 253 +++++++++++++++++- port/linux/game/menu_tags.c | 2 + port/linux/src/xinput_sdl.c | 44 ++- source/game/players.c | 9 + source/interface/player_ui.c | 13 + source/interface/player_ui.h | 3 + .../ui_widget_event_handler_functions.c | 20 ++ tools/port_settings.py | 69 ++++- 15 files changed, 531 insertions(+), 48 deletions(-) create mode 100644 port/assets/menus/ce/port/main_menu/multiplayer_type_select/lobby/header_add_player.png create mode 100644 port/assets/menus/port_svg/main_menu/multiplayer_type_select/lobby/header_add_player.svg diff --git a/port/assets/menus/NON_HANDDRAWN.md b/port/assets/menus/NON_HANDDRAWN.md index f12ff5f2..e5ef361a 100644 --- a/port/assets/menus/NON_HANDDRAWN.md +++ b/port/assets/menus/NON_HANDDRAWN.md @@ -51,7 +51,7 @@ until it is redrawn. | `ce/shell/main_menu/settings_select/player_setup/player_profile_edit/profile_options__6.png` | 512x256 | `shell/main_menu/settings_select/player_setup/player_profile_edit/profile_options__6.png` | | `ce/shell/main_menu/settings_select/player_setup/player_profile_edit/profile_options__8.png` | 512x256 | `shell/main_menu/settings_select/player_setup/player_profile_edit/profile_options__8.png` | -13 of 155 frames are placeholders. +13 of 156 frames are placeholders. The PC version's `ui\gamespy` and `ui\ticker` fonts are drawn with `ui\small_ui`, which the Xbox's map has. diff --git a/port/assets/menus/ce/bitmaps.xml b/port/assets/menus/ce/bitmaps.xml index 3d6dd799..d83d40d8 100644 --- a/port/assets/menus/ce/bitmaps.xml +++ b/port/assets/menus/ce/bitmaps.xml @@ -318,6 +318,9 @@ + + + diff --git a/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml b/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml index 28338f3a..62042cde 100644 --- a/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml +++ b/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml @@ -2,33 +2,92 @@ + - - + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -45,10 +104,16 @@ - - + + + + + + + + @@ -64,6 +129,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/port/assets/menus/ce/port/main_menu/multiplayer_type_select/lobby/header_add_player.png b/port/assets/menus/ce/port/main_menu/multiplayer_type_select/lobby/header_add_player.png new file mode 100644 index 0000000000000000000000000000000000000000..9fc7d40cf09eaa9ad69c56152b3ee3325041ca5c GIT binary patch literal 63575 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�TuF_BemDr~ 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 + + + + + + + + + + + + diff --git a/port/linux/README.md b/port/linux/README.md index 832bafae..38f4f796 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -95,9 +95,11 @@ The keyboard and the mouse are a control scheme of their own for the player of controller 1: each action has up to two keys or mouse buttons, which Settings > Controls Setup (or `[controls]` in `config.toml`) changes. The game adds the input of the first gamepad to controller 1. The other -gamepads operate controllers 2 to 4. In co-op with only one gamepad, that -gamepad is controller 2 (player 2) and the keyboard and mouse stay -controller 1. The profile's button layout (Settings > Gamepads) is the +gamepads operate controllers 2 to 4. With two or more players on this +computer (co-op, or split screen in a network game) and only one gamepad, +that gamepad is controller 2 (player 2) and the keyboard and mouse stay +controller 1. The gamepad changes controller only when none of its buttons +is held. The profile's button layout (Settings > Gamepads) is the gamepads' only. | Action | Keys and buttons (default) | @@ -166,6 +168,17 @@ Player 2 then chooses a profile with their own controller (a gamepad), and New Game's levels are those either profile has reached. A co-op game does not continue a saved game of one player. +Network games have split screen too: up to 4 players on each computer. In +the game lobby, another controller presses START to join, and the new +player's profile is chosen on the ADD PLAYER screen that opens (with any +controller). With one gamepad, choose the lobby's ADD PLAYER button first: +until then that gamepad shares controller 1 with the keyboard. Two players on one profile get different names from the host. A +player's B in the lobby leaves the game alone, and the last player of the +computer leaves it for all of them. In the game, each player's pause menu +opens on their part of the screen, and its LEAVE GAME is theirs: their part +of the screen stays until the game ends. A game under way is joined by one +player; others join in the lobby of the next. + In a multiplayer game, the pause menu (escape) has SETTINGS, which opens the profile's settings while the game goes on, and for the host END GAME. END GAME ends the game as its time limit does: the players stay, and after @@ -680,7 +693,7 @@ Other changes: | `networking/`, `game/`, `interface/`, `bungie_net/network/` and the pools of objects, effects and sounds | The system link limits and the memory for them. | | `game/`, `objects/`, `units/`, `networking/` | The distributed netcode. Refer to `NETCODE.md`. | | `cache/cache_files.c` | When a map's tags load and unload, the port finds the bitmaps that the high-res HUD replaces (`game/hud_hires_tags.c`), and adds the tags of the menus to the menus' map (`game/menu_tags.c`). | -| `interface/ui_widget.c`, `interface/ui_widget_event_handler_functions.c`, `interface/ui_widget_game_data_input_functions.c` | The main menu is the PC version's from `port/assets/menus` (`display.menus`); the menus' widgets can call the port's functions (`game/menu_functions.c`) and send the PC version's custom activation event; the widgets' memory is 256 KB, not 16 KB; the main menu and Multiplayer clear co-op's controllers, so that a gamepad going back to controller 1 is not a controller unplugged. | +| `interface/ui_widget.c`, `interface/ui_widget_event_handler_functions.c`, `interface/ui_widget_game_data_input_functions.c` | The main menu is the PC version's from `port/assets/menus` (`display.menus`); the menus' widgets can call the port's functions (`game/menu_functions.c`) and send the PC version's custom activation event; the widgets' memory is 256 KB, not 16 KB; the main menu and Multiplayer clear co-op's controllers, so that a gamepad going back to controller 1 is not a controller unplugged; the lobby's split screen players leave alone, and one who quits in game is not joined to the next game (`interface/player_ui.c`). | | `input/input_abstraction.c`, `game/player_control.c`, `game/players.c`, `game/player_queues_new.c`, `units/units.h` | The keyboard and mouse's actions (`src/xinput_sdl.c`, `include/halo_keyboard.h`) join controller 1's game controls; their reload key reloads on its own, and their action key only acts (a control flag of the port's, sent with the player's action, stops the reload the controller's X falls back to). | | `sound/sound_manager.c`, `interface/hud.c`, `game/game_engine.c` | The music's and the other sounds' volumes; the HUD's and the scoreboard's settings are read again when Settings changes them. | | `interface/hud.c` | In multiplayer, players' names are drawn above their heads (`display.player_names`, `display.player_name_scale`). | diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index 59838c34..d12c9339 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -105,6 +105,7 @@ short ui_widget_port_list_index(struct widget_instance *list_widget); boolean ui_widget_port_saved_game(char const **map_name, short *level, short *difficulty); short main_get_solo_level_from_name(char const *name); boolean player_name_clean(wchar_t *name, long count); +short players_port_local_player_count(void); boolean pc_menu_event_function_invoke(struct widget_instance *widget, struct event_record *event, long function_index, boolean *widget_deleted); @@ -118,6 +119,7 @@ void pc_menu_game_data_function_invoke(struct widget_instance *widget, long func #define BUTTON_A 0 #define BUTTON_B 1 #define BUTTON_X 2 +#define BUTTON_START 12 enum { @@ -1421,14 +1423,8 @@ screen (the Xbox's Cooperative Play, which the PC version has not): Multiplayer's CO-OP CAMPAIGN ("port coop begin"), player 2's profile, chosen with player 2's controller ("port coop player 2"), then New Game's levels (those either has reached) and difficulty. The main menu and Multiplayer -go back to one player (main_menu_initialize, multiplayer_type_menu_initialize) */ - -/* (xinput_sdl.c) whether two players play on this machine: then one gamepad -is player 2's, not the keyboard's player 1's */ -unsigned char pc_menu_coop_players(void) -{ - return player_spawn_count >= 2; -} +go back to one player (main_menu_initialize, multiplayer_type_menu_initialize). +With one gamepad, it is player 2's (pc_menu_split_players) */ /* "port coop begin": two players, player 1 on its profile (campaign_profile) and the controller that chose co-op */ @@ -1816,6 +1812,8 @@ boolean network_game_client_advertised_game_in_progress(void *client, struct adv boolean ui_widget_port_join(struct widget_instance *widget, void *advertised_game, char const *lobby_name, boolean *widget_deleted); boolean ui_widget_port_multiplayer_player(short controller_index, long profile_index); +boolean ui_widget_port_unjoin_player(struct widget_instance *widget, struct event_record *event, + boolean *widget_deleted); void network_game_server_port_set_settings(wchar_t const *name, long maximum_players); void *global_network_game_client_get(void); void *global_network_game_server_get(void); @@ -3068,6 +3066,205 @@ gametype, the countdown */ static struct network_player *lobby_players[HALO_PORT_MAXIMUM_NETWORK_PLAYERS]; static short lobby_player_count; +/* split screen: up to 4 players on this machine. In the lobby, a controller +not playing presses START to join ("port lobby join"), then chooses a +profile (its screen, "port lobby player choose"); ADD PLAYER ("port lobby +add player") gives one gamepad its own controller first (as it shares +player 1's: pc_menu_split_players). A player's B leaves the game alone ("port +lobby leave"; the machine's last leaves it). In game, a player's pause menu's +QUIT is theirs (the Xbox's "mp game player quit") */ +static struct +{ + /* ADD PLAYER chosen: one gamepad is the new player's */ + boolean adding; + /* the controller choosing its profile, NONE if none */ + short controller; +} lobby_join = { FALSE, NONE }; + +/* the client's player of the controller on this machine, NULL if none */ +static struct network_player *lobby_local_player(short controller) +{ + void *client = global_network_game_client_get(); + struct network_game *game = client ? network_game_client_get_game(client) : NULL; + short machine_index = network_game_client_get_local_machine_index(); + short index; + + for (index = 0; game && machine_index != NONE && index < HALO_PORT_MAXIMUM_NETWORK_PLAYERS; index++) + { + struct network_player *player = &game->players[index]; + + if (network_player_is_valid(player) && (short)player->machine_index == machine_index && + (short)player->controller_index == controller) + { + return player; + } + } + return NULL; +} + +/* whether the controller plays (or is to: joined, not yet added) */ +static boolean lobby_controller_playing(short controller) +{ + return player_ui_local_player_wants_to_play_multiplayer(controller) || lobby_local_player(controller); +} + +/* this machine's players in the network game (or to be) */ +static short lobby_local_player_count(void) +{ + short controller, count = 0; + + if (!global_network_game_client_get()) + return 0; + for (controller = 0; controller < MAXIMUM_LOCAL_PLAYERS; controller++) + count += lobby_controller_playing(controller) ? 1 : 0; + return count; +} + +/* (xinput_sdl.c) whether this machine has, or is adding, a second player: co-op, +or split screen in a network game (a player who quit keeps their part of the +screen until the game ends). Then one gamepad is its own controller, not +sharing player 1's with the keyboard */ +unsigned char pc_menu_split_players(void) +{ + return player_spawn_count >= 2 || lobby_join.adding || lobby_join.controller != NONE || + lobby_local_player_count() >= 2 || players_port_local_player_count() >= 2; +} + +/* the lobby's widget that has the focus (the one a press goes to) */ +static struct widget_instance *focused_leaf(struct widget_instance *widget) +{ + while (widget->parent) + widget = widget->parent; + while (widget->focused_child) + widget = widget->focused_child; + return widget; +} + +/* "port lobby open" (the lobby made, or come back to): no player being added */ +static boolean lobby_join_reset(void) +{ + lobby_join.adding = FALSE; + lobby_join.controller = NONE; + return TRUE; +} + +/* "port lobby add player" (ADD PLAYER): the next START of another controller +joins (one gamepad leaves the keyboard's controller for its own) */ +static boolean lobby_add_player(void) +{ + if (lobby_local_player_count() >= MAXIMUM_LOCAL_PLAYERS) + return campaign_fail(); + lobby_join.adding = TRUE; + return TRUE; +} + +/* "port lobby join" (START): a controller not playing joins, choosing its +profile next (FALSE: no profile screen); a player's START is the focused +button's */ +static boolean lobby_join_start(struct widget_instance *widget, short controller, boolean *widget_deleted) +{ + void *client = global_network_game_client_get(); + struct network_game *game = client ? network_game_client_get_game(client) : NULL; + short state_data; + + if (lobby_controller_playing(controller)) + { + ui_widget_port_dispatch_event(focused_leaf(widget), BUTTON_START, controller, widget_deleted); + return FALSE; + } + if (!game || network_game_client_get_state(client, &state_data) != _client_state_pregame) + return campaign_fail(); + if (lobby_player_count >= game->maximum_players) + { + display_error_text_deferred(L"The game is full.", NONE); + return campaign_fail(); + } + lobby_join.controller = controller; + return TRUE; +} + +/* "port lobby leave" (B): a player leaves the game, the machine's last +leaving it (TRUE: back from the lobby); a controller not playing cancels +ADD PLAYER */ +static boolean lobby_leave(struct widget_instance *widget, struct event_record *event, short controller, + boolean *widget_deleted) +{ + if (!lobby_controller_playing(controller)) + { + lobby_join_reset(); + return FALSE; + } + return ui_widget_port_unjoin_player(widget, event, widget_deleted); +} + +/* "port lobby player list initialize": on the joining controller's profile, +else the first no other player of this machine has */ +static boolean lobby_player_list_initialize(struct widget_instance *list) +{ + long mine = lobby_join.controller != NONE ? player_ui_get_active_player_profile_index(lobby_join.controller) : NONE; + short index, controller; + + profile_list_read(TRUE); + profile_list.chosen = 0; + for (index = profile_list.count - 1; index >= 0; index--) + { + boolean taken = FALSE; + + for (controller = 0; controller < MAXIMUM_LOCAL_PLAYERS; controller++) + { + taken |= controller != lobby_join.controller && lobby_controller_playing(controller) && + player_ui_get_active_player_profile_index(controller) == profile_list.indices[index]; + } + if (!taken) + profile_list.chosen = index; + } + for (index = 0; index < profile_list.count && mine != NONE; index++) + { + if (profile_list.indices[index] == mine) + profile_list.chosen = index; + } + profile_list.first = (short)PIN(profile_list.chosen - PROFILE_ROWS / 2, 0, + MAX(0, profile_list.count + 1 - PROFILE_ROWS)); + focus_row(list, (short)(profile_list.chosen - profile_list.first)); + return TRUE; +} + +/* "port lobby player choose": the joining controller's player on the profile +chosen (the lobby's "net splitscreen prejoin players" adds them) */ +static boolean lobby_player_choose(void) +{ + short controller = lobby_join.controller; + + if (controller == NONE || profile_list.chosen >= profile_list.count || + !ui_widget_port_multiplayer_player(controller, profile_list.indices[profile_list.chosen])) + { + return campaign_fail(); + } + return TRUE; +} + +/* the lobby's line under its players: how another player joins */ +static void lobby_join_help(struct widget_instance *list) +{ + struct widget_instance *help = named(screen_of(list), "lobby_join_help", 0); + wchar_t const *text = L""; + short controller; + + if (lobby_join.adding) + { + text = L"New player: press START."; + } + else if (lobby_local_player_count() < MAXIMUM_LOCAL_PLAYERS) + { + for (controller = 0; controller < MAXIMUM_LOCAL_PLAYERS; controller++) + { + if (input_has_gamepad(controller) && !lobby_controller_playing(controller)) + text = L"Another controller: START joins."; + } + } + text_set(help, text); +} + static void lobby_row_text(short row, wchar_t *text) { struct network_player *player = lobby_players[multiplayer.lobby_first + row]; @@ -3080,6 +3277,13 @@ static void lobby_row_text(short row, wchar_t *text) usnprintf(text, ROW_TEXT_LENGTH - 1, L"%s (%s)", name, player->team_index ? L"BLUE" : L"RED"); else usnprintf(text, ROW_TEXT_LENGTH - 1, L"%s", name); + /* (this machine's players, when it has more than one: their controllers) */ + if (lobby_local_player_count() >= 2 && (short)player->machine_index == network_game_client_get_local_machine_index()) + { + size_t length = ustrlen(text); + + usnprintf(text + length, ROW_TEXT_LENGTH - 1 - length, L" [P%d]", player->controller_index + 1); + } text[ROW_TEXT_LENGTH - 1] = 0; } @@ -3121,6 +3325,7 @@ static void lobby_update(struct widget_instance *list) if (multiplayer.lobby_first > MAX(0, lobby_player_count - LOBBY_ROWS)) multiplayer.lobby_first = (short)MAX(0, lobby_player_count - LOBBY_ROWS); rows_update(list, (short)MIN(lobby_player_count, LOBBY_ROWS), lobby_row_text); + lobby_join_help(list); visible_set(named(list, "lobby_button_team", 0), game && game->variant.universal_variant.teams); /* (the buttons' focus, off Switch Team when it is hidden) */ focus_off_hidden(named(list, "lobby_button_bar", 0)); @@ -3938,17 +4143,19 @@ boolean pc_menu_event_function_invoke( { return pc_menu_profile_edit_begin(); } + /* (the press posted is the controller's that chose the button: a + split screen player's LEAVE is theirs) */ else if (!strcmp(name, "mouse emit accept event")) { - event_manager_post_button(controller_of(widget), BUTTON_A); + event_manager_post_button(controller, BUTTON_A); } else if (!strcmp(name, "mouse emit back event")) { - event_manager_post_button(controller_of(widget), BUTTON_B); + event_manager_post_button(controller, BUTTON_B); } else if (!strcmp(name, "mouse emit x event")) { - event_manager_post_button(controller_of(widget), BUTTON_X); + event_manager_post_button(controller, BUTTON_X); } else if (!strcmp(name, "emit custom activation event")) { @@ -4117,6 +4324,30 @@ boolean pc_menu_event_function_invoke( { return profile_choose(controller); } + else if (!strcmp(name, "port lobby open")) + { + return lobby_join_reset(); + } + else if (!strcmp(name, "port lobby add player")) + { + return lobby_add_player(); + } + else if (!strcmp(name, "port lobby join")) + { + return lobby_join_start(widget, controller, widget_deleted); + } + else if (!strcmp(name, "port lobby leave")) + { + return lobby_leave(widget, event, controller, widget_deleted); + } + else if (!strcmp(name, "port lobby player list initialize")) + { + return lobby_player_list_initialize(widget); + } + else if (!strcmp(name, "port lobby player choose")) + { + return lobby_player_choose(); + } else if (!strcmp(name, "port coop begin")) { return coop_begin(controller); diff --git a/port/linux/game/menu_tags.c b/port/linux/game/menu_tags.c index 4235cc13..21b30fd4 100644 --- a/port/linux/game/menu_tags.c +++ b/port/linux/game/menu_tags.c @@ -342,6 +342,8 @@ static char const *const port_function_names[] = "port setup edit", "port pause end game", "port coop begin", "port coop player 2 list initialize", "port coop player 2", + "port lobby open", "port lobby add player", "port lobby join", "port lobby leave", + "port lobby player list initialize", "port lobby player choose", /* (the gametype editor's: the Xbox's walk their rows by place, which the PC version's screens changed) */ "mp profile begin editing", "mp profile save changes", "request del playlist profile", "final del playlist profile", diff --git a/port/linux/src/xinput_sdl.c b/port/linux/src/xinput_sdl.c index 8808294c..a2e87267 100644 --- a/port/linux/src/xinput_sdl.c +++ b/port/linux/src/xinput_sdl.c @@ -54,9 +54,9 @@ drive the controller. /* main/console.c */ extern unsigned char console_is_active(void); -/* port/linux/game/menu_functions.c: two players on this machine (the -campaign's co-op) */ -extern unsigned char pc_menu_coop_players(void); +/* port/linux/game/menu_functions.c: two or more players on this machine +(co-op, or split screen in a network game) */ +extern unsigned char pc_menu_split_players(void); /* ---------- device tables */ @@ -637,13 +637,43 @@ static int sdl_gamepads(SDL_Gamepad *gamepads[PORT_COUNT]) return found; } +/* whether one gamepad is port 1's (port_gamepad) */ +static BOOL lone_gamepad_split; + +/* no button of the gamepad held, its sticks and triggers at rest */ +static BOOL gamepad_idle(SDL_Gamepad *gamepad) +{ + int index; + + for (index = 0; index < SDL_GAMEPAD_BUTTON_COUNT; index++) + { + if (SDL_GetGamepadButton(gamepad, (SDL_GamepadButton)index)) + return FALSE; + } + for (index = 0; index < SDL_GAMEPAD_AXIS_COUNT; index++) + { + if (abs(SDL_GetGamepadAxis(gamepad, (SDL_GamepadAxis)index)) > 8000) + return FALSE; + } + return TRUE; +} + /* the gamepad of a port: the first shares port 0 with the keyboard, but for -two players with one gamepad (co-op), port 1 has it (the keyboard's player -is 1, the gamepad's 2) */ +two or more players with one gamepad (co-op, split screen), port 1 has it +(the keyboard's player is 1, the gamepad's 2). It changes port only at rest: +a button held across the change would be pressed again on the other port +(the B that leaves a profile screen leaving the game as player 1's) */ static SDL_Gamepad *port_gamepad(SDL_Gamepad *gamepads[PORT_COUNT], int count, int port) { - if (count == 1 && pc_menu_coop_players()) - return port == 1 ? gamepads[0] : NULL; + if (count == 1) + { + BOOL split = pc_menu_split_players() != 0; + + if (split != lone_gamepad_split && gamepad_idle(gamepads[0])) + lone_gamepad_split = split; + if (lone_gamepad_split) + return port == 1 ? gamepads[0] : NULL; + } return port < count ? gamepads[port] : NULL; } diff --git a/source/game/players.c b/source/game/players.c index a834d481..b5925fbb 100644 --- a/source/game/players.c +++ b/source/game/players.c @@ -704,6 +704,15 @@ short local_player_count( return players_globals->local_player_count; } +/* port: local_player_count, 0 before the players' globals are made (the +input's first polls ask it: port/linux/game/menu_functions.c's +pc_menu_split_players) */ +short players_port_local_player_count( + void) +{ + return players_globals ? players_globals->local_player_count : 0; +} + short local_player_get_next( short local_player_index) { diff --git a/source/interface/player_ui.c b/source/interface/player_ui.c index 201f413d..9c92e7e5 100644 --- a/source/interface/player_ui.c +++ b/source/interface/player_ui.c @@ -514,6 +514,19 @@ void player_ui_local_player_joined_multiplayer_game( return; } +/* port: the local player out of this game and the next (a split screen +player who quit: player_ui_autojoin_players_to_next_multiplayer_game would +join them again) */ +void player_ui_local_player_left_multiplayer_game( + short local_player_index) +{ + if (local_player_index < 0 || local_player_index >= MAXIMUM_NUMBER_OF_LOCAL_PLAYERS) + return; + player_ui_globals.local_players[local_player_index].prejoined_multiplayer = FALSE; + player_ui_globals.multiplayer_autojoin[local_player_index] = FALSE; + return; +} + boolean player_ui_rumble_disabled( short local_player_index) { diff --git a/source/interface/player_ui.h b/source/interface/player_ui.h index d38c900c..59a51f6c 100644 --- a/source/interface/player_ui.h +++ b/source/interface/player_ui.h @@ -72,6 +72,9 @@ short player_ui_get_last_single_player_level_played( short local_player_index); void player_ui_local_player_joined_multiplayer_game( short local_player_index); +/* port: the local player out of this game and the next */ +void player_ui_local_player_left_multiplayer_game( + short local_player_index); boolean player_ui_rumble_disabled( short local_player_index); boolean player_ui_get_path_to_local_player_profile_directory( diff --git a/source/interface/ui_widget_event_handler_functions.c b/source/interface/ui_widget_event_handler_functions.c index 32e90165..87f3b410 100644 --- a/source/interface/ui_widget_event_handler_functions.c +++ b/source/interface/ui_widget_event_handler_functions.c @@ -2411,6 +2411,10 @@ static boolean network_game_remove_local_player( event && event->controller_index >= 0 && event->controller_index < 4, "valid controller index required to remove player from network game"); network_game_client_local_player_quit(event->controller_index); + /* port: a split screen player who quit, the others staying, is not + joined to the next game */ + if (local_player_count() > 1) + player_ui_local_player_left_multiplayer_game(event->controller_index); return TRUE; } @@ -6049,6 +6053,22 @@ boolean ui_widget_port_multiplayer_player( return TRUE; } +/* the lobby's B of a player (port/linux/game/menu_functions.c): that +controller's player leaves the game (netgame_unjoin_player); TRUE if they were +the machine's last, which leaves it (and are joined again if its host's +lobby comes back), else they leave the next game too */ +boolean ui_widget_port_unjoin_player( + struct widget_instance *widget, + struct event_record *event, + boolean *widget_deleted) +{ + boolean left = netgame_unjoin_player(widget, event, widget_deleted); + + if (!left && event && event->controller_index >= 0 && event->controller_index < MAXIMUM_NUMBER_OF_LOCAL_PLAYERS) + player_ui_local_player_left_multiplayer_game(event->controller_index); + return left; +} + /* a screen by name in place of the widget's (back returns to it: as ui_widget_port_join opens the lobby) */ boolean ui_widget_port_open( diff --git a/tools/port_settings.py b/tools/port_settings.py index b30cf026..89194ce9 100644 --- a/tools/port_settings.py +++ b/tools/port_settings.py @@ -461,6 +461,7 @@ TITLES = { f"{MT}/join_game/header_direct_link": "DIRECT LINK", f"{MT}/lobby/header_lobby": "GAME LOBBY", f"{MT}/coop/header_player_2": "PLAYER 2 PROFILE", + f"{MT}/lobby/header_add_player": "ADD PLAYER", } @@ -684,25 +685,43 @@ def _lobby() -> list: pregame's functions, which need no widget of theirs): up to the port's 128 players, scrolling; the game's map and gametype; the countdown""" base = f"{MT}/lobby" + # (split screen: another controller's START joins it, its B leaves alone: + # menu_functions.c's lobby_join) lines = _widget(f"{base}/lobby_screen", [("width", 640), ("height", 480), ("flags", "pass_unhandled_to_focused_child"), ("bitmap", "bitmaps/gradient")], - ['', + ['', + '', '', - '', - '', + '', + '', + f'', '', f'', - f'']) + f'', + f'']) lines += _header(f"{base}/header_lobby", f"{base}/header_lobby") - rows = [f'' - for index in range(11)] + lines += _widget(f"{base}/lobby_join_help", [("type", "text"), ("controller", 1), ("left", 355), ("top", 446), + ("width", 275), ("height", 24), ("font", "ui\\small_ui"), + ("color", "#FF2896FF"), ("align", "right"), ("text_y", 5), + ("text_flags", "no_focus_test")], []) + # (its rows: any controller's left and right switch its own player's team, + # on the rows only, so that they move along the buttons) + rows = [f'' for index in range(11)] lines += _widget(f"{base}/lobby_list", [("type", "column_list"), ("width", 640), ("height", 480), ("flags", "pass_unhandled_to_focused_child up_down_tabs_children"), ("description", f"{base}/lobby_desc")], ['', '', - '', *rows, + *rows, f'']) + for index in range(11): + lines += _widget(f"{base}/list_item_{index}", + [("width", 390), ("height", 28), + ("bitmap", "bitmaps/sel_list_item_bkd_top" if index == 0 else "bitmaps/sel_list_item_bkd"), + ("font", "ui\\large_ui"), ("color", "#FF2896FF"), ("align", "center"), ("text_y", 3)], + ['', + '', + '']) lines += _widget(f"{base}/lobby_desc", [("width", 640), ("height", 480)], ['', f'']) @@ -721,10 +740,16 @@ def _lobby() -> list: ("color", "#FF2896FF")], []) lines += _widget(f"{base}/lobby_button_bar", [("type", "column_list"), ("width", 640), ("height", 28), ("flags", "pass_unhandled_to_focused_child left_right_tabs_items")], - [f'', - f'', + # (SWITCH TEAM first: hidden in a game without teams, which + # moves the focus to the first shown, START NOW; at the end + # it traps none) + [f'', + f'', + f'', f'']) for key, caption, handlers in ( + ("add", "ADD PLAYER", ['', + '']), ("team", "SWITCH TEAM", ['', '']), ("start", "START NOW", ['', '']), @@ -735,6 +760,32 @@ def _lobby() -> list: ("text", caption), ("font", "ui\\small_ui"), ("color", "#FFFFFFFF"), ("align", "center"), ("text_y", 2)], handlers + ['']) + # a split screen player's profile, chosen as they join the lobby (any + # controller's presses: Co-op's rows; the host's countdown waits) + lines += _widget(f"{base}/player_profile_screen", [("width", 640), ("height", 480), + ("flags", "pass_unhandled_to_focused_child"), + ("bitmap", "bitmaps/gradient")], + ['', + '', + f'', + f'', + f'']) + lines += _header(f"{base}/header_add_player", f"{base}/header_add_player") + lines += _widget(f"{base}/player_profile_list", + [("type", "column_list"), ("width", 640), ("height", 480), + ("flags", "pass_unhandled_to_focused_child up_down_tabs_children"), + ("description", "main_menu/profile_manager/player_profile_extended_desc")], + ['', + '', + '', + '', + *[f'' + for index in range(11)], + f'']) + lines += _widget(f"{base}/player_profile_help", [("type", "text"), ("width", 350), ("height", 24), + ("text", "The new player's profile."), + ("font", "ui\\small_ui"), ("color", "#FF2896FF"), ("text_y", 5), + ("text_flags", "no_focus_test")], []) # a game under way's lobby, before joining it (the browser's rows of # games in progress): what its advertisement tells, JOIN GAME lines += _widget(f"{base}/preview_screen", [("width", 640), ("height", 480), From 88a7c07ec781fde6fe00467b89507918b629d2bb Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:15:11 +1300 Subject: [PATCH 09/51] Brokers in brokers.txt --- port/android/host/host_main.c | 12 +++++++ port/assets/network/brokers.txt | 10 ++++++ port/linux/README.md | 18 ++++++---- port/linux/src/p2p_signal.c | 58 ++++++++++++++++++++++++++++++--- port/linux/src/port_config.c | 11 ++++--- tools/android_build.py | 8 +++-- tools/ci_build.py | 4 +++ tools/linux_build.py | 6 +++- tools/windows_build.py | 5 ++- 9 files changed, 112 insertions(+), 20 deletions(-) create mode 100644 port/assets/network/brokers.txt diff --git a/port/android/host/host_main.c b/port/android/host/host_main.c index ba5d9e6a..401c85bf 100644 --- a/port/android/host/host_main.c +++ b/port/android/host/host_main.c @@ -281,6 +281,18 @@ static void *game_main(void *unused) } time_zone(zone, sizeof(zone)); environment_set(&environment, "TZ", zone); + /* internet play's MQTT brokers (network.brokers_file): the APK's list, + written beside config.toml at each start, as a desktop update replaces + the file beside its game */ + { + size_t brokers_size = 0; + void *brokers = SDL_LoadFile("brokers.txt", &brokers_size); + + snprintf(path, sizeof(path), "%s/brokers.txt", data_root); + if (!brokers || !SDL_SaveFile(path, brokers, brokers_size)) + host_logf(HOST_LOG_ERROR, "cannot write %s: %s", path, SDL_GetError()); + SDL_free(brokers); + } snprintf(path, sizeof(path), "%s/config.toml", data_root); image = SDL_LoadFile("halo_guest.elf", &image_size); diff --git a/port/assets/network/brokers.txt b/port/assets/network/brokers.txt new file mode 100644 index 00000000..f583f8d7 --- /dev/null +++ b/port/assets/network/brokers.txt @@ -0,0 +1,10 @@ +# The public MQTT brokers of internet play: the machines of an invite find +# each other through them, and the server browser's listings go through +# them. The game uses every one at once (up to 4), so any one working is +# enough. One host:port on each line; "#" starts a comment. +# (The game reads this file beside config.toml: network.brokers_file. An +# update replaces it; keep a list of your own under another name.) +opence.milenko.org:1883 +broker.emqx.io:1883 +broker.hivemq.com:1883 +test.mosquitto.org:1883 diff --git a/port/linux/README.md b/port/linux/README.md index 38f4f796..470eaefd 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -83,7 +83,8 @@ These files are in the data root: | `init.txt` | Console commands that the game does at start-up. For example, `map_name levels\a10\a10` starts the first campaign level. | The settings are in `config.toml` next to the executable. Refer to -"Settings". +"Settings". Internet play's MQTT brokers are in `brokers.txt` next to it +(`network.brokers_file`). If the game stops because of a fatal signal, it writes the address and a backtrace to the standard error. To find the function at the address, enter @@ -254,7 +255,7 @@ the setting for one start of the game. It has priority over the file. | `network.allow_upnp` | `true` | `HALO_NET_ALLOW_UPNP` | `true`: internet play can ask the router to forward its port (UPnP). `false`: the game does not ask. Refer to "Internet play". | | `network.public_lobby` | `true` | `HALO_NET_PUBLIC_LOBBY` | `true`: the server browser. Public games are listed, and Join Game > Server Browser shows them. `false`: no games are listed or shown. Refer to "Server browser". | | `network.host_public` | `true` | `HALO_NET_HOST_PUBLIC` | `true`: a new game of Create Game > Internet starts as PUBLIC. `false`: it starts as PRIVATE. LISTING in Server Setup changes it for each game. Refer to "Server browser". | -| `network.signalling_brokers` | three public brokers | `HALO_NET_BROKERS` | The public MQTT brokers (`host:port`, with commas between them) that let the machines of an invite find each other, and that carry the listings of the server browser. | +| `network.brokers_file` | `"brokers.txt"` | `HALO_NET_BROKERS_FILE` | The file of the public MQTT brokers that let the machines of an invite find each other, and that carry the listings of the server browser: next to `config.toml`, unless a full path. One `host:port` on each line, up to 4; `#` starts a comment. | | `network.stun_servers` | Google and Cloudflare | `HALO_NET_STUN` | The public STUN servers (`host:port`, with commas between them) that give the internet address of a machine. | | `discord.application_id` | the application of the project | `HALO_DISCORD_APPLICATION` | The Discord application for invites. Empty: no Discord. | | `update.auto` | `true` | `HALO_UPDATE_AUTO` | `true`: at start-up, the game looks for a new version. Refer to "Updates". `false`: the game does not look. | @@ -457,7 +458,7 @@ How it operates (`src/p2p_lobby.c`): - The host of a public game publishes a listing: the invite, and the name, map, gametype and player counts of the game. The listing goes to the - same MQTT brokers as the invites (`network.signalling_brokers`), retained, + same MQTT brokers as the invites (`network.brokers_file`), retained, to a topic of the host (`hceu/3/lobby/s/`). - The key of the host signs the listing (Ed25519). The key is the key of the invite, so no other machine can list the invite of the host, change its @@ -479,8 +480,13 @@ with the invite can ask the host to connect, and the host then sends its addresses. Thus anyone can learn the address of the host of a public game, as for any public server. -To use a broker of your own, add it to `network.signalling_brokers`. All the -players must use the same broker to see each other's games. The game uses +The brokers are in `brokers.txt` next to the executable (from +`port/assets/network/brokers.txt`; on Android, the app writes it next to +`config.toml` at each start), one `host:port` on each line. The game uses +all of them at once (up to 4), so one that works is enough. An update +replaces `brokers.txt`: to use brokers of your own, put them in another +file and name it in `network.brokers_file`. All the players must use the +same broker to see each other's games. The game uses MQTT 5 if the broker has it, else MQTT 3.1.1. A broker that does not keep retained messages, or does not let clients subscribe with wildcards, carries only invites, not listings. @@ -497,7 +503,7 @@ Only machines with the invite can find the game: random 16-byte token. The identifier of the host is from the first 6 bytes of the hash. - The machines exchange their public keys and addresses through public MQTT - brokers (`network.signalling_brokers`). The topics are HMACs of the token. + brokers (`network.brokers_file`). The topics are HMACs of the token. A key from the token encrypts and authenticates the messages (`src/p2p_signal.c`, `src/p2p_crypto.c`). The host authenticates its answer with a key that only it and the player can calculate. Its public key must diff --git a/port/linux/src/p2p_signal.c b/port/linux/src/p2p_signal.c index 0f3a7bf3..d3c710de 100644 --- a/port/linux/src/p2p_signal.c +++ b/port/linux/src/p2p_signal.c @@ -3,9 +3,10 @@ P2P_SIGNAL.C Internet play's signalling (p2p.c): how a joiner and the host of an invite tell each other where they can be reached, through public MQTT brokers -(network.signalling_brokers; MQTT 5 over TCP, or 3.1.1 with a broker that -refuses 5). Every broker is used at once, so any one of them working is -enough (an answer goes back through each broker a request came through). +(those in brokers.txt, network.brokers_file; MQTT 5 over TCP, or 3.1.1 with +a broker that refuses 5). Every broker is used at once, so any one of them +working is enough (an answer goes back through each broker a request came +through). They also carry the server browser's listings of public games (p2p_lobby.c): retained in each host's slot, published at least once (QoS 1, sent again until the broker acknowledges them), expiring at the broker after @@ -72,6 +73,8 @@ answered it in a while. #include "port_config.h" #include "p2p_internal.h" +#include + #include #include #include @@ -1511,8 +1514,50 @@ static void broker_readable(struct broker *broker) /* ---------- p2p.c's side */ +/* the brokers in network.brokers_file (beside config.toml, unless a full +path: port/assets/network/brokers.txt, which the builds put there), one on +each line, "#" starting a comment, into text: host:port entries separated by +commas; empty if the file cannot be read */ +static void brokers_list(char *text, size_t size) +{ + const char *name = config_string("network.brokers_file"); + char path[1024]; + char *file; + size_t file_size = 0, index, length = 0; + int comment = 0; + + text[0] = 0; + if (name[0] == '/' || name[0] == '\\' || (name[0] && name[1] == ':')) + snprintf(path, sizeof(path), "%s", name); + else + { + config_folder(path, sizeof(path)); + snprintf(path + strlen(path), sizeof(path) - strlen(path), "%s", name); + } + file = (char *)SDL_LoadFile(path, &file_size); + if (!file) + { + platform_log("Internet play: the brokers' file %s cannot be read (network.brokers_file)", path); + return; + } + for (index = 0; index < file_size && length + 1 < size; index++) + { + char character = file[index]; + + if (character == '\n' || character == '\r') + comment = 0; + else if (character == '#') + comment = 1; + if (!comment) + text[length++] = character == '\n' || character == '\r' || character == '\t' ? ',' : character; + } + text[length] = 0; + SDL_free(file); +} + void p2p_signal_start(void) { + char list[1024]; const char *text; unsigned char random[8]; char hex[17]; @@ -1529,7 +1574,8 @@ void p2p_signal_start(void) p2p_key_hash(p2p_public_key(), hash); p2p_lobby_slot_topic(hash, signalling.own_slot, sizeof(signalling.own_slot)); } - text = config_string("network.signalling_brokers"); + brokers_list(list, sizeof(list)); + text = list; while (*text && signalling.broker_count < MAXIMUM_BROKERS) { const char *end = text + strcspn(text, ","); @@ -1561,7 +1607,9 @@ void p2p_signal_start(void) text = *end ? end + 1 : end; } if (!signalling.broker_count) - platform_log("Internet play: no signalling brokers (network.signalling_brokers), so invites cannot work"); + platform_log("Internet play: no signalling brokers (network.brokers_file), so invites cannot work"); + else if (text[strspn(text, ", ")]) + platform_log("Internet play: only the first %d signalling brokers are used", MAXIMUM_BROKERS); } void p2p_signal_select_sets(int *read, int *read_count, int *write, int *write_count, int maximum_count) diff --git a/port/linux/src/port_config.c b/port/linux/src/port_config.c index 477c2e3f..8796ce79 100644 --- a/port/linux/src/port_config.c +++ b/port/linux/src/port_config.c @@ -243,11 +243,12 @@ static const struct config_setting config_settings[] = "in everyone's server browser: anyone can see and join it) or, false,\n" "PRIVATE (only players with its invite link can join). Server Setup's\n" "LISTING changes it for each game." }, - { "network.signalling_brokers", _config_string, - "\"broker.emqx.io:1883,broker.hivemq.com:1883,test.mosquitto.org:1883\"", - "HALO_NET_BROKERS", _environment_value, _platform_all, - "Public MQTT brokers through which the machines of an invite find each\n" - "other (its messages are encrypted); comma-separated host:port." }, + { "network.brokers_file", _config_string, "\"brokers.txt\"", + "HALO_NET_BROKERS_FILE", _environment_value, _platform_all, + "The file of the public MQTT brokers through which the machines of an\n" + "invite find each other (its messages are encrypted), beside this file\n" + "unless a full path: one host:port on each line, up to 4. Updates\n" + "replace brokers.txt: keep a list of your own under another name." }, { "network.stun_servers", _config_string, "\"stun.l.google.com:19302,stun.cloudflare.com:3478\"", "HALO_NET_STUN", _environment_value, _platform_all, "Public STUN servers that tell this machine its internet address;\n" diff --git a/tools/android_build.py b/tools/android_build.py index 4538b74d..2f895d3e 100644 --- a/tools/android_build.py +++ b/tools/android_build.py @@ -545,7 +545,11 @@ def generate_android_build(n: Writer, sln: Any) -> None: n.rule(name="android_copy", command="cp $in $out", description="ANDROID STAGE $out") n.build(outputs=staged_sdl, rule="android_copy", inputs=libsdl) n.build(outputs=staged_image, rule="android_copy", inputs=image) - n.build(outputs="android", rule="phony", inputs=[libmain, staged_sdl, staged_image]) + # internet play's MQTT brokers, in the APK: the app writes them beside + # config.toml (port/android/host/host_main.c) + staged_brokers = assets_dir / "brokers.txt" + n.build(outputs=staged_brokers, rule="android_copy", inputs=Path("port/assets/network/brokers.txt")) + n.build(outputs="android", rule="phony", inputs=[libmain, staged_sdl, staged_image, staged_brokers]) apk = PORT_DIR / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk" n.rule( @@ -556,6 +560,6 @@ def generate_android_build(n: Writer, sln: Any) -> None: description="ANDROID GRADLE $out", pool="console", ) - n.build(outputs=apk, rule="android_gradle", inputs=[libmain, staged_sdl, staged_image]) + n.build(outputs=apk, rule="android_gradle", inputs=[libmain, staged_sdl, staged_image, staged_brokers]) n.build(outputs="android_apk", rule="phony", inputs=apk) n.newline() diff --git a/tools/ci_build.py b/tools/ci_build.py index 6e67e362..ef153ad5 100644 --- a/tools/ci_build.py +++ b/tools/ci_build.py @@ -98,6 +98,10 @@ def main() -> int: # the menus' XML parser (port/third_party/expat), in every build, whose # MIT license asks copies to carry its notice shutil.copy2(ROOT / "port/third_party/expat/COPYING", dist / "expat-COPYING.txt") + # internet play's MQTT brokers, a file beside the game (network.brokers_file; + # Android's APK has its own copy) + if args.platform != "android": + shutil.copy2(ROOT / "port/assets/network/brokers.txt", dist / "brokers.txt") return 0 diff --git a/tools/linux_build.py b/tools/linux_build.py index 4dd3ad70..ed29f8b3 100644 --- a/tools/linux_build.py +++ b/tools/linux_build.py @@ -503,5 +503,9 @@ def generate_linux_build(n: Writer, sln: Any) -> None: cflags, ldflags = lto_flags(sln, build_dir / "thinlto-cache") cflags += profile_use_flags(profile) emit(obj_dir, output, cflags, ldflags, [profile] if profile else []) - n.build(outputs="linux", rule="phony", inputs=output) + # internet play's MQTT brokers, a file beside the game (network.brokers_file) + brokers = build_dir / "brokers.txt" + n.rule(name="linux_copy", command="cp $in $out", description="LINUX COPY $out") + n.build(outputs=brokers, rule="linux_copy", inputs=Path("port/assets/network/brokers.txt")) + n.build(outputs="linux", rule="phony", inputs=[output, brokers]) n.newline() diff --git a/tools/windows_build.py b/tools/windows_build.py index 9aa7576f..30877c8a 100644 --- a/tools/windows_build.py +++ b/tools/windows_build.py @@ -477,5 +477,8 @@ def generate_windows_build(n: Writer, sln: Any) -> None: emit(obj_dir, output, lto_cflags + profile_use_flags(profile), lto_cflags + [OPTIMISATION] if lto_cflags else [], [], [profile] if profile else []) n.build(outputs=sdl_dll, rule="windows_copy", inputs=SDL_DIR / "lib" / "x86" / "SDL3.dll") - n.build(outputs="windows", rule="phony", inputs=[output, sdl_dll]) + # internet play's MQTT brokers, a file beside the game (network.brokers_file) + brokers = BUILD / "brokers.txt" + n.build(outputs=brokers, rule="windows_copy", inputs=Path("port/assets/network/brokers.txt")) + n.build(outputs="windows", rule="phony", inputs=[output, sdl_dll, brokers]) n.newline() From ffc1512dcbdc1ec6a3ea1dac400c99cc8ee9f132 Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:27:05 +1300 Subject: [PATCH 10/51] Read brokers.txt without SDL on Android --- port/linux/src/p2p_signal.c | 6 ++---- port/linux/src/port_config.c | 5 +++++ port/linux/src/port_config.h | 3 +++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/port/linux/src/p2p_signal.c b/port/linux/src/p2p_signal.c index d3c710de..32a194a2 100644 --- a/port/linux/src/p2p_signal.c +++ b/port/linux/src/p2p_signal.c @@ -73,8 +73,6 @@ answered it in a while. #include "port_config.h" #include "p2p_internal.h" -#include - #include #include #include @@ -1534,7 +1532,7 @@ static void brokers_list(char *text, size_t size) config_folder(path, sizeof(path)); snprintf(path + strlen(path), sizeof(path) - strlen(path), "%s", name); } - file = (char *)SDL_LoadFile(path, &file_size); + file = config_file_read(path, &file_size); if (!file) { platform_log("Internet play: the brokers' file %s cannot be read (network.brokers_file)", path); @@ -1552,7 +1550,7 @@ static void brokers_list(char *text, size_t size) text[length++] = character == '\n' || character == '\r' || character == '\t' ? ',' : character; } text[length] = 0; - SDL_free(file); + free(file); } void p2p_signal_start(void) diff --git a/port/linux/src/port_config.c b/port/linux/src/port_config.c index 8796ce79..419a08e9 100644 --- a/port/linux/src/port_config.c +++ b/port/linux/src/port_config.c @@ -1075,6 +1075,11 @@ void config_folder(char *path, size_t size) /* ---------- public code */ +char *config_file_read(const char *path, size_t *size) +{ + return config_read_file(path, size); +} + unsigned long config_changes(void) { return config_change_count; diff --git a/port/linux/src/port_config.h b/port/linux/src/port_config.h index 14213a5d..e1dcb372 100644 --- a/port/linux/src/port_config.h +++ b/port/linux/src/port_config.h @@ -29,6 +29,9 @@ such setting */ int config_text(const char *name, char *text, size_t size); /* the folder config.toml is in, with its separator */ void config_folder(char *path, size_t size); +/* a whole file (a UTF-8 path), NUL terminated, or NULL; free() it (the Android +guest has no SDL_LoadFile) */ +char *config_file_read(const char *path, size_t *size); /* a setting's default, as text; 0 if there is no such setting */ int config_default(const char *name, char *text, size_t size); /* how many times config_write has changed a setting: what keeps one reads From 95416b2cfc81959dd332b163da3208ca70971b2c Mon Sep 17 00:00:00 2001 From: thelinkin3000 Date: Sun, 4 Oct 2026 23:37:36 +0000 Subject: [PATCH 11/51] Make a new game each time Create is chosen Backing out of a hosted game keeps its server: the lobby's last local player leaving pauses it (netgame_unjoin_player). Choosing Create again threw the host's client away, but network_game_start_new_server makes and joins a server only when there is none, so the new client never joined the one left over. The lobby then had nobody in it: START added no player, B could not leave (not in pregame), and the server's map changes failed to reach the client that was gone. Create now disposes the client and server first, so it always makes a new game and joins it. Co-Authored-By: Claude Opus 5.5 --- source/interface/ui_widget_event_handler_functions.c | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/source/interface/ui_widget_event_handler_functions.c b/source/interface/ui_widget_event_handler_functions.c index 87f3b410..11108256 100644 --- a/source/interface/ui_widget_event_handler_functions.c +++ b/source/interface/ui_widget_event_handler_functions.c @@ -5960,12 +5960,18 @@ boolean ui_widget_port_gametype_choose( return TRUE; } -/* hosting (as the Xbox's server list's Y) */ +/* hosting (as the Xbox's server list's Y): always a new game. A game made +before and backed out of keeps its server (the lobby's last player leaving +pauses it: netgame_unjoin_player), and network_game_start_new_server joins +only a server it makes, so the client it made for that one never joined it +and the lobby had nobody in it */ boolean ui_widget_port_host( struct widget_instance *widget, struct event_record *event, boolean *widget_deleted) { + dispose_global_network_game_client(); + dispose_global_network_game_server(); return network_game_start_new_server(widget, event, widget_deleted); } From 7e00135d31bf55250c477295279f6625e3e45983 Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:59:15 +1300 Subject: [PATCH 12/51] Fix menu crash with no network client --- source/networking/network_game_globals.c | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/source/networking/network_game_globals.c b/source/networking/network_game_globals.c index 2e59918c..0370d3d9 100644 --- a/source/networking/network_game_globals.c +++ b/source/networking/network_game_globals.c @@ -518,6 +518,16 @@ boolean network_game_client_start_frame( main_goto_main_menu(); result = TRUE; } + else if (!global_network_game_client) + { + /* port: no client (the menus let it go, as a lobby's last player + leaving does, without changing the connection): no network game, as + network_game_client_end_frame has it, which only a frame in a game + reaches */ + game_connection_set(0); + main_menu_ensure_player_queues_exist(); + result = TRUE; + } else { result = network_game_client_idle(global_network_game_client); From cd345aac7c8abd3e30d43132cb6e9e67c042cc2c Mon Sep 17 00:00:00 2001 From: eltanschauung <158000645+eltanschauung@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:32:31 -0400 Subject: [PATCH 13/51] Count death and co-op respawn delays in game ticks --- source/main/main.c | 69 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/source/main/main.c b/source/main/main.c index e35691e1..2595ba53 100644 --- a/source/main/main.c +++ b/source/main/main.c @@ -700,6 +700,9 @@ static char const *scenario_paths[10] = }; static struct _main_globals main_globals = { 0 }; +/* Keep the original globals layout; these clocks belong to pending requests. */ +static long main_loss_last_tick; +static long main_respawn_last_tick; boolean debug_force_frame_rate_update = FALSE; boolean debug_no_drawing = FALSE; boolean debug_game_save = FALSE; @@ -903,6 +906,11 @@ void main_won_map( void main_lost_map( void) { + if (!main_globals.lost_map) + { + main_globals.loss_timer = 0; + main_loss_last_tick = game_time_get(); + } main_globals.saving_map = FALSE; main_globals.lost_map = TRUE; return; @@ -963,9 +971,14 @@ void main_save_map_nonsafe( void main_respawn( boolean in_multiplayer) { + if (!main_globals.respawn) + { + main_globals.respawn_timer = 0; + main_respawn_last_tick = game_time_get(); + } main_globals.respawn = TRUE; if (in_multiplayer) - main_globals.respawn_timer = 91; + main_globals.respawn_timer = 92; return; } @@ -1944,17 +1957,45 @@ static void main_switch_to_structure_bsp_private( return; } +/* The original post-increment test expires on its 92nd 30 Hz update. + Render-only frames must not advance it. Saturate so blocked co-op + respawns can retry indefinitely without overflowing the short counter. */ +static boolean main_death_timer_expired( + short *timer, + long *last_tick, + boolean advance) +{ + long current_tick = game_time_get(); + unsigned long elapsed_ticks = 0; + + if (current_tick < *last_tick) + { + /* A checkpoint/core load can move the simulation clock backwards. */ + *timer = 0; + } + else + { + elapsed_ticks = (unsigned long)current_tick - (unsigned long)*last_tick; + } + *last_tick = current_tick; + + if (!advance) + return FALSE; + + *timer = (short)MIN(92, (unsigned long)*timer + elapsed_ticks); + return *timer >= 92; +} + static void main_lost_map_private( void) { - if (!game_time_get_paused()) + if (main_death_timer_expired( + &main_globals.loss_timer, &main_loss_last_tick, + !game_time_get_paused())) { - if (main_globals.loss_timer++ > 90) - { - main_globals.lost_map = FALSE; - main_globals.loss_timer = 0; - game_state_revert(); - } + main_globals.lost_map = FALSE; + main_globals.loss_timer = 0; + game_state_revert(); } return; } @@ -1962,13 +2003,13 @@ static void main_lost_map_private( static void main_respawn_private( void) { - if (!game_time_get_paused() && !cinematic_in_progress()) + if (main_death_timer_expired( + &main_globals.respawn_timer, &main_respawn_last_tick, + !game_time_get_paused() && !cinematic_in_progress()) && + players_respawn_coop()) { - if (main_globals.respawn_timer++ > 90 && players_respawn_coop()) - { - main_globals.respawn = FALSE; - main_globals.respawn_timer = 0; - } + main_globals.respawn = FALSE; + main_globals.respawn_timer = 0; } return; } From fb1abedd8bffa512d86462d5d98ecac6738815d9 Mon Sep 17 00:00:00 2001 From: eltanschauung <158000645+eltanschauung@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:32:59 -0400 Subject: [PATCH 14/51] Keep dead camera target when no living player exists --- source/camera/dead_camera.c | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/source/camera/dead_camera.c b/source/camera/dead_camera.c index 7c69a41a..6c2c0e9f 100644 --- a/source/camera/dead_camera.c +++ b/source/camera/dead_camera.c @@ -190,11 +190,9 @@ void dead_camera_update( } else { - next_unit_index = (long)result; + next_unit_index = NONE; } - /* January preserves the command pointer as the fallback object index - * when no player is found. This is a bug; a corrected build should - * initialize next_unit_index to NONE instead. */ + /* With no living player, keep observing the dead unit/current position. */ if (next_unit_index != camera->unit_index && next_unit_index != NONE) { From 0f22c1d59244b18c3ee9d13d1ef35c9542a0c4f5 Mon Sep 17 00:00:00 2001 From: eltanschauung <158000645+eltanschauung@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:33:17 -0400 Subject: [PATCH 15/51] Add standalone death and co-op regression tests --- tools/test_death_timing.py | 254 +++++++++++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 tools/test_death_timing.py diff --git a/tools/test_death_timing.py b/tools/test_death_timing.py new file mode 100644 index 00000000..983ee4a0 --- /dev/null +++ b/tools/test_death_timing.py @@ -0,0 +1,254 @@ +"""Compile actual death handlers, co-op respawn and camera code against a fake world.""" +import argparse +import os +import re +import shutil +import subprocess +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def function(source, name): + match = re.search(r"^(?:static )?[\w *]+\b" + name + r"\(\s*[^;{]*\)\s*\{", source, re.M) + if not match: + raise ValueError(f"Function not found: {name}") + start = source.index('{', match.start()) + depth, end = 1, start + 1 + # The extracted functions have no braces inside strings/comments. + while depth: + depth += (source[end] == '{') - (source[end] == '}') + end += 1 + return source[match.start():end] + + +PRELUDE = r''' +#include +#include +#include +#include +typedef int boolean; +typedef float real; +typedef unsigned char byte; +typedef struct {real x,y,z;} real_point3d; +typedef struct {real i,j,k;} real_vector3d; +typedef struct {real yaw,pitch;} real_euler_angles2d; +#define TRUE 1 +#define FALSE 0 +#define NONE (-1) +#define MIN(a,b) ((a)<(b)?(a):(b)) +#define MAX(a,b) ((a)>(b)?(a):(b)) +#define FLAG(b) (1u<<(b)) +#define TEST_FLAG(v,b) ((v)&FLAG(b)) +#define match_vassert(...) ((void)0) +static struct {short loss_timer,respawn_timer;boolean lost_map,respawn,saving_map,reset_map,revert_map;long switch_to_structure_bsp_index;} main_globals; +static long main_loss_last_tick,main_respawn_last_tick; +static long tick; +static int paused,cinematic,reverts,attempts,spawns,teleports,player_count=2; +static int projectiles,enemies,airborne,vehicle_moving,spawn_failed,teleport_failed,multiplayer; +static struct {int respawn_failure,respawn_failed;} players_state,*players_globals=&players_state; +struct player_datum {long unit_index,team_index;}; +static struct player_datum players[4]; +struct object_datum {struct {real_point3d bounding_sphere_center;} object;}; +static struct object_datum objects[4]; +struct biped_datum {struct {unsigned flags;} biped;}; +static struct biped_datum biped; +struct players_vehicle_datum {struct {int unknown_state;} vehicle;}; +static struct players_vehicle_datum vehicle; +struct data_iterator {long datum_index;int next;}; +static int player_data; +enum {_biped_airborne_bit=0,_object_mask_vehicle=1}; +static void data_iterator_new(struct data_iterator *i,int d) {(void)d;i->next=0;} +static void *data_iterator_next(struct data_iterator *i) { + if(i->next>=player_count) return NULL; + i->datum_index=i->next++;return &players[i->datum_index]; +} +static struct player_datum *player_get(long i) {assert(i>=0&&i=100&&i<104);return &objects[i-100]; +} +static struct object_datum *object_get(long i) {return object_try_and_get(i);} +static long object_get_ultimate_parent(long i) {return vehicle_moving ? 999 : i;} +static struct biped_datum *biped_try_and_get(long i) {(void)i;biped.biped.flags=airborne;return &biped;} +static void *object_try_and_get_and_verify_type(long i,int mask) { + (void)i;(void)mask;vehicle.vehicle.unknown_state=vehicle_moving;return &vehicle; +} +static int dangerous_projectiles_near_player(void) {return projectiles;} +static int any_unit_is_dangerous(void) {return 0;} +static int ai_enemies_attacking_player(void) {return enemies;} +static void player_spawn(long i) {spawns++;if(!spawn_failed) players[i].unit_index=100+i;} +static int player_teleport(long i,long target,const real_point3d *p) { + (void)i;assert(target==100);assert(p->x==100);teleports++;return !teleport_failed; +} +static long game_time_get(void) {return tick;} +static int game_time_get_paused(void) {return paused;} +static int cinematic_in_progress(void) {return cinematic;} +static int game_engine_running(void) {return multiplayer;} +static void game_state_revert(void) {reverts++;} +static real_vector3d zero_vector,*global_zero_vector3d=&zero_vector; +static void vector3d_from_euler_angles2d(real_vector3d *v,const real_euler_angles2d *a) { + (void)a;v->i=1;v->j=v->k=0; +} +static void observer_up_from_forward(const real_vector3d *f,real_vector3d *u) { + (void)f;u->k=1;u->i=u->j=0; +} +static struct {real dead_timer,multiplayer_switch_timer,singleplayer_switch_timer;} dead_camera_constants={3,15,3}; +''' +TESTS = r''' +static void reset(int count) { + int i; + memset(&main_globals,0,sizeof(main_globals));memset(&players_state,0,sizeof(players_state)); + tick=main_loss_last_tick=main_respawn_last_tick=0; + paused=cinematic=reverts=attempts=spawns=teleports=0; + projectiles=enemies=airborne=vehicle_moving=spawn_failed=teleport_failed=multiplayer=0; + player_count=count; + for(i=0;i Date: Mon, 5 Oct 2026 14:06:51 +1300 Subject: [PATCH 16/51] Split screen joins of games under way --- ...ain_menu.multiplayer_type_select.lobby.xml | 17 ++- port/linux/README.md | 5 +- port/linux/game/menu_functions.c | 102 +++++++++++++++++- port/linux/game/menu_tags.c | 1 + tools/port_settings.py | 21 ++-- 5 files changed, 131 insertions(+), 15 deletions(-) diff --git a/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml b/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml index 62042cde..24336832 100644 --- a/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml +++ b/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml @@ -159,13 +159,16 @@ - - + + + + + - + @@ -173,7 +176,8 @@ - + + @@ -181,6 +185,11 @@ + + + + + diff --git a/port/linux/README.md b/port/linux/README.md index 470eaefd..171a0982 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -177,8 +177,9 @@ until then that gamepad shares controller 1 with the keyboard. Two players on on player's B in the lobby leaves the game alone, and the last player of the computer leaves it for all of them. In the game, each player's pause menu opens on their part of the screen, and its LEAVE GAME is theirs: their part -of the screen stays until the game ends. A game under way is joined by one -player; others join in the lobby of the next. +of the screen stays until the game ends. A game under way shows its own +screen before JOIN GAME: players join there the same way, START or ADD +PLAYER then START, and JOIN GAME brings them all into the game. In a multiplayer game, the pause menu (escape) has SETTINGS, which opens the profile's settings while the game goes on, and for the host END GAME. diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index d12c9339..1055ed03 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -2844,6 +2844,9 @@ static boolean lobby_browser_select(struct widget_instance *widget, short contro return campaign_fail(); if (advertised_in_progress(found)) { + /* (player 1 joining it: others join them there) */ + if (!multiplayer_player(controller)) + return FALSE; csmemcpy(multiplayer.preview_key_id, found->key_id, sizeof(multiplayer.preview_key_id)); csmemcpy(multiplayer.preview_xnaddr, found->xnaddr, sizeof(multiplayer.preview_xnaddr)); return ui_widget_port_open(widget, PREVIEW_NAME, widget_deleted); @@ -3017,6 +3020,9 @@ static boolean browser_select(struct widget_instance *widget, struct event_recor if (multiplayer.game_chosen < multiplayer.game_count && advertised_in_progress(multiplayer.games[multiplayer.game_chosen])) { + /* (player 1 joining it: others join them there) */ + if (!multiplayer_player(controller)) + return FALSE; csmemcpy(multiplayer.preview_key_id, multiplayer.games[multiplayer.game_chosen]->key_id, sizeof(multiplayer.preview_key_id)); csmemcpy(multiplayer.preview_xnaddr, multiplayer.games[multiplayer.game_chosen]->xnaddr, @@ -3366,7 +3372,9 @@ static void lobby_update(struct widget_instance *list) /* ---- an in-progress game's lobby, before joining it (Direct Link and LAN's rows of games under way): what its advertisement tells (no players' -names: they come with joining), JOIN GAME */ +names: they come with joining), JOIN GAME. Split screen players join here +as in the lobby (lobby_join), for the game starts at once for a machine +that joins it: player 1 as it opens, the others with START */ /* the previewed game, found again among the client's (NULL: gone) */ static struct advertised_game *preview_game(void) @@ -3387,6 +3395,34 @@ static struct advertised_game *preview_game(void) return NULL; } +/* the preview's status's end: the players joining from here, when there +are more than one (split screen) */ +static void preview_players_text(wchar_t *text, short size) +{ + short controller; + size_t length; + + if (lobby_local_player_count() < 2) + return; + length = ustrlen(text); + usnprintf(text + length, size - 1 - length, L"\r\n\r\nJoining from here:"); + for (controller = 0; controller < MAXIMUM_LOCAL_PLAYERS; controller++) + { + struct player_profile profile; + wchar_t name[NUMBEROF(profile.player_name) + 1]; + + if (!player_ui_local_player_wants_to_play_multiplayer(controller)) + continue; + player_ui_get_active_player_profile(controller, &profile); + ustrncpy(name, profile.player_name, NUMBEROF(profile.player_name)); + name[NUMBEROF(profile.player_name)] = 0; + text[size - 1] = 0; + length = ustrlen(text); + usnprintf(text + length, size - 1 - length, L"\r\n%s [P%d]", name, controller + 1); + } + text[size - 1] = 0; +} + /* "port lobby preview update" */ static void preview_update(struct widget_instance *list) { @@ -3409,22 +3445,74 @@ static void preview_update(struct widget_instance *list) usnprintf(text, NUMBEROF(text) - 1, L"%s\r\n\r\nThis game is under way.\r\n\r\n%s", name, game->open ? L"JOIN GAME joins it now;\r\nits players show then." : L"It cannot be joined now:\r\nit is loading, over or full."); + text[NUMBEROF(text) - 1] = 0; + preview_players_text(text, NUMBEROF(text)); } else usnprintf(text, NUMBEROF(text) - 1, L"The game is gone."); text[NUMBEROF(text) - 1] = 0; text_set_length(named(list, "preview_status", 0), text, LOBBY_TEXT_LENGTH); + lobby_join_help(list); profile_name_show(description); } -/* "port lobby preview join" */ +/* "port lobby preview add" (START): as the lobby's ("port lobby join"), a +controller not joining chooses its profile, to join with the others; a +joining player's START is the focused button's */ +static boolean preview_add(struct widget_instance *widget, short controller, boolean *widget_deleted) +{ + struct advertised_game *game = preview_game(); + short count = lobby_local_player_count(); + + if (lobby_controller_playing(controller)) + { + ui_widget_port_dispatch_event(focused_leaf(widget), BUTTON_START, controller, widget_deleted); + return FALSE; + } + if (!game || !game->open || count >= MAXIMUM_LOCAL_PLAYERS) + return campaign_fail(); + if (game->player_count + count >= game->maximum_player_count) + { + display_error_text_deferred(L"The game is full.", NONE); + return campaign_fail(); + } + lobby_join.controller = controller; + return TRUE; +} + +/* "port lobby preview leave" (B): a joining player stays out, the last of +them backing out (TRUE); a controller not joining cancels ADD PLAYER, else +backs out for them all (the game is not joined yet: none is kept here) */ +static boolean preview_leave(short controller) +{ + short index; + + if (!lobby_controller_playing(controller)) + { + if (lobby_join.adding) + { + lobby_join_reset(); + return FALSE; + } + for (index = 0; index < MAXIMUM_LOCAL_PLAYERS; index++) + player_ui_local_player_left_multiplayer_game(index); + return TRUE; + } + player_ui_local_player_left_multiplayer_game(controller); + return lobby_local_player_count() == 0; +} + +/* "port lobby preview join": with the players joining from here (player 1, +as the preview opened, and those added), whom the lobby asks the host for +all at once ("net splitscreen prejoin players"): it starts the machine in +the game once it has them all */ static boolean preview_join(struct widget_instance *widget, short controller, boolean *widget_deleted) { struct advertised_game *game = preview_game(); if (!game || !game->open) return campaign_fail(); - if (!multiplayer_player(controller)) + if (!lobby_local_player_count() && !multiplayer_player(controller)) return FALSE; return ui_widget_port_join(widget, game, LOBBY_NAME, widget_deleted); } @@ -4348,6 +4436,14 @@ boolean pc_menu_event_function_invoke( { return lobby_player_choose(); } + else if (!strcmp(name, "port lobby preview add")) + { + return preview_add(widget, controller, widget_deleted); + } + else if (!strcmp(name, "port lobby preview leave")) + { + return preview_leave(controller); + } else if (!strcmp(name, "port coop begin")) { return coop_begin(controller); diff --git a/port/linux/game/menu_tags.c b/port/linux/game/menu_tags.c index 21b30fd4..4d3e69e4 100644 --- a/port/linux/game/menu_tags.c +++ b/port/linux/game/menu_tags.c @@ -344,6 +344,7 @@ static char const *const port_function_names[] = "port coop begin", "port coop player 2 list initialize", "port coop player 2", "port lobby open", "port lobby add player", "port lobby join", "port lobby leave", "port lobby player list initialize", "port lobby player choose", + "port lobby preview add", "port lobby preview leave", /* (the gametype editor's: the Xbox's walk their rows by place, which the PC version's screens changed) */ "mp profile begin editing", "mp profile save changes", "request del playlist profile", "final del playlist profile", diff --git a/tools/port_settings.py b/tools/port_settings.py index 89194ce9..c791848a 100644 --- a/tools/port_settings.py +++ b/tools/port_settings.py @@ -787,16 +787,22 @@ def _lobby() -> list: ("font", "ui\\small_ui"), ("color", "#FF2896FF"), ("text_y", 5), ("text_flags", "no_focus_test")], []) # a game under way's lobby, before joining it (the browser's rows of - # games in progress): what its advertisement tells, JOIN GAME + # games in progress): what its advertisement tells, JOIN GAME. Split + # screen players join here, as in the lobby, before JOIN GAME: the game + # starts at once for the machine, with the players it brings lines += _widget(f"{base}/preview_screen", [("width", 640), ("height", 480), ("flags", "pass_unhandled_to_focused_child"), ("bitmap", "bitmaps/gradient")], - ['', '', + ['', + '', + '', + f'', '', f'', - f'']) + f'', + f'']) lines += _widget(f"{base}/preview_list", [("type", "column_list"), ("width", 640), ("height", 480), - ("flags", "pass_unhandled_to_focused_child left_right_tabs_items"), + ("flags", "pass_unhandled_to_focused_child up_down_tabs_children"), ("description", f"{base}/lobby_desc")], ['', f'', @@ -805,9 +811,12 @@ def _lobby() -> list: ("font", "ui\\large_ui"), ("color", "#FF2896FF")], []) lines += _widget(f"{base}/preview_button_bar", [("type", "column_list"), ("width", 640), ("height", 28), ("flags", "pass_unhandled_to_focused_child left_right_tabs_items")], - [f'', + [f'', + f'', f'']) - for key, caption, run in (("join", "JOIN GAME", "port lobby preview join"), ("back", "BACK", "mouse emit back event")): + for key, caption, run in (("join", "JOIN GAME", "port lobby preview join"), + ("add", "ADD PLAYER", "port lobby add player"), + ("back", "BACK", "mouse emit back event")): lines += _widget(f"{base}/preview_button_{key}", [("type", "text"), ("width", 128), ("height", 24), ("bitmap", "bitmaps/text_button_background"), ("text", caption), ("font", "ui\\small_ui"), From 933ed2b7b8e5a7dc560102bc1d1d13ddaeda0d60 Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:21:03 +1300 Subject: [PATCH 17/51] Point updates at OpenCommunityEdition/OpenCE --- README.md | 8 ++++---- .../app/src/main/java/com/halo/decomp/Updater.java | 2 +- port/linux/src/updater.c | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2db745cc..2d9943fb 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ builds of the latest release: | Platform | Release | Debug | | --- | --- | --- | -| Linux | [halo-linux-release.zip](https://github.com/cybersecurity/halo-ce-universal/releases/latest/download/halo-linux-release.zip) | [halo-linux-debug.zip](https://github.com/cybersecurity/halo-ce-universal/releases/latest/download/halo-linux-debug.zip) | -| Windows | [halo-windows-release.zip](https://github.com/cybersecurity/halo-ce-universal/releases/latest/download/halo-windows-release.zip) | [halo-windows-debug.zip](https://github.com/cybersecurity/halo-ce-universal/releases/latest/download/halo-windows-debug.zip) | -| Android | [halo-android-release.zip](https://github.com/cybersecurity/halo-ce-universal/releases/latest/download/halo-android-release.zip) | [halo-android-debug.zip](https://github.com/cybersecurity/halo-ce-universal/releases/latest/download/halo-android-debug.zip) | +| Linux | [halo-linux-release.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-release.zip) | [halo-linux-debug.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-debug.zip) | +| Windows | [halo-windows-release.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-windows-release.zip) | [halo-windows-debug.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-windows-debug.zip) | +| Android | [halo-android-release.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-android-release.zip) | [halo-android-debug.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-android-debug.zip) | Use the release build to play. The debug build stops at the first failed assertion and writes it to the log. Use the debug build to find and report @@ -32,7 +32,7 @@ if you want to install it. Refer to "Updates" in [port/linux/README.md](port/linux/README.md#updates). Each build of the `main` branch that passes on all three platforms is a new -release. The [Releases](https://github.com/cybersecurity/halo-ce-universal/releases) +release. The [Releases](https://github.com/OpenCommunityEdition/OpenCE/releases) page keeps the last five releases. If the latest build has a problem, get an older build from that page. diff --git a/port/android/app/src/main/java/com/halo/decomp/Updater.java b/port/android/app/src/main/java/com/halo/decomp/Updater.java index fa56e089..7a35a362 100644 --- a/port/android/app/src/main/java/com/halo/decomp/Updater.java +++ b/port/android/app/src/main/java/com/halo/decomp/Updater.java @@ -47,7 +47,7 @@ import java.util.zip.ZipInputStream; * app must keep for Android to install a new version over it. */ final class Updater { - private static final String REPOSITORY = "cybersecurity/halo-ce-universal"; + private static final String REPOSITORY = "OpenCommunityEdition/OpenCE"; private static final String USER_AGENT = "halo-ce-universal-updater"; private static final int TIMEOUT_MILLISECONDS = 20000; diff --git a/port/linux/src/updater.c b/port/linux/src/updater.c index 09c13e1a..ed0cdaab 100644 --- a/port/linux/src/updater.c +++ b/port/linux/src/updater.c @@ -46,7 +46,7 @@ update.h's: posix_update.c on Linux, win32_update.c on Windows. #define HALO_BUILD_FLAVOR "release" #endif -#define UPDATE_REPOSITORY "cybersecurity/halo-ce-universal" +#define UPDATE_REPOSITORY "OpenCommunityEdition/OpenCE" #ifdef _WIN32 #define UPDATE_PLATFORM "windows" #define PATH_SEPARATOR "\\" From 685bf260a0a648a1d898928a99b17428a2549b92 Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:47:11 +1300 Subject: [PATCH 18/51] Either co-op player picks the level --- port/linux/README.md | 3 ++- port/linux/game/menu_functions.c | 6 ++++-- source/interface/ui_widget.c | 36 ++++++++++++++++++++++++++------ tools/port_settings.py | 4 +++- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/port/linux/README.md b/port/linux/README.md index 171a0982..da997a9b 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -166,7 +166,8 @@ Multiplayer > CO-OP CAMPAIGN is the Xbox's cooperative play, which the PC version does not have: two players on this computer play the campaign in split screen. Player 1 is the player who chose it, on the current profile. Player 2 then chooses a profile with their own controller (a gamepad), and -New Game's levels are those either profile has reached. A co-op game does +New Game's levels are those either profile has reached. Either player's +controller chooses the level and the difficulty. A co-op game does not continue a saved game of one player. Network games have split screen too: up to 4 players on each computer. In diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index 1055ed03..de267f17 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -1422,8 +1422,10 @@ static boolean profile_delete(void) screen (the Xbox's Cooperative Play, which the PC version has not): Multiplayer's CO-OP CAMPAIGN ("port coop begin"), player 2's profile, chosen with player 2's controller ("port coop player 2"), then New Game's levels -(those either has reached) and difficulty. The main menu and Multiplayer -go back to one player (main_menu_initialize, multiplayer_type_menu_initialize). +(those either has reached) and difficulty, with either player's controller +(ui_widget.c, widget_takes_events_of_controller). The main menu and +Multiplayer go back to one player (main_menu_initialize, +multiplayer_type_menu_initialize). With one gamepad, it is player 2's (pc_menu_split_players) */ /* "port coop begin": two players, player 1 on its profile (campaign_profile) diff --git a/source/interface/ui_widget.c b/source/interface/ui_widget.c index bec4ab3c..6c864459 100644 --- a/source/interface/ui_widget.c +++ b/source/interface/ui_widget.c @@ -6599,6 +6599,33 @@ static void widget_instance_tab_to_previous_valid_widget( return; } +/* port: whether a widget of the local player (NONE: any) takes the +controller's events. In co-op's menus (Multiplayer's CO-OP CAMPAIGN, +port/linux/game/menu_functions.c) the screens it shares with one player's +campaign, New Game's levels and the difficulty, are player 1's (their rows +the first controller's), and either player's controller uses them: player 1's +is the one that chose co-op, player 2's the one that chose their profile */ +static boolean widget_takes_events_of_controller( + struct widget_instance const *widget, + short controller_index) +{ + short player; + + if (widget->local_player_index == NONE || widget->local_player_index == controller_index) + return TRUE; + if (widget->local_player_index != 0 || !we_are_at_the_main_menu || player_spawn_count < 2 || + controller_index < 0 || controller_index >= MAXIMUM_NUMBER_OF_LOCAL_PLAYERS) + { + return FALSE; + } + for (player = 0; player < 2; player++) + { + if (player_ui_get_single_player_local_player_controller(player) == controller_index) + return TRUE; + } + return FALSE; +} + static void widget_instance_process_one_event_recursive( struct widget_instance *widget, struct ui_widget_definition *definition, @@ -6607,8 +6634,7 @@ static void widget_instance_process_one_event_recursive( { boolean event_handled = FALSE; boolean widget_deleted = FALSE; - boolean event_for_this_widget = widget->local_player_index == NONE || - widget->local_player_index == event->controller_index; + boolean event_for_this_widget = widget_takes_events_of_controller(widget, event->controller_index); long audio_feedback = _ui_audio_feedback_none; match_assert( @@ -7035,8 +7061,7 @@ static void widget_instance_process_one_event_recursive( for (child = widget->child; child; child = child->next) { - if (child->local_player_index == NONE || - child->local_player_index == event->controller_index) + if (widget_takes_events_of_controller(child, event->controller_index)) { widget_instance_process_one_event_recursive( child, @@ -7050,8 +7075,7 @@ static void widget_instance_process_one_event_recursive( } else if (widget->focused_child) { - if (widget->focused_child->local_player_index == NONE || - widget->focused_child->local_player_index == event->controller_index) + if (widget_takes_events_of_controller(widget->focused_child, event->controller_index)) { widget_instance_process_one_event_recursive( widget->focused_child, diff --git a/tools/port_settings.py b/tools/port_settings.py index c791848a..36cfe890 100644 --- a/tools/port_settings.py +++ b/tools/port_settings.py @@ -831,7 +831,9 @@ def _coop() -> list: (the Xbox's Cooperative Play): Multiplayer's CO-OP CAMPAIGN, then player 2's profile, chosen with player 2's controller (its rows take any controller's presses, as the shared rows do only controller 1's), then - New Game's levels and difficulty (menu_functions.c's coop_begin)""" + New Game's levels and difficulty, which either player's controller uses + (ui_widget.c's widget_takes_events_of_controller; menu_functions.c's + coop_begin)""" base = f"{MT}/coop" lines = _widget(f"{MT}/multiplayer_type_coop_item", [("type", "text"), ("left", 51), ("width", 232), ("height", 32), ("bitmap", "bitmaps/list_item_bkd"), From 7d56f34b836908e8ab29a5de64fad3706d3fb9c0 Mon Sep 17 00:00:00 2001 From: xshxdex98 Date: Sun, 4 Oct 2026 22:54:24 -0500 Subject: [PATCH 19/51] Map screen: choose co-op campaign or multiplayer, then vanilla or custom maps Hosting over LAN or the internet, the Map screen's list first offers CO-OP CAMPAIGN or MULTIPLAYER. CO-OP CAMPAIGN offers CAMPAIGN or CUSTOM CAMPAIGN, then a level and its difficulty. MULTIPLAYER offers VANILLA MAPS or CUSTOM MAPS, then the maps, and goes on to the gametypes as before. B steps back through the choices and leaves the screen from the first. Split screen starts at VANILLA MAPS / CUSTOM MAPS. The custom lists stay empty and a co-op level cannot be hosted yet: network co-op and Custom Edition maps come in their own pull requests, which fill them in. --- ....multiplayer_type_select.mp_map_select.xml | 2 + port/linux/game/menu_functions.c | 233 ++++++++++++++++-- port/linux/game/menu_tags.c | 1 + 3 files changed, 215 insertions(+), 21 deletions(-) diff --git a/port/assets/menus/ce/main_menu.multiplayer_type_select.mp_map_select.xml b/port/assets/menus/ce/main_menu.multiplayer_type_select.mp_map_select.xml index 3d822835..dd9d1e16 100644 --- a/port/assets/menus/ce/main_menu.multiplayer_type_select.mp_map_select.xml +++ b/port/assets/menus/ce/main_menu.multiplayer_type_select.mp_map_select.xml @@ -33,6 +33,8 @@ + + diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index de267f17..096e4de2 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -502,6 +502,7 @@ reached, and (map_data) a saved game on the level */ #define MAXIMUM_PROFILES 32 #define SOUND_ERROR 4 #define SOUND_FORWARD 2 +#define SOUND_BACK 3 /* (an index whose profile reads: saved_game_files.c's) */ #define PROFILE_VALID_BIT 0x80000000UL @@ -1741,7 +1742,6 @@ Xbox's networking, run by the engine's port entry points (ui_widget_event_handler_functions.c) on our lists */ #define MAXIMUM_ADVERTISED_GAMES 9 -#define MULTIPLAYER_MAP_COUNT 13 #define MAP_ROWS 11 #define GAMETYPE_ROWS 10 #define MAXIMUM_GAMETYPES 100 @@ -1819,6 +1819,7 @@ boolean ui_widget_port_unjoin_player(struct widget_instance *widget, struct even void network_game_server_port_set_settings(wchar_t const *name, long maximum_players); void *global_network_game_client_get(void); void *global_network_game_server_get(void); +boolean network_game_is_splitscreen_local(void); struct advertised_game *network_game_client_get_available_games(void *client); boolean network_game_client_advertised_game_is_valid(struct advertised_game *game); short network_game_client_get_state(void *client, short *state_data); @@ -1848,8 +1849,7 @@ static short const maximum_players[] = { 2, 4, 8, 12, 16, 24, 32, 48, 64, 96, 12 static struct { short mode; - /* Create Game's map and gametype lists */ - short map_first, map_chosen; + /* Create Game's gametype list */ long gametypes[MAXIMUM_GAMETYPES]; short gametype_count; /* (those of the bank the list's spinner shows: STANDARD, CUSTOM) */ @@ -2022,43 +2022,230 @@ static boolean multiplayer_host(struct widget_instance *widget, struct event_rec return ui_widget_port_host(widget, event, widget_deleted); } -/* ---- the map list */ +/* ---- the map list (the Map screen's). Hosting over the network, its rows +first offer CO-OP CAMPAIGN or MULTIPLAYER, then that kind's categories, then +the levels (and a level's difficulty) or the maps: each a step that B goes +back from. Split screen starts at the multiplayer categories. + +CUSTOM CAMPAIGN and CUSTOM MAPS list Custom Edition maps, and a co-op level +is hosted as a network co-op game. This build has neither yet: those lists +are empty, and a co-op level cannot be hosted (cooperative_level_host). */ + +enum +{ + MAP_STEP_KINDS, + MAP_STEP_CATEGORIES, + MAP_STEP_MAPS, + MAP_STEP_CAMPAIGN_CATEGORIES, + MAP_STEP_LEVELS, + MAP_STEP_DIFFICULTIES, +}; + +#define MAXIMUM_MAP_ENTRIES 256 + +static wchar_t const *const map_kind_names[] = { L"CO-OP CAMPAIGN", L"MULTIPLAYER" }; +static wchar_t const *const map_category_names[] = { L"VANILLA MAPS", L"CUSTOM MAPS" }; +static wchar_t const *const campaign_category_names[] = { L"CAMPAIGN", L"CUSTOM CAMPAIGN" }; + +static struct +{ + short step; + boolean hosting; + /* the step's rows: multiplayer map indices (ui_widget_port_multiplayer_maps) + or campaign level indices */ + short count; + short entries[MAXIMUM_MAP_ENTRIES]; + short first, chosen; + /* what the earlier steps chose */ + short category, level; +} map_list; + +/* hosts the campaign level `level_name` as a co-op game at `difficulty`; +FALSE when it cannot be */ +static boolean cooperative_level_host(char const *level_name, short difficulty) +{ + (void)level_name; + (void)difficulty; + return FALSE; +} + +static short map_step_count(void) +{ + switch (map_list.step) + { + case MAP_STEP_KINDS: return NUMBEROF(map_kind_names); + case MAP_STEP_CATEGORIES: return NUMBEROF(map_category_names); + case MAP_STEP_CAMPAIGN_CATEGORIES: return NUMBEROF(campaign_category_names); + case MAP_STEP_DIFFICULTIES: return NUMBER_OF_GAME_DIFFICULTY_LEVELS; + default: return map_list.count; + } +} + +/* opens a step at the row given */ +static void map_step_open(struct widget_instance *list, short step, short row) +{ + short count; + + map_list.step = step; + count = map_step_count(); + map_list.chosen = (short)PIN(row, 0, MAX(count - 1, 0)); + map_list.first = (short)PIN(map_list.chosen - MAP_ROWS / 2, 0, MAX(count - MAP_ROWS, 0)); + focus_row(list, (short)(map_list.chosen - map_list.first)); +} + +/* the multiplayer maps of a category, or the campaign levels of one; the +row of the map used last */ +static short map_entries_read(boolean campaign, short category) +{ + char const *const *names; + short last_used = 0; + short index; + + map_list.count = 0; + if (category != 0) + return 0; + if (campaign) + map_list.count = NUMBER_OF_SINGLE_PLAYER_LEVELS; + else + map_list.count = ui_widget_port_multiplayer_maps(&names, &last_used); + for (index = 0; index < map_list.count; index++) + map_list.entries[index] = index; + return last_used; +} static void map_row_text(short row, wchar_t *text) { - string_get("pc\\main_menu\\mp_map_list", (short)(multiplayer.map_first + row), text); + short index = (short)(map_list.first + row); + + switch (map_list.step) + { + case MAP_STEP_KINDS: ustrncpy(text, map_kind_names[index], ROW_TEXT_LENGTH - 1); break; + case MAP_STEP_CATEGORIES: ustrncpy(text, map_category_names[index], ROW_TEXT_LENGTH - 1); break; + case MAP_STEP_CAMPAIGN_CATEGORIES: ustrncpy(text, campaign_category_names[index], ROW_TEXT_LENGTH - 1); break; + case MAP_STEP_DIFFICULTIES: + string_get("pc\\main_menu\\player_profiles_select\\difficulty_names", index, text); + break; + case MAP_STEP_LEVELS: + string_get("pc\\main_menu\\map_list", map_list.entries[index], text); + break; + default: + string_get("pc\\main_menu\\mp_map_list", map_list.entries[index], text); + break; + } + text[ROW_TEXT_LENGTH - 1] = 0; } /* "mp level list initialize" */ static boolean map_list_initialize(struct widget_instance *list) { - char const *const *names; - - ui_widget_port_multiplayer_maps(&names, &multiplayer.map_chosen); - multiplayer.map_first = (short)PIN(multiplayer.map_chosen - MAP_ROWS / 2, 0, MULTIPLAYER_MAP_COUNT - MAP_ROWS); - focus_row(list, (short)(multiplayer.map_chosen - multiplayer.map_first)); + map_list.hosting = global_network_game_server_get() != NULL && !network_game_is_splitscreen_local(); + map_step_open(list, map_list.hosting ? MAP_STEP_KINDS : MAP_STEP_CATEGORIES, 0); return TRUE; } -/* "mp map list update" */ +/* "mp map list update": the step's rows, and beside a multiplayer map its +picture, name and words */ static void map_list_update(struct widget_instance *list) { struct widget_instance *description = list->parameters.list.extended_description; + short count = map_step_count(); + short row = list_scroll(list, &map_list.first, count, MAP_ROWS); + short map = NONE; struct widget_instance *widget; - short map = list_scroll(list, &multiplayer.map_first, MULTIPLAYER_MAP_COUNT, MAP_ROWS); + if (row != NONE && row < count) + map_list.chosen = row; + rows_update(list, (short)MIN(count, MAP_ROWS), map_row_text); + if (map_list.step == MAP_STEP_MAPS && map_list.chosen < map_list.count) + map = map_list.entries[map_list.chosen]; + visible_set(named(description, "mp_map_right_item", 0), map != NONE); if (map != NONE) - multiplayer.map_chosen = map; - rows_update(list, MAP_ROWS, map_row_text); - if ((widget = named(description, "mp_map_right_name", 0)) != NULL) - widget->parameters.text_box.string_list_index = multiplayer.map_chosen; - if ((widget = named(description, "mp_map_right_pic", 0)) != NULL) - widget->animation.current_frame_index = multiplayer.map_chosen; - if ((widget = named(description, "mp_map_right_data", 0)) != NULL) - widget->parameters.text_box.string_list_index = multiplayer.map_chosen; + { + if ((widget = named(description, "mp_map_right_name", 0)) != NULL) + widget->parameters.text_box.string_list_index = map; + if ((widget = named(description, "mp_map_right_pic", 0)) != NULL) + widget->animation.current_frame_index = map; + if ((widget = named(description, "mp_map_right_data", 0)) != NULL) + widget->parameters.text_box.string_list_index = map; + } profile_name_show(description); } +/* "mp level select" (the list's OK): the next step, or the map or level +chosen. FALSE stays on the Map screen (the gametypes open on TRUE). */ +static boolean map_list_choose(struct widget_instance *list) +{ + short chosen = map_list.chosen; + + switch (map_list.step) + { + case MAP_STEP_KINDS: + ui_play_audio_feedback_sound(SOUND_FORWARD); + map_step_open(list, chosen == 0 ? MAP_STEP_CAMPAIGN_CATEGORIES : MAP_STEP_CATEGORIES, 0); + return FALSE; + case MAP_STEP_CATEGORIES: + ui_play_audio_feedback_sound(SOUND_FORWARD); + map_list.category = chosen; + map_step_open(list, MAP_STEP_MAPS, map_entries_read(FALSE, chosen)); + return FALSE; + case MAP_STEP_CAMPAIGN_CATEGORIES: + ui_play_audio_feedback_sound(SOUND_FORWARD); + map_list.category = chosen; + map_entries_read(TRUE, chosen); + map_step_open(list, MAP_STEP_LEVELS, 0); + return FALSE; + case MAP_STEP_LEVELS: + if (chosen >= map_list.count) + return campaign_fail(); + ui_play_audio_feedback_sound(SOUND_FORWARD); + map_list.level = map_list.entries[chosen]; + map_step_open(list, MAP_STEP_DIFFICULTIES, (short)PIN(main_get_difficulty(), 0, NUMBER_OF_GAME_DIFFICULTY_LEVELS - 1)); + return FALSE; + case MAP_STEP_DIFFICULTIES: + if (!cooperative_level_host(main_get_solo_level_name(map_list.level), chosen)) + return campaign_fail(); + return TRUE; + default: + if (chosen >= map_list.count) + return campaign_fail(); + return ui_widget_port_multiplayer_map_choose(map_list.entries[chosen]); + } +} + +/* "port map list back" (B on the list): the step before, or out of the Map +screen from the first */ +static boolean map_list_back(struct widget_instance *list, boolean *widget_deleted) +{ + short first = map_list.hosting ? MAP_STEP_KINDS : MAP_STEP_CATEGORIES; + + ui_play_audio_feedback_sound(SOUND_BACK); + switch (map_list.step) + { + case MAP_STEP_CATEGORIES: + case MAP_STEP_CAMPAIGN_CATEGORIES: + if (map_list.step != first) + { + map_step_open(list, MAP_STEP_KINDS, map_list.step == MAP_STEP_CATEGORIES ? 1 : 0); + return TRUE; + } + break; + case MAP_STEP_MAPS: + map_step_open(list, MAP_STEP_CATEGORIES, map_list.category); + return TRUE; + case MAP_STEP_LEVELS: + map_step_open(list, MAP_STEP_CAMPAIGN_CATEGORIES, map_list.category); + return TRUE; + case MAP_STEP_DIFFICULTIES: + map_step_open(list, MAP_STEP_LEVELS, map_list.level); + return TRUE; + default: + break; + } + ui_widget_port_go_back(list); + *widget_deleted = TRUE; + return TRUE; +} + /* (the gametype editor's: Server Setup's copy of the game's gametype) */ static void gametype_setup_begin(void); static void gametype_setup_end(void); @@ -4353,7 +4540,11 @@ boolean pc_menu_event_function_invoke( } else if (!strcmp(name, "mp level select")) { - return ui_widget_port_multiplayer_map_choose(multiplayer.map_chosen); + return map_list_choose(widget); + } + else if (!strcmp(name, "port map list back")) + { + return map_list_back(widget, widget_deleted); } else if (!strcmp(name, "mp profiles list initialize")) { diff --git a/port/linux/game/menu_tags.c b/port/linux/game/menu_tags.c index 4d3e69e4..55d7f74e 100644 --- a/port/linux/game/menu_tags.c +++ b/port/linux/game/menu_tags.c @@ -340,6 +340,7 @@ static char const *const port_function_names[] = "mp level select", "mp profiles list initialize", "mp profiles list dispose", "mp profile set for game", "port lobby preview join", "port setup edit", + "port map list back", "port pause end game", "port coop begin", "port coop player 2 list initialize", "port coop player 2", "port lobby open", "port lobby add player", "port lobby join", "port lobby leave", From cf17201ec819092e24710557e09ad7843797535d Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:26:49 +1300 Subject: [PATCH 20/51] Split screen divider spans wide screens --- source/interface/interface.c | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/source/interface/interface.c b/source/interface/interface.c index 996b8957..e0d616f8 100644 --- a/source/interface/interface.c +++ b/source/interface/interface.c @@ -1217,6 +1217,9 @@ static void interface_splitscreen_render( { rectangle2d bounds; short window_count; + /* port: the screen's width, wider than the Xbox's 640 on a wide screen, + whose middle the windows split at (compute_window_bounds) */ + short width = (short)halo_screen_width(); if (game_engine_force_single_screen() || cinematic_in_progress()) return; @@ -1229,7 +1232,7 @@ static void interface_splitscreen_render( bounds.y0 = 239; bounds.x0 = 0; bounds.y1 = 241; - bounds.x1 = 640; + bounds.x1 = width; draw_quad(&bounds, 0xFF000000); if (window_count <= 2) @@ -1238,18 +1241,18 @@ static void interface_splitscreen_render( if (window_count == 3) { bounds.y0 = 240; - bounds.x0 = 319; + bounds.x0 = width / 2 - 1; bounds.y1 = 480; - bounds.x1 = 321; + bounds.x1 = width / 2 + 1; draw_quad(&bounds, 0xFF000000); return; } bounds.y0 = 0; - bounds.x0 = 319; + bounds.x0 = width / 2 - 1; bounds.y1 = 480; - bounds.x1 = 321; + bounds.x1 = width / 2 + 1; match_assert( "c:\\halo\\SOURCE\\interface\\interface.c", From 39ece781ee9d2cd7ba941eba146e55f34408e0b1 Mon Sep 17 00:00:00 2001 From: Fadi Derouiche Date: Sun, 4 Oct 2026 19:45:28 +0100 Subject: [PATCH 21/51] Step the antenna chain in the tick and draw the frames between the ticks An antenna's chain is a simulation of the Xbox's own step, an update a tick, and the port was stepping it once a frame with the frame's own time. Its springs, its carry and the points' physics then acted against the frames' movement of it, so how far the chain moved per update depended on the frame rate, and a vehicle at speed swung it far wider than a tick's step does. - game.c steps the chain in the tick, beside the effects, which are already stepped there. - antenna.c records the shape each tick leaves, relative to the point the vehicle's marker carries the base by. A frame drawn between two ticks is the part of the way between those two shapes that it is, carried to where the marker is now, so the chain stays on the vehicle it hangs from instead of lagging a tick behind it. The gate that left a chain that had not been drawn for five updates unstepped counted frames; it counts ticks now, which is what it counted on the Xbox. - widgets.c no longer steps the chain from the widget update, which ran once a frame. - game_time.c draws the per-frame effects and widgets that hang off objects (game_frame) with the pose between the last two ticks, the pose the renderer draws with, so what they place on an object is drawn where they put it. - objects.c hands markers the matrices the renderer draws with, so an effect, a particle system or a contrail attached to a moving object follows it between the ticks too. Co-Authored-By: Claude Opus 5 (1M context) --- source/game/game.c | 7 ++ source/game/game_time.c | 7 ++ source/objects/objects.c | 8 +- source/objects/widgets/antenna.c | 140 +++++++++++++++++++++++++++++-- source/objects/widgets/widgets.c | 4 +- 5 files changed, 154 insertions(+), 12 deletions(-) diff --git a/source/game/game.c b/source/game/game.c index 0549cc04..52fe8a5f 100644 --- a/source/game/game.c +++ b/source/game/game.c @@ -162,6 +162,7 @@ struct game_options; #include "networking/network_messages.h" #include "networking/telnet_console.h" #include "objects/objects.h" +#include "objects/widgets/antenna.h" #include "objects/widgets/widgets.h" #include "physics/breakable_surfaces.h" #include "physics/collision_usage.h" @@ -331,6 +332,12 @@ void game_tick( ? 1.0f / (2 * TICKS_PER_SECOND) : 1.0f / TICKS_PER_SECOND; effects_update(seconds_per_tick); + /* An antenna's chain is a simulation of the Xbox's own step, an update a + tick: stepped a frame at a time, its springs, its carry and its points' + physics act against the frames' own movement of it, and a vehicle at + speed swings it far wider than a tick's step does. The frames between the + ticks are drawn from what each tick leaves (antenna.c). */ + antennas_update(seconds_per_tick); lock_global_random_seed(); rumble_update(); first_person_weapons_update(); diff --git a/source/game/game_time.c b/source/game/game_time.c index e3454dd5..739418be 100644 --- a/source/game/game_time.c +++ b/source/game/game_time.c @@ -482,7 +482,14 @@ void game_time_update( } } + /* The effects and the widgets that move every frame (game_frame) hang + off objects: they read the pose between the last two ticks, as the + renderer does, and the ticks above have taken their snapshots already + (port/linux/game/render_interpolation.c). The frames of one tick share + the fraction, so what they hang off is drawn where they put it. */ + render_interpolation_frame_begin(); game_frame(game_time_get_speed()*time_delta_sec); + render_interpolation_frame_end(); } else { diff --git a/source/objects/objects.c b/source/objects/objects.c index 3ffb5ab7..cfb31232 100644 --- a/source/objects/objects.c +++ b/source/objects/objects.c @@ -1857,9 +1857,11 @@ short object_get_marker_by_name( struct object_datum const *object = object_get(object_index); struct object_definition const *object_definition = object_definition_get(object->definition_index); - struct object_datum *matrix_object = object_get(object_index); - real_matrix4x3 const *matrices = (real_matrix4x3 *)object_header_block_get(object_index, - &matrix_object->object.node_matrices); + /* port: the matrices the renderer draws with, so a marker an effect, a + particle system or a contrail hangs off moves as the object does, between + the ticks too (port/linux/game/render_interpolation.c). While a tick runs, + which is when the game asks for markers, this is the same array. */ + real_matrix4x3 const *matrices = object_get_node_matrices(object_index); marker = model_get_marker_by_name( object_definition->object.model.index, diff --git a/source/objects/widgets/antenna.c b/source/objects/widgets/antenna.c index 00185605..0ffb0d81 100644 --- a/source/objects/widgets/antenna.c +++ b/source/objects/widgets/antenna.c @@ -51,6 +51,18 @@ symbols in this file: /* ---------- structures */ +/* A chain's shape after each of the last two ticks, relative to the point +its object's marker carries the base by: a frame between the ticks is drawn +with the part of the way between those two shapes that it is, as an object is +drawn between its own (port/linux/game/render_interpolation.c). */ +struct antenna_shape +{ + short count; + boolean has_previous; + real_point3d previous[MAXIMUM_ANTENNA_VERTICES + 1]; + real_point3d latest[MAXIMUM_ANTENNA_VERTICES + 1]; +}; + /* ---------- prototypes */ struct bitmap_data *bitmap_group_try_and_get_bitmap( @@ -63,7 +75,12 @@ static void antenna_update_attachment( struct location *attachment_location, real_point3d *attachment_point, real_vector3d *attachment_vector); +static void antenna_record_shape( + long antenna_index, + struct antenna_datum *antenna, + short count); static void antenna_render_proper( + long antenna_index, struct antenna_datum *antenna, struct antenna_definition *definition); static void antenna_update( @@ -74,6 +91,7 @@ static void antenna_update( /* ---------- globals */ struct data_array *antenna_data; +static struct antenna_shape antenna_shapes[MAXIMUM_ANTENNAS]; /* ---------- public code */ @@ -247,7 +265,7 @@ void antenna_render( } antenna->updates_since_last_render = 0; - antenna_render_proper(antenna, definition); + antenna_render_proper(antenna_index, antenna, definition); } return; @@ -269,7 +287,19 @@ void antennas_update( { antenna->updates_since_last_render++; if (antenna->object_index != NONE && antenna->updates_since_last_render < 5) + { + /* The chain is a simulation of the Xbox's own step, an update + a tick: its springs, its carry and its points' physics are + per-update quantities (game.c steps it in the tick, not in + game_frame with the frames' own movement). */ antenna_update(antenna, definition, MIN(delta, 1.0f / 15.0f)); + /* what this tick leaves a frame between ticks to be drawn in + (antenna_render_proper) */ + antenna_record_shape( + antenna_index, + antenna, + (short)(definition->vertices.count + 1)); + } } } @@ -324,7 +354,51 @@ static void antenna_update_attachment( return; } +/* the shape this tick leaves the chain in, relative to the point the base +was put at (antenna_update_attachment): a tick on from the last one, so a +frame between the two is drawn from both (antenna_render_proper) */ +static void antenna_record_shape( + long antenna_index, + struct antenna_datum *antenna, + short count) +{ + long absolute_index = DATUM_INDEX_TO_ABSOLUTE_INDEX(antenna_index); + struct antenna_shape *shape; + real_point3d const *base = &antenna->vertices[0].position; + short vertex_index; + + if (count <= 0 || count > MAXIMUM_ANTENNA_VERTICES + 1 || + absolute_index < 0 || absolute_index >= MAXIMUM_ANTENNAS) + { + return; + } + + shape = &antenna_shapes[absolute_index]; + if (shape->count == count) + { + csmemcpy(shape->previous, shape->latest, sizeof(real_point3d) * count); + shape->has_previous = TRUE; + } + else + { + shape->count = count; + shape->has_previous = FALSE; + } + + for (vertex_index = 0; vertex_index < count; vertex_index++) + { + real_point3d const *position = &antenna->vertices[vertex_index].position; + + shape->latest[vertex_index].x = position->x - base->x; + shape->latest[vertex_index].y = position->y - base->y; + shape->latest[vertex_index].z = position->z - base->z; + } + + return; +} + static void antenna_render_proper( + long antenna_index, struct antenna_datum *antenna, struct antenna_definition *definition) { @@ -336,6 +410,12 @@ static void antenna_render_proper( real falloff_scale = (100.0f - definition->cutoff_pixels) / (definition->falloff_pixels - definition->cutoff_pixels); + struct antenna_shape const *shape = + &antenna_shapes[DATUM_INDEX_TO_ABSOLUTE_INDEX(antenna_index)]; + real_point3d chain[MAXIMUM_ANTENNA_VERTICES + 1]; + real fraction = render_interpolation_fraction(); + real_point3d base = antenna->vertices[0].position; + short chain_count = (short)(vertices->count + 1); short vertex_index; if (falloff_scale < 0.0f) @@ -343,6 +423,52 @@ static void antenna_render_proper( else if (falloff_scale > 1.0f) falloff_scale = 1.0f; + if (chain_count > MAXIMUM_ANTENNA_VERTICES + 1) + chain_count = MAXIMUM_ANTENNA_VERTICES + 1; + + /* The frames between the ticks (port/linux/game/render_interpolation.c): + the ticks left the chain's shape, a frame is drawn the part of the way + between the last two of them that it is, and the point its vehicle's + marker is at now carries the whole of it. The ticks' own base is what + the shape was recorded against, so with no such record the shape the + last tick left is drawn instead. */ + if (antenna->object_index != NONE) + { + struct object_marker marker; + + object_get_marker_by_name( + antenna->object_index, + definition->attachment_marker, + &marker, + 1); + base = marker.matrix.position; + } + + for (vertex_index = 0; vertex_index < chain_count; vertex_index++) + { + real_point3d offset; + + if (shape->count == chain_count && shape->has_previous) + { + offset.x = shape->previous[vertex_index].x + + (shape->latest[vertex_index].x - shape->previous[vertex_index].x) * fraction; + offset.y = shape->previous[vertex_index].y + + (shape->latest[vertex_index].y - shape->previous[vertex_index].y) * fraction; + offset.z = shape->previous[vertex_index].z + + (shape->latest[vertex_index].z - shape->previous[vertex_index].z) * fraction; + } + else + { + offset.x = antenna->vertices[vertex_index].position.x - antenna->vertices[0].position.x; + offset.y = antenna->vertices[vertex_index].position.y - antenna->vertices[0].position.y; + offset.z = antenna->vertices[vertex_index].position.z - antenna->vertices[0].position.z; + } + + chain[vertex_index].x = base.x + offset.x; + chain[vertex_index].y = base.y + offset.y; + chain[vertex_index].z = base.z + offset.z; + } + build_sprites_begin( &sprite_data, (short)vertices->count, @@ -351,12 +477,10 @@ static void antenna_render_proper( 0); for (vertex_index = 0; - vertex_index < vertices->count; + vertex_index < chain_count - 1; vertex_index = (short)(vertex_index + 1)) { struct antenna_vertex_datum *vertex = &antenna->vertices[vertex_index]; - struct antenna_vertex_datum *next_vertex = - &antenna->vertices[vertex_index + 1]; struct antenna_vertex_definition *definition_vertex = TAG_BLOCK_GET_ELEMENT( vertices, vertex_index, @@ -364,9 +488,9 @@ static void antenna_render_proper( real_vector3d direction; real_argb_color color; - direction.i = next_vertex->position.x - vertex->position.x; - direction.j = next_vertex->position.y - vertex->position.y; - direction.k = next_vertex->position.z - vertex->position.z; + direction.i = chain[vertex_index + 1].x - chain[vertex_index].x; + direction.j = chain[vertex_index + 1].y - chain[vertex_index].y; + direction.k = chain[vertex_index + 1].z - chain[vertex_index].z; color = definition_vertex->color; if (vertex->sprite_scale != 0.0f && falloff_scale > 0.0f) @@ -376,7 +500,7 @@ static void antenna_render_proper( 1, definition_vertex->sequence_index, 0, - &vertex->position, + &chain[vertex_index], &direction, 0.0f, vertex->sprite_scale, diff --git a/source/objects/widgets/widgets.c b/source/objects/widgets/widgets.c index 3fd0caf0..7c04f7e5 100644 --- a/source/objects/widgets/widgets.c +++ b/source/objects/widgets/widgets.c @@ -102,7 +102,9 @@ static struct widget_type_definition data_0030b2b0[NUMBER_OF_WIDGET_TYPES] = antennas_dispose, antenna_new, antenna_delete, - antennas_update, + /* the chain simulates at the Xbox's own rate, an update a tick, so + the tick steps it rather than the frame (game.c; antenna.c) */ + NULL, antenna_render }, { From 2853a42253f10a50684bf8d8d0bcb49493d7834c Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:10:47 +1300 Subject: [PATCH 22/51] Clear an antenna's shape record when it is created --- source/objects/widgets/antenna.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/source/objects/widgets/antenna.c b/source/objects/widgets/antenna.c index 0ffb0d81..6586c42b 100644 --- a/source/objects/widgets/antenna.c +++ b/source/objects/widgets/antenna.c @@ -162,6 +162,9 @@ long antenna_new( antenna->definition_index = definition_index; antenna->object_index = NONE; antenna->updates_since_last_render = 0; + /* (a new chain blends from no shape: the slot's record may be a + deleted antenna's, or an earlier map's) */ + antenna_shapes[DATUM_INDEX_TO_ABSOLUTE_INDEX(antenna_index)].count = 0; antenna->last_attachment_location.z = 0.0f; antenna->last_attachment_location.y = 0.0f; antenna->last_attachment_location.x = 0.0f; From 3ae09c3d2ccd357f413e5ce1af936a16231cd09b Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:23:47 +1300 Subject: [PATCH 23/51] Fade the radar wedge out fully --- port/assets/hud/hud_unit_backgrounds__6.png | Bin 24810 -> 26002 bytes port/assets/hud/hud_unit_backgrounds__7.png | Bin 14642 -> 15144 bytes ...aps__combined__hud_unit_backgrounds__6.svg | 2 +- ...aps__combined__hud_unit_backgrounds__7.svg | 2 +- 4 files changed, 2 insertions(+), 2 deletions(-) diff --git a/port/assets/hud/hud_unit_backgrounds__6.png b/port/assets/hud/hud_unit_backgrounds__6.png index 79c8f2c33ca9d566b393ffdd984307eeb237219c..74f0d399ed4b0af1771f0113d0797299f1d02ddf 100644 GIT binary patch literal 26002 zcmeFZ`9GBV`v-o{*hQqBV(M^6#7QN`GNaRCJE4@cSP~A2XhD{l7Oh8|2yI3ip|Z7E zM^O|}q$F8Fl5OnUnEQKOw?2>W_fPo#@P0k!aq7s-{aUW&c|EV^YtGqh-XtrnC`}NA zthw3xEd(J7e-$Mp#o-V3;^ij^!p*^ay{TPbea~0VgL^t$l%2KaGbB$KKHt!wdVZ;z z!+Evk{%*?>*7|psoV!u?ckRBIyGOd}&5ffwRrNX_^+qOLd7^h9xl*-8^Qh5m_08RN znXU%gH_p+voHO5Vwz(fo-&XbITteyuL6a|kxq)rxZL^^Kb6?K!2e%&pu~Q^BqufOzqAJr%0A^}W@i4uGbV^R1c&A1 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 literal 24810 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 literal 14642 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 diff --git a/port/assets/hud/svg/hud__bitmaps__combined__hud_unit_backgrounds__6.svg b/port/assets/hud/svg/hud__bitmaps__combined__hud_unit_backgrounds__6.svg index 55246c2c..36b2dd35 100644 --- a/port/assets/hud/svg/hud__bitmaps__combined__hud_unit_backgrounds__6.svg +++ b/port/assets/hud/svg/hud__bitmaps__combined__hud_unit_backgrounds__6.svg @@ -2,7 +2,7 @@ - + diff --git a/port/assets/hud/svg/hud__bitmaps__combined__hud_unit_backgrounds__7.svg b/port/assets/hud/svg/hud__bitmaps__combined__hud_unit_backgrounds__7.svg index f78451fb..d765b142 100644 --- a/port/assets/hud/svg/hud__bitmaps__combined__hud_unit_backgrounds__7.svg +++ b/port/assets/hud/svg/hud__bitmaps__combined__hud_unit_backgrounds__7.svg @@ -2,7 +2,7 @@ - + From 737551fd515d2cb99d7cb6630482e8b65e448cf3 Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:28:58 +1300 Subject: [PATCH 24/51] Resolution, scaling and window size settings --- ...tup.player_profile_edit.video_settings.xml | 64 ++-- port/linux/README.md | 15 +- port/linux/game/menu_functions.c | 53 ++- port/linux/game/menu_tags.c | 41 ++- port/linux/src/d3d8_gl.c | 18 +- port/linux/src/port_config.c | 31 +- port/linux/src/sdl_platform.c | 303 +++++++++++++++--- port/linux/src/sdl_platform.h | 9 + tools/port_settings.py | 30 +- 9 files changed, 473 insertions(+), 91 deletions(-) diff --git a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.video_settings.xml b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.video_settings.xml index b66f0660..d8aa1c70 100644 --- a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.video_settings.xml +++ b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.video_settings.xml @@ -14,13 +14,15 @@ - - - - - - - + + + + + + + + + @@ -37,20 +39,38 @@ - - - + + + - + - + + + + + + + + + + + + + + + + + + + - + @@ -59,7 +79,7 @@ - + @@ -68,7 +88,7 @@ - + @@ -77,7 +97,7 @@ - + @@ -86,7 +106,7 @@ - + @@ -95,7 +115,7 @@ - + @@ -112,7 +132,9 @@ + + @@ -122,8 +144,10 @@ - - + + + + diff --git a/port/linux/README.md b/port/linux/README.md index da997a9b..4c6f29d2 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -220,16 +220,19 @@ the setting for one start of the game. It has priority over the file. | Setting | Default | Environment variable | Function | | --- | --- | --- | --- | -| `display.mode` | `""` | `HALO_DISPLAY_MODE` | `"fullscreen"`: the display, taken at its desktop resolution. `"borderless"`: a window over the whole desktop. Both draw at the resolution of the display, the picture 480 lines of the game and the width of the display. `"windowed"`: a window with the 640x480 picture of the Xbox, scaled. Empty: `display.fullscreen` decides (`true`: borderless). F11 changes between the window and the fullscreen mode. Video Setup sets it. | -| `display.fullscreen` | `true` | `HALO_FULLSCREEN` | `true`: fullscreen at the resolution of the display. The picture has 480 lines of the game and the width of the display. `false`: a window with the 640x480 picture of the Xbox. Used when `display.mode` is empty. | -| `display.window_scale` | `2` | `HALO_WINDOW_SCALE` | The size of the window, as a multiple of 640x480. You can change the size of the window. | +| `display.mode` | `""` | `HALO_DISPLAY_MODE` | `"fullscreen"`: the display, taken at the mode of `display.resolution` (the nearest the display has), or at its desktop mode. `"borderless"`: a window over the whole desktop, whose mode does not change. `"windowed"`: a window of `display.window_size`. Empty: `display.fullscreen` decides (`true`: borderless). F11 changes between the window and the fullscreen mode. Video Setup sets it. | +| `display.fullscreen` | `true` | `HALO_FULLSCREEN` | `true`: borderless, as `display.mode = "borderless"`. `false`: a window, as `display.mode = "windowed"`. Used when `display.mode` is empty. | +| `display.resolution` | `"native"` | `HALO_RESOLUTION` | What fullscreen and borderless draw at: `"native"`, the display's own resolution, or `"x"`, such as `"1920x1080"`, 640x480 or more. Fullscreen sets the display to it. Borderless draws at it and scales the picture to the display, where the display has room for it. The picture has 480 lines of the game and the width of the resolution's shape. A window draws at its own size instead. Video Setup's Resolution sets it, from the display's modes; it shows with Fullscreen and Borderless. | +| `display.resolution_scaling` | `"native"` | `HALO_RESOLUTION_SCALING` | `"native"`: the game draws at the resolution of the window, or of the display (or `display.resolution`) fullscreen. `"original"`: the game draws the 640x480 picture of the Xbox and scales it up to the window or the display, whatever `display.mode` is. Video Setup sets it. | +| `display.window_size` | `""` | `HALO_WINDOW_SIZE` | The size of the window, as `"x"`, such as `"1920x1080"`, 640x480 or more. You can change the size of the window; the game's picture takes its shape. Empty: `display.window_scale` decides. Video Setup's Window Size sets it, from sizes of each shape (4:3, 16:10, 16:9 and 21:9) that fit the desktop; it shows with Windowed. | +| `display.window_scale` | `2` | `HALO_WINDOW_SCALE` | Used when `display.window_size` is empty: the size of the window, as a multiple of 640x480. | | `display.vsync` | `true` | `HALO_NO_VSYNC=1` sets `false` | `true`: each frame waits for the display. | | `display.max_fps` | `0` | `HALO_MAX_FPS` | With vsync off, the most frames each second. `0`: twice the display's refresh rate. `-1`: no limit, which can hang some Intel graphics (Raptor Lake), resetting the desktop's graphics too. | | `debug.gpu_flush_draws` | `-1` | `HALO_GPU_FLUSH_DRAWS` | Flush the GPU's pipeline every this many draws. `-1`: every 3 on Intel graphics with Mesa's driver, which can otherwise hang in the game's long runs of small draws and reset the desktop's graphics too. `0`: never. | | `display.interpolation` | `true` | `HALO_INTERPOLATION` | `true`: one frame for each refresh of the display. `false`: 30 frames each second, as on the Xbox. Refer to "Frame rate". | | `display.direct_camera` | `true` | `HALO_DIRECT_CAMERA` | `true`: in first person, on foot, the view points where the player aims in each frame, not where the last tick left it. Refer to "Frame rate". | | `display.high_res_hud` | `true` | `HALO_HIGH_RES_HUD` | `true`: the HUD (meters, counters, panels and their outlines, the motion sensor, reticles, waypoints, scopes) is drawn from the high-res assets in `port/assets/hud`, 8x the size of the maps' bitmaps. The bitmaps with English text keep the maps' own. `false`: the maps' own bitmaps. | -| `display.high_res_text` | `true` | `HALO_HIGH_RES_TEXT` | `true`: the menus' and HUD's text is drawn with the fonts in `port/assets/fonts` (Overpass, in place of the maps' Interstate) at the display's resolution, laid out as before, and the menus' titles are drawn from the high-res pictures in `port/assets/titles`. `false`: the maps' bitmap fonts and titles. | +| `display.high_res_text` | `true` | `HALO_HIGH_RES_TEXT` | `true`: the menus' and HUD's text is drawn with the fonts in `port/assets/fonts` (Overpass, in place of the maps' Interstate) at the resolution the game draws at, laid out as before, and the menus' titles are drawn from the high-res pictures in `port/assets/titles`. `false`: the maps' bitmap fonts and titles. | | `display.menus` | `"pc"` | `HALO_MENUS` | `"pc"`: the PC version's menus, from the files in `port/assets/menus` and a `menus` folder next to `config.toml`. Refer to "Menus". `"xbox"`: the Xbox's menus. | | `display.player_names` | `"all"` | `HALO_PLAYER_NAMES` | In multiplayer, whose names are drawn above their heads: `"all"`, `"allies"`, `"enemies"` or `"none"`. An ally's name is drawn above the triangle the game shows over teammates. An enemy's name shows only within the motion sensor's reach, while the enemy is in sight and not camouflaged, so it never shows where an enemy hides. The gametype's motion tracker setting also applies: no names if it shows no players, only allies' if it shows only friends. | | `display.player_name_scale` | `1.0` | `HALO_PLAYER_NAME_SCALE` | How large the players' names are drawn: `1.0` is three quarters of the size of the HUD's text, from `0.25` to `4`. With high-res text, larger names are rasterized at their size, so they stay sharp. | @@ -615,7 +618,7 @@ The game sends the activity only to a Discord client of the same user. | Game code | All 466 C files of the game. The changes are in "Game source changes". | | Graphics | Direct3D 8 on OpenGL 4.5 core through SDL3 (`src/d3d8_gl.c`). The port translates the NV2A vertex shaders and register combiners to GLSL. It decodes all the Xbox texture formats. The vertex and index buffers come from a GL copy of the Xbox memory. | | High-res HUD | The HUD is drawn from high-res assets: redraws at 8x the size of the maps' bitmaps (4x for the largest), in `port/assets/hud`. They cover the meters, counters, panels and their outlines, the motion sensor, reticles, waypoints and scopes, but no bitmap with English text. `tools/hud_assets.py` makes them from the SVG redraws, and the build embeds them in the executable. When the game uploads one of those bitmaps, `src/hud_hires.c` gives the high-res texture in its place, if the bitmap's pixels are those of the English maps: another language's maps keep their own. The game sizes and places the HUD from its tags as before. `display.high_res_hud = false` turns this off. | -| High-res text | The menus' and HUD's text is drawn with Overpass (`port/assets/fonts`, SIL Open Font License) in place of the maps' bitmap fonts, which are Interstate. `src/text_hires.c` rasterizes each glyph with stb_truetype (`port/third_party/stb`) at the display's resolution, into an atlas that a placeholder bitmap of the game stands for. The game lays the text out from its font tags as before. The menus' titles (the screens' headers and the main menu's items) are pictures of text in the maps, so they are drawn as the high-res HUD is: `tools/title_assets.py` sets each one again in OpenCE, Roger White's public-domain Newtown respaced to match the maps' commercial title typeface (`tools/title_font.py`), at 4x the bitmap's size over its own plate or glow, each letter placed where the map's letter is, in `port/assets/titles`. The postgame carnage report's title is set over a hand-made SVG redraw of its panel (`port/assets/titles/svg`) instead. `display.high_res_text = false` turns it off. | +| High-res text | The menus' and HUD's text is drawn with Overpass (`port/assets/fonts`, SIL Open Font License) in place of the maps' bitmap fonts, which are Interstate. `src/text_hires.c` rasterizes each glyph with stb_truetype (`port/third_party/stb`) at the resolution the game draws at, into an atlas that a placeholder bitmap of the game stands for. The game lays the text out from its font tags as before. The menus' titles (the screens' headers and the main menu's items) are pictures of text in the maps, so they are drawn as the high-res HUD is: `tools/title_assets.py` sets each one again in OpenCE, Roger White's public-domain Newtown respaced to match the maps' commercial title typeface (`tools/title_font.py`), at 4x the bitmap's size over its own plate or glow, each letter placed where the map's letter is, in `port/assets/titles`. The postgame carnage report's title is set over a hand-made SVG redraw of its panel (`port/assets/titles/svg`) instead. `display.high_res_text = false` turns it off. | | Sound | Xbox DirectSound on SDL3 audio (`src/dsound_sdl.c`): PCM and Xbox ADPCM, mixed at 48 kHz, with volume, pitch, mix bins, distance, stereo pan, occlusion and obstruction. There is no Doppler effect, no cones and no reverb. | | Input | XInput on SDL3 (`src/xinput_sdl.c`): keyboard, mouse, gamepads with rumble, and the debug keyboard for the console. | | Files | The Win32 file functions and the MSVC file functions on POSIX, with the translation of Xbox paths. | @@ -705,7 +708,7 @@ Other changes: | `input/input_abstraction.c`, `game/player_control.c`, `game/players.c`, `game/player_queues_new.c`, `units/units.h` | The keyboard and mouse's actions (`src/xinput_sdl.c`, `include/halo_keyboard.h`) join controller 1's game controls; their reload key reloads on its own, and their action key only acts (a control flag of the port's, sent with the player's action, stops the reload the controller's X falls back to). | | `sound/sound_manager.c`, `interface/hud.c`, `game/game_engine.c` | The music's and the other sounds' volumes; the HUD's and the scoreboard's settings are read again when Settings changes them. | | `interface/hud.c` | In multiplayer, players' names are drawn above their heads (`display.player_names`, `display.player_name_scale`). | -| `rasterizer/rasterizer_text.c`, `text/draw_string.c` | Text is drawn from an atlas of the fonts' glyphs, rasterized at the display's resolution (`src/text_hires.c`), when the font has every character of the string. Text can be drawn scaled about a point (`rasterizer_text_set_scale`), as the players' names are. Each glyph's advance is centred on the font tag character's, so the layout is the same, and a glyph is cut at a text box only where the font tag's character visibly was. | +| `rasterizer/rasterizer_text.c`, `text/draw_string.c` | Text is drawn from an atlas of the fonts' glyphs, rasterized at the resolution the game draws at (`src/text_hires.c`), when the font has every character of the string. Text can be drawn scaled about a point (`rasterizer_text_set_scale`), as the players' names are. Each glyph's advance is centred on the font tag character's, so the layout is the same, and a glyph is cut at a text box only where the font tag's character visibly was. | The x86 inline assembly of the game is replaced by C. Thus the compiler can optimize that code for each processor: diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index de267f17..49601bcf 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -25,7 +25,8 @@ calls them from PC_MENU_FUNCTION_BASE on): - the settings screens' (tools/port_settings.py): "port settings save" (OK) writes those changed and applies the window's (the rest follow config.toml themselves), "port settings defaults" shows the defaults, - "port settings help" the help of the row chosen; + "port settings help" the help of the row chosen (and Video Setup's + Resolution or Window Size, for the display mode shown); - Controls Setup's: the keyboard and mouse's controls, two bindings each, shown a group at a time ("controls update menu"); "controls begin binding" takes the next key or button pressed for the binding left and @@ -314,6 +315,22 @@ static boolean setting_text(char const *name, char *text, unsigned int size, boo sdl_platform.c) */ if (!strcmp(name, "display.mode") && !text[0]) snprintf(text, size, "%s", default_value || config_boolean("display.fullscreen") ? "borderless" : "windowed"); + /* (display.window_size empty: 640x480 times display.window_scale, as + older versions set it) */ + if (!strcmp(name, "display.window_size") && !text[0]) + { + char scale_text[32] = ""; + long scale; + + if (default_value) + config_default("display.window_scale", scale_text, sizeof(scale_text)); + else + config_text("display.window_scale", scale_text, sizeof(scale_text)); + scale = atol(scale_text); + if (scale < 1) + scale = 1; + snprintf(text, size, "%ldx%ld", 640 * scale, 480 * scale); + } return TRUE; } for (index = 0; index < NUMBEROF(profile_settings); index++) @@ -1137,6 +1154,37 @@ static void settings_help(struct widget_instance *list) help->parameters.text_box.string_list_index = index; } +/* Video Setup's Resolution and Window Size, in the one row's place +(tools/port_settings.py): the one the display mode shown uses (Window Size +the window's, Resolution fullscreen's and borderless's) is shown, and the +list passes over the other, which takes no focus while it is hidden */ +static void video_rows_show(struct widget_instance *list) +{ + struct widget_instance *mode = named(list, "mode_spinner", 0); + struct widget_instance *resolution = named(list, "op_resolution", 0); + struct widget_instance *window_size = named(list, "op_window_size", 0); + struct pc_menu_setting *setting = mode ? pc_menu_setting_get(mode->definition_tag_index) : NULL; + struct widget_instance *shown, *child; + short index; + + if (!setting || !resolution || !window_size) + return; + index = mode->parameters.list.selected_index; + shown = index >= 0 && index < setting->value_count && !strcmp(setting->values[index], "windowed") ? + window_size : resolution; + resolution->visible = shown == resolution; + window_size->visible = shown == window_size; + /* (the focus on the one hidden goes to the one shown: Defaults can + change the mode while it has it) */ + if (list->focused_child == (shown == resolution ? window_size : resolution)) + { + for (index = 0, child = list->child; child && child != shown; child = child->next) + index++; + list->focused_child = shown; + list->parameters.list.selected_index = index; + } +} + /* ---------- Change Color: the profile's colour, from a list of the game's colours (more than its rows: it scrolls), by the Xbox's names for its spinner's functions */ @@ -4564,7 +4612,10 @@ void pc_menu_game_data_function_invoke( else if (!strcmp(name, "port lobby update")) lobby_update(widget); else if (!strcmp(name, "port settings help")) + { + video_rows_show(widget); settings_help(widget); + } else if (!strcmp(name, "controls update menu")) controls_update(widget); else if (!strcmp(name, "color picker update")) diff --git a/port/linux/game/menu_tags.c b/port/linux/game/menu_tags.c index 4d3e69e4..8617d1bc 100644 --- a/port/linux/game/menu_tags.c +++ b/port/linux/game/menu_tags.c @@ -44,6 +44,8 @@ no screen pauses the game (a network game's clock runs on). /* the platform layer's (port/linux/src) */ void platform_log(char const *format, ...); char const *config_string(char const *name); +int platform_display_resolutions(long *widths, long *heights, int maximum); +int platform_window_sizes(long *widths, long *heights, int maximum); /* the game's network server (this machine hosts: network_game_globals.c) */ void *global_network_game_server_get(void); @@ -700,6 +702,41 @@ static long split(char const *text, char const **pieces) return count; } +/* a spinner's strings, or its values, split: its own, but Video Setup's +sizes are the display's (sdl_platform.c), shown "1920 x 1080" and set +"1920x1080": its resolutions after Resolution's own (NATIVE), and the +window sizes that fit it in place of Window Size's own */ +static long spinner_split(struct halo_menu_widget const *widget, boolean values, char const **pieces) +{ + long count = split(values ? widget->values : widget->strings, pieces); + long widths[MAXIMUM_STRINGS], heights[MAXIMUM_STRINGS]; + long added, index; + + if (widget->setting && !strcmp(widget->setting, "display.resolution")) + added = platform_display_resolutions(widths, heights, (int)(MAXIMUM_STRINGS - count)); + else if (widget->setting && !strcmp(widget->setting, "display.window_size")) + { + added = platform_window_sizes(widths, heights, MAXIMUM_STRINGS); + if (added > 0) + count = 0; + } + else + return count; + for (index = 0; index < added; index++) + { + char text[32]; + char *piece; + + snprintf(text, sizeof(text), values ? "%ldx%ld" : "%ld x %ld", widths[index], heights[index]); + piece = allocate((long)strlen(text) + 1); + if (!piece) + break; + strcpy(piece, text); + pieces[count++] = piece; + } + return count; +} + static void *bitmap_build(struct halo_menu_bitmap const *source, long tag_index) { struct bitmap_group *group = allocate(sizeof(struct bitmap_group)); @@ -914,7 +951,7 @@ static void setting_add(struct halo_menu_widget const *source, long definition_i setting->definition_index = definition_index; setting->setting = source->setting; setting->loaded_index = NONE; - setting->value_count = split(source->values, setting->values); + setting->value_count = spinner_split(source, TRUE, setting->values); } /* a font: large, small, terminal, or the map's by its path */ @@ -1639,7 +1676,7 @@ void menu_tags_loaded( if (widget->strings) { char const *pieces[MAXIMUM_STRINGS]; - long count = split(widget->strings, pieces); + long count = spinner_split(widget, FALSE, pieces); instance_set(instances, UNICODE_STRING_LIST_TAG, build.spinner_tags[index], widget->name, " strings", string_list_build(pieces, count)); diff --git a/port/linux/src/d3d8_gl.c b/port/linux/src/d3d8_gl.c index c3b92a04..41f11475 100644 --- a/port/linux/src/d3d8_gl.c +++ b/port/linux/src/d3d8_gl.c @@ -60,17 +60,19 @@ struct xgpu_capabilities xgpu_capabilities; The Xbox screen is 640x480. The native ports can draw a wider one: 480 lines, and as many columns as the display's shape gives. On Android that is display.screen_width (port_config.c; 640 keeps 4:3); on the desktop, the -display's shape while the game is fullscreen, and 640 in a window. The -game's camera derives its horizontal field of view from the viewport, so the -3D view simply widens. The menus and full-screen overlays are laid out for -640 columns; while they draw (halo_screen_ui_offset), everything shifts right -to center them. +shape of the window (of the display while the game is fullscreen, or of +display.resolution), and 640 where display.resolution_scaling is "original". +The game's camera derives its horizontal field of view from the viewport, so +the 3D view simply widens. The menus and full-screen overlays are laid out +for 640 columns; while they draw (halo_screen_ui_offset), everything shifts +right to center them. -Fullscreen on the desktop also draws at the display's resolution: render +The desktop also draws at that resolution (platform_screen_mode): render targets the size of the screen get that many pixels (screen_scale), and viewports, clears and visibility counts are scaled to match, so the game -still works in its 480 lines. The width and the scale change only between -frames, after one is presented (halo_screen_commit). */ +still works in its 480 lines; "original" draws 640x480, scaled up at +presentation. The width and the scale change only between frames, after one +is presented (halo_screen_commit). */ #define SCREEN_HEIGHT 480 #define SCREEN_MAXIMUM_WIDTH 1920 diff --git a/port/linux/src/port_config.c b/port/linux/src/port_config.c index 419a08e9..96dba105 100644 --- a/port/linux/src/port_config.c +++ b/port/linux/src/port_config.c @@ -66,15 +66,28 @@ struct config_setting static const struct config_setting config_settings[] = { { "display.fullscreen", _config_boolean, "true", "HALO_FULLSCREEN", _environment_value, _platform_desktop, - "Start fullscreen, drawing at the display's resolution and shape; false\n" - "starts in a window, which draws the Xbox's 640x480. F11 switches." }, + "Where display.mode is empty: start borderless over the whole display;\n" + "false starts in a window. F11 switches." }, { "display.mode", _config_string, "\"\"", "HALO_DISPLAY_MODE", _environment_value, _platform_desktop, - "\"fullscreen\" takes the display at its desktop resolution, \"borderless\"\n" - "is a window over the whole desktop (both draw at the display's\n" - "resolution), \"windowed\" a window of the Xbox's 640x480, scaled. Empty:\n" - "display.fullscreen's (true: borderless). F11 switches to the window and back." }, + "\"fullscreen\" takes the display (at display.resolution's mode),\n" + "\"borderless\" is a window over the whole desktop, \"windowed\" a window\n" + "(display.window_size). Empty: display.fullscreen's (true: borderless).\n" + "F11 switches to the window and back." }, + { "display.resolution", _config_string, "\"native\"", "HALO_RESOLUTION", _environment_value, _platform_desktop, + "What fullscreen and borderless draw at: \"native\", the display's own, or\n" + "\"x\" (\"1920x1080\"), 640x480 or more. Fullscreen sets the\n" + "display to it; borderless draws it scaled to the display." }, + { "display.resolution_scaling", _config_string, "\"native\"", "HALO_RESOLUTION_SCALING", _environment_value, + _platform_desktop, + "\"native\" draws at the window's resolution (fullscreen, the display's or\n" + "display.resolution); \"original\" draws the Xbox's 640x480 and scales it\n" + "up." }, + { "display.window_size", _config_string, "\"\"", "HALO_WINDOW_SIZE", _environment_value, _platform_desktop, + "The window's size, \"x\" (\"1920x1080\"), 640x480 or more (it\n" + "can be resized). Empty: display.window_scale's." }, { "display.window_scale", _config_integer, "2", "HALO_WINDOW_SCALE", _environment_value, _platform_desktop, - "The window's size as a multiple of 640x480 (it can be resized)." }, + "Where display.window_size is empty: the window's size as a multiple of\n" + "640x480." }, { "display.screen_width", _config_integer, "0", "HALO_SCREEN_WIDTH", _environment_value, _platform_android, "Columns of the 480-line picture: 0 for the display's shape, 640 for the\n" "Xbox's 4:3." }, @@ -96,8 +109,8 @@ static const struct config_setting config_settings[] = "false draws the maps' own bitmaps." }, { "display.high_res_text", _config_boolean, "true", "HALO_HIGH_RES_TEXT", _environment_value, _platform_all, "Draw the menus' and HUD's text with the fonts in port/assets/fonts\n" - "(Overpass) at the display's resolution, and the menus' titles from\n" - "port/assets/titles; false draws the maps' bitmap fonts and titles." }, + "(Overpass) at the resolution the game draws at, and the menus' titles\n" + "from port/assets/titles; false draws the maps' bitmap fonts and titles." }, { "display.menus", _config_string, "\"pc\"", "HALO_MENUS", _environment_value, _platform_all, "The menus: \"pc\" for the PC version's main menu (port/assets/menus,\n" "and a menus folder here for your own), \"xbox\" for the Xbox's." }, diff --git a/port/linux/src/sdl_platform.c b/port/linux/src/sdl_platform.c index 56bf2212..58130ee4 100644 --- a/port/linux/src/sdl_platform.c +++ b/port/linux/src/sdl_platform.c @@ -378,22 +378,82 @@ static BOOL platform_fullscreen_setting(void) return !config_boolean("debug.hidden_window") && platform_display_mode() != _display_mode_windowed; } +/* a size as a setting has it, "x": whether it is one, and +the Xbox's 640x480 or more */ +static BOOL platform_size_parse(const char *text, long *width, long *height) +{ + char *end; + + *width = strtol(text, &end, 10); + *height = *end == 'x' || *end == 'X' ? strtol(end + 1, &end, 10) : 0; + return !*end && *width >= 640 && *height >= 480; +} + +/* display.resolution in pixels, or 0x0 for the display's own ("native"), +as for one the game cannot draw at */ +static void platform_resolution_setting(long *width, long *height) +{ + if (!platform_size_parse(config_string("display.resolution"), width, height)) + *width = *height = 0; +} + +/* the window's size (display.window_size), else the Xbox's 640x480 times +display.window_scale, as older versions set it */ +static void platform_window_size_setting(long *width, long *height) +{ + long scale = config_integer("display.window_scale"); + + if (!platform_size_parse(config_string("display.window_size"), width, height)) + { + *width = 640 * (scale < 1 ? 1 : scale); + *height = 480 * (scale < 1 ? 1 : scale); + } +} + +/* a display's own size in pixels (its desktop mode) */ +static BOOL platform_display_size(SDL_DisplayID display, long *width, long *height) +{ + const SDL_DisplayMode *mode = display ? SDL_GetDesktopDisplayMode(display) : NULL; + + if (!mode) + return FALSE; + *width = (long)(mode->w * mode->pixel_density + 0.5f); + *height = (long)(mode->h * mode->pixel_density + 0.5f); + return TRUE; +} + /* the window's fullscreen kind (display.mode): borderless, a window over the whole desktop (SDL's fullscreen without a mode), or fullscreen, the -display taken at its desktop resolution. Either draws at the display's -resolution (platform_screen_mode); F11 switches to the kind set. */ +display taken at display.resolution's mode (the one nearest it), else at its +desktop one. F11 switches to the kind set. */ static void platform_fullscreen_kind_apply(void) { static int applied = -1; + static long applied_width, applied_height; int exclusive = platform_display_mode() == _display_mode_fullscreen ? 1 : 0; + long width = 0, height = 0; SDL_DisplayID display; + SDL_DisplayMode closest; + const SDL_DisplayMode *mode = NULL; - if (!platform_window || exclusive == applied) + if (!platform_window) + return; + if (exclusive) + platform_resolution_setting(&width, &height); + if (exclusive == applied && width == applied_width && height == applied_height) return; applied = exclusive; + applied_width = width; + applied_height = height; display = SDL_GetDisplayForWindow(platform_window); - SDL_SetWindowFullscreenMode(platform_window, - exclusive && display ? SDL_GetDesktopDisplayMode(display) : NULL); + if (exclusive && display) + { + if (width && SDL_GetClosestFullscreenDisplayMode(display, (int)width, (int)height, 0.0f, false, &closest)) + mode = &closest; + else + mode = SDL_GetDesktopDisplayMode(display); + } + SDL_SetWindowFullscreenMode(platform_window, mode); } /* whether the game last asked for the window to be fullscreen */ @@ -428,44 +488,197 @@ static BOOL platform_window_fullscreen(void) height >= (int)(mode->h * mode->pixel_density + 0.5f); } -/* whether the game is, or is to be, fullscreen, and if so the size in -pixels of the display it fills (d3d8_gl.c draws at that resolution) */ +/* the size in pixels the game draws its picture at (d3d8_gl.c): the +window's, the display's while fullscreen, or display.resolution's while +fullscreen (either kind) where the display has room for it; before the +window opens, what it will be. FALSE where display.resolution_scaling is +"original": the Xbox's 640x480, scaled to the window. */ BOOL platform_screen_mode(long *width, long *height) { - SDL_DisplayID display; - const SDL_DisplayMode *mode; + /* (the size last given, for a window that has none: minimized) */ + static long last_width, last_height; + long resolution_width, resolution_height; + BOOL fullscreen; - if (platform_window ? !platform_window_fullscreen() : - !platform_fullscreen_setting() || !platform_sdl_initialize()) + if (!strcmp(config_string("display.resolution_scaling"), "original")) + return FALSE; + if (platform_window) { - return FALSE; + int pixel_width = 0, pixel_height = 0; + + SDL_GetWindowSizeInPixels(platform_window, &pixel_width, &pixel_height); + if (pixel_width <= 0 || pixel_height <= 0) + { + *width = last_width; + *height = last_height; + return last_width > 0; + } + *width = pixel_width; + *height = pixel_height; + fullscreen = platform_window_fullscreen(); } - display = platform_window ? SDL_GetDisplayForWindow(platform_window) : SDL_GetPrimaryDisplay(); - mode = display ? SDL_GetDesktopDisplayMode(display) : NULL; - if (!mode) - return FALSE; - *width = (long)(mode->w * mode->pixel_density + 0.5f); - *height = (long)(mode->h * mode->pixel_density + 0.5f); + else + { + if (!platform_sdl_initialize()) + return FALSE; + fullscreen = platform_fullscreen_setting(); + platform_window_size_setting(width, height); + if (fullscreen && !platform_display_size(SDL_GetPrimaryDisplay(), width, height)) + return FALSE; + } + /* (fullscreen's display is at the resolution already, where it has that + mode: platform_fullscreen_kind_apply; borderless's is scaled to) */ + platform_resolution_setting(&resolution_width, &resolution_height); + if (fullscreen && resolution_width && resolution_width <= *width && resolution_height <= *height) + { + *width = resolution_width; + *height = resolution_height; + } + last_width = *width; + last_height = *height; return TRUE; } +/* the size added to the list unless it has it already; the count */ +static int platform_resolution_add(long *widths, long *heights, int count, int maximum, long width, long height) +{ + int index; + + for (index = 0; index < count; index++) + { + if (widths[index] == width && heights[index] == height) + return count; + } + if (count < maximum) + { + widths[count] = width; + heights[count] = height; + count++; + } + return count; +} + +int platform_display_resolutions(long *widths, long *heights, int maximum) +{ + SDL_DisplayID display; + SDL_DisplayMode **modes; + long display_width, display_height, width, height; + int mode_count = 0, count = 0, index; + + if (maximum < 1 || !platform_sdl_initialize()) + return 0; + display = platform_window ? SDL_GetDisplayForWindow(platform_window) : 0; + if (!display) + display = SDL_GetPrimaryDisplay(); + if (!platform_display_size(display, &display_width, &display_height)) + return 0; + modes = SDL_GetFullscreenDisplayModes(display, &mode_count); + for (index = 0; modes && index < mode_count; index++) + { + width = (long)(modes[index]->w * modes[index]->pixel_density + 0.5f); + height = (long)(modes[index]->h * modes[index]->pixel_density + 0.5f); + if (width >= 640 && height >= 480 && width <= display_width && height <= display_height && + (width != display_width || height != display_height)) + { + count = platform_resolution_add(widths, heights, count, maximum, width, height); + } + } + SDL_free(modes); + /* (the one set, though this display has no such mode, so that Video + Setup shows it) */ + platform_resolution_setting(&width, &height); + if (width && (width != display_width || height != display_height)) + count = platform_resolution_add(widths, heights, count, maximum, width, height); + /* largest first */ + for (index = 1; index < count; index++) + { + int place; + + width = widths[index]; + height = heights[index]; + for (place = index; place > 0 && (widths[place - 1] < width || + (widths[place - 1] == width && heights[place - 1] < height)); place--) + { + widths[place] = widths[place - 1]; + heights[place] = heights[place - 1]; + } + widths[place] = width; + heights[place] = height; + } + return count; +} + +/* Video Setup's window sizes, by shape (4:3, 16:10, 16:9, 21:9), each from +the smallest */ +static const short platform_window_sizes_offered[][2] = +{ + { 640, 480 }, { 800, 600 }, { 1024, 768 }, { 1280, 960 }, { 1600, 1200 }, { 1920, 1440 }, { 2560, 1920 }, + { 1280, 800 }, { 1440, 900 }, { 1680, 1050 }, { 1920, 1200 }, { 2560, 1600 }, + { 1280, 720 }, { 1600, 900 }, { 1920, 1080 }, { 2560, 1440 }, { 3840, 2160 }, + { 2560, 1080 }, { 3440, 1440 }, { 3840, 1600 }, { 5120, 2160 }, +}; + +int platform_window_sizes(long *widths, long *heights, int maximum) +{ + SDL_DisplayID display; + SDL_Rect usable; + long width, height; + int count = 0, index; + + if (maximum < 1 || !platform_sdl_initialize()) + return 0; + display = platform_window ? SDL_GetDisplayForWindow(platform_window) : 0; + if (!display) + display = SDL_GetPrimaryDisplay(); + if (!display || !SDL_GetDisplayUsableBounds(display, &usable)) + usable.w = usable.h = 0; + for (index = 0; index < (int)(sizeof(platform_window_sizes_offered) / sizeof(*platform_window_sizes_offered)); + index++) + { + width = platform_window_sizes_offered[index][0]; + height = platform_window_sizes_offered[index][1]; + /* (the Xbox's own whatever the desktop's size) */ + if (!index || (width <= usable.w && height <= usable.h)) + count = platform_resolution_add(widths, heights, count, maximum, width, height); + } + /* (the one set, though it is none of them or too big for this desktop, + so that Video Setup shows it) */ + platform_window_size_setting(&width, &height); + return platform_resolution_add(widths, heights, count, maximum, width, height); +} + +#else +int platform_display_resolutions(long *widths, long *heights, int maximum) +{ + (void)widths; + (void)heights; + (void)maximum; + return 0; +} + +int platform_window_sizes(long *widths, long *heights, int maximum) +{ + (void)widths; + (void)heights; + (void)maximum; + return 0; +} + +#endif +#ifndef HALO_ANDROID +/* the window's size (platform_window_size_setting), as the window was made +or last resized: platform_display_apply */ +static long platform_window_width = -1, platform_window_height = -1; #endif -/* the window's scale (display.window_scale, as the window was made or last -resized: platform_display_apply) */ -static long platform_window_scale = -1; BOOL platform_video_initialize(unsigned long width, unsigned long height) { - int scale = (int)config_integer("display.window_scale"); int version; if (platform_window) return TRUE; if (!platform_sdl_initialize()) return FALSE; - if (scale < 1) - scale = 1; - platform_window_scale = scale; #ifdef HALO_ANDROID SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES); @@ -490,14 +703,23 @@ BOOL platform_video_initialize(unsigned long width, unsigned long height) #endif #ifdef HALO_ANDROID - platform_window = SDL_CreateWindow("Halo", (int)(width * scale), (int)(height * scale), - SDL_WINDOW_OPENGL | SDL_WINDOW_FULLSCREEN); + { + int scale = (int)config_integer("display.window_scale"); + + if (scale < 1) + scale = 1; + platform_window = SDL_CreateWindow("Halo", (int)(width * scale), (int)(height * scale), + SDL_WINDOW_OPENGL | SDL_WINDOW_FULLSCREEN); + } #else - /* fullscreen at the desktop's resolution unless display.fullscreen is - false, where the game draws the display's shape at its resolution - (d3d8_gl.c); the window size is the windowed mode F11 switches to and - from, where it draws 640x480 */ - platform_window = SDL_CreateWindow("Halo", (int)(width * scale), (int)(height * scale), + /* fullscreen (either kind) unless display.mode is the window, which F11 + switches to and from: display.window_size, whatever shape the fullscreen + picture has. The game draws at the size platform_screen_mode gives + (d3d8_gl.c). */ + (void)width; + (void)height; + platform_window_size_setting(&platform_window_width, &platform_window_height); + platform_window = SDL_CreateWindow("Halo", (int)platform_window_width, (int)platform_window_height, SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY | (config_boolean("debug.hidden_window") ? SDL_WINDOW_HIDDEN : 0) | (platform_fullscreen_setting() ? SDL_WINDOW_FULLSCREEN : 0)); @@ -539,23 +761,28 @@ BOOL platform_video_initialize(unsigned long width, unsigned long height) return TRUE; } -/* display.fullscreen, display.window_scale (when it changes: the window can -be resized) and display.vsync, as Settings has written them */ +/* display.mode, display.resolution (fullscreen's display mode), +display.window_size (when it changes: the window can be resized) and +display.vsync, as Settings has written them; display.resolution_scaling +and borderless's resolution are taken up between frames +(halo_screen_commit) */ void platform_display_apply(void) { #ifndef HALO_ANDROID BOOL fullscreen = platform_fullscreen_setting(); - long scale = config_integer("display.window_scale"); + long width, height; if (!platform_window) return; platform_fullscreen_kind_apply(); if (platform_window_fullscreen() != (fullscreen != FALSE)) platform_window_set_fullscreen(fullscreen); - if (scale != platform_window_scale && scale >= 1) + platform_window_size_setting(&width, &height); + if (width != platform_window_width || height != platform_window_height) { - platform_window_scale = scale; - SDL_SetWindowSize(platform_window, (int)(640 * scale), (int)(480 * scale)); + platform_window_width = width; + platform_window_height = height; + SDL_SetWindowSize(platform_window, (int)width, (int)height); } #else if (!platform_window) diff --git a/port/linux/src/sdl_platform.h b/port/linux/src/sdl_platform.h index 6ecd3b36..1c4978be 100644 --- a/port/linux/src/sdl_platform.h +++ b/port/linux/src/sdl_platform.h @@ -62,6 +62,15 @@ BOOL platform_video_initialize(unsigned long width, unsigned long height); #ifndef HALO_ANDROID BOOL platform_screen_mode(long *width, long *height); #endif +/* Video Setup's resolutions (port/linux/game/menu_tags.c), in pixels: the +display's modes the game can draw at, largest first, each once, but the +display's own (Native), and display.resolution's where it is none of them; +how many. None on Android. */ +int platform_display_resolutions(long *widths, long *heights, int maximum); +/* Video Setup's window sizes: those of each shape (4:3, 16:10, 16:9, 21:9) +that fit the desktop, and display.window_size's; how many. None on +Android. */ +int platform_window_sizes(long *widths, long *heights, int maximum); void platform_video_drawable_size(int *width, int *height); /* the window's mode and size and V-Sync, from config.toml as Settings has just written it (the main thread's) */ diff --git a/tools/port_settings.py b/tools/port_settings.py index 36cfe890..d79da606 100644 --- a/tools/port_settings.py +++ b/tools/port_settings.py @@ -29,14 +29,25 @@ SCREENS = { "screen": "video_settings_screen", "header": ("header_profile_video_settings", f"{PE}/video_settings/header_profile_video_settings"), "spacing": 26, + # (rows in the place of the row before them: Window Size in + # Resolution's, port/linux/game/menu_functions.c showing the one the + # display mode chosen uses) + "same_place": ["display.window_size"], "rows": [ ("DISPLAY MODE:", "display.mode", [("FULLSCREEN", "fullscreen"), ("BORDERLESS", "borderless"), ("WINDOWED", "windowed")], - "Fullscreen and borderless draw at the display's\nresolution; windowed, 640x480 scaled. F11: window.", + "Fullscreen takes the display; borderless covers it\nwith a window. F11 switches to the window.", "desktop"), - ("WINDOW SIZE:", "display.window_scale", - [("640 x 480", "1"), ("1280 x 960", "2"), ("1920 x 1440", "3"), ("2560 x 1920", "4")], - "The window's size when windowed (its edges can\nalso be dragged).", "desktop"), + # (port/linux/game/menu_tags.c adds the display's resolutions) + ("RESOLUTION:", "display.resolution", [("NATIVE", "native")], + "What fullscreen and borderless draw at. Fullscreen\nsets the display to it; borderless scales it.", + "desktop"), + # (port/linux/game/menu_tags.c puts the sizes that fit the desktop + # in place of this one) + ("WINDOW SIZE:", "display.window_size", [("1280 x 960", "1280x960")], + "The window's size: 4:3, then 16:10, 16:9 and 21:9\n(its edges can also be dragged).", "desktop"), + ("RESOLUTION SCALING:", "display.resolution_scaling", [("NATIVE", "native"), ("ORIGINAL", "original")], + "Native draws at the resolution; Original draws\nthe Xbox's 640x480 and scales it up.", "desktop"), ("V-SYNC:", "display.vsync", ON_OFF, "Wait for the display between frames, so that the\npicture never tears.", None), ("FRAME RATE LIMIT:", "display.max_fps", @@ -191,8 +202,10 @@ def _screen(folder: str, spec: dict, rows: list, list_inputs: list, list_handler ("height", 60), ("string_list", f"{base}/help_strings"), ("font", "ui\\large_ui"), ("color", "#FFFFFFFF")], []) children = [f'' for name in list_inputs] + list_handlers - for index, (row, platform) in enumerate(rows): - children.append(f'') + # (each row: its widget, platform, and the place it is in, else the next) + for index, (row, platform, *place) in enumerate(rows): + y = 73 + (place[0] if place else index) * spec["spacing"] + children.append(f'') children.append(f'') lines += _widget(f"{base}/options_menu", [("type", "column_list"), ("width", 640), ("height", 480), @@ -211,10 +224,13 @@ def _screen(folder: str, spec: dict, rows: list, list_inputs: list, list_handler def _setting_screen(folder: str, spec: dict) -> list: base = f"{PE}/{folder}" rows, extra = [], [] + place = -1 for index, (label, setting, choices, _, platform) in enumerate(spec["rows"]): key = setting.split(".", 1)[1] row = f"{base}/op_{key}" - rows.append((row, platform)) + if setting not in spec.get("same_place", ()): + place += 1 + rows.append((row, platform, place)) extra += _widget(row, [("width", 512), ("height", 28), ("flags", "pass_unhandled_to_focused_child"), ("bitmap", "bitmaps/option_bkds"), ("color", "#FF2896FF"), ("platform", platform)], [f'', From 8194c5a00b719eb470787cd00ab2496048a76e4d Mon Sep 17 00:00:00 2001 From: xshxdex98 Date: Mon, 5 Oct 2026 00:19:10 -0500 Subject: [PATCH 25/51] Online co-op: play the campaign together over LAN and the internet Hosting a co-op level from the Map screen (CO-OP CAMPAIGN, a level and its difficulty, then Server Setup) makes a network game on the campaign map with no game engine, which is what co-op is. Only the host runs the scripts and the AI; port/linux/game/network_coop.c sends clients what the scripts do (cutscenes, camera, fades, HUD, sounds, devices, named objects, animations), and network_actors.c drives the host's AI units on every machine. Players spawn beside each other, respawn beside a safe teammate, and spectate while dead or waiting. Skipping a cutscene is a vote. Players can join a game in progress at any time. --- port/linux/NETCODE.md | 42 + port/linux/game/coop_spectate.c | 285 ++ port/linux/game/coop_spectate.h | 45 + port/linux/game/menu_functions.c | 67 +- port/linux/game/network_actors.c | 881 ++++++ port/linux/game/network_coop.c | 2704 +++++++++++++++++ port/linux/game/network_coop.h | 96 + port/linux/game/network_damage.c | 22 + port/linux/game/network_distributed.c | 197 +- port/linux/game/network_distributed.h | 105 + port/linux/game/network_objects.c | 406 ++- source/ai/actors.c | 13 +- source/ai/ai.c | 22 +- source/ai/ai.h | 3 + source/camera/director.c | 21 +- source/cutscene/cinematics.c | 2 + source/devices/devices.c | 60 + source/devices/devices.h | 11 + source/effects/player_effects.c | 49 + source/effects/player_effects.h | 15 + source/game/game.c | 15 +- source/game/player_control.c | 122 +- source/game/players.c | 662 +++- source/game/players.h | 16 + source/hs/hs.h | 3 + source/hs/hs_library_external.c | 15 + source/hs/hs_runtime.c | 18 + source/interface/hud.c | 18 +- source/interface/hud_messaging.c | 46 + source/interface/hud_messaging.h | 18 + source/interface/hud_nav_points.c | 9 + source/interface/hud_unit.c | 14 + source/interface/hud_unit.h | 5 + source/interface/hud_weapon.c | 14 + source/interface/hud_weapon.h | 5 + source/interface/ui_widget.c | 11 +- .../ui_widget_event_handler_functions.c | 42 + source/main/main.c | 41 +- source/networking/network_server_manager.c | 65 + source/networking/network_server_manager.h | 9 + source/objects/objects.c | 6 + source/objects/scenery.c | 3 + source/rasterizer/rasterizer_cinematics.c | 81 + source/rasterizer/rasterizer_cinematics.h | 32 + source/saved games/game_state.c | 16 + source/sound/game_sound.c | 15 + source/units/unit_dialogue.c | 12 + source/units/unit_scripting_commands.c | 19 + source/units/units.c | 117 +- source/units/units.h | 25 + tools/linux_build.py | 3 + tools/windows_build.py | 3 + 52 files changed, 6373 insertions(+), 153 deletions(-) create mode 100644 port/linux/game/coop_spectate.c create mode 100644 port/linux/game/coop_spectate.h create mode 100644 port/linux/game/network_actors.c create mode 100644 port/linux/game/network_coop.c create mode 100644 port/linux/game/network_coop.h diff --git a/port/linux/NETCODE.md b/port/linux/NETCODE.md index 2c3913a7..d49fe095 100644 --- a/port/linux/NETCODE.md +++ b/port/linux/NETCODE.md @@ -21,6 +21,37 @@ with ideas from VALORANT's netcode articles, keeping the 30 Hz tick: vehicle it drives) from its local input at once. Remote players are driven by the inputs the host relays every tick, the latest one held until a newer arrives. +- **Actors driven.** Only the host runs the AI. Each tick it sends its + clients the control its actors gave their units (how they move, where + they face, aim and look, their trigger and buttons, their animation + impulses) with the units' state, those near a client's players every + tick and others less often; a client drives each unit with the latest + it has, as it drives a remote player's, until it hears nothing of it for + two seconds (`port/linux/game/network_actors.c`). +- **Co-op.** A network game on a campaign level with no game engine + (Create Game > COOPERATIVE > CAMPAIGN) is co-op. Only the host runs the + level's scripts and spawns players. `network_coop.c` sends the clients + everything the scripts do that they would otherwise miss: + - every tick: the cinematic, camera, screen fade, the HUD settings the + scripts control (what is shown, the mission timer) and the skip vote; + - once each, numbered so nothing is applied twice: script sounds, + chapter titles, help and objective text, "Checkpoint" messages, screen + shake, nav points, and custom animations on units and scenery; + - device groups (doors, elevators, switches; a client sets none itself, + and its player's use of one is relayed to the host); + - which named objects exist, so scripted creates and deletes match. + + Every machine follows the host's structure BSP. A dead player watches a + living teammate (`coop_spectate.c`) and comes back beside one once it is + safe. With everyone dead they come back where they were at the last + checkpoint, since only the host could revert. A level won ends the round + as in multiplayer, and the next round is the campaign's next level. + + Cutscenes are skipped by vote: more than half the machines must press + skip. The host then reverts as single player does, but keeps its clock + moving forward (the netcode depends on that) and moves the script + threads' wake times along with it. The object, device and name syncs + bring the clients up to date. - **Host authoritative.** The host alone decides damage, deaths, spawns, pickups, scores and the game's objects; clients do not decide them but apply what the host sends. @@ -95,6 +126,17 @@ every machine of a player the host dropped for cheating, each client tells the host its Discord user, and a machine's join request carries its hardware id; version 10 sends every player's ping for the scoreboard. +A host never checks a joining client's version: the client reads the +host's from its advertisement and joins only a version it plays with. That +is its own, or one of a range (HALO_PORT_NETWORK_VERSION_MINIMUM to +HALO_PORT_NETWORK_VERSION_MAXIMUM, halo_port_limits.h) of versions that +differ from it only in messages the other machine drops, not knowing them: +version 10 (OpenCE's build-73) adds the host's message of the players' +pings, which a machine of version 9 drops. This build has version 10, as +OpenCE's current builds (so their clients join its hosts), without that +message (its hosts do not send it, its clients drop it), and joins hosts of +9 and 10. The game browser lists the range's games. + A client plays by its host's rules: in another's game (searching for it, in its lobby, or playing it) the developer console, the telnet console and the cheat buttons run only commands that change nothing of the game diff --git a/port/linux/game/coop_spectate.c b/port/linux/game/coop_spectate.c new file mode 100644 index 00000000..0e25a59e --- /dev/null +++ b/port/linux/game/coop_spectate.c @@ -0,0 +1,285 @@ +/* +COOP_SPECTATE.C + +Spectating in network co-op. A dead player watches a living teammate +until they respawn (players.c respawns them beside a teammate once it is +safe). The campaign's dead camera would watch the body; instead this +follows a teammate from behind, and A (jump on the keyboard) switches to +the next one. director.c and hud.c call in here. + +It also draws the line telling players about the vote to skip a cutscene +(network_coop.c counts the votes). +*/ + +#include "cseries.h" +#include "camera/dead_camera.h" +#include "game/game.h" +#include "game/game_engine.h" +#include "game/players.h" +#include "input/input.h" +#include "interface/hud_messaging.h" +#include "objects/objects.h" +#include "rasterizer/rasterizer.h" +#include "render/render.h" +#include "text/draw_string.h" +#include "text/font_group.h" +#include "text/unicode.h" +#include "units/units.h" + +#include "coop_spectate.h" +#include "network_coop.h" + +#include + +/* ---------- constants */ + +/* camera distance behind the unit, pitch (radians), and how much of the +turn toward the unit's facing it makes each frame */ +#define SPECTATE_DISTANCE 3.5f +#define SPECTATE_PITCH -0.3f +#define SPECTATE_TURN 0.15f + +/* a passenger (riding a Pelican) is watched from in front of them, above +and looking down, so the camera stays inside the vehicle; a wide view (the +observer allows up to 90 degrees) takes in all of them from that close */ +#define SPECTATE_PASSENGER_DISTANCE 1.0f +#define SPECTATE_PASSENGER_PITCH -0.5f +#define SPECTATE_PASSENGER_FIELD_OF_VIEW DEGREES_TO_RADIANS(85.0f) +/* field of view for every other spectate view (dead_camera.c's default) */ +#define SPECTATE_FIELD_OF_VIEW DEGREES_TO_RADIANS(70.0f) + +/* a teammate the scripts hold (Pillar of Autumn's cryo tube) is watched from +in front, far enough back to see all of what holds them */ +#define SPECTATE_HELD_DISTANCE 2.0f +#define SPECTATE_HELD_PITCH -0.1f + +/* ---------- globals */ + +/* the player each local player is watching, or NONE */ +static long coop_spectate_watched[MAXIMUM_LOCAL_PLAYERS] = { NONE, NONE, NONE, NONE }; + +/* input_abstraction.c: how many ticks the keyboard's jump key has been held */ +byte input_abstraction_port_accept(short controller_index); + +/* ---------- private code */ + +/* The next living player after `after` (or the first, if `after` is NONE), +skipping `self` and wrapping around. NONE if nobody else is alive. */ +static long next_living_player(long self, long after) +{ + struct data_iterator iterator; + struct player_datum *player; + long first = NONE; + boolean passed = after == NONE; + + data_iterator_new(&iterator, player_data); + while ((player = data_iterator_next(&iterator)) != NULL) + { + if (iterator.datum_index == after) + { + passed = TRUE; + continue; + } + if (iterator.datum_index == self || player->unit_index == NONE) + continue; + if (passed) + return iterator.datum_index; + if (first == NONE) + first = iterator.datum_index; + } + + return first; +} + +/* Returns TRUE once per press of A (or jump on the keyboard), however long +it is held. The buttons count ticks, and one tick can span several frames, +so testing "held" alone would switch more than once. */ +static boolean next_pressed(short controller_index) +{ + static boolean held[MAXIMUM_GAMEPADS]; + struct gamepad_state const *gamepad; + boolean down; + boolean pressed; + + if (controller_index < 0 || controller_index >= MAXIMUM_GAMEPADS) + return FALSE; + gamepad = input_get_gamepad_state(controller_index); + down = (gamepad && gamepad->buttons[FIRST_GAMEPAD_ANALOG_BUTTON + _gamepad_analog_button_a] > 0) || + input_abstraction_port_accept(controller_index) > 0; + pressed = down && !held[controller_index]; + held[controller_index] = down; + return pressed; +} + +/* whether the unit rides in a vehicle it isn't driving */ +static boolean unit_is_passenger( + long unit_index) +{ + long vehicle_index = object_get(unit_index)->object.parent_object_index; + + return vehicle_index != NONE && object_try_and_get_and_verify_type(vehicle_index, _object_mask_unit) && + unit_get(vehicle_index)->unit.driver_object_index != unit_index; +} + +/* ---------- public code */ + +boolean coop_spectating( + void) +{ + return network_coop_active(); +} + +long coop_spectate_unit( + short local_player_index) +{ + long self = local_player_get_player_index(local_player_index); + long *watched = &coop_spectate_watched[local_player_index]; + struct player_datum *player; + + boolean next; + + if (self == NONE) + return NONE; + player = player_get(self); + /* checked every frame so a press is never missed or counted twice */ + next = next_pressed(player->network_player_data.controller_index); + /* alive: keep `watched`, so the next death starts on the same teammate */ + if (player->unit_index != NONE) + return NONE; + if (*watched == NONE || !player_try_and_get(*watched) || player_get(*watched)->unit_index == NONE || next) + { + *watched = next_living_player(self, *watched); + } + + return *watched != NONE ? player_get(*watched)->unit_index : NONE; +} + +boolean coop_spectate_watching_rider( + short local_player_index) +{ + long self = local_player_get_player_index(local_player_index); + long watched = coop_spectate_watched[local_player_index]; + long unit_index; + long driver_index; + + if (self == NONE || player_get(self)->unit_index != NONE) + return FALSE; + /* the scripted camera may have been running since before this player + died, so nobody has been picked to watch yet */ + if (watched == NONE || !player_try_and_get(watched) || player_get(watched)->unit_index == NONE) + watched = next_living_player(self, NONE); + unit_index = watched != NONE ? player_get(watched)->unit_index : NONE; + if (unit_index == NONE || !unit_is_passenger(unit_index)) + return FALSE; + driver_index = unit_get(object_get(unit_index)->object.parent_object_index)->unit.driver_object_index; + return driver_index == NONE || unit_get(driver_index)->unit.player_index == NONE; +} + +boolean coop_spectate_nothing_to_watch( + short local_player_index) +{ + long self = local_player_get_player_index(local_player_index); + + return self != NONE && player_get(self)->unit_index == NONE && next_living_player(self, NONE) == NONE; +} + +void coop_spectate_camera( + struct dead_camera *camera) +{ + struct object_datum *unit = camera->unit_index != NONE ? object_try_and_get(camera->unit_index) : NULL; + real yaw, turn; + + if (!unit) + return; + yaw = (real)atan2(unit->object.forward.j, unit->object.forward.i); + if (unit_is_passenger(camera->unit_index)) + { + /* fixed in front of the seat, turning with the vehicle */ + camera->facing.yaw = yaw + _pi; + camera->facing.pitch = SPECTATE_PASSENGER_PITCH; + camera->distance = SPECTATE_PASSENGER_DISTANCE; + camera->field_of_view = SPECTATE_PASSENGER_FIELD_OF_VIEW; + return; + } + camera->field_of_view = SPECTATE_FIELD_OF_VIEW; + if (!player_input_enabled()) + { + camera->facing.yaw = yaw + _pi; + camera->facing.pitch = SPECTATE_HELD_PITCH; + camera->distance = SPECTATE_HELD_DISTANCE; + return; + } + /* ease the camera round behind the unit */ + turn = yaw - camera->facing.yaw; + while (turn > _pi) + turn -= 2.0f * _pi; + while (turn < -_pi) + turn += 2.0f * _pi; + camera->facing.yaw += turn * SPECTATE_TURN; + camera->facing.pitch = SPECTATE_PITCH; + camera->distance = SPECTATE_DISTANCE; +} + +/* centred text near the bottom of the screen, in the HUD's font */ +static void draw_bottom_text( + wchar_t const *text) +{ + long font_index = hud_get_font_index(); + struct font_header *font; + real_argb_color color = *global_real_argb_white; + rectangle2d bounds; + short height; + + if (font_index == NONE) + return; + font = font_definition_get(font_index); + height = (short)(font->ascending_height + font->descending_height); + bounds = render.camera.window_bounds; + bounds.y0 = (short)(bounds.y1 - 3 * height - 24); + bounds.y1 = (short)(bounds.y1 - 24); + /* draw mode 2: centered */ + draw_string_set_draw_mode(font_index, NONE, 2, 0, &color); + rasterizer_draw_unicode_string(&bounds, NULL, NULL, 0, text); +} + +void coop_spectate_draw( + short local_player_index) +{ + long watched = coop_spectate_watched[local_player_index]; + wchar_t text[96]; + wchar_t const *hint; + + /* nobody to watch yet (still joining): network_coop.c shows the host's + view from behind instead */ + if (watched == NONE || !player_try_and_get(watched)) + { + if (coop_spectate_nothing_to_watch(local_player_index)) + draw_bottom_text(L"JOINING THE GAME\r\nYou spawn beside a teammate once it is safe"); + return; + } + if (!players_coop_waiting_to_start(local_player_get_player_index(local_player_index))) + hint = L"You come back beside them once it is safe"; + else + hint = L"You join them when they're on foot"; + usnprintf(text, NUMBEROF(text) - 1, L"SPECTATING %.12s (A: NEXT)\r\n%s", player_get(watched)->name, hint); + text[NUMBEROF(text) - 1] = 0; + draw_bottom_text(text); +} + +void coop_skip_vote_draw( + short local_player_index) +{ + short votes, voters; + boolean voted; + wchar_t text[96]; + + /* once, for the first local player */ + if (local_player_index != local_player_get_next(NONE) || !network_coop_skip_vote_status(&votes, &voters, &voted)) + return; + if (voted) + usnprintf(text, NUMBEROF(text) - 1, L"VOTED TO SKIP %d OF %d", votes, voters); + else + usnprintf(text, NUMBEROF(text) - 1, L"PRESS SPACE OR A TO VOTE TO SKIP %d OF %d", votes, voters); + text[NUMBEROF(text) - 1] = 0; + draw_bottom_text(text); +} diff --git a/port/linux/game/coop_spectate.h b/port/linux/game/coop_spectate.h new file mode 100644 index 00000000..253fb9e0 --- /dev/null +++ b/port/linux/game/coop_spectate.h @@ -0,0 +1,45 @@ +/* +COOP_SPECTATE.H + +Spectating in network co-op, and the cutscene skip vote's line +(coop_spectate.c). +*/ + +#ifndef __COOP_SPECTATE_H +#define __COOP_SPECTATE_H + +struct dead_camera; + +/* whether this is network co-op: a network game with no game engine (a campaign map) */ +boolean coop_spectating( + void); + +/* the unit a dead local player watches (A switches teammate), or NONE if +the player is alive or nobody else is */ +long coop_spectate_unit( + short local_player_index); + +/* TRUE if the teammate this local player is spectating (while dead or not +yet spawned) rides in an AI-driven vehicle, such as an insertion Pelican. +network_coop.c then shows that seat instead of the scripted camera. */ +boolean coop_spectate_watching_rider( + short local_player_index); + +/* whether a local player has nothing to look at: no unit of its own and +no living teammate to watch (network_coop.c shows it the host's view) */ +boolean coop_spectate_nothing_to_watch( + short local_player_index); + +/* keeps the dead camera behind the watched unit as it turns */ +void coop_spectate_camera( + struct dead_camera *camera); + +/* the HUD line saying who a dead local player is watching */ +void coop_spectate_draw( + short local_player_index); + +/* during a skippable cutscene in network co-op, the vote count and how to vote */ +void coop_skip_vote_draw( + short local_player_index); + +#endif diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index 096e4de2..1ed70710 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -1805,6 +1805,7 @@ typedef char verify_advertised_game_open_offset[offsetof(struct advertised_game, /* the engine's (port) */ short ui_widget_port_multiplayer_maps(char const *const **names, short *last_used); boolean ui_widget_port_multiplayer_map_choose(short level_index); +boolean ui_widget_port_cooperative_level_choose(char const *map_name, short difficulty); short ui_widget_port_gametypes(long *indices, short maximum, short *last_used); boolean ui_widget_port_gametype_choose(long profile_index); boolean ui_widget_port_host(struct widget_instance *widget, struct event_record *event, boolean *widget_deleted); @@ -2027,9 +2028,9 @@ first offer CO-OP CAMPAIGN or MULTIPLAYER, then that kind's categories, then the levels (and a level's difficulty) or the maps: each a step that B goes back from. Split screen starts at the multiplayer categories. -CUSTOM CAMPAIGN and CUSTOM MAPS list Custom Edition maps, and a co-op level -is hosted as a network co-op game. This build has neither yet: those lists -are empty, and a co-op level cannot be hosted (cooperative_level_host). */ +A co-op level is hosted as a network co-op game, and goes to Server Setup. +CUSTOM CAMPAIGN and CUSTOM MAPS list Custom Edition maps, which this build +has none of yet, so those lists are empty. */ enum { @@ -2060,14 +2061,7 @@ static struct short category, level; } map_list; -/* hosts the campaign level `level_name` as a co-op game at `difficulty`; -FALSE when it cannot be */ -static boolean cooperative_level_host(char const *level_name, short difficulty) -{ - (void)level_name; - (void)difficulty; - return FALSE; -} +#define SERVER_SETUP_NAME "pc\\main_menu\\multiplayer_type_select\\server_settings\\server_settings_screen" static short map_step_count(void) { @@ -2173,7 +2167,7 @@ static void map_list_update(struct widget_instance *list) /* "mp level select" (the list's OK): the next step, or the map or level chosen. FALSE stays on the Map screen (the gametypes open on TRUE). */ -static boolean map_list_choose(struct widget_instance *list) +static boolean map_list_choose(struct widget_instance *list, boolean *widget_deleted) { short chosen = map_list.chosen; @@ -2202,9 +2196,10 @@ static boolean map_list_choose(struct widget_instance *list) map_step_open(list, MAP_STEP_DIFFICULTIES, (short)PIN(main_get_difficulty(), 0, NUMBER_OF_GAME_DIFFICULTY_LEVELS - 1)); return FALSE; case MAP_STEP_DIFFICULTIES: - if (!cooperative_level_host(main_get_solo_level_name(map_list.level), chosen)) + /* the co-op game set up, then Server Setup in the gametypes' place */ + if (!ui_widget_port_cooperative_level_choose(main_get_solo_level_name(map_list.level), chosen)) return campaign_fail(); - return TRUE; + return ui_widget_port_open(list, SERVER_SETUP_NAME, widget_deleted); default: if (chosen >= map_list.count) return campaign_fail(); @@ -2408,6 +2403,28 @@ static void game_name_done(char const *text) multiplayer.game_name[index] = 0; } +/* Whether a network game is co-op: a campaign level with no game engine +(set up by ui_widget_port_cooperative_level_choose). */ +static boolean game_cooperative(struct network_game const *game) +{ + return game && !game->variant.game_engine_index && main_get_solo_level_from_name(game->map.name) != NONE; +} + +/* the same, for the game this machine is hosting */ +static boolean hosting_cooperative(void) +{ + void *server = global_network_game_server_get(); + + return server && game_cooperative(network_game_server_get_game(server)); +} + +/* Server Setup's gametype rows, which co-op hides */ +static char const *const server_settings_gametype_rows[] = +{ + "op_game_type", "op_player_options", "op_item_options", "op_vehicle_options", "op_indicator_options", + "op_team_options", +}; + /* "server settings init": the game's name (player 1's, else the one given last), the most players, the gametype's copy (once: the screen is made again on coming back from an option's screen) */ @@ -2415,7 +2432,12 @@ static boolean server_settings_initialize(struct widget_instance *list) { struct widget_instance *spinner = named(list, "max_players_spinner", 0); - gametype_setup_begin(); + /* co-op has no gametype to edit, and allows the most players unless set + lower */ + if (hosting_cooperative()) + multiplayer.maximum_players_index = NUMBEROF(maximum_players) - 1; + else + gametype_setup_begin(); if (!multiplayer.game_name[0] && player_ui_get_active_player_profile_index(0) != NONE) { struct player_profile profile; @@ -2467,6 +2489,13 @@ static void server_settings_update(struct widget_instance *list) text_set(named(list, "game_type_value", 0), type); } settings_help(list); + { + boolean cooperative = hosting_cooperative(); + short row; + + for (row = 0; row < NUMBEROF(server_settings_gametype_rows); row++) + visible_set(named(list, server_settings_gametype_rows[row], 0), !cooperative); + } /* LISTING (an internet game's): PUBLIC, listed in everyone's server browser, or PRIVATE, for this game. Its help is its choice's */ visible_set(named(list, "op_listing", 0), multiplayer.mode == _multiplayer_mode_host_internet && @@ -2517,8 +2546,10 @@ static boolean server_start(void) text_field_end(TRUE); network_game_server_port_set_settings(multiplayer.game_name, maximum_players[PIN(multiplayer.maximum_players_index, 0, NUMBEROF(maximum_players) - 1)]); - /* (the gametype as Server Setup's options left it) */ - if (!gametype_setup_apply()) + /* the gametype as Server Setup's options left it (co-op keeps its own) */ + if (hosting_cooperative()) + gametype_setup_end(); + else if (!gametype_setup_apply()) return campaign_fail(); return global_network_game_server_get() != NULL; } @@ -4540,7 +4571,7 @@ boolean pc_menu_event_function_invoke( } else if (!strcmp(name, "mp level select")) { - return map_list_choose(widget); + return map_list_choose(widget, widget_deleted); } else if (!strcmp(name, "port map list back")) { diff --git a/port/linux/game/network_actors.c b/port/linux/game/network_actors.c new file mode 100644 index 00000000..2b5260a4 --- /dev/null +++ b/port/linux/game/network_actors.c @@ -0,0 +1,881 @@ +/* +NETWORK_ACTORS.C + +AI sync (port/linux/NETCODE.md). + +Only the host runs the AI (game.c skips ai_update on clients). An actor +drives its unit the way a player does, through unit_control. The clients +have the units (network_objects.c); this file makes them move like the +host's. + +Each tick the host records the control every actor gave its unit (a hook +in unit_control, and the bursts it holds the trigger for) and sends it to +each client with the unit's state: position, seat, shields and health, +camouflage, and whether it flees in a panic. Units near a client's players +are sent every tick, others less often, but a unit starting a one-tick +action (a jump, a grenade) goes to everyone that tick. Each tick a client +feeds the latest control into unit_control, the same path that drives a +remote player's unit, so the unit walks, aims, fires and throws as it does +on the host. The state corrects any drift. + +What the AI starts once is numbered, so a client plays each once however +many times it receives it: animation impulses (dives, vaults), melee +attacks and leaps (which the AI starts directly, not through the control), +speech, and custom animations (outside co-op, whose events carry them). +Pain and death sounds aren't sent: a client plays those itself as it +replays the host's damage. + +A client creates no actors of its own (actors.c): the host's are the only +ones. Deaths need nothing here: network_damage.c kills a client's copy of +an AI unit the same way it kills a player's. +*/ + +/* ---------- headers */ + +#include "cseries.h" +#include "game/game.h" +#include "networking/network_game_globals.h" +#include "objects/objects.h" +#include "objects/damage.h" +#include "sound/game_sound.h" +#include "sound/sound_definitions.h" +#include "units/units.h" +#include "units/unit_control_data.h" +#include "network_coop.h" +#include "network_distributed.h" + +#include + +/* ---------- constants */ + +enum +{ + /* AI units tracked at once: every actor (actors.h allows 256) and the + units the cutscenes' recorded animations drive */ + MAXIMUM_NETWORK_ACTORS = 288, + MAXIMUM_ENTRIES_PER_MESSAGE = 64, + /* A client keeps applying a control for this long after the host last + sent one. After that the unit is left alone: the actor let go of it, or + the host went quiet. */ + CONTROL_HELD_TICKS = 2 * TICKS_PER_SECOND, + /* an impulse is repeated in this many entries, in case one is lost */ + IMPULSE_REPEAT_TICKS = 3, + /* the number of unit_start_animation_impulse impulses (private to units.c) */ + NUMBER_OF_UNIT_ANIMATION_IMPULSES = 14, + NO_IMPULSE = 0xFF, + + /* An entry's impulse is one of the animation impulses or, past them, an + action the AI starts directly: a melee attack (unit_melee_attack_begin) + or a leap (unit_leap_begin). */ + _actor_action_melee = NUMBER_OF_UNIT_ANIMATION_IMPULSES, + _actor_action_leap, + NUMBER_OF_ACTOR_ACTIONS, + + /* control flags a unit acts on in one tick: a unit that sets one goes to + every client that tick, however far away, so the action isn't missed */ + ONE_SHOT_CONTROL_FLAGS = FLAG(_unit_control_jump_bit) | FLAG(_unit_control_action_bit) | + FLAG(_unit_control_weapon_reload_bit) | FLAG(_unit_control_throw_grenade_bit) | + FLAG(_unit_control_swap_weapons_bit), +}; + +/* struct distributed_actor_state flags */ +enum +{ + _distributed_actor_rides_bit = 0, + _distributed_actor_shield_depleted_bit, + _distributed_actor_shield_charging_bit, + _distributed_actor_shield_over_charging_bit, + _distributed_actor_camouflaged_bit, + _distributed_actor_super_camouflaged_bit, + /* the impulse turns the unit to impulse_alignment */ + _distributed_actor_impulse_aligned_bit, + /* fleeing in a panic, toward run_blindly_angle (unit_start_running_blindly) */ + _distributed_actor_running_blindly_bit, +}; + +/* facing, aiming and looking vectors are sent as yaw and pitch */ +enum +{ + _angle_yaw, + _angle_pitch, + NUMBER_OF_ANGLES +}; + +/* ---------- structures */ + +/* an AI unit's control and state at the end of the host's tick (like +struct distributed_unit_state for players) */ +struct distributed_actor_state +{ + long unit_index; + long vehicle_index; + short seat_index; + byte flags; + /* the impulse's sequence number; impulse is NO_IMPULSE when there is none */ + byte impulse_number; + byte impulse; + byte animation_state; + byte aiming_speed; + byte primary_trigger; + word control_flags; + signed char throttle[3]; + byte active_camouflage; + /* the speech's sequence number; 0 when there is none */ + byte speech_number; + byte pad; + short facing[NUMBER_OF_ANGLES]; + short aiming[NUMBER_OF_ANGLES]; + short looking[NUMBER_OF_ANGLES]; + short impulse_alignment; + short run_blindly_angle; + word body_vitality; + word shield_vitality; + /* the sound the unit started saying (a sound tag) */ + long speech_sound; + /* the custom animation it started (unit_start_user_animation), and its + sequence number; the graph is NONE when there is none */ + long user_animation_graph; + short user_animation; + byte user_animation_number; + byte user_animation_interpolate; + real_point3d position; + struct distributed_vector velocity; + struct distributed_vector forward; + struct distributed_vector up; +}; + +/* the damage an AI unit is taking: its shield's flare and its body's wounds +are drawn by it */ +struct distributed_actor_damage +{ + long unit_index; + word current_shield_damage; + word recent_shield_damage; + word current_body_damage; + word recent_body_damage; +}; + +struct distributed_actor_damage_message +{ + struct distributed_message_header header; + struct distributed_actor_damage units[MAXIMUM_ENTRIES_PER_MESSAGE]; +}; + +struct distributed_actor_state_message +{ + struct distributed_message_header header; + struct distributed_actor_state states[MAXIMUM_ENTRIES_PER_MESSAGE]; +}; + +/* host: what an actor gave its unit this tick */ +struct host_actor +{ + long unit_index; + /* got a control since the last send (noted during game_tick, sent after it) */ + boolean noted; + /* its damage was sent last tick (and once more when it ends) */ + boolean damaged; + struct unit_control_data control; + /* the control flags of the last tick's entry */ + word sent_control_flags; + /* the latest impulse, how many more entries carry it, and its number */ + short impulse; + short impulse_sends; + byte impulse_number; + boolean impulse_aligned; + real_vector2d impulse_alignment; + /* the latest speech, how many more entries carry it, and its number */ + long speech_sound; + short speech_sends; + byte speech_number; + /* the latest custom animation, the same way */ + long user_animation_graph; + short user_animation; + boolean user_animation_interpolate; + short user_animation_sends; + byte user_animation_number; +}; + +/* client: the latest the host sent about an AI unit */ +struct client_actor +{ + long unit_index; + long received_time; + struct unit_control_data control; + /* the impulse to play, and the number of the last one played */ + short impulse; + byte impulse_number; + byte played_impulse_number; + boolean impulse_aligned; + real_vector2d impulse_alignment; + /* the speech to play (NONE when there is none), and the number of the last played */ + long speech_sound; + byte speech_number; + byte played_speech_number; + /* the custom animation to play (graph NONE when there is none), and the + number of the last played */ + long user_animation_graph; + short user_animation; + boolean user_animation_interpolate; + byte user_animation_number; + byte played_user_animation_number; + boolean driven; +}; + +/* ---------- globals */ + +static struct host_actor host_actors[MAXIMUM_NETWORK_ACTORS]; +static short host_actor_count; +/* host: the numbers of every unit's impulses, speech and animations, from +one counter, so a unit that drops out of the table and comes back can't +repeat the number a client last played */ +static byte host_event_number; + +static struct client_actor client_actors[MAXIMUM_NETWORK_ACTORS]; +static short client_actor_count; + +/* ---------- private code */ + +static void angles_pack( + real_vector3d const *vector, + short *angles) +{ + angles[_angle_yaw] = distributed_angle_pack((real)atan2(vector->j, vector->i)); + angles[_angle_pitch] = distributed_angle_pack((real)asin(PIN(vector->k, -1.0f, 1.0f))); +} + +static void angles_unpack( + short const *angles, + real_vector3d *vector) +{ + real yaw = distributed_angle_unpack(angles[_angle_yaw], FALSE); + real pitch = distributed_angle_unpack(angles[_angle_pitch], TRUE); + + vector->i = (real)(cos(pitch) * cos(yaw)); + vector->j = (real)(cos(pitch) * sin(yaw)); + vector->k = (real)sin(pitch); +} + +/* host: the next event's number (never 0, which a client's new entry has +as played) */ +static byte next_event_number( + void) +{ + if (++host_event_number == 0) + host_event_number = 1; + return host_event_number; +} + +/* the unit's entry, adding one if needed; NULL if the table is full */ +static struct host_actor *host_actor_for( + long unit_index) +{ + short index; + + for (index = 0; index < host_actor_count; index++) + { + if (host_actors[index].unit_index == unit_index) + return &host_actors[index]; + } + if (host_actor_count >= MAXIMUM_NETWORK_ACTORS) + return NULL; + csmemset(&host_actors[host_actor_count], 0, sizeof(host_actors[0])); + host_actors[host_actor_count].unit_index = unit_index; + host_actors[host_actor_count].impulse = NONE; + host_actors[host_actor_count].speech_sound = NONE; + host_actors[host_actor_count].user_animation_graph = NONE; + return &host_actors[host_actor_count++]; +} + +static struct client_actor *client_actor_for( + long unit_index) +{ + short index; + + for (index = 0; index < client_actor_count; index++) + { + if (client_actors[index].unit_index == unit_index) + return &client_actors[index]; + } + if (client_actor_count >= MAXIMUM_NETWORK_ACTORS) + return NULL; + csmemset(&client_actors[client_actor_count], 0, sizeof(client_actors[0])); + client_actors[client_actor_count].unit_index = unit_index; + client_actors[client_actor_count].impulse = NONE; + client_actors[client_actor_count].speech_sound = NONE; + client_actors[client_actor_count].user_animation_graph = NONE; + return &client_actors[client_actor_count++]; +} + +/* whether the unit exists, is alive, and isn't a player's */ +static boolean actor_unit_valid( + long unit_index) +{ + struct unit_datum *unit; + + if (!object_try_and_get_and_verify_type(unit_index, _object_mask_unit)) + return FALSE; + unit = unit_get(unit_index); + return !TEST_FLAG(unit->object.damage_flags, _object_dead_bit) && unit->unit.player_index == NONE; +} + +/* host: builds the entry to send for an AI unit */ +static void actor_state_from_unit( + struct host_actor const *actor, + struct distributed_actor_state *state) +{ + struct unit_datum *unit = unit_get(actor->unit_index); + struct damage_network_state damage; + real camouflage = PIN(unit->unit.active_camouflage, 0.0f, 1.0f); + + csmemset(state, 0, sizeof(*state)); + state->unit_index = actor->unit_index; + state->vehicle_index = NONE; + state->seat_index = NONE; + if (unit->object.parent_object_index != NONE && unit->unit.parent_seat_index != NONE) + { + SET_FLAG(state->flags, _distributed_actor_rides_bit, TRUE); + state->vehicle_index = unit->object.parent_object_index; + state->seat_index = unit->unit.parent_seat_index; + } + state->animation_state = (byte)actor->control.animation_state; + state->aiming_speed = (byte)actor->control.aiming_speed; + state->primary_trigger = (byte)(long)floor(PIN(actor->control.primary_trigger, 0.0f, 1.0f) * 255.0f + 0.5f); + state->control_flags = actor->control.control_flags; + /* for a burst the AI holds the trigger for (unit_persistent_control), + send the flags and trigger the unit's update produced this tick */ + if (unit->unit.persistent_control_timer > 0) + { + state->control_flags = (word)(unit->unit.control_flags & (FLAG(NUMBER_OF_UNIT_CONTROL_FLAGS) - 1)); + state->primary_trigger = (byte)(long)floor(PIN(unit->unit.primary_trigger, 0.0f, 1.0f) * 255.0f + 0.5f); + } + state->throttle[0] = (signed char)(long)floor(PIN(actor->control.throttle.i, -1.0f, 1.0f) * 127.0f + 0.5f); + state->throttle[1] = (signed char)(long)floor(PIN(actor->control.throttle.j, -1.0f, 1.0f) * 127.0f + 0.5f); + state->throttle[2] = (signed char)(long)floor(PIN(actor->control.throttle.k, -1.0f, 1.0f) * 127.0f + 0.5f); + angles_pack(&actor->control.facing_vector, state->facing); + angles_pack(&actor->control.aiming_vector, state->aiming); + angles_pack(&actor->control.looking_vector, state->looking); + state->impulse = NO_IMPULSE; + if (actor->impulse != NONE && actor->impulse_sends > 0) + { + state->impulse = (byte)actor->impulse; + state->impulse_number = actor->impulse_number; + SET_FLAG(state->flags, _distributed_actor_impulse_aligned_bit, actor->impulse_aligned); + if (actor->impulse_aligned) + { + state->impulse_alignment = distributed_angle_pack( + (real)atan2(actor->impulse_alignment.j, actor->impulse_alignment.i)); + } + } + state->speech_sound = NONE; + if (actor->speech_sound != NONE && actor->speech_sends > 0) + { + state->speech_sound = actor->speech_sound; + state->speech_number = actor->speech_number; + } + state->user_animation_graph = NONE; + if (actor->user_animation_graph != NONE && actor->user_animation_sends > 0) + { + state->user_animation_graph = actor->user_animation_graph; + state->user_animation = actor->user_animation; + state->user_animation_interpolate = (byte)actor->user_animation_interpolate; + state->user_animation_number = actor->user_animation_number; + } + if (TEST_FLAG(unit->unit.flags, _unit_running_blindly_bit)) + { + SET_FLAG(state->flags, _distributed_actor_running_blindly_bit, TRUE); + state->run_blindly_angle = distributed_angle_pack(unit->unit.run_blindly_angle); + } + damage_get_network_state(actor->unit_index, &damage); + SET_FLAG(state->flags, _distributed_actor_shield_depleted_bit, damage.shield_depleted); + SET_FLAG(state->flags, _distributed_actor_shield_charging_bit, damage.shield_charging); + SET_FLAG(state->flags, _distributed_actor_shield_over_charging_bit, damage.shield_over_charging); + state->body_vitality = distributed_vitality_pack(damage.body_vitality); + state->shield_vitality = distributed_vitality_pack(damage.shield_vitality); + SET_FLAG(state->flags, _distributed_actor_camouflaged_bit, + TEST_FLAG(unit->unit.flags, _unit_active_camouflaged_bit)); + SET_FLAG(state->flags, _distributed_actor_super_camouflaged_bit, + TEST_FLAG(unit->unit.flags, _unit_super_camouflaged_bit)); + state->active_camouflage = (byte)(long)floor(camouflage * 255.0f + 0.5f); + state->position = unit->object.position; + distributed_vector_pack(&unit->object.translational_velocity, DISTRIBUTED_VELOCITY_SCALE, &state->velocity); + distributed_vector_pack(&unit->object.forward, DISTRIBUTED_UNIT_SCALE, &state->forward); + distributed_vector_pack(&unit->object.up, DISTRIBUTED_UNIT_SCALE, &state->up); +} + +/* Client: stores the entry's control and applies its state to the unit. +Returns FALSE for an entry with invalid values. */ +static boolean actor_state_apply( + struct distributed_actor_state const *state, + long now) +{ + struct client_actor *actor; + struct unit_datum *unit; + struct damage_network_state damage; + real_vector3d velocity; + real_vector3d forward; + real_vector3d up; + + if (!distributed_object_index_valid(state->unit_index) || !network_objects_client_has(state->unit_index) || + !actor_unit_valid(state->unit_index) || + state->animation_state >= NUMBER_OF_UNIT_ANIMATION_STATES || + state->aiming_speed >= NUMBER_OF_UNIT_AIMING_SPEEDS || + !VALID_FLAGS(state->control_flags, NUMBER_OF_UNIT_CONTROL_FLAGS) || + (state->impulse != NO_IMPULSE && state->impulse >= NUMBER_OF_ACTOR_ACTIONS) || + (state->speech_sound != NONE && !distributed_tag_of_group(state->speech_sound, SOUND_DEFINITION_TAG)) || + (state->user_animation_graph != NONE && + !distributed_graph_animation(state->user_animation_graph, state->user_animation)) || + !distributed_point_valid(&state->position, UNIT_WORLD_BOUND)) + { + return FALSE; + } + distributed_vector_unpack(&state->velocity, DISTRIBUTED_VELOCITY_SCALE, &velocity); + distributed_vector_unpack(&state->forward, DISTRIBUTED_UNIT_SCALE, &forward); + distributed_vector_unpack(&state->up, DISTRIBUTED_UNIT_SCALE, &up); + if (!distributed_axes_make_valid(&forward, &up)) + return FALSE; + actor = client_actor_for(state->unit_index); + if (!actor) + return TRUE; + + /* the control, applied every tick until the next entry */ + actor->received_time = now; + actor->control.animation_state = (char)state->animation_state; + actor->control.aiming_speed = (char)state->aiming_speed; + actor->control.control_flags = state->control_flags; + actor->control.weapon_index = NONE; + actor->control.grenade_index = NONE; + actor->control.zoom_level = NONE; + actor->control.throttle.i = (real)state->throttle[0] / 127.0f; + actor->control.throttle.j = (real)state->throttle[1] / 127.0f; + actor->control.throttle.k = (real)state->throttle[2] / 127.0f; + actor->control.primary_trigger = (real)state->primary_trigger / 255.0f; + angles_unpack(state->facing, &actor->control.facing_vector); + angles_unpack(state->aiming, &actor->control.aiming_vector); + angles_unpack(state->looking, &actor->control.looking_vector); + if (state->impulse != NO_IMPULSE && state->impulse_number != actor->played_impulse_number) + { + real yaw = distributed_angle_unpack(state->impulse_alignment, FALSE); + + actor->impulse = state->impulse; + actor->impulse_number = state->impulse_number; + actor->impulse_aligned = TEST_FLAG(state->flags, _distributed_actor_impulse_aligned_bit); + actor->impulse_alignment.i = (real)cos(yaw); + actor->impulse_alignment.j = (real)sin(yaw); + } + + if (state->speech_sound != NONE && state->speech_number != actor->played_speech_number) + { + actor->speech_sound = state->speech_sound; + actor->speech_number = state->speech_number; + } + if (state->user_animation_graph != NONE && state->user_animation_number != actor->played_user_animation_number) + { + actor->user_animation_graph = state->user_animation_graph; + actor->user_animation = state->user_animation; + actor->user_animation_interpolate = state->user_animation_interpolate != 0; + actor->user_animation_number = state->user_animation_number; + } + + /* the state: seat, shields and health, camouflage, running blindly, and + position (a unit in a vehicle goes where the vehicle goes) */ + unit = unit_get(state->unit_index); + if (TEST_FLAG(state->flags, _distributed_actor_rides_bit)) + { + if (unit->object.parent_object_index != state->vehicle_index || + unit->unit.parent_seat_index != state->seat_index) + { + network_objects_set_seat(state->unit_index, state->vehicle_index, state->seat_index); + } + } + else if (unit->object.parent_object_index != NONE) + { + network_objects_set_seat(state->unit_index, NONE, NONE); + } + damage_get_network_state(state->unit_index, &damage); + damage.shield_depleted = TEST_FLAG(state->flags, _distributed_actor_shield_depleted_bit); + damage.shield_charging = TEST_FLAG(state->flags, _distributed_actor_shield_charging_bit); + damage.shield_over_charging = TEST_FLAG(state->flags, _distributed_actor_shield_over_charging_bit); + damage.body_vitality = distributed_vitality_unpack(state->body_vitality); + damage.shield_vitality = distributed_vitality_unpack(state->shield_vitality); + damage_set_network_state(state->unit_index, &damage); + SET_FLAG(unit->unit.flags, _unit_active_camouflaged_bit, + TEST_FLAG(state->flags, _distributed_actor_camouflaged_bit)); + SET_FLAG(unit->unit.flags, _unit_super_camouflaged_bit, + TEST_FLAG(state->flags, _distributed_actor_super_camouflaged_bit)); + unit->unit.active_camouflage = (real)state->active_camouflage / 255.0f; + SET_FLAG(unit->unit.flags, _unit_running_blindly_bit, + TEST_FLAG(state->flags, _distributed_actor_running_blindly_bit)); + if (TEST_FLAG(state->flags, _distributed_actor_running_blindly_bit)) + unit->unit.run_blindly_angle = distributed_angle_unpack(state->run_blindly_angle, FALSE); + /* vehicles are placed only by the host's object states (network_objects.c) */ + if (unit->object.parent_object_index == NONE && unit->object.type != _object_type_vehicle) + { + real dx = state->position.x - unit->object.position.x; + real dy = state->position.y - unit->object.position.y; + real dz = state->position.z - unit->object.position.z; + + if (dx * dx + dy * dy + dz * dz > REMOTE_CORRECTION_TOLERANCE * REMOTE_CORRECTION_TOLERANCE && + network_objects_reconcile(state->unit_index, &state->position, &forward, &up, &velocity, NULL, + REMOTE_BLEND_DISTANCE)) + { + distributed_count_correction(); + } + } + return TRUE; +} + +/* Client: plays a sound at the unit's head, as unit_dialogue_update starts +a unit's speech. */ +static void speech_play( + long unit_index, + long sound_definition_index) +{ + struct object_marker marker; + real_point3d position = *global_origin3d; + real_vector3d forward = *global_forward3d; + short node_index = 0; + + if (object_get_marker_by_name(unit_index, "head", &marker, 1)) + { + node_index = marker.node_index; + position = marker.node_matrix.position; + forward = marker.node_matrix.forward; + } + object_impulse_sound_new(unit_index, sound_definition_index, node_index, &position, &forward, 1.0f); +} + +/* ---------- public code */ + +void network_actors_new_game( + void) +{ + host_actor_count = 0; + client_actor_count = 0; +} + +/* host: unit_control calls this when an actor drives its unit */ +void network_actors_note_control( + long unit_index, + struct unit_control_data const *control_data) +{ + struct host_actor *actor; + + if (game_connection() != _game_connection_network_server) + return; + actor = host_actor_for(unit_index); + if (!actor) + return; + actor->noted = TRUE; + actor->control = *control_data; +} + +/* host: an impulse or action for an AI unit, sent in the next few entries */ +static void note_action( + long unit_index, + short action, + real_vector2d const *alignment_vector) +{ + struct host_actor *actor; + + if (game_connection() != _game_connection_network_server) + return; + actor = host_actor_for(unit_index); + if (!actor) + return; + actor->impulse = action; + actor->impulse_sends = IMPULSE_REPEAT_TICKS; + actor->impulse_number = next_event_number(); + actor->impulse_aligned = alignment_vector != NULL; + if (alignment_vector) + actor->impulse_alignment = *alignment_vector; +} + +/* host: unit_start_animation_impulse calls this for an AI unit */ +void network_actors_note_impulse( + long unit_index, + short animation_impulse, + real_vector2d const *alignment_vector) +{ + note_action(unit_index, animation_impulse, alignment_vector); +} + +/* host: unit_melee_attack_begin calls this when an AI unit starts a melee attack */ +void network_actors_note_melee( + long unit_index, + real_vector2d const *alignment_vector) +{ + note_action(unit_index, _actor_action_melee, alignment_vector); +} + +/* host: unit_leap_begin calls this when an AI unit leaps */ +void network_actors_note_leap( + long unit_index, + real_vector2d const *alignment_vector) +{ + note_action(unit_index, _actor_action_leap, alignment_vector); +} + +/* host: unit_dialogue_update calls this when an AI unit starts saying +something (not a pain or death sound, which clients play themselves) */ +void network_actors_note_speech( + long unit_index, + long sound_definition_index) +{ + struct host_actor *actor; + + if (game_connection() != _game_connection_network_server) + return; + actor = host_actor_for(unit_index); + if (!actor) + return; + actor->speech_sound = sound_definition_index; + actor->speech_sends = IMPULSE_REPEAT_TICKS; + actor->speech_number = next_event_number(); +} + +/* host: unit_start_user_animation calls this when an AI unit starts a +custom animation (co-op's events carry them in co-op) */ +void network_actors_note_user_animation( + long unit_index, + long animation_graph_index, + short animation_index, + boolean interpolate) +{ + struct host_actor *actor; + + if (game_connection() != _game_connection_network_server || network_coop_active()) + return; + actor = host_actor_for(unit_index); + if (!actor) + return; + actor->user_animation_graph = animation_graph_index; + actor->user_animation = animation_index; + actor->user_animation_interpolate = interpolate; + actor->user_animation_sends = IMPULSE_REPEAT_TICKS; + actor->user_animation_number = next_event_number(); +} + +/* host: the damage of the AI units taking some, to every client (and once +more when it ends, so a flare goes) */ +static void host_send_damage( + void) +{ + struct distributed_actor_damage_message message; + short count = 0; + short index; + + for (index = 0; index < host_actor_count; index++) + { + struct host_actor *actor = &host_actors[index]; + struct damage_network_state damage; + boolean damaged; + + if (!actor_unit_valid(actor->unit_index)) + continue; + damage_get_network_state(actor->unit_index, &damage); + damaged = damage.current_shield_damage > 0.0f || damage.recent_shield_damage > 0.0f || + damage.current_body_damage > 0.0f || damage.recent_body_damage > 0.0f; + if (!damaged && !actor->damaged) + continue; + actor->damaged = damaged; + message.units[count].unit_index = actor->unit_index; + message.units[count].current_shield_damage = distributed_vitality_pack(damage.current_shield_damage); + message.units[count].recent_shield_damage = distributed_vitality_pack(damage.recent_shield_damage); + message.units[count].current_body_damage = distributed_vitality_pack(damage.current_body_damage); + message.units[count].recent_body_damage = distributed_vitality_pack(damage.recent_body_damage); + if (++count == MAXIMUM_ENTRIES_PER_MESSAGE) + { + distributed_send(&message, _distributed_message_actor_damage, count, + (word)(sizeof(message.header) + count * sizeof(message.units[0])), _distributed_to_clients); + count = 0; + } + } + if (count) + { + distributed_send(&message, _distributed_message_actor_damage, count, + (word)(sizeof(message.header) + count * sizeof(message.units[0])), _distributed_to_clients); + } +} + +/* Host, after each tick: sends each client the AI units driven this tick. +Units near the client's players go every tick, others less often. A unit +with a fresh impulse, action, speech or animation goes to everyone in each +of the next IMPULSE_REPEAT_TICKS ticks, and one starting a one-tick action +goes that tick. A unit no actor drove this tick is dropped from the table, +unless it is fleeing: a unit running blindly moves by itself, without its +actor's control, and dropped it stood running in place on the clients. */ +void network_actors_host_tick( + void) +{ + static struct distributed_actor_state states[MAXIMUM_NETWORK_ACTORS]; + /* sent to every client this tick, whatever its period */ + static boolean urgent[MAXIMUM_NETWORK_ACTORS]; + struct distributed_actor_state_message message; + long machine_indices[HALO_PORT_MAXIMUM_NETWORK_MACHINES]; + short machine_count = distributed_client_machines(machine_indices, HALO_PORT_MAXIMUM_NETWORK_MACHINES); + long now = game_time_get(); + short state_count = 0; + short index; + short machine_number; + + for (index = 0; index < host_actor_count; index++) + { + struct host_actor *actor = &host_actors[index]; + + if (!actor_unit_valid(actor->unit_index) || + (!actor->noted && !TEST_FLAG(unit_get(actor->unit_index)->unit.flags, _unit_running_blindly_bit))) + { + host_actors[index--] = host_actors[--host_actor_count]; + continue; + } + actor_state_from_unit(actor, &states[state_count]); + urgent[state_count] = states[state_count].impulse != NO_IMPULSE || states[state_count].speech_sound != NONE || + states[state_count].user_animation_graph != NONE || + (states[state_count].control_flags & ~actor->sent_control_flags & ONE_SHOT_CONTROL_FLAGS) != 0; + actor->sent_control_flags = states[state_count].control_flags; + state_count++; + actor->noted = FALSE; + if (actor->impulse_sends > 0) + actor->impulse_sends--; + if (actor->speech_sends > 0) + actor->speech_sends--; + if (actor->user_animation_sends > 0) + actor->user_animation_sends--; + } + host_send_damage(); + for (machine_number = 0; machine_number < machine_count; machine_number++) + { + long machine_index = machine_indices[machine_number]; + short count = 0; + + for (index = 0; index < state_count; index++) + { + struct distributed_actor_state const *state = &states[index]; + short period = network_objects_send_period(machine_index, &state->position); + + if (!urgent[index] && (now + DATUM_INDEX_TO_ABSOLUTE_INDEX(state->unit_index)) % period != 0) + continue; + message.states[count++] = *state; + if (count == MAXIMUM_ENTRIES_PER_MESSAGE || count == DATAGRAM_ENTRIES(struct distributed_actor_state)) + { + distributed_send_to_machine(machine_index, &message, _distributed_message_actor_states, count, + (word)(sizeof(message.header) + count * sizeof(struct distributed_actor_state))); + count = 0; + } + } + if (count) + { + distributed_send_to_machine(machine_index, &message, _distributed_message_actor_states, count, + (word)(sizeof(message.header) + count * sizeof(struct distributed_actor_state))); + } + } +} + +word network_actors_damage_entry_size( + void) +{ + return sizeof(struct distributed_actor_damage); +} + +/* client: the damage the host's AI units are taking */ +void network_actors_handle_damage( + void const *entries, + short count) +{ + struct distributed_actor_damage const *units = entries; + short index; + + for (index = 0; index < count; index++) + { + struct damage_network_state damage; + + if (!distributed_object_index_valid(units[index].unit_index) || + !network_objects_client_has(units[index].unit_index) || !actor_unit_valid(units[index].unit_index)) + { + continue; + } + damage_get_network_state(units[index].unit_index, &damage); + damage.current_shield_damage = distributed_vitality_unpack(units[index].current_shield_damage); + damage.recent_shield_damage = distributed_vitality_unpack(units[index].recent_shield_damage); + damage.current_body_damage = distributed_vitality_unpack(units[index].current_body_damage); + damage.recent_body_damage = distributed_vitality_unpack(units[index].recent_body_damage); + damage_set_network_state(units[index].unit_index, &damage); + } +} + +word network_actors_entry_size( + void) +{ + return sizeof(struct distributed_actor_state); +} + +/* client: entries from the host */ +void network_actors_handle_states( + void const *entries, + short count) +{ + struct distributed_actor_state const *states = entries; + long now = game_time_get(); + short index; + + for (index = 0; index < count; index++) + actor_state_apply(&states[index], now); +} + +/* Client: called at the point in the tick where the host runs the AI. +Drives each AI unit with the last control the host sent. */ +void network_actors_drive( + void) +{ + long now = game_time_get(); + short index; + + for (index = 0; index < client_actor_count; index++) + { + struct client_actor *actor = &client_actors[index]; + + if (!actor_unit_valid(actor->unit_index) || now - actor->received_time > CONTROL_HELD_TICKS) + { + if (actor->driven && object_try_and_get_and_verify_type(actor->unit_index, _object_mask_unit)) + unit_set_actively_controlled(actor->unit_index, FALSE); + client_actors[index--] = client_actors[--client_actor_count]; + continue; + } + /* vehicles follow the host's transform instead (network_objects.c) */ + if (object_get(actor->unit_index)->object.type == _object_type_vehicle) + continue; + if (!actor->driven) + { + unit_set_actively_controlled(actor->unit_index, TRUE); + actor->driven = TRUE; + } + unit_control(actor->unit_index, &actor->control); + if (actor->impulse != NONE && actor->impulse_number != actor->played_impulse_number) + { + real_vector2d *alignment = actor->impulse_aligned ? &actor->impulse_alignment : NULL; + + if (actor->impulse == _actor_action_melee) + unit_melee_attack_begin(actor->unit_index, FALSE, alignment); + else if (actor->impulse == _actor_action_leap) + unit_leap_begin(actor->unit_index, alignment); + else + unit_start_animation_impulse(actor->unit_index, actor->impulse, alignment); + actor->played_impulse_number = actor->impulse_number; + actor->impulse = NONE; + } + if (actor->speech_sound != NONE) + { + speech_play(actor->unit_index, actor->speech_sound); + actor->played_speech_number = actor->speech_number; + actor->speech_sound = NONE; + } + if (actor->user_animation_graph != NONE) + { + unit_port_play_user_animation(actor->unit_index, actor->user_animation_graph, actor->user_animation, + actor->user_animation_interpolate, 0); + actor->played_user_animation_number = actor->user_animation_number; + actor->user_animation_graph = NONE; + } + } +} diff --git a/port/linux/game/network_coop.c b/port/linux/game/network_coop.c new file mode 100644 index 00000000..7fb7ee79 --- /dev/null +++ b/port/linux/game/network_coop.c @@ -0,0 +1,2704 @@ +/* +NETWORK_COOP.C + +Campaign co-op over the network (port/linux/NETCODE.md). + +A network game on a campaign map, with no game engine running, is co-op. +Only the host runs the map's scripts (game.c), so everything the scripts do +that clients need to see is sent from this file: + +- Presentation, every tick: whether a cinematic is playing, the letterbox, + the camera, the screen fade, the HUD settings the scripts control (what + is shown, the mission timer), the players' maximum vitality, and the + cutscene skip vote. A client plays them one a tick by the host's clock, a + couple of ticks behind the newest, so the camera keeps an even pace. It + starts and stops its own cinematic to match, and looks through the host's + camera meanwhile. If the host goes quiet for two seconds, the client ends + the cinematic so its players aren't stuck. +- Events, which happen once: script sounds (dialogue, music, ambience), + chapter titles, help and objective text, "Checkpoint" messages, screen + shake, nav points, and custom animations on units and scenery. Each is + sent in three ticks' messages in case one is lost, and numbered so a + client applies each exactly once, in order. The engine calls the + network_coop_note_ functions (network_coop.h) to queue them. +- Devices (doors, elevators, switches, lights). A device moves toward its + device group's value (devices.c). On a client nothing sets those values + but this file: doors a player walks up to and switches a player uses are + decided by the host (the player's action is relayed to it). The host + sends a group's value for three ticks when it changes, plus a few groups + every tick in rotation, those that have moved since the map loaded more + often. A scenario group is identified by its index, which is the same on + every machine; a device's own group by the device. +- Named objects (scenery and devices) the scripts create or destroy. + network_objects.c already handles units, vehicles, weapons and equipment. + Twice a second the host sends which object names currently exist. A + client that sees the same difference twice in a row, on the same BSP, + creates or deletes its copy. +- State a late joiner would otherwise lack, resent every two seconds and on + the tick a machine joins (host_resend): where scripted scenery and + machines have moved, how objects look (permutations, scale), what the + scripts have attached, the looping sounds playing, the full-screen + cinematic effect and the nav points; on a join also the objective, the + object names and every device that has moved. +- The view of a client with nobody to spectate yet: the host's view from + behind, eased so it doesn't jerk. + +The host also makes up for scripts written for one player: vitality they +set on player0 is set on every player (a10's shields), and a Pelican's +Warthog drop brings more Warthogs in a larger game. + +Skipping a cutscene is a vote. Pressing the skip key during a skippable +cutscene votes (a client sends its vote to the host every tick), and the +host skips once more than half the machines have voted. The skip itself is +the campaign's: the host reverts to the state saved when the cutscene began +and the script carries on past it. Clients catch up through the object, +device and name syncs. The host keeps its clock running through the revert, +because the netcode needs it to only go forward (network_coop_skip_reverted). + +Objects (scenery and devices) that aren't synced by network_objects.c are +found on a client by their scenario name, or, unnamed, by their object +index and tag, since the map placed them at the same index everywhere. +*/ + +/* ---------- headers */ + +#include "cseries.h" +#include "cseries/errors.h" +#include "cache/cache_files.h" +#include "camera/camera_scripting.h" +#include "camera/director.h" +#include "camera/observer.h" +#include "cutscene/cinematics.h" +#include "devices/devices.h" +#include "effects/effect_definitions.h" +#include "effects/player_effects.h" +#include "game/game.h" +#include "game/game_engine.h" +#include "game/players.h" +#include "game/player_queues_new.h" +#include "hs/hs.h" +#include "hs/hs_library_external.h" +#include "interface/hud.h" +#include "interface/hud_definitions.h" +#include "interface/hud_messaging.h" +#include "interface/hud_unit.h" +#include "interface/hud_weapon.h" +#include "main/main.h" +#include "models/model_animation_definitions.h" +#include "models/model_definitions.h" +#include "models/models.h" +#include "objects/object_definitions.h" +#include "objects/objects.h" +#include "objects/object_types.h" +#include "objects/damage.h" +#include "objects/scenery.h" +#include "physics/collisions.h" +#include "rasterizer/rasterizer_cinematics.h" +#include "saved games/game_state.h" +#include "scenario/scenario.h" +#include "scenario/scenario_definitions.h" +#include "sound/game_sound.h" +#include "sound/sound_definitions.h" +#include "units/units.h" +#include "coop_spectate.h" +#include "network_coop.h" +#include "network_distributed.h" + +/* ---------- constants */ + +/* A joining player with nobody to spectate yet watches the host's view +from this far behind and above the host's eyes, easing a fraction of the way +toward it each tick. */ +#define WATCH_HOST_DISTANCE 3.0f +#define WATCH_HOST_HEIGHT 1.0f +#define WATCH_HOST_FOLLOW 0.2f + +/* how often the host sends again the state a client that joined since, or +missed a message, would lack (host_resend) */ +#define OBJECT_REFRESH_TICKS (2 * TICKS_PER_SECOND) + +enum +{ + /* the farthest an object the cutscene camera films can be from it + (camera_object_get) */ + CAMERA_OBJECT_DISTANCE = 8, + /* a client ends the cinematic after this long without hearing from the host */ + PRESENTATION_SILENCE_TICKS = 2 * TICKS_PER_SECOND, + /* A client plays the host's presentations (its cutscene camera, fades, + letterbox) one a tick, about this many ticks behind the newest it has, + so they keep an even pace however unevenly the network delivers them. */ + PRESENTATION_DELAY_TICKS = 2, + PRESENTATION_BUFFER_COUNT = 8, + /* the field of view (radians) is sent as a word, scaled by this */ + FIELD_OF_VIEW_SCALE = 10000, + + MAXIMUM_QUEUED_EVENTS = 128, + /* each event is sent in this many ticks' messages */ + EVENT_SENDS = 3, + + /* the size of devices.c's device group array */ + MAXIMUM_DEVICE_GROUPS = 1024, + /* a changed group is sent in this many ticks' messages */ + DEVICE_GROUP_SENDS = 3, + /* unchanged groups sent each tick, in rotation */ + DEVICE_GROUP_REFRESHES_PER_TICK = 8, + + OBJECT_NAMES_INTERVAL_TICKS = TICKS_PER_SECOND / 2, + OBJECT_NAME_BYTES = MAXIMUM_OBJECT_NAMES_PER_SCENARIO / 8, + /* the types network_objects.c syncs; the object names sync skips them */ + NETWORKED_OBJECT_TYPES = _object_mask_biped | _object_mask_vehicle | _object_mask_weapon | _object_mask_equipment, + + /* the host forgets a client's skip vote it hasn't heard again for this long */ + SKIP_VOTE_HELD_TICKS = TICKS_PER_SECOND, + /* After a skip the cutscene stays skippable until the script reaches + cinematic_skip_stop; votes still in flight must not skip it again. */ + SKIP_COOLDOWN_TICKS = 2 * TICKS_PER_SECOND, + + /* a Pelican's Warthog drop: one Warthog for every this many players, + at most this many in all */ + PLAYERS_PER_DROPPED_VEHICLE = 4, + MAXIMUM_DROPPED_VEHICLES = 5, +}; + +/* distributed_coop_event.kind */ +enum +{ + _coop_event_sound, + _coop_event_title, + _coop_event_hud, + _coop_event_player_effect, + _coop_event_nav_point, + _coop_event_unit_animation, + _coop_event_scenery_animation, + _coop_event_effect, + _coop_event_attach, +}; + +/* distributed_coop_event.type of an effect: at a cutscene flag (value), or on +an object's marker (value: its index in the object's model's markers) */ +enum +{ + _coop_effect_at_flag, + _coop_effect_on_marker, +}; + +/* distributed_coop_event.type of an attach event */ +enum +{ + _coop_attach, + _coop_detach, +}; + +/* which of a device's two groups an entry is */ +enum +{ + _device_group_role_power, + _device_group_role_position, +}; + +/* distributed_coop_presentation.flags */ +enum +{ + _presentation_cinematic_bit = 0, + _presentation_letterbox_bit, + _presentation_fading_out_bit, + _presentation_skippable_bit, + _presentation_show_hud_bit, + _presentation_show_help_text_bit, + _presentation_timer_paused_bit, + _presentation_timer_enabled_bit, +}; + +/* ---------- structures */ + +struct distributed_coop_presentation +{ + byte flags; + byte fade_color[3]; + short fade_ticks; + /* ticks since the fade began, capped at SHORT_MAX */ + short fade_elapsed; + /* the host's game time when the fade began; a new value means a new fade */ + long fade_start_time; + real_point3d camera_position; + struct distributed_vector camera_forward; + struct distributed_vector camera_up; + word camera_field_of_view; + /* the skip vote: machines that voted, out of how many */ + byte skip_votes; + byte skip_voters; + /* the scripts' HUD settings (hud_unit.c, hud_weapon.c) */ + long unit_hud_flags; + long weapon_hud_flags; + /* the script timer (hud_messaging.c); the clocks agree, so its + reference time means the same on every machine */ + long timer_reference_time; + short timer_ticks; + short timer_flash_cutoff; + short timer_x; + short timer_y; + short timer_corner; + /* whether the scripts have the host's camera (camera_control): else a + cutscene leaves it with the player, and a client spectates */ + byte camera_scripted; + /* whether the scripts hold the players' controls (player_enable_input) */ + byte input_disabled; + /* the object the camera films (camera_object_get) and the camera's + offset from it: a client puts its camera by its own copy, which its + timing may have a tick from the host's */ + long camera_object_index; + real_vector3d camera_object_offset; + /* whether the scripts' screen shake is running on the host. A client + whose shake outlives it (the stop was skipped with a cutscene, or lost) + ends its own. */ + byte scripted_shake; + /* whether the scripts have set the players' maximum vitality, and to + what (network_coop_set_players_vitality) */ + byte players_vitality_set; + byte pad[2]; + real players_maximum_body_vitality; + real players_maximum_shield_vitality; +}; + +struct distributed_coop_presentation_message +{ + struct distributed_message_header header; + struct distributed_coop_presentation presentation; +}; + +/* Something that happens once. Which fields mean what depends on the kind: + + sound type: _coop_sound_ kind; tag_index; object_index (the source, or NONE); reals[0]: scale + title value: title index; reals[0]: delay in seconds + hud type: _coop_hud_ kind; value + player_effect type: _coop_player_effect_ kind; reals: the function's arguments + nav_point type: _coop_nav_point_ kind; value: nav index (NONE to deactivate); + target: team or unit; object_index: cutscene flag or object; reals[0]: offset + unit_animation object_index: unit; tag_index: graph (NONE: stop); value: animation; + frame; interpolate + scenery_animation object_index, name_index, definition_index: the scenery; tag_index: graph; + value: animation; frame */ +struct distributed_coop_event +{ + byte kind; + byte type; + word number; + short value; + short frame; + short name_index; + byte interpolate; + byte pad; + long object_index; + long target; + long tag_index; + long definition_index; + real reals[3]; +}; + +struct distributed_coop_events_message +{ + struct distributed_message_header header; + struct distributed_coop_event events[MAXIMUM_QUEUED_EVENTS]; +}; + +/* A device group's state. group_index is set for a scenario group. For a +device's own group it is NONE, and the device is found by name_index, or +(unnamed) by object_index and definition_index. */ +struct distributed_coop_device_group +{ + short group_index; + short name_index; + long object_index; + long definition_index; + byte role; + byte flags; + /* counts the host's immediate sets (wraps at 256) */ + byte snaps; + /* sent because it changed, rather than in the rotation */ + byte changed; + real value; +}; + +struct distributed_coop_device_groups_message +{ + struct distributed_message_header header; + struct distributed_coop_device_group groups[MAXIMUM_DEVICE_GROUPS]; +}; + +/* one bit per scenario object name: whether the host has that object */ +struct distributed_coop_object_names +{ + short structure_bsp_index; + short name_count; + byte present[OBJECT_NAME_BYTES]; +}; + +struct distributed_coop_object_names_message +{ + struct distributed_message_header header; + struct distributed_coop_object_names names; +}; + +/* Where one of the host's scenery or machines is. Scripts move them (a +cutscene's drop pod) and nothing else tells a client. Found as the +scenery animations' are (object_find). */ +struct distributed_coop_object_transform +{ + short name_index; + short pad; + long object_index; + long definition_index; + real_point3d position; + struct distributed_vector forward; + struct distributed_vector up; +}; + +#define MAXIMUM_OBJECT_TRANSFORMS_PER_MESSAGE 64 +#define COOP_MOVED_OBJECTS (_object_mask_scenery | _object_mask_machine) + +struct distributed_coop_object_transforms_message +{ + struct distributed_message_header header; + struct distributed_coop_object_transform transforms[MAXIMUM_OBJECT_TRANSFORMS_PER_MESSAGE]; +}; + +/* How one of the host's objects looks: the permutation of each region of +its model (NONE for none) and its scale. The scripts change them (a +helmet off, a ship scaled) and so does damage. */ +struct distributed_coop_object_look +{ + short name_index; + short pad; + long object_index; + long definition_index; + byte region_permutations[MAXIMUM_REGIONS_PER_OBJECT]; + real scale; +}; + +#define MAXIMUM_OBJECT_LOOKS_PER_MESSAGE 64 + +struct distributed_coop_object_looks_message +{ + struct distributed_message_header header; + struct distributed_coop_object_look looks[MAXIMUM_OBJECT_LOOKS_PER_MESSAGE]; +}; + +/* a client's vote to skip the cinematic, sent every tick while it stands */ +struct distributed_coop_skip_vote +{ + byte voted; + byte pad[3]; +}; + +struct distributed_coop_skip_vote_message +{ + struct distributed_message_header header; + struct distributed_coop_skip_vote vote; +}; + +/* ---------- globals */ + +/* client: the cinematic it started to match the host's */ +static struct +{ + boolean cinematic_started; + /* looking through the host's camera: its scripted cutscene camera, or + its view for a player with nothing else to look at */ + boolean host_camera; + /* the eased view behind the host, used while there is nothing else to + watch (watching_host_valid is FALSE until it is first set) */ + boolean watching_host_valid; + real_point3d watching_host_position; + real_vector3d watching_host_forward; + long heard_time; + long fade_start_time; + /* the host's presentations as they arrived, by the host's tick; the + newest tick heard and the tick last played (nothing is played until + one has been heard) */ + struct distributed_coop_presentation buffered[PRESENTATION_BUFFER_COUNT]; + long buffered_times[PRESENTATION_BUFFER_COUNT]; + boolean heard_any; + boolean playing; + long newest_time; + long played_time; +} coop_presentation; + +/* host: events still to be sent; client: the last one applied */ +static struct +{ + struct distributed_coop_event events[MAXIMUM_QUEUED_EVENTS]; + short sends[MAXIMUM_QUEUED_EVENTS]; + short count; + word next_number; + word applied_number; + boolean applied_any; +} coop_events; + +/* host: each device group as last sent, indexed by group */ +static struct +{ + /* false until the first tick, which records the map's starting state */ + boolean started; + real values[MAXIMUM_DEVICE_GROUPS]; + byte flags[MAXIMUM_DEVICE_GROUPS]; + byte snaps[MAXIMUM_DEVICE_GROUPS]; + /* ticks this group is still to be sent as a change */ + byte sends[MAXIMUM_DEVICE_GROUPS]; + /* immediate sets so far (devices.c calls network_coop_note_device_snap) */ + byte snap_counts[MAXIMUM_DEVICE_GROUPS]; + /* whether this group has changed since the map loaded */ + boolean moved[MAXIMUM_DEVICE_GROUPS]; + short refresh_next, moved_refresh_next; + struct distributed_coop_device_groups_message message; +} host_devices; + +/* The players' maximum vitality, once the scripts have set a player's: on +the host as they set it, on a client as the host's presentation has it. +Every player's unit is kept at it, a new one included. */ +static struct +{ + boolean set; + real maximum_body; + real maximum_shield; +} players_vitality; + +/* host: the client machines it had last tick. On the tick a new one +appears, and every OBJECT_REFRESH_TICKS, the state a client that joined +since would lack is sent again (refresh), and on the join every device that +has moved since the map loaded too (joined). */ +static struct +{ + long machines[HALO_PORT_MAXIMUM_NETWORK_MACHINES]; + short machine_count; + boolean joined; + boolean refresh; +} host_resend; + +/* client: the host's snap count last seen for each of its groups */ +static struct +{ + byte snaps[MAXIMUM_DEVICE_GROUPS]; + boolean seen[MAXIMUM_DEVICE_GROUPS]; +} client_devices; + +/* client: the object names that differed from the host's last time */ +static byte client_names_differing[OBJECT_NAME_BYTES]; + +/* The cutscene skip vote. The host records when it last heard from each +client that is showing the cutscene, and when that client last voted. A +client keeps the host's latest tally for display. */ +static struct +{ + boolean voted; + boolean offered; + short votes; + short voters; + long client_heard_times[HALO_PORT_MAXIMUM_NETWORK_MACHINES]; + long client_vote_times[HALO_PORT_MAXIMUM_NETWORK_MACHINES]; + /* host: no voting before this game time (set after a skip) */ + long cooldown_until; + /* host: the vote passed and main_skip_cinematic was called once */ + boolean requested; + /* host: the save the script makes as the cutscene becomes skippable is + written (a skip reverts to it) */ + boolean skip_save_written; +} skip_vote; + +/* ---------- prototypes */ + +static void client_play_presentation(void); +/* unit_scripting_commands.c */ +void unit_scripting_set_current_vitality_of(long unit_index, real body_vitality, real shield_vitality); + +/* ---------- private code */ + +static boolean coop_game( + void) +{ + return network_coop_active(); +} + +static boolean coop_host( + void) +{ + return game_connection() == _game_connection_network_server && coop_game(); +} + +static boolean coop_client( + void) +{ + return game_connection() == _game_connection_network_client && coop_game(); +} + +/* host: sends count entries of entry_size in message to every client */ +static void send_to_clients( + void *message, + byte type, + short count, + word entry_size) +{ + distributed_send(message, type, count, (word)(sizeof(struct distributed_message_header) + count * entry_size), + _distributed_to_clients); +} + +/* host, each tick: whether a client machine joined since the last, and so +whether to resend this tick (host_resend) */ +static void host_resend_update( + void) +{ + long machines[HALO_PORT_MAXIMUM_NETWORK_MACHINES]; + short count = distributed_client_machines(machines, HALO_PORT_MAXIMUM_NETWORK_MACHINES); + short index, known; + + host_resend.joined = FALSE; + for (index = 0; index < count && !host_resend.joined; index++) + { + for (known = 0; known < host_resend.machine_count && host_resend.machines[known] != machines[index]; known++) + ; + host_resend.joined = known == host_resend.machine_count; + } + csmemcpy(host_resend.machines, machines, count * sizeof(machines[0])); + host_resend.machine_count = count; + host_resend.refresh = host_resend.joined || game_time_get() % OBJECT_REFRESH_TICKS == 0; + if (host_resend.joined) + error(_error_silent, "co-op: a machine joined; resending the game's state"); +} + +/* whether the object is a player's unit or carries one (scripts never delete those) */ +static boolean object_holds_player( + long object_index) +{ + long child_index; + + if (player_index_from_unit_index(object_index) != NONE) + return TRUE; + for (child_index = object_get(object_index)->object.first_child_object_index; child_index != NONE; + child_index = object_get(child_index)->object.next_object_index) + { + if (object_holds_player(child_index)) + return TRUE; + } + + return FALSE; +} + +/* the object type a scenario object name refers to, or NONE */ +static short object_name_type( + short name_index) +{ + struct scenario *scenario = global_scenario_get(); + + if (name_index < 0 || name_index >= scenario->object_names.count) + return NONE; + + return TAG_BLOCK_GET_ELEMENT(&scenario->object_names, name_index, struct scenario_object_name)->runtime_object_type; +} + +/* Client: finds the local copy of an object the host named, of one of the +types in type_mask, and returns its index (or NONE). An unnamed object sits +at the same object index on every machine; if its salt differs here, +matching the absolute index is enough, as long as the tag matches too. */ +static long object_find( + short name_index, + long object_index, + long definition_index, + unsigned long type_mask) +{ + struct object_datum *object; + + if (name_index != NONE) + object_index = object_index_from_name_index(name_index); + if (object_index == NONE) + return NONE; + object = object_try_and_get_and_verify_type(object_index, type_mask); + if (!object && name_index == NONE) + { + struct object_iterator iterator; + struct object_datum *candidate; + + object_iterator_new(&iterator, type_mask, 0); + while (!object && (candidate = object_iterator_next(&iterator)) != NULL) + { + if ((iterator.index & 0xFFFF) == (object_index & 0xFFFF)) + { + object = candidate; + object_index = iterator.index; + } + } + } + if (!object || object->definition_index != definition_index || + (name_index == NONE && object->object.name_index != NONE)) + { + return NONE; + } + + return object_index; +} + +/* host: a new event, filled in by the caller; NULL if it isn't wanted or the queue is full */ +static struct distributed_coop_event *event_new( + byte kind) +{ + struct distributed_coop_event *event; + + if (!coop_host()) + return NULL; + if (coop_events.count == MAXIMUM_QUEUED_EVENTS) + { + error(_error_silent, "co-op: event queue full; an event of kind %d is not sent", kind); + return NULL; + } + event = &coop_events.events[coop_events.count]; + csmemset(event, 0, sizeof(*event)); + event->kind = kind; + event->number = ++coop_events.next_number; + event->object_index = NONE; + event->target = NONE; + event->tag_index = NONE; + event->definition_index = NONE; + event->name_index = NONE; + coop_events.sends[coop_events.count] = 0; + coop_events.count++; + + return event; +} + +static void host_send_events( + void) +{ + struct distributed_coop_events_message message; + short index; + + if (!coop_events.count) + return; + csmemcpy(message.events, coop_events.events, coop_events.count * sizeof(message.events[0])); + send_to_clients(&message, _distributed_message_coop_events, coop_events.count, sizeof(message.events[0])); + /* drop the ones sent EVENT_SENDS times, keeping the rest in order */ + for (index = 0; index < coop_events.count; ) + { + if (++coop_events.sends[index] < EVENT_SENDS) + { + index++; + continue; + } + coop_events.count--; + csmemmove(&coop_events.events[index], &coop_events.events[index + 1], + (coop_events.count - index) * sizeof(coop_events.events[0])); + csmemmove(&coop_events.sends[index], &coop_events.sends[index + 1], + (coop_events.count - index) * sizeof(coop_events.sends[0])); + } +} + +/* Fills in the group's value, flags and snap count. Returns FALSE if the +group doesn't exist. */ +static boolean device_group_state( + short group_index, + struct distributed_coop_device_group *entry) +{ + word flags; + boolean runtime; + + if (group_index < 0 || group_index >= MAXIMUM_DEVICE_GROUPS || + !device_group_network_get(group_index, &entry->value, &flags, &runtime)) + { + return FALSE; + } + entry->flags = (byte)flags; + entry->snaps = host_devices.snap_counts[group_index]; + entry->changed = FALSE; + + return TRUE; +} + +/* Host: lists every device group, scenario groups first, then each device's +own groups. group_indices gets each entry's group index. Returns the count. */ +static short device_group_entries( + struct distributed_coop_device_group *entries, + short *group_indices) +{ + struct scenario *scenario = global_scenario_get(); + struct object_iterator iterator; + struct device_datum *device; + short count = 0, group_index; + + for (group_index = 0; group_index < scenario->device_groups.count && count < MAXIMUM_DEVICE_GROUPS; group_index++) + { + struct distributed_coop_device_group *entry = &entries[count]; + + entry->group_index = group_index; + entry->name_index = NONE; + entry->object_index = NONE; + entry->definition_index = NONE; + entry->role = _device_group_role_power; + if (device_group_state(group_index, entry)) + group_indices[count++] = group_index; + } + object_iterator_new(&iterator, _object_mask_device, 0); + while ((device = object_iterator_next(&iterator)) != NULL) + { + short groups[2]; + short role; + + groups[_device_group_role_power] = device->device.power_group_index; + groups[_device_group_role_position] = device->device.position_group_index; + for (role = 0; role < NUMBEROF(groups) && count < MAXIMUM_DEVICE_GROUPS; role++) + { + struct distributed_coop_device_group *entry = &entries[count]; + real value; + word flags; + boolean runtime; + + /* scenario groups were listed above */ + if (!device_group_network_get(groups[role], &value, &flags, &runtime) || !runtime) + continue; + entry->group_index = NONE; + entry->name_index = device->object.name_index; + entry->object_index = iterator.index; + entry->definition_index = device->definition_index; + entry->role = (byte)role; + if (device_group_state(groups[role], entry)) + group_indices[count++] = groups[role]; + } + } + + return count; +} + +/* client: the local group a host entry refers to, or NONE */ +static short device_group_find( + struct distributed_coop_device_group const *entry) +{ + struct device_datum *device; + long device_index; + real value; + word flags; + boolean runtime; + short group_index; + + if (entry->group_index != NONE) + { + group_index = entry->group_index; + return group_index >= 0 && group_index < global_scenario_get()->device_groups.count && + device_group_network_get(group_index, &value, &flags, &runtime) && !runtime ? group_index : NONE; + } + device_index = object_find(entry->name_index, entry->object_index, entry->definition_index, _object_mask_device); + if (device_index == NONE) + return NONE; + device = object_get_and_verify_type(device_index, _object_mask_device); + switch (entry->role) + { + case _device_group_role_power: group_index = device->device.power_group_index; break; + case _device_group_role_position: group_index = device->device.position_group_index; break; + default: return NONE; + } + + return group_index >= 0 && group_index < MAXIMUM_DEVICE_GROUPS && + device_group_network_get(group_index, &value, &flags, &runtime) && runtime ? group_index : NONE; +} + +/* host: sends the groups that changed, plus the next few in each rotation: +one through every group, and a quicker one through the groups that have +moved since the map loaded. A late joiner loaded the same map, so those are +the ones it lacks, and a level's few moved devices (a light bridge) reach it +within a tick or two instead of after a pass through hundreds. */ +static void host_send_device_groups( + void) +{ + static short group_indices[MAXIMUM_DEVICE_GROUPS]; + struct distributed_coop_device_group *entries = host_devices.message.groups; + short count = device_group_entries(entries, group_indices); + short moved_count = 0, moved_index = 0; + short index, sent = 0; + + for (index = 0; index < count; index++) + { + struct distributed_coop_device_group const *entry = &entries[index]; + short group_index = group_indices[index]; + + if (!host_devices.started || + entry->value != host_devices.values[group_index] || + entry->flags != host_devices.flags[group_index] || + entry->snaps != host_devices.snaps[group_index]) + { + host_devices.values[group_index] = entry->value; + host_devices.flags[group_index] = entry->flags; + host_devices.snaps[group_index] = entry->snaps; + /* clients loaded the same map, so the starting state isn't sent */ + if (host_devices.started) + { + host_devices.sends[group_index] = DEVICE_GROUP_SENDS; + host_devices.moved[group_index] = TRUE; + } + } + if (host_devices.moved[group_index]) + moved_count++; + } + if (host_devices.refresh_next >= count) + host_devices.refresh_next = 0; + if (host_devices.moved_refresh_next >= moved_count) + host_devices.moved_refresh_next = 0; + for (index = 0; index < count; index++) + { + struct distributed_coop_device_group *entry = &entries[index]; + short group_index = group_indices[index]; + boolean refresh = (index - host_devices.refresh_next + count) % count < DEVICE_GROUP_REFRESHES_PER_TICK; + + if (host_devices.moved[group_index]) + { + refresh = refresh || host_resend.joined || + (moved_index - host_devices.moved_refresh_next + moved_count) % moved_count < DEVICE_GROUP_REFRESHES_PER_TICK; + moved_index++; + } + entry->changed = host_devices.sends[group_index] != 0; + if (entry->changed) + host_devices.sends[group_index]--; + else if (!refresh) + continue; + entries[sent++] = *entry; + } + host_devices.started = TRUE; + host_devices.refresh_next = count ? (short)((host_devices.refresh_next + DEVICE_GROUP_REFRESHES_PER_TICK) % count) : 0; + host_devices.moved_refresh_next = moved_count ? + (short)((host_devices.moved_refresh_next + DEVICE_GROUP_REFRESHES_PER_TICK) % moved_count) : 0; + if (sent) + send_to_clients(&host_devices.message, _distributed_message_coop_device_groups, sent, sizeof(entries[0])); +} + +/* host: where each scenery or machine was last sent, by its absolute +index, and whether it has moved since the map placed it */ +static struct +{ + long object_index; + boolean moved; + real_point3d position; + real_vector3d forward; +} host_sent_transforms[MAXIMUM_OBJECTS_PER_MAP]; + +/* host, each tick: the scenery and machines that moved (and, every +OBJECT_REFRESH_TICKS, all that ever have) */ +static void host_send_object_transforms( + void) +{ + struct distributed_coop_object_transforms_message message; + struct object_iterator iterator; + struct object_datum *object; + short count = 0; + + object_iterator_new(&iterator, COOP_MOVED_OBJECTS, 0); + while ((object = object_iterator_next(&iterator)) != NULL) + { + short absolute_index = DATUM_INDEX_TO_ABSOLUTE_INDEX(iterator.index); + struct distributed_coop_object_transform *transform; + boolean moving; + + if (object->object.parent_object_index != NONE || absolute_index < 0 || absolute_index >= MAXIMUM_OBJECTS_PER_MAP) + continue; + if (host_sent_transforms[absolute_index].object_index != iterator.index) + { + /* an object seen for the first time is where the map placed it, on + every machine, so there is nothing to send yet */ + host_sent_transforms[absolute_index].object_index = iterator.index; + host_sent_transforms[absolute_index].moved = FALSE; + host_sent_transforms[absolute_index].position = object->object.position; + host_sent_transforms[absolute_index].forward = object->object.forward; + continue; + } + moving = distance_squared3d(&host_sent_transforms[absolute_index].position, &object->object.position) >= 0.0001f || + dot_product3d(&host_sent_transforms[absolute_index].forward, &object->object.forward) <= 0.9999f; + if (!moving && !(host_resend.refresh && host_sent_transforms[absolute_index].moved)) + continue; + host_sent_transforms[absolute_index].moved = TRUE; + host_sent_transforms[absolute_index].position = object->object.position; + host_sent_transforms[absolute_index].forward = object->object.forward; + transform = &message.transforms[count++]; + transform->name_index = object->object.name_index; + transform->pad = 0; + transform->object_index = iterator.index; + transform->definition_index = object->definition_index; + transform->position = object->object.position; + distributed_vector_pack(&object->object.forward, DISTRIBUTED_UNIT_SCALE, &transform->forward); + distributed_vector_pack(&object->object.up, DISTRIBUTED_UNIT_SCALE, &transform->up); + if (count == MAXIMUM_OBJECT_TRANSFORMS_PER_MESSAGE) + { + send_to_clients(&message, _distributed_message_coop_object_transforms, count, sizeof(message.transforms[0])); + count = 0; + } + } + if (count > 0) + send_to_clients(&message, _distributed_message_coop_object_transforms, count, sizeof(message.transforms[0])); +} + +/* host: how each object looked when last sent, by its absolute index, and +whether that has changed since it was made */ +static struct +{ + long object_index; + boolean changed; + byte region_permutations[MAXIMUM_REGIONS_PER_OBJECT]; + real scale; +} host_sent_looks[MAXIMUM_OBJECTS_PER_MAP]; + +/* host, each tick: the objects whose looks changed (and, every +OBJECT_REFRESH_TICKS, all whose looks ever have) */ +static void host_send_object_looks( + void) +{ + struct distributed_coop_object_looks_message message; + struct object_iterator iterator; + struct object_datum *object; + short count = 0; + + object_iterator_new(&iterator, _object_mask_all, 0); + while ((object = object_iterator_next(&iterator)) != NULL) + { + short absolute_index = DATUM_INDEX_TO_ABSOLUTE_INDEX(iterator.index); + struct distributed_coop_object_look *look; + boolean changing; + + if (absolute_index < 0 || absolute_index >= MAXIMUM_OBJECTS_PER_MAP) + continue; + if (host_sent_looks[absolute_index].object_index != iterator.index) + { + /* an object seen for the first time looks the same on every machine */ + host_sent_looks[absolute_index].object_index = iterator.index; + host_sent_looks[absolute_index].changed = FALSE; + host_sent_looks[absolute_index].scale = object->object.scale; + csmemcpy(host_sent_looks[absolute_index].region_permutations, object->object.region_permutations, + sizeof(object->object.region_permutations)); + continue; + } + changing = host_sent_looks[absolute_index].scale != object->object.scale || + csmemcmp(host_sent_looks[absolute_index].region_permutations, object->object.region_permutations, + sizeof(object->object.region_permutations)); + if (!changing && !(host_resend.refresh && host_sent_looks[absolute_index].changed)) + continue; + host_sent_looks[absolute_index].changed = TRUE; + host_sent_looks[absolute_index].scale = object->object.scale; + csmemcpy(host_sent_looks[absolute_index].region_permutations, object->object.region_permutations, + sizeof(object->object.region_permutations)); + look = &message.looks[count++]; + look->name_index = object->object.name_index; + look->pad = 0; + look->object_index = iterator.index; + look->definition_index = object->definition_index; + csmemcpy(look->region_permutations, object->object.region_permutations, sizeof(look->region_permutations)); + look->scale = object->object.scale; + if (count == MAXIMUM_OBJECT_LOOKS_PER_MESSAGE) + { + send_to_clients(&message, _distributed_message_coop_object_looks, count, sizeof(message.looks[0])); + count = 0; + } + } + if (count > 0) + send_to_clients(&message, _distributed_message_coop_object_looks, count, sizeof(message.looks[0])); +} + +/* ---------- the cinematic screen effect + +The scripts' full-screen effects (cinematic_screen_effect_*: blur, warp, +light enhancement, desaturation, the video effect) are state the host's +scripts set and a client's never do. The host sends it when it changes and +every OBJECT_REFRESH_TICKS, for a client that joined since or missed it. */ + +struct distributed_coop_screen_effect_message +{ + struct distributed_message_header header; + struct rasterizer_screen_effect_port_state state; +}; + +/* host: the state last sent */ +static struct rasterizer_screen_effect_port_state host_sent_screen_effect; + +static void host_send_screen_effect( + void) +{ + struct distributed_coop_screen_effect_message message; + + rasterizer_screen_effect_port_get(&message.state); + if (!host_resend.refresh && !csmemcmp(&message.state, &host_sent_screen_effect, sizeof(message.state))) + return; + host_sent_screen_effect = message.state; + send_to_clients(&message, _distributed_message_coop_screen_effect, 1, sizeof(message.state)); +} + +word network_coop_screen_effect_entry_size( + void) +{ + return sizeof(struct rasterizer_screen_effect_port_state); +} + +void network_coop_handle_screen_effect( + void const *entries, + short count) +{ + struct rasterizer_screen_effect_port_state state; + /* every field from the tint onward is a real (rasterizer_cinematics.h) */ + real const *reals = &state.filter_desaturation_tint.red; + short real_count = (short)((sizeof(state) - + offsetof(struct rasterizer_screen_effect_port_state, filter_desaturation_tint)) / sizeof(real)); + short index; + + if (!coop_client() || count != 1) + return; + csmemcpy(&state, entries, sizeof(state)); + for (index = 0; index < real_count; index++) + { + if (!distributed_real_valid(reals[index])) + return; + } + /* the game can only blur one window, so split screen goes without */ + if (main_get_window_count() > 1) + { + state.convolution_type = 0; + state.convolution_radius[0] = state.convolution_radius[1] = 0.0f; + } + rasterizer_screen_effect_port_set(&state); +} + +/* host: what its scripts have attached, sent again every +OBJECT_REFRESH_TICKS for a client that joined since */ +enum +{ + MAXIMUM_ATTACHMENTS = 32, +}; + +static struct +{ + long parent_index; + long child_index; + short parent_marker_index; + short child_marker_index; +} host_attachments[MAXIMUM_ATTACHMENTS]; +static short host_attachment_count; + +/* host: the HUD state the scripts set that a late joiner would lack: the +objective, and the nav points active (their kind, nav index, target, marker +and offset as the note was given), resent by host_send_hud_state */ +enum +{ + MAXIMUM_NAV_POINTS = 16, +}; + +static struct +{ + short objective; + short nav_point_count; + struct + { + short kind; + short nav_index; + long target; + long marker; + real vertical_offset; + } nav_points[MAXIMUM_NAV_POINTS]; +} host_hud_state; + +/* host: the looping sounds the scripts have started (music, ambience). +They are resent every OBJECT_REFRESH_TICKS so a late joiner hears them. */ +enum +{ + MAXIMUM_LOOPING_SOUNDS = 32, +}; + +static struct +{ + long definition_index; + long object_index; + real scale; +} host_looping_sounds[MAXIMUM_LOOPING_SOUNDS]; +static short host_looping_sound_count; + +static void host_send_object_names( + void) +{ + struct distributed_coop_object_names_message message; + struct distributed_coop_object_names *names = &message.names; + short name_index; + + csmemset(names, 0, sizeof(*names)); + names->structure_bsp_index = global_structure_bsp_index_get(); + names->name_count = (short)MIN(global_scenario_get()->object_names.count, MAXIMUM_OBJECT_NAMES_PER_SCENARIO); + for (name_index = 0; name_index < names->name_count; name_index++) + { + if (object_index_from_name_index(name_index) != NONE) + names->present[name_index / 8] |= (byte)(1 << (name_index % 8)); + } + send_to_clients(&message, _distributed_message_coop_object_names, 1, sizeof(message.names)); +} + +static void skip_vote_clear( + void) +{ + short index; + + skip_vote.voted = FALSE; + skip_vote.votes = 0; + for (index = 0; index < NUMBEROF(skip_vote.client_vote_times); index++) + { + skip_vote.client_heard_times[index] = NONE; + skip_vote.client_vote_times[index] = NONE; + } +} + +/* host: counts the votes to skip the cinematic, and skips when more than +half the machines showing it have voted (one still loading, or joined since +it began, has no say until it shows it) */ +static void host_count_skip_votes( + void) +{ + long machine_indices[HALO_PORT_MAXIMUM_NETWORK_MACHINES]; + short machine_count = distributed_client_machines(machine_indices, HALO_PORT_MAXIMUM_NETWORK_MACHINES); + long now = game_time_get(); + boolean skippable = cinematic_in_progress() && cinematic_can_be_skipped(); + boolean was_offered = skip_vote.offered; + short index; + + /* Wait until the save the script makes when the cutscene becomes + skippable has been written; reverting earlier would go back to the save + before it. A save requested later in the cutscene doesn't hold up the + vote, since the skip cancels it, as in a solo game. */ + if (!skippable) + skip_vote.skip_save_written = FALSE; + else if (!main_saving_map()) + skip_vote.skip_save_written = TRUE; + skip_vote.offered = skippable && skip_vote.skip_save_written && now >= skip_vote.cooldown_until; + if (skip_vote.offered && !was_offered) + error(_error_silent, "co-op: the cutscene can be skipped"); + if (!skip_vote.offered) + { + skip_vote_clear(); + skip_vote.voters = 0; + skip_vote.requested = FALSE; + return; + } + /* already requested: main.c skips at the end of this frame */ + if (skip_vote.requested) + return; + /* the host votes too, unless it is a dedicated server with no player */ + skip_vote.voters = local_player_get_next(NONE) != NONE ? 1 : 0; + skip_vote.votes = skip_vote.voted ? 1 : 0; + for (index = 0; index < machine_count; index++) + { + long heard_time = skip_vote.client_heard_times[machine_indices[index]]; + long vote_time = skip_vote.client_vote_times[machine_indices[index]]; + + if (heard_time == NONE || now - heard_time > SKIP_VOTE_HELD_TICKS) + continue; + skip_vote.voters++; + if (vote_time != NONE && now - vote_time <= SKIP_VOTE_HELD_TICKS) + skip_vote.votes++; + } + if (skip_vote.votes * 2 > skip_vote.voters) + { + error(_error_silent, "co-op: skipping the cutscene (%d of %d voted)", skip_vote.votes, skip_vote.voters); + skip_vote.requested = TRUE; + main_skip_cinematic(); + } +} + +/* the least speed (squared, world units a tick) of an object the cutscene +camera films when the scripts don't name it */ +#define CAMERA_OBJECT_SPEED 0.01f + +/* Host: the object the cutscene camera films: the nearest vehicle moving +close to it (a camera riding along with a drop pod or a Pelican), else the +one the scripts set it relative to; NONE for a camera on its own. The last +one is kept while it stays close, so the camera doesn't jump between two. */ +static long camera_object_get( + struct observer_result const *camera) +{ + static long filmed = NONE; + real nearest = CAMERA_OBJECT_DISTANCE * CAMERA_OBJECT_DISTANCE; + struct object_iterator iterator; + struct object_datum *object; + + object = object_try_and_get_and_verify_type(filmed, _object_mask_vehicle); + if (object && distance_squared3d(&camera->position, &object->object.position) < nearest) + return filmed; + filmed = NONE; + object_iterator_new(&iterator, _object_mask_vehicle, 0); + while ((object = object_iterator_next(&iterator)) != NULL) + { + real distance = distance_squared3d(&camera->position, &object->object.position); + + if (distance < nearest && object->object.parent_object_index == NONE && + magnitude_squared3d(&object->object.translational_velocity) > CAMERA_OBJECT_SPEED) + { + nearest = distance; + filmed = iterator.index; + } + } + if (filmed != NONE) + return filmed; + return object_try_and_get(scripted_camera_object_relative_to()) ? scripted_camera_object_relative_to() : NONE; +} + +/* host: the presentation sent to every client this tick */ +static void host_presentation( + struct distributed_coop_presentation *presentation) +{ + struct observer_result const *camera = observer_get_camera(0); + struct hud_timer_state timer; + real_rgb_color fade_color; + boolean fading_out; + long elapsed; + + csmemset(presentation, 0, sizeof(*presentation)); + SET_FLAG(presentation->flags, _presentation_cinematic_bit, cinematic_in_progress()); + SET_FLAG(presentation->flags, _presentation_letterbox_bit, cinematic_globals->show_letterbox); + player_effect_port_screen_fade_get(&fade_color, &presentation->fade_ticks, &fading_out, + &presentation->fade_start_time); + SET_FLAG(presentation->flags, _presentation_fading_out_bit, fading_out); + presentation->fade_color[0] = (byte)(PIN(fade_color.red, 0.0f, 1.0f) * 255.0f + 0.5f); + presentation->fade_color[1] = (byte)(PIN(fade_color.green, 0.0f, 1.0f) * 255.0f + 0.5f); + presentation->fade_color[2] = (byte)(PIN(fade_color.blue, 0.0f, 1.0f) * 255.0f + 0.5f); + elapsed = game_time_get() - presentation->fade_start_time; + presentation->fade_elapsed = (short)PIN(elapsed, 0, SHORT_MAX); + if (camera) + { + presentation->camera_position = camera->position; + distributed_vector_pack(&camera->forward, DISTRIBUTED_UNIT_SCALE, &presentation->camera_forward); + distributed_vector_pack(&camera->up, DISTRIBUTED_UNIT_SCALE, &presentation->camera_up); + presentation->camera_field_of_view = + (word)PIN(camera->field_of_view * FIELD_OF_VIEW_SCALE + 0.5f, 1, UNSIGNED_SHORT_MAX); + presentation->camera_object_index = camera_object_get(camera); + if (presentation->camera_object_index != NONE) + { + vector_from_points3d(&object_get(presentation->camera_object_index)->object.position, &camera->position, + &presentation->camera_object_offset); + } + } + else + presentation->camera_object_index = NONE; + + presentation->camera_scripted = (byte)(*director_camera_scripted != FALSE); + presentation->input_disabled = (byte)!player_input_enabled(); + presentation->scripted_shake = (byte)player_effect_port_scripted_active(); + presentation->players_vitality_set = (byte)players_vitality.set; + presentation->players_maximum_body_vitality = players_vitality.maximum_body; + presentation->players_maximum_shield_vitality = players_vitality.maximum_shield; + SET_FLAG(presentation->flags, _presentation_skippable_bit, skip_vote.offered); + presentation->skip_votes = (byte)MIN(skip_vote.votes, 255); + presentation->skip_voters = (byte)MIN(skip_vote.voters, 255); + + SET_FLAG(presentation->flags, _presentation_show_hud_bit, hud_scripted_globals->show_hud); + SET_FLAG(presentation->flags, _presentation_show_help_text_bit, hud_scripted_globals->show_hud_help_text); + presentation->unit_hud_flags = hud_unit_port_script_flags(); + presentation->weapon_hud_flags = hud_weapon_port_script_flags(); + hud_messaging_port_timer_get(&timer); + SET_FLAG(presentation->flags, _presentation_timer_paused_bit, timer.paused); + SET_FLAG(presentation->flags, _presentation_timer_enabled_bit, timer.enabled); + presentation->timer_reference_time = timer.reference_time; + presentation->timer_ticks = timer.ticks; + presentation->timer_flash_cutoff = timer.flash_cutoff; + presentation->timer_x = timer.x; + presentation->timer_y = timer.y; + presentation->timer_corner = timer.corner; +} + +static void client_cinematic_end( + void) +{ + if (!coop_presentation.cinematic_started) + return; + coop_presentation.cinematic_started = FALSE; + cinematic_stop(); +} + +/* client with nothing else to watch: moves the camera (position, forward, +up) behind and above the host's eyes, like spectating a teammate. It eases +toward the target so the host looking around doesn't jerk it. */ +static void client_watch_host_from_behind( + real_point3d *position, + real_vector3d *forward, + real_vector3d *up) +{ + real_point3d behind; + real_vector3d level = { forward->i, forward->j, 0.0f }; + + if (normalize3d(&level) == 0.0f) + level = *forward; + point_from_line3d(position, &level, -WATCH_HOST_DISTANCE, &behind); + behind.z += WATCH_HOST_HEIGHT; + if (!coop_presentation.watching_host_valid) + { + coop_presentation.watching_host_valid = TRUE; + coop_presentation.watching_host_position = behind; + coop_presentation.watching_host_forward = *forward; + } + else + { + points_interpolate(&coop_presentation.watching_host_position, &behind, WATCH_HOST_FOLLOW, + &coop_presentation.watching_host_position); + vectors_interpolate(&coop_presentation.watching_host_forward, forward, WATCH_HOST_FOLLOW, + &coop_presentation.watching_host_forward); + normalize3d(&coop_presentation.watching_host_forward); + } + *position = coop_presentation.watching_host_position; + *forward = coop_presentation.watching_host_forward; + /* keep the camera upright */ + up->i = 0.0f; + up->j = 0.0f; + up->k = 1.0f; + distributed_axes_make_valid(forward, up); +} + +/* client: looks through the host's camera, or with its own */ +static void client_host_camera_set( + boolean host_camera) +{ + if (coop_presentation.host_camera == host_camera) + return; + coop_presentation.host_camera = host_camera; + director_script_camera(host_camera); +} + +static void client_apply_hud( + struct distributed_coop_event const *event) +{ + switch (event->type) + { + case _coop_hud_help_text: + if (event->value >= 0 && event->value < hud_messaging_port_message_count()) + scripted_hud_set_state_message(event->value); + break; + case _coop_hud_objective: + if (event->value >= 0 && event->value < hud_messaging_port_message_count()) + scripted_hud_set_objective(event->value); + break; + case _coop_hud_help_flash: + scripted_hud_set_flashing_state(event->value != 0); + break; + case _coop_hud_messages_clear: + scripted_hud_messages_clear(); + break; + case _coop_hud_checkpoint: + hud_autosave(event->value != 0); + break; + default: + break; + } +} + +static void client_apply_player_effect( + struct distributed_coop_event const *event) +{ + real const *reals = event->reals; + + switch (event->type) + { + case _coop_player_effect_translation: + scripted_player_effect_set_translation(reals[0], reals[1], reals[2]); + break; + case _coop_player_effect_rotation: + scripted_player_effect_set_rotation(reals[0], reals[1], reals[2]); + break; + case _coop_player_effect_start: + scripted_player_effect_start(PIN(reals[0], 0.0f, 1.0f), PIN(reals[1], 0.0f, 60.0f)); + break; + case _coop_player_effect_stop: + scripted_player_effect_stop(PIN(reals[0], 0.0f, 60.0f)); + break; + default: + break; + } +} + +static void client_apply_nav_point( + struct distributed_coop_event const *event) +{ + struct scenario *scenario = global_scenario_get(); + boolean activate = event->value != NONE; + boolean flag = event->type == _coop_nav_point_team_flag || event->type == _coop_nav_point_unit_flag; + boolean team = event->type == _coop_nav_point_team_flag || event->type == _coop_nav_point_team_object; + real offset = event->reals[0]; + + if (activate && (event->value < 0 || event->value >= hud_globals->waypoint.arrows.count)) + return; + if (flag && (event->object_index < 0 || event->object_index >= scenario->cutscene_flags.count)) + return; + if (team && (event->target < 0 || event->target >= NUMBER_OF_SOLO_CAMPAIGN_TEAMS)) + return; + if (!team && !object_try_and_get_and_verify_type(event->target, _object_mask_unit)) + return; + switch (event->type) + { + case _coop_nav_point_team_flag: + if (activate) + hud_activate_team_nav_point_with_flag(event->value, (short)event->target, (short)event->object_index, offset); + else + hud_deactivate_team_nav_point_with_flag((short)event->target, (short)event->object_index); + break; + case _coop_nav_point_team_object: + if (activate) + hud_activate_team_nav_point_with_object(event->value, (short)event->target, event->object_index, offset); + else + hud_deactivate_team_nav_point_with_object((short)event->target, event->object_index); + break; + case _coop_nav_point_unit_flag: + if (activate) + hud_unit_activate_nav_point_with_flag(event->value, event->target, (short)event->object_index, offset); + else + hud_unit_deactivate_nav_point_with_flag(event->target, (short)event->object_index); + break; + case _coop_nav_point_unit_object: + if (activate) + hud_unit_activate_nav_point_with_object(event->value, event->target, event->object_index, offset); + else + hud_unit_deactivate_nav_point_with_object(event->target, event->object_index); + break; + default: + break; + } +} + +static void client_apply_unit_animation( + struct distributed_coop_event const *event) +{ + if (!distributed_object_index_valid(event->object_index) || !network_objects_client_has(event->object_index) || + !object_try_and_get_and_verify_type(event->object_index, _object_mask_unit)) + { + return; + } + if (event->tag_index == NONE) + unit_stop_custom_animation(event->object_index); + else if (distributed_graph_animation(event->tag_index, event->value)) + unit_port_play_user_animation(event->object_index, event->tag_index, event->value, event->interpolate, event->frame); +} + +static void client_apply_scenery_animation( + struct distributed_coop_event const *event) +{ + long scenery_index = object_find(event->name_index, event->object_index, event->definition_index, + _object_mask_scenery); + struct animation *animation = distributed_graph_animation(event->tag_index, event->value); + + /* scenery animations have no random permutations, so the name finds the same one */ + if (scenery_index != NONE && animation) + scenery_animation_start_at_frame(scenery_index, event->tag_index, animation->name, event->frame); +} + +static void client_apply_sound( + struct distributed_coop_event const *event) +{ + long object_index = event->object_index != NONE && network_objects_client_has(event->object_index) ? + event->object_index : NONE; + real scale = PIN(event->reals[0], 0.0f, 1.0f); + + switch (event->type) + { + case _coop_sound_impulse: + if (distributed_tag_of_group(event->tag_index, SOUND_DEFINITION_TAG)) + scripted_sound_new(event->tag_index, object_index, scale); + break; + case _coop_sound_looping_start: + /* a resent sound that is already playing here is left alone */ + if (distributed_tag_of_group(event->tag_index, LOOPING_SOUND_DEFINITION_TAG) && + looping_sound_definition_get(event->tag_index)->runtime_scripting_sound_index == NONE) + { + scripted_looping_sound_start(event->tag_index, object_index, scale); + } + break; + case _coop_sound_looping_stop: + if (distributed_tag_of_group(event->tag_index, LOOPING_SOUND_DEFINITION_TAG)) + scripted_looping_sound_stop(event->tag_index); + break; + default: + break; + } +} + +static void client_apply_effect( + struct distributed_coop_event const *event) +{ + long object_index; + struct object_datum *object; + struct model *model; + + if (!distributed_tag_of_group(event->tag_index, EFFECT_DEFINITION_TAG)) + return; + if (event->type == _coop_effect_at_flag) + { + if (event->value >= 0 && event->value < global_scenario_get()->cutscene_flags.count) + hs_effect_new(event->tag_index, event->value); + return; + } + object_index = object_find(event->name_index, event->object_index, event->definition_index, _object_mask_all); + object = object_index != NONE ? object_get(object_index) : NULL; + model = object && object_definition_get(object->definition_index)->object.model.index != NONE ? + model_definition_get(object_definition_get(object->definition_index)->object.model.index) : NULL; + if (model && event->value >= 0 && event->value < model->markers.count) + { + hs_effect_new_from_object_marker(event->tag_index, object_index, + TAG_BLOCK_GET_ELEMENT(&model->markers, event->value, struct model_marker)->name); + } +} + +/* the name of a marker of the object's model by its index there; "" (the +object's origin) for NONE or one it lacks */ +static char const *object_marker_name( + long object_index, + short marker_index) +{ + long model_index = object_definition_get(object_get(object_index)->definition_index)->object.model.index; + struct model *model = model_index != NONE ? model_definition_get(model_index) : NULL; + + if (!model || marker_index < 0 || marker_index >= model->markers.count) + return ""; + return TAG_BLOCK_GET_ELEMENT(&model->markers, marker_index, struct model_marker)->name; +} + +/* the index of a marker of the object's model by its name, NONE for "" or +one it lacks */ +static short object_marker_index( + long object_index, + char const *marker_name) +{ + long model_index = object_definition_get(object_get(object_index)->definition_index)->object.model.index; + + return model_index != NONE && marker_name && marker_name[0] ? model_find_marker(model_index, marker_name) : NONE; +} + +/* An attach or a detach. The parent is object_index, name_index and +definition_index; the child is target, with its definition in tag_index and +its name index in reals[0]; value and frame are the parent's and the child's +markers. */ +static void client_apply_attach( + struct distributed_coop_event const *event) +{ + long parent_index = object_find(event->name_index, event->object_index, event->definition_index, _object_mask_all); + long child_index = object_find((short)event->reals[0], event->target, event->tag_index, _object_mask_all); + + if (parent_index == NONE || child_index == NONE || parent_index == child_index) + return; + /* a resent attach this machine already has */ + if (event->type == _coop_attach && object_get(child_index)->object.parent_object_index == parent_index) + return; + if (event->type == _coop_detach) + objects_scripting_detach(parent_index, child_index); + else + { + objects_scripting_attach(parent_index, object_marker_name(parent_index, event->value), child_index, + object_marker_name(child_index, event->frame)); + } +} + +static void client_apply_event( + struct distributed_coop_event const *event) +{ + switch (event->kind) + { + case _coop_event_sound: + client_apply_sound(event); + break; + case _coop_event_title: + if (event->value >= 0 && event->value < global_scenario_get()->cutscene_chapter_titles.count) + cinematic_set_title_delayed(event->value, PIN(event->reals[0], 0.0f, 60.0f)); + break; + case _coop_event_hud: + client_apply_hud(event); + break; + case _coop_event_player_effect: + client_apply_player_effect(event); + break; + case _coop_event_nav_point: + client_apply_nav_point(event); + break; + case _coop_event_unit_animation: + client_apply_unit_animation(event); + break; + case _coop_event_scenery_animation: + client_apply_scenery_animation(event); + break; + case _coop_event_effect: + client_apply_effect(event); + break; + case _coop_event_attach: + client_apply_attach(event); + break; + default: + break; + } +} + +/* client: the HUD settings in the host's presentation */ +static void client_apply_hud_state( + struct distributed_coop_presentation const *presentation) +{ + struct hud_timer_state timer; + + hud_scripted_globals->show_hud = TEST_FLAG(presentation->flags, _presentation_show_hud_bit); + hud_scripted_globals->show_hud_help_text = TEST_FLAG(presentation->flags, _presentation_show_help_text_bit); + hud_unit_port_set_script_flags(presentation->unit_hud_flags); + hud_weapon_port_set_script_flags(presentation->weapon_hud_flags); + timer.reference_time = presentation->timer_reference_time; + timer.ticks = presentation->timer_ticks; + timer.flash_cutoff = presentation->timer_flash_cutoff; + timer.x = presentation->timer_x; + timer.y = presentation->timer_y; + timer.corner = presentation->timer_corner; + timer.paused = TEST_FLAG(presentation->flags, _presentation_timer_paused_bit); + timer.enabled = TEST_FLAG(presentation->flags, _presentation_timer_enabled_bit); + hud_messaging_port_timer_set(&timer); +} + +/* the players in the game */ +static short coop_player_count( + void) +{ + struct data_iterator iterator; + short count = 0; + + data_iterator_new(&iterator, player_data); + while (data_iterator_next(&iterator)) + count++; + + return count; +} + +static boolean tag_name_has( + long definition_index, + char const *text) +{ + return definition_index != NONE && strstr(tag_get_name(definition_index), text) != NULL; +} + +/* each tick: every player's unit at the players' maximum vitality. The +host's own units get their current vitality full, as the scripts' set does; +a client's follows the host's damage states. */ +static void players_vitality_keep( + void) +{ + struct data_iterator iterator; + struct player_datum *player; + + if (!players_vitality.set) + return; + data_iterator_new(&iterator, player_data); + while ((player = data_iterator_next(&iterator)) != NULL) + { + struct object_datum *unit = object_try_and_get(player->unit_index); + + if (!unit || (unit->object.maximum_body_vitality == players_vitality.maximum_body && + unit->object.maximum_shield_vitality == players_vitality.maximum_shield)) + { + continue; + } + if (coop_host()) + object_initialize_vitality(player->unit_index, &players_vitality.maximum_body, &players_vitality.maximum_shield); + else + { + unit->object.maximum_body_vitality = players_vitality.maximum_body; + unit->object.maximum_shield_vitality = players_vitality.maximum_shield; + } + } +} + +/* ---------- public code */ + +void network_coop_new_game( + void) +{ + csmemset(&coop_presentation, 0, sizeof(coop_presentation)); + csmemset(&coop_events, 0, sizeof(coop_events)); + csmemset(&host_devices, 0, sizeof(host_devices)); + csmemset(&client_devices, 0, sizeof(client_devices)); + csmemset(client_names_differing, 0, sizeof(client_names_differing)); + csmemset(host_sent_transforms, 0, sizeof(host_sent_transforms)); + csmemset(host_sent_looks, 0, sizeof(host_sent_looks)); + csmemset(&host_sent_screen_effect, 0, sizeof(host_sent_screen_effect)); + csmemset(&host_resend, 0, sizeof(host_resend)); + csmemset(&players_vitality, 0, sizeof(players_vitality)); + host_attachment_count = 0; + host_looping_sound_count = 0; + host_hud_state.objective = NONE; + host_hud_state.nav_point_count = 0; + skip_vote_clear(); + skip_vote.offered = FALSE; + skip_vote.voters = 0; + skip_vote.cooldown_until = 0; + skip_vote.skip_save_written = FALSE; +} + +/* A network game on a campaign scenario with no game engine. Checking the +scenario matters: the main menu's scene keeps running while a network game +is set up, also with no game engine, and it is not co-op. */ +boolean network_coop_active( + void) +{ + short connection = game_connection(); + + return (connection == _game_connection_network_server || connection == _game_connection_network_client) && + global_scenario && global_scenario->type == _scenario_type_solo && !game_engine_running(); +} + +boolean network_coop_devices_remote( + void) +{ + return coop_client(); +} + +/* units.c: a vehicle left the vehicle carrying it. When a Pelican drops a +Warthog in a larger game, the host drops more beside it, so every player has +a ride: one Warthog for every four players, at most five. They go side by +side, alternately left and right, each only where no wall stands between it +and the first, and reach the clients as any vehicle does. */ +void network_coop_vehicle_dropped( + long vehicle_index, + long carrier_index) +{ + struct object_datum const *vehicle = object_get(vehicle_index); + short wanted = (short)MIN((coop_player_count() + PLAYERS_PER_DROPPED_VEHICLE - 1) / PLAYERS_PER_DROPPED_VEHICLE, + MAXIMUM_DROPPED_VEHICLES) - 1; + real_vector3d left; + short spot, placed = 0; + + if (!coop_host() || wanted <= 0 || !tag_name_has(vehicle->definition_index, "warthog") || + !tag_name_has(object_get(carrier_index)->definition_index, "pelican")) + { + return; + } + cross_product3d(&vehicle->object.up, &vehicle->object.forward, &left); + for (spot = 1; spot <= 2 * MAXIMUM_DROPPED_VEHICLES && placed < wanted; spot++) + { + /* (1.5 world units apart: a Warthog's width and a gap) */ + real distance = 1.5f * ((spot + 1) / 2) * (spot % 2 ? 1.0f : -1.0f); + struct object_placement_data data; + struct collision_result collision; + real_vector3d offset; + + scale_vector3d(&left, distance, &offset); + if (collision_test_vector(FLAG(_collision_test_structure_bit), &vehicle->object.position, &offset, NONE, + &collision)) + { + continue; + } + object_placement_data_new(&data, vehicle->definition_index, NONE); + point_from_line3d(&vehicle->object.position, &offset, 1.0f, &data.position); + data.forward = vehicle->object.forward; + data.up = vehicle->object.up; + data.translational_velocity = vehicle->object.translational_velocity; + if (object_new(&data) != NONE) + placed++; + } + error(_error_silent, "co-op: a Pelican dropped %d more Warthogs for %d players", placed, coop_player_count()); +} + +/* unit_scripting_commands.c: the scripts set a unit's maximum (maximum +TRUE) or current vitality. Campaign scripts name only player0 for what +every player should have, so in co-op a player's is set on every player's +unit, and a maximum is kept for those spawned later and sent to the +clients. FALSE if the unit isn't a co-op player's, for the caller to set. */ +boolean network_coop_set_players_vitality( + long unit_index, + boolean maximum, + real body, + real shield) +{ + struct data_iterator iterator; + struct player_datum *player; + + if (!coop_host() || player_index_from_unit_index(unit_index) == NONE) + return FALSE; + if (maximum) + { + players_vitality.set = TRUE; + players_vitality.maximum_body = body; + players_vitality.maximum_shield = shield; + } + data_iterator_new(&iterator, player_data); + while ((player = data_iterator_next(&iterator)) != NULL) + { + struct object_datum *unit = object_try_and_get(player->unit_index); + + if (!unit || TEST_FLAG(unit->object.damage_flags, _object_dead_bit)) + continue; + if (maximum) + object_initialize_vitality(player->unit_index, &body, &shield); + else + unit_scripting_set_current_vitality_of(player->unit_index, body, shield); + } + return TRUE; +} + +void network_coop_note_device_snap( + short group_index) +{ + if (coop_host() && group_index >= 0 && group_index < MAXIMUM_DEVICE_GROUPS) + host_devices.snap_counts[group_index]++; +} + +/* index of the looping sound in host_looping_sounds, or NONE */ +static short host_looping_sound_find( + long definition_index) +{ + short index; + + for (index = 0; index < host_looping_sound_count; index++) + { + if (host_looping_sounds[index].definition_index == definition_index) + return index; + } + return NONE; +} + +static void host_send_looping_sounds( + void) +{ + short index = 0; + + if (!host_resend.refresh) + return; + while (index < host_looping_sound_count) + { + long definition_index = host_looping_sounds[index].definition_index; + struct distributed_coop_event *event; + + /* it stopped on its own: drop it */ + if (looping_sound_definition_get(definition_index)->runtime_scripting_sound_index == NONE) + { + host_looping_sounds[index] = host_looping_sounds[--host_looping_sound_count]; + continue; + } + if ((event = event_new(_coop_event_sound)) != NULL) + { + event->type = _coop_sound_looping_start; + event->tag_index = definition_index; + event->object_index = host_looping_sounds[index].object_index; + event->reals[0] = host_looping_sounds[index].scale; + } + index++; + } +} + +void network_coop_note_sound( + short kind, + long definition_index, + long object_index, + real scale) +{ + struct distributed_coop_event *event = event_new(_coop_event_sound); + short index; + + if (!event) + return; + event->type = (byte)kind; + event->tag_index = definition_index; + event->object_index = object_index; + event->reals[0] = scale; + + index = host_looping_sound_find(definition_index); + if (kind == _coop_sound_looping_stop && index != NONE) + { + host_looping_sounds[index] = host_looping_sounds[--host_looping_sound_count]; + } + else if (kind == _coop_sound_looping_start) + { + if (index == NONE && host_looping_sound_count < MAXIMUM_LOOPING_SOUNDS) + index = host_looping_sound_count++; + if (index != NONE) + { + host_looping_sounds[index].definition_index = definition_index; + host_looping_sounds[index].object_index = object_index; + host_looping_sounds[index].scale = scale; + } + } +} + +void network_coop_note_title( + short title_index, + real delay) +{ + struct distributed_coop_event *event = event_new(_coop_event_title); + + if (!event) + return; + event->value = title_index; + event->reals[0] = delay; +} + +void network_coop_note_hud( + short kind, + short value) +{ + struct distributed_coop_event *event = event_new(_coop_event_hud); + + if (!event) + return; + event->type = (byte)kind; + event->value = value; + if (kind == _coop_hud_objective) + host_hud_state.objective = value; +} + +void network_coop_note_player_effect( + short kind, + real a, + real b, + real c) +{ + struct distributed_coop_event *event = event_new(_coop_event_player_effect); + + if (!event) + return; + event->type = (byte)kind; + event->reals[0] = a; + event->reals[1] = b; + event->reals[2] = c; +} + +static void send_nav_point( + short kind, + short nav_index, + long target, + long marker, + real vertical_offset) +{ + struct distributed_coop_event *event = event_new(_coop_event_nav_point); + + if (!event) + return; + event->type = (byte)kind; + event->value = nav_index; + event->target = target; + event->object_index = marker; + event->reals[0] = vertical_offset; +} + +void network_coop_note_nav_point( + short kind, + short nav_index, + long target, + long marker, + real vertical_offset) +{ + short index; + + if (!coop_host()) + return; + send_nav_point(kind, nav_index, target, marker, vertical_offset); + for (index = 0; index < host_hud_state.nav_point_count; index++) + { + if (host_hud_state.nav_points[index].kind == kind && host_hud_state.nav_points[index].target == target && + host_hud_state.nav_points[index].marker == marker) + { + break; + } + } + if (nav_index == NONE) + { + if (index < host_hud_state.nav_point_count) + host_hud_state.nav_points[index] = host_hud_state.nav_points[--host_hud_state.nav_point_count]; + return; + } + if (index == host_hud_state.nav_point_count) + { + if (index == MAXIMUM_NAV_POINTS) + return; + host_hud_state.nav_point_count++; + } + host_hud_state.nav_points[index].kind = kind; + host_hud_state.nav_points[index].nav_index = nav_index; + host_hud_state.nav_points[index].target = target; + host_hud_state.nav_points[index].marker = marker; + host_hud_state.nav_points[index].vertical_offset = vertical_offset; +} + +/* host: the active nav points again (host_resend), and the objective to a +machine that joined (it shows on screen when it comes, so not more often) */ +static void host_send_hud_state( + void) +{ + short index; + + if (!host_resend.refresh) + return; + if (host_resend.joined && host_hud_state.objective != NONE) + { + struct distributed_coop_event *event = event_new(_coop_event_hud); + + if (event) + { + event->type = _coop_hud_objective; + event->value = host_hud_state.objective; + } + } + for (index = 0; index < host_hud_state.nav_point_count; index++) + { + send_nav_point(host_hud_state.nav_points[index].kind, host_hud_state.nav_points[index].nav_index, + host_hud_state.nav_points[index].target, host_hud_state.nav_points[index].marker, + host_hud_state.nav_points[index].vertical_offset); + } +} + +void network_coop_note_unit_animation( + long unit_index, + long animation_graph_index, + short animation_index, + boolean interpolate) +{ + struct distributed_coop_event *event = event_new(_coop_event_unit_animation); + + if (!event) + return; + event->object_index = unit_index; + event->tag_index = animation_graph_index; + event->value = animation_index; + event->interpolate = (byte)interpolate; +} + +/* unit_custom_animation_at_frame starts the animation and then sets the +frame in the same call, so the start is still queued and unsent: the frame +goes into it */ +void network_coop_note_unit_animation_frame( + long unit_index, + short frame_index) +{ + short index; + + if (!coop_host()) + return; + for (index = coop_events.count - 1; index >= 0; index--) + { + struct distributed_coop_event *event = &coop_events.events[index]; + + if (event->kind == _coop_event_unit_animation && event->object_index == unit_index) + { + if (coop_events.sends[index] == 0) + event->frame = frame_index; + return; + } + } +} + +void network_coop_note_scenery_animation( + long object_index, + long animation_graph_index, + short animation_index, + short frame_index) +{ + struct object_datum *object = object_try_and_get(object_index); + struct distributed_coop_event *event; + + if (!object || !(event = event_new(_coop_event_scenery_animation))) + return; + event->object_index = object_index; + event->name_index = object->object.name_index; + event->definition_index = object->definition_index; + event->tag_index = animation_graph_index; + event->value = animation_index; + event->frame = frame_index; +} + +/* An attach (with the markers' indexes) or a detach (NONE markers). */ +static void send_attach( + byte type, + long parent_index, + short parent_marker_index, + long child_index, + short child_marker_index) +{ + struct object_datum *parent = object_try_and_get(parent_index); + struct object_datum *child = object_try_and_get(child_index); + struct distributed_coop_event *event; + + if (!parent || !child || !(event = event_new(_coop_event_attach))) + return; + event->type = type; + event->object_index = parent_index; + event->name_index = parent->object.name_index; + event->definition_index = parent->definition_index; + event->target = child_index; + event->tag_index = child->definition_index; + event->reals[0] = child->object.name_index; + event->value = parent_marker_index; + event->frame = child_marker_index; +} + +/* the child's attachment in host_attachments, or NONE */ +static short host_attachment_find( + long child_index) +{ + short index; + + for (index = 0; index < host_attachment_count; index++) + { + if (host_attachments[index].child_index == child_index) + return index; + } + return NONE; +} + +static void host_send_attachments( + void) +{ + short index = 0; + + if (!host_resend.refresh) + return; + while (index < host_attachment_count) + { + struct object_datum *child = object_try_and_get(host_attachments[index].child_index); + + /* the child is gone or has a new parent: forget it */ + if (!child || child->object.parent_object_index != host_attachments[index].parent_index) + { + host_attachments[index] = host_attachments[--host_attachment_count]; + continue; + } + send_attach(_coop_attach, host_attachments[index].parent_index, host_attachments[index].parent_marker_index, + host_attachments[index].child_index, host_attachments[index].child_marker_index); + index++; + } +} + +void network_coop_note_attach( + long parent_index, + char const *parent_marker_name, + long child_index, + char const *child_marker_name) +{ + short parent_marker_index, child_marker_index; + short index; + + if (!coop_host() || !object_try_and_get(parent_index) || !object_try_and_get(child_index)) + return; + parent_marker_index = object_marker_index(parent_index, parent_marker_name); + child_marker_index = object_marker_index(child_index, child_marker_name); + send_attach(_coop_attach, parent_index, parent_marker_index, child_index, child_marker_index); + + index = host_attachment_find(child_index); + if (index == NONE) + { + if (host_attachment_count == MAXIMUM_ATTACHMENTS) + return; + index = host_attachment_count++; + } + host_attachments[index].parent_index = parent_index; + host_attachments[index].child_index = child_index; + host_attachments[index].parent_marker_index = parent_marker_index; + host_attachments[index].child_marker_index = child_marker_index; +} + +void network_coop_note_detach( + long parent_index, + long child_index) +{ + short index; + + if (!coop_host()) + return; + send_attach(_coop_detach, parent_index, NONE, child_index, NONE); + index = host_attachment_find(child_index); + if (index != NONE) + host_attachments[index] = host_attachments[--host_attachment_count]; +} + +void network_coop_note_effect( + long effect_definition_index, + short cutscene_flag_index) +{ + struct distributed_coop_event *event = event_new(_coop_event_effect); + + if (!event) + return; + event->type = _coop_effect_at_flag; + event->tag_index = effect_definition_index; + event->value = cutscene_flag_index; +} + +void network_coop_note_object_effect( + long effect_definition_index, + long object_index, + char const *marker_name) +{ + struct object_datum *object = object_try_and_get(object_index); + long model_index = object ? object_definition_get(object->definition_index)->object.model.index : NONE; + short marker_index = model_index != NONE ? model_find_marker(model_index, marker_name) : NONE; + struct distributed_coop_event *event; + + if (marker_index == NONE || !(event = event_new(_coop_event_effect))) + return; + event->type = _coop_effect_on_marker; + event->tag_index = effect_definition_index; + event->object_index = object_index; + event->name_index = object->object.name_index; + event->definition_index = object->definition_index; + event->value = marker_index; +} + +boolean network_coop_skip_offered( + void) +{ + return coop_client() && skip_vote.offered && coop_presentation.cinematic_started; +} + +boolean network_coop_vote_skip( + void) +{ + if (!coop_host() && !coop_client()) + return FALSE; + skip_vote.voted = TRUE; + return TRUE; +} + +/* The skip reverted the host's game state, clock included. The clock is +put back, since clients and the netcode expect it to only go forward, and +the script threads' wake times move by the same amount so they still wake +when they would have. The revert also renumbered the input queues from the +old clock (update_queues_reset_and_fill_with_lies); they are renumbered +from the restored one, or no tick would run until they caught up. And the +revert is stamped with the restored time, since the script's game_reverted +compares the stamp with the clock: otherwise it would play the cinematic +it was meant to skip. */ +void network_coop_skip_reverted( + long now) +{ + long ticks; + + if (!coop_host()) + return; + ticks = now - game_time_get(); + if (ticks > 0) + { + game_time_set_distributed(now); + update_queues_reset_and_fill_with_lies(); + game_state_port_restamp_revert_time(); + hs_runtime_port_shift_sleep_times(ticks); + } + error(_error_silent, "co-op: cutscene skipped; reverted %ld ticks, clock kept at %ld", ticks, now); + skip_vote_clear(); + skip_vote.requested = FALSE; + skip_vote.cooldown_until = game_time_get() + SKIP_COOLDOWN_TICKS; +} + +boolean network_coop_skip_vote_status( + short *votes, + short *voters, + boolean *voted) +{ + boolean offered = coop_host() ? skip_vote.offered : network_coop_skip_offered(); + + if (!offered || skip_vote.voters <= 0) + return FALSE; + *votes = skip_vote.votes; + *voters = skip_vote.voters; + *voted = skip_vote.voted; + return TRUE; +} + +/* host, after each tick */ +void network_coop_host_tick( + void) +{ + struct distributed_coop_presentation_message message; + + if (!coop_game()) + return; + host_resend_update(); + players_vitality_keep(); + host_count_skip_votes(); + host_presentation(&message.presentation); + send_to_clients(&message, _distributed_message_coop_presentation, 1, sizeof(message.presentation)); + host_send_device_groups(); + if (host_resend.joined || game_time_get() % OBJECT_NAMES_INTERVAL_TICKS == 0) + host_send_object_names(); + host_send_object_transforms(); + host_send_object_looks(); + host_send_screen_effect(); + host_send_attachments(); + host_send_looping_sounds(); + host_send_hud_state(); + host_send_events(); +} + +/* client, after each tick */ +void network_coop_client_tick( + void) +{ + client_play_presentation(); + players_vitality_keep(); + if (game_time_get() - coop_presentation.heard_time > PRESENTATION_SILENCE_TICKS) + { + client_cinematic_end(); + client_host_camera_set(FALSE); + player_input_enable(TRUE); + } + /* sent every tick while a skip is offered, voted or not, because the + host only counts machines it hears from */ + if (!network_coop_skip_offered()) + skip_vote.voted = FALSE; + else + { + struct distributed_coop_skip_vote_message message; + + csmemset(&message.vote, 0, sizeof(message.vote)); + message.vote.voted = skip_vote.voted; + distributed_send(&message, _distributed_message_coop_skip_vote, 1, (word)sizeof(message), _distributed_to_host); + } +} + +word network_coop_presentation_entry_size( + void) +{ + return sizeof(struct distributed_coop_presentation); +} + +word network_coop_event_entry_size( + void) +{ + return sizeof(struct distributed_coop_event); +} + +word network_coop_device_group_entry_size( + void) +{ + return sizeof(struct distributed_coop_device_group); +} + +word network_coop_object_transform_entry_size( + void) +{ + return sizeof(struct distributed_coop_object_transform); +} + +void network_coop_handle_object_transforms( + void const *entries, + short count) +{ + struct distributed_coop_object_transform const *transforms = entries; + short index; + + if (!coop_client()) + return; + for (index = 0; index < count; index++) + { + struct distributed_coop_object_transform const *transform = &transforms[index]; + long object_index = object_find(transform->name_index, transform->object_index, transform->definition_index, + COOP_MOVED_OBJECTS); + real_vector3d forward, up; + + if (object_index == NONE || object_get(object_index)->object.parent_object_index != NONE) + continue; + distributed_vector_unpack(&transform->forward, DISTRIBUTED_UNIT_SCALE, &forward); + distributed_vector_unpack(&transform->up, DISTRIBUTED_UNIT_SCALE, &up); + if (distributed_point_valid(&transform->position, UNIT_WORLD_BOUND) && distributed_axes_make_valid(&forward, &up)) + object_set_position(object_index, &transform->position, &forward, &up); + } +} + +word network_coop_object_look_entry_size( + void) +{ + return sizeof(struct distributed_coop_object_look); +} + +void network_coop_handle_object_looks( + void const *entries, + short count) +{ + struct distributed_coop_object_look const *looks = entries; + short index; + + if (!coop_client()) + return; + for (index = 0; index < count; index++) + { + struct distributed_coop_object_look const *look = &looks[index]; + long object_index = object_find(look->name_index, look->object_index, look->definition_index, _object_mask_all); + struct object_datum *object = object_index != NONE ? object_get(object_index) : NULL; + long model_index = object ? object_definition_get(object->definition_index)->object.model.index : NONE; + struct model *model = model_index != NONE ? model_definition_get(model_index) : NULL; + short region_index; + + if (!object) + continue; + /* only a permutation its model has, or none */ + for (region_index = 0; model && region_index < model->regions.count && + region_index < MAXIMUM_REGIONS_PER_OBJECT; region_index++) + { + byte permutation_index = look->region_permutations[region_index]; + + if (permutation_index == (byte)NONE || + permutation_index < TAG_BLOCK_GET_ELEMENT(&model->regions, region_index, struct model_region)->permutations.count) + { + object->object.region_permutations[region_index] = permutation_index; + } + } + if (distributed_real_valid(look->scale) && look->scale > 0.0f && look->scale < 100.0f && + look->scale != object->object.scale) + { + objects_scripting_set_scale(object_index, look->scale, 0); + } + } +} + +word network_coop_object_names_entry_size( + void) +{ + return sizeof(struct distributed_coop_object_names); +} + +word network_coop_skip_vote_entry_size( + void) +{ + return sizeof(struct distributed_coop_skip_vote); +} + +/* host: a client's skip vote */ +void network_coop_handle_skip_vote( + long machine_index, + void const *entries) +{ + struct distributed_coop_skip_vote const *vote = entries; + + if (!coop_host() || machine_index < 0 || machine_index >= HALO_PORT_MAXIMUM_NETWORK_MACHINES) + return; + skip_vote.client_heard_times[machine_index] = game_time_get(); + skip_vote.client_vote_times[machine_index] = vote->voted ? game_time_get() : NONE; +} + +void network_coop_handle_device_groups( + void const *entries, + short count) +{ + struct distributed_coop_device_group const *groups = entries; + short index; + + if (!coop_game()) + return; + for (index = 0; index < count; index++) + { + struct distributed_coop_device_group const *entry = &groups[index]; + short group_index = device_group_find(entry); + real value, here; + word flags; + boolean runtime, snap; + + if (group_index == NONE || !device_group_network_get(group_index, &here, &flags, &runtime)) + continue; + value = PIN(entry->value, 0.0f, 1.0f); + /* Jump straight to the value when the host did, or when a rotation + entry disagrees with us (we missed the change, or joined late). + A change entry animates, as it did on the host. */ + snap = (client_devices.seen[group_index] && entry->snaps != client_devices.snaps[group_index]) || + (!entry->changed && value != here); + if (snap || value != here || entry->flags != (byte)flags) + device_group_network_set(group_index, value, entry->flags, snap); + client_devices.snaps[group_index] = entry->snaps; + client_devices.seen[group_index] = TRUE; + } +} + +/* Client: creates or deletes named scenery and devices to match the host. +A difference must show up twice in a row, on the same BSP, so a message +that crosses a BSP switch or a script's create can't cause a false one. */ +void network_coop_handle_object_names( + void const *entries) +{ + struct distributed_coop_object_names const *names = entries; + short name_index; + + if (!coop_game()) + return; + if (names->structure_bsp_index != global_structure_bsp_index_get() || + names->name_count != MIN(global_scenario_get()->object_names.count, MAXIMUM_OBJECT_NAMES_PER_SCENARIO)) + { + csmemset(client_names_differing, 0, sizeof(client_names_differing)); + return; + } + for (name_index = 0; name_index < names->name_count; name_index++) + { + byte *differing = &client_names_differing[name_index / 8]; + byte bit = (byte)(1 << (name_index % 8)); + boolean host_has = (names->present[name_index / 8] & bit) != 0; + long object_index = object_index_from_name_index(name_index); + short type = object_name_type(name_index); + + if (type < 0 || type >= NUMBER_OF_OBJECT_TYPES || TEST_FLAG(NETWORKED_OBJECT_TYPES, type) || + host_has == (object_index != NONE)) + { + *differing &= (byte)~bit; + continue; + } + if (!(*differing & bit)) + { + *differing |= bit; + continue; + } + *differing &= (byte)~bit; + if (host_has) + object_new_by_name(name_index); + else if (!object_holds_player(object_index)) + object_delete(object_index); + } +} + +/* client: applies each event once, in order */ +void network_coop_handle_events( + void const *entries, + short count) +{ + struct distributed_coop_event const *events = entries; + short index; + + if (!coop_game()) + return; + for (index = 0; index < count; index++) + { + /* already applied (the comparison handles the number wrapping) */ + if (coop_events.applied_any && (short)(events[index].number - coop_events.applied_number) <= 0) + continue; + coop_events.applied_number = events[index].number; + coop_events.applied_any = TRUE; + client_apply_event(&events[index]); + } +} + +/* client: keeps the host's presentation of tick host_time, to be played +in its turn (client_play_presentation) */ +void network_coop_handle_presentation( + void const *entries, + long host_time) +{ + short slot; + + if (!coop_game() || host_time < 0) + return; + coop_presentation.heard_time = game_time_get(); + if (coop_presentation.playing && host_time <= coop_presentation.played_time) + return; + slot = (short)(host_time % PRESENTATION_BUFFER_COUNT); + coop_presentation.buffered[slot] = *(struct distributed_coop_presentation const *)entries; + coop_presentation.buffered_times[slot] = host_time; + if (!coop_presentation.heard_any || host_time > coop_presentation.newest_time) + coop_presentation.newest_time = host_time; + coop_presentation.heard_any = TRUE; +} + +static void client_presentation_apply( + struct distributed_coop_presentation const *presentation) +{ + boolean cinematic = TEST_FLAG(presentation->flags, _presentation_cinematic_bit); + + player_input_enable(!presentation->input_disabled); + if (presentation->players_vitality_set && distributed_real_valid(presentation->players_maximum_body_vitality) && + distributed_real_valid(presentation->players_maximum_shield_vitality)) + { + players_vitality.set = TRUE; + players_vitality.maximum_body = presentation->players_maximum_body_vitality; + players_vitality.maximum_shield = presentation->players_maximum_shield_vitality; + } + if (!presentation->scripted_shake && player_effect_port_scripted_active()) + player_effect_port_scripted_end(); + + /* a machine that joined mid-cutscene may already have started it */ + if (cinematic && !coop_presentation.cinematic_started) + { + if (!cinematic_in_progress()) + cinematic_start(); + coop_presentation.cinematic_started = TRUE; + } + else if (!cinematic) + { + client_cinematic_end(); + } + /* Use the host's camera while its scripts control it. Spectate instead + when the cutscene leaves the camera with the player, or when the watched + teammate rides an AI-flown Pelican (a level's insertion): shots filmed + beside a moving Pelican shake against this machine's copy of it. A player + with no unit and nobody to watch also gets the host's view, rather than + a dead camera at the world origin. */ + client_host_camera_set((coop_presentation.cinematic_started && presentation->camera_scripted && + !coop_spectate_watching_rider(0)) || coop_spectate_nothing_to_watch(0)); + if (coop_presentation.cinematic_started) + cinematic_show_letterbox(TEST_FLAG(presentation->flags, _presentation_letterbox_bit)); + if (coop_presentation.host_camera) + { + real_vector3d forward, up; + + distributed_vector_unpack(&presentation->camera_forward, DISTRIBUTED_UNIT_SCALE, &forward); + distributed_vector_unpack(&presentation->camera_up, DISTRIBUTED_UNIT_SCALE, &up); + real_point3d position = presentation->camera_position; + struct object_datum *filmed = presentation->camera_object_index != NONE && + distributed_object_index_valid(presentation->camera_object_index) ? + object_try_and_get(presentation->camera_object_index) : NULL; + + /* place the camera relative to this machine's copy of the filmed + object, which may be a tick behind the host's */ + if (filmed && distributed_real_valid(presentation->camera_object_offset.i) && + distributed_real_valid(presentation->camera_object_offset.j) && + distributed_real_valid(presentation->camera_object_offset.k) && + magnitude_squared3d(&presentation->camera_object_offset) < CAMERA_OBJECT_DISTANCE * CAMERA_OBJECT_DISTANCE * 4.0f) + { + point_from_line3d(&filmed->object.position, &presentation->camera_object_offset, 1.0f, &position); + } + if (distributed_point_valid(&position, UNIT_WORLD_BOUND) && distributed_axes_make_valid(&forward, &up)) + { + if (!(coop_presentation.cinematic_started && presentation->camera_scripted)) + client_watch_host_from_behind(&position, &forward, &up); + scripted_camera_set_camera_point_relative(&position, &forward, &up, + (real)presentation->camera_field_of_view / FIELD_OF_VIEW_SCALE, 0, NONE); + } + } + else + { + coop_presentation.watching_host_valid = FALSE; + } + + /* start each new fade, backdated to when the host started it */ + if (presentation->fade_start_time != coop_presentation.fade_start_time) + { + real_rgb_color color; + + color.red = presentation->fade_color[0] / 255.0f; + color.green = presentation->fade_color[1] / 255.0f; + color.blue = presentation->fade_color[2] / 255.0f; + player_effect_port_screen_fade_set(&color, presentation->fade_ticks, + TEST_FLAG(presentation->flags, _presentation_fading_out_bit), + game_time_get() - presentation->fade_elapsed); + coop_presentation.fade_start_time = presentation->fade_start_time; + } + + skip_vote.offered = TEST_FLAG(presentation->flags, _presentation_skippable_bit); + skip_vote.votes = presentation->skip_votes; + skip_vote.voters = presentation->skip_voters; + client_apply_hud_state(presentation); +} + +/* client, each tick: plays the next of the host's presentations. It moves +one host tick a tick, two when it has fallen behind, and waits when it has +caught up with the newest; a lost tick's place is taken by the one before. */ +static void client_play_presentation( + void) +{ + struct distributed_coop_presentation const *presentation = NULL; + long lag, best_time = NONE; + short index; + + if (!coop_presentation.heard_any) + return; + lag = coop_presentation.newest_time - coop_presentation.played_time; + if (!coop_presentation.playing || lag > PRESENTATION_BUFFER_COUNT || lag < -PRESENTATION_BUFFER_COUNT) + coop_presentation.played_time = coop_presentation.newest_time - PRESENTATION_DELAY_TICKS; + else if (lag > PRESENTATION_DELAY_TICKS + 1) + coop_presentation.played_time += 2; + else if (lag > 0) + coop_presentation.played_time++; + else + return; + coop_presentation.playing = TRUE; + for (index = 0; index < PRESENTATION_BUFFER_COUNT; index++) + { + long time = coop_presentation.buffered_times[index]; + + if (time <= coop_presentation.played_time && time > coop_presentation.played_time - PRESENTATION_BUFFER_COUNT && + time > best_time) + { + best_time = time; + presentation = &coop_presentation.buffered[index]; + } + } + if (presentation) + client_presentation_apply(presentation); +} diff --git a/port/linux/game/network_coop.h b/port/linux/game/network_coop.h new file mode 100644 index 00000000..4364208f --- /dev/null +++ b/port/linux/game/network_coop.h @@ -0,0 +1,96 @@ +/* +NETWORK_COOP.H + +The calls Halo's own code (source/) makes into network_coop.c. On a co-op +host the note_ functions send what the scripts just did to the clients; on +any other machine they do nothing, so the clients can call the same engine +functions to apply what they receive. +*/ + +#ifndef __NETWORK_COOP_H +#define __NETWORK_COOP_H + +/* ---------- constants */ + +/* network_coop_note_hud */ +enum +{ + _coop_hud_help_text, + _coop_hud_objective, + _coop_hud_help_flash, + _coop_hud_messages_clear, + _coop_hud_checkpoint, +}; + +/* network_coop_note_player_effect */ +enum +{ + _coop_player_effect_translation, + _coop_player_effect_rotation, + _coop_player_effect_start, + _coop_player_effect_stop, +}; + +/* network_coop_note_nav_point */ +enum +{ + _coop_nav_point_team_flag, + _coop_nav_point_team_object, + _coop_nav_point_unit_flag, + _coop_nav_point_unit_object, +}; + +/* ---------- prototypes/NETWORK_COOP.C */ + +/* whether this is a network co-op game (not its lobby, whose menu scene also +has no game engine) */ +boolean network_coop_active(void); +/* whether this machine is a co-op client, whose devices only the host moves */ +boolean network_coop_devices_remote(void); +/* devices.c: a group's devices were set straight to its value */ +void network_coop_note_device_snap(short group_index); +/* unit_scripting_commands.c: a script set a unit's maximum or current +vitality; TRUE if it was a co-op player's, now set on every player's */ +boolean network_coop_set_players_vitality(long unit_index, boolean maximum, real body, real shield); +/* units.c: a vehicle left the vehicle carrying it (a Pelican's Warthog drop) */ +void network_coop_vehicle_dropped(long vehicle_index, long carrier_index); + +/* cinematics.c: a chapter title */ +void network_coop_note_title(short title_index, real delay); +/* hud_messaging.c, hud.c: value is a message index, or TRUE/FALSE */ +void network_coop_note_hud(short kind, short value); +/* player_effects.c: the script screen shake (a, b, c as the function takes them) */ +void network_coop_note_player_effect(short kind, real a, real b, real c); +/* hud_nav_points.c: activate (or, with nav_index NONE, deactivate) a nav point. +target is the team or the unit; marker is the cutscene flag or the object. */ +void network_coop_note_nav_point(short kind, short nav_index, long target, long marker, real vertical_offset); + +/* units.c: a unit started a custom animation (animation_index NONE: stopped) */ +void network_coop_note_unit_animation(long unit_index, long animation_graph_index, short animation_index, + boolean interpolate); +/* units.c: unit_custom_animation_at_frame moved it to a frame */ +void network_coop_note_unit_animation_frame(long unit_index, short frame_index); +/* scenery.c: a scenery animation started */ +/* the scripts' effects (hs_library_external.c): at a cutscene flag, or on an +object's marker */ +void network_coop_note_effect(long effect_definition_index, short cutscene_flag_index); +/* the scripts' objects_attach and objects_detach (objects.c) */ +void network_coop_note_attach(long parent_index, char const *parent_marker_name, long child_index, + char const *child_marker_name); +void network_coop_note_detach(long parent_index, long child_index); +void network_coop_note_object_effect(long effect_definition_index, long object_index, char const *marker_name); +void network_coop_note_scenery_animation(long object_index, long animation_graph_index, short animation_index, + short frame_index); + +/* player_control.c: whether the skip key should do anything, and the press. +network_coop_vote_skip returns FALSE outside network co-op, where the +cinematic is skipped as usual. */ +boolean network_coop_skip_offered(void); +boolean network_coop_vote_skip(void); +/* main.c: after a co-op host reverts to skip a cinematic; now is the game +time before the revert */ +void network_coop_skip_reverted(long now); +/* the vote count to show, if a skippable cinematic is playing */ +boolean network_coop_skip_vote_status(short *votes, short *voters, boolean *voted); + +#endif diff --git a/port/linux/game/network_damage.c b/port/linux/game/network_damage.c index 0c23555d..5ca04586 100644 --- a/port/linux/game/network_damage.c +++ b/port/linux/game/network_damage.c @@ -36,6 +36,7 @@ being hit looks and feels like on the clients). */ #include "cseries.h" +#include "cseries/errors.h" #include "game/game.h" #include "game/game_globals.h" #include "game/players.h" @@ -1939,6 +1940,26 @@ static boolean distributed_event_goes_to( return FALSE; } +/* How often the host logs how many client hits it accepted and refused +(only when it refused some). Tells "my shots don't register" apart from +"I missed". */ +#define REPORT_LOG_TICKS (30 * TICKS_PER_SECOND) + +static void distributed_log_reports( + void) +{ + static long logged_dealt; + static long logged_rejected; + + if (game_time_get() % REPORT_LOG_TICKS != 0 || damage_rejected_reports == logged_rejected) + return; + error(_error_silent, "damage: clients' hits in the last %d seconds: %ld dealt, %ld refused", + REPORT_LOG_TICKS / TICKS_PER_SECOND, damage_dealt_reports - logged_dealt, + damage_rejected_reports - logged_rejected); + logged_dealt = damage_dealt_reports; + logged_rejected = damage_rejected_reports; +} + void network_damage_host_tick( void) { @@ -1949,6 +1970,7 @@ void network_damage_host_tick( distributed_client_machines(machine_indices, HALO_PORT_MAXIMUM_NETWORK_MACHINES) : 0; short machine_number; + distributed_log_reports(); distributed_note_weapons(); distributed_note_targets(); distributed_note_event_destinations(); diff --git a/port/linux/game/network_distributed.c b/port/linux/game/network_distributed.c index 2b773cc6..c5e882d0 100644 --- a/port/linux/game/network_distributed.c +++ b/port/linux/game/network_distributed.c @@ -53,18 +53,24 @@ machine (their datum identifiers need not be). #include "cseries.h" #include "cseries/errors.h" +#include "cache/cache_files.h" +#include "models/model_animation_definitions.h" #include "game/game.h" #include "game/game_globals.h" #include "game/players.h" +#include "game/game_engine.h" +#include "main/main.h" #include "game/player_queues_new.h" #include "networking/network_game_globals.h" #include "objects/objects.h" #include "objects/damage.h" #include "scenario/scenario.h" +#include "scenario/scenario_definitions.h" #include "structures/structure_bsp_definitions.h" #include "units/units.h" #include "units/biped_definitions.h" #include "units/bipeds.h" +#include "network_coop.h" #include "network_distributed.h" #include @@ -238,11 +244,8 @@ before the host takes it no longer, and before the client is put where the host has it (no further than that: between the two they would disagree for good, the host's player somewhere its own is not) */ #define HOST_ACCEPT_TOLERANCE 3.5f -#define REMOTE_CORRECTION_TOLERANCE 0.05f #define LOCAL_CORRECTION_TOLERANCE 3.0f -/* how far from the origin a unit is (world units), and how fast a client's -own player's unit moves at most (world units a tick) */ -#define UNIT_WORLD_BOUND 32768.0f +/* how fast a client's own player's unit moves at most (world units a tick) */ #define MAXIMUM_PREDICTED_SPEED 2.0f /* ... on foot: this many times as fast as a player runs and jumps, or as fast as the host's ticks threw its copy lately, whichever is more; and how @@ -666,6 +669,40 @@ boolean distributed_point_valid( return fabsf(point->x) <= bound && fabsf(point->y) <= bound && fabsf(point->z) <= bound; } +/* whether a tag index from the host really is a tag of that group */ +boolean distributed_tag_of_group( + long tag_index, + unsigned long group_tag) +{ + struct tag_iterator iterator; + long index; + + tag_iterator_new(&iterator, group_tag); + while ((index = tag_iterator_next(&iterator)) != NONE) + { + if (index == tag_index) + return TRUE; + } + + return FALSE; +} + +/* the graph's animation, if the graph tag and index are valid */ +struct animation *distributed_graph_animation( + long animation_graph_index, + short animation_index) +{ + struct animation_graph *graph; + + if (!distributed_tag_of_group(animation_graph_index, ANIMATION_GRAPH_TAG)) + return NULL; + graph = animation_graph_definition_get(animation_graph_index); + if (animation_index < 0 || animation_index >= graph->animations.count) + return NULL; + + return TAG_BLOCK_GET_ELEMENT(&graph->animations, animation_index, struct animation); +} + boolean distributed_object_index_valid( long object_index) { @@ -749,7 +786,7 @@ void distributed_unit_vector_unpack( } } -static word distributed_vitality_pack( +word distributed_vitality_pack( real value) { value *= VITALITY_SCALE; @@ -757,15 +794,13 @@ static word distributed_vitality_pack( return (word)(long)floor(value + 0.5f); } -static real distributed_vitality_unpack( +real distributed_vitality_unpack( word value) { return (real)value / VITALITY_SCALE; } -/* an angle as a 16-bit fraction of a turn, and back (yaw from 0 to 2 pi, -pitch from -pi to pi) */ -static short distributed_angle_pack( +short distributed_angle_pack( real angle) { real turns = angle / (2.0f * _pi); @@ -774,7 +809,7 @@ static short distributed_angle_pack( return (short)(word)((long)floor(turns * 65536.0f + 0.5f) & 0xFFFF); } -static real distributed_angle_unpack( +real distributed_angle_unpack( short value, boolean signed_angle) { @@ -2232,6 +2267,37 @@ static void distributed_client_send_inputs( } } +/* (the host, in co-op) where each client player's input last faced, by +absolute index: how far it turns between inputs is its looking around */ +static struct +{ + boolean valid; + real_euler_angles2d facing; +} distributed_input_facings[MAXIMUM_TRACKED_PLAYERS]; + +/* (the host, in co-op) a client player's input counted in the scripts' +action tests, as a local player's is (player_control_action_test_note) */ +static void distributed_note_input_actions( + short player_index, + struct player_action const *action) +{ + real_euler_angles2d turn = { 0.0f, 0.0f }; + + if (distributed_input_facings[player_index].valid) + { + turn.yaw = action->desired_facing.yaw - distributed_input_facings[player_index].facing.yaw; + turn.pitch = action->desired_facing.pitch - distributed_input_facings[player_index].facing.pitch; + /* (the short way round) */ + if (turn.yaw > _pi) + turn.yaw -= 2.0f * _pi; + else if (turn.yaw < -_pi) + turn.yaw += 2.0f * _pi; + } + distributed_input_facings[player_index].valid = TRUE; + distributed_input_facings[player_index].facing = action->desired_facing; + player_control_action_test_note(action->control_flags, &turn, &action->throttle, action->primary_trigger); +} + /* (the host) a client's players' input, and from it (once a message) how long a message takes that client and back */ static void distributed_handle_inputs( @@ -2261,6 +2327,9 @@ static void distributed_handle_inputs( action.throttle.i = PIN(action.throttle.i, -1.0f, 1.0f); action.throttle.j = PIN(action.throttle.j, -1.0f, 1.0f); action.primary_trigger = PIN(action.primary_trigger, 0.0f, 1.0f); + /* (a spectator's buttons pick whom it watches: not counted) */ + if (network_coop_active() && player->unit_index != NONE) + distributed_note_input_actions(input->player_index, &action); update_server_handle_distributed_input(DATUM_INDEX_NEW(input->player_index, player->identifier), input->tick, &action, input->control_flags, DISTRIBUTED_INPUT_HISTORY); if (input->host_time != NONE && (host_time == NONE || input->host_time > host_time)) @@ -2931,6 +3000,45 @@ static void distributed_send_pings( } } +/* Co-op: the host's structure BSP. Clients switch BSP only when told +(their own trigger volumes don't, players.c). It is resent regularly so a +client that lost a message or joined late catches up. */ +#define STRUCTURE_BSP_INTERVAL_TICKS (TICKS_PER_SECOND / 2) + +struct distributed_structure_bsp +{ + short structure_bsp_index; + short pad; +}; + +struct distributed_structure_bsp_message +{ + struct distributed_message_header header; + struct distributed_structure_bsp structure_bsp; +}; + +static void distributed_send_structure_bsp( + void) +{ + struct distributed_structure_bsp_message message; + + message.structure_bsp.structure_bsp_index = global_structure_bsp_index_get(); + message.structure_bsp.pad = 0; + distributed_send(&message, _distributed_message_structure_bsp, 1, (word)sizeof(message), _distributed_to_clients); +} + +static void distributed_handle_structure_bsp( + struct distributed_structure_bsp const *structure_bsp) +{ + short index = structure_bsp->structure_bsp_index; + + if (network_coop_active() && index >= 0 && index < global_scenario_get()->structure_bsp_references.count && + index != global_structure_bsp_index_get()) + { + main_switch_structure_bsp(index); + } +} + /* the players' statistics that changed since they were last sent, and when refreshing, STATISTICS_REFRESH_PLAYERS more whatever they are, round them all (a client that lost a change has it again within eight seconds) */ @@ -3076,6 +3184,7 @@ void network_distributed_new_game( csmemset(distributed_accepted, 0, sizeof(distributed_accepted)); csmemset(distributed_host_speeds, 0, sizeof(distributed_host_speeds)); csmemset(distributed_round_trips, 0, sizeof(distributed_round_trips)); + csmemset(distributed_input_facings, 0, sizeof(distributed_input_facings)); csmemset(distributed_client_clocks, 0, sizeof(distributed_client_clocks)); csmemset(distributed_client_identities, 0, sizeof(distributed_client_identities)); distributed_identity_sent = FALSE; @@ -3134,6 +3243,8 @@ void network_distributed_new_game( update_queues_distributed_reset(); network_objects_new_game(); network_damage_new_game(); + network_actors_new_game(); + network_coop_new_game(); } /* after each tick (game_time.c) */ @@ -3177,6 +3288,10 @@ void network_distributed_tick( if (game_time_get() % PING_INTERVAL_TICKS == 0) distributed_send_pings(); distributed_host_send_players(); + network_actors_host_tick(); + if (network_coop_active() && game_time_get() % STRUCTURE_BSP_INTERVAL_TICKS == 0) + distributed_send_structure_bsp(); + network_coop_host_tick(); distributed_send_pickups(); if (game_time_get() % GAME_STATE_INTERVAL_TICKS == 0) distributed_send_game_state(NONE); @@ -3188,6 +3303,7 @@ void network_distributed_tick( distributed_client_send_predictions(); network_objects_client_tick(); network_damage_client_tick(); + network_coop_client_tick(); } distributed_batches_flush(); distributed_machines.in_tick = FALSE; @@ -3215,6 +3331,18 @@ static boolean distributed_message_stale( case _distributed_message_relayed_actions: case _distributed_message_damage_events: case _distributed_message_pings: + case _distributed_message_actor_states: + case _distributed_message_structure_bsp: + case _distributed_message_coop_presentation: + case _distributed_message_coop_device_groups: + case _distributed_message_coop_object_names: + case _distributed_message_coop_skip_vote: + case _distributed_message_coop_events: + case _distributed_message_coop_object_transforms: + case _distributed_message_actor_damage: + case _distributed_message_coop_object_looks: + case _distributed_message_damage_animations: + case _distributed_message_coop_screen_effect: break; default: return FALSE; @@ -3652,6 +3780,18 @@ void network_distributed_handle_message( case _distributed_message_unit_states: entry_size = DISTRIBUTED_UNIT_STATE_MINIMUM_SIZE; break; case _distributed_message_player_statistics: entry_size = sizeof(struct distributed_player_statistics); break; case _distributed_message_pings: entry_size = sizeof(struct distributed_player_ping); break; + case _distributed_message_actor_states: entry_size = network_actors_entry_size(); break; + case _distributed_message_structure_bsp: entry_size = sizeof(struct distributed_structure_bsp); break; + case _distributed_message_coop_presentation: entry_size = network_coop_presentation_entry_size(); break; + case _distributed_message_coop_events: entry_size = network_coop_event_entry_size(); break; + case _distributed_message_coop_skip_vote: entry_size = network_coop_skip_vote_entry_size(); break; + case _distributed_message_coop_device_groups: entry_size = network_coop_device_group_entry_size(); break; + case _distributed_message_coop_object_names: entry_size = network_coop_object_names_entry_size(); break; + case _distributed_message_coop_object_transforms: entry_size = network_coop_object_transform_entry_size(); break; + case _distributed_message_actor_damage: entry_size = network_actors_damage_entry_size(); break; + case _distributed_message_coop_object_looks: entry_size = network_coop_object_look_entry_size(); break; + case _distributed_message_damage_animations: entry_size = network_objects_damage_animation_entry_size(); break; + case _distributed_message_coop_screen_effect: entry_size = network_coop_screen_effect_entry_size(); break; case _distributed_message_pickups: entry_size = sizeof(struct distributed_pickup); break; case _distributed_message_player_inputs: entry_size = sizeof(struct distributed_player_input); break; case _distributed_message_relayed_actions: entry_size = DISTRIBUTED_RELAYED_ACTION_MINIMUM_SIZE; break; @@ -3680,6 +3820,7 @@ void network_distributed_handle_message( case _distributed_message_hit_reports: case _distributed_message_vehicle_prediction: case _distributed_message_player_inputs: + case _distributed_message_coop_skip_vote: if (machine_index == NONE || game_connection() != _game_connection_network_server) return; break; @@ -3716,6 +3857,42 @@ void network_distributed_handle_message( case _distributed_message_unit_states: distributed_handle_unit_states((byte const *)entries, (byte const *)message + size, header.count); break; + case _distributed_message_actor_states: + network_actors_handle_states(entries, header.count); + break; + case _distributed_message_structure_bsp: + distributed_handle_structure_bsp((struct distributed_structure_bsp const *)entries); + break; + case _distributed_message_coop_presentation: + network_coop_handle_presentation(entries, header.game_time); + break; + case _distributed_message_coop_events: + network_coop_handle_events(entries, header.count); + break; + case _distributed_message_coop_skip_vote: + network_coop_handle_skip_vote(machine_index, entries); + break; + case _distributed_message_coop_device_groups: + network_coop_handle_device_groups(entries, header.count); + break; + case _distributed_message_coop_object_names: + network_coop_handle_object_names(entries); + break; + case _distributed_message_coop_object_transforms: + network_coop_handle_object_transforms(entries, header.count); + break; + case _distributed_message_actor_damage: + network_actors_handle_damage(entries, header.count); + break; + case _distributed_message_coop_object_looks: + network_coop_handle_object_looks(entries, header.count); + break; + case _distributed_message_damage_animations: + network_objects_handle_damage_animations(entries, header.count); + break; + case _distributed_message_coop_screen_effect: + network_coop_handle_screen_effect(entries, header.count); + break; case _distributed_message_player_statistics: { /* the host's count of kills, deaths, ... */ diff --git a/port/linux/game/network_distributed.h b/port/linux/game/network_distributed.h index b55c64cc..6e2462a6 100644 --- a/port/linux/game/network_distributed.h +++ b/port/linux/game/network_distributed.h @@ -65,6 +65,44 @@ enum /* every player's ping as the host measures it, every two seconds, for the scoreboard (unreliable) */ _distributed_message_pings, + /* the host's actors' units: their control and state (network_actors.c). + A number of its own, clear of the kinds upstream adds: a build without + it drops the message as a kind it does not know, so the network version + stays upstream's and its players join as before */ + _distributed_message_actor_states = 64, + /* co-op (a campaign map, no game engine): the host's structure BSP, + twice a second, for a client to switch to; numbered as actor_states */ + _distributed_message_structure_bsp, + /* co-op: the host's cinematic, camera and screen fade, every tick + (network_coop.c) */ + _distributed_message_coop_presentation, + /* (0.2.0's co-op script sounds; now part of coop_events. The number + stays taken so a 0.2.0 build never misreads a newer message.) */ + _distributed_message_coop_sounds_retired, + /* co-op: device group state (network_coop.c) */ + _distributed_message_coop_device_groups, + /* co-op: which named objects exist on the host (network_coop.c) */ + _distributed_message_coop_object_names, + /* co-op: a client's vote to skip the cutscene, to the host (network_coop.c) */ + _distributed_message_coop_skip_vote, + /* co-op: what the host's scripts did once: sounds, titles, HUD text, + animations (network_coop.c) */ + _distributed_message_coop_events, + /* co-op: where the host's scenery and machines are, when they move + (network_coop.c) */ + _distributed_message_coop_object_transforms, + /* the damage the host's AI units are taking, which their shields' + flares are drawn by (network_actors.c); 73 in upstream's AI sync too */ + _distributed_message_actor_damage = 73, + /* co-op: how the host's objects look (their permutations, scale), when + that changes (network_coop.c) */ + _distributed_message_coop_object_looks, + /* the flinch and death animations the host picked for its units, which a + client plays rather than its own picks (network_objects.c) */ + _distributed_message_damage_animations = 75, + /* co-op: the host's cinematic screen effect (blur, filters, video), when + it changes and every two seconds (network_coop.c) */ + _distributed_message_coop_screen_effect = 76, NUMBER_OF_DISTRIBUTED_MESSAGES }; @@ -86,6 +124,10 @@ tick, drawn as it goes; further, it is put there, drawn gliding #define HOST_BLEND_DISTANCE 0.25f #define HOST_VEHICLE_BLEND_DISTANCE 0.5f #define REMOTE_BLEND_DISTANCE 1.0f +/* world units: how far a remote unit may be from where the host says +before it is put there, and how far from the origin a unit can be */ +#define REMOTE_CORRECTION_TOLERANCE 0.05f +#define UNIT_WORLD_BOUND 32768.0f #define REMOTE_VEHICLE_BLEND_DISTANCE 2.0f enum @@ -183,6 +225,10 @@ never 0, which any object at the index matches) */ boolean distributed_real_valid(real value); boolean distributed_point_valid(real_point3d const *point, real bound); boolean distributed_object_index_valid(long object_index); +/* whether a tag index from the host really is a tag of that group */ +boolean distributed_tag_of_group(long tag_index, unsigned long group_tag); +/* the graph's animation, if a graph tag and animation index from the host are valid */ +struct animation *distributed_graph_animation(long animation_graph_index, short animation_index); /* ... an orientation's two axes (unpacked): TRUE when they are one long and about square, then made exactly so */ boolean distributed_axes_make_valid(real_vector3d *forward, real_vector3d *up); @@ -193,12 +239,68 @@ void distributed_unit_vector_unpack(struct distributed_vector const *vector, rea #define DISTRIBUTED_UNIT_SCALE 32767.0f #define DISTRIBUTED_VELOCITY_SCALE 1024.0f #define DISTRIBUTED_ANGULAR_VELOCITY_SCALE 4096.0f +/* an angle as a 16-bit fraction of a turn, and back (yaw from 0 to 2 pi, +pitch from -pi to pi) */ +short distributed_angle_pack(real angle); +real distributed_angle_unpack(short value, boolean signed_angle); +/* shields and health in 16 bits */ +word distributed_vitality_pack(real value); +real distributed_vitality_unpack(word value); + +/* ---------- prototypes/NETWORK_ACTORS.C */ + +void network_actors_new_game(void); +/* (the host, after each tick) the units its actors drove this tick, to +each client */ +void network_actors_host_tick(void); +/* (a client) the host's word on its actors' units */ +void network_actors_handle_states(void const *entries, short count); +word network_actors_entry_size(void); +/* (a client, in its tick where the host runs its actors) each actor's unit +given the control the host last sent for it */ +void network_actors_drive(void); + +/* ---------- prototypes/NETWORK_COOP.C */ + +void network_coop_new_game(void); +/* after each tick, on the host and on a client */ +void network_coop_host_tick(void); +void network_coop_client_tick(void); +/* the message handlers, and their entry sizes */ +word network_coop_presentation_entry_size(void); +void network_coop_handle_presentation(void const *entries, long host_time); +word network_coop_event_entry_size(void); +void network_coop_handle_events(void const *entries, short count); +word network_coop_device_group_entry_size(void); +void network_coop_handle_device_groups(void const *entries, short count); +word network_coop_object_names_entry_size(void); +void network_coop_handle_object_names(void const *entries); +word network_coop_object_transform_entry_size(void); +word network_coop_object_look_entry_size(void); +void network_coop_handle_object_looks(void const *entries, short count); +word network_coop_screen_effect_entry_size(void); +void network_coop_handle_screen_effect(void const *entries, short count); +word network_actors_damage_entry_size(void); +void network_actors_handle_damage(void const *entries, short count); +void network_coop_handle_object_transforms(void const *entries, short count); +word network_coop_skip_vote_entry_size(void); +void network_coop_handle_skip_vote(long machine_index, void const *entries); +/* game_sound.c: a sound the host's scripts played */ +enum +{ + _coop_sound_impulse, + _coop_sound_looping_start, + _coop_sound_looping_stop, +}; +void network_coop_note_sound(short kind, long definition_index, long object_index, real scale); /* ---------- prototypes/NETWORK_OBJECTS.C */ void network_objects_new_game(void); /* after each tick */ void network_objects_host_tick(void); +word network_objects_damage_animation_entry_size(void); +void network_objects_handle_damage_animations(void const *entries, short count); void network_objects_client_tick(void); /* (the host) a client has loaded the game and asks for the host's objects: again, having failed to make one of them */ @@ -225,6 +327,9 @@ boolean network_objects_reconcile(long object_index, real_point3d const *positio real blend_distance); /* a unit in the vehicle's seat as the host has it (NONE: in none) */ void network_objects_set_seat(long unit_index, long vehicle_index, short seat_index); +/* (the host) the ticks between sends to a machine of a unit at position, by +how near that machine's players are (network_actors.c) */ +short network_objects_send_period(long machine_index, real_point3d const *position); /* ---------- prototypes/NETWORK_DAMAGE.C */ diff --git a/port/linux/game/network_objects.c b/port/linux/game/network_objects.c index e592f6af..76d21094 100644 --- a/port/linux/game/network_objects.c +++ b/port/linux/game/network_objects.c @@ -50,6 +50,7 @@ same datum index (identifier and all), so that any message can name one: #include "objects/objects.h" #include "objects/damage.h" #include "objects/object_definitions.h" +#include "models/model_animation_definitions.h" #include "models/model_definitions.h" #include "units/units.h" #include "units/biped_definitions.h" @@ -59,6 +60,8 @@ same datum index (identifier and all), so that any message can name one: #include "items/weapons.h" #include "items/weapon_definitions.h" #include "items/equipment_definitions.h" +#include "cutscene/cinematics.h" +#include "network_coop.h" #include "network_distributed.h" #include @@ -329,11 +332,14 @@ static struct } objects_host_inventories[MAXIMUM_TRACKED_OBJECTS]; /* ... each client machine, found once a tick: where its players' living units are, those units, and the vehicles they drive (with the player's -absolute index) */ +absolute index); and where every player's living unit is, for a machine +with none (dead or joining, it watches one of them) */ static struct { short count; long indices[HALO_PORT_MAXIMUM_NETWORK_MACHINES]; + short player_unit_count; + real_point3d player_unit_origins[MAXIMUM_TRACKED_PLAYERS]; struct { short unit_count; @@ -637,6 +643,30 @@ static void distributed_vector_clamp( } } +/* Whether the object is a vehicle no player drives (a Pelican on its flight +path): nothing on a client steers it, so its copy is kept at rest there +(no physics of its own) and moved only to where the host has it. */ +static boolean distributed_vehicle_unsteered( + long object_index) +{ + struct unit_datum *vehicle; + + if (object_get(object_index)->object.type != _object_type_vehicle) + return FALSE; + vehicle = unit_get(object_index); + return vehicle->unit.driver_object_index == NONE || + unit_get(vehicle->unit.driver_object_index)->unit.player_index == NONE; +} + +/* Whether a client puts its copy exactly where the host has it, rather than +closing on it: an unsteered vehicle, and anything in a co-op cutscene +(where it keeps up with the host's camera). */ +static boolean distributed_object_follows_host( + long object_index) +{ + return (network_coop_active() && cinematic_in_progress()) || distributed_vehicle_unsteered(object_index); +} + /* (the transform checked) */ static void distributed_object_move( long object_index, @@ -658,7 +688,10 @@ static void distributed_object_move( object->object.translational_velocity = *velocity; if (angular_velocity) object->object.angular_velocity = *angular_velocity; - render_interpolation_correct_object(object_index, &offset); + if (!distributed_object_follows_host(object_index)) + render_interpolation_correct_object(object_index, &offset); + if (distributed_vehicle_unsteered(object_index)) + SET_FLAG(object->object.flags, _object_at_rest_bit, TRUE); } void network_objects_correct( @@ -703,15 +736,33 @@ boolean network_objects_reconcile( distributed_object_move(object_index, position, &valid_forward, &valid_up, velocity, angular_velocity); return TRUE; } - /* (half of the way: the tick's snapshots draw it moving, no jump) */ - blended.x = object->object.position.x + dx * 0.5f; - blended.y = object->object.position.y + dy * 0.5f; - blended.z = object->object.position.z + dz * 0.5f; - object_set_position(object_index, &blended, &valid_forward, &valid_up); + /* Move halfway, and draw the move as a glide over the next few frames, + as larger corrections are. Drawn as one step, a unit corrected every tick + or two (a distant enemy) visibly hitched each time. */ + if (distributed_object_follows_host(object_index)) + blended = *position; + else + { + blended.x = object->object.position.x + dx * 0.5f; + blended.y = object->object.position.y + dy * 0.5f; + blended.z = object->object.position.z + dz * 0.5f; + } + { + real_vector3d offset; + + offset.i = object->object.position.x - blended.x; + offset.j = object->object.position.y - blended.y; + offset.k = object->object.position.z - blended.z; + object_set_position(object_index, &blended, &valid_forward, &valid_up); + if (!distributed_object_follows_host(object_index)) + render_interpolation_correct_object(object_index, &offset); + } if (velocity) object->object.translational_velocity = *velocity; if (angular_velocity) object->object.angular_velocity = *angular_velocity; + if (distributed_vehicle_unsteered(object_index)) + SET_FLAG(object->object.flags, _object_at_rest_bit, TRUE); return FALSE; } @@ -766,6 +817,175 @@ static long distributed_driven_vehicle( object_get(unit_index)->object.parent_object_index : NONE; } +/* ---------- damage animations + +A unit's flinches and deaths are animations picked by where the damage came +from and at random among their permutations, so a client replaying the +host's damage could pick others. The host sends its picks (each in three +ticks' messages, in case one is lost). A client plays the host's pick when +it gets there first, or switches to it if its own has only just begun +(unit_port_correct_damage_animation). */ + +enum +{ + DAMAGE_ANIMATION_SENDS = 3, + MAXIMUM_QUEUED_DAMAGE_ANIMATIONS = 32, + /* how long a client keeps the host's pick for a hit it hasn't replayed yet */ + DAMAGE_ANIMATION_KEPT_TICKS = 2 * TICKS_PER_SECOND, +}; + +/* type: units.h's _unit_damage_animation_soft_ping and the rest; number: +the pick's, so a client tells a pick sent again from a new one */ +struct distributed_damage_animation +{ + long unit_index; + short animation_index; + byte type; + byte number; +}; + +struct distributed_damage_animations_message +{ + struct distributed_message_header header; + struct distributed_damage_animation animations[MAXIMUM_QUEUED_DAMAGE_ANIMATIONS]; +}; + +/* host: the picks still to be sent */ +static struct +{ + struct distributed_damage_animation animation; + short sends; +} host_damage_animations[MAXIMUM_QUEUED_DAMAGE_ANIMATIONS]; +static short host_damage_animation_count; +static byte host_damage_animation_number; + +/* client: the host's latest pick of each type for each unit, by absolute +index: its number, and when it came (NONE once played) */ +static struct +{ + long unit_index; + short animation_index[NUMBER_OF_UNIT_DAMAGE_ANIMATIONS]; + byte number[NUMBER_OF_UNIT_DAMAGE_ANIMATIONS]; + long time[NUMBER_OF_UNIT_DAMAGE_ANIMATIONS]; +} client_damage_animations[MAXIMUM_TRACKED_OBJECTS]; + +/* whether the animation is one of the unit's graph */ +static boolean distributed_unit_has_animation( + long unit_index, + short animation_index) +{ + long graph_index = unit_definition_get(unit_get(unit_index)->definition_index)->object.animation_graph.index; + + return graph_index != NONE && animation_index >= 0 && + animation_index < animation_graph_definition_get(graph_index)->animations.count; +} + +/* units.c, as a unit flinches or dies: the host notes the animation it +picked; a client takes the host's pick when it has one */ +short network_objects_damage_animation( + long unit_index, + short type, + short animation_index) +{ + short connection = game_connection(); + long absolute_index = DATUM_INDEX_TO_ABSOLUTE_INDEX(unit_index); + + if (type < 0 || type >= NUMBER_OF_UNIT_DAMAGE_ANIMATIONS) + return animation_index; + if (connection == _game_connection_network_server && animation_index != NONE && + host_damage_animation_count < MAXIMUM_QUEUED_DAMAGE_ANIMATIONS) + { + struct distributed_damage_animation *animation = + &host_damage_animations[host_damage_animation_count].animation; + + animation->unit_index = unit_index; + animation->animation_index = animation_index; + animation->type = (byte)type; + animation->number = ++host_damage_animation_number; + host_damage_animations[host_damage_animation_count++].sends = DAMAGE_ANIMATION_SENDS; + } + else if (connection == _game_connection_network_client && absolute_index >= 0 && + absolute_index < MAXIMUM_TRACKED_OBJECTS && client_damage_animations[absolute_index].unit_index == unit_index) + { + long *time = &client_damage_animations[absolute_index].time[type]; + + if (*time != NONE && game_time_get() - *time < DAMAGE_ANIMATION_KEPT_TICKS) + { + *time = NONE; + return client_damage_animations[absolute_index].animation_index[type]; + } + } + return animation_index; +} + +/* host, each tick: the picks still to be sent, to every client */ +static void distributed_host_send_damage_animations( + void) +{ + struct distributed_damage_animations_message message; + short count = 0; + short index; + + for (index = 0; index < host_damage_animation_count; index++) + { + message.animations[count++] = host_damage_animations[index].animation; + if (--host_damage_animations[index].sends <= 0) + host_damage_animations[index--] = host_damage_animations[--host_damage_animation_count]; + } + if (count > 0) + { + distributed_send(&message, _distributed_message_damage_animations, count, + (word)(sizeof(message.header) + count * sizeof(message.animations[0])), _distributed_to_clients); + } +} + +word network_objects_damage_animation_entry_size( + void) +{ + return sizeof(struct distributed_damage_animation); +} + +void network_objects_handle_damage_animations( + void const *entries, + short count) +{ + struct distributed_damage_animation const *animations = entries; + short index; + + for (index = 0; index < count; index++) + { + struct distributed_damage_animation const *animation = &animations[index]; + long absolute_index = DATUM_INDEX_TO_ABSOLUTE_INDEX(animation->unit_index); + short type; + + if (!distributed_object_index_valid(animation->unit_index) || absolute_index >= MAXIMUM_TRACKED_OBJECTS || + animation->type >= NUMBER_OF_UNIT_DAMAGE_ANIMATIONS || !network_objects_client_has(animation->unit_index) || + !object_try_and_get_and_verify_type(animation->unit_index, _object_mask_unit) || + !distributed_unit_has_animation(animation->unit_index, animation->animation_index)) + { + continue; + } + if (client_damage_animations[absolute_index].unit_index != animation->unit_index) + { + csmemset(&client_damage_animations[absolute_index], 0, sizeof(client_damage_animations[absolute_index])); + client_damage_animations[absolute_index].unit_index = animation->unit_index; + for (type = 0; type < NUMBER_OF_UNIT_DAMAGE_ANIMATIONS; type++) + client_damage_animations[absolute_index].time[type] = NONE; + } + /* (a pick sent again) */ + else if (client_damage_animations[absolute_index].number[animation->type] == animation->number) + { + continue; + } + client_damage_animations[absolute_index].number[animation->type] = animation->number; + client_damage_animations[absolute_index].animation_index[animation->type] = animation->animation_index; + /* (replayed here already: switched to the host's if it has only just begun) */ + client_damage_animations[absolute_index].time[animation->type] = + unit_port_correct_damage_animation(animation->unit_index, animation->type, animation->animation_index) ? + NONE : game_time_get(); + } +} + /* ---------- the host */ static void distributed_change_from_object( @@ -877,6 +1097,30 @@ static void distributed_host_update_objects( } } +/* The order a newly loaded client receives the host's objects in, most +needed first: players' units (it may spectate them), the vehicles players +and AI ride, other units, then everything else. */ +enum +{ + _send_rank_player_unit, + _send_rank_vehicle, + _send_rank_unit, + _send_rank_other, + NUMBER_OF_SEND_RANKS +}; + +static short distributed_object_send_rank( + long object_index) +{ + struct object_datum *object = object_get(object_index); + + if (object->object.type == _object_type_vehicle) + return _send_rank_vehicle; + if (object_try_and_get_and_verify_type(object_index, _object_mask_unit)) + return unit_get(object_index)->unit.player_index != NONE ? _send_rank_player_unit : _send_rank_unit; + return _send_rank_other; +} + /* a client has loaded the game: every object the host has, to it alone, and word that that is all of them; asked again, only the word (they are on their way ahead of it), unless it failed to make one since it last asked @@ -889,6 +1133,7 @@ void network_objects_client_asked( short count = 0; short limit = MIN(MAXIMUM_ENTRIES_PER_MESSAGE, RELIABLE_ENTRIES(struct distributed_object_change)); long absolute_index; + short rank; long *player_list; if (machine_index < 0 || machine_index >= HALO_PORT_MAXIMUM_NETWORK_MACHINES) @@ -916,14 +1161,22 @@ void network_objects_client_asked( /* (what every unit carries, with the next of them) */ objects_host_inventories[absolute_index].checksum = 0; objects_host_inventories[absolute_index].weapons_checksum = 0; - if (objects_host_told[absolute_index] == NONE) - continue; - distributed_change_from_object(objects_host_told[absolute_index], &message.changes[count]); - if (++count == limit) + } + for (rank = 0; rank < NUMBER_OF_SEND_RANKS; rank++) + { + for (absolute_index = 0; absolute_index < objects_host_told_count; absolute_index++) { - distributed_send_to_machine_reliably(machine_index, &message, _distributed_message_object_changes, count, - (word)(sizeof(message.header) + count * sizeof(struct distributed_object_change))); - count = 0; + long object_index = objects_host_told[absolute_index]; + + if (object_index == NONE || distributed_object_send_rank(object_index) != rank) + continue; + distributed_change_from_object(object_index, &message.changes[count]); + if (++count == limit) + { + distributed_send_to_machine_reliably(machine_index, &message, _distributed_message_object_changes, count, + (word)(sizeof(message.header) + count * sizeof(struct distributed_object_change))); + count = 0; + } } } if (count) @@ -968,8 +1221,24 @@ static void distributed_host_find_viewers( { short machine_number; + struct data_iterator iterator; + struct player_datum *player; + objects_host_viewers.count = distributed_client_machines(objects_host_viewers.indices, HALO_PORT_MAXIMUM_NETWORK_MACHINES); + objects_host_viewers.player_unit_count = 0; + data_iterator_new(&iterator, player_data); + while ((player = data_iterator_next(&iterator)) != NULL && + objects_host_viewers.player_unit_count < MAXIMUM_TRACKED_PLAYERS) + { + long unit_index = distributed_living_unit(player); + + if (unit_index != NONE) + { + object_get_origin(unit_index, + &objects_host_viewers.player_unit_origins[objects_host_viewers.player_unit_count++]); + } + } for (machine_number = 0; machine_number < objects_host_viewers.count; machine_number++) { long *player_list = machine_get_player_list(objects_host_viewers.indices[machine_number]); @@ -1001,19 +1270,32 @@ static void distributed_host_find_viewers( } } -/* how often (ticks) the host sends that client machine an object where it -is, moving: by its nearest player; every tick to one with none in the -world (dead, it watches anyone) */ +/* How often, in ticks, the host sends a moving object's position to a +client machine, based on its distance from that machine's nearest player. +A machine with no player in the world (dead or joining) spectates someone, +so the distance is measured from every player instead. Sending such a +machine every moving object every tick flooded it on campaign levels and +threw everything it saw out of sync. */ static short distributed_host_object_period( short machine_number, real_point3d const *position) { + short count = objects_host_viewers.machines[machine_number].unit_count; + real_point3d const *origins = objects_host_viewers.machines[machine_number].origins; real nearest = -1.0f; short index; - for (index = 0; index < objects_host_viewers.machines[machine_number].unit_count; index++) + if (!count) { - real_point3d const *origin = &objects_host_viewers.machines[machine_number].origins[index]; + count = objects_host_viewers.player_unit_count; + origins = objects_host_viewers.player_unit_origins; + } + /* nobody is alive anywhere, so nothing needs to be sent often */ + if (!count) + return MAXIMUM_OBJECT_PERIOD_TICKS; + for (index = 0; index < count; index++) + { + real_point3d const *origin = &origins[index]; real dx = position->x - origin->x; real dy = position->y - origin->y; real dz = position->z - origin->z; @@ -1027,6 +1309,21 @@ static short distributed_host_object_period( nearest < FAR_OBJECT_DISTANCE * FAR_OBJECT_DISTANCE ? 3 : MAXIMUM_OBJECT_PERIOD_TICKS; } +/* ... for other units sent by a machine's index (network_actors.c) */ +short network_objects_send_period( + long machine_index, + real_point3d const *position) +{ + short machine_number; + + for (machine_number = 0; machine_number < objects_host_viewers.count; machine_number++) + { + if (objects_host_viewers.indices[machine_number] == machine_index) + return distributed_host_object_period(machine_number, position); + } + return 1; +} + /* the kinds of states sent this tick */ enum { @@ -1644,6 +1941,7 @@ void network_objects_host_tick( distributed_host_send_states(); if (game_time_get() % INVENTORY_INTERVAL_TICKS == 0) distributed_host_send_inventories(); + distributed_host_send_damage_animations(); } /* ---------- a client */ @@ -1838,6 +2136,19 @@ static void distributed_client_apply_change( struct object_datum *object = object_get(object_index); csmemcpy(object->object.base_change_colors, change->change_colors, sizeof(object->object.base_change_colors)); + /* (and the colors drawn, which object_new chose from the tag: an AI unit's + are its variant's, set after it was made, actors.c) */ + { + short color_index; + + for (color_index = 0; color_index < NUMBER_OF_OBJECT_CHANGE_COLORS; color_index++) + { + object->object.outgoing_change_colors[color_index].red = PIN(change->change_colors[color_index].red, 0.0f, 1.0f); + object->object.outgoing_change_colors[color_index].green = + PIN(change->change_colors[color_index].green, 0.0f, 1.0f); + object->object.outgoing_change_colors[color_index].blue = PIN(change->change_colors[color_index].blue, 0.0f, 1.0f); + } + } /* (a permutation the model has, or none: the model's renderer takes it as it is) */ { @@ -2147,6 +2458,12 @@ void network_objects_handle_states( if (blend_distance > 0.0f) blend_distance = REMOTE_VEHICLE_BLEND_DISTANCE; + /* (one no player drives follows every move the host sends, however small) */ + if (distributed_vehicle_unsteered(state->object_index)) + { + tolerance = 0.0f; + angle_tolerance = 2.0f; + } if (vehicle->unit.driver_object_index != NONE && distributed_player_is_local(unit_get(vehicle->unit.driver_object_index)->unit.player_index)) { @@ -2177,7 +2494,8 @@ void network_objects_handle_states( { distributed_count_correction(); } - SET_FLAG(object->object.flags, _object_at_rest_bit, TEST_FLAG(state->flags, _distributed_object_at_rest_bit)); + SET_FLAG(object->object.flags, _object_at_rest_bit, TEST_FLAG(state->flags, _distributed_object_at_rest_bit) || + distributed_vehicle_unsteered(state->object_index)); } } @@ -2598,6 +2916,51 @@ static void distributed_client_remove_own_objects( objects_client_new_object_count = 0; } +/* Client: keeps each AI-driven vehicle (such as a Pelican on its flight +path) moving at the velocity the host last sent. These vehicles are held at +rest here, and a distant one is only updated every few ticks, so otherwise +it would stop between updates and jump on each one. */ +static void distributed_client_carry_unsteered_vehicles( + void) +{ + struct object_iterator iterator; + struct object_datum *object; + + object_iterator_new(&iterator, _object_mask_vehicle, 0); + while ((object = object_iterator_next(&iterator)) != NULL) + { + real_vector3d const *velocity = &object->object.translational_velocity; + real_vector3d axis = object->object.angular_velocity; + real_vector3d forward = object->object.forward; + real_vector3d up = object->object.up; + real_point3d position; + real angle; + + if (object->object.parent_object_index != NONE || unit_get(iterator.index)->unit.driver_object_index == NONE || + !distributed_vehicle_unsteered(iterator.index)) + { + continue; + } + angle = normalize3d(&axis); + if (velocity->i == 0.0f && velocity->j == 0.0f && velocity->k == 0.0f && angle == 0.0f) + continue; + position.x = object->object.position.x + velocity->i; + position.y = object->object.position.y + velocity->j; + position.z = object->object.position.z + velocity->k; + /* rotate the same way physics.c applies angular velocity */ + if (angle != 0.0f) + { + real_matrix4x3 rotation; + + matrix4x3_rotation_from_axis_and_angle(&rotation, &axis, (real)sin(angle), (real)cos(angle)); + matrix4x3_transform_vector(&rotation, &object->object.forward, &forward); + matrix4x3_transform_vector(&rotation, &object->object.up, &up); + } + if (distributed_transform_valid(&position, &forward, &up, NULL, NULL, &forward, &up)) + object_set_position(iterator.index, &position, &forward, &up); + } +} + void network_objects_client_tick( void) { @@ -2625,6 +2988,7 @@ void network_objects_client_tick( distributed_client_ready_picked_up_weapons(); distributed_client_note_own_inventories(); distributed_client_send_vehicles(); + distributed_client_carry_unsteered_vehicles(); /* (who it is, as its Discord told it: once its ready went, which makes it a machine the host takes messages of) */ if (objects_client_ready_time != NONE) @@ -2642,6 +3006,8 @@ void network_objects_new_game( short index; csmemset(objects_host_inventories, 0, sizeof(objects_host_inventories)); + host_damage_animation_count = 0; + csmemset(client_damage_animations, 0, sizeof(client_damage_animations)); for (absolute_index = 0; absolute_index < MAXIMUM_TRACKED_OBJECTS; absolute_index++) { objects_host_told[absolute_index] = NONE; diff --git a/source/ai/actors.c b/source/ai/actors.c index da4ec393..2ad33a0c 100644 --- a/source/ai/actors.c +++ b/source/ai/actors.c @@ -317,6 +317,7 @@ symbols in this file: #include "units/units.h" #include "units/vehicle_definitions.h" #include "units/vehicles.h" +#include "networking/network_game_globals.h" /* ---------- constants */ @@ -2224,6 +2225,12 @@ long actor_new( long actor_definition_index; long actor_index = NONE; + /* port: a client of the distributed netcode runs no AI: it has the host's + actors' units, driven as the host sends them (port/linux/game/network_actors.c), + and its own (placed at the map's start or by its scripts) would be extras + the host doesn't have */ + if (network_game_distributed_client()) + return NONE; if (actor_variant_definition_index != NONE) { actor_variant_definition = actor_variant_definition_get(actor_variant_definition_index); @@ -2662,6 +2669,9 @@ long actor_place( long unit_index; match_assert("c:\\halo\\SOURCE\\ai\\actors.c", 603, starting_location); + /* port: none on a client of the distributed netcode (actor_new) */ + if (network_game_distributed_client()) + return NONE; objects_garbage_collection(); actor_variant_definition = @@ -2831,7 +2841,8 @@ short actors_spawn_from_unit( { short spawned_actor_count = 0; - if (actor_variant_definition_index != NONE && actor_count > 0) + /* port: none on a client of the distributed netcode (actor_new) */ + if (actor_variant_definition_index != NONE && actor_count > 0 && !network_game_distributed_client()) { struct unit_datum *source_unit = unit_get(unit_index); long source_actor_index = source_unit->unit.swarm_actor_index != NONE ? diff --git a/source/ai/ai.c b/source/ai/ai.c index bee7eaa7..d590686a 100644 --- a/source/ai/ai.c +++ b/source/ai/ai.c @@ -465,7 +465,8 @@ typedef char ai_prop_unopposable_enemy_offset_assert[ static void ai_place_pending_mounted_weapons( void); static boolean ai_enemies_endanger_player( - boolean must_be_attacking); + boolean must_be_attacking, + long unit_index); static void ai_flush_spatial_effects( void); @@ -1971,8 +1972,11 @@ void ai_update( return; } +/* port: unit_index NONE is any player's unit (the game's own check); else +only enemies of that unit count (network co-op's respawn, players.c) */ static boolean ai_enemies_endanger_player( - boolean must_be_attacking) + boolean must_be_attacking, + long unit_index) { long current_time = game_time_get(); struct data_iterator iterator; @@ -1985,7 +1989,8 @@ static boolean ai_enemies_endanger_player( { if (prop->player && prop->enemy && - unit_get(prop->unit_index)->unit.player_index != NONE) + unit_get(prop->unit_index)->unit.player_index != NONE && + (unit_index == NONE || prop->unit_index == unit_index)) { struct actor_datum *actor = actor_get(prop->owner_actor_index); long object_index = actor->meta.swarm @@ -2068,13 +2073,20 @@ static boolean ai_enemies_endanger_player( boolean ai_enemies_can_see_player( void) { - return ai_enemies_endanger_player(FALSE); + return ai_enemies_endanger_player(FALSE, NONE); } boolean ai_enemies_attacking_player( void) { - return ai_enemies_endanger_player(TRUE); + return ai_enemies_endanger_player(TRUE, NONE); +} + +/* port: whether enemies are attacking this player's unit */ +boolean ai_port_enemies_attacking_unit( + long unit_index) +{ + return ai_enemies_endanger_player(TRUE, unit_index); } long ai_get_race_from_team_index( diff --git a/source/ai/ai.h b/source/ai/ai.h index be7cdfa1..a6460a09 100644 --- a/source/ai/ai.h +++ b/source/ai/ai.h @@ -142,6 +142,9 @@ boolean ai_consider_major_upgrade( short squad_index, real chance); +/* port: network co-op's respawn (players.c) */ +boolean ai_port_enemies_attacking_unit( + long unit_index); boolean ai_enemies_attacking_player( void); boolean ai_enemies_can_see_player( diff --git a/source/camera/director.c b/source/camera/director.c index fbcdf3c4..d1672b9e 100644 --- a/source/camera/director.c +++ b/source/camera/director.c @@ -121,6 +121,7 @@ symbols in this file: #include "camera_scripting.h" #include "dead_camera.h" +#include "coop_spectate.h" /* port: port/linux/game/coop_spectate.c */ #include "editor_flying_camera.h" #include "first_person_camera.h" #include "flying_camera.h" @@ -700,8 +701,12 @@ static void director_choose_camera_game( } else { - struct player_datum *player = player_get(local_player_get_player_index(local_player_index)); - boolean use_dead_camera = player->unit_index == NONE && player->statistics.deaths > 0; + long player_index = local_player_get_player_index(local_player_index); + struct player_datum *player = player_get(player_index); + /* port: and a network co-op player who hasn't spawned yet, waiting for + the level's first checkpoint, watches too */ + boolean use_dead_camera = player->unit_index == NONE && + (player->statistics.deaths > 0 || players_coop_waiting_to_start(player_index)); /* (port: not a client in another's game, the host's rules: a flying camera would see all of it, and one behind the player round its @@ -713,17 +718,25 @@ static void director_choose_camera_game( director_choose_game_perspective(local_player_index, initialize); if (use_dead_camera) { - if (director->camera_proc != (director_camera_update_proc)dead_camera_update) + /* port: a dead network co-op player watches a living teammate + (port/linux/game/coop_spectate.c), or its own body if nobody is alive */ + long watched_unit_index = coop_spectating() ? coop_spectate_unit(local_player_index) : NONE; + + if (director->camera_proc != (director_camera_update_proc)dead_camera_update || + (watched_unit_index != NONE && + ((struct dead_camera *)director->camera_data)->unit_index != watched_unit_index)) { dead_camera_new( (struct dead_camera *)director->camera_data, local_player_index, - NONE); + watched_unit_index); director_set_camera( local_player_index, (director_camera_update_proc)dead_camera_update, TRUE); } + if (watched_unit_index != NONE) + coop_spectate_camera((struct dead_camera *)director->camera_data); } else if (director->camera_proc == (director_camera_update_proc)dead_camera_update) { diff --git a/source/cutscene/cinematics.c b/source/cutscene/cinematics.c index 6c9c4317..26d9f895 100644 --- a/source/cutscene/cinematics.c +++ b/source/cutscene/cinematics.c @@ -74,6 +74,7 @@ symbols in this file: #include "scenario/scenario_definitions.h" #include "text/draw_string.h" #include "text/text_group.h" +#include "network_coop.h" /* port: port/linux/game/network_coop.c */ /* ---------- constants */ @@ -265,6 +266,7 @@ void cinematic_set_title_delayed( { short title_slot_index; + network_coop_note_title(title_index, delay); for (title_slot_index = 0; title_slot_index < MAXIMUM_QUEUED_CINEMATIC_TITLES && cinematic_globals->queued_titles[title_slot_index].title_index != NONE; diff --git a/source/devices/devices.c b/source/devices/devices.c index 4a6bd195..b3e089b6 100644 --- a/source/devices/devices.c +++ b/source/devices/devices.c @@ -98,6 +98,7 @@ symbols in this file: #include "scenario/scenario_definitions.h" #include "sound/game_sound.h" #include "sound/sound_definitions.h" +#include "network_coop.h" /* port: port/linux/game/network_coop.c */ /* ---------- constants */ @@ -112,6 +113,10 @@ enum _scenario_device_group_can_change_only_once_bit = 0, }; +/* port: the group flags network_coop.c syncs */ +#define DEVICE_GROUP_NETWORK_FLAGS \ + (FLAG(_device_group_can_change_only_once_bit) | FLAG(_device_group_changed_once_bit)) + enum { _device_function_none = 0, @@ -186,6 +191,9 @@ typedef char device_animation_frame_count_offset_assert[ void device_group_set_actual_value( short group_index, real actual_value); +static boolean device_group_change( + short group_index, + real desired_value); static short device_group_new( real initial_value, unsigned long flags); @@ -594,6 +602,7 @@ void device_group_set_actual_value( group = datum_get(device_groups_data, group_index); group->actual_value = actual_value; + network_coop_note_device_snap(group_index); object_iterator_new(&iterator, _object_mask_device, 0); while ((device = object_iterator_next(&iterator)) != NULL) @@ -677,6 +686,57 @@ void device_render_debug( boolean device_group_set_desired_value( short group_index, real desired_value) +{ + /* port: on a co-op client only the host moves devices (network_coop.c) */ + if (network_coop_devices_remote()) + return FALSE; + + return device_group_change(group_index, desired_value); +} + +/* port: reads a group for network_coop.c; FALSE if it doesn't exist */ +boolean device_group_network_get( + short group_index, + real *value, + word *flags, + boolean *runtime) +{ + struct device_group_datum *group = group_index != NONE ? datum_try_and_get(device_groups_data, group_index) : NULL; + + if (!group) + return FALSE; + *value = group->actual_value; + *flags = group->flags & DEVICE_GROUP_NETWORK_FLAGS; + *runtime = TEST_FLAG(group->flags, _device_group_runtime_bit); + + return TRUE; +} + +/* port: a co-op client applies the host's group state. snap jumps the +devices to the value; otherwise they move there as usual. */ +void device_group_network_set( + short group_index, + real value, + word flags, + boolean snap) +{ + struct device_group_datum *group = group_index != NONE ? datum_try_and_get(device_groups_data, group_index) : NULL; + + if (!group) + return; + if (snap) + device_group_set_actual_value(group_index, value); + else + { + group->flags &= ~FLAG(_device_group_changed_once_bit); + device_group_change(group_index, value); + } + group->flags = (word)((group->flags & ~DEVICE_GROUP_NETWORK_FLAGS) | (flags & DEVICE_GROUP_NETWORK_FLAGS)); +} + +static boolean device_group_change( + short group_index, + real desired_value) { boolean changed = FALSE; diff --git a/source/devices/devices.h b/source/devices/devices.h index 591443f4..c5a5f977 100644 --- a/source/devices/devices.h +++ b/source/devices/devices.h @@ -129,6 +129,17 @@ void device_set_power( boolean device_group_set_desired_value( short group_index, real desired_value); +/* port: co-op device sync (port/linux/game/network_coop.c) */ +boolean device_group_network_get( + short group_index, + real *value, + word *flags, + boolean *runtime); +void device_group_network_set( + short group_index, + real value, + word flags, + boolean snap); boolean device_can_change_position( long device_index); boolean device_frontfacing( diff --git a/source/effects/player_effects.c b/source/effects/player_effects.c index 17facdec..365d3885 100644 --- a/source/effects/player_effects.c +++ b/source/effects/player_effects.c @@ -107,6 +107,7 @@ symbols in this file: #include "units/units.h" #include +#include "network_coop.h" /* port: port/linux/game/network_coop.c */ /* ---------- constants */ @@ -397,6 +398,7 @@ void scripted_player_effect_set_rotation( real pitch, real roll) { + network_coop_note_player_effect(_coop_player_effect_rotation, yaw, pitch, roll); player_effect_globals->scripted_effect.max_rotation.yaw = DEGREES_TO_RADIANS(yaw); player_effect_globals->scripted_effect.max_rotation.pitch = DEGREES_TO_RADIANS(pitch); player_effect_globals->scripted_effect.max_rotation.roll = DEGREES_TO_RADIANS(roll); @@ -458,6 +460,50 @@ void player_effect_screen_fade_out( return; } +/* port: read and set the screen fade, so a co-op host can send it to its +clients (port/linux/game/network_coop.c): color, length, direction, and +the game time it started */ +void player_effect_port_screen_fade_get( + real_rgb_color *color, + short *ticks, + boolean *fading_out, + long *start_time) +{ + *color = player_effect_globals->screen_fade.color; + *ticks = player_effect_globals->screen_fade.ticks; + *fading_out = player_effect_globals->screen_fade.fading_out; + *start_time = player_effect_globals->screen_fade.start_time; + + return; +} + +void player_effect_port_screen_fade_set( + real_rgb_color const *color, + short ticks, + boolean fading_out, + long start_time) +{ + player_effect_globals->screen_fade.color = *color; + player_effect_globals->screen_fade.ticks = ticks; + player_effect_globals->screen_fade.fading_out = fading_out; + player_effect_globals->screen_fade.start_time = start_time; + + return; +} + +boolean player_effect_port_scripted_active( + void) +{ + return TEST_FLAG(player_effect_globals->global_flags, _scripted_player_effect_active_bit); +} + +void player_effect_port_scripted_end( + void) +{ + SET_FLAG(player_effect_globals->global_flags, _scripted_player_effect_active_bit, FALSE); + SET_FLAG(player_effect_globals->global_flags, _scripted_player_effect_stopping_bit, FALSE); +} + void player_effect_get_damage_indicators( short local_player_index, byte *damage_indicators) @@ -609,6 +655,7 @@ void scripted_player_effect_set_translation( { real_vector3d *translation = &player_effect_globals->scripted_effect.max_translation; + network_coop_note_player_effect(_coop_player_effect_translation, horizontal, vertical, depth); translation->i = horizontal; translation->j = vertical; translation->k = depth; @@ -622,6 +669,7 @@ void scripted_player_effect_start( { short ticks; + network_coop_note_player_effect(_coop_player_effect_start, maximum_intensity, attack_time, 0.0f); player_effect_globals->scripted_effect.max_intensity = maximum_intensity; ticks = (short)fast_ftol(attack_time * TICKS_PER_SECOND); @@ -644,6 +692,7 @@ void scripted_player_effect_stop( { short ticks = (short)fast_ftol(duration * TICKS_PER_SECOND); + network_coop_note_player_effect(_coop_player_effect_stop, duration, 0.0f, 0.0f); player_effect_globals->scripted_effect.timer = ticks; player_effect_globals->scripted_effect.total_time = ticks; SET_FLAG( diff --git a/source/effects/player_effects.h b/source/effects/player_effects.h index 4f96a772..3688fe75 100644 --- a/source/effects/player_effects.h +++ b/source/effects/player_effects.h @@ -56,6 +56,21 @@ void player_effect_screen_fade_out( real green, real blue, short ticks); +/* port: screen fade sync for network co-op */ +void player_effect_port_screen_fade_get( + real_rgb_color *color, + short *ticks, + boolean *fading_out, + long *start_time); +void player_effect_port_screen_fade_set( + real_rgb_color const *color, + short ticks, + boolean fading_out, + long start_time); +/* port: whether the scripts' screen shake is running, and ending it at once +(network co-op keeps a client's in step with the host's) */ +boolean player_effect_port_scripted_active(void); +void player_effect_port_scripted_end(void); void player_effect_get_damage_indicators( short local_player_index, byte *damage_indicators); diff --git a/source/game/game.c b/source/game/game.c index 52fe8a5f..b6ed1634 100644 --- a/source/game/game.c +++ b/source/game/game.c @@ -180,9 +180,12 @@ struct game_options; #include "structures/structures.h" #include "units/units.h" #include "units/vehicles.h" +#include "network_coop.h" /* port: port/linux/game/network_coop.c */ /* network_game_globals.c's */ boolean network_game_distributed_client(void); +/* port: a client drives the host's actors' units as the host sent them (port/linux/game/network_actors.c) */ +void network_actors_drive(void); /* ---------- constants */ @@ -326,6 +329,8 @@ void game_tick( positions, and could place objects of their own) */ if (!network_game_distributed_client()) ai_update(); + else + network_actors_drive(); players_update_before_game(); seconds_per_tick = game_globals->players_are_double_speed @@ -344,7 +349,11 @@ void game_tick( unlock_global_random_seed(); game_engine_update(); editor_update(); - hs_update(); + /* port: in network co-op only the host runs the scripts. A client running + them would place the map's actors and objects a second time and make + decisions that belong to the host. */ + if (!(network_game_distributed_client() && network_coop_active())) + hs_update(); recorded_animations_update(); objects_update(); players_update_after_game(); @@ -932,7 +941,9 @@ void remove_quitting_players_from_game( struct player_datum *player; long current_time; - if (!game_engine_running()) + /* port: also in network co-op, which has no game engine. Without this a + player who left kept their unit, and it respawned. */ + if (!game_engine_running() && !network_coop_active()) return; current_time = game_time_get(); diff --git a/source/game/player_control.c b/source/game/player_control.c index 72798d4b..4e7faacc 100644 --- a/source/game/player_control.c +++ b/source/game/player_control.c @@ -209,6 +209,7 @@ symbols in this file: #include "units/vehicles.h" #include "real_math.h" +#include "network_coop.h" /* port: port/linux/game/network_coop.c */ /* ---------- constants */ @@ -1679,25 +1680,85 @@ boolean player_control_action_test_look_relative_all_directions( _player_control_look_relative_all_directions_flags); } +/* The actions the scripts test for (player_control_action_test_*) that a +player's unit control, turning, sticks and trigger show. A local player's +come from its input blob; in network co-op the host's scripts count a +client's too (port/linux/game/network_distributed.c), so Pillar of +Autumn's tutorial goes on for whichever player does what it asks. */ +void player_control_action_test_note( + unsigned long unit_control_flags, + real_euler_angles2d const *facing_delta, + real_vector2d const *throttle, + real primary_trigger) +{ + struct player_control_globals_data *globals = player_control_globals; + + if (TEST_FLAG(unit_control_flags, _unit_control_action_bit)) + { + globals->action_flags |= FLAG(_player_control_action_bit); + } + if (TEST_FLAG(unit_control_flags, _unit_control_jump_bit)) + { + globals->action_flags |= FLAG(_player_control_jump_bit); + } + if (TEST_FLAG(unit_control_flags, _unit_control_throw_grenade_bit)) + { + globals->action_flags |= FLAG(_player_control_grenade_trigger_bit); + } + if (primary_trigger > 0.f) + { + globals->action_flags |= FLAG(_player_control_primary_trigger_bit); + } + + if (facing_delta->pitch > 0.f) + { + globals->action_flags |= FLAG(_player_control_look_relative_up_bit); + } + else if (facing_delta->pitch < 0.f) + { + globals->action_flags |= FLAG(_player_control_look_relative_down_bit); + } + if (facing_delta->yaw > 0.f) + { + globals->action_flags |= FLAG(_player_control_look_relative_left_bit); + } + else if (facing_delta->yaw < 0.f) + { + globals->action_flags |= FLAG(_player_control_look_relative_right_bit); + } + if (throttle->i > 0.f) + { + globals->action_flags |= FLAG(_player_control_move_relative_forward_bit); + } + else if (throttle->i < 0.f) + { + globals->action_flags |= FLAG(_player_control_move_relative_backward_bit); + } + if (throttle->j > 0.f) + { + globals->action_flags |= FLAG(_player_control_move_relative_right_bit); + } + else if (throttle->j < 0.f) + { + globals->action_flags |= FLAG(_player_control_move_relative_left_bit); + } +} + static void player_control_action_test_check_reset_input_blob( struct input_blob *input) { struct player_control_globals_data *globals; - if (input->accept && cinematic_can_be_skipped()) + if (input->accept && (cinematic_can_be_skipped() || network_coop_skip_offered())) { - main_skip_cinematic(); + /* port: in network co-op the players vote to skip (network_coop.c) */ + if (!network_coop_vote_skip()) + main_skip_cinematic(); } + player_control_action_test_note(input->unit_control_flags, &input->facing_delta, &input->throttle, + input->primary_trigger); globals = player_control_globals; - if (TEST_FLAG(input->unit_control_flags, _unit_control_action_bit)) - { - globals->action_flags |= FLAG(_player_control_action_bit); - } - if (TEST_FLAG(input->unit_control_flags, _unit_control_jump_bit)) - { - globals->action_flags |= FLAG(_player_control_jump_bit); - } if (input->accept) { globals->action_flags |= FLAG(_player_control_accept_bit); @@ -1706,52 +1767,11 @@ static void player_control_action_test_check_reset_input_blob( { globals->action_flags |= FLAG(_player_control_back_bit); } - if (input->primary_trigger > 0.f) - { - globals->action_flags |= FLAG(_player_control_primary_trigger_bit); - } - if (TEST_FLAG(input->unit_control_flags, _unit_control_throw_grenade_bit)) - { - globals->action_flags |= FLAG(_player_control_grenade_trigger_bit); - } if (TEST_FLAG(input->player_control_flags, _player_control_input_zoom_bit)) { globals->action_flags |= FLAG(_player_control_zoom_bit); } - if (input->facing_delta.pitch > 0.f) - { - globals->action_flags |= FLAG(_player_control_look_relative_up_bit); - } - else if (input->facing_delta.pitch < 0.f) - { - globals->action_flags |= FLAG(_player_control_look_relative_down_bit); - } - if (input->facing_delta.yaw > 0.f) - { - globals->action_flags |= FLAG(_player_control_look_relative_left_bit); - } - else if (input->facing_delta.yaw < 0.f) - { - globals->action_flags |= FLAG(_player_control_look_relative_right_bit); - } - if (input->throttle.i > 0.f) - { - globals->action_flags |= FLAG(_player_control_move_relative_forward_bit); - } - else if (input->throttle.i < 0.f) - { - globals->action_flags |= FLAG(_player_control_move_relative_backward_bit); - } - if (input->throttle.j > 0.f) - { - globals->action_flags |= FLAG(_player_control_move_relative_right_bit); - } - else if (input->throttle.j < 0.f) - { - globals->action_flags |= FLAG(_player_control_move_relative_left_bit); - } - if (!TEST_FLAG(globals->action_test_flags, _player_control_action_bit)) { if (!TEST_FLAG(globals->suppressed_action_flags, _player_control_action_bit)) diff --git a/source/game/players.c b/source/game/players.c index b5925fbb..afa18a00 100644 --- a/source/game/players.c +++ b/source/game/players.c @@ -236,6 +236,8 @@ symbols in this file: #include "camera/observer.h" #include "data.h" #include "devices/devices.h" +#include "network_coop.h" /* port: port/linux/game/network_coop.c */ +#include "cutscene/cinematics.h" /* port: network co-op's first spawns */ #include "editor/editor_stubs.h" #include "effects/effects.h" #include "effects/player_effects.h" @@ -270,6 +272,7 @@ symbols in this file: #include "tag_files/tag_groups.h" #include "text/text_group.h" #include "text/unicode.h" +#include "objects/object_definitions.h" #include "units/biped_definitions.h" #include "units/bipeds.h" #include "units/units.h" @@ -405,6 +408,8 @@ typedef char players_static_data_screen_flash_offset_assert[ /* ---------- prototypes */ +static boolean players_respawn_network_coop( + void); static long is_player_in_trigger( short bsp_switch_trigger_volume_index, long object_index); @@ -454,6 +459,37 @@ static void player_handle_powerup_equipment( /* ---------- globals */ +/* port: network co-op timers, by player absolute index. Each holds the +game time plus one at which something started, or 0 when it isn't happening. +spawnable_since: when the player became somewhere others can spawn beside +(player_spawnable_beside); they wait a while after (players_coop_room_to_spawn). */ +static struct +{ + long spawnable_since[NETWORK_GAME_MAXIMUM_PLAYER_COUNT]; + /* when the dead started waiting for a safe teammate to respawn beside */ + long respawn_wait_since; + /* when each player's unit left the loaded structure BSP */ + long stranded_since[NETWORK_GAME_MAXIMUM_PLAYER_COUNT]; + /* no BSP switch trigger counts before this game time (one has just + switched, and the players are being brought into the new BSP) */ + long bsp_switch_allowed_time; + /* where each player last stood on the ground, and on which BSP: where + everyone comes back when the last checkpoint was on another one */ + short ground_structure_bsp_index; + boolean has_ground_position[NETWORK_GAME_MAXIMUM_PLAYER_COUNT]; + real_point3d ground_positions[NETWORK_GAME_MAXIMUM_PLAYER_COUNT]; +} players_coop_state; + +/* port: where each player was at the last checkpoint, in network co-op +(players_note_checkpoint) */ +static struct +{ + boolean valid; + short structure_bsp_index; + boolean has_position[NETWORK_GAME_MAXIMUM_PLAYER_COUNT]; + real_point3d positions[NETWORK_GAME_MAXIMUM_PLAYER_COUNT]; +} players_checkpoint; + struct players_globals *players_globals; struct data_array *team_data; struct data_array *player_data; @@ -550,6 +586,9 @@ void players_initialize_for_new_map( players_globals->all_dead = FALSE; players_globals->pending_teleport_starting_location_index = NONE; players_globals->respawn_failure = 0; + /* port: a new map has no network co-op checkpoint yet */ + csmemset(&players_checkpoint, 0, sizeof(players_checkpoint)); + csmemset(&players_coop_state, 0, sizeof(players_coop_state)); data_make_valid(player_data); data_make_valid(team_data); csmemset( @@ -922,7 +961,9 @@ boolean any_player_is_dead( player_data); while ((player = (struct player_datum *)data_iterator_next(&iterator)) != NULL) { - if (player->unit_index == NONE) + /* port: ignore co-op players still waiting for the first checkpoint, + or the checkpoint that lets them in could never be saved */ + if (player->unit_index == NONE && !players_coop_waiting_to_start(iterator.datum_index)) return TRUE; } @@ -1266,6 +1307,31 @@ long find_best_starting_location_index( return best_starting_location_index; } +/* port: paints a network co-op player's unit in their profile colour. The +campaign Spartan is always green because its tag fixes that colour, which +overrides the colour the unit is created with; this replaces fixed colours. */ +static void players_coop_color_unit( + long unit_index, + real_rgb_color const *color) +{ + struct object_datum *object = object_get(unit_index); + struct object_definition *definition = object_definition_get(object->definition_index); + short index; + + for (index = 0; index < definition->object.change_colors.count && index < NUMBER_OF_OBJECT_CHANGE_COLORS; index++) + { + struct object_change_color_definition *change_color = TAG_BLOCK_GET_ELEMENT( + &definition->object.change_colors, index, struct object_change_color_definition); + + /* skip colours driven by a function, like the shield glow */ + if (change_color->permutations.count > 0 && !change_color->scaled_by && !change_color->darken_by) + { + object->object.base_change_colors[index] = *color; + object->object.outgoing_change_colors[index] = *color; + } + } +} + static void player_spawn( long player_index) { @@ -1376,6 +1442,8 @@ static void player_spawn( &placement_data, &change_color); unit_index = object_new(&placement_data); + if (unit_index != NONE && network_coop_active()) + players_coop_color_unit(unit_index, &change_color); if (unit_index != NONE) { unit = unit_try_and_get(unit_index); @@ -1804,14 +1872,17 @@ static boolean player_handle_action( switch (player->action_result) { case _player_action_result_touch_device: - device_touched(player->action_object_index, player->unit_index); + /* port: on a co-op client the host decides device use; the action is + relayed to it (network_coop.c) */ + if (!network_coop_devices_remote()) + device_touched(player->action_object_index, player->unit_index); result = TRUE; break; case _player_action_result_swap_for_powerup: - /* port: a distributed client's inventories are the host's (the - powerup is swapped where the host decides pickups, and the relayed - action of a remote player reaches here too): it swaps nothing */ + /* port: a distributed client swaps nothing: the host owns inventories + and decides pickups, and a remote player's relayed action also reaches + here */ if (!players_decide_pickups()) { result = TRUE; @@ -1913,7 +1984,7 @@ static boolean player_teleport_internal( match_assert( "c:\\halo\\SOURCE\\game\\players.c", 0x4FB, - source_unit_index==NONE || local_player_count()>1); + source_unit_index==NONE || local_player_count()>1 || game_connection()!=_game_connection_local); if (source_unit_index != NONE && object_get_ultimate_parent(source_unit_index) != source_unit_index) { @@ -2109,11 +2180,12 @@ static boolean player_teleport_internal( else { error(2, "couldn't teleport player into a valid location"); - match_assert( - "c:\\halo\\SOURCE\\game\\players.c", - 0x5AB, - player->local_player_index!=NONE); - player_pseudo_kill(player_index, source_unit_index); + /* port: only split screen pseudo-kills, because its respawn hands the + unit back to the local player, which a network game can't do. In a + network game the player stays put; the Xbox halted for another + machine's player. */ + if (player->local_player_index != NONE && game_connection() == _game_connection_local) + player_pseudo_kill(player_index, source_unit_index); } return result; @@ -2312,6 +2384,11 @@ boolean players_respawn_coop( boolean result; boolean dangerous; + /* port: network co-op checks safety around each teammate instead + (players_respawn_network_coop) */ + if (game_connection() == _game_connection_network_server && network_coop_active()) + return players_respawn_network_coop(); + players_globals->respawn_failure = 0; result = FALSE; if (!players_globals->respawn_failed) @@ -2407,6 +2484,491 @@ result_complete: return result; } +/* ---------- port: network co-op + +A network game on a campaign map with no game engine is co-op. Players +spawn on the host and respawn beside each other, as in local co-op +(players_respawn_coop). When everyone is dead, local co-op reverts to the +last checkpoint (game_state_revert), but every machine would have to do +that in step. Instead the players respawn where they were at the last +checkpoint (main_save_map_private records it), or at the map's start if +that was on another BSP. */ + +static boolean players_coop_room_to_spawn( + void); +static boolean player_place_beside_teammate( + long player_index); + +/* How long into a level the extra players wait before spawning. The opening +cutscene starts a tick or so in, and the first player may still be somewhere +with no room beside them (the drop pod on Halo). */ +#define COOP_LEVEL_START_TICKS (2 * TICKS_PER_SECOND) + +/* Co-op: TRUE while players after the first are held back from spawning: +during the level's first seconds, during a cutscene, or until a teammate has +been free on foot for a while (players_coop_room_to_spawn). */ +static boolean players_coop_extras_held( + void) +{ + return game_time_get() < COOP_LEVEL_START_TICKS || cinematic_in_progress() || !players_coop_room_to_spawn(); +} + +/* Co-op host: TRUE if a player who hasn't spawned on this level yet may +spawn now. The first player spawns at once (the level's script places them). +The others spawn beside a teammate (player_place_beside_teammate) once nothing +holds them back (players_coop_extras_held): when the opening cutscene ends or +is skipped, or straight away if they join later. */ +static boolean players_coop_may_spawn( + void) +{ + struct data_iterator iterator; + struct player_datum *player; + + data_iterator_new(&iterator, player_data); + while ((player = data_iterator_next(&iterator)) != NULL) + { + if (player->unit_index != NONE) + return !players_coop_extras_held(); + } + + return TRUE; +} + +/* TRUE if this player hasn't spawned on the level yet and is being held +back (players_coop_extras_held); they spectate a teammate meanwhile */ +boolean players_coop_waiting_to_start( + long player_index) +{ + struct player_datum *player = player_try_and_get(player_index); + + return player && player->unit_index == NONE && player->statistics.deaths == 0 && network_coop_active() && + players_coop_extras_held(); +} + +/* an enemy projectile this close (world units) makes a teammate unsafe +to respawn beside */ +#define COOP_RESPAWN_DANGER_RADIUS 15.0f +/* how often the respawn's safety test runs while someone is dead */ +#define COOP_RESPAWN_CHECK_TICKS (TICKS_PER_SECOND / 2) +/* after waiting this long for a safe teammate, the dead respawn beside any +teammate standing on the ground */ +#define COOP_RESPAWN_FALLBACK_TICKS (10 * TICKS_PER_SECOND) + +/* TRUE if an enemy projectile (a grenade, a plasma bolt) is flying near the +unit. Players' own shots and needles stuck in a body don't count. */ +static boolean players_coop_danger_near( + long unit_index) +{ + real_point3d const *center = &object_get(unit_index)->object.bounding_sphere_center; + real radius_squared = COOP_RESPAWN_DANGER_RADIUS * COOP_RESPAWN_DANGER_RADIUS; + struct object_iterator iterator; + struct object_datum *object; + + object_iterator_new(&iterator, _object_mask_projectile, 0); + while ((object = object_iterator_next(&iterator)) != NULL) + { + if (object->object.owner_player_index == NONE && object->object.parent_object_index == NONE && + distance_squared3d(center, &object->object.bounding_sphere_center) <= radius_squared) + { + return TRUE; + } + } + + return FALSE; +} + +/* TRUE if the unit (or the vehicle it rides) is inside the loaded structure +BSP. In network co-op players can be left outside it after a BSP switch. */ +static boolean players_coop_unit_in_structure( + long unit_index) +{ + return object_get(object_get_ultimate_parent(unit_index))->object.location.cluster_index != NONE; +} + +/* TRUE if a living player's unit is on solid ground: inside the structure +BSP, not airborne and not in a moving vehicle */ +static boolean players_coop_unit_grounded( + long unit_index) +{ + long root_index = object_get_ultimate_parent(unit_index); + + if (!players_coop_unit_in_structure(unit_index)) + return FALSE; + if (root_index == unit_index) + { + struct biped_datum *biped = biped_try_and_get(unit_index); + + return !biped || !TEST_FLAG(biped->biped.flags, _biped_airborne_bit); + } + else + { + struct players_vehicle_datum *vehicle = (struct players_vehicle_datum *) + object_try_and_get_and_verify_type(root_index, _object_mask_vehicle); + + return !vehicle || vehicle->vehicle.unknown_state <= 0; + } +} + +/* TRUE if it is safe to respawn beside a living player's unit: grounded, +not under attack, and no enemy projectile nearby */ +static boolean players_coop_unit_safe( + long unit_index) +{ + return players_coop_unit_grounded(unit_index) && !ai_port_enemies_attacking_unit(unit_index) && + !players_coop_danger_near(unit_index); +} + +/* the unit of the first living player that passes `test`, or NONE */ +static long players_coop_unit_where( + boolean (*test)(long unit_index)) +{ + struct data_iterator iterator; + struct player_datum *player; + + data_iterator_new(&iterator, player_data); + while ((player = data_iterator_next(&iterator)) != NULL) + { + if (player->unit_index != NONE && test(player->unit_index)) + return player->unit_index; + } + + return NONE; +} + +/* Network co-op respawn: the dead come back beside the first teammate who +is safe, or after COOP_RESPAWN_FALLBACK_TICKS beside one at least on the +ground, so a long fight can't keep them out. The campaign's own test +(players_respawn_coop) is map-wide (any projectile or enemy attack +anywhere), which with many players spread out would almost never pass. +Returns whether everyone waiting came back. */ +static boolean players_respawn_network_coop( + void) +{ + struct data_iterator iterator; + struct player_datum *player; + long safe_unit_index; + boolean result = TRUE; + + if (!players_coop_state.respawn_wait_since) + players_coop_state.respawn_wait_since = game_time_get() + 1; + /* main.c calls this every tick while someone is dead; the safety test + walks all the map's projectiles, so twice a second is enough */ + if (game_time_get() % COOP_RESPAWN_CHECK_TICKS != 0) + return FALSE; + safe_unit_index = players_coop_unit_where(players_coop_unit_safe); + if (safe_unit_index == NONE && + game_time_get() + 1 - players_coop_state.respawn_wait_since >= COOP_RESPAWN_FALLBACK_TICKS) + { + safe_unit_index = players_coop_unit_where(players_coop_unit_grounded); + } + if (safe_unit_index == NONE) + return FALSE; + data_iterator_new(&iterator, player_data); + while ((player = data_iterator_next(&iterator)) != NULL) + { + /* players still waiting for the level's first checkpoint spawn then */ + if (player->unit_index != NONE || player->quit_out_of_game || + players_coop_waiting_to_start(iterator.datum_index)) + { + continue; + } + player_spawn(iterator.datum_index); + if (player->unit_index == NONE) + result = FALSE; + /* (beside the safe teammate, else any with room: never left at the + level's start, where player_spawn put them) */ + else if (!player_teleport(iterator.datum_index, safe_unit_index, + &object_get(safe_unit_index)->object.bounding_sphere_center) && + !player_place_beside_teammate(iterator.datum_index)) + { + result = FALSE; + } + } + if (result) + players_coop_state.respawn_wait_since = 0; + + return result; +} + +/* What a new player can spawn beside: the player's unit on foot, or the +vehicle it rides if a player drives it (a Warthog). NONE while scripts hold +the controls (Pillar of Autumn's cryo tube) or while it rides an AI vehicle +(Silent Cartographer's Pelican), which would put the new player inside. */ +static long player_spawnable_beside( + struct player_datum const *player) +{ + long vehicle_index; + long driver_index; + + if (player->unit_index == NONE || !player_input_enabled() || !players_coop_unit_in_structure(player->unit_index)) + return NONE; + vehicle_index = object_get_ultimate_parent(player->unit_index); + if (vehicle_index == player->unit_index) + return vehicle_index; + driver_index = object_try_and_get_and_verify_type(vehicle_index, _object_mask_unit) ? + unit_get(vehicle_index)->unit.driver_object_index : NONE; + return driver_index != NONE && unit_get(driver_index)->unit.player_index != NONE ? vehicle_index : NONE; +} + +/* How long a teammate must have been somewhere spawnable before others +spawn beside them, so nobody lands inside a vehicle they just left. */ +#define COOP_DISEMBARK_TICKS (4 * TICKS_PER_SECOND) + +/* Where the player comes back: their position at the last checkpoint, or, +when that was on another structure BSP (everyone died before the next one), +where they last stood on the ground on this one. NULL if the player has +neither (they joined since); they come back beside a teammate. */ +static real_point3d const *players_checkpoint_position( + long player_index) +{ + short index = DATUM_INDEX_TO_ABSOLUTE_INDEX(player_index); + + if (players_checkpoint.valid && players_checkpoint.structure_bsp_index == global_structure_bsp_index_get()) + return players_checkpoint.has_position[index] ? &players_checkpoint.positions[index] : NULL; + if (players_coop_state.ground_structure_bsp_index == global_structure_bsp_index_get() && + players_coop_state.has_ground_position[index]) + { + return &players_coop_state.ground_positions[index]; + } + + return NULL; +} + +/* TRUE if the unit is outside the loaded structure BSP, where it would fall +forever: on foot, or in a vehicle that is empty or player-driven. AI-driven +vehicles are ignored because the intro Pelicans fly outside the BSP. */ +static boolean players_coop_unit_stranded( + long unit_index) +{ + long vehicle_index = object_get_ultimate_parent(unit_index); + long driver_index; + + if (players_coop_unit_in_structure(unit_index)) + return FALSE; + if (vehicle_index == unit_index) + return TRUE; + driver_index = object_try_and_get_and_verify_type(vehicle_index, _object_mask_unit) ? + unit_get(vehicle_index)->unit.driver_object_index : NONE; + return driver_index == NONE || unit_get(driver_index)->unit.player_index != NONE; +} + +/* How long a player can be outside the BSP before being brought back. A +BSP switch settles in a tick or two; waiting a bit longer avoids moving +someone who is just crossing a seam. */ +#define COOP_STRANDED_TICKS TICKS_PER_SECOND + +/* After a BSP switch, how long the co-op host lets no other trigger switch +it while the players are brought into the new one */ +#define COOP_BSP_SWITCH_SETTLE_TICKS (2 * TICKS_PER_SECOND) + +/* Co-op host, each tick: a player stranded for COOP_STRANDED_TICKS is moved +beside a grounded teammate inside the BSP, else any teammate inside it, else +to the last checkpoint. Skipped while scripts hold the controls, since +cutscenes place the players themselves. A BSP switch already moves everyone +(players_reconnect_to_structure_bsp); this catches anyone it missed. */ +static void players_coop_rescue_stranded( + void) +{ + struct data_iterator iterator; + struct player_datum *player; + + if (!network_coop_active() || game_connection() != _game_connection_network_server) + return; + data_iterator_new(&iterator, player_data); + while ((player = data_iterator_next(&iterator)) != NULL) + { + long *since = &players_coop_state.stranded_since[DATUM_INDEX_TO_ABSOLUTE_INDEX(iterator.datum_index)]; + long teammate_index; + boolean rescued; + + if (player->unit_index == NONE || !player_input_enabled() || !players_coop_unit_stranded(player->unit_index)) + { + *since = 0; + continue; + } + if (*since == 0) + *since = game_time_get() + 1; + if (game_time_get() + 1 - *since < COOP_STRANDED_TICKS) + continue; + teammate_index = players_coop_unit_where(players_coop_unit_grounded); + if (teammate_index == NONE) + teammate_index = players_coop_unit_where(players_coop_unit_in_structure); + if (teammate_index != NONE) + { + rescued = player_teleport(iterator.datum_index, teammate_index, + &object_get(teammate_index)->object.bounding_sphere_center); + } + else + { + real_point3d const *position = players_checkpoint_position(iterator.datum_index); + + rescued = position && player_teleport(iterator.datum_index, NONE, position); + } + if (rescued) + *since = 0; + } +} + +/* co-op, each tick: records when each player became somewhere spawnable, +and where each last stood on the ground */ +static void players_coop_note_on_foot( + void) +{ + struct data_iterator iterator; + struct player_datum *player; + + if (!network_coop_active()) + return; + if (players_coop_state.ground_structure_bsp_index != global_structure_bsp_index_get()) + { + players_coop_state.ground_structure_bsp_index = global_structure_bsp_index_get(); + csmemset(players_coop_state.has_ground_position, 0, sizeof(players_coop_state.has_ground_position)); + } + data_iterator_new(&iterator, player_data); + while ((player = data_iterator_next(&iterator)) != NULL) + { + short index = DATUM_INDEX_TO_ABSOLUTE_INDEX(iterator.datum_index); + long *since = &players_coop_state.spawnable_since[index]; + + if (player_spawnable_beside(player) == NONE) + *since = 0; + else if (*since == 0) + *since = game_time_get() + 1; + if (player->unit_index != NONE && object_get_ultimate_parent(player->unit_index) == player->unit_index && + players_coop_unit_grounded(player->unit_index)) + { + players_coop_state.ground_positions[index] = object_get(player->unit_index)->object.bounding_sphere_center; + players_coop_state.has_ground_position[index] = TRUE; + } + } +} + +/* Co-op host: TRUE if a new player has somewhere to spawn, i.e. a teammate +who has been spawnable for COOP_DISEMBARK_TICKS. While every teammate rides +an AI vehicle (Silent Cartographer's Pelican) or is held by the scripts +(Pillar of Autumn's cryo tube), the others spectate. */ +static boolean players_coop_room_to_spawn( + void) +{ + struct data_iterator iterator; + struct player_datum *player; + + data_iterator_new(&iterator, player_data); + while ((player = data_iterator_next(&iterator)) != NULL) + { + long since = players_coop_state.spawnable_since[DATUM_INDEX_TO_ABSOLUTE_INDEX(iterator.datum_index)]; + + if (player_spawnable_beside(player) != NONE && since != 0 && game_time_get() + 1 - since >= COOP_DISEMBARK_TICKS) + return TRUE; + } + + return FALSE; +} + +/* Co-op host: moves a newly spawned player beside a teammate who is on foot +or in a player-driven vehicle (player_spawnable_beside). player_teleport tries +a few spots around each. Teammates are tried in turn so a big lobby spreads +out. FALSE if nobody has room; the player stays where they spawned. */ +static boolean player_place_beside_teammate( + long player_index) +{ + struct data_iterator iterator; + struct player_datum *other; + + if (player_get(player_index)->unit_index == NONE) + return FALSE; + data_iterator_new(&iterator, player_data); + while ((other = data_iterator_next(&iterator)) != NULL) + { + if (iterator.datum_index != player_index && player_spawnable_beside(other) != NONE && + player_teleport(player_index, other->unit_index, &object_get(other->unit_index)->object.bounding_sphere_center)) + { + return TRUE; + } + } + + return FALSE; +} + +void players_note_checkpoint( + void) +{ + struct data_iterator iterator; + struct player_datum *player; + + csmemset(&players_checkpoint, 0, sizeof(players_checkpoint)); + players_checkpoint.structure_bsp_index = global_structure_bsp_index_get(); + data_iterator_new(&iterator, player_data); + while ((player = data_iterator_next(&iterator)) != NULL) + { + short index = DATUM_INDEX_TO_ABSOLUTE_INDEX(iterator.datum_index); + + if (player->unit_index != NONE) + { + players_checkpoint.positions[index] = object_get(player->unit_index)->object.bounding_sphere_center; + players_checkpoint.has_position[index] = TRUE; + players_checkpoint.valid = TRUE; + } + } + + return; +} + +/* Everyone back after all died: each where they were at the last checkpoint +(players_checkpoint_position), then those with no such spot beside the +others. Players who come back are spawned first, at the level's start. */ +void players_respawn_at_checkpoint( + void) +{ + struct data_iterator iterator; + struct player_datum *player; + boolean unplaced[NETWORK_GAME_MAXIMUM_PLAYER_COUNT] = { FALSE }; + + data_iterator_new(&iterator, player_data); + while ((player = data_iterator_next(&iterator)) != NULL) + { + real_point3d const *position; + + if (player->unit_index != NONE || player->quit_out_of_game) + continue; + player_spawn(iterator.datum_index); + position = players_checkpoint_position(iterator.datum_index); + unplaced[DATUM_INDEX_TO_ABSOLUTE_INDEX(iterator.datum_index)] = player->unit_index != NONE && + !(position && player_teleport(iterator.datum_index, NONE, position)); + } + data_iterator_new(&iterator, player_data); + while ((player = data_iterator_next(&iterator)) != NULL) + { + if (unplaced[DATUM_INDEX_TO_ABSOLUTE_INDEX(iterator.datum_index)]) + player_place_beside_teammate(iterator.datum_index); + } + + return; +} + +/* port: whether the unit stands in a trigger that switches away from the +loaded structure BSP */ +static boolean players_coop_in_bsp_switch_trigger( + long unit_index) +{ + struct scenario *scenario = global_scenario_get(); + short index; + + for (index = 0; index < scenario->bsp_switch_trigger_volumes.count; index++) + { + struct scenario_bsp_switch_trigger_volume *volume = TAG_BLOCK_GET_ELEMENT( + &scenario->bsp_switch_trigger_volumes, index, struct scenario_bsp_switch_trigger_volume); + + if (volume->source_structure_bsp_index == global_structure_bsp_index && + scenario_trigger_volume_test_object(volume->trigger_volume_index, unit_index)) + { + return TRUE; + } + } + + return FALSE; +} + static void player_teleport_on_bsp_switch( long player_index, long source_unit_index, @@ -2428,7 +2990,15 @@ static void player_teleport_on_bsp_switch( position); if (biped) { - if (players_globals->pending_teleport_starting_location_index != NONE && + /* port: network co-op moves the players the switch left outside the + BSP, and those standing where they would switch it straight back, + which switched the BSP back and forth every tick. Pulling everyone to + whoever crossed, as split screen does, bounced players far apart. */ + if (network_coop_active()) + { + outside_switch_trigger = players_coop_in_bsp_switch_trigger(unit_index); + } + else if (players_globals->pending_teleport_starting_location_index != NONE && !scenario_trigger_volume_test_object( TAG_BLOCK_GET_ELEMENT( &global_scenario_get()->bsp_switch_trigger_volumes, @@ -2487,13 +3057,13 @@ void players_reconnect_to_structure_bsp( long source_unit_index; boolean teleport_position_valid; boolean found_player; - short local_player_index; short cutscene_flag_index; - long player_index; long player_unit_index; + /* port: also on a network co-op host, whose other players are remote */ if (players_globals->pending_teleport_starting_location_index != NONE && - players_globals->local_player_count > 1) + (players_globals->local_player_count > 1 || + (network_coop_active() && game_connection() == _game_connection_network_server))) { scenario = global_scenario_get(); bsp_switch = TAG_BLOCK_GET_ELEMENT( @@ -2573,28 +3143,46 @@ void players_reconnect_to_structure_bsp( } } + /* port: in network co-op, if whoever crossed isn't in the new BSP yet, + the others gather to a player who is and stands on no switch trigger, + so nobody is left to switch it straight back */ + if (!found_player && network_coop_active()) + { + data_iterator_new(&iterator, player_data); + while ((player = data_iterator_next(&iterator)) != NULL && !found_player) + { + struct object_datum *unit = object_try_and_get(player->unit_index); + + if (unit && scenario_leaf_index_from_point(&unit->object.bounding_sphere_center) != NONE && + !players_coop_in_bsp_switch_trigger(player->unit_index)) + { + source_unit_index = player->unit_index; + teleport_position = unit->object.position; + found_player = TRUE; + } + } + } + match_vassert( "c:\\halo\\SOURCE\\game\\players.c", 0x63E, found_player, "no players in the bsp"); + /* port: all players, not just local ones (the same set in split screen) */ if (found_player) { - local_player_index = local_player_get_next(NONE); - while (local_player_index != NONE) + data_iterator_new(&iterator, player_data); + while ((player = data_iterator_next(&iterator)) != NULL) { - player_index = local_player_get_player_index(local_player_index); - player = player_get(player_index); if (player->unit_index != NONE && player->unit_index != source_unit_index) { player_teleport_on_bsp_switch( - player_index, + iterator.datum_index, source_unit_index, &teleport_position); - player_get(player_index)->cluster_index = NONE; + player->cluster_index = NONE; } - local_player_index = local_player_get_next(local_player_index); } } players_globals->pending_teleport_starting_location_index = NONE; @@ -3638,6 +4226,8 @@ void players_update_before_game( short action_index; profile_enter(PLAYERS_UPDATE_BEFORE_GAME_PROFILE); + players_coop_note_on_foot(); + players_coop_rescue_stranded(); if (update_client_dequeue(actions)) { data_iterator_new(&iterator, player_data); @@ -3708,10 +4298,24 @@ void players_update_before_game( else if (network_game_distributed_client()) game_engine_client_respawn_countdown(iterator.datum_index); } - else if (!main_menu_is_active()) + /* port: in co-op only the host spawns players; clients get their units + from the network. One who quit doesn't come back. */ + else if (!main_menu_is_active() && !network_game_distributed_client() && !player->quit_out_of_game) { if (player->statistics.deaths == 0) - player_spawn(iterator.datum_index); + { + /* port: in network co-op the first player spawns at the map's + start; everyone else, late joiners included, spawns beside a + player already in the game once nothing holds them back + (players_coop_may_spawn) */ + if (game_connection() != _game_connection_network_server) + player_spawn(iterator.datum_index); + else if (players_coop_may_spawn()) + { + player_spawn(iterator.datum_index); + player_place_beside_teammate(iterator.datum_index); + } + } else if (!players_globals->all_dead) main_respawn(players_globals->respawn_failed); } @@ -3930,7 +4534,12 @@ void players_update_after_game( { root_object_index = object_get_ultimate_parent(player->unit_index); root_object = object_get(root_object_index); - if (!TEST_FLAG(root_object->object.flags, _object_outside_of_map_bit)) + /* port: a co-op client only switches BSP when the host does + (network_distributed.c), and the host not again until the players + are in the new one */ + if (!TEST_FLAG(root_object->object.flags, _object_outside_of_map_bit) && + !(network_coop_active() && + (network_game_distributed_client() || game_time_get() < players_coop_state.bsp_switch_allowed_time))) { scenario = global_scenario_get(); for (bsp_switch_trigger_volume_index = 0; @@ -3964,6 +4573,7 @@ void players_update_after_game( bsp_switch_trigger_volume_index; main_switch_structure_bsp( bsp_switch_trigger_volume->destination_structure_bsp_index); + players_coop_state.bsp_switch_allowed_time = game_time_get() + COOP_BSP_SWITCH_SETTLE_TICKS; } } } @@ -3987,7 +4597,7 @@ void players_update_after_game( if (players_globals->all_dead) { - if (!game_engine_running() && !players_lost_map_started) + if (!game_engine_running() && !players_lost_map_started && !network_game_distributed_client()) { main_lost_map(); players_lost_map_started = TRUE; diff --git a/source/game/players.h b/source/game/players.h index 11de4202..d77e29e1 100644 --- a/source/game/players.h +++ b/source/game/players.h @@ -295,6 +295,11 @@ void player_control_new_unit( long unit_index); void player_control_action_test_reset( void); +void player_control_action_test_note( + unsigned long unit_control_flags, + real_euler_angles2d const *facing_delta, + real_vector2d const *throttle, + real primary_trigger); boolean player_control_action_test_accept( void); boolean player_control_action_test_back( @@ -373,6 +378,17 @@ short players_get_respawn_failure( boolean players_respawn_coop( void); +/* port: network co-op checkpoints (players.c): remember where the players +were at the last checkpoint, and respawn everyone there. Players other than +the first are held back from spawning on a new level: until its opening +cutscene ends and a teammate has been free on foot a few seconds (not +riding a vehicle, nor held by the scripts). */ +boolean players_coop_waiting_to_start( + long player_index); +void players_note_checkpoint( + void); +void players_respawn_at_checkpoint( + void); void players_reconnect_to_structure_bsp( void); diff --git a/source/hs/hs.h b/source/hs/hs.h index 2ec84cc9..98c7f317 100644 --- a/source/hs/hs.h +++ b/source/hs/hs.h @@ -179,6 +179,9 @@ void hs_runtime_initialize( void); void hs_runtime_initialize_for_new_map( void); +/* port: network co-op (port/linux/game/network_coop.c) */ +void hs_runtime_port_shift_sleep_times( + long ticks); void hs_runtime_update( void); long hs_runtime_evaluate( diff --git a/source/hs/hs_library_external.c b/source/hs/hs_library_external.c index bee90038..06aee84d 100644 --- a/source/hs/hs_library_external.c +++ b/source/hs/hs_library_external.c @@ -104,6 +104,7 @@ symbols in this file: #include "sound/sound_definitions.h" #include "units/units.h" #include "object_lists.h" +#include "network_coop.h" /* port: port/linux/game/network_coop.c */ /* ---------- constants */ @@ -674,6 +675,8 @@ void hs_effect_new( struct scenario_cutscene_flag *cutscene_flag; real_vector3d forward; + /* port: and on network co-op's clients (port/linux/game/network_coop.c) */ + network_coop_note_effect(effect_definition_index, cutscene_flag_index); cutscene_flag = TAG_BLOCK_GET_ELEMENT( &global_scenario_get()->cutscene_flags, cutscene_flag_index, @@ -707,6 +710,9 @@ void hs_effect_new_from_object_marker( { struct object_marker marker; + /* port: and on network co-op's clients (port/linux/game/network_coop.c) */ + network_coop_note_object_effect(effect_definition_index, object_index, marker_name); + if (object_get_marker_by_name( object_index, marker_name, @@ -998,6 +1004,9 @@ void hs_teleport_players_not_in_trigger_volume( short cutscene_flag_index) { long player_index; + /* port: in network co-op the first player moved goes to the flag and + the rest around them, instead of all into the same spot */ + long first_unit_index = NONE; for (player_index = data_next_index(player_data, NONE); player_index != NONE; @@ -1011,11 +1020,17 @@ void hs_teleport_players_not_in_trigger_volume( trigger_volume_index, player->unit_index)) { + if (first_unit_index != NONE && network_coop_active()) + { + player_teleport(player_index, first_unit_index, &object_get(first_unit_index)->object.position); + continue; + } hs_object_orient( player->unit_index, cutscene_flag_index, TRUE, TRUE); + first_unit_index = player->unit_index; } } diff --git a/source/hs/hs_runtime.c b/source/hs/hs_runtime.c index 51e5b910..dcb98ee1 100644 --- a/source/hs/hs_runtime.c +++ b/source/hs/hs_runtime.c @@ -1120,6 +1120,24 @@ void render_debug_trigger_volumes( return; } +/* port: moves every sleeping script thread's wake time on by `ticks`, after +a network co-op host reverts but keeps its clock (network_coop.c) */ +void hs_runtime_port_shift_sleep_times( + long ticks) +{ + struct data_iterator iterator; + struct hs_thread_datum *thread; + + data_iterator_new(&iterator, hs_thread_data); + while ((thread = data_iterator_next(&iterator)) != NULL) + { + if (thread->sleep_until > 0) + thread->sleep_until += ticks; + if (thread->previous_sleep_until > 0) + thread->previous_sleep_until += ticks; + } +} + void hs_runtime_update( void) { diff --git a/source/interface/hud.c b/source/interface/hud.c index f8ea1b4f..3c13a022 100644 --- a/source/interface/hud.c +++ b/source/interface/hud.c @@ -107,6 +107,8 @@ symbols in this file: #include "sound/game_sound.h" #include "tag_files/tag_files.h" #include "text/draw_string.h" +#include "coop_spectate.h" /* port: port/linux/game/coop_spectate.c */ +#include "network_coop.h" /* port: port/linux/game/network_coop.c */ #include "text/font_group.h" #include "text/text_group.h" #include "units/unit_definitions.h" @@ -870,6 +872,7 @@ void hud_autosave( : hud_globals->checkpoint_end_index; short local_player_index; + network_coop_note_hud(_coop_hud_checkpoint, active); scripted_hud_messages_clear(); if (active && hud_globals->checkpoint_sound.index != NONE) { @@ -993,7 +996,7 @@ static void hud_draw_players( return; } -/* port: in multiplayer, players' names above their heads +/* port: in multiplayer and network co-op, players' names above their heads (display.player_names: "all", "allies", "enemies" or "none"). An ally's goes above the triangle the game draws over teammates; an enemy's only within the motion sensor's reach, while the view sees them and they are not @@ -1190,7 +1193,8 @@ static void hud_draw_player_names( if (setting == _player_names_none || player_index == NONE) return; team_index = player_get(player_index)->team_index; - indicators = game_engine_display_team_indicators(); + /* the campaign always draws teammate triangles (hud_draw_players) */ + indicators = game_engine_display_team_indicators() || !game_engine_running(); enemy_range = hud_player_name_enemy_range(); /* (the players the motion tracker would show this local player) */ game_engine_motion_sensor_viewer(render.local_player_index); @@ -1360,9 +1364,15 @@ void hud_draw_screen( hud_draw_players(); } - /* port: players' names above their heads, in multiplayer */ - if (game_engine_running() && !cinematic_in_progress()) + /* port: players' names above their heads, in multiplayer and network co-op */ + if ((game_engine_running() || network_coop_active()) && !cinematic_in_progress()) hud_draw_player_names(); + /* port: who a dead network co-op player is watching */ + if (player->unit_index == NONE && coop_spectating() && !cinematic_in_progress()) + coop_spectate_draw(render.local_player_index); + /* port: the network co-op vote to skip a cinematic */ + if (cinematic_in_progress()) + coop_skip_vote_draw(render.local_player_index); if (!game_time_get_paused() && render.local_player_index == local_player_get_next(NONE)) diff --git a/source/interface/hud_messaging.c b/source/interface/hud_messaging.c index 1413e765..7ce25c99 100644 --- a/source/interface/hud_messaging.c +++ b/source/interface/hud_messaging.c @@ -145,6 +145,7 @@ symbols in this file: #include "text/font_group.h" #include "text/text_group.h" #include "text/unicode.h" +#include "network_coop.h" /* port: port/linux/game/network_coop.c */ #include @@ -606,6 +607,7 @@ void scripted_hud_set_state_message( { struct scenario *scenario = global_scenario_get(); + network_coop_note_hud(_coop_hud_help_text, message_index); if (hud_scripted_globals->show_hud_help_text && scenario->hud_messages.index != NONE) { @@ -626,6 +628,7 @@ void scripted_hud_set_flashing_state( { long time; + network_coop_note_hud(_coop_hud_help_flash, flash); if (flash && !hud_messaging_globals->use_flash) { time = game_time_get(); @@ -656,6 +659,7 @@ void scripted_hud_set_objective( { struct scenario *scenario = global_scenario_get(); + network_coop_note_hud(_coop_hud_objective, message_index); if (scenario->hud_messages.index != NONE) { struct hud_message_text_definition *hud_messages = @@ -737,6 +741,47 @@ void scripted_hud_show_timer( return; } +/* port: the script timer and the scenario's message count, for network +co-op (port/linux/game/network_coop.c) */ +void hud_messaging_port_timer_get( + struct hud_timer_state *state) +{ + struct hud_timer_data_definition const *timer = &hud_messaging_globals->timer; + + state->reference_time = timer->reference_time; + state->ticks = timer->ticks; + state->flash_cutoff = timer->flash_cutoff; + state->x = timer->position.n[0]; + state->y = timer->position.n[1]; + state->corner = timer->corner; + state->paused = timer->paused; + state->enabled = timer->enabled; +} + +void hud_messaging_port_timer_set( + struct hud_timer_state const *state) +{ + struct hud_timer_data_definition *timer = &hud_messaging_globals->timer; + + timer->reference_time = state->reference_time; + timer->ticks = state->ticks; + timer->flash_cutoff = state->flash_cutoff; + timer->position.n[0] = state->x; + timer->position.n[1] = state->y; + timer->corner = PIN(state->corner, 0, 4); + timer->paused = state->paused; + timer->enabled = state->enabled; +} + +short hud_messaging_port_message_count( + void) +{ + struct scenario *scenario = global_scenario_get(); + + return scenario->hud_messages.index != NONE ? + (short)HUD_MESSAGE_TEXT_DEFINITION_GET(scenario->hud_messages.index)->messages.count : 0; +} + void scripted_hud_pause_timer( boolean paused) { @@ -1129,6 +1174,7 @@ void scripted_hud_messages_clear( struct hud_messaging_datum_definition *datum = hud_messaging_globals->message_data; long datum_count = NUMBER_OF_HUD_MESSAGING_DATUMS; + network_coop_note_hud(_coop_hud_messages_clear, 0); do { struct hud_message_definition *message = datum->messages; diff --git a/source/interface/hud_messaging.h b/source/interface/hud_messaging.h index 76cb0e90..5e16c5a4 100644 --- a/source/interface/hud_messaging.h +++ b/source/interface/hud_messaging.h @@ -27,6 +27,24 @@ void hud_messaging_dispose_from_old_map( void); void hud_messaging_dispose( void); +/* port: network co-op (port/linux/game/network_coop.c) */ +struct hud_timer_state +{ + long reference_time; + short ticks; + short flash_cutoff; + short x, y; + short corner; + boolean paused; + boolean enabled; +}; +void hud_messaging_port_timer_get( + struct hud_timer_state *state); +void hud_messaging_port_timer_set( + struct hud_timer_state const *state); +short hud_messaging_port_message_count( + void); + void scripted_hud_set_state_message( short message_index); void hud_messaging_globals_update( diff --git a/source/interface/hud_nav_points.c b/source/interface/hud_nav_points.c index c0718e75..c253d21f 100644 --- a/source/interface/hud_nav_points.c +++ b/source/interface/hud_nav_points.c @@ -123,6 +123,7 @@ symbols in this file: #include "scenario/scenario.h" #include "scenario/scenario_definitions.h" #include "units/units.h" +#include "network_coop.h" /* port: port/linux/game/network_coop.c */ /* ---------- constants */ @@ -496,6 +497,7 @@ void hud_activate_team_nav_point_with_flag( short flag_index, float vertical_offset) { + network_coop_note_nav_point(_coop_nav_point_team_flag, nav_index, team_index, flag_index, vertical_offset); hud_activate_team_nav_point(nav_index, team_index, _hud_nav_point_type_flag, flag_index, vertical_offset); return; @@ -507,6 +509,7 @@ void hud_activate_team_nav_point_with_object( long object_index, float vertical_offset) { + network_coop_note_nav_point(_coop_nav_point_team_object, nav_index, team_index, object_index, vertical_offset); hud_activate_team_nav_point(nav_index, team_index, _hud_nav_point_type_object, object_index, vertical_offset); return; @@ -553,6 +556,7 @@ void hud_deactivate_team_nav_point_with_flag( short team_index, short flag_index) { + network_coop_note_nav_point(_coop_nav_point_team_flag, NONE, team_index, flag_index, 0.0f); hud_deactivate_team_nav_point(team_index, _hud_nav_point_type_flag, flag_index); return; @@ -562,6 +566,7 @@ void hud_deactivate_team_nav_point_with_object( short team_index, long object_index) { + network_coop_note_nav_point(_coop_nav_point_team_object, NONE, team_index, object_index, 0.0f); hud_deactivate_team_nav_point(team_index, _hud_nav_point_type_object, object_index); return; @@ -575,6 +580,7 @@ void hud_unit_activate_nav_point_with_flag( { long player_index = player_index_from_unit_index(unit_index); + network_coop_note_nav_point(_coop_nav_point_unit_flag, nav_index, unit_index, flag_index, vertical_offset); if (player_index!=NONE) { hud_activate_nav_point(nav_index, player_index, _hud_nav_point_type_flag, flag_index, vertical_offset); @@ -591,6 +597,7 @@ void hud_unit_activate_nav_point_with_object( { long player_index = player_index_from_unit_index(unit_index); + network_coop_note_nav_point(_coop_nav_point_unit_object, nav_index, unit_index, object_index, vertical_offset); if (player_index!=NONE) { hud_activate_nav_point(nav_index, player_index, _hud_nav_point_type_object, object_index, vertical_offset); @@ -605,6 +612,7 @@ void hud_unit_deactivate_nav_point_with_flag( { long player_index = player_index_from_unit_index(unit_index); + network_coop_note_nav_point(_coop_nav_point_unit_flag, NONE, unit_index, flag_index, 0.0f); if (player_index!=NONE) { hud_deactivate_nav_point(player_index, _hud_nav_point_type_flag, flag_index); @@ -619,6 +627,7 @@ void hud_unit_deactivate_nav_point_with_object( { long player_index = player_index_from_unit_index(unit_index); + network_coop_note_nav_point(_coop_nav_point_unit_object, NONE, unit_index, object_index, 0.0f); if (player_index!=NONE) { hud_deactivate_nav_point(player_index, _hud_nav_point_type_object, object_index); diff --git a/source/interface/hud_unit.c b/source/interface/hud_unit.c index 5b04897f..c5d4fcf6 100644 --- a/source/interface/hud_unit.c +++ b/source/interface/hud_unit.c @@ -381,6 +381,20 @@ void scripted_hud_blink_motion_sensor( return; } +/* port: the scripts' show and blink flags, for network co-op +(port/linux/game/network_coop.c) */ +long hud_unit_port_script_flags( + void) +{ + return unit_hud_globals->script_flags; +} + +void hud_unit_port_set_script_flags( + long flags) +{ + unit_hud_globals->script_flags = flags; +} + void hud_play_unit_sounds( struct player_datum const *player, boolean show_hud) diff --git a/source/interface/hud_unit.h b/source/interface/hud_unit.h index 30a86371..41598a56 100644 --- a/source/interface/hud_unit.h +++ b/source/interface/hud_unit.h @@ -31,6 +31,11 @@ void hud_tick_shield( long player_index, real amount); +/* port: network co-op (port/linux/game/network_coop.c) */ +long hud_unit_port_script_flags( + void); +void hud_unit_port_set_script_flags( + long flags); void scripted_hud_show_health( boolean show); void scripted_hud_blink_health( diff --git a/source/interface/hud_weapon.c b/source/interface/hud_weapon.c index 165639a2..bdc88317 100644 --- a/source/interface/hud_weapon.c +++ b/source/interface/hud_weapon.c @@ -480,6 +480,20 @@ void scripted_hud_show_crosshair( return; } +/* port: the scripts' crosshair flag, for network co-op +(port/linux/game/network_coop.c) */ +long hud_weapon_port_script_flags( + void) +{ + return weapon_hud_globals->script_flags; +} + +void hud_weapon_port_set_script_flags( + long flags) +{ + weapon_hud_globals->script_flags = flags; +} + static struct weapon_hud_state *get_hud_state( short local_player_index) { diff --git a/source/interface/hud_weapon.h b/source/interface/hud_weapon.h index 7fcc6117..2f33adaa 100644 --- a/source/interface/hud_weapon.h +++ b/source/interface/hud_weapon.h @@ -14,6 +14,11 @@ void hud_dispose_weapon_interface_from_old_map( void hud_dispose_weapon_interface( void); +/* port: network co-op (port/linux/game/network_coop.c) */ +long hud_weapon_port_script_flags( + void); +void hud_weapon_port_set_script_flags( + long flags); void scripted_hud_show_crosshair( boolean show); diff --git a/source/interface/ui_widget.c b/source/interface/ui_widget.c index 6c864459..762f7677 100644 --- a/source/interface/ui_widget.c +++ b/source/interface/ui_widget.c @@ -662,6 +662,7 @@ struct widget_instance; #include "networking/network_connection.h" #include "networking/network_game_globals.h" #include "networking/network_server_manager.h" +#include "network_coop.h" /* port: port/linux/game/network_coop.c */ #include "rasterizer/rasterizer.h" #include "saved games/player_profile.h" #include "saved games/playlist_profile.h" @@ -3697,7 +3698,10 @@ static void widget_instance_initialize( widget->visible = TRUE; widget->render_regardless_of_controller_index = TEST_FLAG(definition->flags, _widget_render_regardless_of_controller_index_bit); - widget->pause_game_time = TEST_FLAG(definition->flags, _widget_pause_game_time_bit); + /* port: a network game never pauses (co-op opens the campaign's pause + screen, which would) */ + widget->pause_game_time = TEST_FLAG(definition->flags, _widget_pause_game_time_bit) && + !network_game_is_active(); widget->creation_time = widget_globals.current_system_milliseconds; widget->milliseconds_to_auto_close = MAX(definition->milliseconds_to_auto_close, 0); widget->auto_close_fade_time = MAX(definition->auto_close_fade_time, 0); @@ -7181,7 +7185,10 @@ static boolean ui_check_for_pause_game( network_game_client_get_machine_index(client); char const *widget_name; - switch (local_player_count) + /* port: a campaign map has only the campaign's pause screen */ + if (network_coop_active()) + widget_name = "ui\\shell\\solo_game\\pause_game\\pause_game"; + else switch (local_player_count) { case 1: widget_name = diff --git a/source/interface/ui_widget_event_handler_functions.c b/source/interface/ui_widget_event_handler_functions.c index 11108256..53e19bb6 100644 --- a/source/interface/ui_widget_event_handler_functions.c +++ b/source/interface/ui_widget_event_handler_functions.c @@ -923,6 +923,7 @@ symbols in this file: #include "networking/network_game_manager.h" #include "networking/network_messages.h" #include "networking/network_server_manager.h" +#include "network_coop.h" /* port: port/linux/game/network_coop.c */ #include "saved games/game_state.h" #include "saved games/player_profile.h" #include "interface/ui_widget_definitions.h" @@ -1991,6 +1992,9 @@ static boolean pause_game_restart_at_checkpoint( struct event_record *event, boolean *widget_deleted) { + /* port: in co-op this would revert only this machine */ + if (network_coop_active()) + return FALSE; main_revert_map(); return TRUE; } @@ -2000,6 +2004,9 @@ static boolean pause_game_restart_level( struct event_record *event, boolean *widget_deleted) { + /* port: in co-op this would restart only this machine */ + if (network_coop_active()) + return FALSE; main_reset_map(); return TRUE; } @@ -2009,6 +2016,17 @@ static boolean pause_game_quit_to_main_menu( struct event_record *event, boolean *widget_deleted) { + /* port: in co-op, take every player on this machine out of the network + game with one press (not one per split screen player). The solo save is + left alone. */ + if (network_coop_active()) + { + short controller_index; + + for (controller_index = 0; controller_index < MAXIMUM_NUMBER_OF_LOCAL_PLAYERS; controller_index++) + network_game_client_local_player_quit(controller_index); + return TRUE; + } game_state_save_to_persistent_storage(); main_goto_main_menu(); return TRUE; @@ -5928,6 +5946,30 @@ short ui_widget_port_gametypes( return (short)count; } +/* port: sets up the server for co-op (port/linux/game/menu_functions.c): +the campaign level, the difficulty, and a gametype with no game engine, +which is what makes a network game co-op (game.c, players.c). Returns FALSE +without a server or a campaign level. */ +boolean ui_widget_port_cooperative_level_choose( + char const *map_name, + short difficulty) +{ + struct network_game_server *server = global_network_game_server_get(); + struct game_variant variant; + + if (!server || !map_name || main_get_solo_level_from_name(map_name) == NONE) + return FALSE; + csmemset(&variant, 0, sizeof(variant)); + ustrncpy(variant.human_readable_game_description, L"Co-op", + NUMBEROF(variant.human_readable_game_description) - 1); + main_set_difficulty(difficulty); + main_set_multiplayer_map_name(map_name); + network_game_server_port_set_cooperative(server, difficulty); + network_game_server_change_map_name(server, map_name); + network_game_server_change_game_variant(server, &variant); + return TRUE; +} + /* the gametype chosen (as multiplayer_profile_set_for_game), the server's if there is one */ boolean ui_widget_port_gametype_choose( diff --git a/source/main/main.c b/source/main/main.c index 2595ba53..c41798ab 100644 --- a/source/main/main.c +++ b/source/main/main.c @@ -377,6 +377,9 @@ symbols in this file: #include "bink/bink_playback.h" #include "main/d3d_intimacy.h" #include "networking/network_game_globals.h" +#include "networking/network_game_manager.h" +#include "networking/network_server_manager.h" /* port: a co-op game's level won */ +#include "network_coop.h" /* port: port/linux/game/network_coop.c */ #include "camera/director.h" #include "camera/observer.h" #include "cutscene/cinematics.h" @@ -1854,12 +1857,23 @@ static void main_revert_map_private( static void main_skip_cinematic_private( void) { - if (cinematic_can_be_skipped()) + /* port: a network host never reverts without a saved state, which + would reset the map on the host alone */ + if (cinematic_can_be_skipped() && + (game_connection() != _game_connection_network_server || game_state_port_saved_game_valid())) { + long now = game_time_get(); + game_state_revert(); + network_coop_skip_reverted(now); ui_widgets_disable_pause_game(30); main_globals.revert_map = FALSE; } + else if (game_connection() == _game_connection_network_server) + { + error(_error_silent, "co-op: cutscene not skipped (skippable %d, saved state %d)", + cinematic_can_be_skipped(), game_state_port_saved_game_valid()); + } main_globals.skip_cinematic = FALSE; return; } @@ -1939,6 +1953,8 @@ static void main_save_map_private( if (save_map) { + /* port: remember where the players are, for network co-op respawns */ + players_note_checkpoint(); hud_autosave(TRUE); main_globals.save_map_completed = TRUE; main_globals.saving_map = FALSE; @@ -1995,7 +2011,12 @@ static void main_lost_map_private( { main_globals.lost_map = FALSE; main_globals.loss_timer = 0; - game_state_revert(); + /* port: network co-op can't revert to a checkpoint (every machine would + have to), so the players respawn where they were at the last one */ + if (game_connection() == _game_connection_network_server) + players_respawn_at_checkpoint(); + else + game_state_revert(); } return; } @@ -2153,6 +2174,22 @@ static void main_won_map_private( { short level; short local_player_index; + + /* port: when a network co-op level is won, the round ends for everyone as + in multiplayer, back to the lobby, and the next round is the campaign's + next level (The Maw's: The Pillar of Autumn). A level not in the campaign + repeats. */ + if (game_connection() == _game_connection_network_server) + { + struct network_game *game = network_game_get_game(); + + main_globals.won_map = FALSE; + level = game ? main_get_solo_level_from_name(game->map.name) : NONE; + player_profile_save_level_completed(0); + network_game_server_port_cooperative_won(level != NONE ? + main_get_solo_level_name((level + 1) % NUMBER_OF_SINGLE_PLAYER_LEVELS) : NULL); + return; + } main_globals.want_to_be_at_main_menu = TRUE; main_globals.won_map = FALSE; level = main_get_solo_level_from_name(main_globals.soloplayer_map_name) + 1; diff --git a/source/networking/network_server_manager.c b/source/networking/network_server_manager.c index be9916bb..af40eb7b 100644 --- a/source/networking/network_server_manager.c +++ b/source/networking/network_server_manager.c @@ -765,6 +765,10 @@ static void network_game_server_remove_players_gone_while_loading( /* ---------- globals */ +/* port: the map for the next co-op round after a win +(network_game_server_port_cooperative_won); empty when there is none */ +static char network_game_server_cooperative_next_map[sizeof(((struct network_game *)NULL)->map.name)]; + struct network_game_server network_game_server_memory_do_not_use_directly; boolean network_game_server_memory_do_not_use_directly_in_use = FALSE; @@ -1182,6 +1186,9 @@ void network_game_server_dispose( { match_assert(NETWORK_SERVER_MANAGER_FILE, 0x120, server); + /* port: a won co-op round's next level belongs to this server alone */ + network_game_server_cooperative_next_map[0] = 0; + switch (server->state) { case _network_game_server_state_pregame: @@ -1344,6 +1351,14 @@ static void network_game_server_list( listing_text(name, sizeof(name), game->name, NUMBEROF(game->name)); listing_text(gametype, sizeof(gametype), game->variant.human_readable_game_description, NUMBEROF(game->variant.human_readable_game_description)); + /* co-op: add the difficulty */ + if (!game->variant.game_engine_index && game->difficulty >= 0 && game->difficulty < 4) + { + static char const *const difficulty_names[] = { "Easy", "Normal", "Heroic", "Legendary" }; + size_t length = strlen(gametype); + + snprintf(gametype + length, sizeof(gametype) - length, " %s", difficulty_names[game->difficulty]); + } /* (the scenario's name, not its path: the listing has 32 characters) */ p2p_set_game_listing(name, tag_name_strip_path(game->map.name), gametype, game->variant.game_engine_index, open, in_progress, game->variant.universal_variant.teams); @@ -3835,6 +3850,55 @@ void network_game_server_port_set_settings( network_game_server_port_settings_apply(server); } +void network_game_server_port_cooperative_won( + char const *next_map) +{ + struct network_game_server *server = global_network_game_server_get(); + + if (!server) + return; + csstrncpy(network_game_server_cooperative_next_map, next_map ? next_map : server->game.map.name, + sizeof(network_game_server_cooperative_next_map) - 1); + network_game_server_cooperative_next_map[sizeof(network_game_server_cooperative_next_map) - 1] = 0; + network_game_server_switch_to_postgame(server); +} + +/* port: reapply the co-op settings after a won round, since the playlist +the next round is set up from (network_game_server_setup_game_from_playlist) +has a multiplayer gametype and map */ +static void network_game_server_cooperative_round( + struct network_game_server *server) +{ + struct game_variant variant; + + if (!network_game_server_cooperative_next_map[0]) + return; + csmemset(&variant, 0, sizeof(variant)); + ustrncpy(variant.human_readable_game_description, L"Co-op", + NUMBEROF(variant.human_readable_game_description) - 1); + csmemcpy(&server->game.variant, &variant, sizeof(server->game.variant)); + network_game_server_variant_options(&server->game.variant, &server->game.variant_options); + csstrncpy(server->game.map.name, network_game_server_cooperative_next_map, sizeof(server->game.map.name) - 1); + server->game.map.name[sizeof(server->game.map.name) - 1] = 0; + main_set_multiplayer_map_name(server->game.map.name); + server->game.maximum_teams = 1; + network_game_server_cooperative_next_map[0] = 0; +} + +void network_game_server_port_set_cooperative( + struct network_game_server *server, + short difficulty) +{ + if (!server || server->state != _network_game_server_state_pregame) + return; + server->game.difficulty = difficulty; + server->game.maximum_players = MAXIMUM_NETWORK_PLAYER_COUNT; + if (!network_game_server_send_game_data_pregame(server)) + network_event("network_game_server_port_set_cooperative() failed to send updated game settings to clients"); + + return; +} + /* port: a gametype's PC options: the menus' (player_ui_set_game_variant_options) when it is the menus' gametype, else its defaults */ static void network_game_server_variant_options( @@ -4592,6 +4656,7 @@ boolean network_game_server_reset_to_pregame( network_game_reset_for_next_round(&server->game, FALSE); if (network_game_server_setup_game_from_playlist(server)) { + network_game_server_cooperative_round(server); /* the settings record goes out in pieces */ /* (the pregame whatever a machine missed: the machines are in it, and the pregame's flush sends the settings again) */ diff --git a/source/networking/network_server_manager.h b/source/networking/network_server_manager.h index e11c1276..be1243a7 100644 --- a/source/networking/network_server_manager.h +++ b/source/networking/network_server_manager.h @@ -59,6 +59,15 @@ every game the server sets up */ void network_game_server_port_set_settings( wchar_t const *name, long maximum_players); +/* port: a co-op level was won (main.c). Ends the round for everyone as in +multiplayer; the next round is on next_map (NULL repeats the level). */ +void network_game_server_port_cooperative_won( + char const *next_map); +/* port: co-op server settings: the difficulty, sent to clients, and the +build's maximum players (Server Setup's player count is for PvP) */ +void network_game_server_port_set_cooperative( + struct network_game_server *server, + short difficulty); boolean network_game_server_ban_player( char const *text); short network_game_server_matching_player_names( diff --git a/source/objects/objects.c b/source/objects/objects.c index cfb31232..f434efb2 100644 --- a/source/objects/objects.c +++ b/source/objects/objects.c @@ -117,6 +117,10 @@ on the host's word */ long network_objects_new_object_index(void); boolean network_objects_creating_host_object(void); boolean network_objects_may_delete(long object_index); +/* port/linux/game/network_coop.c's: the scripts' attaching, on co-op's clients */ +void network_coop_note_attach(long parent_index, char const *parent_marker_name, long child_index, + char const *child_marker_name); +void network_coop_note_detach(long parent_index, long child_index); static void object_connect_lights(long object_index, boolean disconnect, boolean reconnect); static void object_name_list_allocate(void); @@ -2887,6 +2891,7 @@ void objects_scripting_detach( child_object_index!=NONE && object_get(child_object_index)->object.parent_object_index == parent_object_index) { + network_coop_note_detach(parent_object_index, child_object_index); object_detach(child_object_index); } @@ -3822,6 +3827,7 @@ void objects_scripting_attach( child_object_index !=NONE && object_get(child_object_index)->object.parent_object_index==NONE) { + network_coop_note_attach(parent_object_index, parent_marker_name, child_object_index, child_marker_name); object_attach_to_marker(parent_object_index, parent_marker_name, child_object_index, child_marker_name); } diff --git a/source/objects/scenery.c b/source/objects/scenery.c index 2c65141f..ec46b93c 100644 --- a/source/objects/scenery.c +++ b/source/objects/scenery.c @@ -42,6 +42,7 @@ symbols in this file: #include "objects/object_definitions.h" #include "scenario/scenario_definitions.h" #include "tag_files/tag_files.h" +#include "network_coop.h" /* port: port/linux/game/network_coop.c */ /* ---------- constants */ @@ -239,6 +240,8 @@ static void scenery_animation_start_private( scenery->object.object.animation.state.frame_index = frame_index < 0 ? 0 : MIN(frame_index, animation->frame_count - 1); scenery->object.object.animation.animation_graph_index = animation_graph_index; + network_coop_note_scenery_animation(object_index, animation_graph_index, animation_index, + scenery->object.object.animation.state.frame_index); } else { diff --git a/source/rasterizer/rasterizer_cinematics.c b/source/rasterizer/rasterizer_cinematics.c index a6bd37d4..95ac5bd4 100644 --- a/source/rasterizer/rasterizer_cinematics.c +++ b/source/rasterizer/rasterizer_cinematics.c @@ -497,4 +497,85 @@ real rasterizer_get_near_clip_distance( return near_clip_distance; } +/* port: the state for network co-op (rasterizer_cinematics.h) */ +void rasterizer_screen_effect_port_get( + struct rasterizer_screen_effect_port_state *state) +{ + struct rasterizer_cinematic_screen_effect_state const *globals = cinematic_screen_effect_globals; + + csmemset(state, 0, sizeof(*state)); + if (!globals) + return; + state->has_control = globals->has_control; + state->initialized = globals->initialized; + state->video_on = globals->parameters.video_on; + state->filter_desaturation_is_additive = globals->parameters.filter_desaturation_is_additive; + state->filter_light_enhancement_uses_convolution_mask = + globals->parameters.filter_light_enhancement_uses_convolution_mask; + state->filter_desaturation_uses_convolution_mask = globals->parameters.filter_desaturation_uses_convolution_mask; + state->convolution_extra_passes = globals->parameters.convolution_extra_passes; + state->convolution_type = globals->parameters.convolution_type; + state->video_overbright_mode = globals->parameters.video_overbright_mode; + state->filter_desaturation_tint = globals->parameters.filter_desaturation_tint; + state->video_noise_intensity = globals->parameters.video_noise_intensity; + csmemcpy(state->convolution_radius, globals->convolution_radius, sizeof(state->convolution_radius)); + csmemcpy(state->convolution_time, globals->convolution_time, sizeof(state->convolution_time)); + csmemcpy(state->filter_light_enhancement_intensity, globals->filter_light_enhancement_intensity, + sizeof(state->filter_light_enhancement_intensity)); + csmemcpy(state->filter_desaturation_intensity, globals->filter_desaturation_intensity, + sizeof(state->filter_desaturation_intensity)); + csmemcpy(state->filter_time, globals->filter_time, sizeof(state->filter_time)); + csmemcpy(state->script_values, globals->script_values, sizeof(state->script_values)); + state->near_clip_distance = globals->near_clip_distance; +} + +/* port: the first bitmap of one of the video effect's bitmap groups, as +rasterizer_screen_effect_set_video uses it */ +static struct bitmap_data *screen_effect_video_bitmap( + long bitmap_group_index) +{ + return TAG_BLOCK_GET_ELEMENT(&bitmap_group_get(bitmap_group_index)->bitmaps, 0, struct bitmap_data); +} + +/* port: the host's state put in place on a client, with the video effect's +bitmaps found on this machine */ +void rasterizer_screen_effect_port_set( + struct rasterizer_screen_effect_port_state const *state) +{ + struct rasterizer_cinematic_screen_effect_state *globals = cinematic_screen_effect_globals; + boolean video = state->video_on && global_rasterizer_data && + global_rasterizer_data->screen_effect_video_scanline_map.index != NONE && + global_rasterizer_data->screen_effect_video_noise_map.index != NONE; + + if (!globals) + return; + globals->has_control = state->has_control; + globals->initialized = state->initialized; + globals->parameters.convolution_extra_passes = state->convolution_extra_passes; + globals->parameters.convolution_type = state->convolution_type; + globals->parameters.convolution_mask = NULL; + globals->parameters.filter_desaturation_tint = state->filter_desaturation_tint; + globals->parameters.filter_desaturation_is_additive = state->filter_desaturation_is_additive; + globals->parameters.filter_light_enhancement_uses_convolution_mask = + state->filter_light_enhancement_uses_convolution_mask; + globals->parameters.filter_desaturation_uses_convolution_mask = state->filter_desaturation_uses_convolution_mask; + globals->parameters.video_on = video; + globals->parameters.video_overbright_mode = state->video_overbright_mode; + globals->parameters.video_noise_intensity = state->video_noise_intensity; + globals->parameters.video_noise_map_scale = video ? 1.0f : 0.0f; + globals->parameters.video_scanline_map = + video ? screen_effect_video_bitmap(global_rasterizer_data->screen_effect_video_scanline_map.index) : NULL; + globals->parameters.video_noise_map = + video ? screen_effect_video_bitmap(global_rasterizer_data->screen_effect_video_noise_map.index) : NULL; + csmemcpy(globals->convolution_radius, state->convolution_radius, sizeof(globals->convolution_radius)); + csmemcpy(globals->convolution_time, state->convolution_time, sizeof(globals->convolution_time)); + csmemcpy(globals->filter_light_enhancement_intensity, state->filter_light_enhancement_intensity, + sizeof(globals->filter_light_enhancement_intensity)); + csmemcpy(globals->filter_desaturation_intensity, state->filter_desaturation_intensity, + sizeof(globals->filter_desaturation_intensity)); + csmemcpy(globals->filter_time, state->filter_time, sizeof(globals->filter_time)); + csmemcpy(globals->script_values, state->script_values, sizeof(globals->script_values)); + globals->near_clip_distance = state->near_clip_distance; +} + /* ---------- private code */ diff --git a/source/rasterizer/rasterizer_cinematics.h b/source/rasterizer/rasterizer_cinematics.h index b74c6d1f..174425ed 100644 --- a/source/rasterizer/rasterizer_cinematics.h +++ b/source/rasterizer/rasterizer_cinematics.h @@ -87,6 +87,38 @@ void rasterizer_set_near_clip_distance( real rasterizer_get_near_clip_distance( void); +/* port: the cinematic screen effect as the scripts left it, without its +bitmaps' addresses, for network co-op to copy from the host to a client +(port/linux/game/network_coop.c). Its times are game time, the same on +every machine, so a client's effect fades as the host's does. Every field +from filter_desaturation_tint on is a real. */ +struct rasterizer_screen_effect_port_state +{ + byte has_control; + byte initialized; + byte video_on; + byte filter_desaturation_is_additive; + byte filter_light_enhancement_uses_convolution_mask; + byte filter_desaturation_uses_convolution_mask; + short convolution_extra_passes; + short convolution_type; + short video_overbright_mode; + real_rgb_color filter_desaturation_tint; + real video_noise_intensity; + real convolution_radius[2]; + real convolution_time[2]; + real filter_light_enhancement_intensity[2]; + real filter_desaturation_intensity[2]; + real filter_time[2]; + real script_values[4]; + real near_clip_distance; +}; + +void rasterizer_screen_effect_port_get( + struct rasterizer_screen_effect_port_state *state); +void rasterizer_screen_effect_port_set( + struct rasterizer_screen_effect_port_state const *state); + /* ---------- globals */ /* ---------- public code */ diff --git a/source/saved games/game_state.c b/source/saved games/game_state.c index 3388746c..726605c9 100644 --- a/source/saved games/game_state.c +++ b/source/saved games/game_state.c @@ -299,6 +299,22 @@ void game_state_save( return; } +/* port: whether game_state_revert has a saved state to go back to */ +boolean game_state_port_saved_game_valid( + void) +{ + return game_state_globals.saved_game_valid; +} + +/* port: stamps the revert at the current game time again, after a network +co-op host moved its clock on past the revert (game_state_reverted +compares the two) */ +void game_state_port_restamp_revert_time( + void) +{ + game_state_globals.revert_time = game_time_get(); +} + void game_state_revert( void) { diff --git a/source/sound/game_sound.c b/source/sound/game_sound.c index a798be5b..7081db00 100644 --- a/source/sound/game_sound.c +++ b/source/sound/game_sound.c @@ -512,6 +512,16 @@ long unspatialized_impulse_sound_new( 0); } +/* port: a co-op host sends its scripts' sounds to its clients +(port/linux/game/network_coop.c; the kinds are in network_distributed.h) */ +enum +{ + _coop_sound_impulse, + _coop_sound_looping_start, + _coop_sound_looping_stop, +}; +void network_coop_note_sound(short kind, long definition_index, long object_index, real scale); + void scripted_sound_new( long definition_index, long source_object_index, @@ -525,6 +535,7 @@ void scripted_sound_new( if (definition_index != NONE) { + network_coop_note_sound(_coop_sound_impulse, definition_index, source_object_index, scale); definition = sound_definition_get(definition_index); sound_stop_impulse(definition->scripting_sound_index); definition->scripting_time = game_time_get() @@ -737,6 +748,8 @@ static void scripted_looping_sound_stop_internal( void scripted_looping_sound_stop( long sound_index) { + if (sound_index != NONE) + network_coop_note_sound(_coop_sound_looping_stop, sound_index, NONE, 0.0f); scripted_looping_sound_stop_internal(sound_index, FALSE); return; @@ -755,6 +768,8 @@ void scripted_looping_sound_start( { definition = looping_sound_definition_get(sound_index); scripted_looping_sound_stop(sound_index); + /* after the stop above, so clients get them in the same order */ + network_coop_note_sound(_coop_sound_looping_start, sound_index, source_object_index, gain); match_assert( "c:\\halo\\SOURCE\\sound\\game_sound.c", 495, diff --git a/source/units/unit_dialogue.c b/source/units/unit_dialogue.c index d62e4145..31172b59 100644 --- a/source/units/unit_dialogue.c +++ b/source/units/unit_dialogue.c @@ -94,6 +94,9 @@ symbols in this file: #include "tag_files/tag_groups.h" #include "unit_definitions.h" +/* port: the AI's speech goes to the clients' copies (port/linux/game/network_actors.c) */ +void network_actors_note_speech(long unit_index, long sound_definition_index); + /* ---------- constants */ enum unit_play_speech_type @@ -1036,6 +1039,15 @@ void unit_dialogue_update( &position, &forward, 1.0f); + /* port: the AI's speech, heard on the clients too (they + play pain and death sounds themselves) */ + if (unit->unit.player_index == NONE && + unit->unit.speech.current.priority != _unit_speech_pain && + unit->unit.speech.current.priority != _unit_speech_involuntary && + unit->unit.speech.current.priority != _unit_speech_death) + { + network_actors_note_speech(unit_index, unit->unit.speech.current.sound_definition_index); + } } ai_communication_started( diff --git a/source/units/unit_scripting_commands.c b/source/units/unit_scripting_commands.c index 47e83373..93db429c 100644 --- a/source/units/unit_scripting_commands.c +++ b/source/units/unit_scripting_commands.c @@ -33,6 +33,7 @@ symbols in this file: #include "items/weapons.h" #include "objects/damage.h" #include "units.h" +#include "network_coop.h" /* port: port/linux/game/network_coop.c */ /* ---------- constants */ @@ -46,6 +47,10 @@ void unit_scripting_set_current_vitality( long unit_index, real body_vitality, real shield_vitality); +void unit_scripting_set_current_vitality_of( + long unit_index, + real body_vitality, + real shield_vitality); /* ---------- globals */ @@ -56,6 +61,9 @@ void unit_scripting_set_maximum_vitality( real body_vitality, real shield_vitality) { + /* port: in network co-op, a player's is every player's */ + if (unit_index != NONE && network_coop_set_players_vitality(unit_index, TRUE, body_vitality, shield_vitality)) + return; if (unit_index != NONE) { struct object_datum const *object; @@ -86,6 +94,17 @@ void unit_scripting_set_current_vitality( long unit_index, real body_vitality, real shield_vitality) +{ + /* port: in network co-op, a player's is every player's */ + if (unit_index != NONE && !network_coop_set_players_vitality(unit_index, FALSE, body_vitality, shield_vitality)) + unit_scripting_set_current_vitality_of(unit_index, body_vitality, shield_vitality); +} + +/* port: the set itself, for one unit (network_coop.c sets every player's) */ +void unit_scripting_set_current_vitality_of( + long unit_index, + real body_vitality, + real shield_vitality) { if (unit_index != NONE) { diff --git a/source/units/units.c b/source/units/units.c index 5c4099b7..09cde946 100644 --- a/source/units/units.c +++ b/source/units/units.c @@ -691,6 +691,18 @@ symbols in this file: #include "saved games/game_state.h" #include "sound/game_sound.h" #include "vehicles.h" +#include "network_coop.h" /* port: port/linux/game/network_coop.c */ + +/* port: the control and animation impulses the host's actors give their +units go to the clients' copies (port/linux/game/network_actors.c) */ +void network_actors_note_control(long unit_index, struct unit_control_data const *control_data); +void network_actors_note_impulse(long unit_index, short animation_impulse, real_vector2d const *alignment_vector); +void network_actors_note_melee(long unit_index, real_vector2d const *alignment_vector); +void network_actors_note_leap(long unit_index, real_vector2d const *alignment_vector); +/* port/linux/game/network_objects.c: the host's pick of a flinch or death animation */ +short network_objects_damage_animation(long unit_index, short type, short animation_index); +void network_actors_note_user_animation(long unit_index, long animation_graph_index, short animation_index, + boolean interpolate); /* ---------- constants */ @@ -735,14 +747,6 @@ enum _unit_debug_function_active_bit = 2, }; -enum -{ - _unit_damage_animation_soft_ping = 0, - _unit_damage_animation_hard_ping, - _unit_damage_animation_soft_kill, - _unit_damage_animation_hard_kill, -}; - enum { _unit_damage_direction_front = 0, @@ -2324,6 +2328,7 @@ boolean unit_custom_animation_at_frame( { unit->object.animation.state.frame_index = frame_index; success = TRUE; + network_coop_note_unit_animation_frame(unit_index, frame_index); } } @@ -3693,6 +3698,7 @@ void unit_detach_from_parent( if (unit->object.parent_object_index != NONE) { + long parent_index = unit->object.parent_object_index; real_point3d parent_origin; real_point3d unit_origin; real_vector3d velocity; @@ -3722,6 +3728,10 @@ void unit_detach_from_parent( &unit->object.translational_velocity); object_set_visibility(unit_index, TRUE); object_compute_node_matrices(unit_index); + /* port: a vehicle dropped by another comes with more in a large + co-op game (port/linux/game/network_coop.c) */ + if (unit->object.type == _object_type_vehicle && object_get(parent_index)->object.type == _object_type_vehicle) + network_coop_vehicle_dropped(unit_index, parent_index); } return; @@ -4038,6 +4048,13 @@ boolean unit_start_user_animation( TRUE); object_compute_node_matrices_recursive(unit_index); animation_started = TRUE; + network_coop_note_unit_animation(unit_index, animation_graph_index, animation_index, + interpolate); + if (unit->unit.player_index == NONE) + { + network_actors_note_user_animation(unit_index, animation_graph_index, animation_index, + interpolate); + } } } } @@ -4088,11 +4105,79 @@ void unit_stop_custom_animation( if (unit_index!=NONE && unit_get(unit_index)->unit.animation.state==_unit_state_user_animation) { unit_animation_set_state(unit_index, _unit_state_idle); + network_coop_note_unit_animation(unit_index, NONE, NONE, FALSE); } return; } +/* how far into its own flinch or death animation a client's unit still +switches to the host's pick */ +#define DAMAGE_ANIMATION_SWITCH_TICKS 10 + +/* port: whether the unit plays a flinch or death animation of the type +(the host's pick, port/linux/game/network_objects.c): it is switched to the +host's if its own has only just begun. FALSE if it plays none. */ +boolean unit_port_correct_damage_animation( + long unit_index, + short type, + short animation_index) +{ + struct unit_datum *unit = unit_get(unit_index); + long animation_graph_index = unit_definition_get(unit->definition_index)->object.animation_graph.index; + + if (type == _unit_damage_animation_soft_ping) + { + /* (an overlay, beside the unit's animation) */ + if (unit->unit.animation.soft_ping_animation.index == NONE) + return FALSE; + if (unit->unit.animation.soft_ping_animation.frame_index < DAMAGE_ANIMATION_SWITCH_TICKS) + unit->unit.animation.soft_ping_animation.index = animation_index; + return TRUE; + } + if (type == _unit_damage_animation_hard_ping ? + unit->unit.animation.state != _unit_state_hard_ping : + unit->unit.animation.state != _unit_state_dying && unit->unit.animation.state != _unit_state_dying_airborne) + { + return FALSE; + } + if (unit->object.animation.state.index != animation_index && + unit->object.animation.state.frame_index < DAMAGE_ANIMATION_SWITCH_TICKS) + { + unit_set_animation(unit_index, animation_graph_index, animation_index); + object_compute_node_matrices_recursive(unit_index); + } + return TRUE; +} + +/* port: a co-op client plays the custom animation the host's unit started +(network_coop.c): exactly that animation, not another random permutation */ +void unit_port_play_user_animation( + long unit_index, + long animation_graph_index, + short animation_index, + boolean interpolate, + short frame_index) +{ + struct unit_datum *unit = unit_get(unit_index); + struct animation_graph *animation_graph = animation_graph_definition_get(animation_graph_index); + struct animation *animation; + + if (animation_index < 0 || animation_index >= animation_graph->animations.count) + return; + animation = TAG_BLOCK_GET_ELEMENT(&animation_graph->animations, animation_index, struct animation); + if (animation->type != _animation_base) + return; + if (interpolate) + object_start_interpolation(unit_index, 6); + unit->unit.animation.state = _unit_state_user_animation; + unit_set_animation(unit_index, animation_graph_index, animation_index); + SET_FLAG(unit->unit.animation.flags, _unit_animation_postpone_weapon_ik_until_interpolation_ends_bit, TRUE); + if (frame_index > 0 && frame_index < animation->frame_count) + unit->object.animation.state.frame_index = frame_index; + object_compute_node_matrices_recursive(unit_index); +} + boolean unit_melee_attack_begin( long unit_index, boolean continuous, @@ -4172,6 +4257,9 @@ boolean unit_melee_attack_begin( else { unit->unit.melee_attack_state = 1; + /* port: the AI's swing, played on the clients too */ + if (unit->unit.player_index == NONE) + network_actors_note_melee(unit_index, alignment_vector); } result = TRUE; } @@ -4225,6 +4313,9 @@ boolean unit_leap_begin( { unit_align_facing(unit_index, alignment_vector); } + /* port: the AI's leap, played on the clients too */ + if (unit->unit.player_index == NONE) + network_actors_note_leap(unit_index, alignment_vector); result = TRUE; } @@ -6386,6 +6477,9 @@ static void unit_ping_animation( animation_graph_index, selected_damage_animation_index); } + /* port: the host's pick on every machine (port/linux/game/network_objects.c) */ + animation_index = network_objects_damage_animation(unit_index, _unit_damage_animation_soft_ping, + animation_index); if (animation_index==NONE) { @@ -6510,6 +6604,8 @@ static void unit_ping_animation( animation_graph_index, selected_damage_animation_index); } + /* port: the host's pick on every machine (port/linux/game/network_objects.c) */ + animation_index = network_objects_damage_animation(unit_index, damage_animation_type, animation_index); if (animation_index!=NONE) { @@ -8467,6 +8563,8 @@ boolean unit_start_animation_impulse( { unit_align_facing(unit_index, alignment_vector); } + if (unit->unit.player_index == NONE) + network_actors_note_impulse(unit_index, animation_impulse, alignment_vector); result = TRUE; } @@ -10976,6 +11074,9 @@ void unit_control( { struct unit_datum *unit = unit_get(unit_index); + if (unit->unit.player_index == NONE) + network_actors_note_control(unit_index, control_data); + match_assert( "c:\\halo\\SOURCE\\units\\units.c", 0x5e1, diff --git a/source/units/units.h b/source/units/units.h index b3502064..7443aaa9 100644 --- a/source/units/units.h +++ b/source/units/units.h @@ -13,6 +13,17 @@ UNITS.H /* ---------- constants */ +/* a unit's damage animations: flinches (a soft ping plays over its +animation, a hard ping in its place) and deaths */ +enum +{ + _unit_damage_animation_soft_ping = 0, + _unit_damage_animation_hard_ping, + _unit_damage_animation_soft_kill, + _unit_damage_animation_hard_kill, + NUMBER_OF_UNIT_DAMAGE_ANIMATIONS +}; + enum { MAXIMUM_WEAPONS_PER_UNIT = 4, @@ -758,6 +769,20 @@ boolean unit_is_playing_custom_animation( long unit_index); boolean unit_flying_through_air( long unit_index); +/* port: whether the unit plays a flinch or death animation of the type, +switched to the host's if it has only just begun +(port/linux/game/network_objects.c) */ +boolean unit_port_correct_damage_animation( + long unit_index, + short type, + short animation_index); +/* port: network co-op (port/linux/game/network_coop.c) */ +void unit_port_play_user_animation( + long unit_index, + long animation_graph_index, + short animation_index, + boolean interpolate, + short frame_index); void unit_stop_custom_animation( long unit_index); boolean unit_melee_attack_begin( diff --git a/tools/linux_build.py b/tools/linux_build.py index ed29f8b3..ee68091b 100644 --- a/tools/linux_build.py +++ b/tools/linux_build.py @@ -401,6 +401,9 @@ def generate_linux_build(n: Writer, sln: Any) -> None: f"-include {prefix_header}", f"-include {semantics_header}", f"-I{port_include}", + # the headers of the port's own game units (port/linux/game), for + # the game sources that call them + f"-iquote {Path(config['game_sources'])}", game_defines_and_includes(config), sdk_flags, ]) diff --git a/tools/windows_build.py b/tools/windows_build.py index a172b98d..02c25ef7 100644 --- a/tools/windows_build.py +++ b/tools/windows_build.py @@ -346,6 +346,9 @@ def generate_windows_build(n: Writer, sln: Any) -> None: # halo_menus.h), but not the Linux build's C runtime wrappers # next to them, which no game unit includes in quotes f"-iquote {LINUX_DIR / 'include'}", + # the headers of the port's own game units (port/linux/game), for + # the game sources that call them + f"-iquote {Path(linux_config['game_sources'])}", game_defines_and_includes(linux_config), # the Xbox SDK declarations (port/include/xdk) come before the # Windows SDK, which has headers of the same names From 606109551d7bdfc8d9a94c739b34ec7957cb84e5 Mon Sep 17 00:00:00 2001 From: xshxdex98 Date: Mon, 5 Oct 2026 00:46:51 -0500 Subject: [PATCH 26/51] Co-op: friendly reticles, skipped cutscenes' sound, the end of a level, tutorial prompts - AI units' teams go with their states, so friendlies aren't red on clients' reticles. - A skipped cutscene's music and dialogue stop on the clients. - Winning a level returns every machine to the lobby with the next level set up, instead of leaving them on its last screen. - Help text carries whether the host shows it, so no prompt is dropped. --- port/linux/game/network_actors.c | 9 +++- port/linux/game/network_coop.c | 59 ++++++++++++++++++++-- source/networking/network_server_manager.c | 5 ++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/port/linux/game/network_actors.c b/port/linux/game/network_actors.c index 2b5260a4..bf78b066 100644 --- a/port/linux/game/network_actors.c +++ b/port/linux/game/network_actors.c @@ -63,6 +63,7 @@ enum /* the number of unit_start_animation_impulse impulses (private to units.c) */ NUMBER_OF_UNIT_ANIMATION_IMPULSES = 14, NO_IMPULSE = 0xFF, + NO_TEAM = 0xFF, /* An entry's impulse is one of the animation impulses or, past them, an action the AI starts directly: a melee attack (unit_melee_attack_begin) @@ -122,7 +123,9 @@ struct distributed_actor_state byte active_camouflage; /* the speech's sequence number; 0 when there is none */ byte speech_number; - byte pad; + /* the unit's team (NO_TEAM for none): the AI sets it after the unit is + made, so a client's copy would keep the team it was made with */ + byte team; short facing[NUMBER_OF_ANGLES]; short aiming[NUMBER_OF_ANGLES]; short looking[NUMBER_OF_ANGLES]; @@ -397,6 +400,8 @@ static void actor_state_from_unit( SET_FLAG(state->flags, _distributed_actor_super_camouflaged_bit, TEST_FLAG(unit->unit.flags, _unit_super_camouflaged_bit)); state->active_camouflage = (byte)(long)floor(camouflage * 255.0f + 0.5f); + state->team = unit->object.owner_team_index >= 0 && unit->object.owner_team_index < NUMBER_OF_SOLO_CAMPAIGN_TEAMS ? + (byte)unit->object.owner_team_index : NO_TEAM; state->position = unit->object.position; distributed_vector_pack(&unit->object.translational_velocity, DISTRIBUTED_VELOCITY_SCALE, &state->velocity); distributed_vector_pack(&unit->object.forward, DISTRIBUTED_UNIT_SCALE, &state->forward); @@ -504,6 +509,8 @@ static boolean actor_state_apply( SET_FLAG(unit->unit.flags, _unit_super_camouflaged_bit, TEST_FLAG(state->flags, _distributed_actor_super_camouflaged_bit)); unit->unit.active_camouflage = (real)state->active_camouflage / 255.0f; + if (state->team != NO_TEAM && state->team < NUMBER_OF_SOLO_CAMPAIGN_TEAMS) + unit->object.owner_team_index = state->team; SET_FLAG(unit->unit.flags, _unit_running_blindly_bit, TEST_FLAG(state->flags, _distributed_actor_running_blindly_bit)); if (TEST_FLAG(state->flags, _distributed_actor_running_blindly_bit)) diff --git a/port/linux/game/network_coop.c b/port/linux/game/network_coop.c index 7fb7ee79..9061c80d 100644 --- a/port/linux/game/network_coop.c +++ b/port/linux/game/network_coop.c @@ -98,6 +98,7 @@ index and tag, since the map placed them at the same index everywhere. #include "scenario/scenario.h" #include "scenario/scenario_definitions.h" #include "sound/game_sound.h" +#include "sound/sound_manager.h" #include "sound/sound_definitions.h" #include "units/units.h" #include "coop_spectate.h" @@ -172,6 +173,8 @@ enum _coop_event_scenery_animation, _coop_event_effect, _coop_event_attach, + /* the host skipped the cutscene: a client stops its dialogue */ + _coop_event_cutscene_skipped, }; /* distributed_coop_event.type of an effect: at a cutscene flag (value), or on @@ -481,6 +484,19 @@ static struct boolean seen[MAXIMUM_DEVICE_GROUPS]; } client_devices; +/* client: the script sounds (impulse ones: dialogue) it played lately, +which a skipped cutscene stops */ +enum +{ + CLIENT_SCRIPT_SOUND_COUNT = 16, +}; + +static struct +{ + long definition_indices[CLIENT_SCRIPT_SOUND_COUNT]; + short next; +} client_script_sounds; + /* client: the object names that differed from the host's last time */ static byte client_names_differing[OBJECT_NAME_BYTES]; @@ -1359,6 +1375,7 @@ static void client_apply_hud( switch (event->type) { case _coop_hud_help_text: + hud_scripted_globals->show_hud_help_text = event->frame != 0; if (event->value >= 0 && event->value < hud_messaging_port_message_count()) scripted_hud_set_state_message(event->value); break; @@ -1489,7 +1506,11 @@ static void client_apply_sound( { case _coop_sound_impulse: if (distributed_tag_of_group(event->tag_index, SOUND_DEFINITION_TAG)) + { scripted_sound_new(event->tag_index, object_index, scale); + client_script_sounds.definition_indices[client_script_sounds.next] = event->tag_index; + client_script_sounds.next = (short)((client_script_sounds.next + 1) % CLIENT_SCRIPT_SOUND_COUNT); + } break; case _coop_sound_looping_start: /* a resent sound that is already playing here is left alone */ @@ -1583,6 +1604,23 @@ static void client_apply_attach( } } +/* client: the cutscene was skipped, so the dialogue it started stops (the +host's went with its revert; its music stops through host_send_looping_sounds) */ +static void client_stop_script_sounds( + void) +{ + short index; + + for (index = 0; index < CLIENT_SCRIPT_SOUND_COUNT; index++) + { + long definition_index = client_script_sounds.definition_indices[index]; + + if (definition_index != NONE && distributed_tag_of_group(definition_index, SOUND_DEFINITION_TAG)) + sound_stop_impulse(sound_definition_get(definition_index)->scripting_sound_index); + client_script_sounds.definition_indices[index] = NONE; + } +} + static void client_apply_event( struct distributed_coop_event const *event) { @@ -1616,6 +1654,9 @@ static void client_apply_event( case _coop_event_attach: client_apply_attach(event); break; + case _coop_event_cutscene_skipped: + client_stop_script_sounds(); + break; default: break; } @@ -1704,6 +1745,7 @@ void network_coop_new_game( csmemset(&host_devices, 0, sizeof(host_devices)); csmemset(&client_devices, 0, sizeof(client_devices)); csmemset(client_names_differing, 0, sizeof(client_names_differing)); + csmemset(client_script_sounds.definition_indices, NONE, sizeof(client_script_sounds.definition_indices)); csmemset(host_sent_transforms, 0, sizeof(host_sent_transforms)); csmemset(host_sent_looks, 0, sizeof(host_sent_looks)); csmemset(&host_sent_screen_effect, 0, sizeof(host_sent_screen_effect)); @@ -1842,25 +1884,30 @@ static short host_looping_sound_find( return NONE; } +/* host, each tick: a looping sound that stopped without the scripts +stopping it (it ended, or a skipped cutscene's revert took it) is stopped on +the clients too; the rest are started again with the resent state */ static void host_send_looping_sounds( void) { short index = 0; - if (!host_resend.refresh) - return; while (index < host_looping_sound_count) { long definition_index = host_looping_sounds[index].definition_index; struct distributed_coop_event *event; - /* it stopped on its own: drop it */ if (looping_sound_definition_get(definition_index)->runtime_scripting_sound_index == NONE) { + if ((event = event_new(_coop_event_sound)) != NULL) + { + event->type = _coop_sound_looping_stop; + event->tag_index = definition_index; + } host_looping_sounds[index] = host_looping_sounds[--host_looping_sound_count]; continue; } - if ((event = event_new(_coop_event_sound)) != NULL) + if (host_resend.refresh && (event = event_new(_coop_event_sound)) != NULL) { event->type = _coop_sound_looping_start; event->tag_index = definition_index; @@ -1927,6 +1974,9 @@ void network_coop_note_hud( return; event->type = (byte)kind; event->value = value; + /* (help text shows only while the scripts show it; the presentation + saying so comes a little after the event, so the event carries it) */ + event->frame = (short)hud_scripted_globals->show_hud_help_text; if (kind == _coop_hud_objective) host_hud_state.objective = value; } @@ -2267,6 +2317,7 @@ void network_coop_skip_reverted( hs_runtime_port_shift_sleep_times(ticks); } error(_error_silent, "co-op: cutscene skipped; reverted %ld ticks, clock kept at %ld", ticks, now); + event_new(_coop_event_cutscene_skipped); skip_vote_clear(); skip_vote.requested = FALSE; skip_vote.cooldown_until = game_time_get() + SKIP_COOLDOWN_TICKS; diff --git a/source/networking/network_server_manager.c b/source/networking/network_server_manager.c index af40eb7b..5977d875 100644 --- a/source/networking/network_server_manager.c +++ b/source/networking/network_server_manager.c @@ -3861,6 +3861,11 @@ void network_game_server_port_cooperative_won( sizeof(network_game_server_cooperative_next_map) - 1); network_game_server_cooperative_next_map[sizeof(network_game_server_cooperative_next_map) - 1] = 0; network_game_server_switch_to_postgame(server); + /* Back to the lobby at once, with the next level set up there to start + or change. Multiplayer leaves the postgame when the host presses a button + on its scoreboard (game_engine.c); co-op has neither, and every machine + was left on the level's last screen. */ + network_game_server_reset_to_pregame(server); } /* port: reapply the co-op settings after a won round, since the playlist From 3f6c0c37a44d270241b01694d07267e272884835 Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:47:39 +1300 Subject: [PATCH 27/51] Friendly fire stops teammates' instant kills --- source/objects/damage.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/source/objects/damage.c b/source/objects/damage.c index 5f00c31c..aab1f8d7 100644 --- a/source/objects/damage.c +++ b/source/objects/damage.c @@ -1693,7 +1693,12 @@ void object_cause_damage( force_kill = TRUE; } + /* port: nor does a teammate's hit kill outright (a melee from + behind, an instant kill) where the gametype's friendly fire + spares the body; a killing blow the host dealt (a client's + replay of it) is dealt as the host dealt it */ if (force_kill && + (friendly_damage == _friendly_damage_all || distributed_damage_authorized) && !TEST_FLAG(current_object->object.damage_flags, _object_dead_bit)) { current_object->object.body_vitality = 0.f; From ba6fe0a826711d873c4b040d78b6b994cee9b93c Mon Sep 17 00:00:00 2001 From: xshxdex98 Date: Mon, 5 Oct 2026 00:59:33 -0500 Subject: [PATCH 28/51] AI sync: any team the host's unit has goes with it, not only the campaign's --- port/linux/game/network_actors.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/port/linux/game/network_actors.c b/port/linux/game/network_actors.c index bf78b066..9deaff5f 100644 --- a/port/linux/game/network_actors.c +++ b/port/linux/game/network_actors.c @@ -400,7 +400,7 @@ static void actor_state_from_unit( SET_FLAG(state->flags, _distributed_actor_super_camouflaged_bit, TEST_FLAG(unit->unit.flags, _unit_super_camouflaged_bit)); state->active_camouflage = (byte)(long)floor(camouflage * 255.0f + 0.5f); - state->team = unit->object.owner_team_index >= 0 && unit->object.owner_team_index < NUMBER_OF_SOLO_CAMPAIGN_TEAMS ? + state->team = unit->object.owner_team_index >= 0 && unit->object.owner_team_index < NO_TEAM ? (byte)unit->object.owner_team_index : NO_TEAM; state->position = unit->object.position; distributed_vector_pack(&unit->object.translational_velocity, DISTRIBUTED_VELOCITY_SCALE, &state->velocity); @@ -509,7 +509,7 @@ static boolean actor_state_apply( SET_FLAG(unit->unit.flags, _unit_super_camouflaged_bit, TEST_FLAG(state->flags, _distributed_actor_super_camouflaged_bit)); unit->unit.active_camouflage = (real)state->active_camouflage / 255.0f; - if (state->team != NO_TEAM && state->team < NUMBER_OF_SOLO_CAMPAIGN_TEAMS) + if (state->team != NO_TEAM) unit->object.owner_team_index = state->team; SET_FLAG(unit->unit.flags, _unit_running_blindly_bit, TEST_FLAG(state->flags, _distributed_actor_running_blindly_bit)); From ab7c507a29a1b3df7f0dff5353111310575f82bd Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:39:57 +1300 Subject: [PATCH 29/51] Leaving the lobby after a game goes to the main menu --- .../menus/ce/main_menu.multiplayer_type_select.lobby.xml | 2 +- tools/port_settings.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml b/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml index 24336832..015af27a 100644 --- a/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml +++ b/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml @@ -1,7 +1,7 @@ - + diff --git a/tools/port_settings.py b/tools/port_settings.py index d79da606..5bf07cc6 100644 --- a/tools/port_settings.py +++ b/tools/port_settings.py @@ -702,9 +702,12 @@ def _lobby() -> list: 128 players, scrolling; the game's map and gametype; the countdown""" base = f"{MT}/lobby" # (split screen: another controller's START joins it, its B leaves alone: - # menu_functions.c's lobby_join) + # menu_functions.c's lobby_join. After a game it is the only screen, + # network_game_reset_to_pregame_ui opening it in place of the Xbox's + # pregame: leaving it goes to the main menu, as the PC version's postgame + # screens do) lines = _widget(f"{base}/lobby_screen", [("width", 640), ("height", 480), - ("flags", "pass_unhandled_to_focused_child"), + ("flags", "pass_unhandled_to_focused_child main_menu_if_no_history"), ("bitmap", "bitmaps/gradient")], ['', '', From de5206064412d32f8770ed722570260bb2b1c51d Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:54:33 +1300 Subject: [PATCH 30/51] Host takes grenade damage only from grenades thrown --- port/linux/NETCODE.md | 6 +- port/linux/game/network_damage.c | 194 ++++++++++++++++++++------ port/linux/game/network_distributed.h | 3 + source/units/units.c | 5 + 4 files changed, 167 insertions(+), 41 deletions(-) diff --git a/port/linux/NETCODE.md b/port/linux/NETCODE.md index 2c3913a7..0efd41e8 100644 --- a/port/linux/NETCODE.md +++ b/port/linux/NETCODE.md @@ -282,8 +282,10 @@ a pregame keep-alive every five seconds from the host host's latest tick it had heard of when it made the report. - The host deals a report once it has checked it: from that machine's player; damage one of their weapons (a vehicle's a driver's or - gunner's; now or in the last ten seconds), their grenades (while they - have them, and for a while after) or the vehicle they drove (in the + gunner's; now or in the last ten seconds), a grenade the host's own game + saw them throw in the last ten seconds that has not gone off (each + throw's explosion is taken once, its other hits that tick with it: + holding grenades deals nothing) or the vehicle they drove (in the last ten seconds: its collisions) can deal (its projectiles' impacts and detonations, followed through the tags), no harder than it can be (all of it, but an airborne melee blow's half again); of the shape the diff --git a/port/linux/game/network_damage.c b/port/linux/game/network_damage.c index 0c23555d..0ff5e8ae 100644 --- a/port/linux/game/network_damage.c +++ b/port/linux/game/network_damage.c @@ -16,8 +16,9 @@ being hit looks and feels like on the clients). - A client deals no damage itself. What its own players' shots, grenades, melee and vehicles hit it reports to the host, which deals it once it has checked it: the report is of that machine's player, the damage one that - player's weapons (now or lately), grenades or the vehicle they drove - deal, of the shape the game gives it (at a point, a melee blow from the + player's weapons (now or lately), a grenade the host saw them throw + (each throw's explosion once) or the vehicle they drove deal, of the + shape the game gives it (at a point, a melee blow from the striker, a collision from the vehicle), the target about where the host had it when the shooter saw it (the host keeps a second of where players' units and vehicles were, and looks back as far as the report @@ -81,6 +82,13 @@ enum died or dropped the launcher) */ MAXIMUM_RECENT_WEAPONS = 8, RECENT_WEAPON_TICKS = 10 * TICKS_PER_SECOND, + /* the grenades a player has thrown lately, as the host's own game threw + them, whose damage the host takes from them: each goes off once, within + RECENT_WEAPON_TICKS of its throw. Each player has their own (all of + MAXIMUM_TRACKED_PLAYERS); one that hits nothing is kept the whole time, + so this is more than a player throws in it, infinite grenades and all + (a throw takes most of a second); past it the oldest gives way */ + MAXIMUM_GRENADE_THROWS = 32, /* a player's hits: a bucket of this many seconds of fire, filling at a second a second, a hit taking what one of its weapon's projectiles takes to fire (at twice the weapon's fastest rate: a shotgun's @@ -341,7 +349,9 @@ static struct { long definition_indices[MAXIMUM_RECENT_WEAPONS]; long times[MAXIMUM_RECENT_WEAPONS]; - long grenade_times[NUMBER_OF_UNIT_GRENADE_TYPES]; + /* (the grenades thrown, not yet gone off: NONE for none) */ + long throw_times[MAXIMUM_GRENADE_THROWS]; + short throw_types[MAXIMUM_GRENADE_THROWS]; long driven_time; /* (the blow of the player's unit with no weapon: units.c's unit_unarmed_melee_damage) */ @@ -1078,21 +1088,74 @@ static real distributed_source_deals( return distributed_source_deals_uncached(source_index, damage_index, grenade, kinds, reach); } +/* the oldest grenade of the type the host saw the player throw within +RECENT_WEAPON_TICKS that has not gone off yet (network_damage_note_grenade), +NONE for none */ +static short distributed_grenade_throw( + short player_index, + short grenade_type) +{ + short index; + short oldest = NONE; + + for (index = 0; index < MAXIMUM_GRENADE_THROWS; index++) + { + long time = damage_players[player_index].throw_times[index]; + + if (time != NONE && damage_players[player_index].throw_types[index] == grenade_type && + game_time_get() - time <= RECENT_WEAPON_TICKS && + (oldest == NONE || time < damage_players[player_index].throw_times[oldest])) + { + oldest = index; + } + } + return oldest; +} + +/* whether an explosion of the damage is among the player's this tick +(distributed_report_paid): at the epicenter, or (NULL) anywhere */ +static boolean distributed_explosion_this_tick( + short player_index, + long damage_index, + real_point3d const *epicenter) +{ + short index; + + if (damage_explosions[player_index].time != game_time_get()) + return FALSE; + for (index = 0; index < damage_explosions[player_index].count; index++) + { + if (damage_explosions[player_index].explosions[index].definition_index == damage_index && + (!epicenter || (damage_explosions[player_index].explosions[index].epicenter.x == epicenter->x && + damage_explosions[player_index].explosions[index].epicenter.y == epicenter->y && + damage_explosions[player_index].explosions[index].epicenter.z == epicenter->z))) + { + return TRUE; + } + } + return FALSE; +} + /* how many hits a second the player could deal of the damage: their -weapons, lately, their grenades, the vehicle they drove lately; 0 when none -of them deals it; how they deal it (the _damage_source flags); and how far -from them what they fire deals it */ +weapons, lately, a grenade the host saw them throw that has not gone off +(or one going off this tick, hitting more than one object), the vehicle +they drove lately; 0 when none of them deals it; how they deal it (the +_damage_source flags); how far from them what they fire deals it; and the +type of grenade when nothing but a grenade of theirs deals it, else NONE */ static real distributed_player_deals( short player_index, long damage_index, byte *kinds, - struct damage_reach *reach) + struct damage_reach *reach, + short *grenade_type) { struct game_globals *globals = scenario_get_game_globals(); short index; real rate = 0.0f; + real grenade_rate = 0.0f; *kinds = 0; + *grenade_type = NONE; reach->distance = 0.0f; reach->ticks = 0.0f; reach->carried_ticks = 0.0f; @@ -1112,25 +1175,6 @@ static real distributed_player_deals( *kinds |= weapon_kinds; } } - for (index = 0; index < globals->grenades.count && index < NUMBER_OF_UNIT_GRENADE_TYPES; index++) - { - struct game_globals_grenade *grenade = TAG_BLOCK_GET_ELEMENT(&globals->grenades, index, - struct game_globals_grenade); - - if (game_time_get() - damage_players[player_index].grenade_times[index] <= RECENT_WEAPON_TICKS && - grenade->projectile.index != NONE) - { - byte grenade_kinds; - struct damage_reach grenade_reach; - real grenade_rate = distributed_source_deals(grenade->projectile.index, damage_index, TRUE, &grenade_kinds, - &grenade_reach); - - if (grenade_rate > 0.0f) - distributed_reach_combine(reach, &grenade_reach); - rate = MAX(rate, grenade_rate); - *kinds |= grenade_kinds; - } - } /* (a blow with no weapon: the unit's own, units.c) */ if (damage_players[player_index].unarmed_melee_damage_index == damage_index && damage_index != NONE && game_time_get() - damage_players[player_index].unarmed_time <= RECENT_WEAPON_TICKS) @@ -1152,6 +1196,36 @@ static real distributed_player_deals( SET_FLAG(*kinds, _damage_source_collision_bit, TRUE); } } + /* (a grenade's damage only from one thrown: holding grenades deals + nothing) */ + for (index = 0; index < globals->grenades.count && index < NUMBER_OF_UNIT_GRENADE_TYPES; index++) + { + struct game_globals_grenade *grenade = TAG_BLOCK_GET_ELEMENT(&globals->grenades, index, + struct game_globals_grenade); + boolean thrown = distributed_grenade_throw(player_index, index) != NONE; + + if ((thrown || distributed_explosion_this_tick(player_index, damage_index, NULL)) && + grenade->projectile.index != NONE) + { + byte this_kinds; + struct damage_reach this_reach; + real this_rate = distributed_source_deals(grenade->projectile.index, damage_index, TRUE, &this_kinds, + &this_reach); + + if (this_rate > 0.0f) + { + distributed_reach_combine(reach, &this_reach); + /* (one with a throw left before one only going off now) */ + if (*grenade_type == NONE || thrown) + *grenade_type = index; + } + grenade_rate = MAX(grenade_rate, this_rate); + *kinds |= this_kinds; + } + } + if (rate > 0.0f) + *grenade_type = NONE; + rate = MAX(rate, grenade_rate); return rate * HIT_REPORT_RATE_MARGIN; } @@ -1432,16 +1506,6 @@ static void distributed_note_weapons( if (vehicle && vehicle->unit.driver_object_index == unit_index) damage_players[player_index].driven_time = game_time_get(); } - { - struct unit_datum *unit = unit_get(unit_index); - short grenade_index; - - for (grenade_index = 0; grenade_index < NUMBER_OF_UNIT_GRENADE_TYPES; grenade_index++) - { - if (unit->unit.grenade_counts[grenade_index] > 0) - damage_players[player_index].grenade_times[grenade_index] = game_time_get(); - } - } for (unit_number = 0; unit_number < 2; unit_number++) { struct unit_datum *unit = units[unit_number] != NONE ? @@ -1639,6 +1703,8 @@ static boolean distributed_report_valid( real reach; struct damage_reach fired_reach; short seen; + short grenade_type; + short throw_index = NONE; /* that machine's player */ if (player_index == NO_PLAYER || player_index >= MAXIMUM_TRACKED_PLAYERS || @@ -1671,7 +1737,8 @@ static boolean distributed_report_valid( return FALSE; ticks = MIN(now - report->host_time + TARGET_HISTORY_SLACK_TICKS, TARGET_HISTORY_TICKS - 1); /* damage that player could deal, and how they deal it */ - rate = distributed_player_deals(player_index, report->damage.definition_index, &kinds, &fired_reach); + rate = distributed_player_deals(player_index, report->damage.definition_index, &kinds, &fired_reach, + &grenade_type); if (rate <= 0.0f) return FALSE; /* ... of its shape: at a point, else a melee blow, else a collision; and @@ -1713,12 +1780,26 @@ static boolean distributed_report_valid( reach += definition->cutoff_radius; if (!(distributed_distance_squared(&report->damage.origin, &report->target_position) <= reach * reach)) return FALSE; + /* what only a grenade of theirs deals, from one the host saw them throw + that has not gone off: its explosion once (the explosion's other objects + that tick come free, distributed_report_paid), what it hits at a point + while it is about */ + if (grenade_type != NONE && + !distributed_explosion_this_tick(player_index, report->damage.definition_index, &report->damage.epicenter)) + { + throw_index = distributed_grenade_throw(player_index, grenade_type); + if (throw_index == NONE) + return FALSE; + } /* no more than the weapon fires */ if (!distributed_report_paid(player_index, report, rate, area && !melee && !collision, definition->cutoff_radius > 0.0f)) { return FALSE; } + /* (the grenade gone off: its throw spent) */ + if (throw_index != NONE && area && !melee && !collision) + damage_players[player_index].throw_times[throw_index] = NONE; /* a melee blow from where the host had the player (its origin their head, its epicenter their body); a collision from the vehicle they drove */ @@ -2102,6 +2183,41 @@ void network_damage_statistics( *replayed_events = damage_replayed_events; } +/* the host: a grenade a player's unit threw (units.c), as the host's own +game threw it, which that player's machine reports the damage of +(distributed_report_valid) */ +void network_damage_note_grenade( + long unit_index, + short grenade_type) +{ + struct unit_datum *unit = unit_get(unit_index); + long player_index; + short index; + short slot = 0; + + if (game_connection() != _game_connection_network_server || unit->unit.player_index == NONE || + grenade_type < 0 || grenade_type >= NUMBER_OF_UNIT_GRENADE_TYPES) + { + return; + } + player_index = DATUM_INDEX_TO_ABSOLUTE_INDEX(unit->unit.player_index); + if (player_index >= MAXIMUM_TRACKED_PLAYERS) + return; + /* (in a free place, else the oldest's) */ + for (index = 0; index < MAXIMUM_GRENADE_THROWS; index++) + { + if (damage_players[player_index].throw_times[index] == NONE) + { + slot = index; + break; + } + if (damage_players[player_index].throw_times[index] < damage_players[player_index].throw_times[slot]) + slot = index; + } + damage_players[player_index].throw_times[slot] = game_time_get(); + damage_players[player_index].throw_types[slot] = grenade_type; +} + void network_damage_new_game( void) { @@ -2121,8 +2237,8 @@ void network_damage_new_game( for (index = 0; index < MAXIMUM_RECENT_WEAPONS; index++) damage_players[player_index].definition_indices[index] = NONE; damage_players[player_index].unarmed_melee_damage_index = NONE; - for (index = 0; index < NUMBER_OF_UNIT_GRENADE_TYPES; index++) - damage_players[player_index].grenade_times[index] = -RECENT_WEAPON_TICKS - 1; + for (index = 0; index < MAXIMUM_GRENADE_THROWS; index++) + damage_players[player_index].throw_times[index] = NONE; damage_players[player_index].driven_time = -RECENT_WEAPON_TICKS - 1; damage_players[player_index].hit_seconds = HIT_REPORT_BURST_SECONDS; damage_explosions[player_index].time = NONE; diff --git a/port/linux/game/network_distributed.h b/port/linux/game/network_distributed.h index b55c64cc..fdc53c4e 100644 --- a/port/linux/game/network_distributed.h +++ b/port/linux/game/network_distributed.h @@ -229,6 +229,9 @@ void network_objects_set_seat(long unit_index, long vehicle_index, short seat_in /* ---------- prototypes/NETWORK_DAMAGE.C */ void network_damage_new_game(void); +/* the host: a grenade a player's unit threw (units.c), whose damage that +player's machine reports */ +void network_damage_note_grenade(long unit_index, short grenade_type); void network_damage_host_tick(void); void network_damage_client_tick(void); void network_damage_handle_events(void const *entries, short count); diff --git a/source/units/units.c b/source/units/units.c index 5c4099b7..a9662fe6 100644 --- a/source/units/units.c +++ b/source/units/units.c @@ -974,6 +974,8 @@ static long unit_get_weapon(struct unit_datum *unit, short index); static void unit_drop_item(long unit_index, long item_index); /* port/linux/game/network_objects.c's */ boolean network_objects_creating_host_object(void); +/* port/linux/game/network_damage.c's */ +void network_damage_note_grenade(long unit_index, short grenade_type); /* network_game_globals.c's */ boolean network_game_distributed_client(void); static void unit_drop_grenades( @@ -7653,6 +7655,9 @@ static void unit_throw_grenade_move_to_hand( object_attach_to_node(unit_index, object_index, marker.node_index); unit->unit.grenade_object_index = object_index; unit->unit.grenade_throw_state = _unit_grenade_throw_in_hand; + /* port: the host takes a client's report of the grenade's damage only + from a grenade thrown (network_damage.c) */ + network_damage_note_grenade(unit_index, unit->unit.current_grenade_index); } else { From 7d0e587c515dd4fc23717b757c98a095a513926f Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:56:14 +1300 Subject: [PATCH 31/51] Co-op review fixes --- port/linux/NETCODE.md | 19 +-- port/linux/game/menu_functions.c | 223 ++++++++++---------------- port/linux/game/network_coop.c | 78 +++++++-- port/linux/game/network_distributed.c | 13 ++ port/linux/game/network_objects.c | 10 +- port/linux/include/halo_port_limits.h | 2 +- source/camera/camera_scripting.h | 2 + source/game/players.c | 10 +- source/interface/ui_widget.c | 4 +- source/main/main.c | 2 +- source/saved games/game_state.h | 5 + tools/android_build.py | 6 +- tools/port_settings.py | 9 ++ 13 files changed, 210 insertions(+), 173 deletions(-) diff --git a/port/linux/NETCODE.md b/port/linux/NETCODE.md index cf5d69ba..fc6fa1ea 100644 --- a/port/linux/NETCODE.md +++ b/port/linux/NETCODE.md @@ -29,7 +29,8 @@ with ideas from VALORANT's netcode articles, keeping the 30 Hz tick: it has, as it drives a remote player's, until it hears nothing of it for two seconds (`port/linux/game/network_actors.c`). - **Co-op.** A network game on a campaign level with no game engine - (Create Game > COOPERATIVE > CAMPAIGN) is co-op. Only the host runs the + (Create Game's Map screen, whose campaign levels follow the multiplayer + maps over LAN and the internet) is co-op. Only the host runs the level's scripts and spawns players. `network_coop.c` sends the clients everything the scripts do that they would otherwise miss: - every tick: the cinematic, camera, screen fade, the HUD settings the @@ -124,18 +125,10 @@ is dead; version 8 is the first whose clients play by the host's rules (below), so a build without them joins no host of it; version 9 tells every machine of a player the host dropped for cheating, each client tells the host its Discord user, and a machine's join request carries its -hardware id; version 10 sends every player's ping for the scoreboard. - -A host never checks a joining client's version: the client reads the -host's from its advertisement and joins only a version it plays with. That -is its own, or one of a range (HALO_PORT_NETWORK_VERSION_MINIMUM to -HALO_PORT_NETWORK_VERSION_MAXIMUM, halo_port_limits.h) of versions that -differ from it only in messages the other machine drops, not knowing them: -version 10 (OpenCE's build-73) adds the host's message of the players' -pings, which a machine of version 9 drops. This build has version 10, as -OpenCE's current builds (so their clients join its hosts), without that -message (its hosts do not send it, its clients drop it), and joins hosts of -9 and 10. The game browser lists the range's games. +hardware id; version 10 sends every player's ping for the scoreboard; +version 11 sends with the game's settings its gametype's PC options; +version 12 plays the campaign together (co-op, above), drives the host's +actors on its clients and sends the flinches and deaths the host picked. A client plays by its host's rules: in another's game (searching for it, in its lobby, or playing it) the developer console, the telnet console diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index bc471ad8..480acc15 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -1873,6 +1873,7 @@ struct advertised_game *network_game_client_get_available_games(void *client); boolean network_game_client_advertised_game_is_valid(struct advertised_game *game); short network_game_client_get_state(void *client, short *state_data); struct network_game *network_game_client_get_game(void *client); +struct network_game *network_game_server_get_game(void *server); short network_game_client_get_local_machine_index(void); short network_game_client_get_seconds_to_game_start(void *client); boolean network_player_is_valid(struct network_player *player); @@ -1907,6 +1908,10 @@ static struct /* the server settings */ wchar_t game_name[16]; short maximum_players_index; + /* (co-op's own, the most until set lower, so it leaves multiplayer's + as it was) */ + short cooperative_maximum_players_index; + boolean cooperative_maximum_players_set; /* the browser's games */ struct advertised_game *games[MAXIMUM_ADVERTISED_GAMES]; short game_count, game_chosen; @@ -2071,56 +2076,34 @@ static boolean multiplayer_host(struct widget_instance *widget, struct event_rec return ui_widget_port_host(widget, event, widget_deleted); } -/* ---- the map list (the Map screen's). Hosting over the network, its rows -first offer CO-OP CAMPAIGN or MULTIPLAYER, then that kind's categories, then -the levels (and a level's difficulty) or the maps: each a step that B goes -back from. Split screen starts at the multiplayer categories. - -A co-op level is hosted as a network co-op game, and goes to Server Setup. -CUSTOM CAMPAIGN and CUSTOM MAPS list Custom Edition maps, which this build -has none of yet, so those lists are empty. */ +/* ---- the map list (the Map screen's): the multiplayer maps, as Custom +Edition lists them. Hosting over the network, the campaign's levels follow +them: one chosen lists the difficulties (B goes back to the level), and is +hosted as a network co-op game, which goes to Server Setup. */ enum { - MAP_STEP_KINDS, - MAP_STEP_CATEGORIES, MAP_STEP_MAPS, - MAP_STEP_CAMPAIGN_CATEGORIES, - MAP_STEP_LEVELS, MAP_STEP_DIFFICULTIES, }; -#define MAXIMUM_MAP_ENTRIES 256 - -static wchar_t const *const map_kind_names[] = { L"CO-OP CAMPAIGN", L"MULTIPLAYER" }; -static wchar_t const *const map_category_names[] = { L"VANILLA MAPS", L"CUSTOM MAPS" }; -static wchar_t const *const campaign_category_names[] = { L"CAMPAIGN", L"CUSTOM CAMPAIGN" }; - static struct { short step; - boolean hosting; - /* the step's rows: multiplayer map indices (ui_widget_port_multiplayer_maps) - or campaign level indices */ - short count; - short entries[MAXIMUM_MAP_ENTRIES]; + /* the multiplayer maps (ui_widget_port_multiplayer_maps), then, hosting + over the network, the campaign's levels */ + short map_count, level_count; short first, chosen; - /* what the earlier steps chose */ - short category, level; + /* the level whose difficulties are listed */ + short level; } map_list; #define SERVER_SETUP_NAME "pc\\main_menu\\multiplayer_type_select\\server_settings\\server_settings_screen" static short map_step_count(void) { - switch (map_list.step) - { - case MAP_STEP_KINDS: return NUMBEROF(map_kind_names); - case MAP_STEP_CATEGORIES: return NUMBEROF(map_category_names); - case MAP_STEP_CAMPAIGN_CATEGORIES: return NUMBEROF(campaign_category_names); - case MAP_STEP_DIFFICULTIES: return NUMBER_OF_GAME_DIFFICULTY_LEVELS; - default: return map_list.count; - } + return map_list.step == MAP_STEP_DIFFICULTIES ? NUMBER_OF_GAME_DIFFICULTY_LEVELS : + (short)(map_list.map_count + map_list.level_count); } /* opens a step at the row given */ @@ -2135,53 +2118,29 @@ static void map_step_open(struct widget_instance *list, short step, short row) focus_row(list, (short)(map_list.chosen - map_list.first)); } -/* the multiplayer maps of a category, or the campaign levels of one; the -row of the map used last */ -static short map_entries_read(boolean campaign, short category) -{ - char const *const *names; - short last_used = 0; - short index; - - map_list.count = 0; - if (category != 0) - return 0; - if (campaign) - map_list.count = NUMBER_OF_SINGLE_PLAYER_LEVELS; - else - map_list.count = ui_widget_port_multiplayer_maps(&names, &last_used); - for (index = 0; index < map_list.count; index++) - map_list.entries[index] = index; - return last_used; -} - static void map_row_text(short row, wchar_t *text) { short index = (short)(map_list.first + row); - switch (map_list.step) - { - case MAP_STEP_KINDS: ustrncpy(text, map_kind_names[index], ROW_TEXT_LENGTH - 1); break; - case MAP_STEP_CATEGORIES: ustrncpy(text, map_category_names[index], ROW_TEXT_LENGTH - 1); break; - case MAP_STEP_CAMPAIGN_CATEGORIES: ustrncpy(text, campaign_category_names[index], ROW_TEXT_LENGTH - 1); break; - case MAP_STEP_DIFFICULTIES: + if (map_list.step == MAP_STEP_DIFFICULTIES) string_get("pc\\main_menu\\player_profiles_select\\difficulty_names", index, text); - break; - case MAP_STEP_LEVELS: - string_get("pc\\main_menu\\map_list", map_list.entries[index], text); - break; - default: - string_get("pc\\main_menu\\mp_map_list", map_list.entries[index], text); - break; - } + else if (index >= map_list.map_count) + string_get("pc\\main_menu\\map_list", (short)(index - map_list.map_count), text); + else + string_get("pc\\main_menu\\mp_map_list", index, text); text[ROW_TEXT_LENGTH - 1] = 0; } /* "mp level list initialize" */ static boolean map_list_initialize(struct widget_instance *list) { - map_list.hosting = global_network_game_server_get() != NULL && !network_game_is_splitscreen_local(); - map_step_open(list, map_list.hosting ? MAP_STEP_KINDS : MAP_STEP_CATEGORIES, 0); + char const *const *names; + short last_used = 0; + boolean hosting = global_network_game_server_get() != NULL && !network_game_is_splitscreen_local(); + + map_list.map_count = ui_widget_port_multiplayer_maps(&names, &last_used); + map_list.level_count = hosting ? NUMBER_OF_SINGLE_PLAYER_LEVELS : 0; + map_step_open(list, MAP_STEP_MAPS, last_used); return TRUE; } @@ -2198,8 +2157,8 @@ static void map_list_update(struct widget_instance *list) if (row != NONE && row < count) map_list.chosen = row; rows_update(list, (short)MIN(count, MAP_ROWS), map_row_text); - if (map_list.step == MAP_STEP_MAPS && map_list.chosen < map_list.count) - map = map_list.entries[map_list.chosen]; + if (map_list.step == MAP_STEP_MAPS && map_list.chosen < map_list.map_count) + map = map_list.chosen; visible_set(named(description, "mp_map_right_item", 0), map != NONE); if (map != NONE) { @@ -2213,76 +2172,39 @@ static void map_list_update(struct widget_instance *list) profile_name_show(description); } -/* "mp level select" (the list's OK): the next step, or the map or level -chosen. FALSE stays on the Map screen (the gametypes open on TRUE). */ +/* "mp level select" (the list's OK): the map chosen, or a level's +difficulties, or the level at the difficulty chosen. FALSE stays on the Map +screen (the gametypes open on TRUE). */ static boolean map_list_choose(struct widget_instance *list, boolean *widget_deleted) { short chosen = map_list.chosen; - switch (map_list.step) + if (map_list.step == MAP_STEP_DIFFICULTIES) { - case MAP_STEP_KINDS: - ui_play_audio_feedback_sound(SOUND_FORWARD); - map_step_open(list, chosen == 0 ? MAP_STEP_CAMPAIGN_CATEGORIES : MAP_STEP_CATEGORIES, 0); - return FALSE; - case MAP_STEP_CATEGORIES: - ui_play_audio_feedback_sound(SOUND_FORWARD); - map_list.category = chosen; - map_step_open(list, MAP_STEP_MAPS, map_entries_read(FALSE, chosen)); - return FALSE; - case MAP_STEP_CAMPAIGN_CATEGORIES: - ui_play_audio_feedback_sound(SOUND_FORWARD); - map_list.category = chosen; - map_entries_read(TRUE, chosen); - map_step_open(list, MAP_STEP_LEVELS, 0); - return FALSE; - case MAP_STEP_LEVELS: - if (chosen >= map_list.count) - return campaign_fail(); - ui_play_audio_feedback_sound(SOUND_FORWARD); - map_list.level = map_list.entries[chosen]; - map_step_open(list, MAP_STEP_DIFFICULTIES, (short)PIN(main_get_difficulty(), 0, NUMBER_OF_GAME_DIFFICULTY_LEVELS - 1)); - return FALSE; - case MAP_STEP_DIFFICULTIES: /* the co-op game set up, then Server Setup in the gametypes' place */ if (!ui_widget_port_cooperative_level_choose(main_get_solo_level_name(map_list.level), chosen)) return campaign_fail(); return ui_widget_port_open(list, SERVER_SETUP_NAME, widget_deleted); - default: - if (chosen >= map_list.count) - return campaign_fail(); - return ui_widget_port_multiplayer_map_choose(map_list.entries[chosen]); } + if (chosen < map_list.map_count) + return ui_widget_port_multiplayer_map_choose(chosen); + if (chosen >= map_list.map_count + map_list.level_count) + return campaign_fail(); + ui_play_audio_feedback_sound(SOUND_FORWARD); + map_list.level = (short)(chosen - map_list.map_count); + map_step_open(list, MAP_STEP_DIFFICULTIES, (short)PIN(main_get_difficulty(), 0, NUMBER_OF_GAME_DIFFICULTY_LEVELS - 1)); + return FALSE; } -/* "port map list back" (B on the list): the step before, or out of the Map -screen from the first */ +/* "port map list back" (B on the list): from the difficulties back to the +level, else out of the Map screen */ static boolean map_list_back(struct widget_instance *list, boolean *widget_deleted) { - short first = map_list.hosting ? MAP_STEP_KINDS : MAP_STEP_CATEGORIES; - ui_play_audio_feedback_sound(SOUND_BACK); - switch (map_list.step) + if (map_list.step == MAP_STEP_DIFFICULTIES) { - case MAP_STEP_CATEGORIES: - case MAP_STEP_CAMPAIGN_CATEGORIES: - if (map_list.step != first) - { - map_step_open(list, MAP_STEP_KINDS, map_list.step == MAP_STEP_CATEGORIES ? 1 : 0); - return TRUE; - } - break; - case MAP_STEP_MAPS: - map_step_open(list, MAP_STEP_CATEGORIES, map_list.category); + map_step_open(list, MAP_STEP_MAPS, (short)(map_list.map_count + map_list.level)); return TRUE; - case MAP_STEP_LEVELS: - map_step_open(list, MAP_STEP_CAMPAIGN_CATEGORIES, map_list.category); - return TRUE; - case MAP_STEP_DIFFICULTIES: - map_step_open(list, MAP_STEP_LEVELS, map_list.level); - return TRUE; - default: - break; } ui_widget_port_go_back(list); *widget_deleted = TRUE; @@ -2473,6 +2395,20 @@ static char const *const server_settings_gametype_rows[] = "op_team_options", }; +/* the most players Server Setup shows and sets: the multiplayer game's, or +the co-op game's */ +static short *server_settings_maximum_players_index(void) +{ + if (!hosting_cooperative()) + return &multiplayer.maximum_players_index; + if (!multiplayer.cooperative_maximum_players_set) + { + multiplayer.cooperative_maximum_players_index = NUMBEROF(maximum_players) - 1; + multiplayer.cooperative_maximum_players_set = TRUE; + } + return &multiplayer.cooperative_maximum_players_index; +} + /* "server settings init": the game's name (player 1's, else the one given last), the most players, the gametype's copy (once: the screen is made again on coming back from an option's screen) */ @@ -2480,11 +2416,8 @@ static boolean server_settings_initialize(struct widget_instance *list) { struct widget_instance *spinner = named(list, "max_players_spinner", 0); - /* co-op has no gametype to edit, and allows the most players unless set - lower */ - if (hosting_cooperative()) - multiplayer.maximum_players_index = NUMBEROF(maximum_players) - 1; - else + /* (co-op has no gametype to edit) */ + if (!hosting_cooperative()) gametype_setup_begin(); if (!multiplayer.game_name[0] && player_ui_get_active_player_profile_index(0) != NONE) { @@ -2494,7 +2427,7 @@ static boolean server_settings_initialize(struct widget_instance *list) ustrncpy(multiplayer.game_name, profile.player_name, NUMBEROF(multiplayer.game_name) - 1); } if (spinner) - spinner->parameters.list.selected_index = multiplayer.maximum_players_index; + spinner->parameters.list.selected_index = *server_settings_maximum_players_index(); /* (PUBLIC or PRIVATE: this game's; the screen is made again on coming back from an option's screen) */ if ((spinner = named(list, "listing_spinner", 0)) != NULL) @@ -2519,7 +2452,7 @@ static void server_settings_update(struct widget_instance *list) char text[TEXT_FIELD_LENGTH]; if (spinner) - multiplayer.maximum_players_index = (short)PIN(spinner->parameters.list.selected_index, 0, + *server_settings_maximum_players_index() = (short)PIN(spinner->parameters.list.selected_index, 0, NUMBEROF(maximum_players) - 1); wide_to_text(multiplayer.game_name, text, sizeof(text)); text_field_show(named(list, "server_name_value", 0), text, text_field_editing(row)); @@ -2593,7 +2526,7 @@ static boolean server_start(void) if (text_field_editing(NULL)) text_field_end(TRUE); network_game_server_port_set_settings(multiplayer.game_name, - maximum_players[PIN(multiplayer.maximum_players_index, 0, NUMBEROF(maximum_players) - 1)]); + maximum_players[PIN(*server_settings_maximum_players_index(), 0, NUMBEROF(maximum_players) - 1)]); /* the gametype as Server Setup's options left it (co-op keeps its own) */ if (hosting_cooperative()) gametype_setup_end(); @@ -2769,8 +2702,16 @@ static char const *scenario_name(char const *path) return name ? name + 1 : path; } -/* a map's name as the menus show it (its scenario's name, if not one of -theirs), from its scenario's path or name */ +/* a game's type as the browsers and lobby show it: its engine's name, or +CO-OP for one with none (network co-op: ui_widget_port_cooperative_level_choose) */ +static wchar_t const *game_type_name(long engine_type) +{ + return engine_type == 0 ? L"CO-OP" : engine_names[PIN(engine_type, 0, 5)]; +} + +/* a map's name as the menus show it (a campaign level's too, hosted as +network co-op; its scenario's name, if not one of theirs), from its +scenario's path or name */ static void map_display_name(char const *map_name, wchar_t *text) { char const *const *names; @@ -2785,6 +2726,14 @@ static void map_display_name(char const *map_name, wchar_t *text) return; } } + for (index = 0; index < NUMBER_OF_SINGLE_PLAYER_LEVELS; index++) + { + if (!_stricmp(scenario_name(main_get_solo_level_name(index)), scenario_name(map_name))) + { + string_get("pc\\main_menu\\map_list", index, text); + return; + } + } for (index = 0; map_name[index] && index < ROW_TEXT_LENGTH - 1; index++) text[index] = (wchar_t)(unsigned char)map_name[index]; text[index] = 0; @@ -2981,7 +2930,7 @@ static void lobby_browser_update(struct widget_instance *list) text_set(named(row, "server_item_server_name", 0), text); map_display_name(game->map, text); text_set(named(row, "server_item_map", 0), text); - text_set(named(row, "server_item_type", 0), engine_names[PIN(game->engine_type, 0, 5)]); + text_set(named(row, "server_item_type", 0), game_type_name(game->engine_type)); usnprintf(text, ROW_TEXT_LENGTH - 1, L"%d/%d", game->player_count, game->maximum_player_count); text_set(named(row, "server_item_players", 0), text); /* (no ping yet: its host is reached only on joining) */ @@ -3186,7 +3135,7 @@ static void browser_update(struct widget_instance *list) text_set(named(row, "server_item_server_name", 0), text); game_map_name(game, text); text_set(named(row, "server_item_map", 0), text); - text_set(named(row, "server_item_type", 0), engine_names[PIN(game->engine_type, 0, 5)]); + text_set(named(row, "server_item_type", 0), game_type_name(game->engine_type)); usnprintf(text, ROW_TEXT_LENGTH - 1, L"%d/%d", game->player_count, game->maximum_player_count); text_set(named(row, "server_item_players", 0), text); text_set(named(row, "server_item_ping", 0), advertised_in_progress(game) ? L"LIVE" : L""); @@ -3703,7 +3652,7 @@ static void preview_update(struct widget_instance *list) if (valid) { lobby_map_show(description, game->map_name); - usnprintf(text, NUMBEROF(text) - 1, L"%s\r\n%d of %d players\r\non %d machines", engine_names[PIN(game->engine_type, 0, 5)], + usnprintf(text, NUMBEROF(text) - 1, L"%s\r\n%d of %d players\r\non %d machines", game_type_name(game->engine_type), game->player_count, game->maximum_player_count, game->machine_count); text[NUMBEROF(text) - 1] = 0; text_set_length(named(description, "lobby_game_data", 0), text, LOBBY_TEXT_LENGTH); diff --git a/port/linux/game/network_coop.c b/port/linux/game/network_coop.c index 9061c80d..7b3b47e8 100644 --- a/port/linux/game/network_coop.c +++ b/port/linux/game/network_coop.c @@ -118,6 +118,14 @@ toward it each tick. */ missed a message, would lack (host_resend) */ #define OBJECT_REFRESH_TICKS (2 * TICKS_PER_SECOND) +/* the screen effect a client takes from its host: the rasterizer's +convolution types (none, blur, warp) and video overbright modes +(rasterizer_xbox_screen_effect.c), and at most this many extra convolution +passes, more than any script asks for */ +#define SCREEN_EFFECT_CONVOLUTION_TYPES 3 +#define SCREEN_EFFECT_OVERBRIGHT_MODES 3 +#define SCREEN_EFFECT_MAXIMUM_EXTRA_PASSES 8 + enum { /* the farthest an object the cutscene camera films can be from it @@ -322,6 +330,8 @@ struct distributed_coop_device_group real value; }; +#define MAXIMUM_DEVICE_GROUPS_PER_MESSAGE 64 + struct distributed_coop_device_groups_message { struct distributed_message_header header; @@ -876,8 +886,18 @@ static void host_send_device_groups( host_devices.refresh_next = count ? (short)((host_devices.refresh_next + DEVICE_GROUP_REFRESHES_PER_TICK) % count) : 0; host_devices.moved_refresh_next = moved_count ? (short)((host_devices.moved_refresh_next + DEVICE_GROUP_REFRESHES_PER_TICK) % moved_count) : 0; - if (sent) - send_to_clients(&host_devices.message, _distributed_message_coop_device_groups, sent, sizeof(entries[0])); + /* (in messages of at most MAXIMUM_DEVICE_GROUPS_PER_MESSAGE, since a + message counts its entries in a byte: each moved to the front, where the + message's entries begin, over those already sent) */ + for (index = 0; index < sent; index += MAXIMUM_DEVICE_GROUPS_PER_MESSAGE) + { + short message_count = (short)MIN(sent - index, MAXIMUM_DEVICE_GROUPS_PER_MESSAGE); + + if (index > 0) + csmemcpy(entries, &entries[index], message_count * sizeof(entries[0])); + send_to_clients(&host_devices.message, _distributed_message_coop_device_groups, message_count, + sizeof(entries[0])); + } } /* host: where each scenery or machine was last sent, by its absolute @@ -1062,6 +1082,18 @@ void network_coop_handle_screen_effect( if (!distributed_real_valid(reals[index])) return; } + /* (what the rasterizer can draw: its convolution types and overbright + modes, a script's few extra passes, and video with no convolution, as + rasterizer_screen_effect_set_video leaves it) */ + state.convolution_type = (short)PIN(state.convolution_type, 0, SCREEN_EFFECT_CONVOLUTION_TYPES - 1); + state.convolution_extra_passes = (short)PIN(state.convolution_extra_passes, 0, SCREEN_EFFECT_MAXIMUM_EXTRA_PASSES); + state.video_overbright_mode = (short)PIN(state.video_overbright_mode, 0, SCREEN_EFFECT_OVERBRIGHT_MODES - 1); + if (state.video_on) + { + state.convolution_type = 0; + state.convolution_extra_passes = 0; + state.convolution_radius[0] = state.convolution_radius[1] = 0.0f; + } /* the game can only blur one window, so split screen goes without */ if (main_get_window_count() > 1) { @@ -1215,6 +1247,10 @@ static void host_count_skip_votes( /* the least speed (squared, world units a tick) of an object the cutscene camera films when the scripts don't name it */ #define CAMERA_OBJECT_SPEED 0.01f +/* the camera an observer accepts (observer.c) */ +#define CAMERA_WORLD_BOUND 5000.0f +#define CAMERA_MINIMUM_FIELD_OF_VIEW 0.001f +#define CAMERA_MAXIMUM_FIELD_OF_VIEW (_pi / 2.0f) /* Host: the object the cutscene camera films: the nearest vehicle moving close to it (a camera riding along with a drop pod or a Pelican), else the @@ -1589,12 +1625,21 @@ static void client_apply_attach( { long parent_index = object_find(event->name_index, event->object_index, event->definition_index, _object_mask_all); long child_index = object_find((short)event->reals[0], event->target, event->tag_index, _object_mask_all); + long ancestor_index; if (parent_index == NONE || child_index == NONE || parent_index == child_index) return; /* a resent attach this machine already has */ if (event->type == _coop_attach && object_get(child_index)->object.parent_object_index == parent_index) return; + /* (not to one of its own children, which objects.c cannot do: copies + of the hierarchy that differ here, or a host's mistake) */ + for (ancestor_index = object_get(parent_index)->object.parent_object_index; event->type == _coop_attach && + ancestor_index != NONE; ancestor_index = object_get(ancestor_index)->object.parent_object_index) + { + if (ancestor_index == child_index) + return; + } if (event->type == _coop_detach) objects_scripting_detach(parent_index, child_index); else @@ -2367,6 +2412,9 @@ void network_coop_host_tick( void network_coop_client_tick( void) { + /* (a multiplayer game's input and camera are its own) */ + if (!coop_client()) + return; client_play_presentation(); players_vitality_keep(); if (game_time_get() - coop_presentation.heard_time > PRESENTATION_SILENCE_TICKS) @@ -2516,7 +2564,7 @@ void network_coop_handle_device_groups( struct distributed_coop_device_group const *groups = entries; short index; - if (!coop_game()) + if (!coop_client()) return; for (index = 0; index < count; index++) { @@ -2528,6 +2576,8 @@ void network_coop_handle_device_groups( if (group_index == NONE || !device_group_network_get(group_index, &here, &flags, &runtime)) continue; + if (!distributed_real_valid(entry->value)) + continue; value = PIN(entry->value, 0.0f, 1.0f); /* Jump straight to the value when the host did, or when a rotation entry disagrees with us (we missed the change, or joined late). @@ -2550,7 +2600,7 @@ void network_coop_handle_object_names( struct distributed_coop_object_names const *names = entries; short name_index; - if (!coop_game()) + if (!coop_client()) return; if (names->structure_bsp_index != global_structure_bsp_index_get() || names->name_count != MIN(global_scenario_get()->object_names.count, MAXIMUM_OBJECT_NAMES_PER_SCENARIO)) @@ -2593,7 +2643,7 @@ void network_coop_handle_events( struct distributed_coop_event const *events = entries; short index; - if (!coop_game()) + if (!coop_client()) return; for (index = 0; index < count; index++) { @@ -2602,6 +2652,12 @@ void network_coop_handle_events( continue; coop_events.applied_number = events[index].number; coop_events.applied_any = TRUE; + /* (not with a number that isn't one) */ + if (!distributed_real_valid(events[index].reals[0]) || !distributed_real_valid(events[index].reals[1]) || + !distributed_real_valid(events[index].reals[2])) + { + continue; + } client_apply_event(&events[index]); } } @@ -2614,7 +2670,7 @@ void network_coop_handle_presentation( { short slot; - if (!coop_game() || host_time < 0) + if (!coop_client() || host_time < 0) return; coop_presentation.heard_time = game_time_get(); if (coop_presentation.playing && host_time <= coop_presentation.played_time) @@ -2634,7 +2690,8 @@ static void client_presentation_apply( player_input_enable(!presentation->input_disabled); if (presentation->players_vitality_set && distributed_real_valid(presentation->players_maximum_body_vitality) && - distributed_real_valid(presentation->players_maximum_shield_vitality)) + distributed_real_valid(presentation->players_maximum_shield_vitality) && + presentation->players_maximum_body_vitality > 0.0f && presentation->players_maximum_shield_vitality >= 0.0f) { players_vitality.set = TRUE; players_vitality.maximum_body = presentation->players_maximum_body_vitality; @@ -2684,12 +2741,15 @@ static void client_presentation_apply( { point_from_line3d(&filmed->object.position, &presentation->camera_object_offset, 1.0f, &position); } - if (distributed_point_valid(&position, UNIT_WORLD_BOUND) && distributed_axes_make_valid(&forward, &up)) + /* (where and how widely an observer can look: observer.c's + match_assert_valid_observer_command) */ + if (distributed_point_valid(&position, CAMERA_WORLD_BOUND) && distributed_axes_make_valid(&forward, &up)) { if (!(coop_presentation.cinematic_started && presentation->camera_scripted)) client_watch_host_from_behind(&position, &forward, &up); scripted_camera_set_camera_point_relative(&position, &forward, &up, - (real)presentation->camera_field_of_view / FIELD_OF_VIEW_SCALE, 0, NONE); + PIN((real)presentation->camera_field_of_view / FIELD_OF_VIEW_SCALE, CAMERA_MINIMUM_FIELD_OF_VIEW, + CAMERA_MAXIMUM_FIELD_OF_VIEW), 0, NONE); } } else diff --git a/port/linux/game/network_distributed.c b/port/linux/game/network_distributed.c index c5e882d0..d6f9dcef 100644 --- a/port/linux/game/network_distributed.c +++ b/port/linux/game/network_distributed.c @@ -3809,6 +3809,19 @@ void network_distributed_handle_message( { return; } + /* (the kinds whose handlers read one entry: not without it) */ + switch (header.type) + { + case _distributed_message_structure_bsp: + case _distributed_message_coop_presentation: + case _distributed_message_coop_object_names: + case _distributed_message_coop_skip_vote: + if (header.count < 1) + return; + break; + default: + break; + } distributed_statistics.received++; /* (each kind from the host, or from a client) */ diff --git a/port/linux/game/network_objects.c b/port/linux/game/network_objects.c index 76d21094..0572283f 100644 --- a/port/linux/game/network_objects.c +++ b/port/linux/game/network_objects.c @@ -643,15 +643,17 @@ static void distributed_vector_clamp( } } -/* Whether the object is a vehicle no player drives (a Pelican on its flight -path): nothing on a client steers it, so its copy is kept at rest there -(no physics of its own) and moved only to where the host has it. */ +/* Whether the object is a vehicle no player drives in co-op (a Pelican on +its flight path): nothing on a client steers it, so its copy is kept at rest +there (no physics of its own) and moved only to where the host has it. In +multiplayer an empty vehicle (one bailed out of, or tumbling) keeps its own +physics between updates. */ static boolean distributed_vehicle_unsteered( long object_index) { struct unit_datum *vehicle; - if (object_get(object_index)->object.type != _object_type_vehicle) + if (!network_coop_active() || object_get(object_index)->object.type != _object_type_vehicle) return FALSE; vehicle = unit_get(object_index); return vehicle->unit.driver_object_index == NONE || diff --git a/port/linux/include/halo_port_limits.h b/port/linux/include/halo_port_limits.h index 92571c49..800430cc 100644 --- a/port/linux/include/halo_port_limits.h +++ b/port/linux/include/halo_port_limits.h @@ -66,7 +66,7 @@ is newer (network_client_manager.c). A host advertises it, with its netcode, in its game's advertisement's reserved bytes (network_server_message_handler.c), which hosts built before there was a version send as zeros: version 0. Raise it with any change to what the machines send each other. */ -#define HALO_PORT_NETWORK_VERSION 11 +#define HALO_PORT_NETWORK_VERSION 12 /* ... the advertisement's reserved bytes: the version (a little-endian word), then flags */ #define HALO_PORT_ADVERTISED_VERSION_OFFSET 0 diff --git a/source/camera/camera_scripting.h b/source/camera/camera_scripting.h index a677c51e..cdd37bf4 100644 --- a/source/camera/camera_scripting.h +++ b/source/camera/camera_scripting.h @@ -53,6 +53,8 @@ void scripted_camera_set_camera_point_relative( long relative_object_index); short scripted_camera_time( void); +long scripted_camera_object_relative_to( + void); void scripted_camera_update( struct dead_camera *camera, struct camera_control const *controls, diff --git a/source/game/players.c b/source/game/players.c index afa18a00..abe1227e 100644 --- a/source/game/players.c +++ b/source/game/players.c @@ -2180,11 +2180,11 @@ static boolean player_teleport_internal( else { error(2, "couldn't teleport player into a valid location"); - /* port: only split screen pseudo-kills, because its respawn hands the - unit back to the local player, which a network game can't do. In a - network game the player stays put; the Xbox halted for another - machine's player. */ - if (player->local_player_index != NONE && game_connection() == _game_connection_local) + /* port: only a local player is pseudo-killed, because the respawn + hands the unit back to a local player; another machine's stays put + (the Xbox halted for it). Network co-op respawns its own way, so its + players stay put too. */ + if (player->local_player_index != NONE && !network_coop_active()) player_pseudo_kill(player_index, source_unit_index); } diff --git a/source/interface/ui_widget.c b/source/interface/ui_widget.c index 762f7677..ad45e08e 100644 --- a/source/interface/ui_widget.c +++ b/source/interface/ui_widget.c @@ -3698,10 +3698,10 @@ static void widget_instance_initialize( widget->visible = TRUE; widget->render_regardless_of_controller_index = TEST_FLAG(definition->flags, _widget_render_regardless_of_controller_index_bit); - /* port: a network game never pauses (co-op opens the campaign's pause + /* port: a network co-op game never pauses (it opens the campaign's pause screen, which would) */ widget->pause_game_time = TEST_FLAG(definition->flags, _widget_pause_game_time_bit) && - !network_game_is_active(); + !network_coop_active(); widget->creation_time = widget_globals.current_system_milliseconds; widget->milliseconds_to_auto_close = MAX(definition->milliseconds_to_auto_close, 0); widget->auto_close_fade_time = MAX(definition->auto_close_fade_time, 0); diff --git a/source/main/main.c b/source/main/main.c index c41798ab..3ddd894d 100644 --- a/source/main/main.c +++ b/source/main/main.c @@ -2179,7 +2179,7 @@ static void main_won_map_private( in multiplayer, back to the lobby, and the next round is the campaign's next level (The Maw's: The Pillar of Autumn). A level not in the campaign repeats. */ - if (game_connection() == _game_connection_network_server) + if (game_connection() == _game_connection_network_server && network_coop_active()) { struct network_game *game = network_game_get_game(); diff --git a/source/saved games/game_state.h b/source/saved games/game_state.h index d82ee86a..b8e8e02a 100644 --- a/source/saved games/game_state.h +++ b/source/saved games/game_state.h @@ -38,6 +38,11 @@ void game_state_save( void); void game_state_revert( void); +/* port: network co-op's (game_state.c) */ +boolean game_state_port_saved_game_valid( + void); +void game_state_port_restamp_revert_time( + void); void game_state_save_to_persistent_storage( void); boolean game_state_test_persistent_storage( diff --git a/tools/android_build.py b/tools/android_build.py index f2c7afde..a22e9afc 100644 --- a/tools/android_build.py +++ b/tools/android_build.py @@ -405,7 +405,11 @@ def generate_android_build(n: Writer, sln: Any) -> None: game_cflags = " ".join([ guest_abi, guest_code, " ".join(game_flags), profile_flags, f"-include {prefix_header}", f"-include {semantics_header}", - f"-I{LINUX_DIR}/include", game_defines_and_includes(config), *libc_includes, f"-idirafter {XDK_INCLUDE}", + f"-I{LINUX_DIR}/include", + # the headers of the port's own game units (port/linux/game), for the + # game sources that call them + f"-iquote {Path(config['game_sources'])}", + game_defines_and_includes(config), *libc_includes, f"-idirafter {XDK_INCLUDE}", ]) for source in game_sources(config): cflags = game_cflags diff --git a/tools/port_settings.py b/tools/port_settings.py index 5bf07cc6..92c02856 100644 --- a/tools/port_settings.py +++ b/tools/port_settings.py @@ -439,6 +439,15 @@ WIDGET_PATCHES = { f"{MT}/multiplayer_type_select_list": {"insert_before": { f"{MT}/multiplayer_type_gametypes_item": [f''], }}, + # (B on the Map screen goes from a co-op level's difficulties back to the + # level, else out of the screen: menu_functions.c's map list) + f"{MT}/mp_map_select/mp_map_select_list_2": {"handlers": [ + '', + '', + f'', + '', + '', + ]}, f"{MT}/join_game/header_join_game": {"children": [ f'', f'', From e82d880060bd5168752d9b2b4e175aecf27affbc Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:17:22 +1300 Subject: [PATCH 32/51] Singleplayer and multiplayer map choice --- ...ain_menu.multiplayer_type_select.lobby.xml | 2 + ....multiplayer_type_select.mp_map_select.xml | 5 +- .../menus/ce/main_menu.new_select.port.xml | 14 + .../menus/ce/main_menu.solo_level_select.xml | 5 +- port/assets/menus/menus.json | 1 + port/linux/NETCODE.md | 4 +- port/linux/game/menu_functions.c | 350 ++++++++++++++---- .../ui_widget_event_handler_functions.c | 5 +- tools/ce_menus.py | 5 + tools/port_settings.py | 51 ++- 10 files changed, 358 insertions(+), 84 deletions(-) create mode 100644 port/assets/menus/ce/main_menu.new_select.port.xml diff --git a/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml b/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml index 015af27a..2d5c5865 100644 --- a/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml +++ b/port/assets/menus/ce/main_menu.multiplayer_type_select.lobby.xml @@ -95,6 +95,8 @@ + + diff --git a/port/assets/menus/ce/main_menu.multiplayer_type_select.mp_map_select.xml b/port/assets/menus/ce/main_menu.multiplayer_type_select.mp_map_select.xml index dd9d1e16..731abcd3 100644 --- a/port/assets/menus/ce/main_menu.multiplayer_type_select.mp_map_select.xml +++ b/port/assets/menus/ce/main_menu.multiplayer_type_select.mp_map_select.xml @@ -13,6 +13,9 @@ + + + @@ -35,7 +38,7 @@ - + diff --git a/port/assets/menus/ce/main_menu.new_select.port.xml b/port/assets/menus/ce/main_menu.new_select.port.xml new file mode 100644 index 00000000..df781299 --- /dev/null +++ b/port/assets/menus/ce/main_menu.new_select.port.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/port/assets/menus/ce/main_menu.solo_level_select.xml b/port/assets/menus/ce/main_menu.solo_level_select.xml index 3da98277..0ab7778d 100644 --- a/port/assets/menus/ce/main_menu.solo_level_select.xml +++ b/port/assets/menus/ce/main_menu.solo_level_select.xml @@ -14,6 +14,9 @@ + + + @@ -38,7 +41,7 @@ - + diff --git a/port/assets/menus/menus.json b/port/assets/menus/menus.json index 687b5e16..f98a53ed 100644 --- a/port/assets/menus/menus.json +++ b/port/assets/menus/menus.json @@ -17,6 +17,7 @@ "ce/main_menu.multiplayer_type_select.mp_map_select.xml", "ce/main_menu.multiplayer_type_select.server_settings.xml", "ce/main_menu.multiplayer_type_select.xml", + "ce/main_menu.new_select.port.xml", "ce/main_menu.new_select.xml", "ce/main_menu.player_profiles_select.xml", "ce/main_menu.profile_manager.xml", diff --git a/port/linux/NETCODE.md b/port/linux/NETCODE.md index fc6fa1ea..557767d4 100644 --- a/port/linux/NETCODE.md +++ b/port/linux/NETCODE.md @@ -29,8 +29,8 @@ with ideas from VALORANT's netcode articles, keeping the 30 Hz tick: it has, as it drives a remote player's, until it hears nothing of it for two seconds (`port/linux/game/network_actors.c`). - **Co-op.** A network game on a campaign level with no game engine - (Create Game's Map screen, whose campaign levels follow the multiplayer - maps over LAN and the internet) is co-op. Only the host runs the + (Create Game's Map screen, its SINGLEPLAYER maps, over LAN and the + internet) is co-op. Only the host runs the level's scripts and spawns players. `network_coop.c` sends the clients everything the scripts do that they would otherwise miss: - every tick: the cinematic, camera, screen fade, the HUD settings the diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index 480acc15..7809280a 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -43,7 +43,8 @@ the one last used, else the first) and the game's saved game in it: list update", "solo level set map") has those the profile has reached, and marks those it has finished on Normal, Heroic and Legendary, as the Xbox's list does; a level with the saved game in it goes on with it, at - its difficulty; + its difficulty. Its first row's chooser shows MULTIPLAYER's maps + instead, one chosen played alone at once; - the difficulty menu: "difficulty item select" (a difficulty chosen) and "set difficulty" (its OK button: the difficulty shown) start the game; - Load Game's list ("load game menu init", "load game list update", "load @@ -857,12 +858,147 @@ static boolean campaign_continue(short controller) return TRUE; } -/* New Game's list: "initialize sp level list solo" starts on the level last -played */ +/* ---- the map lists (New Game's and the Map screen's): their first row's +chooser of SINGLEPLAYER (the campaign's levels) or MULTIPLAYER maps +(port_settings.MAP_KIND_CHOOSER), then the kind's rows, scrolling as +list_scroll scrolls */ + +enum +{ + MAP_KIND_SINGLEPLAYER, + MAP_KIND_MULTIPLAYER, + NUMBER_OF_MAP_KINDS +}; + +/* the rows after the chooser */ +#define MAP_KIND_ROWS 10 + +static void visible_set(struct widget_instance *widget, boolean visible); +short ui_widget_port_multiplayer_maps(char const *const **names, short *last_used); + +/* the kind the chooser shows (kept at `only`, unless NONE) */ +static short map_kind_shown(struct widget_instance *list, short only) +{ + struct widget_instance *spinner = named(list, "list_item_0_map_kind_spinner", 0); + + if (!spinner) + return only != NONE ? only : MAP_KIND_SINGLEPLAYER; + if (only != NONE) + spinner->parameters.list.selected_index = only; + return (short)PIN(spinner->parameters.list.selected_index, 0, NUMBER_OF_MAP_KINDS - 1); +} + +static void map_kind_set(struct widget_instance *list, short kind) +{ + struct widget_instance *spinner = named(list, "list_item_0_map_kind_spinner", 0); + + if (spinner) + spinner->parameters.list.selected_index = kind; +} + +/* the first of count entries shown, for the entry chosen to be in the middle */ +static short map_kind_first(short chosen, short count) +{ + return (short)PIN(chosen - MAP_KIND_ROWS / 2, 0, MAX(count - MAP_KIND_ROWS, 0)); +} + +static void map_kind_focus(struct widget_instance *list, short first, short chosen) +{ + focus_row(list, (short)(1 + chosen - first)); +} + +/* the rows after the chooser: count entries from *first's, scrolled on at +their ends; the entry focused, NONE when the chooser or the buttons have the +focus */ +static short map_kind_rows_update(struct widget_instance *list, short *first, short count, + void (*entry_text)(short entry, wchar_t *text)) +{ + short shown = (short)MIN(count, MAP_KIND_ROWS); + short row = focused_row(list), index = 0; + struct widget_instance *child; + + if (row != NONE && row >= 1 && row == shown && *first + shown < count) + { + (*first)++; + focus_row(list, --row); + } + else if (row == 1 && *first > 0) + { + (*first)--; + focus_row(list, ++row); + } + for (child = list->child; child; child = child->next, index++) + { + wchar_t text[ROW_TEXT_LENGTH]; + + if (index < 1) + continue; + if (strncmp(child->name, "list_item_", 10)) + break; + child->visible = index - 1 < shown; + if (index - 1 < shown) + { + entry_text((short)(*first + index - 1), text); + text_set(named(child, "list_item_text", 0), text); + } + visible_set(named(child, "list_item_arrows", 0), child == list->focused_child); + visible_set(named(child, "scroll_up_button", 0), FALSE); + visible_set(named(child, "scroll_down_button", 0), FALSE); + } + return row == NONE || row < 1 || row > shown ? NONE : (short)(*first + row - 1); +} + +static void multiplayer_map_text(short map, wchar_t *text) +{ + string_get("pc\\main_menu\\mp_map_list", map, text); +} + +/* the description's map (each list's has both kinds' widgets): a campaign +level's picture, name and words (level_description sets them), or a +multiplayer map's, or (both NONE) neither */ +static void map_description_show(struct widget_instance *description, short level, short map) +{ + struct widget_instance *widget; + short marker; + + visible_set(named(description, "replay_level_right_name", 0), level != NONE); + visible_set(named(description, "replay_level_right_pic", 0), level != NONE); + visible_set(named(description, "replay_level_right_data", 0), level != NONE); + visible_set(named(description, "mp_map_right_name", 0), map != NONE); + visible_set(named(description, "mp_map_right_pic", 0), map != NONE); + visible_set(named(description, "mp_map_right_data", 0), map != NONE); + if (level == NONE) + { + for (marker = 0; marker < 3; marker++) + visible_set(named(description, "difficulty_indicator", marker), FALSE); + } + if (map == NONE) + return; + if ((widget = named(description, "mp_map_right_name", 0)) != NULL) + widget->parameters.text_box.string_list_index = map; + if ((widget = named(description, "mp_map_right_pic", 0)) != NULL) + widget->animation.current_frame_index = map; + if ((widget = named(description, "mp_map_right_data", 0)) != NULL) + widget->parameters.text_box.string_list_index = map; +} + +/* New Game's list: SINGLEPLAYER's levels, the profile's reached, or +MULTIPLAYER's maps, played alone to walk around (no game engine: a campaign +game on the map) */ +static struct +{ + short kind; + short first, chosen; + /* the multiplayer maps (ui_widget_port_multiplayer_maps) */ + char const *const *map_names; + short map_count; +} level_list; + +/* "initialize sp level list solo" starts on the level last played */ static boolean level_list_initialize(struct widget_instance *list, short controller) { struct player_profile profile; - short level; + short level, last_used; if (!campaign_profile(controller, &profile)) return FALSE; @@ -873,37 +1009,70 @@ static boolean level_list_initialize(struct widget_instance *list, short control if (!campaign.levels[level].available) level = 0; campaign.shown_level = level; - focus_row(list, level); + level_list.map_count = ui_widget_port_multiplayer_maps(&level_list.map_names, &last_used); + level_list.kind = MAP_KIND_SINGLEPLAYER; + level_list.chosen = level; + level_list.first = map_kind_first(level, NUMBER_OF_SINGLE_PLAYER_LEVELS); + map_kind_set(list, level_list.kind); + map_kind_focus(list, level_list.first, level_list.chosen); return TRUE; } -static void level_row_text(short row, wchar_t *text) +static void level_row_text(short level, wchar_t *text) { - string_get("pc\\main_menu\\map_list", campaign.levels[row].available ? row : LEVEL_UNAVAILABLE, text); + string_get("pc\\main_menu\\map_list", campaign.levels[level].available ? level : LEVEL_UNAVAILABLE, text); } /* "solo map list update" */ static void level_list_update(struct widget_instance *list) { - short row = focused_row(list), level; + struct widget_instance *description = list->parameters.list.extended_description; + short kind = map_kind_shown(list, NONE), count, entry, level; - rows_update(list, NUMBER_OF_SINGLE_PLAYER_LEVELS, level_row_text); - if (row != NONE && row < NUMBER_OF_SINGLE_PLAYER_LEVELS) - campaign.shown_level = row; + if (kind != level_list.kind) + { + level_list.kind = kind; + level_list.first = level_list.chosen = 0; + } + count = kind == MAP_KIND_SINGLEPLAYER ? NUMBER_OF_SINGLE_PLAYER_LEVELS : level_list.map_count; + entry = map_kind_rows_update(list, &level_list.first, count, + kind == MAP_KIND_SINGLEPLAYER ? level_row_text : multiplayer_map_text); + if (entry != NONE) + level_list.chosen = entry; + if (kind == MAP_KIND_MULTIPLAYER) + { + map_description_show(description, NONE, level_list.chosen < count ? level_list.chosen : NONE); + profile_name_show(description); + return; + } + map_description_show(description, 0, NONE); + campaign.shown_level = level_list.chosen; level = campaign.shown_level; if (!campaign.levels[level].available) level = LEVEL_UNAVAILABLE; - level_description(list->parameters.list.extended_description, "replay_level", level, - level == campaign.saved_level, level == LEVEL_UNAVAILABLE ? NULL : campaign.levels[level].finished, NONE); - profile_name_show(list->parameters.list.extended_description); + level_description(description, "replay_level", level, level == campaign.saved_level, + level == LEVEL_UNAVAILABLE ? NULL : campaign.levels[level].finished, NONE); + profile_name_show(description); } /* "solo level set map": the level shown, if reached, for the difficulty -menu */ -static boolean level_choose(void) +menu; or the multiplayer map, played at once (FALSE: no difficulty menu) */ +static boolean level_choose(short controller) { short level = campaign.shown_level; + if (level_list.kind == MAP_KIND_MULTIPLAYER) + { + struct player_profile profile; + + if (level_list.chosen < 0 || level_list.chosen >= level_list.map_count || + !campaign_profile(controller, &profile)) + { + return campaign_fail(); + } + campaign_start(level_list.map_names[level_list.chosen], main_get_difficulty(), controller); + return FALSE; + } if (level < 0 || level >= NUMBER_OF_SINGLE_PLAYER_LEVELS || !campaign.levels[level].available) return campaign_fail(); /* (not yet: setting it at the main menu changes map, as the Xbox's has it @@ -1790,7 +1959,6 @@ Xbox's networking, run by the engine's port entry points (ui_widget_event_handler_functions.c) on our lists */ #define MAXIMUM_ADVERTISED_GAMES 9 -#define MAP_ROWS 11 #define GAMETYPE_ROWS 10 #define MAXIMUM_GAMETYPES 100 #define BROWSER_ROWS 15 @@ -2076,10 +2244,11 @@ static boolean multiplayer_host(struct widget_instance *widget, struct event_rec return ui_widget_port_host(widget, event, widget_deleted); } -/* ---- the map list (the Map screen's): the multiplayer maps, as Custom -Edition lists them. Hosting over the network, the campaign's levels follow -them: one chosen lists the difficulties (B goes back to the level), and is -hosted as a network co-op game, which goes to Server Setup. */ +/* ---- the map list (the Map screen's): its chooser's SINGLEPLAYER levels +or MULTIPLAYER maps (the map lists', above). A level chosen lists the +difficulties (B goes back to the level) and is hosted as a network co-op +game, which goes to Server Setup; a map goes on to the gametypes. Split +screen hosts only multiplayer maps, its chooser kept at MULTIPLAYER. */ enum { @@ -2089,10 +2258,11 @@ enum static struct { - short step; - /* the multiplayer maps (ui_widget_port_multiplayer_maps), then, hosting - over the network, the campaign's levels */ - short map_count, level_count; + short kind, step; + /* hosting over the network, which co-op needs */ + boolean hosting; + /* the multiplayer maps (ui_widget_port_multiplayer_maps) */ + short map_count; short first, chosen; /* the level whose difficulties are listed */ short level; @@ -2102,72 +2272,76 @@ static struct static short map_step_count(void) { - return map_list.step == MAP_STEP_DIFFICULTIES ? NUMBER_OF_GAME_DIFFICULTY_LEVELS : - (short)(map_list.map_count + map_list.level_count); + if (map_list.step == MAP_STEP_DIFFICULTIES) + return NUMBER_OF_GAME_DIFFICULTY_LEVELS; + return map_list.kind == MAP_KIND_SINGLEPLAYER ? NUMBER_OF_SINGLE_PLAYER_LEVELS : map_list.map_count; } -/* opens a step at the row given */ -static void map_step_open(struct widget_instance *list, short step, short row) +/* opens a step at the entry given */ +static void map_step_open(struct widget_instance *list, short step, short entry) { short count; map_list.step = step; count = map_step_count(); - map_list.chosen = (short)PIN(row, 0, MAX(count - 1, 0)); - map_list.first = (short)PIN(map_list.chosen - MAP_ROWS / 2, 0, MAX(count - MAP_ROWS, 0)); - focus_row(list, (short)(map_list.chosen - map_list.first)); + map_list.chosen = (short)PIN(entry, 0, MAX(count - 1, 0)); + map_list.first = map_kind_first(map_list.chosen, count); + map_kind_focus(list, map_list.first, map_list.chosen); } -static void map_row_text(short row, wchar_t *text) +static void map_level_text(short level, wchar_t *text) { - short index = (short)(map_list.first + row); - - if (map_list.step == MAP_STEP_DIFFICULTIES) - string_get("pc\\main_menu\\player_profiles_select\\difficulty_names", index, text); - else if (index >= map_list.map_count) - string_get("pc\\main_menu\\map_list", (short)(index - map_list.map_count), text); - else - string_get("pc\\main_menu\\mp_map_list", index, text); - text[ROW_TEXT_LENGTH - 1] = 0; + string_get("pc\\main_menu\\map_list", level, text); } -/* "mp level list initialize" */ +static void map_difficulty_text(short difficulty, wchar_t *text) +{ + string_get("pc\\main_menu\\player_profiles_select\\difficulty_names", difficulty, text); +} + +/* "mp level list initialize": on the multiplayer map used last */ static boolean map_list_initialize(struct widget_instance *list) { char const *const *names; short last_used = 0; - boolean hosting = global_network_game_server_get() != NULL && !network_game_is_splitscreen_local(); + map_list.hosting = global_network_game_server_get() != NULL && !network_game_is_splitscreen_local(); map_list.map_count = ui_widget_port_multiplayer_maps(&names, &last_used); - map_list.level_count = hosting ? NUMBER_OF_SINGLE_PLAYER_LEVELS : 0; + map_list.kind = MAP_KIND_MULTIPLAYER; + map_kind_set(list, map_list.kind); map_step_open(list, MAP_STEP_MAPS, last_used); return TRUE; } -/* "mp map list update": the step's rows, and beside a multiplayer map its -picture, name and words */ +/* "mp map list update": the chooser's kind, the step's rows, and beside +them the level or map chosen: its picture, name and words */ static void map_list_update(struct widget_instance *list) { struct widget_instance *description = list->parameters.list.extended_description; - short count = map_step_count(); - short row = list_scroll(list, &map_list.first, count, MAP_ROWS); - short map = NONE; - struct widget_instance *widget; + short kind = map_kind_shown(list, map_list.hosting ? NONE : MAP_KIND_MULTIPLAYER), count, entry; - if (row != NONE && row < count) - map_list.chosen = row; - rows_update(list, (short)MIN(count, MAP_ROWS), map_row_text); - if (map_list.step == MAP_STEP_MAPS && map_list.chosen < map_list.map_count) - map = map_list.chosen; - visible_set(named(description, "mp_map_right_item", 0), map != NONE); - if (map != NONE) + if (kind != map_list.kind) { - if ((widget = named(description, "mp_map_right_name", 0)) != NULL) - widget->parameters.text_box.string_list_index = map; - if ((widget = named(description, "mp_map_right_pic", 0)) != NULL) - widget->animation.current_frame_index = map; - if ((widget = named(description, "mp_map_right_data", 0)) != NULL) - widget->parameters.text_box.string_list_index = map; + map_list.kind = kind; + map_list.step = MAP_STEP_MAPS; + map_list.first = map_list.chosen = 0; + } + count = map_step_count(); + entry = map_kind_rows_update(list, &map_list.first, count, map_list.step == MAP_STEP_DIFFICULTIES ? + map_difficulty_text : kind == MAP_KIND_SINGLEPLAYER ? map_level_text : multiplayer_map_text); + if (entry != NONE) + map_list.chosen = entry; + visible_set(named(description, "mp_map_right_item", 0), map_list.chosen < count); + if (map_list.step == MAP_STEP_DIFFICULTIES || kind == MAP_KIND_SINGLEPLAYER) + { + short level = map_list.step == MAP_STEP_DIFFICULTIES ? map_list.level : map_list.chosen; + + map_description_show(description, level, NONE); + level_description(description, "replay_level", level, FALSE, NULL, NONE); + } + else + { + map_description_show(description, NONE, map_list.chosen < count ? map_list.chosen : NONE); } profile_name_show(description); } @@ -2179,6 +2353,8 @@ static boolean map_list_choose(struct widget_instance *list, boolean *widget_del { short chosen = map_list.chosen; + if (chosen >= map_step_count()) + return campaign_fail(); if (map_list.step == MAP_STEP_DIFFICULTIES) { /* the co-op game set up, then Server Setup in the gametypes' place */ @@ -2186,12 +2362,10 @@ static boolean map_list_choose(struct widget_instance *list, boolean *widget_del return campaign_fail(); return ui_widget_port_open(list, SERVER_SETUP_NAME, widget_deleted); } - if (chosen < map_list.map_count) + if (map_list.kind == MAP_KIND_MULTIPLAYER) return ui_widget_port_multiplayer_map_choose(chosen); - if (chosen >= map_list.map_count + map_list.level_count) - return campaign_fail(); ui_play_audio_feedback_sound(SOUND_FORWARD); - map_list.level = (short)(chosen - map_list.map_count); + map_list.level = chosen; map_step_open(list, MAP_STEP_DIFFICULTIES, (short)PIN(main_get_difficulty(), 0, NUMBER_OF_GAME_DIFFICULTY_LEVELS - 1)); return FALSE; } @@ -2203,7 +2377,7 @@ static boolean map_list_back(struct widget_instance *list, boolean *widget_delet ui_play_audio_feedback_sound(SOUND_BACK); if (map_list.step == MAP_STEP_DIFFICULTIES) { - map_step_open(list, MAP_STEP_MAPS, (short)(map_list.map_count + map_list.level)); + map_step_open(list, MAP_STEP_MAPS, map_list.level); return TRUE; } ui_widget_port_go_back(list); @@ -2702,6 +2876,20 @@ static char const *scenario_name(char const *path) return name ? name + 1 : path; } +/* the campaign level whose scenario a path or name is (a network co-op +game's map), else NONE */ +static short campaign_level_of(char const *map_name) +{ + short level; + + for (level = 0; level < NUMBER_OF_SINGLE_PLAYER_LEVELS; level++) + { + if (!_stricmp(scenario_name(main_get_solo_level_name(level)), scenario_name(map_name))) + return level; + } + return NONE; +} + /* a game's type as the browsers and lobby show it: its engine's name, or CO-OP for one with none (network co-op: ui_widget_port_cooperative_level_choose) */ static wchar_t const *game_type_name(long engine_type) @@ -2726,13 +2914,10 @@ static void map_display_name(char const *map_name, wchar_t *text) return; } } - for (index = 0; index < NUMBER_OF_SINGLE_PLAYER_LEVELS; index++) + if ((index = campaign_level_of(map_name)) != NONE) { - if (!_stricmp(scenario_name(main_get_solo_level_name(index)), scenario_name(map_name))) - { - string_get("pc\\main_menu\\map_list", index, text); - return; - } + string_get("pc\\main_menu\\map_list", index, text); + return; } for (index = 0; map_name[index] && index < ROW_TEXT_LENGTH - 1; index++) text[index] = (wchar_t)(unsigned char)map_name[index]; @@ -3515,6 +3700,7 @@ static void lobby_map_show(struct widget_instance *description, char const *map_ { char const *const *names; short last, count = ui_widget_port_multiplayer_maps(&names, &last), map = 19, index; + short level = campaign_level_of(map_name); struct widget_instance *widget; for (index = 0; index < count; index++) @@ -3522,10 +3708,22 @@ static void lobby_map_show(struct widget_instance *description, char const *map_ if (!_stricmp(names[index], map_name)) map = index; } + /* (a network co-op game's level: its picture and name, sp_levels' and + map_list's, in the place of the map's) */ + visible_set(named(description, "lobby_map_pic", 0), level == NONE); + visible_set(named(description, "lobby_map_name", 0), level == NONE); + visible_set(named(description, "replay_level_right_pic", 0), level != NONE); + visible_set(named(description, "replay_level_right_name", 0), level != NONE); if ((widget = named(description, "lobby_map_pic", 0)) != NULL) widget->animation.current_frame_index = map; if ((widget = named(description, "lobby_map_name", 0)) != NULL) widget->parameters.text_box.string_list_index = map; + if (level == NONE) + return; + if ((widget = named(description, "replay_level_right_pic", 0)) != NULL) + widget->animation.current_frame_index = level; + if ((widget = named(description, "replay_level_right_name", 0)) != NULL) + widget->parameters.text_box.string_list_index = level; } /* "port lobby update" */ @@ -4711,7 +4909,7 @@ boolean pc_menu_event_function_invoke( } else if (!strcmp(name, "solo level set map")) { - return level_choose(); + return level_choose(controller); } else if (!strcmp(name, "difficulty item select")) { diff --git a/source/interface/ui_widget_event_handler_functions.c b/source/interface/ui_widget_event_handler_functions.c index 53e19bb6..96ae7484 100644 --- a/source/interface/ui_widget_event_handler_functions.c +++ b/source/interface/ui_widget_event_handler_functions.c @@ -2027,7 +2027,10 @@ static boolean pause_game_quit_to_main_menu( network_game_client_local_player_quit(controller_index); return TRUE; } - game_state_save_to_persistent_storage(); + /* port: a multiplayer map played alone (New Game's MULTIPLAYER maps) is + not saved, so it never takes the place of the campaign's saved game */ + if (main_get_current_solo_level() != NONE) + game_state_save_to_persistent_storage(); main_goto_main_menu(); return TRUE; } diff --git a/tools/ce_menus.py b/tools/ce_menus.py index af8a6294..45a8a451 100644 --- a/tools/ce_menus.py +++ b/tools/ce_menus.py @@ -83,6 +83,11 @@ CHILD_OFFSETS = { **{("ui\\shell\\main_menu\\multiplayer_type_select\\join_game\\join_game_items_list", f"ui\\shell\\main_menu\\multiplayer_type_select\\join_game\\server_item_{row}"): (10, 102 + 17 * (row - 1)) for row in range(1, 16)}, + # the map lists' first row, the SINGLEPLAYER or MULTIPLAYER chooser + # (port_settings.MAP_KIND_CHOOSER), where the gametype list has its own + **{(f"ui\\shell\\main_menu\\{list_tag}", "ui\\shell\\main_menu\\new_select\\list_item_0"): (82, 73) + for list_tag in ("multiplayer_type_select\\mp_map_select\\mp_map_select_list_2", + "solo_level_select\\solo_level_select_list")}, } SCALE = 4 MAXIMUM_SIZE = 2048 diff --git a/tools/port_settings.py b/tools/port_settings.py index 92c02856..dfbdaf54 100644 --- a/tools/port_settings.py +++ b/tools/port_settings.py @@ -415,6 +415,9 @@ STRING_OVERRIDES.update({ "SHORT RANGE", "HUMAN", "COVENANT", "CLASSIC", "HEAVY WEAPONS", "NO GRENADES"], }) +# the map lists' first row: SINGLEPLAYER or MULTIPLAYER maps (_map_kind) +MAP_KIND_CHOOSER = "main_menu/new_select/list_item_0_map_kind" + # changes to the PC version's widgets (by our names): attributes set, all # their handlers replaced, children added WIDGET_PATCHES = { @@ -439,15 +442,32 @@ WIDGET_PATCHES = { f"{MT}/multiplayer_type_select_list": {"insert_before": { f"{MT}/multiplayer_type_gametypes_item": [f''], }}, - # (B on the Map screen goes from a co-op level's difficulties back to the - # level, else out of the screen: menu_functions.c's map list) - f"{MT}/mp_map_select/mp_map_select_list_2": {"handlers": [ + # (the Map screen's and New Game's lists: SINGLEPLAYER or MULTIPLAYER + # maps, the first row's chooser's (_map_kind); B on the Map screen goes + # from a co-op level's difficulties back to the level, else out of the + # screen: menu_functions.c's map list) + f"{MT}/mp_map_select/mp_map_select_list_2": {"swap": {"main_menu/new_select/list_item_0": MAP_KIND_CHOOSER}, + "handlers": [ '', '', f'', '', '', ]}, + "main_menu/solo_level_select/solo_level_select_list": {"swap": {"main_menu/new_select/list_item_0": MAP_KIND_CHOOSER}}, + # (each description shows the other kind's maps too: a campaign level's + # picture, name and words on the Map screen, a multiplayer map's on New + # Game's) + f"{MT}/mp_map_select/mp_map_right_item": {"children": [ + '', + '', + '', + ]}, + "main_menu/solo_level_select/replay_level_right_item": {"children": [ + f'', + f'', + f'', + ]}, f"{MT}/join_game/header_join_game": {"children": [ f'', f'', @@ -756,7 +776,12 @@ def _lobby() -> list: lines += _widget(f"{base}/lobby_right_item", [("controller", 1), ("left", 406), ("top", 75), ("width", 162), ("height", 326), ("bitmap", "bitmaps/spinner_list_3_wide_item_background")], + # (a campaign level's picture and name in place of the + # map's, for network co-op: menu_functions.c's + # lobby_map_show) [f'', f'', + '', + '', f'']) lines += _widget(f"{base}/lobby_map_pic", [("controller", 1), ("left", 419), ("top", 87), ("width", 140), ("height", 114), ("bitmap", "ui\\shell\\bitmaps\\mp_map_grafix")], []) @@ -943,6 +968,25 @@ def _item_options_extras() -> list: return lines +def _map_kind() -> list: + """the map lists' first row (New Game's and the Map screen's), as the + gametype list's chooser: a spinner of SINGLEPLAYER or MULTIPLAYER maps, + for either controller (split screen co-op's New Game takes both)""" + lines = _widget(MAP_KIND_CHOOSER, [("width", 256), ("height", 28), ("flags", "pass_unhandled_to_focused_child"), + ("bitmap", "bitmaps/option_bkds_sm"), ("font", "ui\\large_ui"), + ("color", "#FF2896FF"), ("align", "center"), ("text_y", 3)], + [f'']) + lines += _widget(f"{MAP_KIND_CHOOSER}_spinner", + [("type", "spinner"), ("width", 226), ("height", 22), ("flags", "left_right_tabs_items"), + ("string_list", "main_menu/new_select/var_map_kinds"), ("font", "ui\\large_ui"), + ("color", "#FF2896FF"), ("align", "center"), ("text_y", 1), ("list_flags", "items_from_strings"), + ("header_bitmap", "bitmaps/arrow_sm_left"), ("footer_bitmap", "bitmaps/arrow_sm_right"), + ("header_bounds", "7 -6 19 0"), ("footer_bounds", "7 226 19 232")], + ['']) + lines += _strings("main_menu/new_select/var_map_kinds", ["SINGLEPLAYER", "MULTIPLAYER"]) + return lines + + def multiplayer_files() -> dict: """the port's multiplayer widgets: the browser's additions, the server settings, the lobby""" @@ -953,6 +997,7 @@ def multiplayer_files() -> dict: f"{MT}/server_settings".replace("/", ".") + ".xml": head + _server_settings() + ["", ""], f"{MT}/lobby".replace("/", ".") + ".xml": head + _lobby() + ["", ""], f"{MT}/coop".replace("/", ".") + ".xml": head + _coop() + ["", ""], + "main_menu/new_select".replace("/", ".") + ".port.xml": head + _map_kind() + ["", ""], "main_menu/settings_select/multiplayer_setup/item_options_edit".replace("/", ".") + ".port.xml": head + _item_options_extras() + ["", ""], } From 783b591a6aa0584393ad658e2cae86c8ec1a0b5f Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:22:12 +1300 Subject: [PATCH 33/51] Co-op defaults to 16 players and private --- port/linux/README.md | 4 ++- port/linux/game/menu_functions.c | 42 +++++++++++++++++++--- port/linux/src/port_config.c | 4 +++ source/networking/network_server_manager.c | 4 ++- 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/port/linux/README.md b/port/linux/README.md index 4c6f29d2..87129221 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -260,6 +260,7 @@ the setting for one start of the game. It has priority over the file. | `network.allow_upnp` | `true` | `HALO_NET_ALLOW_UPNP` | `true`: internet play can ask the router to forward its port (UPnP). `false`: the game does not ask. Refer to "Internet play". | | `network.public_lobby` | `true` | `HALO_NET_PUBLIC_LOBBY` | `true`: the server browser. Public games are listed, and Join Game > Server Browser shows them. `false`: no games are listed or shown. Refer to "Server browser". | | `network.host_public` | `true` | `HALO_NET_HOST_PUBLIC` | `true`: a new game of Create Game > Internet starts as PUBLIC. `false`: it starts as PRIVATE. LISTING in Server Setup changes it for each game. Refer to "Server browser". | +| `network.coop_public` | `false` | `HALO_NET_COOP_PUBLIC` | `true`: an online co-op game (Create Game > Internet, a SINGLEPLAYER map) starts as PUBLIC. `false`: it starts as PRIVATE. LISTING in co-op's Server Setup writes its choice here. Refer to "Server browser". | | `network.brokers_file` | `"brokers.txt"` | `HALO_NET_BROKERS_FILE` | The file of the public MQTT brokers that let the machines of an invite find each other, and that carry the listings of the server browser: next to `config.toml`, unless a full path. One `host:port` on each line, up to 4; `#` starts a comment. | | `network.stun_servers` | Google and Cloudflare | `HALO_NET_STUN` | The public STUN servers (`host:port`, with commas between them) that give the internet address of a machine. | | `discord.application_id` | the application of the project | `HALO_DISCORD_APPLICATION` | The Discord application for invites. Empty: no Discord. | @@ -452,7 +453,8 @@ Server Setup in Create Game > Internet has a LISTING row: - PRIVATE: only players with the invite link can join. Each new game starts as PUBLIC (`network.host_public = false` makes new -games start as PRIVATE). A LAN game is never listed. `network.public_lobby = false` turns the server browser off. +games start as PRIVATE). An online co-op game starts as PRIVATE, and keeps +the last choice of its LISTING (`network.coop_public`). A LAN game is never listed. `network.public_lobby = false` turns the server browser off. In the Server Browser, select a game to join it. The game joins the invite of the game, as for a link. When it reaches the host, it opens the lobby. diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index 7809280a..b9d4db19 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -91,6 +91,7 @@ char const *pc_menu_game_data_input_name(long function_index); void event_manager_post_button(short controller_index, short button_index); int config_text(char const *name, char *text, unsigned int size); int config_write(char const *name, char const *value); +int config_boolean(char const *name); int config_default(char const *name, char *text, unsigned int size); char const *config_string(char const *name); void platform_display_apply(void); @@ -2076,8 +2077,8 @@ static struct /* the server settings */ wchar_t game_name[16]; short maximum_players_index; - /* (co-op's own, the most until set lower, so it leaves multiplayer's - as it was) */ + /* (co-op's own, so it leaves multiplayer's as it was: each game hosted + starts with COOPERATIVE_DEFAULT_PLAYERS players at most) */ short cooperative_maximum_players_index; boolean cooperative_maximum_players_set; /* the browser's games */ @@ -2241,6 +2242,7 @@ static boolean multiplayer_host(struct widget_instance *widget, struct event_rec new game starts with as network.host_public says) */ multiplayer.game_private = !config_boolean("network.host_public"); p2p_set_hosting_public(multiplayer.mode == _multiplayer_mode_host_internet && !multiplayer.game_private); + multiplayer.cooperative_maximum_players_set = FALSE; return ui_widget_port_host(widget, event, widget_deleted); } @@ -2569,20 +2571,49 @@ static char const *const server_settings_gametype_rows[] = "op_team_options", }; +/* the most players a co-op game hosted starts with (maximum_players') */ +#define COOPERATIVE_DEFAULT_PLAYERS 16 + /* the most players Server Setup shows and sets: the multiplayer game's, or -the co-op game's */ +the co-op game's (COOPERATIVE_DEFAULT_PLAYERS the first time in each game +hosted: multiplayer_host) */ static short *server_settings_maximum_players_index(void) { + short index; + if (!hosting_cooperative()) return &multiplayer.maximum_players_index; if (!multiplayer.cooperative_maximum_players_set) { multiplayer.cooperative_maximum_players_index = NUMBEROF(maximum_players) - 1; + for (index = 0; index < NUMBEROF(maximum_players); index++) + { + if (maximum_players[index] == COOPERATIVE_DEFAULT_PLAYERS) + multiplayer.cooperative_maximum_players_index = index; + } multiplayer.cooperative_maximum_players_set = TRUE; } return &multiplayer.cooperative_maximum_players_index; } +/* Server Setup's LISTING, PRIVATE: the multiplayer game's (as +network.host_public started it), or co-op's, saved in network.coop_public */ +static boolean server_settings_private(void) +{ + return hosting_cooperative() ? !config_boolean("network.coop_public") : multiplayer.game_private; +} + +static void server_settings_private_set(boolean private_game) +{ + if (!hosting_cooperative()) + multiplayer.game_private = private_game; + else if (private_game != server_settings_private() && + !config_write("network.coop_public", private_game ? "false" : "true")) + { + platform_log("menus: could not write network.coop_public to config.toml"); + } +} + /* "server settings init": the game's name (player 1's, else the one given last), the most players, the gametype's copy (once: the screen is made again on coming back from an option's screen) */ @@ -2605,7 +2636,8 @@ static boolean server_settings_initialize(struct widget_instance *list) /* (PUBLIC or PRIVATE: this game's; the screen is made again on coming back from an option's screen) */ if ((spinner = named(list, "listing_spinner", 0)) != NULL) - spinner->parameters.list.selected_index = multiplayer.game_private ? 1 : 0; + spinner->parameters.list.selected_index = server_settings_private() ? 1 : 0; + p2p_set_hosting_public(multiplayer.mode == _multiplayer_mode_host_internet && !server_settings_private()); return TRUE; } @@ -2660,7 +2692,7 @@ static void server_settings_update(struct widget_instance *list) boolean public = spinner->parameters.list.selected_index == 0; struct widget_instance *help = list->parameters.list.extended_description; - multiplayer.game_private = !public; + server_settings_private_set(!public); p2p_set_hosting_public(public); if (help && list->focused_child == named(list, "op_listing", 0)) help->parameters.text_box.string_list_index = (short)(10 + spinner->parameters.list.selected_index); diff --git a/port/linux/src/port_config.c b/port/linux/src/port_config.c index 96dba105..031b64b2 100644 --- a/port/linux/src/port_config.c +++ b/port/linux/src/port_config.c @@ -256,6 +256,10 @@ static const struct config_setting config_settings[] = "in everyone's server browser: anyone can see and join it) or, false,\n" "PRIVATE (only players with its invite link can join). Server Setup's\n" "LISTING changes it for each game." }, + { "network.coop_public", _config_boolean, "false", "HALO_NET_COOP_PUBLIC", _environment_value, _platform_all, + "Whether an online co-op game (Create Game > Internet, a SINGLEPLAYER\n" + "map) starts as PUBLIC or, false, PRIVATE: Server Setup's LISTING in\n" + "co-op, which writes its choice here." }, { "network.brokers_file", _config_string, "\"brokers.txt\"", "HALO_NET_BROKERS_FILE", _environment_value, _platform_all, "The file of the public MQTT brokers through which the machines of an\n" diff --git a/source/networking/network_server_manager.c b/source/networking/network_server_manager.c index 5977d875..4c810270 100644 --- a/source/networking/network_server_manager.c +++ b/source/networking/network_server_manager.c @@ -3897,7 +3897,9 @@ void network_game_server_port_set_cooperative( if (!server || server->state != _network_game_server_state_pregame) return; server->game.difficulty = difficulty; - server->game.maximum_players = MAXIMUM_NETWORK_PLAYER_COUNT; + /* (Server Setup's default for co-op, menu_functions.c's + COOPERATIVE_DEFAULT_PLAYERS, until it sets its own) */ + server->game.maximum_players = MIN(16, MAXIMUM_NETWORK_PLAYER_COUNT); if (!network_game_server_send_game_data_pregame(server)) network_event("network_game_server_port_set_cooperative() failed to send updated game settings to clients"); From 8be9a038a1c65753388cd2fd0128fbffa9d33003 Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:40:34 +1300 Subject: [PATCH 34/51] Co-op friendly fire setting --- ...ultiplayer_type_select.server_settings.xml | 17 +++++++++ port/linux/README.md | 1 + port/linux/game/menu_functions.c | 31 +++++++++++++++- port/linux/src/port_config.c | 6 ++++ source/game/game_engine.c | 24 ++++++++++--- source/networking/network_server_manager.c | 15 ++++++++ source/networking/network_server_manager.h | 6 +++- tools/port_settings.py | 36 +++++++++++++++++-- 8 files changed, 128 insertions(+), 8 deletions(-) diff --git a/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml b/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml index d6ef6d9e..4adc6442 100644 --- a/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml +++ b/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml @@ -18,6 +18,7 @@ + @@ -74,6 +75,17 @@ + + + + + + + + + + + @@ -159,6 +171,7 @@ + @@ -173,5 +186,9 @@ + + + + diff --git a/port/linux/README.md b/port/linux/README.md index 87129221..7e60efeb 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -260,6 +260,7 @@ the setting for one start of the game. It has priority over the file. | `network.allow_upnp` | `true` | `HALO_NET_ALLOW_UPNP` | `true`: internet play can ask the router to forward its port (UPnP). `false`: the game does not ask. Refer to "Internet play". | | `network.public_lobby` | `true` | `HALO_NET_PUBLIC_LOBBY` | `true`: the server browser. Public games are listed, and Join Game > Server Browser shows them. `false`: no games are listed or shown. Refer to "Server browser". | | `network.host_public` | `true` | `HALO_NET_HOST_PUBLIC` | `true`: a new game of Create Game > Internet starts as PUBLIC. `false`: it starts as PRIVATE. LISTING in Server Setup changes it for each game. Refer to "Server browser". | +| `network.coop_friendly_fire` | `"off"` | `HALO_NET_COOP_FRIENDLY_FIRE` | Whether the players of an online co-op game hurt each other: `"off"`, `"on"`, `"shields_only"` or `"explosives_only"`. FRIENDLY FIRE in co-op's Server Setup writes its choice here. Their AI allies they always can, as in the campaign. | | `network.coop_public` | `false` | `HALO_NET_COOP_PUBLIC` | `true`: an online co-op game (Create Game > Internet, a SINGLEPLAYER map) starts as PUBLIC. `false`: it starts as PRIVATE. LISTING in co-op's Server Setup writes its choice here. Refer to "Server browser". | | `network.brokers_file` | `"brokers.txt"` | `HALO_NET_BROKERS_FILE` | The file of the public MQTT brokers that let the machines of an invite find each other, and that carry the listings of the server browser: next to `config.toml`, unless a full path. One `host:port` on each line, up to 4; `#` starts a comment. | | `network.stun_servers` | Google and Cloudflare | `HALO_NET_STUN` | The public STUN servers (`host:port`, with commas between them) that give the internet address of a machine. | diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index b9d4db19..a1e6f8a8 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -2035,6 +2035,7 @@ boolean ui_widget_port_multiplayer_player(short controller_index, long profile_i boolean ui_widget_port_unjoin_player(struct widget_instance *widget, struct event_record *event, boolean *widget_deleted); void network_game_server_port_set_settings(wchar_t const *name, long maximum_players); +void network_game_server_port_set_cooperative_friendly_fire(short friendly_fire); void *global_network_game_client_get(void); void *global_network_game_server_get(void); boolean network_game_is_splitscreen_local(void); @@ -2081,6 +2082,8 @@ static struct starts with COOPERATIVE_DEFAULT_PLAYERS players at most) */ short cooperative_maximum_players_index; boolean cooperative_maximum_players_set; + /* co-op's FRIENDLY FIRE shown (network.coop_friendly_fire's) */ + short cooperative_friendly_fire; /* the browser's games */ struct advertised_game *games[MAXIMUM_ADVERTISED_GAMES]; short game_count, game_chosen; @@ -2573,6 +2576,16 @@ static char const *const server_settings_gametype_rows[] = /* the most players a co-op game hosted starts with (maximum_players') */ #define COOPERATIVE_DEFAULT_PLAYERS 16 +/* Server Setup's help for co-op's FRIENDLY FIRE, by its choice (its +help_strings, tools/port_settings.py) */ +#define COOPERATIVE_FRIENDLY_FIRE_HELP 12 + +/* co-op's FRIENDLY FIRE's choices (network.coop_friendly_fire's values, +port_settings.COOP_FRIENDLY_FIRE_VALUES, in this order) */ +static short const cooperative_friendly_fire_modes[] = +{ + _friendly_fire_off, _friendly_fire_on, _friendly_fire_shields_only, _friendly_fire_explosives_only +}; /* the most players Server Setup shows and sets: the multiplayer game's, or the co-op game's (COOPERATIVE_DEFAULT_PLAYERS the first time in each game @@ -2682,6 +2695,18 @@ static void server_settings_update(struct widget_instance *list) for (row = 0; row < NUMBEROF(server_settings_gametype_rows); row++) visible_set(named(list, server_settings_gametype_rows[row], 0), !cooperative); + /* co-op's FRIENDLY FIRE, in their place: its help is its choice's */ + visible_set(named(list, "op_friendly_fire", 0), cooperative); + if (cooperative && (spinner = named(list, "friendly_fire_spinner", 0)) != NULL) + { + short choice = (short)PIN(spinner->parameters.list.selected_index, 0, + NUMBEROF(cooperative_friendly_fire_modes) - 1); + struct widget_instance *help = list->parameters.list.extended_description; + + multiplayer.cooperative_friendly_fire = cooperative_friendly_fire_modes[choice]; + if (help && list->focused_child == named(list, "op_friendly_fire", 0)) + help->parameters.text_box.string_list_index = (short)(COOPERATIVE_FRIENDLY_FIRE_HELP + choice); + } } /* LISTING (an internet game's): PUBLIC, listed in everyone's server browser, or PRIVATE, for this game. Its help is its choice's */ @@ -2733,9 +2758,13 @@ static boolean server_start(void) text_field_end(TRUE); network_game_server_port_set_settings(multiplayer.game_name, maximum_players[PIN(*server_settings_maximum_players_index(), 0, NUMBEROF(maximum_players) - 1)]); - /* the gametype as Server Setup's options left it (co-op keeps its own) */ + /* the gametype as Server Setup's options left it (co-op keeps its own, + and its FRIENDLY FIRE) */ if (hosting_cooperative()) + { gametype_setup_end(); + network_game_server_port_set_cooperative_friendly_fire(multiplayer.cooperative_friendly_fire); + } else if (!gametype_setup_apply()) return campaign_fail(); return global_network_game_server_get() != NULL; diff --git a/port/linux/src/port_config.c b/port/linux/src/port_config.c index 031b64b2..3b43b1e1 100644 --- a/port/linux/src/port_config.c +++ b/port/linux/src/port_config.c @@ -260,6 +260,12 @@ static const struct config_setting config_settings[] = "Whether an online co-op game (Create Game > Internet, a SINGLEPLAYER\n" "map) starts as PUBLIC or, false, PRIVATE: Server Setup's LISTING in\n" "co-op, which writes its choice here." }, + { "network.coop_friendly_fire", _config_string, "\"off\"", "HALO_NET_COOP_FRIENDLY_FIRE", _environment_value, + _platform_all, + "Whether the players of an online co-op game hurt each other: \"off\",\n" + "\"on\", \"shields_only\" or \"explosives_only\" (Server Setup's FRIENDLY\n" + "FIRE in co-op, which writes its choice here). Their AI allies they\n" + "always can, as in the campaign." }, { "network.brokers_file", _config_string, "\"brokers.txt\"", "HALO_NET_BROKERS_FILE", _environment_value, _platform_all, "The file of the public MQTT brokers through which the machines of an\n" diff --git a/source/game/game_engine.c b/source/game/game_engine.c index 9ec84248..29800ae1 100644 --- a/source/game/game_engine.c +++ b/source/game/game_engine.c @@ -568,6 +568,7 @@ symbols in this file: #include "networking/network_game_globals.h" #include "networking/network_server_manager.h" #include "networking/network_game_manager.h" +#include "network_coop.h" /* port: port/linux/game/network_coop.c */ /* (network_server_manager_internal.h's: the host's game record) */ struct network_game *network_game_server_get_game(struct network_game_server *server); #include "objects.h" @@ -7068,16 +7069,31 @@ short game_engine_friendly_damage( { struct game_variant_options const *options = game_variant_options_get(); struct player_datum *attacker; + short friendly_fire; - if (!game_engine || !global_variant.universal_variant.teams || attacker_player_index == NONE || - options->friendly_fire == _friendly_fire_on) + /* port: network co-op's friendly fire is Server Setup's FRIENDLY FIRE, + in the host's game settings (only the host deals damage), between its + players: their AI allies they always hurt, as in the campaign */ + if (network_coop_active()) { - return _friendly_damage_all; + struct network_game *game = network_game_get_game(); + struct unit_datum *unit = (struct unit_datum *)object_try_and_get_and_verify_type(object_index, + _object_mask_unit); + + if (!game || !unit || unit->unit.player_index == NONE) + return _friendly_damage_all; + friendly_fire = game->variant_options.friendly_fire; } + else if (!game_engine || !global_variant.universal_variant.teams) + return _friendly_damage_all; + else + friendly_fire = options->friendly_fire; + if (attacker_player_index == NONE || friendly_fire == _friendly_fire_on) + return _friendly_damage_all; attacker = (struct player_datum *)datum_try_and_get(player_data, attacker_player_index); if (!attacker || attacker->unit_index == object_index) return _friendly_damage_all; - switch (options->friendly_fire) + switch (friendly_fire) { case _friendly_fire_off: return _friendly_damage_none; case _friendly_fire_shields_only: return _friendly_damage_shields; diff --git a/source/networking/network_server_manager.c b/source/networking/network_server_manager.c index 4c810270..72bebd9d 100644 --- a/source/networking/network_server_manager.c +++ b/source/networking/network_server_manager.c @@ -3875,6 +3875,7 @@ static void network_game_server_cooperative_round( struct network_game_server *server) { struct game_variant variant; + short friendly_fire = server->game.variant_options.friendly_fire; if (!network_game_server_cooperative_next_map[0]) return; @@ -3883,6 +3884,8 @@ static void network_game_server_cooperative_round( NUMBEROF(variant.human_readable_game_description) - 1); csmemcpy(&server->game.variant, &variant, sizeof(server->game.variant)); network_game_server_variant_options(&server->game.variant, &server->game.variant_options); + /* (Server Setup's FRIENDLY FIRE, for every level of the game) */ + server->game.variant_options.friendly_fire = friendly_fire; csstrncpy(server->game.map.name, network_game_server_cooperative_next_map, sizeof(server->game.map.name) - 1); server->game.map.name[sizeof(server->game.map.name) - 1] = 0; main_set_multiplayer_map_name(server->game.map.name); @@ -3906,6 +3909,18 @@ void network_game_server_port_set_cooperative( return; } +void network_game_server_port_set_cooperative_friendly_fire( + short friendly_fire) +{ + struct network_game_server *server = global_network_game_server_get(); + + if (!server || friendly_fire < 0 || friendly_fire >= NUMBER_OF_FRIENDLY_FIRE_MODES) + return; + server->game.variant_options.friendly_fire = friendly_fire; + if (server->state == _network_game_server_state_pregame && !network_game_server_send_game_data_pregame(server)) + network_event("network_game_server_port_set_cooperative_friendly_fire() failed to send updated game settings to clients"); +} + /* port: a gametype's PC options: the menus' (player_ui_set_game_variant_options) when it is the menus' gametype, else its defaults */ static void network_game_server_variant_options( diff --git a/source/networking/network_server_manager.h b/source/networking/network_server_manager.h index be1243a7..47708c53 100644 --- a/source/networking/network_server_manager.h +++ b/source/networking/network_server_manager.h @@ -64,10 +64,14 @@ multiplayer; the next round is on next_map (NULL repeats the level). */ void network_game_server_port_cooperative_won( char const *next_map); /* port: co-op server settings: the difficulty, sent to clients, and the -build's maximum players (Server Setup's player count is for PvP) */ +most players a co-op game starts with (Server Setup sets its own) */ void network_game_server_port_set_cooperative( struct network_game_server *server, short difficulty); +/* port: co-op's friendly fire between its players (a _friendly_fire_ mode: +Server Setup's FRIENDLY FIRE), kept for the levels after */ +void network_game_server_port_set_cooperative_friendly_fire( + short friendly_fire); boolean network_game_server_ban_player( char const *text); short network_game_server_matching_player_names( diff --git a/tools/port_settings.py b/tools/port_settings.py index dfbdaf54..4abee640 100644 --- a/tools/port_settings.py +++ b/tools/port_settings.py @@ -657,6 +657,30 @@ def _server_settings() -> list: ("header_bounds", "7 -6 19 0"), ("footer_bounds", "7 150 19 156")], ['']) rows.append((f"{base}/op_listing", None)) + # co-op's FRIENDLY FIRE (between its players), in the place of the + # gametype's rows, which co-op hides as multiplayer hides it, laid out + # as Teamplay Options' (menu_functions.c's server_settings_update); its + # choice kept in network.coop_friendly_fire + extra += _widget(f"{base}/op_friendly_fire", [("width", 512), ("height", 28), + ("flags", "pass_unhandled_to_focused_child"), + ("bitmap", "bitmaps/option_bkds"), ("color", "#FF2896FF")], + [f'', + f'']) + extra += _widget(f"{base}/friendly_fire_label", [("type", "text"), ("controller", 1), ("width", 300), + ("height", 22), ("string_list", f"{base}/labels"), + ("string_index", 10), ("font", "ui\\large_ui"), + ("color", "#FF2896FF"), ("text_x", 13), ("text_y", 4)], []) + extra += _widget(f"{base}/friendly_fire_spinner", + [("type", "spinner"), ("top", 2), ("width", 206), ("height", 20), + ("flags", "pass_unhandled_to_focused_child left_right_tabs_items"), + ("strings", "OFF|ON|SHIELD ONLY|EXPLOSIVES ONLY"), ("setting", "network.coop_friendly_fire"), + ("values", "|".join(COOP_FRIENDLY_FIRE_VALUES)), ("font", "ui\\large_ui"), + ("color", "#FF2896FF"), ("align", "center"), ("text_y", 4), + ("header_bitmap", "bitmaps/arrow_sm_left"), ("footer_bitmap", "bitmaps/arrow_sm_right"), + ("header_bounds", "7 -13 19 -7"), ("footer_bounds", "7 208 19 214")], + ['', '', + '']) + rows.append((f"{base}/op_friendly_fire", None, 4)) # the gametype's options for this game (the gametype editor's screens, # editing a copy of the gametype chosen: "port setup edit") for index, (key, screen) in enumerate(SETUP_OPTION_SCREENS): @@ -675,7 +699,7 @@ def _server_settings() -> list: extra += _widget(f"{base}/{key}_value", [("type", "text"), ("controller", 1), ("width", 280), ("height", 22), ("font", "ui\\small_ui"), ("color", "#FF2896FF"), ("align", "center"), ("text_y", 5), ("text_flags", "no_focus_test")], []) - rows.append((f"{base}/op_{key}", None)) + rows.append((f"{base}/op_{key}", None, 4 + index)) extra += _button(f"{base}/button_defaults", 3, []) extra += _widget(f"{base}/button_ok", [("type", "text"), ("width", 128), ("height", 24), ("bitmap", "bitmaps/text_button_background"), ("text", "START GAME"), @@ -686,7 +710,7 @@ def _server_settings() -> list: '']) extra += _strings(f"{base}/labels", ["GAME NAME:", "MAXIMUM PLAYERS:", "INVITE LINK:", "GAME TYPE:", "PLAYER OPTIONS:", "ITEM OPTIONS:", "VEHICLE OPTIONS:", "INDICATOR OPTIONS:", - "TEAMPLAY OPTIONS:", "LISTING:"]) + "TEAMPLAY OPTIONS:", "LISTING:", "FRIENDLY FIRE:"]) extra += _strings(f"{base}/help_strings", [ "", "The name the game shows in the lists of games.\\nEnter changes it.", @@ -701,6 +725,11 @@ def _server_settings() -> list: # (LISTING's, by its choice) "Anyone can see and join your game: it is listed\\nin everyone's Server Browser.", "Only players with your invite link can join.", + # (FRIENDLY FIRE's, by its choice, as Teamplay Options' words them) + "Players can not be hurt by weapons and explosives\\nfired by the other players.", + "Players can be hurt by weapons or explosives\\nfired by the other players.", + "Damage from the other players will only reduce\\nshields. Health will be unaffected.", + "Players can be hurt by damage from explosives\\nfired by the other players.", ]) lines = _screen(base, spec, rows, ["server settings update"], [''], extra) @@ -713,6 +742,9 @@ def _server_settings() -> list: MAXIMUM_PLAYERS = [2, 4, 8, 12, 16, 24, 32, 48, 64, 96, 128] +# network.coop_friendly_fire's values, as Server Setup's FRIENDLY FIRE shows +# them (menu_functions.c's cooperative_friendly_fire_modes, in this order) +COOP_FRIENDLY_FIRE_VALUES = ["off", "on", "shields_only", "explosives_only"] # Server Setup's rows of the gametype's options: the gametype editor's # screens SETUP_OPTION_SCREENS = [ From fa85aa53f59737d86662a692e3c09c7799279439 Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:51:05 +1300 Subject: [PATCH 35/51] Kick command; co-op friendly fire on by default --- port/linux/NETCODE.md | 4 +- port/linux/README.md | 6 +- port/linux/game/network_distributed.c | 21 ++++ port/linux/src/port_config.c | 2 +- source/hs/hs.c | 41 ++++++-- source/main/console.c | 39 +++++--- source/networking/network_server_manager.c | 110 ++++++++++++++------- source/networking/network_server_manager.h | 4 + 8 files changed, 169 insertions(+), 58 deletions(-) diff --git a/port/linux/NETCODE.md b/port/linux/NETCODE.md index 557767d4..da9eca98 100644 --- a/port/linux/NETCODE.md +++ b/port/linux/NETCODE.md @@ -173,7 +173,9 @@ added to `bans.txt` beside `debug.txt` (a line each, as in `cheaters.txt`, with `ip=` and `hwid=`): the host refuses a machine joining whose address or hardware id is in it (a line taken out unbans). Both are as the player's machine tells them: anyone with administrator or root access can change -them, and players behind one address share it. +them, and players behind one address share it. The console's `kick ` drops a player as `ban` does (every machine told), but adds no line +and keeps no address out: the player may join again at once. A speed hack of less than a tenth is let be: the host's bounds on how far and how fast a client's player moves and fires hold it to the host's time anyway. diff --git a/port/linux/README.md b/port/linux/README.md index 7e60efeb..39356b5a 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -260,7 +260,7 @@ the setting for one start of the game. It has priority over the file. | `network.allow_upnp` | `true` | `HALO_NET_ALLOW_UPNP` | `true`: internet play can ask the router to forward its port (UPnP). `false`: the game does not ask. Refer to "Internet play". | | `network.public_lobby` | `true` | `HALO_NET_PUBLIC_LOBBY` | `true`: the server browser. Public games are listed, and Join Game > Server Browser shows them. `false`: no games are listed or shown. Refer to "Server browser". | | `network.host_public` | `true` | `HALO_NET_HOST_PUBLIC` | `true`: a new game of Create Game > Internet starts as PUBLIC. `false`: it starts as PRIVATE. LISTING in Server Setup changes it for each game. Refer to "Server browser". | -| `network.coop_friendly_fire` | `"off"` | `HALO_NET_COOP_FRIENDLY_FIRE` | Whether the players of an online co-op game hurt each other: `"off"`, `"on"`, `"shields_only"` or `"explosives_only"`. FRIENDLY FIRE in co-op's Server Setup writes its choice here. Their AI allies they always can, as in the campaign. | +| `network.coop_friendly_fire` | `"on"` | `HALO_NET_COOP_FRIENDLY_FIRE` | Whether the players of an online co-op game hurt each other: `"off"`, `"on"`, `"shields_only"` or `"explosives_only"`. FRIENDLY FIRE in co-op's Server Setup writes its choice here. Their AI allies they always can, as in the campaign. | | `network.coop_public` | `false` | `HALO_NET_COOP_PUBLIC` | `true`: an online co-op game (Create Game > Internet, a SINGLEPLAYER map) starts as PUBLIC. `false`: it starts as PRIVATE. LISTING in co-op's Server Setup writes its choice here. Refer to "Server browser". | | `network.brokers_file` | `"brokers.txt"` | `HALO_NET_BROKERS_FILE` | The file of the public MQTT brokers that let the machines of an invite find each other, and that carry the listings of the server browser: next to `config.toml`, unless a full path. One `host:port` on each line, up to 4; `#` starts a comment. | | `network.stun_servers` | Google and Cloudflare | `HALO_NET_STUN` | The public STUN servers (`host:port`, with commas between them) that give the internet address of a machine. | @@ -551,7 +551,9 @@ Only machines with the invite can find the game: `bans.txt`, and refuses a machine whose address or hardware id is in it. - The host can ban a player with `ban ` in the developer console (Tab completes the name). Remove a line from `bans.txt` to unban. - Refer to `NETCODE.md`. So that every player can be named, the host trims + Refer to `NETCODE.md`. `kick ` drops the player the same + way, but keeps nothing: no line in `bans.txt`, and the player can join + again at once. So that every player can be named, the host trims the spaces around a name and removes characters that draw as nothing. A letter with a mark is typed as the plain letter (`ban jose` for "José"). A name with nothing left to type becomes "Player", and a name that another diff --git a/port/linux/game/network_distributed.c b/port/linux/game/network_distributed.c index d6f9dcef..9af57a0a 100644 --- a/port/linux/game/network_distributed.c +++ b/port/linux/game/network_distributed.c @@ -3645,6 +3645,27 @@ void network_distributed_ban( } } +/* (the host: network_server_manager.c, its kick command) players kicked by +the host, which may join again: every machine told, nothing kept (no line +in BANS_FILE) */ +void network_distributed_kick( + char const *names) +{ + char kept_names[64]; + char notice[MAXIMUM_NOTICE_LENGTH]; + + distributed_printable(kept_names, sizeof(kept_names), names); + snprintf(notice, sizeof(notice), "%s kicked by the host", kept_names); + /* (to every client in the game: in the lobby, the host's own) */ + if (game_in_progress()) + distributed_send_notice(notice); + else + { + console_warning("%s", notice); + error(_error_log, "%s", notice); + } +} + /* (the host) a client machine's tick, which one of its messages is stamped with: its clock measured, each window, against the host's; one whose game runs fast (distributed_client_clock) has its players' diff --git a/port/linux/src/port_config.c b/port/linux/src/port_config.c index 3b43b1e1..8dbfd944 100644 --- a/port/linux/src/port_config.c +++ b/port/linux/src/port_config.c @@ -260,7 +260,7 @@ static const struct config_setting config_settings[] = "Whether an online co-op game (Create Game > Internet, a SINGLEPLAYER\n" "map) starts as PUBLIC or, false, PRIVATE: Server Setup's LISTING in\n" "co-op, which writes its choice here." }, - { "network.coop_friendly_fire", _config_string, "\"off\"", "HALO_NET_COOP_FRIENDLY_FIRE", _environment_value, + { "network.coop_friendly_fire", _config_string, "\"on\"", "HALO_NET_COOP_FRIENDLY_FIRE", _environment_value, _platform_all, "Whether the players of an online co-op game hurt each other: \"off\",\n" "\"on\", \"shields_only\" or \"explosives_only\" (Server Setup's FRIENDLY\n" diff --git a/source/hs/hs.c b/source/hs/hs.c index fb6bdd1c..baa0b122 100644 --- a/source/hs/hs.c +++ b/source/hs/hs.c @@ -14088,6 +14088,29 @@ static boolean hs_expression_changes_no_game( static boolean hs_compile_and_evaluate_command( char const *expression); +/* port: the text after the host's command `word` ("ban", "kick") that an +expression starts with (spaces and an opening parenthesis before it, in +either case, then a space or the end), else NULL */ +static char const *hs_host_player_command( + char const *expression, + char const *word) +{ + char const *text = expression; + long index; + + while (*text == ' ' || *text == '\t' || *text == '(') + text++; + for (index = 0; word[index]; index++) + { + char character = text[index] >= 'A' && text[index] <= 'Z' ? text[index] - 'A' + 'a' : text[index]; + + if (character != word[index]) + return NULL; + } + text += index; + return *text == ' ' || *text == '\t' || *text == 0 ? text : NULL; +} + /* port: a command someone typed (the console, the telnet console, a cheat button, init.txt): what it logs is its answer, shown whatever config.toml's game.console_log is (terminal_command_running) */ @@ -14122,20 +14145,20 @@ static boolean hs_compile_and_evaluate_command( console_warning("not while playing in another's game: the host decides the game"); return FALSE; } - /* port: the host's ban command ("ban ", or its start: Tab - completes it), which is no script's */ + /* port: the host's ban and kick commands ("ban ", "kick + ", or the name's start: Tab completes it), which are no + script's */ { - char const *text = expression; + char const *text = hs_host_player_command(expression, "ban"); + boolean kick = FALSE; - while (*text == ' ' || *text == '\t' || *text == '(') - text++; - if ((text[0] == 'b' || text[0] == 'B') && (text[1] == 'a' || text[1] == 'A') && - (text[2] == 'n' || text[2] == 'N') && (text[3] == ' ' || text[3] == '\t' || text[3] == 0)) + if (!text && (text = hs_host_player_command(expression, "kick")) != NULL) + kick = TRUE; + if (text) { char name[64]; long length = 0; - text += 3; while (*text == ' ' || *text == '\t' || *text == '"') text++; while (*text && *text != '"' && *text != ')' && length < (long)sizeof(name) - 1) @@ -14143,7 +14166,7 @@ static boolean hs_compile_and_evaluate_command( while (length > 0 && (name[length - 1] == ' ' || name[length - 1] == '\t')) length--; name[length] = 0; - return network_game_server_ban_player(name); + return kick ? network_game_server_kick_player(name) : network_game_server_ban_player(name); } } csstrncpy(buffer, expression, sizeof(buffer)); diff --git a/source/main/console.c b/source/main/console.c index 11e64fa5..7bc8c0fb 100644 --- a/source/main/console.c +++ b/source/main/console.c @@ -257,23 +257,38 @@ static char *console_get_text_to_autocomplete( return result; } -/* port: the text after the host's ban command ("ban "), which completes as -a player's name (network_game_server_matching_player_names); NULL if the -input is not it */ -static char *console_ban_command_name( +/* port: the text after the host's ban or kick command ("ban ", "kick "), +which completes as a player's name (network_game_server_matching_player_names); +NULL if the input is not one */ +static char *console_player_command_name( void) { + static char const *const commands[] = { "ban", "kick" }; char *text = console_globals.input_state.result; + short command; while (*text == ' ' || *text == '(') text++; - if ((text[0] == 'b' || text[0] == 'B') && (text[1] == 'a' || text[1] == 'A') && - (text[2] == 'n' || text[2] == 'N') && text[3] == ' ') + for (command = 0; command < (short)NUMBEROF(commands); command++) { - text += 3; - while (*text == ' ' || *text == '"') - text++; - return text; + char const *word = commands[command]; + long length = (long)strlen(word); + long index; + + for (index = 0; index < length; index++) + { + char character = text[index] >= 'A' && text[index] <= 'Z' ? text[index] - 'A' + 'a' : text[index]; + + if (character != word[index]) + break; + } + if (index == length && text[length] == ' ') + { + text += length; + while (*text == ' ' || *text == '"') + text++; + return text; + } } return NULL; } @@ -283,10 +298,10 @@ static void console_complete( { char *matching_items[256]; char print_buffer[1024]; - /* (port: the players' names the ban command completes) */ + /* (port: the players' names the ban and kick commands complete) */ static char player_names[64][NETWORK_GAME_SERVER_NAME_TEXT_SIZE]; - char *token = console_ban_command_name(); + char *token = console_player_command_name(); short count; if (token) diff --git a/source/networking/network_server_manager.c b/source/networking/network_server_manager.c index 72bebd9d..1b4934c1 100644 --- a/source/networking/network_server_manager.c +++ b/source/networking/network_server_manager.c @@ -795,6 +795,7 @@ static boolean network_game_server_countdown_slowed[MAXIMUM_NETWORK_MACHINE_COUN /* port/linux/game/network_distributed.c's (the host's bans: bans.txt) */ boolean network_distributed_banned(unsigned long address, char const *hardware_id); void network_distributed_ban(long machine_index, unsigned long address, char const *names); +void network_distributed_kick(char const *names); /* port/linux/src/p2p.c's */ enum { @@ -804,15 +805,22 @@ void p2p_hardware_id_sanitize(char *destination, int size, const char *source); /* console.c's */ void console_warning(const char *format, ...); -/* port: the client machines the distributed netcode asked to drop (their -games sped up: network_game_server_kick_machine), dropped as this server -next looks at its machines; and the addresses of those dropped, kept out -of this server's games while it lasts */ +/* port: the client machines to drop, as this server next looks at its +machines: those the distributed netcode asked to (their games sped up: +network_game_server_kick_machine) and those the host banned, whose +addresses are kept out of this server's games while it lasts; and those the +host kicked, which may join again at once */ enum { MAXIMUM_KICKED_ADDRESSES = 64, }; -static boolean network_game_server_kick_pending[MAXIMUM_NETWORK_MACHINE_COUNT]; +enum +{ + _kick_none = 0, + _kick_kept_out, + _kick_rejoinable, +}; +static byte network_game_server_kick_pending[MAXIMUM_NETWORK_MACHINE_COUNT]; /* port: each client machine's hardware id as it told it joining, hex only (p2p_hardware_id_sanitize), by slot */ static char network_game_server_hardware_ids[MAXIMUM_NETWORK_MACHINE_COUNT][P2P_HARDWARE_ID_SIZE]; @@ -934,11 +942,16 @@ short network_game_server_matching_player_names( return count; } -/* port: the host's ban command: the other machine of the player of the name -(in either case; else the one player whose name begins with it) dropped, its -address in bans.txt (network_distributed_ban), and kept out */ -boolean network_game_server_ban_player( - char const *text) +/* port: the host's ban and kick commands' player: the other machine of the +player of the name (in either case; else the one player whose name begins +with it), and the names of that machine's players (each command's warnings +start with its own name); FALSE (said) if there is none */ +static boolean network_game_server_named_machine( + char const *command, + char const *text, + long *machine_index, + char *names, + long names_size) { struct network_game_server *server = global_network_game_server_get(); long found_index = NONE; @@ -946,12 +959,11 @@ boolean network_game_server_ban_player( long exact_index = NONE; long exact_count = 0; long index; - long machine_index; - char names[96] = ""; + names[0] = 0; if (!server) { - console_warning("ban: only the host of a game bans"); + console_warning("%s: only the host of a game does this", command); return FALSE; } for (index = 0; index < MAXIMUM_NETWORK_PLAYER_COUNT; index++) @@ -977,7 +989,7 @@ boolean network_game_server_ban_player( if (exact_count > 1) { /* (the host numbers players of the same name: network_server_message_handler.c) */ - console_warning("ban: %ld players are named \"%s\"", exact_count, text); + console_warning("%s: %ld players are named \"%s\"", command, exact_count, text); return FALSE; } if (exact_count == 1) @@ -987,25 +999,25 @@ boolean network_game_server_ban_player( } if (!text[0]) { - console_warning("ban: give a player's name (Tab completes it)"); + console_warning("%s: give a player's name (Tab completes it)", command); return FALSE; } if (match_count > 1) { - console_warning("ban: %ld players' names begin with \"%s\": give more of it", match_count, text); + console_warning("%s: %ld players' names begin with \"%s\": give more of it", command, match_count, text); return FALSE; } if (match_count == 0) { - console_warning("ban: no player's name begins with \"%s\"", text); + console_warning("%s: no player's name begins with \"%s\"", command, text); return FALSE; } - machine_index = server->game.players[found_index].machine_index; - if (!VALID_INDEX(machine_index, MAXIMUM_NETWORK_MACHINE_COUNT) || - !network_game_server_client_machine_is_joined_to_game(server, &server->client_machines[machine_index]) || - network_game_server_client_machine_is_local(server, &server->client_machines[machine_index])) + *machine_index = server->game.players[found_index].machine_index; + if (!VALID_INDEX(*machine_index, MAXIMUM_NETWORK_MACHINE_COUNT) || + !network_game_server_client_machine_is_joined_to_game(server, &server->client_machines[*machine_index]) || + network_game_server_client_machine_is_local(server, &server->client_machines[*machine_index])) { - console_warning("ban: not a player of the host's own machine, nor one not joined"); + console_warning("%s: not a player of the host's own machine, nor one not joined", command); return FALSE; } /* (every player of that machine, named) */ @@ -1014,16 +1026,46 @@ boolean network_game_server_ban_player( struct network_player const *player = &server->game.players[index]; char name[NETWORK_GAME_SERVER_NAME_TEXT_SIZE]; - if (!network_player_is_valid(player) || player->machine_index != machine_index) + if (!network_player_is_valid(player) || player->machine_index != *machine_index) continue; network_game_server_player_name_text(player, name, sizeof(name)); - if (names[0] && csstrlen(names) + 2 < sizeof(names)) + if (names[0] && csstrlen(names) + 2 < names_size) csstrcat(names, ", "); - if (csstrlen(names) + csstrlen(name) < sizeof(names)) + if (csstrlen(names) + csstrlen(name) < names_size) csstrcat(names, name); } + return TRUE; +} + +/* port: the host's ban command: the player's machine (as +network_game_server_named_machine finds it) dropped, its address in +bans.txt (network_distributed_ban), and kept out */ +boolean network_game_server_ban_player( + char const *text) +{ + long machine_index; + char names[96]; + + if (!network_game_server_named_machine("ban", text, &machine_index, names, sizeof(names))) + return FALSE; network_distributed_ban(machine_index, network_game_server_client_machine_addresses[machine_index], names); - network_game_server_kick_pending[machine_index] = TRUE; + network_game_server_kick_pending[machine_index] = _kick_kept_out; + return TRUE; +} + +/* port: the host's kick command: the player's machine (as the ban +command's) dropped, every machine told, and nothing kept: it may join again +at once */ +boolean network_game_server_kick_player( + char const *text) +{ + long machine_index; + char names[96]; + + if (!network_game_server_named_machine("kick", text, &machine_index, names, sizeof(names))) + return FALSE; + network_distributed_kick(names); + network_game_server_kick_pending[machine_index] = _kick_rejoinable; return TRUE; } @@ -1071,7 +1113,7 @@ void network_game_server_kick_machine( { return; } - network_game_server_kick_pending[machine_index] = TRUE; + network_game_server_kick_pending[machine_index] = _kick_kept_out; } /* (a client machine's player queued to add in game: one refused is as one @@ -1933,7 +1975,7 @@ boolean network_game_server_accept_client_machine_into_game( } /* (a kick asked for the slot's machine before is not this one's) */ if (VALID_INDEX(machine_index, MAXIMUM_NETWORK_MACHINE_COUNT)) - network_game_server_kick_pending[machine_index] = FALSE; + network_game_server_kick_pending[machine_index] = _kick_none; if (VALID_INDEX(machine_index, MAXIMUM_NETWORK_MACHINE_COUNT) && machine == &server->client_machines[machine_index]) { @@ -4217,18 +4259,20 @@ static boolean network_game_server_handle_client_machines( if (!network_game_server_drop_client_machine(server, client_machine)) network_event("failed to remove client machine %x from game", machine_index); } - /* port: one the distributed netcode found cheating: told, and - dropped, its address kept out */ + /* port: one the distributed netcode found cheating, or the host + banned or kicked: told, and dropped; its address kept out but + for a kick's */ else if (VALID_INDEX(client_machine->machine_index, MAXIMUM_NETWORK_MACHINE_COUNT) && - network_game_server_kick_pending[client_machine->machine_index]) + network_game_server_kick_pending[client_machine->machine_index] != _kick_none) { short machine_index = client_machine->machine_index; struct message_server_machine_rejected rejection = { _rejection_code_blacklisted_machine }; struct network_message *message; unsigned long address = network_game_server_client_machine_addresses[machine_index]; + boolean kept_out = network_game_server_kick_pending[machine_index] == _kick_kept_out; - network_game_server_kick_pending[machine_index] = FALSE; - if (address && !network_game_server_client_machine_is_local(server, client_machine)) + network_game_server_kick_pending[machine_index] = _kick_none; + if (kept_out && address && !network_game_server_client_machine_is_local(server, client_machine)) { network_game_server_kicked_addresses[network_game_server_kicked_address_next++ % MAXIMUM_KICKED_ADDRESSES] = address; diff --git a/source/networking/network_server_manager.h b/source/networking/network_server_manager.h index 47708c53..9a013c42 100644 --- a/source/networking/network_server_manager.h +++ b/source/networking/network_server_manager.h @@ -74,6 +74,10 @@ void network_game_server_port_set_cooperative_friendly_fire( short friendly_fire); boolean network_game_server_ban_player( char const *text); +/* port: the host's kick command: as the ban command, but nothing kept (no +bans.txt line, the address not kept out): the player may join again at once */ +boolean network_game_server_kick_player( + char const *text); short network_game_server_matching_player_names( char const *text, char (*names)[NETWORK_GAME_SERVER_NAME_TEXT_SIZE], From a38ede078ae9fa934bb1c2447ea701a42c6b718a Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:25:15 +1300 Subject: [PATCH 36/51] Co-op extra enemies per player --- ...ultiplayer_type_select.server_settings.xml | 14 + port/linux/NETCODE.md | 13 +- port/linux/README.md | 1 + port/linux/game/coop_enemies.c | 482 ++++++++++++++++++ port/linux/game/coop_enemies.h | 48 ++ port/linux/game/menu_functions.c | 14 +- port/linux/game/network_actors.c | 7 +- port/linux/game/network_coop.c | 5 + port/linux/include/halo_port_capacity.h | 18 +- port/linux/include/halo_port_limits.h | 2 +- port/linux/src/port_config.c | 5 + source/ai/actors.h | 7 +- source/ai/ai_profile.c | 4 +- source/ai/encounters.c | 39 +- source/ai/props.c | 5 +- source/units/units.c | 21 + tools/port_settings.py | 29 +- 17 files changed, 691 insertions(+), 23 deletions(-) create mode 100644 port/linux/game/coop_enemies.c create mode 100644 port/linux/game/coop_enemies.h diff --git a/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml b/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml index 4adc6442..e3592c2e 100644 --- a/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml +++ b/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml @@ -19,6 +19,7 @@ + @@ -86,6 +87,17 @@ + + + + + + + + + + + @@ -172,6 +184,7 @@ + @@ -190,5 +203,6 @@ + diff --git a/port/linux/NETCODE.md b/port/linux/NETCODE.md index da9eca98..a5c083f9 100644 --- a/port/linux/NETCODE.md +++ b/port/linux/NETCODE.md @@ -53,6 +53,15 @@ with ideas from VALORANT's netcode articles, keeping the 30 Hz tick: moving forward (the netcode depends on that) and moves the script threads' wake times along with it. The object, device and name syncs bring the clients up to date. + + The host's EXTRA ENEMIES (`coop_enemies.c`, `network.coop_enemies`) + give each squad of enemies a level places that percentage of itself more + for each player past the first, in rings about its starting locations + where the ground is open, and never past the actors a level needs for its + own (the actor pool, `halo_port_capacity.h`, holds 1024). Riders a + dropship has no seats for are kept, and placed beside its riders once + they get out. Only the host runs the AI, so the clients see them as the + host's other actors. - **Host authoritative.** The host alone decides damage, deaths, spawns, pickups, scores and the game's objects; clients do not decide them but apply what the host sends. @@ -128,7 +137,9 @@ tells the host its Discord user, and a machine's join request carries its hardware id; version 10 sends every player's ping for the scoreboard; version 11 sends with the game's settings its gametype's PC options; version 12 plays the campaign together (co-op, above), drives the host's -actors on its clients and sends the flinches and deaths the host picked. +actors on its clients and sends the flinches and deaths the host picked; +version 13 drives up to 1056 of the host's AI units on its clients (co-op's +extra enemies), where 12 drove 288. A client plays by its host's rules: in another's game (searching for it, in its lobby, or playing it) the developer console, the telnet console diff --git a/port/linux/README.md b/port/linux/README.md index 39356b5a..ebbedd90 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -261,6 +261,7 @@ the setting for one start of the game. It has priority over the file. | `network.public_lobby` | `true` | `HALO_NET_PUBLIC_LOBBY` | `true`: the server browser. Public games are listed, and Join Game > Server Browser shows them. `false`: no games are listed or shown. Refer to "Server browser". | | `network.host_public` | `true` | `HALO_NET_HOST_PUBLIC` | `true`: a new game of Create Game > Internet starts as PUBLIC. `false`: it starts as PRIVATE. LISTING in Server Setup changes it for each game. Refer to "Server browser". | | `network.coop_friendly_fire` | `"on"` | `HALO_NET_COOP_FRIENDLY_FIRE` | Whether the players of an online co-op game hurt each other: `"off"`, `"on"`, `"shields_only"` or `"explosives_only"`. FRIENDLY FIRE in co-op's Server Setup writes its choice here. Their AI allies they always can, as in the campaign. | +| `network.coop_enemies` | `50` | `HALO_NET_COOP_ENEMIES` | Online co-op's extra enemies, a percentage from `0` to `200`: for each player past the first, each squad of enemies that a level places gets this much of itself more (`100`: as many again, so four players meet four times the squad). The extra enemies stand around the squad's places, and those that a dropship has no seats for drop out of it after its passengers. EXTRA ENEMIES in co-op's Server Setup writes its choice here. | | `network.coop_public` | `false` | `HALO_NET_COOP_PUBLIC` | `true`: an online co-op game (Create Game > Internet, a SINGLEPLAYER map) starts as PUBLIC. `false`: it starts as PRIVATE. LISTING in co-op's Server Setup writes its choice here. Refer to "Server browser". | | `network.brokers_file` | `"brokers.txt"` | `HALO_NET_BROKERS_FILE` | The file of the public MQTT brokers that let the machines of an invite find each other, and that carry the listings of the server browser: next to `config.toml`, unless a full path. One `host:port` on each line, up to 4; `#` starts a comment. | | `network.stun_servers` | Google and Cloudflare | `HALO_NET_STUN` | The public STUN servers (`host:port`, with commas between them) that give the internet address of a machine. | diff --git a/port/linux/game/coop_enemies.c b/port/linux/game/coop_enemies.c new file mode 100644 index 00000000..4feb53ee --- /dev/null +++ b/port/linux/game/coop_enemies.c @@ -0,0 +1,482 @@ +/* +COOP_ENEMIES.C + +Network co-op's extra enemies: Server Setup's EXTRA ENEMIES +(network.coop_enemies, a percentage). For each player past the first, each +squad of the players' enemies that a level places (encounters.c's +encounter_create) gets that much of its count more: at 100%, two players +meet twice the squad, four players four times it. The extra enemies stand +in rings about the squad's starting locations, on open ground, so they do +not start inside each other. + +A squad a script loads into a dropship (vehicle_load_magic) can be larger +than the dropship's seats. The riders left without a seat are erased, and +kept: once the dropship's own riders get out (vehicle_unload, or any other +way), they are placed beside those riders, a few ticks apart, and drop and +fight as they do. A dropship gone first takes them with it. + +Only the host runs the AI, so all of this is the host's. +*/ + +#include "cseries.h" +#include "ai/actor_placement.h" +#include "ai/actors.h" +#include "ai/ai_scenario_definitions.h" +#include "ai/encounters.h" +#include "game/game.h" +#include "game/game_allegiance.h" +#include "game/players.h" +#include "networking/network_game_globals.h" +#include "networking/network_game_manager.h" +#include "objects/objects.h" +#include "physics/collisions.h" +#include "scenario/scenario.h" +#include "scenario/scenario_definitions.h" +#include "units/units.h" +#include "units/unit_definitions.h" + +#include "coop_enemies.h" +#include "network_coop.h" + +#include + +/* port_config.c's */ +long config_integer(char const *name); + +/* ---------- constants */ + +enum +{ + /* the actors a level keeps for its own placements, which the extra + enemies never take: as many as the Xbox's whole pool */ + COOP_ENEMIES_LEVEL_ACTORS = 256, + /* EXTRA ENEMIES' greatest (Server Setup's choices: 0 to 200%) */ + COOP_ENEMIES_MAXIMUM_PERCENT = 200, + /* the places tried for an extra enemy, a ring further out each */ + SPREAD_ATTEMPTS = 8, + SPREAD_PLACES_PER_RING = 6, + MAXIMUM_RIDING_VEHICLES = 32, + MAXIMUM_SEATED_RIDERS = 16, + MAXIMUM_KEPT_RIDERS = 512, + /* a kept rider placed every this many ticks, as the seated ones + jumped out */ + RIDER_RELEASE_TICKS = 4, +}; + +/* the rings' spacing, and the height over the ground the way to a place is +looked along, and the ground looked for below it */ +#define SPREAD_SPACING 0.8f +#define SPREAD_STEP_HEIGHT 0.6f +#define SPREAD_GROUND_DEPTH 2.0f +/* a kept rider's place beside a rider who got out, each a little apart */ +#define RIDER_SPACING 0.5f + +/* ---------- structures */ + +/* a vehicle a script loaded riders into: its seated ones, and whether they +have begun to get out */ +struct coop_riding_vehicle +{ + long vehicle_index; + long seated_unit_indices[MAXIMUM_SEATED_RIDERS]; + short seat_indices[MAXIMUM_SEATED_RIDERS]; + short seated_count; + boolean releasing; + short released_count; + long next_release_time; +}; + +/* a rider left without a seat, to place once its vehicle's get out */ +struct coop_kept_rider +{ + long vehicle_index; + long variant_definition_index; + long encounter_index; + short squad_index; +}; + +/* ---------- globals */ + +static struct +{ + /* EXTRA ENEMIES, as the game began with it */ + short percent; + struct coop_riding_vehicle vehicles[MAXIMUM_RIDING_VEHICLES]; + short vehicle_count; + struct coop_kept_rider riders[MAXIMUM_KEPT_RIDERS]; + short rider_count; +} coop_enemies; + +/* ---------- private code */ + +static boolean coop_enemies_host( + void) +{ + return game_connection() == _game_connection_network_server && network_coop_active(); +} + +/* the players in the game, every machine's: the network game's (a level +places its first enemies before the players' units are made, as it loads: +game_initialize_for_new_map) */ +static short coop_enemies_player_count( + void) +{ + struct network_game *game = network_game_get_game(); + short count = 0; + short index; + + for (index = 0; game && index < HALO_PORT_MAXIMUM_NETWORK_PLAYERS; index++) + { + if (network_player_is_valid(&game->players[index])) + count++; + } + return count; +} + +/* whether the way from a to b is open (no structure between them) */ +static boolean coop_enemies_open( + real_point3d const *a, + real_point3d const *b) +{ + struct collision_result collision; + real_vector3d vector; + + vector.i = b->x - a->x; + vector.j = b->y - a->y; + vector.k = b->z - a->z; + return !collision_test_vector(FLAG(_collision_test_structure_bit) | FLAG(_collision_test_front_facing_surfaces_bit) | + FLAG(_collision_test_back_facing_surfaces_bit), a, &vector, NONE, &collision); +} + +/* the ground below a point, within SPREAD_GROUND_DEPTH of it */ +static boolean coop_enemies_ground( + real_point3d const *point, + real_point3d *ground) +{ + struct collision_result collision; + real_vector3d vector = { 0.0f, 0.0f, -(SPREAD_STEP_HEIGHT + SPREAD_GROUND_DEPTH) }; + + if (!collision_test_vector(FLAG(_collision_test_structure_bit) | FLAG(_collision_test_front_facing_surfaces_bit), + point, &vector, NONE, &collision)) + { + return FALSE; + } + *ground = collision.point; + return TRUE; +} + +static struct coop_riding_vehicle *coop_enemies_riding_vehicle( + long vehicle_index, + boolean create) +{ + short index; + + for (index = 0; index < coop_enemies.vehicle_count; index++) + { + if (coop_enemies.vehicles[index].vehicle_index == vehicle_index) + return &coop_enemies.vehicles[index]; + } + if (!create || coop_enemies.vehicle_count >= MAXIMUM_RIDING_VEHICLES) + return NULL; + csmemset(&coop_enemies.vehicles[coop_enemies.vehicle_count], 0, sizeof(coop_enemies.vehicles[0])); + coop_enemies.vehicles[coop_enemies.vehicle_count].vehicle_index = vehicle_index; + return &coop_enemies.vehicles[coop_enemies.vehicle_count++]; +} + +/* the riders kept for a vehicle forgotten, and the vehicle with them */ +static void coop_enemies_forget_vehicle( + short vehicle_number) +{ + long vehicle_index = coop_enemies.vehicles[vehicle_number].vehicle_index; + short index, kept = 0; + + for (index = 0; index < coop_enemies.rider_count; index++) + { + if (coop_enemies.riders[index].vehicle_index != vehicle_index) + coop_enemies.riders[kept++] = coop_enemies.riders[index]; + } + coop_enemies.rider_count = kept; + coop_enemies.vehicles[vehicle_number] = coop_enemies.vehicles[--coop_enemies.vehicle_count]; +} + +/* whether a vehicle's seated riders have begun to get out: one alive and +out of it (one killed in its seat is not getting out) */ +static boolean coop_enemies_riders_getting_out( + struct coop_riding_vehicle const *vehicle) +{ + short index; + + for (index = 0; index < vehicle->seated_count; index++) + { + struct object_datum *unit = object_try_and_get_and_verify_type(vehicle->seated_unit_indices[index], + _object_mask_unit); + + if (unit && !TEST_FLAG(unit->object.damage_flags, _object_dead_bit) && + unit->object.parent_object_index != vehicle->vehicle_index) + { + return TRUE; + } + } + return FALSE; +} + +/* where a vehicle's `number`th kept rider is placed: beside one of the +riders who got out of it (where it is now: out of the vehicle, in the +open), a ring further about them each round; else at a seat's marker, else +the vehicle's place */ +static void coop_enemies_rider_place( + struct coop_riding_vehicle const *vehicle, + short number, + real_point3d *position, + real *facing) +{ + struct object_datum *object = object_get(vehicle->vehicle_index); + struct unit_definition *definition = unit_definition_get(object->definition_index); + long out_unit_indices[MAXIMUM_SEATED_RIDERS]; + short out_count = 0; + short index, round; + real angle; + + *position = object->object.position; + *facing = (real)atan2(object->object.forward.j, object->object.forward.i); + for (index = 0; index < vehicle->seated_count; index++) + { + struct object_datum *unit = object_try_and_get_and_verify_type(vehicle->seated_unit_indices[index], + _object_mask_unit); + + if (unit && !TEST_FLAG(unit->object.damage_flags, _object_dead_bit) && + unit->object.parent_object_index != vehicle->vehicle_index) + { + out_unit_indices[out_count++] = vehicle->seated_unit_indices[index]; + } + } + if (out_count > 0) + { + *position = object_get(out_unit_indices[number % out_count])->object.position; + round = (short)(number / out_count); + } + else + { + struct object_marker marker; + + round = number; + if (vehicle->seated_count > 0 && VALID_INDEX(vehicle->seat_indices[0], definition->unit.seats.count)) + { + struct unit_seat *seat = TAG_BLOCK_GET_ELEMENT(&definition->unit.seats, vehicle->seat_indices[0], + struct unit_seat); + + if (seat->marker_name[0] && + object_get_marker_by_name(vehicle->vehicle_index, seat->marker_name, &marker, 1) > 0) + { + *position = marker.matrix.position; + } + } + } + /* (never on the rider's own place: a ring about it, further each round) */ + angle = (real)number * (_pi * 2.0f / SPREAD_PLACES_PER_RING); + position->x += (real)cos(angle) * RIDER_SPACING * (real)(round / SPREAD_PLACES_PER_RING + 1); + position->y += (real)sin(angle) * RIDER_SPACING * (real)(round / SPREAD_PLACES_PER_RING + 1); +} + +/* ---------- public code */ + +void coop_enemies_new_game( + void) +{ + csmemset(&coop_enemies, 0, sizeof(coop_enemies)); + coop_enemies.percent = (short)PIN(config_integer("network.coop_enemies"), 0, COOP_ENEMIES_MAXIMUM_PERCENT); +} + +void coop_enemies_reset( + void) +{ + coop_enemies.vehicle_count = 0; + coop_enemies.rider_count = 0; +} + +short coop_enemies_extra_count( + long encounter_index, + short count) +{ + struct encounter_datum *encounter; + long extra, room; + short players; + + if (count <= 0 || coop_enemies.percent <= 0 || !coop_enemies_host()) + return 0; + encounter = encounter_get(encounter_index); + if (!game_team_is_enemy(_game_team_player, encounter->team_index)) + return 0; + players = coop_enemies_player_count(); + if (players <= 1) + return 0; + extra = ((long)count * coop_enemies.percent * (players - 1) + 50) / 100; + room = MAXIMUM_ACTORS - COOP_ENEMIES_LEVEL_ACTORS - actor_data->actual_count; + return (short)PIN(extra, 0, MAX(room, 0)); +} + +boolean coop_enemies_spread_position( + real_point3d const *origin, + short number, + real_point3d *position) +{ + real_point3d from = *origin; + short place = (short)((number - 1) % SPREAD_PLACES_PER_RING); + short ring = (short)((number - 1) / SPREAD_PLACES_PER_RING); + short attempt; + + from.z += SPREAD_STEP_HEIGHT; + for (attempt = 0; attempt < SPREAD_ATTEMPTS; attempt++, ring++) + { + /* (each ring turned half a place from the last, so its places fall + between the last's) */ + real angle = ((real)place + 0.5f * (real)(ring % 2)) * (_pi * 2.0f / SPREAD_PLACES_PER_RING); + real radius = SPREAD_SPACING * (real)(ring + 1); + real_point3d to = from; + + to.x += (real)cos(angle) * radius; + to.y += (real)sin(angle) * radius; + if (coop_enemies_open(&from, &to) && coop_enemies_ground(&to, position)) + return TRUE; + } + return FALSE; +} + +void coop_enemies_rider_seated( + long vehicle_index, + long unit_index) +{ + struct coop_riding_vehicle *vehicle; + struct unit_datum *unit; + + if (coop_enemies.percent <= 0 || !coop_enemies_host()) + return; + vehicle = coop_enemies_riding_vehicle(vehicle_index, TRUE); + unit = unit_get(unit_index); + if (!vehicle) + return; + /* (a vehicle loaded again: the riders that got out of it before are not + this load's) */ + { + short index, kept = 0; + + for (index = 0; index < vehicle->seated_count; index++) + { + struct object_datum *seated = object_try_and_get_and_verify_type(vehicle->seated_unit_indices[index], + _object_mask_unit); + + if (seated && seated->object.parent_object_index == vehicle_index) + { + vehicle->seated_unit_indices[kept] = vehicle->seated_unit_indices[index]; + vehicle->seat_indices[kept++] = vehicle->seat_indices[index]; + } + } + vehicle->seated_count = kept; + } + if (vehicle->seated_count >= MAXIMUM_SEATED_RIDERS) + return; + vehicle->seated_unit_indices[vehicle->seated_count] = unit_index; + vehicle->seat_indices[vehicle->seated_count] = unit->unit.parent_seat_index; + vehicle->seated_count++; + /* (a vehicle loaded again, after its riders got out: they get out again) */ + vehicle->releasing = FALSE; +} + +boolean coop_enemies_rider_unseated( + long vehicle_index, + long unit_index) +{ + struct unit_datum *unit; + struct actor_datum *actor; + struct coop_kept_rider *rider; + struct coop_riding_vehicle *vehicle; + + if (coop_enemies.percent <= 0 || !coop_enemies_host() || coop_enemies.rider_count >= MAXIMUM_KEPT_RIDERS) + return FALSE; + unit = unit_get(unit_index); + vehicle = coop_enemies_riding_vehicle(vehicle_index, FALSE); + /* (kept only for a vehicle with riders seated, whose getting out places + it: one of a load that seated nobody is left where it is) */ + if (unit->object.type != _object_type_biped || unit->unit.actor_index == NONE || !vehicle || + vehicle->seated_count == 0) + { + return FALSE; + } + actor = actor_get(unit->unit.actor_index); + if (actor->meta.swarm || actor->meta.encounter_index == NONE || + !game_team_is_enemy(_game_team_player, encounter_get(actor->meta.encounter_index)->team_index)) + { + return FALSE; + } + rider = &coop_enemies.riders[coop_enemies.rider_count++]; + rider->vehicle_index = vehicle_index; + rider->variant_definition_index = actor->meta.variant_definition_index; + rider->encounter_index = actor->meta.encounter_index; + rider->squad_index = actor->meta.squad_index; + return TRUE; +} + +void coop_enemies_update( + void) +{ + long now = game_time_get(); + short vehicle_number; + + if (!coop_enemies_host()) + return; + for (vehicle_number = coop_enemies.vehicle_count - 1; vehicle_number >= 0; vehicle_number--) + { + struct coop_riding_vehicle *vehicle = &coop_enemies.vehicles[vehicle_number]; + struct object_datum *object = object_try_and_get_and_verify_type(vehicle->vehicle_index, _object_mask_unit); + short index; + + /* (the vehicle's next kept rider) */ + for (index = 0; index < coop_enemies.rider_count; index++) + { + if (coop_enemies.riders[index].vehicle_index == vehicle->vehicle_index) + break; + } + /* (a vehicle gone, or destroyed, takes its kept riders with it; one + with none kept, every one loaded seated or all placed, is done with) */ + if (!object || TEST_FLAG(object->object.damage_flags, _object_dead_bit) || index >= coop_enemies.rider_count) + { + coop_enemies_forget_vehicle(vehicle_number); + continue; + } + if (!vehicle->releasing) + { + if (!coop_enemies_riders_getting_out(vehicle)) + continue; + vehicle->releasing = TRUE; + vehicle->released_count = 0; + vehicle->next_release_time = now; + } + if (now < vehicle->next_release_time) + continue; + { + struct coop_kept_rider rider = coop_enemies.riders[index]; + struct encounter_definition *encounter_definition; + struct squad_definition *squad_definition; + struct actor_starting_location location; + + coop_enemies.riders[index] = coop_enemies.riders[--coop_enemies.rider_count]; + encounter_definition = TAG_BLOCK_GET_ELEMENT(&global_scenario_get()->ai_encounters, + DATUM_INDEX_TO_ABSOLUTE_INDEX(rider.encounter_index), struct encounter_definition); + squad_definition = TAG_BLOCK_GET_ELEMENT(&encounter_definition->squads, rider.squad_index, + struct squad_definition); + /* (as the squad's: its states, no command list of a starting + location's) */ + csmemset(&location, 0, sizeof(location)); + coop_enemies_rider_place(vehicle, vehicle->released_count++, &location.position, &location.facing); + location.cluster_index = NONE; + location.noncombat_sequence_id = NONE; + location.default_state = squad_definition->default_state; + location.initial_state = squad_definition->initial_state; + location.actor_variant_index = NONE; + location.command_list_index = NONE; + actor_place(rider.variant_definition_index, rider.encounter_index, rider.squad_index, &location, FALSE, 0); + encounter_update_status(rider.encounter_index); + } + vehicle->next_release_time = now + RIDER_RELEASE_TICKS; + } +} diff --git a/port/linux/game/coop_enemies.h b/port/linux/game/coop_enemies.h new file mode 100644 index 00000000..e9e138e1 --- /dev/null +++ b/port/linux/game/coop_enemies.h @@ -0,0 +1,48 @@ +/* +COOP_ENEMIES.H + +Network co-op's extra enemies, for its players (coop_enemies.c). +*/ + +#ifndef __COOP_ENEMIES_H +#define __COOP_ENEMIES_H + +/* network_coop.c: a game begins (its EXTRA ENEMIES read), or the host's +game state went back (a cutscene skipped): the dropships' riders kept are +forgotten */ +void coop_enemies_new_game( + void); +void coop_enemies_reset( + void); + +/* encounters.c: how many enemies more a squad placed with `count` gets +(none but on a co-op host, for an encounter of the players' enemies) */ +short coop_enemies_extra_count( + long encounter_index, + short count); + +/* encounters.c: the place of a squad's `number`th extra enemy (from 1) at +its starting location `origin`: rings about it, on open ground; FALSE if +none is (the starting location itself then) */ +boolean coop_enemies_spread_position( + real_point3d const *origin, + short number, + real_point3d *position); + +/* units.c's vehicle_scripting_load_magic: a rider seated, and one left +without a seat (the vehicle's seats taken: an extra enemy), which is kept, +off the map, until the vehicle's riders get out (TRUE: kept, the caller +erases it) */ +void coop_enemies_rider_seated( + long vehicle_index, + long unit_index); +boolean coop_enemies_rider_unseated( + long vehicle_index, + long unit_index); + +/* network_coop.c, after each of the host's ticks: the riders kept placed +at their vehicle's seats once its own riders get out */ +void coop_enemies_update( + void); + +#endif diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index a1e6f8a8..63b52d02 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -2576,9 +2576,10 @@ static char const *const server_settings_gametype_rows[] = /* the most players a co-op game hosted starts with (maximum_players') */ #define COOPERATIVE_DEFAULT_PLAYERS 16 -/* Server Setup's help for co-op's FRIENDLY FIRE, by its choice (its -help_strings, tools/port_settings.py) */ +/* Server Setup's help for co-op's FRIENDLY FIRE, by its choice, and its +EXTRA ENEMIES (its help_strings, tools/port_settings.py) */ #define COOPERATIVE_FRIENDLY_FIRE_HELP 12 +#define COOPERATIVE_EXTRA_ENEMIES_HELP 16 /* co-op's FRIENDLY FIRE's choices (network.coop_friendly_fire's values, port_settings.COOP_FRIENDLY_FIRE_VALUES, in this order) */ @@ -2707,6 +2708,15 @@ static void server_settings_update(struct widget_instance *list) if (help && list->focused_child == named(list, "op_friendly_fire", 0)) help->parameters.text_box.string_list_index = (short)(COOPERATIVE_FRIENDLY_FIRE_HELP + choice); } + /* ... and its EXTRA ENEMIES (network.coop_enemies, which the host + reads as the game begins: coop_enemies.c) */ + visible_set(named(list, "op_extra_enemies", 0), cooperative); + if (cooperative && list->parameters.list.extended_description && + list->focused_child == named(list, "op_extra_enemies", 0)) + { + list->parameters.list.extended_description->parameters.text_box.string_list_index = + COOPERATIVE_EXTRA_ENEMIES_HELP; + } } /* LISTING (an internet game's): PUBLIC, listed in everyone's server browser, or PRIVATE, for this game. Its help is its choice's */ diff --git a/port/linux/game/network_actors.c b/port/linux/game/network_actors.c index 9deaff5f..746a2ad5 100644 --- a/port/linux/game/network_actors.c +++ b/port/linux/game/network_actors.c @@ -50,9 +50,10 @@ an AI unit the same way it kills a player's. enum { - /* AI units tracked at once: every actor (actors.h allows 256) and the - units the cutscenes' recorded animations drive */ - MAXIMUM_NETWORK_ACTORS = 288, + /* AI units tracked at once: every actor (halo_port_capacity.h's + HALO_PORT_MAXIMUM_ACTORS) and the units the cutscenes' recorded + animations drive */ + MAXIMUM_NETWORK_ACTORS = HALO_PORT_MAXIMUM_ACTORS + 32, MAXIMUM_ENTRIES_PER_MESSAGE = 64, /* A client keeps applying a control for this long after the host last sent one. After that the unit is left alone: the actor let go of it, or diff --git a/port/linux/game/network_coop.c b/port/linux/game/network_coop.c index 7b3b47e8..a0125652 100644 --- a/port/linux/game/network_coop.c +++ b/port/linux/game/network_coop.c @@ -101,6 +101,7 @@ index and tag, since the map placed them at the same index everywhere. #include "sound/sound_manager.h" #include "sound/sound_definitions.h" #include "units/units.h" +#include "coop_enemies.h" #include "coop_spectate.h" #include "network_coop.h" #include "network_distributed.h" @@ -1805,6 +1806,7 @@ void network_coop_new_game( skip_vote.voters = 0; skip_vote.cooldown_until = 0; skip_vote.skip_save_written = FALSE; + coop_enemies_new_game(); } /* A network game on a campaign scenario with no game engine. Checking the @@ -2361,6 +2363,8 @@ void network_coop_skip_reverted( game_state_port_restamp_revert_time(); hs_runtime_port_shift_sleep_times(ticks); } + /* (the dropships' riders kept are of the game state reverted from) */ + coop_enemies_reset(); error(_error_silent, "co-op: cutscene skipped; reverted %ld ticks, clock kept at %ld", ticks, now); event_new(_coop_event_cutscene_skipped); skip_vote_clear(); @@ -2393,6 +2397,7 @@ void network_coop_host_tick( return; host_resend_update(); players_vitality_keep(); + coop_enemies_update(); host_count_skip_votes(); host_presentation(&message.presentation); send_to_clients(&message, _distributed_message_coop_presentation, 1, sizeof(message.presentation)); diff --git a/port/linux/include/halo_port_capacity.h b/port/linux/include/halo_port_capacity.h index 281793d5..91d11a25 100644 --- a/port/linux/include/halo_port_capacity.h +++ b/port/linux/include/halo_port_capacity.h @@ -24,15 +24,27 @@ state above the tag cache (which ends at 0x819A6000), inside the Xbox memory window (0x80000000-0x88000000, port/linux/src/platform.h) and below everything the window hands out top-down (texture and sound caches, Direct3D resources). -The CPU part holds about 13.6 MB of pools at the sizes below (the Xbox pools +The CPU part holds about 17.2 MB of pools at the sizes below (the Xbox pools fill 3,165,260 of its 0x305000 bytes); the GPU part holds only the decal -vertices, as on the Xbox. */ +vertices, as on the Xbox. A change to a pool's size changes the game state's +layout: saved games of builds before it no longer load. */ #define HALO_PORT_GAME_STATE_BASE_ADDRESS 0x81A00000 /* (0x80061000) */ -#define HALO_PORT_GAME_STATE_CPU_SIZE 0xFC0000 /* (0x305000) */ +#define HALO_PORT_GAME_STATE_CPU_SIZE 0x13C0000 /* (0x305000) */ #define HALO_PORT_GAME_STATE_GPU_SIZE 0x40000 /* (0x40000) */ #define HALO_PORT_GAME_STATE_SIZE (HALO_PORT_GAME_STATE_CPU_SIZE+HALO_PORT_GAME_STATE_GPU_SIZE) +/* ---------- AI + +Network co-op adds enemies for its players (port/linux/game/coop_enemies.c): +the actors, and their knowledge of the units about them (props: with many +players, more each), have room for several times a level's own. */ + +#define HALO_PORT_MAXIMUM_ACTORS 1024 /* (256) */ +#define HALO_PORT_MAXIMUM_PROPS 8192 /* (768) */ +#define HALO_PORT_MAXIMUM_SWARMS 128 /* (32) */ +#define HALO_PORT_MAXIMUM_SWARM_COMPONENTS 1024 /* (256) */ + /* ---------- objects */ #define HALO_PORT_MAXIMUM_OBJECTS_PER_MAP 8192 /* (2048) */ diff --git a/port/linux/include/halo_port_limits.h b/port/linux/include/halo_port_limits.h index 800430cc..06af391b 100644 --- a/port/linux/include/halo_port_limits.h +++ b/port/linux/include/halo_port_limits.h @@ -66,7 +66,7 @@ is newer (network_client_manager.c). A host advertises it, with its netcode, in its game's advertisement's reserved bytes (network_server_message_handler.c), which hosts built before there was a version send as zeros: version 0. Raise it with any change to what the machines send each other. */ -#define HALO_PORT_NETWORK_VERSION 12 +#define HALO_PORT_NETWORK_VERSION 13 /* ... the advertisement's reserved bytes: the version (a little-endian word), then flags */ #define HALO_PORT_ADVERTISED_VERSION_OFFSET 0 diff --git a/port/linux/src/port_config.c b/port/linux/src/port_config.c index 8dbfd944..0542d5c5 100644 --- a/port/linux/src/port_config.c +++ b/port/linux/src/port_config.c @@ -266,6 +266,11 @@ static const struct config_setting config_settings[] = "\"on\", \"shields_only\" or \"explosives_only\" (Server Setup's FRIENDLY\n" "FIRE in co-op, which writes its choice here). Their AI allies they\n" "always can, as in the campaign." }, + { "network.coop_enemies", _config_integer, "50", "HALO_NET_COOP_ENEMIES", _environment_value, _platform_all, + "Online co-op's extra enemies, a percentage: for each player past the\n" + "first, each squad of enemies a level places gets this much of itself\n" + "more (100: as many again; 0 to 200). Server Setup's EXTRA ENEMIES in\n" + "co-op writes its choice here." }, { "network.brokers_file", _config_string, "\"brokers.txt\"", "HALO_NET_BROKERS_FILE", _environment_value, _platform_all, "The file of the public MQTT brokers through which the machines of an\n" diff --git a/source/ai/actors.h b/source/ai/actors.h index da8a1eff..f8d53af3 100644 --- a/source/ai/actors.h +++ b/source/ai/actors.h @@ -25,9 +25,10 @@ enum enum { - MAXIMUM_ACTORS = 256, - MAXIMUM_SWARMS = 32, - MAXIMUM_SWARM_COMPONENTS = 256, + /* port: the native builds' larger pools (halo_port_capacity.h) */ + MAXIMUM_ACTORS = HALO_PORT_MAXIMUM_ACTORS, + MAXIMUM_SWARMS = HALO_PORT_MAXIMUM_SWARMS, + MAXIMUM_SWARM_COMPONENTS = HALO_PORT_MAXIMUM_SWARM_COMPONENTS, SWARM_COMPONENT_DATUM_SIZE = 0x40, }; diff --git a/source/ai/ai_profile.c b/source/ai/ai_profile.c index 2c9876a4..fd67d200 100644 --- a/source/ai/ai_profile.c +++ b/source/ai/ai_profile.c @@ -298,7 +298,7 @@ void ai_profile_display( ai_profile.meters[_ai_meter_units_updated].current_value, ai_profile.meters[_ai_meter_units].current_value, ai_profile.meters[_ai_meter_props].current_value, - 768); + HALO_PORT_MAXIMUM_PROPS); return; } @@ -450,7 +450,7 @@ static void ai_profile_render_encounters( ai_profile.meters[_ai_meter_encounters_updated].current_value, ai_profile.meters[_ai_meter_encounters].current_value, ai_profile.meters[_ai_meter_props].current_value, - 768); + HALO_PORT_MAXIMUM_PROPS); ai_profile_draw_string(profilestring, NUMBEROF(tab_stops), tab_stops, global_real_argb_white); return; } diff --git a/source/ai/encounters.c b/source/ai/encounters.c index e41f47e0..83a3d7f0 100644 --- a/source/ai/encounters.c +++ b/source/ai/encounters.c @@ -311,6 +311,7 @@ symbols in this file: #include "scenario/scenario_definitions.h" #include "structures/structure_bsp_definitions.h" #include "units/units.h" +#include "coop_enemies.h" /* port: port/linux/game/coop_enemies.c */ #include @@ -540,7 +541,8 @@ static boolean encounter_place_actor( long encounter_index, short squad_index, short initial_variant, - boolean spawning); + boolean spawning, + short extra_number); static void encounterless_deactivate( long actor_index); static void encounters_test_activation( @@ -1836,7 +1838,7 @@ boolean encounter_spawn_actor( { if (ai_globals->ai_initialized_for_map) { - if (encounter_place_actor(encounter_index, squad_index, 0, TRUE)) + if (encounter_place_actor(encounter_index, squad_index, 0, TRUE, 0)) { struct encounter_datum *encounter = encounter_get(encounter_index); struct encounter_definition *encounter_definition = TAG_BLOCK_GET_ELEMENT( @@ -2245,9 +2247,17 @@ void encounter_create( for (i = 0; i < count; ++i) { - encounter_place_actor(encounter_index, squad_index, initial_variant, FALSE); + encounter_place_actor(encounter_index, squad_index, initial_variant, FALSE, 0); initial_variant = 0; } + /* port: network co-op's extra enemies, for its players + (coop_enemies.c) */ + { + short extra_count = coop_enemies_extra_count(encounter_index, count); + + for (i = 0; i < extra_count; ++i) + encounter_place_actor(encounter_index, squad_index, 0, FALSE, (short)(i + 1)); + } } encounter_update_status(encounter_index); @@ -2996,18 +3006,25 @@ static void encounter_post_combat( return; } +/* port: extra_number, from 1, places one of network co-op's extra enemies +(coop_enemies.c): at the squad's starting locations in turn, each the +place a ring about its own (0: the squad's own actor, as the Xbox's) */ static boolean encounter_place_actor( long encounter_index, short squad_index, short initial_variant, - boolean spawning) + boolean spawning, + short extra_number) { boolean placed = FALSE; struct encounter_definition *encounter_definition = TAG_BLOCK_GET_ELEMENT( &global_scenario_get()->ai_encounters, DATUM_INDEX_TO_ABSOLUTE_INDEX(encounter_index), struct encounter_definition); struct squad_definition *squad_definition = TAG_BLOCK_GET_ELEMENT( &encounter_definition->squads, squad_index, struct squad_definition); - short starting_location_index = encounter_get_actor_starting_location(encounter_index, squad_index, spawning); + short starting_location_index = extra_number > 0 && squad_definition->starting_locations.count > 0 ? + (short)((extra_number - 1) % squad_definition->starting_locations.count) : + encounter_get_actor_starting_location(encounter_index, squad_index, spawning); + struct actor_starting_location spread_location; if (starting_location_index != NONE) { @@ -3016,6 +3033,18 @@ static boolean encounter_place_actor( short actor_palette_index = squad_definition->actor_palette_index; struct scenario *scenario = global_scenario_get(); + /* port: an extra enemy a ring about the starting location */ + if (extra_number > 0) + { + spread_location = *starting_location; + if (coop_enemies_spread_position(&starting_location->position, + (short)((extra_number - 1) / squad_definition->starting_locations.count + 1), + &spread_location.position)) + { + starting_location = &spread_location; + } + } + if (starting_location->actor_variant_index != NONE) actor_palette_index = starting_location->actor_variant_index; diff --git a/source/ai/props.c b/source/ai/props.c index 933e937e..84334d76 100644 --- a/source/ai/props.c +++ b/source/ai/props.c @@ -95,7 +95,8 @@ static long last_prop_data_full_warn_time = NONE; void props_initialize( void) { - prop_data = game_state_data_new("prop", 768, sizeof(struct prop_datum)); + /* port: the native builds' larger pool (halo_port_capacity.h) */ + prop_data = game_state_data_new("prop", HALO_PORT_MAXIMUM_PROPS, sizeof(struct prop_datum)); match_assert("c:\\halo\\SOURCE\\ai\\props.c", 0x24, prop_data); @@ -141,7 +142,7 @@ static void prop_add( error( _error_silent, "AI knowledge database (%d entries) is full (warns once every 30 sec)", - 768); + HALO_PORT_MAXIMUM_PROPS); last_prop_data_full_warn_time = game_time; } } diff --git a/source/units/units.c b/source/units/units.c index 9523055a..2a0864d6 100644 --- a/source/units/units.c +++ b/source/units/units.c @@ -692,6 +692,7 @@ symbols in this file: #include "sound/game_sound.h" #include "vehicles.h" #include "network_coop.h" /* port: port/linux/game/network_coop.c */ +#include "coop_enemies.h" /* port: port/linux/game/coop_enemies.c */ /* port: the control and animation impulses the host's actors give their units go to the clients' copies (port/linux/game/network_actors.c) */ @@ -5183,6 +5184,11 @@ short vehicle_scripting_load_magic( short available_seat_count; long reference_index; long unit_index; + /* port: the riders left without a seat that network co-op keeps + (coop_enemies.c), erased once the list is gone through */ + long unseated_actor_indices[64]; + short unseated_count = 0; + short unseated_number; available_seat_count = vehicle_scripting_find_available_seats( vehicle_index, @@ -5201,6 +5207,7 @@ short vehicle_scripting_load_magic( { struct unit_datum *unit = (struct unit_datum *)object; short available_seat_index; + long loaded_before = loaded_count; for (available_seat_index = 0; available_seat_index loaded_before) + coop_enemies_rider_seated(vehicle_index, unit_index); + else if (unit->object.parent_object_index == NONE && unit->unit.actor_index != NONE && + unseated_count < (short)NUMBEROF(unseated_actor_indices) && + coop_enemies_rider_unseated(vehicle_index, unit_index)) + { + unseated_actor_indices[unseated_count++] = unit->unit.actor_index; + } } unit_index = object_list_get_next(object_list_index, &reference_index); } + for (unseated_number = 0; unseated_number < unseated_count; unseated_number++) + actor_erase(unseated_actor_indices[unseated_number], FALSE); } return (short)loaded_count; diff --git a/tools/port_settings.py b/tools/port_settings.py index 4abee640..d3277d2a 100644 --- a/tools/port_settings.py +++ b/tools/port_settings.py @@ -681,6 +681,29 @@ def _server_settings() -> list: ['', '', '']) rows.append((f"{base}/op_friendly_fire", None, 4)) + # ... and its EXTRA ENEMIES (port/linux/game/coop_enemies.c), below it; + # its choice kept in network.coop_enemies + extra += _widget(f"{base}/op_extra_enemies", [("width", 512), ("height", 28), + ("flags", "pass_unhandled_to_focused_child"), + ("bitmap", "bitmaps/option_bkds"), ("color", "#FF2896FF")], + [f'', + f'']) + extra += _widget(f"{base}/extra_enemies_label", [("type", "text"), ("controller", 1), ("width", 300), + ("height", 22), ("string_list", f"{base}/labels"), + ("string_index", 11), ("font", "ui\\large_ui"), + ("color", "#FF2896FF"), ("text_x", 13), ("text_y", 4)], []) + extra += _widget(f"{base}/extra_enemies_spinner", + [("type", "spinner"), ("top", 2), ("width", 206), ("height", 20), + ("flags", "pass_unhandled_to_focused_child left_right_tabs_items"), + ("strings", "|".join(f"{value}%" for value in COOP_ENEMIES_VALUES)), + ("setting", "network.coop_enemies"), + ("values", "|".join(str(value) for value in COOP_ENEMIES_VALUES)), ("font", "ui\\large_ui"), + ("color", "#FF2896FF"), ("align", "center"), ("text_y", 4), + ("header_bitmap", "bitmaps/arrow_sm_left"), ("footer_bitmap", "bitmaps/arrow_sm_right"), + ("header_bounds", "7 -13 19 -7"), ("footer_bounds", "7 208 19 214")], + ['', '', + '']) + rows.append((f"{base}/op_extra_enemies", None, 5)) # the gametype's options for this game (the gametype editor's screens, # editing a copy of the gametype chosen: "port setup edit") for index, (key, screen) in enumerate(SETUP_OPTION_SCREENS): @@ -710,7 +733,7 @@ def _server_settings() -> list: '']) extra += _strings(f"{base}/labels", ["GAME NAME:", "MAXIMUM PLAYERS:", "INVITE LINK:", "GAME TYPE:", "PLAYER OPTIONS:", "ITEM OPTIONS:", "VEHICLE OPTIONS:", "INDICATOR OPTIONS:", - "TEAMPLAY OPTIONS:", "LISTING:", "FRIENDLY FIRE:"]) + "TEAMPLAY OPTIONS:", "LISTING:", "FRIENDLY FIRE:", "EXTRA ENEMIES:"]) extra += _strings(f"{base}/help_strings", [ "", "The name the game shows in the lists of games.\\nEnter changes it.", @@ -730,6 +753,8 @@ def _server_settings() -> list: "Players can be hurt by weapons or explosives\\nfired by the other players.", "Damage from the other players will only reduce\\nshields. Health will be unaffected.", "Players can be hurt by damage from explosives\\nfired by the other players.", + # (EXTRA ENEMIES') + "For each player past the first, enemy squads get\\nthis much more of themselves (100%: as many again).", ]) lines = _screen(base, spec, rows, ["server settings update"], [''], extra) @@ -745,6 +770,8 @@ MAXIMUM_PLAYERS = [2, 4, 8, 12, 16, 24, 32, 48, 64, 96, 128] # network.coop_friendly_fire's values, as Server Setup's FRIENDLY FIRE shows # them (menu_functions.c's cooperative_friendly_fire_modes, in this order) COOP_FRIENDLY_FIRE_VALUES = ["off", "on", "shields_only", "explosives_only"] +# network.coop_enemies' values, percentages (Server Setup's EXTRA ENEMIES) +COOP_ENEMIES_VALUES = [0, 50, 100, 150, 200] # Server Setup's rows of the gametype's options: the gametype editor's # screens SETUP_OPTION_SCREENS = [ From 16ad4cc900e64615e9bf973b4419b0a33944dc39 Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:45:31 +1300 Subject: [PATCH 37/51] 25% extra enemies option --- .../ce/main_menu.multiplayer_type_select.server_settings.xml | 2 +- tools/port_settings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml b/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml index e3592c2e..e76e1eea 100644 --- a/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml +++ b/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml @@ -93,7 +93,7 @@ - + diff --git a/tools/port_settings.py b/tools/port_settings.py index d3277d2a..8a483aa5 100644 --- a/tools/port_settings.py +++ b/tools/port_settings.py @@ -771,7 +771,7 @@ MAXIMUM_PLAYERS = [2, 4, 8, 12, 16, 24, 32, 48, 64, 96, 128] # them (menu_functions.c's cooperative_friendly_fire_modes, in this order) COOP_FRIENDLY_FIRE_VALUES = ["off", "on", "shields_only", "explosives_only"] # network.coop_enemies' values, percentages (Server Setup's EXTRA ENEMIES) -COOP_ENEMIES_VALUES = [0, 50, 100, 150, 200] +COOP_ENEMIES_VALUES = [0, 25, 50, 100, 150, 200] # Server Setup's rows of the gametype's options: the gametype editor's # screens SETUP_OPTION_SCREENS = [ From f0804aa587b7ffbdcf9793ceae9b558706b3c924 Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:08:26 +1300 Subject: [PATCH 38/51] Extra enemies: per player or static multiplier --- ...ultiplayer_type_select.server_settings.xml | 32 +++++++- port/linux/NETCODE.md | 17 +++-- port/linux/README.md | 4 +- port/linux/game/coop_enemies.c | 73 ++++++++++++++---- port/linux/game/menu_functions.c | 47 +++++++++--- port/linux/src/port_config.c | 20 ++++- tools/port_settings.py | 75 ++++++++++++------- 7 files changed, 203 insertions(+), 65 deletions(-) diff --git a/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml b/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml index e76e1eea..456615fb 100644 --- a/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml +++ b/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml @@ -20,6 +20,8 @@ + + @@ -93,7 +95,29 @@ - + + + + + + + + + + + + + + + + + + + + + + + @@ -185,6 +209,8 @@ + + @@ -203,6 +229,10 @@ + + + + diff --git a/port/linux/NETCODE.md b/port/linux/NETCODE.md index a5c083f9..806e3c8d 100644 --- a/port/linux/NETCODE.md +++ b/port/linux/NETCODE.md @@ -54,14 +54,15 @@ with ideas from VALORANT's netcode articles, keeping the 30 Hz tick: threads' wake times along with it. The object, device and name syncs bring the clients up to date. - The host's EXTRA ENEMIES (`coop_enemies.c`, `network.coop_enemies`) - give each squad of enemies a level places that percentage of itself more - for each player past the first, in rings about its starting locations - where the ground is open, and never past the actors a level needs for its - own (the actor pool, `halo_port_capacity.h`, holds 1024). Riders a - dropship has no seats for are kept, and placed beside its riders once - they get out. Only the host runs the AI, so the clients see them as the - host's other actors. + The host's EXTRA ENEMIES (`coop_enemies.c`, `network.coop_enemies_mode`) + give each squad of enemies a level places more of itself: PER PLAYER, a + percentage of itself for each player past the first; STATIC MULTIPLIER, + that many times itself for any number of players. They stand in rings + about its starting locations where the ground is open, and never take the + actors a level needs for its own (the actor pool, `halo_port_capacity.h`, + holds 1024). Riders a dropship has no seats for are kept, and placed + beside its riders once they get out. Only the host runs the AI, so the + clients see them as the host's other actors. - **Host authoritative.** The host alone decides damage, deaths, spawns, pickups, scores and the game's objects; clients do not decide them but apply what the host sends. diff --git a/port/linux/README.md b/port/linux/README.md index ebbedd90..bfc82b4b 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -261,7 +261,9 @@ the setting for one start of the game. It has priority over the file. | `network.public_lobby` | `true` | `HALO_NET_PUBLIC_LOBBY` | `true`: the server browser. Public games are listed, and Join Game > Server Browser shows them. `false`: no games are listed or shown. Refer to "Server browser". | | `network.host_public` | `true` | `HALO_NET_HOST_PUBLIC` | `true`: a new game of Create Game > Internet starts as PUBLIC. `false`: it starts as PRIVATE. LISTING in Server Setup changes it for each game. Refer to "Server browser". | | `network.coop_friendly_fire` | `"on"` | `HALO_NET_COOP_FRIENDLY_FIRE` | Whether the players of an online co-op game hurt each other: `"off"`, `"on"`, `"shields_only"` or `"explosives_only"`. FRIENDLY FIRE in co-op's Server Setup writes its choice here. Their AI allies they always can, as in the campaign. | -| `network.coop_enemies` | `50` | `HALO_NET_COOP_ENEMIES` | Online co-op's extra enemies, a percentage from `0` to `200`: for each player past the first, each squad of enemies that a level places gets this much of itself more (`100`: as many again, so four players meet four times the squad). The extra enemies stand around the squad's places, and those that a dropship has no seats for drop out of it after its passengers. EXTRA ENEMIES in co-op's Server Setup writes its choice here. | +| `network.coop_enemies_mode` | `"per_player"` | `HALO_NET_COOP_ENEMIES_MODE` | Online co-op's extra enemies: `"none"`; `"per_player"`, each squad of enemies that a level places grows by `network.coop_enemies` for each player past the first; or `"multiplier"`, each squad is `network.coop_enemies_multiplier` times as large, for any number of players. The extra enemies stand around the squad's places, and those that a dropship has no seats for drop out of it after its passengers. EXTRA ENEMIES in co-op's Server Setup writes its choice here. | +| `network.coop_enemies` | `50` | `HALO_NET_COOP_ENEMIES` | The extra enemies per player, a percentage from `25` to `200`: for each player past the first, each squad of enemies gets this much of itself more (`100`: as many again, so four players meet four times the squad). PER PLAYER in co-op's Server Setup writes its choice here. | +| `network.coop_enemies_multiplier` | `2` | `HALO_NET_COOP_ENEMIES_MULTIPLIER` | The static multiplier of the enemies, `2` to `32`: each squad of enemies is this many times as large. MULTIPLIER in co-op's Server Setup writes its choice here. | | `network.coop_public` | `false` | `HALO_NET_COOP_PUBLIC` | `true`: an online co-op game (Create Game > Internet, a SINGLEPLAYER map) starts as PUBLIC. `false`: it starts as PRIVATE. LISTING in co-op's Server Setup writes its choice here. Refer to "Server browser". | | `network.brokers_file` | `"brokers.txt"` | `HALO_NET_BROKERS_FILE` | The file of the public MQTT brokers that let the machines of an invite find each other, and that carry the listings of the server browser: next to `config.toml`, unless a full path. One `host:port` on each line, up to 4; `#` starts a comment. | | `network.stun_servers` | Google and Cloudflare | `HALO_NET_STUN` | The public STUN servers (`host:port`, with commas between them) that give the internet address of a machine. | diff --git a/port/linux/game/coop_enemies.c b/port/linux/game/coop_enemies.c index 4feb53ee..ade5c80f 100644 --- a/port/linux/game/coop_enemies.c +++ b/port/linux/game/coop_enemies.c @@ -2,12 +2,14 @@ COOP_ENEMIES.C Network co-op's extra enemies: Server Setup's EXTRA ENEMIES -(network.coop_enemies, a percentage). For each player past the first, each -squad of the players' enemies that a level places (encounters.c's -encounter_create) gets that much of its count more: at 100%, two players -meet twice the squad, four players four times it. The extra enemies stand -in rings about the squad's starting locations, on open ground, so they do -not start inside each other. +(network.coop_enemies_mode). PER PLAYER (network.coop_enemies, a +percentage): for each player past the first, each squad of the players' +enemies that a level places (encounters.c's encounter_create) gets that +much of its count more; at 100%, two players meet twice the squad, four +players four times it. STATIC MULTIPLIER (network.coop_enemies_multiplier): +each squad is that many times as large, for any number of players. The +extra enemies stand in rings about the squad's starting locations, on +open ground, so they do not start inside each other. A squad a script loads into a dropship (vehicle_load_magic) can be larger than the dropship's seats. The riders left without a seat are erased, and @@ -39,9 +41,11 @@ Only the host runs the AI, so all of this is the host's. #include "network_coop.h" #include +#include /* port_config.c's */ long config_integer(char const *name); +char const *config_string(char const *name); /* ---------- constants */ @@ -50,8 +54,12 @@ enum /* the actors a level keeps for its own placements, which the extra enemies never take: as many as the Xbox's whole pool */ COOP_ENEMIES_LEVEL_ACTORS = 256, - /* EXTRA ENEMIES' greatest (Server Setup's choices: 0 to 200%) */ + /* EXTRA ENEMIES' amounts, as Server Setup has them: 25 to 200% per + player, or 2 to 32 times (none is its NONE) */ + COOP_ENEMIES_MINIMUM_PERCENT = 25, COOP_ENEMIES_MAXIMUM_PERCENT = 200, + COOP_ENEMIES_MINIMUM_MULTIPLIER = 2, + COOP_ENEMIES_MAXIMUM_MULTIPLIER = 32, /* the places tried for an extra enemy, a ring further out each */ SPREAD_ATTEMPTS = 8, SPREAD_PLACES_PER_RING = 6, @@ -71,6 +79,14 @@ looked along, and the ground looked for below it */ /* a kept rider's place beside a rider who got out, each a little apart */ #define RIDER_SPACING 0.5f +/* EXTRA ENEMIES' choices (network.coop_enemies_mode's values) */ +enum +{ + _coop_enemies_none, + _coop_enemies_per_player, + _coop_enemies_multiplier, +}; + /* ---------- structures */ /* a vehicle a script loaded riders into: its seated ones, and whether they @@ -99,8 +115,11 @@ struct coop_kept_rider static struct { - /* EXTRA ENEMIES, as the game began with it */ + /* EXTRA ENEMIES, as the game began with it: its choice, and the amount + of PER PLAYER (a percentage) and of STATIC MULTIPLIER */ + short mode; short percent; + short multiplier; struct coop_riding_vehicle vehicles[MAXIMUM_RIDING_VEHICLES]; short vehicle_count; struct coop_kept_rider riders[MAXIMUM_KEPT_RIDERS]; @@ -115,6 +134,14 @@ static boolean coop_enemies_host( return game_connection() == _game_connection_network_server && network_coop_active(); } +/* whether this game has extra enemies (it is a co-op host's, with some +chosen) */ +static boolean coop_enemies_on( + void) +{ + return coop_enemies.mode != _coop_enemies_none && coop_enemies_host(); +} + /* the players in the game, every machine's: the network game's (a level places its first enemies before the players' units are made, as it loads: game_initialize_for_new_map) */ @@ -283,8 +310,17 @@ static void coop_enemies_rider_place( void coop_enemies_new_game( void) { + char const *mode = config_string("network.coop_enemies_mode"); + csmemset(&coop_enemies, 0, sizeof(coop_enemies)); - coop_enemies.percent = (short)PIN(config_integer("network.coop_enemies"), 0, COOP_ENEMIES_MAXIMUM_PERCENT); + /* (an amount past Server Setup's, as it shows it: its nearest choice's + end) */ + coop_enemies.percent = (short)PIN(config_integer("network.coop_enemies"), COOP_ENEMIES_MINIMUM_PERCENT, + COOP_ENEMIES_MAXIMUM_PERCENT); + coop_enemies.multiplier = (short)PIN(config_integer("network.coop_enemies_multiplier"), + COOP_ENEMIES_MINIMUM_MULTIPLIER, COOP_ENEMIES_MAXIMUM_MULTIPLIER); + coop_enemies.mode = !strcmp(mode, "per_player") ? _coop_enemies_per_player : + !strcmp(mode, "multiplier") ? _coop_enemies_multiplier : _coop_enemies_none; } void coop_enemies_reset( @@ -302,15 +338,20 @@ short coop_enemies_extra_count( long extra, room; short players; - if (count <= 0 || coop_enemies.percent <= 0 || !coop_enemies_host()) + if (count <= 0 || !coop_enemies_on()) return 0; encounter = encounter_get(encounter_index); if (!game_team_is_enemy(_game_team_player, encounter->team_index)) return 0; - players = coop_enemies_player_count(); - if (players <= 1) - return 0; - extra = ((long)count * coop_enemies.percent * (players - 1) + 50) / 100; + if (coop_enemies.mode == _coop_enemies_multiplier) + extra = (long)count * (coop_enemies.multiplier - 1); + else + { + players = coop_enemies_player_count(); + if (players <= 1) + return 0; + extra = ((long)count * coop_enemies.percent * (players - 1) + 50) / 100; + } room = MAXIMUM_ACTORS - COOP_ENEMIES_LEVEL_ACTORS - actor_data->actual_count; return (short)PIN(extra, 0, MAX(room, 0)); } @@ -349,7 +390,7 @@ void coop_enemies_rider_seated( struct coop_riding_vehicle *vehicle; struct unit_datum *unit; - if (coop_enemies.percent <= 0 || !coop_enemies_host()) + if (!coop_enemies_on()) return; vehicle = coop_enemies_riding_vehicle(vehicle_index, TRUE); unit = unit_get(unit_index); @@ -391,7 +432,7 @@ boolean coop_enemies_rider_unseated( struct coop_kept_rider *rider; struct coop_riding_vehicle *vehicle; - if (coop_enemies.percent <= 0 || !coop_enemies_host() || coop_enemies.rider_count >= MAXIMUM_KEPT_RIDERS) + if (!coop_enemies_on() || coop_enemies.rider_count >= MAXIMUM_KEPT_RIDERS) return FALSE; unit = unit_get(unit_index); vehicle = coop_enemies_riding_vehicle(vehicle_index, FALSE); diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index 63b52d02..7d098faa 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -2576,10 +2576,23 @@ static char const *const server_settings_gametype_rows[] = /* the most players a co-op game hosted starts with (maximum_players') */ #define COOPERATIVE_DEFAULT_PLAYERS 16 -/* Server Setup's help for co-op's FRIENDLY FIRE, by its choice, and its -EXTRA ENEMIES (its help_strings, tools/port_settings.py) */ +/* Server Setup's help for co-op's FRIENDLY FIRE and EXTRA ENEMIES, by +their choices, and EXTRA ENEMIES' PER PLAYER and MULTIPLIER (its +help_strings, tools/port_settings.py) */ #define COOPERATIVE_FRIENDLY_FIRE_HELP 12 #define COOPERATIVE_EXTRA_ENEMIES_HELP 16 +#define COOPERATIVE_ENEMIES_PER_PLAYER_HELP 19 +#define COOPERATIVE_ENEMIES_MULTIPLIER_HELP 20 + +/* co-op's EXTRA ENEMIES' choices (port_settings.COOP_ENEMIES_MODES, in this +order): its amount's row is the choice's */ +enum +{ + _cooperative_enemies_none, + _cooperative_enemies_per_player, + _cooperative_enemies_multiplier, + NUMBER_OF_COOPERATIVE_ENEMIES_MODES +}; /* co-op's FRIENDLY FIRE's choices (network.coop_friendly_fire's values, port_settings.COOP_FRIENDLY_FIRE_VALUES, in this order) */ @@ -2708,14 +2721,30 @@ static void server_settings_update(struct widget_instance *list) if (help && list->focused_child == named(list, "op_friendly_fire", 0)) help->parameters.text_box.string_list_index = (short)(COOPERATIVE_FRIENDLY_FIRE_HELP + choice); } - /* ... and its EXTRA ENEMIES (network.coop_enemies, which the host - reads as the game begins: coop_enemies.c) */ - visible_set(named(list, "op_extra_enemies", 0), cooperative); - if (cooperative && list->parameters.list.extended_description && - list->focused_child == named(list, "op_extra_enemies", 0)) + /* ... and its EXTRA ENEMIES, with the row of the amount of the one + chosen below it (network.coop_enemies_mode and its amounts, which the + host reads as the game begins: coop_enemies.c) */ { - list->parameters.list.extended_description->parameters.text_box.string_list_index = - COOPERATIVE_EXTRA_ENEMIES_HELP; + struct widget_instance *help = list->parameters.list.extended_description; + short mode = _cooperative_enemies_none; + + if ((spinner = named(list, "extra_enemies_spinner", 0)) != NULL) + { + mode = (short)PIN(spinner->parameters.list.selected_index, 0, + NUMBER_OF_COOPERATIVE_ENEMIES_MODES - 1); + } + visible_set(named(list, "op_extra_enemies", 0), cooperative); + visible_set(named(list, "op_enemies_per_player", 0), cooperative && mode == _cooperative_enemies_per_player); + visible_set(named(list, "op_enemies_multiplier", 0), cooperative && mode == _cooperative_enemies_multiplier); + if (cooperative && help) + { + if (list->focused_child == named(list, "op_extra_enemies", 0)) + help->parameters.text_box.string_list_index = (short)(COOPERATIVE_EXTRA_ENEMIES_HELP + mode); + else if (list->focused_child == named(list, "op_enemies_per_player", 0)) + help->parameters.text_box.string_list_index = COOPERATIVE_ENEMIES_PER_PLAYER_HELP; + else if (list->focused_child == named(list, "op_enemies_multiplier", 0)) + help->parameters.text_box.string_list_index = COOPERATIVE_ENEMIES_MULTIPLIER_HELP; + } } } /* LISTING (an internet game's): PUBLIC, listed in everyone's server diff --git a/port/linux/src/port_config.c b/port/linux/src/port_config.c index 0542d5c5..c7554eac 100644 --- a/port/linux/src/port_config.c +++ b/port/linux/src/port_config.c @@ -266,11 +266,23 @@ static const struct config_setting config_settings[] = "\"on\", \"shields_only\" or \"explosives_only\" (Server Setup's FRIENDLY\n" "FIRE in co-op, which writes its choice here). Their AI allies they\n" "always can, as in the campaign." }, + { "network.coop_enemies_mode", _config_string, "\"per_player\"", "HALO_NET_COOP_ENEMIES_MODE", _environment_value, + _platform_all, + "Online co-op's extra enemies: \"none\", \"per_player\" (each squad of\n" + "enemies grows by coop_enemies for each player past the first) or\n" + "\"multiplier\" (each is coop_enemies_multiplier times as large, for any\n" + "number of players). Server Setup's EXTRA ENEMIES in co-op writes its\n" + "choice here." }, { "network.coop_enemies", _config_integer, "50", "HALO_NET_COOP_ENEMIES", _environment_value, _platform_all, - "Online co-op's extra enemies, a percentage: for each player past the\n" - "first, each squad of enemies a level places gets this much of itself\n" - "more (100: as many again; 0 to 200). Server Setup's EXTRA ENEMIES in\n" - "co-op writes its choice here." }, + "Online co-op's extra enemies per player, a percentage: for each player\n" + "past the first, each squad of enemies a level places gets this much of\n" + "itself more (100: as many again; 25 to 200). Server Setup's PER PLAYER\n" + "in co-op writes its choice here." }, + { "network.coop_enemies_multiplier", _config_integer, "2", "HALO_NET_COOP_ENEMIES_MULTIPLIER", _environment_value, + _platform_all, + "Online co-op's static multiplier of its enemies: each squad of enemies\n" + "a level places is this many times as large (2 to 32). Server Setup's\n" + "MULTIPLIER in co-op writes its choice here." }, { "network.brokers_file", _config_string, "\"brokers.txt\"", "HALO_NET_BROKERS_FILE", _environment_value, _platform_all, "The file of the public MQTT brokers through which the machines of an\n" diff --git a/tools/port_settings.py b/tools/port_settings.py index 8a483aa5..c52e7089 100644 --- a/tools/port_settings.py +++ b/tools/port_settings.py @@ -681,29 +681,42 @@ def _server_settings() -> list: ['', '', '']) rows.append((f"{base}/op_friendly_fire", None, 4)) - # ... and its EXTRA ENEMIES (port/linux/game/coop_enemies.c), below it; - # its choice kept in network.coop_enemies - extra += _widget(f"{base}/op_extra_enemies", [("width", 512), ("height", 28), - ("flags", "pass_unhandled_to_focused_child"), - ("bitmap", "bitmaps/option_bkds"), ("color", "#FF2896FF")], - [f'', - f'']) - extra += _widget(f"{base}/extra_enemies_label", [("type", "text"), ("controller", 1), ("width", 300), - ("height", 22), ("string_list", f"{base}/labels"), - ("string_index", 11), ("font", "ui\\large_ui"), - ("color", "#FF2896FF"), ("text_x", 13), ("text_y", 4)], []) - extra += _widget(f"{base}/extra_enemies_spinner", - [("type", "spinner"), ("top", 2), ("width", 206), ("height", 20), - ("flags", "pass_unhandled_to_focused_child left_right_tabs_items"), - ("strings", "|".join(f"{value}%" for value in COOP_ENEMIES_VALUES)), - ("setting", "network.coop_enemies"), - ("values", "|".join(str(value) for value in COOP_ENEMIES_VALUES)), ("font", "ui\\large_ui"), - ("color", "#FF2896FF"), ("align", "center"), ("text_y", 4), - ("header_bitmap", "bitmaps/arrow_sm_left"), ("footer_bitmap", "bitmaps/arrow_sm_right"), - ("header_bounds", "7 -13 19 -7"), ("footer_bounds", "7 208 19 214")], - ['', '', - '']) + # ... and its EXTRA ENEMIES (port/linux/game/coop_enemies.c), below it: + # NONE, PER PLAYER or STATIC MULTIPLIER, and below that the amount of the + # one chosen (its own row, the other hidden: menu_functions.c's + # server_settings_update); each kept in its network.coop_enemies setting + def spinner_row(key, label_index, strings, setting, values): + nonlocal extra + extra += _widget(f"{base}/op_{key}", [("width", 512), ("height", 28), + ("flags", "pass_unhandled_to_focused_child"), + ("bitmap", "bitmaps/option_bkds"), ("color", "#FF2896FF")], + [f'', + f'']) + extra += _widget(f"{base}/{key}_label", [("type", "text"), ("controller", 1), ("width", 300), + ("height", 22), ("string_list", f"{base}/labels"), + ("string_index", label_index), ("font", "ui\\large_ui"), + ("color", "#FF2896FF"), ("text_x", 13), ("text_y", 4)], []) + extra += _widget(f"{base}/{key}_spinner", + [("type", "spinner"), ("top", 2), ("width", 206), ("height", 20), + ("flags", "pass_unhandled_to_focused_child left_right_tabs_items"), + ("strings", "|".join(strings)), ("setting", setting), + ("values", "|".join(str(value) for value in values)), ("font", "ui\\large_ui"), + ("color", "#FF2896FF"), ("align", "center"), ("text_y", 4), + ("header_bitmap", "bitmaps/arrow_sm_left"), ("footer_bitmap", "bitmaps/arrow_sm_right"), + ("header_bounds", "7 -13 19 -7"), ("footer_bounds", "7 208 19 214")], + ['', + '', + '']) + + spinner_row("extra_enemies", 11, ["NONE", "PER PLAYER", "STATIC MULTIPLIER"], "network.coop_enemies_mode", + COOP_ENEMIES_MODES) rows.append((f"{base}/op_extra_enemies", None, 5)) + spinner_row("enemies_per_player", 12, [f"{value}%" for value in COOP_ENEMIES_PERCENTAGES], + "network.coop_enemies", COOP_ENEMIES_PERCENTAGES) + rows.append((f"{base}/op_enemies_per_player", None, 6)) + spinner_row("enemies_multiplier", 13, [f"{value}X" for value in COOP_ENEMIES_MULTIPLIERS], + "network.coop_enemies_multiplier", COOP_ENEMIES_MULTIPLIERS) + rows.append((f"{base}/op_enemies_multiplier", None, 6)) # the gametype's options for this game (the gametype editor's screens, # editing a copy of the gametype chosen: "port setup edit") for index, (key, screen) in enumerate(SETUP_OPTION_SCREENS): @@ -733,7 +746,8 @@ def _server_settings() -> list: '']) extra += _strings(f"{base}/labels", ["GAME NAME:", "MAXIMUM PLAYERS:", "INVITE LINK:", "GAME TYPE:", "PLAYER OPTIONS:", "ITEM OPTIONS:", "VEHICLE OPTIONS:", "INDICATOR OPTIONS:", - "TEAMPLAY OPTIONS:", "LISTING:", "FRIENDLY FIRE:", "EXTRA ENEMIES:"]) + "TEAMPLAY OPTIONS:", "LISTING:", "FRIENDLY FIRE:", "EXTRA ENEMIES:", + "PER PLAYER:", "MULTIPLIER:"]) extra += _strings(f"{base}/help_strings", [ "", "The name the game shows in the lists of games.\\nEnter changes it.", @@ -753,8 +767,13 @@ def _server_settings() -> list: "Players can be hurt by weapons or explosives\\nfired by the other players.", "Damage from the other players will only reduce\\nshields. Health will be unaffected.", "Players can be hurt by damage from explosives\\nfired by the other players.", - # (EXTRA ENEMIES') + # (EXTRA ENEMIES', by its choice: COOP_ENEMIES_MODES' order) + "Enemy squads are as the campaign has them.", + "Enemy squads grow with the players: by the amount\\nbelow for each player past the first.", + "Enemy squads are the amount below times as large,\\nhowever many players there are.", + # (PER PLAYER's and MULTIPLIER's) "For each player past the first, enemy squads get\\nthis much more of themselves (100%: as many again).", + "Each enemy squad is this many times as large.", ]) lines = _screen(base, spec, rows, ["server settings update"], [''], extra) @@ -770,8 +789,12 @@ MAXIMUM_PLAYERS = [2, 4, 8, 12, 16, 24, 32, 48, 64, 96, 128] # network.coop_friendly_fire's values, as Server Setup's FRIENDLY FIRE shows # them (menu_functions.c's cooperative_friendly_fire_modes, in this order) COOP_FRIENDLY_FIRE_VALUES = ["off", "on", "shields_only", "explosives_only"] -# network.coop_enemies' values, percentages (Server Setup's EXTRA ENEMIES) -COOP_ENEMIES_VALUES = [0, 25, 50, 100, 150, 200] +# Server Setup's EXTRA ENEMIES: network.coop_enemies_mode's values (in +# coop_enemies.c's order), and the amounts of PER PLAYER (network.coop_enemies, +# percentages) and STATIC MULTIPLIER (network.coop_enemies_multiplier) +COOP_ENEMIES_MODES = ["none", "per_player", "multiplier"] +COOP_ENEMIES_PERCENTAGES = [25, 50, 100, 150, 200] +COOP_ENEMIES_MULTIPLIERS = [2, 4, 8, 16, 32] # Server Setup's rows of the gametype's options: the gametype editor's # screens SETUP_OPTION_SCREENS = [ From dea5fe3b1e897b43307b2996500ba17ba0196746 Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:29:42 +1300 Subject: [PATCH 39/51] Fix crash past 256 actors --- source/ai/actors.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/source/ai/actors.h b/source/ai/actors.h index f8d53af3..f4b5a9fa 100644 --- a/source/ai/actors.h +++ b/source/ai/actors.h @@ -256,7 +256,11 @@ enum NUMBER_OF_ACTOR_PERCEPTION_TYPES, }; -#define MAXIMUM_NUMBER_OF_ACTORS 256 +/* port: the debug information's actors (ai_debug.c's actor_debug_array, +by actor index): every actor of the native builds' larger pool +(halo_port_capacity.h), else one past the Xbox's 256 is written past its +end */ +#define MAXIMUM_NUMBER_OF_ACTORS HALO_PORT_MAXIMUM_ACTORS #define MAXIMUM_NUMBER_OF_ACTOR_PATHS 32 #define MAXIMUM_UNIT_INDICES_PER_SWARM 16 From 9e6247e0cbfd9f1be6c94f295aa3750ad9350ece Mon Sep 17 00:00:00 2001 From: xshxdex98 Date: Mon, 5 Oct 2026 06:34:00 -0500 Subject: [PATCH 40/51] Co-op: a loading zone brings the team along; only someone falling is rescued - Crossing a BSP loading zone moves every other player to whoever crossed, as split screen does. Moving only some players left the rest where the next switch threw them back to a hallway, or under a floor. - The rescue for a player outside the loaded BSP waits two seconds and only takes someone falling: a player riding an elevator through its shaft is left alone. --- source/game/players.c | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/source/game/players.c b/source/game/players.c index abe1227e..59d8c72e 100644 --- a/source/game/players.c +++ b/source/game/players.c @@ -2734,6 +2734,15 @@ static real_point3d const *players_checkpoint_position( return NULL; } +/* TRUE if a biped stands on something (not airborne), in the BSP or not */ +static boolean players_coop_unit_grounded_anywhere( + long unit_index) +{ + struct biped_datum *biped = biped_try_and_get(unit_index); + + return biped && !TEST_FLAG(biped->biped.flags, _biped_airborne_bit); +} + /* TRUE if the unit is outside the loaded structure BSP, where it would fall forever: on foot, or in a vehicle that is empty or player-driven. AI-driven vehicles are ignored because the intro Pelicans fly outside the BSP. */ @@ -2745,17 +2754,19 @@ static boolean players_coop_unit_stranded( if (players_coop_unit_in_structure(unit_index)) return FALSE; + /* (on foot: only while falling, not standing on something the BSP + doesn't hold, such as an elevator in its shaft) */ if (vehicle_index == unit_index) - return TRUE; + return !players_coop_unit_grounded_anywhere(unit_index); driver_index = object_try_and_get_and_verify_type(vehicle_index, _object_mask_unit) ? unit_get(vehicle_index)->unit.driver_object_index : NONE; return driver_index == NONE || unit_get(driver_index)->unit.player_index != NONE; } -/* How long a player can be outside the BSP before being brought back. A -BSP switch settles in a tick or two; waiting a bit longer avoids moving -someone who is just crossing a seam. */ -#define COOP_STRANDED_TICKS TICKS_PER_SECOND +/* How long a player can be falling outside the BSP before being brought +back. A BSP switch settles in a tick or two; waiting longer avoids moving +someone who is only crossing a seam or riding over a gap. */ +#define COOP_STRANDED_TICKS (2 * TICKS_PER_SECOND) /* After a BSP switch, how long the co-op host lets no other trigger switch it while the players are brought into the new one */ @@ -2990,13 +3001,14 @@ static void player_teleport_on_bsp_switch( position); if (biped) { - /* port: network co-op moves the players the switch left outside the - BSP, and those standing where they would switch it straight back, - which switched the BSP back and forth every tick. Pulling everyone to - whoever crossed, as split screen does, bounced players far apart. */ + /* port: in network co-op a loading zone brings the whole team to + whoever crossed it, as split screen does. Only one BSP is ever + loaded, so anyone left elsewhere would stand outside it, or on a + trigger that switches it straight back (players_update_after_game + waits COOP_BSP_SWITCH_SETTLE_TICKS before another switch). */ if (network_coop_active()) { - outside_switch_trigger = players_coop_in_bsp_switch_trigger(unit_index); + outside_switch_trigger = TRUE; } else if (players_globals->pending_teleport_starting_location_index != NONE && !scenario_trigger_volume_test_object( From 8610b2837693be305060706905159cc4eeeb37f8 Mon Sep 17 00:00:00 2001 From: xshxdex98 Date: Mon, 5 Oct 2026 06:35:07 -0500 Subject: [PATCH 41/51] Co-op: doors and elevators are where the host has them The host sends each device's position and power when they change (and with the resent state), and a client puts its device there when the host's is at rest and they differ, or when they drift apart while it moves. Clients only had the device groups' values, which aim a device: a door could stop half open, and an elevator be somewhere else on a client, so its players fell. --- port/linux/game/network_coop.c | 127 ++++++++++++++++++++++++++ port/linux/game/network_distributed.c | 5 + port/linux/game/network_distributed.h | 5 + source/devices/devices.c | 17 ++++ source/devices/devices.h | 7 ++ 5 files changed, 161 insertions(+) diff --git a/port/linux/game/network_coop.c b/port/linux/game/network_coop.c index a0125652..d6a559cc 100644 --- a/port/linux/game/network_coop.c +++ b/port/linux/game/network_coop.c @@ -901,6 +901,96 @@ static void host_send_device_groups( } } +/* Where one of the host's devices is: its position (open, closed, how far +up an elevator has gone) and power, which the device groups' values only +aim it at. Found as the scenery animations' are (object_find). */ +struct distributed_coop_device_state +{ + short name_index; + byte moving; + byte pad; + long object_index; + long definition_index; + real position; + real power; +}; + +#define MAXIMUM_DEVICE_STATES_PER_MESSAGE 64 + +struct distributed_coop_device_states_message +{ + struct distributed_message_header header; + struct distributed_coop_device_state states[MAXIMUM_DEVICE_STATES_PER_MESSAGE]; +}; + +/* a client puts its device where the host's is when the host's is at rest +there, or when the two are this far apart while it moves (of the device's +travel, 0 to 1) */ +#define DEVICE_POSITION_TOLERANCE 0.03f + +/* host: each device's position and power as last sent, by its absolute +index, and whether they have changed since the map placed it */ +static struct +{ + long object_index; + boolean moved; + real position; + real power; +} host_sent_devices[MAXIMUM_OBJECTS_PER_MAP]; + +/* host, each tick: the devices that moved or changed power (and, with the +resent state, all that ever have) */ +static void host_send_device_states( + void) +{ + struct distributed_coop_device_states_message message; + struct object_iterator iterator; + struct device_datum *device; + short count = 0; + + object_iterator_new(&iterator, _object_mask_device, 0); + while ((device = object_iterator_next(&iterator)) != NULL) + { + short absolute_index = DATUM_INDEX_TO_ABSOLUTE_INDEX(iterator.index); + struct distributed_coop_device_state *state; + boolean changed; + + if (absolute_index < 0 || absolute_index >= MAXIMUM_OBJECTS_PER_MAP) + continue; + if (host_sent_devices[absolute_index].object_index != iterator.index) + { + /* (as the map placed it, on every machine) */ + host_sent_devices[absolute_index].object_index = iterator.index; + host_sent_devices[absolute_index].moved = FALSE; + host_sent_devices[absolute_index].position = device->device.position; + host_sent_devices[absolute_index].power = device->device.power; + continue; + } + changed = host_sent_devices[absolute_index].position != device->device.position || + host_sent_devices[absolute_index].power != device->device.power; + if (!changed && !(host_resend.refresh && host_sent_devices[absolute_index].moved)) + continue; + host_sent_devices[absolute_index].moved = TRUE; + host_sent_devices[absolute_index].position = device->device.position; + host_sent_devices[absolute_index].power = device->device.power; + state = &message.states[count++]; + state->name_index = device->object.name_index; + state->moving = (byte)(device->device.position_velocity != 0.0f); + state->pad = 0; + state->object_index = iterator.index; + state->definition_index = device->definition_index; + state->position = device->device.position; + state->power = device->device.power; + if (count == MAXIMUM_DEVICE_STATES_PER_MESSAGE) + { + send_to_clients(&message, _distributed_message_coop_device_states, count, sizeof(message.states[0])); + count = 0; + } + } + if (count > 0) + send_to_clients(&message, _distributed_message_coop_device_states, count, sizeof(message.states[0])); +} + /* host: where each scenery or machine was last sent, by its absolute index, and whether it has moved since the map placed it */ static struct @@ -1794,6 +1884,7 @@ void network_coop_new_game( csmemset(client_script_sounds.definition_indices, NONE, sizeof(client_script_sounds.definition_indices)); csmemset(host_sent_transforms, 0, sizeof(host_sent_transforms)); csmemset(host_sent_looks, 0, sizeof(host_sent_looks)); + csmemset(host_sent_devices, 0, sizeof(host_sent_devices)); csmemset(&host_sent_screen_effect, 0, sizeof(host_sent_screen_effect)); csmemset(&host_resend, 0, sizeof(host_resend)); csmemset(&players_vitality, 0, sizeof(players_vitality)); @@ -2405,6 +2496,7 @@ void network_coop_host_tick( if (host_resend.joined || game_time_get() % OBJECT_NAMES_INTERVAL_TICKS == 0) host_send_object_names(); host_send_object_transforms(); + host_send_device_states(); host_send_object_looks(); host_send_screen_effect(); host_send_attachments(); @@ -2460,6 +2552,41 @@ word network_coop_device_group_entry_size( return sizeof(struct distributed_coop_device_group); } +word network_coop_device_state_entry_size( + void) +{ + return sizeof(struct distributed_coop_device_state); +} + +/* client: its devices where the host's are (DEVICE_POSITION_TOLERANCE) */ +void network_coop_handle_device_states( + void const *entries, + short count) +{ + struct distributed_coop_device_state const *states = entries; + short index; + + if (!coop_client()) + return; + for (index = 0; index < count; index++) + { + struct distributed_coop_device_state const *state = &states[index]; + long device_index = object_find(state->name_index, state->object_index, state->definition_index, + _object_mask_device); + struct device_datum *device; + real difference; + + if (device_index == NONE || !distributed_real_valid(state->position) || !distributed_real_valid(state->power)) + continue; + device = object_get_and_verify_type(device_index, _object_mask_device); + difference = device->device.position - state->position; + if (difference < 0.0f) + difference = -difference; + device_port_set_state(device_index, state->power, + (!state->moving && difference > 0.001f) || difference > DEVICE_POSITION_TOLERANCE, state->position); + } +} + word network_coop_object_transform_entry_size( void) { diff --git a/port/linux/game/network_distributed.c b/port/linux/game/network_distributed.c index 9af57a0a..73aa6497 100644 --- a/port/linux/game/network_distributed.c +++ b/port/linux/game/network_distributed.c @@ -3343,6 +3343,7 @@ static boolean distributed_message_stale( case _distributed_message_coop_object_looks: case _distributed_message_damage_animations: case _distributed_message_coop_screen_effect: + case _distributed_message_coop_device_states: break; default: return FALSE; @@ -3813,6 +3814,7 @@ void network_distributed_handle_message( case _distributed_message_coop_object_looks: entry_size = network_coop_object_look_entry_size(); break; case _distributed_message_damage_animations: entry_size = network_objects_damage_animation_entry_size(); break; case _distributed_message_coop_screen_effect: entry_size = network_coop_screen_effect_entry_size(); break; + case _distributed_message_coop_device_states: entry_size = network_coop_device_state_entry_size(); break; case _distributed_message_pickups: entry_size = sizeof(struct distributed_pickup); break; case _distributed_message_player_inputs: entry_size = sizeof(struct distributed_player_input); break; case _distributed_message_relayed_actions: entry_size = DISTRIBUTED_RELAYED_ACTION_MINIMUM_SIZE; break; @@ -3927,6 +3929,9 @@ void network_distributed_handle_message( case _distributed_message_coop_screen_effect: network_coop_handle_screen_effect(entries, header.count); break; + case _distributed_message_coop_device_states: + network_coop_handle_device_states(entries, header.count); + break; case _distributed_message_player_statistics: { /* the host's count of kills, deaths, ... */ diff --git a/port/linux/game/network_distributed.h b/port/linux/game/network_distributed.h index f948b9e1..6fccb7f5 100644 --- a/port/linux/game/network_distributed.h +++ b/port/linux/game/network_distributed.h @@ -103,6 +103,9 @@ enum /* co-op: the host's cinematic screen effect (blur, filters, video), when it changes and every two seconds (network_coop.c) */ _distributed_message_coop_screen_effect = 76, + /* co-op: where the host's devices are (doors, elevators), when they move + (network_coop.c) */ + _distributed_message_coop_device_states = 77, NUMBER_OF_DISTRIBUTED_MESSAGES }; @@ -279,6 +282,8 @@ word network_coop_object_transform_entry_size(void); word network_coop_object_look_entry_size(void); void network_coop_handle_object_looks(void const *entries, short count); word network_coop_screen_effect_entry_size(void); +word network_coop_device_state_entry_size(void); +void network_coop_handle_device_states(void const *entries, short count); void network_coop_handle_screen_effect(void const *entries, short count); word network_actors_damage_entry_size(void); void network_actors_handle_damage(void const *entries, short count); diff --git a/source/devices/devices.c b/source/devices/devices.c index b3e089b6..189461d2 100644 --- a/source/devices/devices.c +++ b/source/devices/devices.c @@ -734,6 +734,23 @@ void device_group_network_set( group->flags = (word)((group->flags & ~DEVICE_GROUP_NETWORK_FLAGS) | (flags & DEVICE_GROUP_NETWORK_FLAGS)); } +void device_port_set_state( + long device_index, + real power, + boolean move_to_position, + real position) +{ + struct device_datum *device = device_get(device_index); + + device->device.power = PIN(power, 0.0f, 1.0f); + if (move_to_position) + { + device->device.position = PIN(position, 0.0f, 1.0f); + device->device.position_velocity = 0.0f; + device->device.flags |= FLAG(_device_position_changed_bit); + } +} + static boolean device_group_change( short group_index, real desired_value) diff --git a/source/devices/devices.h b/source/devices/devices.h index c5a5f977..8bfd5f2e 100644 --- a/source/devices/devices.h +++ b/source/devices/devices.h @@ -140,6 +140,13 @@ void device_group_network_set( real value, word flags, boolean snap); +/* port: a co-op client puts a device at the host's power, and at its +position when move_to_position */ +void device_port_set_state( + long device_index, + real power, + boolean move_to_position, + real position); boolean device_can_change_position( long device_index); boolean device_frontfacing( From 862d88737e1685bd8689a9f7504a504e62e1e277 Mon Sep 17 00:00:00 2001 From: xshxdex98 Date: Mon, 5 Oct 2026 06:36:27 -0500 Subject: [PATCH 42/51] Co-op: Flood bodies get up on clients; dropship doors open and close there - A body the host's unit got back up from (a Flood combat form feigning death) gets up on the clients too, instead of its ragdoll being dragged about. The resurrection is one function both use (unit_port_resurrect). - The scripts' unit_open and unit_close go to the clients, so dropships' doors aren't left open there. --- port/linux/game/network_actors.c | 16 ++++++++-- port/linux/game/network_coop.c | 24 +++++++++++++++ port/linux/game/network_coop.h | 2 ++ source/units/units.c | 50 +++++++++++++++----------------- source/units/units.h | 3 ++ 5 files changed, 67 insertions(+), 28 deletions(-) diff --git a/port/linux/game/network_actors.c b/port/linux/game/network_actors.c index 746a2ad5..82e32d3a 100644 --- a/port/linux/game/network_actors.c +++ b/port/linux/game/network_actors.c @@ -422,8 +422,20 @@ static boolean actor_state_apply( real_vector3d forward; real_vector3d up; - if (!distributed_object_index_valid(state->unit_index) || !network_objects_client_has(state->unit_index) || - !actor_unit_valid(state->unit_index) || + if (!distributed_object_index_valid(state->unit_index) || !network_objects_client_has(state->unit_index)) + return FALSE; + /* (a body the host's unit got back up from: a Flood combat form that + feigned death, which it does only once its body is at rest. Left dead, + the ragdoll was dragged about by the host's positions. The host sends + only living units.) */ + if (object_try_and_get_and_verify_type(state->unit_index, _object_mask_unit) && + TEST_FLAG(unit_get(state->unit_index)->object.damage_flags, _object_dead_bit) && + TEST_FLAG(unit_get(state->unit_index)->object.flags, _object_at_rest_bit) && + unit_get(state->unit_index)->unit.player_index == NONE && state->body_vitality > 0) + { + unit_port_resurrect(state->unit_index); + } + if (!actor_unit_valid(state->unit_index) || state->animation_state >= NUMBER_OF_UNIT_ANIMATION_STATES || state->aiming_speed >= NUMBER_OF_UNIT_AIMING_SPEEDS || !VALID_FLAGS(state->control_flags, NUMBER_OF_UNIT_CONTROL_FLAGS) || diff --git a/port/linux/game/network_coop.c b/port/linux/game/network_coop.c index a0125652..0e34a268 100644 --- a/port/linux/game/network_coop.c +++ b/port/linux/game/network_coop.c @@ -184,6 +184,8 @@ enum _coop_event_attach, /* the host skipped the cutscene: a client stops its dialogue */ _coop_event_cutscene_skipped, + /* a unit opened (value TRUE) or closed: a dropship's doors */ + _coop_event_unit_open, }; /* distributed_coop_event.type of an effect: at a cutscene flag (value), or on @@ -1703,6 +1705,16 @@ static void client_apply_event( case _coop_event_cutscene_skipped: client_stop_script_sounds(); break; + case _coop_event_unit_open: + if (distributed_object_index_valid(event->object_index) && network_objects_client_has(event->object_index) && + object_try_and_get_and_verify_type(event->object_index, _object_mask_unit)) + { + if (event->value) + unit_open(event->object_index); + else + unit_close(event->object_index); + } + break; default: break; } @@ -2144,6 +2156,18 @@ void network_coop_note_unit_animation( event->interpolate = (byte)interpolate; } +void network_coop_note_unit_open( + long unit_index, + boolean open) +{ + struct distributed_coop_event *event = event_new(_coop_event_unit_open); + + if (!event) + return; + event->object_index = unit_index; + event->value = (short)open; +} + /* unit_custom_animation_at_frame starts the animation and then sets the frame in the same call, so the start is still queued and unsent: the frame goes into it */ diff --git a/port/linux/game/network_coop.h b/port/linux/game/network_coop.h index 4364208f..306f6ee7 100644 --- a/port/linux/game/network_coop.h +++ b/port/linux/game/network_coop.h @@ -68,6 +68,8 @@ void network_coop_note_nav_point(short kind, short nav_index, long target, long /* units.c: a unit started a custom animation (animation_index NONE: stopped) */ void network_coop_note_unit_animation(long unit_index, long animation_graph_index, short animation_index, boolean interpolate); +/* units.c: a unit (a dropship) opened or closed (unit_open, unit_close) */ +void network_coop_note_unit_open(long unit_index, boolean open); /* units.c: unit_custom_animation_at_frame moved it to a frame */ void network_coop_note_unit_animation_frame(long unit_index, short frame_index); /* scenery.c: a scenery animation started */ diff --git a/source/units/units.c b/source/units/units.c index 2a0864d6..9e6375a0 100644 --- a/source/units/units.c +++ b/source/units/units.c @@ -4114,6 +4114,27 @@ void unit_stop_custom_animation( return; } +/* port: a unit that feigned death gets back up (a Flood combat form). The +host's does when its timer runs out (unit_update); a client's copy when the +host's word on it says it is alive again (network_actors.c). */ +void unit_port_resurrect( + long unit_index) +{ + struct unit_datum *unit = unit_get(unit_index); + short new_state = TEST_FLAG(unit->unit.animation.flags, _unit_animation_fallen_on_front_bit) ? + _unit_state_resurrect_front : _unit_state_resurrect_back; + + SET_FLAG(unit->object.damage_flags, _object_dead_bit, FALSE); + unit_set_actively_controlled(unit_index, TRUE); + unit_set_or_test_seat_and_weapon_label(unit_index, base_seat_label_get(_unit_animation_state_suspicious), NULL, + TRUE); + unit_animation_set_state(unit_index, new_state); + SET_FLAG(unit->unit.animation.flags, _unit_animation_ignore_translation_bit, FALSE); + if (unit->object.type == _object_type_biped) + biped_stop_limp_body_physics(unit_index); + unit_scream(unit_index, _unit_scream_resurrection); +} + /* how far into its own flinch or death animation a client's unit still switches to the host's pick */ #define DAMAGE_ANIMATION_SWITCH_TICKS 10 @@ -5271,6 +5292,7 @@ void unit_open( { if (unit_index!=NONE) { + network_coop_note_unit_open(unit_index, TRUE); unit_animation_set_state(unit_index, _unit_state_opening); } @@ -5282,6 +5304,7 @@ void unit_close( { if (unit_index!=NONE) { + network_coop_note_unit_open(unit_index, FALSE); unit_animation_set_state(unit_index, _unit_state_closing); } @@ -5547,32 +5570,7 @@ boolean unit_update( { if (unit->object.body_vitality>0.f) { - short new_state = TEST_FLAG( - unit->unit.animation.flags, - _unit_animation_fallen_on_front_bit) ? _unit_state_resurrect_front : _unit_state_resurrect_back; - - SET_FLAG(unit->object.damage_flags, _object_dead_bit, FALSE); - - unit_set_actively_controlled(unit_index, TRUE); - unit_set_or_test_seat_and_weapon_label( - unit_index, - base_seat_label_get(_unit_animation_state_suspicious), - NULL, - TRUE - ); - unit_animation_set_state(unit_index, new_state); - - SET_FLAG( - unit->unit.animation.flags, - _unit_animation_ignore_translation_bit, - FALSE); - - if (unit->object.type==_object_type_biped) - { - biped_stop_limp_body_physics(unit_index); - } - - unit_scream(unit_index, _unit_scream_resurrection); + unit_port_resurrect(unit_index); } else { diff --git a/source/units/units.h b/source/units/units.h index 7443aaa9..ba4d9c2b 100644 --- a/source/units/units.h +++ b/source/units/units.h @@ -769,6 +769,9 @@ boolean unit_is_playing_custom_animation( long unit_index); boolean unit_flying_through_air( long unit_index); +/* port: a unit that feigned death gets back up (units.c) */ +void unit_port_resurrect( + long unit_index); /* port: whether the unit plays a flinch or death animation of the type, switched to the host's if it has only just begun (port/linux/game/network_objects.c) */ From ddcbe79283604d4fad708a61210f228032cccd5e Mon Sep 17 00:00:00 2001 From: xshxdex98 Date: Mon, 5 Oct 2026 06:41:27 -0500 Subject: [PATCH 43/51] test_death_timing.py: stubs for the network co-op paths The test compiles main.c's and players.c's death handlers against a fake world; their network co-op branches call functions it had none of, so it no longer compiled. Stubbed off, it covers the local game it was written for, and passes. --- tools/test_death_timing.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/test_death_timing.py b/tools/test_death_timing.py index 983ee4a0..8eaee390 100644 --- a/tools/test_death_timing.py +++ b/tools/test_death_timing.py @@ -58,6 +58,12 @@ struct players_vehicle_datum {struct {int unknown_state;} vehicle;}; static struct players_vehicle_datum vehicle; struct data_iterator {long datum_index;int next;}; static int player_data; +/* (the network co-op paths, off: this is a local game) */ +enum {_game_connection_local,_game_connection_network_client,_game_connection_network_server}; +static short game_connection(void) {return _game_connection_local;} +static boolean network_coop_active(void) {return FALSE;} +static boolean players_respawn_network_coop(void) {return FALSE;} +static void players_respawn_at_checkpoint(void) {} enum {_biped_airborne_bit=0,_object_mask_vehicle=1}; static void data_iterator_new(struct data_iterator *i,int d) {(void)d;i->next=0;} static void *data_iterator_next(struct data_iterator *i) { From 91e838e2f64f1e5f82bedd032c2d0477afa53fa1 Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:54:11 +1300 Subject: [PATCH 44/51] Network version 14 --- port/linux/NETCODE.md | 10 +++++++--- port/linux/include/halo_port_limits.h | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/port/linux/NETCODE.md b/port/linux/NETCODE.md index 806e3c8d..07c38cef 100644 --- a/port/linux/NETCODE.md +++ b/port/linux/NETCODE.md @@ -37,9 +37,12 @@ with ideas from VALORANT's netcode articles, keeping the 30 Hz tick: scripts control (what is shown, the mission timer) and the skip vote; - once each, numbered so nothing is applied twice: script sounds, chapter titles, help and objective text, "Checkpoint" messages, screen - shake, nav points, and custom animations on units and scenery; + shake, nav points, custom animations on units and scenery, and units + opening and closing (dropships' doors); - device groups (doors, elevators, switches; a client sets none itself, - and its player's use of one is relayed to the host); + and its player's use of one is relayed to the host), and each device's + position and power as it changes: a client puts its device where the + host's is once that stops, or if they drift apart while it moves; - which named objects exist, so scripted creates and deletes match. Every machine follows the host's structure BSP. A dead player watches a @@ -140,7 +143,8 @@ version 11 sends with the game's settings its gametype's PC options; version 12 plays the campaign together (co-op, above), drives the host's actors on its clients and sends the flinches and deaths the host picked; version 13 drives up to 1056 of the host's AI units on its clients (co-op's -extra enemies), where 12 drove 288. +extra enemies), where 12 drove 288; version 14 sends co-op's device positions +and its units opening and closing. A client plays by its host's rules: in another's game (searching for it, in its lobby, or playing it) the developer console, the telnet console diff --git a/port/linux/include/halo_port_limits.h b/port/linux/include/halo_port_limits.h index 06af391b..28cc441a 100644 --- a/port/linux/include/halo_port_limits.h +++ b/port/linux/include/halo_port_limits.h @@ -66,7 +66,7 @@ is newer (network_client_manager.c). A host advertises it, with its netcode, in its game's advertisement's reserved bytes (network_server_message_handler.c), which hosts built before there was a version send as zeros: version 0. Raise it with any change to what the machines send each other. */ -#define HALO_PORT_NETWORK_VERSION 13 +#define HALO_PORT_NETWORK_VERSION 14 /* ... the advertisement's reserved bytes: the version (a little-endian word), then flags */ #define HALO_PORT_ADVERTISED_VERSION_OFFSET 0 From 5ff4b6ee8d13043d406dd99eb2519349eccf9fca Mon Sep 17 00:00:00 2001 From: xshxdex98 Date: Mon, 5 Oct 2026 06:58:05 -0500 Subject: [PATCH 45/51] Co-op: clients take the host's allegiances The scripts make allegiances (ai_allegiance player human) and only the host runs them, so on clients marines stayed enemies of the players: red reticles and the wrong team. The host sends its ally and friend tables with the presentation each tick. --- port/linux/game/network_coop.c | 7 +++++++ source/game/game_allegiance.c | 26 ++++++++++++++++++++++++-- source/game/game_allegiance.h | 9 +++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/port/linux/game/network_coop.c b/port/linux/game/network_coop.c index 50421dbd..9e38dd31 100644 --- a/port/linux/game/network_coop.c +++ b/port/linux/game/network_coop.c @@ -73,6 +73,7 @@ index and tag, since the map placed them at the same index everywhere. #include "effects/effect_definitions.h" #include "effects/player_effects.h" #include "game/game.h" +#include "game/game_allegiance.h" #include "game/game_engine.h" #include "game/players.h" #include "game/player_queues_new.h" @@ -272,6 +273,10 @@ struct distributed_coop_presentation byte pad[2]; real players_maximum_body_vitality; real players_maximum_shield_vitality; + /* which teams are allies and friends (game_allegiance.c): the scripts + make them, so a client's would leave the marines its enemies */ + unsigned long ally_teams[GAME_ALLEGIANCE_BITVECTOR_LONGS]; + unsigned long friendly_teams[GAME_ALLEGIANCE_BITVECTOR_LONGS]; }; struct distributed_coop_presentation_message @@ -1422,6 +1427,7 @@ static void host_presentation( presentation->players_vitality_set = (byte)players_vitality.set; presentation->players_maximum_body_vitality = players_vitality.maximum_body; presentation->players_maximum_shield_vitality = players_vitality.maximum_shield; + game_allegiance_get_teams(presentation->ally_teams, presentation->friendly_teams); SET_FLAG(presentation->flags, _presentation_skippable_bit, skip_vote.offered); presentation->skip_votes = (byte)MIN(skip_vote.votes, 255); presentation->skip_voters = (byte)MIN(skip_vote.voters, 255); @@ -2853,6 +2859,7 @@ static void client_presentation_apply( players_vitality.maximum_body = presentation->players_maximum_body_vitality; players_vitality.maximum_shield = presentation->players_maximum_shield_vitality; } + game_allegiance_set_teams(presentation->ally_teams, presentation->friendly_teams); if (!presentation->scripted_shake && player_effect_port_scripted_active()) player_effect_port_scripted_end(); diff --git a/source/game/game_allegiance.c b/source/game/game_allegiance.c index 15f2e970..4421b65f 100644 --- a/source/game/game_allegiance.c +++ b/source/game/game_allegiance.c @@ -88,8 +88,8 @@ struct game_allegiance_globals short allegiance_count; struct game_allegiance_record allegiances[8]; byte reserved92[2]; - unsigned long ally_bitvector[4]; - unsigned long friendly_bitvector[4]; + unsigned long ally_bitvector[GAME_ALLEGIANCE_BITVECTOR_LONGS]; + unsigned long friendly_bitvector[GAME_ALLEGIANCE_BITVECTOR_LONGS]; }; typedef char game_allegiance_record_size_assert[ @@ -452,6 +452,28 @@ void game_allegiance_update( return; } +/* port: which teams are allies and which friends. A co-op client takes the +host's, as only the host runs the scripts that make allegiances. */ +void game_allegiance_get_teams( + unsigned long ally_bitvector[GAME_ALLEGIANCE_BITVECTOR_LONGS], + unsigned long friendly_bitvector[GAME_ALLEGIANCE_BITVECTOR_LONGS]) +{ + csmemcpy(ally_bitvector, game_allegiance_globals->ally_bitvector, + sizeof(game_allegiance_globals->ally_bitvector)); + csmemcpy(friendly_bitvector, game_allegiance_globals->friendly_bitvector, + sizeof(game_allegiance_globals->friendly_bitvector)); +} + +void game_allegiance_set_teams( + unsigned long const ally_bitvector[GAME_ALLEGIANCE_BITVECTOR_LONGS], + unsigned long const friendly_bitvector[GAME_ALLEGIANCE_BITVECTOR_LONGS]) +{ + csmemcpy(game_allegiance_globals->ally_bitvector, ally_bitvector, + sizeof(game_allegiance_globals->ally_bitvector)); + csmemcpy(game_allegiance_globals->friendly_bitvector, friendly_bitvector, + sizeof(game_allegiance_globals->friendly_bitvector)); +} + void game_allegiance_create( short team1_index, boolean team1_suspicious, diff --git a/source/game/game_allegiance.h b/source/game/game_allegiance.h index e607caeb..fbbe2ab4 100644 --- a/source/game/game_allegiance.h +++ b/source/game/game_allegiance.h @@ -11,6 +11,9 @@ enum _allegiance_incident_forgive }; +/* port: the longs of game_allegiance_get_teams' bit vectors */ +#define GAME_ALLEGIANCE_BITVECTOR_LONGS 4 + /* ---------- prototypes/GAME_ALLEGIANCE.C */ void game_allegiance_initialize( @@ -23,6 +26,12 @@ void game_allegiance_dispose_from_old_map( void); void game_allegiance_update( void); +void game_allegiance_get_teams( + unsigned long ally_bitvector[GAME_ALLEGIANCE_BITVECTOR_LONGS], + unsigned long friendly_bitvector[GAME_ALLEGIANCE_BITVECTOR_LONGS]); +void game_allegiance_set_teams( + unsigned long const ally_bitvector[GAME_ALLEGIANCE_BITVECTOR_LONGS], + unsigned long const friendly_bitvector[GAME_ALLEGIANCE_BITVECTOR_LONGS]); void game_allegiance_create( short team1_index, boolean team1_suspicious, From c3f82fa9b2e01b284af78c4c48bcc67007ac2bea Mon Sep 17 00:00:00 2001 From: xshxdex98 Date: Mon, 5 Oct 2026 06:58:05 -0500 Subject: [PATCH 46/51] Co-op: going back to a BSP already visited needs the whole team there A loading zone brings the whole team to whoever crosses it. Into a new part of the level that's what you want, but it let one player drag everyone back. A switch back to a BSP the team has been in now waits until every living player is in the trigger or near the player in it. --- source/game/players.c | 49 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/source/game/players.c b/source/game/players.c index 59d8c72e..846a187f 100644 --- a/source/game/players.c +++ b/source/game/players.c @@ -473,6 +473,8 @@ static struct /* no BSP switch trigger counts before this game time (one has just switched, and the players are being brought into the new BSP) */ long bsp_switch_allowed_time; + /* the structure BSPs the players have been in this map, by bit */ + word visited_structure_bsps; /* where each player last stood on the ground, and on which BSP: where everyone comes back when the last checkpoint was on another one */ short ground_structure_bsp_index; @@ -2772,6 +2774,10 @@ someone who is only crossing a seam or riding over a gap. */ it while the players are brought into the new one */ #define COOP_BSP_SWITCH_SETTLE_TICKS (2 * TICKS_PER_SECOND) +/* How near the player on a switch trigger the rest of the team must be to +go back to a BSP they have already been in (world units) */ +#define COOP_BACKTRACK_GATHER_DISTANCE 4.0f + /* Co-op host, each tick: a player stranded for COOP_STRANDED_TICKS is moved beside a grounded teammate inside the BSP, else any teammate inside it, else to the last checkpoint. Skipped while scripts hold the controls, since @@ -2980,6 +2986,43 @@ static boolean players_coop_in_bsp_switch_trigger( return FALSE; } +/* port: whether a network co-op host lets the unit on this trigger switch +the BSP, which brings the whole team along. Anyone can lead the team into a +BSP it hasn't been in; going back to one needs every living player there, +so one player can't drag everyone back through the level. */ +static boolean players_coop_bsp_switch_allowed( + short bsp_switch_trigger_volume_index, + long unit_index) +{ + struct scenario_bsp_switch_trigger_volume *volume = TAG_BLOCK_GET_ELEMENT( + &global_scenario_get()->bsp_switch_trigger_volumes, bsp_switch_trigger_volume_index, + struct scenario_bsp_switch_trigger_volume); + real_point3d const *center = &object_get(unit_index)->object.bounding_sphere_center; + struct data_iterator iterator; + struct player_datum *player; + + if (!VALID_INDEX(volume->destination_structure_bsp_index, MAXIMUM_STRUCTURE_BSPS_PER_SCENARIO) || + !TEST_FLAG(players_coop_state.visited_structure_bsps, volume->destination_structure_bsp_index)) + { + return TRUE; + } + data_iterator_new(&iterator, player_data); + while ((player = data_iterator_next(&iterator)) != NULL) + { + struct object_datum *teammate = object_try_and_get(player->unit_index); + + if (teammate && !TEST_FLAG(teammate->object.damage_flags, _object_dead_bit) && + !scenario_trigger_volume_test_object(volume->trigger_volume_index, player->unit_index) && + distance_squared3d(center, &teammate->object.bounding_sphere_center) > + COOP_BACKTRACK_GATHER_DISTANCE * COOP_BACKTRACK_GATHER_DISTANCE) + { + return FALSE; + } + } + + return TRUE; +} + static void player_teleport_on_bsp_switch( long player_index, long source_unit_index, @@ -4500,6 +4543,8 @@ void players_update_after_game( if (players_globals->double_speed_ticks == 0) game_set_players_are_double_speed(FALSE); } + if (VALID_INDEX(global_structure_bsp_index, MAXIMUM_STRUCTURE_BSPS_PER_SCENARIO)) + SET_FLAG(players_coop_state.visited_structure_bsps, global_structure_bsp_index, TRUE); data_iterator_new(&iterator, player_data); while (player = data_iterator_next(&iterator)) @@ -4566,7 +4611,9 @@ void players_update_after_game( global_structure_bsp_index && scenario_trigger_volume_test_object( bsp_switch_trigger_volume->trigger_volume_index, - player->unit_index)) + player->unit_index) && + (!network_coop_active() || + players_coop_bsp_switch_allowed(bsp_switch_trigger_volume_index, player->unit_index))) { if (players_globals->local_player_triggered_switch != _local_player_triggered_switch_none && From 8aa381ed1488144ed13603f39d70eb3cc05e3c11 Mon Sep 17 00:00:00 2001 From: xshxdex98 Date: Mon, 5 Oct 2026 07:01:10 -0500 Subject: [PATCH 47/51] Co-op: vehicles the host destroys explode on clients Clients only had a kill path for bipeds, so a Ghost, Banshee or Wraith the host destroyed stayed intact on clients. A vehicle the host's state says is dead is now destroyed there too, with its explosion. --- port/linux/game/network_objects.c | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/port/linux/game/network_objects.c b/port/linux/game/network_objects.c index 0572283f..a59b562d 100644 --- a/port/linux/game/network_objects.c +++ b/port/linux/game/network_objects.c @@ -2405,6 +2405,20 @@ static void distributed_client_dead_biped( } } +/* client: a vehicle the host says is dead is destroyed here too, with its +explosion. No killing blow comes for vehicles, only for bipeds. */ +static void distributed_client_dead_vehicle( + long object_index) +{ + struct unit_datum *vehicle = (struct unit_datum *)object_try_and_get_and_verify_type(object_index, + _object_mask_vehicle); + + if (!vehicle || TEST_FLAG(vehicle->object.damage_flags, _object_dead_bit)) + return; + unit_kill_no_statistics(object_index); + object_damage_update(object_index); +} + void network_objects_handle_states( void const *entries, short count) @@ -2428,6 +2442,7 @@ void network_objects_handle_states( if (TEST_FLAG(state->flags, _distributed_object_dead_bit)) { distributed_client_dead_biped(state->object_index); + distributed_client_dead_vehicle(state->object_index); /* (a body its tag destroys at once is gone) */ if (!object_try_and_get_and_verify_type(state->object_index, _object_mask_all)) continue; From 8dc796028e02fb63f7846046c18a27d5b0bbf549 Mon Sep 17 00:00:00 2001 From: xshxdex98 Date: Mon, 5 Oct 2026 07:14:28 -0500 Subject: [PATCH 48/51] Co-op: going back to a visited BSP needs two thirds of the team One player who stayed behind or went AFK no longer blocks the team. --- source/game/players.c | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/source/game/players.c b/source/game/players.c index 846a187f..17dfb825 100644 --- a/source/game/players.c +++ b/source/game/players.c @@ -2774,8 +2774,9 @@ someone who is only crossing a seam or riding over a gap. */ it while the players are brought into the new one */ #define COOP_BSP_SWITCH_SETTLE_TICKS (2 * TICKS_PER_SECOND) -/* How near the player on a switch trigger the rest of the team must be to -go back to a BSP they have already been in (world units) */ +/* Going back to a BSP the team has already been in needs two thirds of the +living players at the trigger: inside it, or this near the player in it +(world units) */ #define COOP_BACKTRACK_GATHER_DISTANCE 4.0f /* Co-op host, each tick: a player stranded for COOP_STRANDED_TICKS is moved @@ -2988,8 +2989,8 @@ static boolean players_coop_in_bsp_switch_trigger( /* port: whether a network co-op host lets the unit on this trigger switch the BSP, which brings the whole team along. Anyone can lead the team into a -BSP it hasn't been in; going back to one needs every living player there, -so one player can't drag everyone back through the level. */ +BSP it hasn't been in; going back to one needs two thirds of the team +there, so one player can't drag everyone back through the level. */ static boolean players_coop_bsp_switch_allowed( short bsp_switch_trigger_volume_index, long unit_index) @@ -3000,6 +3001,8 @@ static boolean players_coop_bsp_switch_allowed( real_point3d const *center = &object_get(unit_index)->object.bounding_sphere_center; struct data_iterator iterator; struct player_datum *player; + short living_count = 0; + short gathered_count = 0; if (!VALID_INDEX(volume->destination_structure_bsp_index, MAXIMUM_STRUCTURE_BSPS_PER_SCENARIO) || !TEST_FLAG(players_coop_state.visited_structure_bsps, volume->destination_structure_bsp_index)) @@ -3011,16 +3014,18 @@ static boolean players_coop_bsp_switch_allowed( { struct object_datum *teammate = object_try_and_get(player->unit_index); - if (teammate && !TEST_FLAG(teammate->object.damage_flags, _object_dead_bit) && - !scenario_trigger_volume_test_object(volume->trigger_volume_index, player->unit_index) && - distance_squared3d(center, &teammate->object.bounding_sphere_center) > + if (!teammate || TEST_FLAG(teammate->object.damage_flags, _object_dead_bit)) + continue; + living_count++; + if (scenario_trigger_volume_test_object(volume->trigger_volume_index, player->unit_index) || + distance_squared3d(center, &teammate->object.bounding_sphere_center) <= COOP_BACKTRACK_GATHER_DISTANCE * COOP_BACKTRACK_GATHER_DISTANCE) { - return FALSE; + gathered_count++; } } - return TRUE; + return gathered_count * 3 >= living_count * 2; } static void player_teleport_on_bsp_switch( From 24957ff6a0930a11f24c0f3e43cdcd4591a56c44 Mon Sep 17 00:00:00 2001 From: MrBruh <4706866+cybersecurity@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:28:04 +1300 Subject: [PATCH 49/51] Network version 15 --- port/linux/NETCODE.md | 15 ++++++++++++--- port/linux/include/halo_port_limits.h | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/port/linux/NETCODE.md b/port/linux/NETCODE.md index 07c38cef..98d9da3a 100644 --- a/port/linux/NETCODE.md +++ b/port/linux/NETCODE.md @@ -34,7 +34,9 @@ with ideas from VALORANT's netcode articles, keeping the 30 Hz tick: level's scripts and spawns players. `network_coop.c` sends the clients everything the scripts do that they would otherwise miss: - every tick: the cinematic, camera, screen fade, the HUD settings the - scripts control (what is shown, the mission timer) and the skip vote; + scripts control (what is shown, the mission timer), the skip vote, and + which teams are allies and friends (the scripts' allegiances, so the + marines are the players' allies on every machine); - once each, numbered so nothing is applied twice: script sounds, chapter titles, help and objective text, "Checkpoint" messages, screen shake, nav points, custom animations on units and scenery, and units @@ -45,7 +47,13 @@ with ideas from VALORANT's netcode articles, keeping the 30 Hz tick: host's is once that stops, or if they drift apart while it moves; - which named objects exist, so scripted creates and deletes match. - Every machine follows the host's structure BSP. A dead player watches a + Every machine follows the host's structure BSP. A loading zone into a + BSP the team hasn't been in brings every player to whoever crossed it, + as split screen does; one back into a BSP it has been in switches only + with two thirds of the living players at it (in the trigger, or within + 4 world units of the player in it), so one player can't drag the team + back through the level. A player outside the loaded BSP and falling for + two seconds is brought back beside a teammate. A dead player watches a living teammate (`coop_spectate.c`) and comes back beside one once it is safe. With everyone dead they come back where they were at the last checkpoint, since only the host could revert. A level won ends the round @@ -144,7 +152,8 @@ version 12 plays the campaign together (co-op, above), drives the host's actors on its clients and sends the flinches and deaths the host picked; version 13 drives up to 1056 of the host's AI units on its clients (co-op's extra enemies), where 12 drove 288; version 14 sends co-op's device positions -and its units opening and closing. +and its units opening and closing; version 15 sends co-op's allegiances with +its presentation, a message of another size. A client plays by its host's rules: in another's game (searching for it, in its lobby, or playing it) the developer console, the telnet console diff --git a/port/linux/include/halo_port_limits.h b/port/linux/include/halo_port_limits.h index 28cc441a..8f4eaa7b 100644 --- a/port/linux/include/halo_port_limits.h +++ b/port/linux/include/halo_port_limits.h @@ -66,7 +66,7 @@ is newer (network_client_manager.c). A host advertises it, with its netcode, in its game's advertisement's reserved bytes (network_server_message_handler.c), which hosts built before there was a version send as zeros: version 0. Raise it with any change to what the machines send each other. */ -#define HALO_PORT_NETWORK_VERSION 14 +#define HALO_PORT_NETWORK_VERSION 15 /* ... the advertisement's reserved bytes: the version (a little-endian word), then flags */ #define HALO_PORT_ADVERTISED_VERSION_OFFSET 0 From 087ca8a875c83fc890eb80a36d4ec2bdfc939c42 Mon Sep 17 00:00:00 2001 From: xshxdex98 Date: Mon, 5 Oct 2026 08:05:43 -0500 Subject: [PATCH 50/51] Co-op: every machine stays on the host's BSP Players in big sessions ended up on different BSPs: - The host told clients about a switch only every half second, and not reliably, so a client could predict its player for a while in a BSP it hadn't loaded. A switch is now sent at once, reliably, ahead of the players' new positions. - A client still loading reports so: its input carries the BSP it has loaded. Until it has the host's, the host takes none of its movement (which falls there with no floor) and none of its loading zones. - After a switch, by a trigger, a script or a skipped cutscene, no trigger can switch again until every machine has loaded the new BSP (at most ten seconds), so no client falls two switches behind. - A player the host can't place beside whoever crossed, when a big team runs out of room there, is placed beside another teammate instead of being left where they were. --- port/linux/game/network_coop.c | 20 ++++++++ port/linux/game/network_coop.h | 7 +++ port/linux/game/network_distributed.c | 39 ++++++++++++---- port/linux/game/network_objects.c | 4 ++ source/game/players.c | 66 +++++++++++++++++++++------ 5 files changed, 114 insertions(+), 22 deletions(-) diff --git a/port/linux/game/network_coop.c b/port/linux/game/network_coop.c index 9e38dd31..0c189e94 100644 --- a/port/linux/game/network_coop.c +++ b/port/linux/game/network_coop.c @@ -945,6 +945,10 @@ static struct real power; } host_sent_devices[MAXIMUM_OBJECTS_PER_MAP]; +/* host: the structure BSP each client player's machine last said it has, +NONE before its first input */ +static short host_player_structure_bsps[MAXIMUM_TRACKED_PLAYERS]; + /* host, each tick: the devices that moved or changed power (and, with the resent state, all that ever have) */ static void host_send_device_states( @@ -1903,6 +1907,7 @@ void network_coop_new_game( csmemset(host_sent_transforms, 0, sizeof(host_sent_transforms)); csmemset(host_sent_looks, 0, sizeof(host_sent_looks)); csmemset(host_sent_devices, 0, sizeof(host_sent_devices)); + csmemset(host_player_structure_bsps, NONE, sizeof(host_player_structure_bsps)); csmemset(&host_sent_screen_effect, 0, sizeof(host_sent_screen_effect)); csmemset(&host_resend, 0, sizeof(host_resend)); csmemset(&players_vitality, 0, sizeof(players_vitality)); @@ -2582,6 +2587,21 @@ word network_coop_device_group_entry_size( return sizeof(struct distributed_coop_device_group); } +void network_coop_note_player_structure_bsp( + short player_index, + short structure_bsp_index) +{ + if (player_index >= 0 && player_index < MAXIMUM_TRACKED_PLAYERS) + host_player_structure_bsps[player_index] = structure_bsp_index; +} + +boolean network_coop_player_has_structure_bsp( + long player_index) +{ + return !network_coop_active() || player_get(player_index)->local_player_index != NONE || + host_player_structure_bsps[DATUM_INDEX_TO_ABSOLUTE_INDEX(player_index)] == global_structure_bsp_index_get(); +} + word network_coop_device_state_entry_size( void) { diff --git a/port/linux/game/network_coop.h b/port/linux/game/network_coop.h index 306f6ee7..30c330d7 100644 --- a/port/linux/game/network_coop.h +++ b/port/linux/game/network_coop.h @@ -52,6 +52,13 @@ void network_coop_note_device_snap(short group_index); /* unit_scripting_commands.c: a script set a unit's maximum or current vitality; TRUE if it was a co-op player's, now set on every player's */ boolean network_coop_set_players_vitality(long unit_index, boolean maximum, real body, real shield); +/* host: whether the player's machine has the host's structure BSP loaded +(always outside co-op, and for the host's own players). Until a client has, +the host takes none of its players' movement, which falls there with no +floor, and lets them trigger no BSP switch (players.c). */ +boolean network_coop_player_has_structure_bsp(long player_index); +/* network_distributed.c: the structure BSP a client's input says it has */ +void network_coop_note_player_structure_bsp(short player_index, short structure_bsp_index); /* units.c: a vehicle left the vehicle carrying it (a Pelican's Warthog drop) */ void network_coop_vehicle_dropped(long vehicle_index, long carrier_index); diff --git a/port/linux/game/network_distributed.c b/port/linux/game/network_distributed.c index 73aa6497..454f0a50 100644 --- a/port/linux/game/network_distributed.c +++ b/port/linux/game/network_distributed.c @@ -320,7 +320,9 @@ ticks before it */ struct distributed_player_input { byte player_index; - byte pad[3]; + /* co-op: the structure BSP the client has loaded */ + byte structure_bsp_index; + byte pad[2]; /* the client's tick */ long tick; /* the latest host tick the client has had a message of (the host @@ -2083,9 +2085,14 @@ static void distributed_apply_predictions( { distributed_predictions[player_index].valid = FALSE; distributed_predictions[player_index].taken_host_time = NONE; - /* (of the unit it has now: not one of a life before) */ - if (unit_index != NONE && unit_index == state->unit_index) + /* (of the unit it has now: not one of a life before; and not + from a co-op client still loading the host's BSP, whose player + falls there with no floor under it) */ + if (unit_index != NONE && unit_index == state->unit_index && + network_coop_player_has_structure_bsp(unit_get(unit_index)->unit.player_index)) + { distributed_take_prediction(player_index, unit_index, &bound); + } } /* (what the host's next tick starts from) */ if (unit_index != NONE) @@ -2257,6 +2264,7 @@ static void distributed_client_send_inputs( if (!update_client_distributed_input(local_player_index, &input->tick, &input->action, input->control_flags)) continue; input->player_index = distributed_player_to_byte(player_index); + input->structure_bsp_index = (byte)global_structure_bsp_index_get(); input->host_time = distributed_host_time; count++; } @@ -2330,6 +2338,7 @@ static void distributed_handle_inputs( /* (a spectator's buttons pick whom it watches: not counted) */ if (network_coop_active() && player->unit_index != NONE) distributed_note_input_actions(input->player_index, &action); + network_coop_note_player_structure_bsp(input->player_index, input->structure_bsp_index); update_server_handle_distributed_input(DATUM_INDEX_NEW(input->player_index, player->identifier), input->tick, &action, input->control_flags, DISTRIBUTED_INPUT_HISTORY); if (input->host_time != NONE && (host_time == NONE || input->host_time > host_time)) @@ -3001,10 +3010,13 @@ static void distributed_send_pings( } /* Co-op: the host's structure BSP. Clients switch BSP only when told -(their own trigger volumes don't, players.c). It is resent regularly so a -client that lost a message or joined late catches up. */ +(their own trigger volumes don't, players.c). A switch is sent at once and +reliably; it is also resent regularly so a client that joined late catches +up. */ #define STRUCTURE_BSP_INTERVAL_TICKS (TICKS_PER_SECOND / 2) +static short distributed_sent_structure_bsp_index = NONE; + struct distributed_structure_bsp { short structure_bsp_index; @@ -3021,10 +3033,14 @@ static void distributed_send_structure_bsp( void) { struct distributed_structure_bsp_message message; + short index = global_structure_bsp_index_get(); + boolean switched = index != distributed_sent_structure_bsp_index; - message.structure_bsp.structure_bsp_index = global_structure_bsp_index_get(); + message.structure_bsp.structure_bsp_index = index; message.structure_bsp.pad = 0; - distributed_send(&message, _distributed_message_structure_bsp, 1, (word)sizeof(message), _distributed_to_clients); + distributed_send(&message, _distributed_message_structure_bsp, 1, (word)sizeof(message), + switched ? _distributed_to_clients_reliably : _distributed_to_clients); + distributed_sent_structure_bsp_index = index; } static void distributed_handle_structure_bsp( @@ -3287,10 +3303,15 @@ void network_distributed_tick( distributed_statistics_due = FALSE; if (game_time_get() % PING_INTERVAL_TICKS == 0) distributed_send_pings(); + /* (before the players, so clients load a new BSP before they hear + where the host moved everyone into it) */ + if (network_coop_active() && (global_structure_bsp_index_get() != distributed_sent_structure_bsp_index || + game_time_get() % STRUCTURE_BSP_INTERVAL_TICKS == 0)) + { + distributed_send_structure_bsp(); + } distributed_host_send_players(); network_actors_host_tick(); - if (network_coop_active() && game_time_get() % STRUCTURE_BSP_INTERVAL_TICKS == 0) - distributed_send_structure_bsp(); network_coop_host_tick(); distributed_send_pickups(); if (game_time_get() % GAME_STATE_INTERVAL_TICKS == 0) diff --git a/port/linux/game/network_objects.c b/port/linux/game/network_objects.c index a59b562d..3f0f6a62 100644 --- a/port/linux/game/network_objects.c +++ b/port/linux/game/network_objects.c @@ -1795,6 +1795,10 @@ void network_objects_apply_vehicle_predictions( { continue; } + /* (not from a co-op client still loading the host's BSP: the + vehicle falls there with no ground under it) */ + if (!network_coop_player_has_structure_bsp(driver->unit.player_index)) + continue; dx = state->position.x - vehicle->object.position.x; dy = state->position.y - vehicle->object.position.y; dz = state->position.z - vehicle->object.position.z; diff --git a/source/game/players.c b/source/game/players.c index 17dfb825..98623f10 100644 --- a/source/game/players.c +++ b/source/game/players.c @@ -470,9 +470,10 @@ static struct long respawn_wait_since; /* when each player's unit left the loaded structure BSP */ long stranded_since[NETWORK_GAME_MAXIMUM_PLAYER_COUNT]; - /* no BSP switch trigger counts before this game time (one has just - switched, and the players are being brought into the new BSP) */ - long bsp_switch_allowed_time; + /* the structure BSP loaded, and when it last switched, by a trigger, + a script or a skipped cutscene (players_coop_bsp_switch_ready) */ + short structure_bsp_index; + long bsp_switch_time; /* the structure BSPs the players have been in this map, by bit */ word visited_structure_bsps; /* where each player last stood on the ground, and on which BSP: where @@ -2771,8 +2772,10 @@ someone who is only crossing a seam or riding over a gap. */ #define COOP_STRANDED_TICKS (2 * TICKS_PER_SECOND) /* After a BSP switch, how long the co-op host lets no other trigger switch -it while the players are brought into the new one */ +it while the players are brought into the new one, and the longest it then +waits for every machine to have loaded it */ #define COOP_BSP_SWITCH_SETTLE_TICKS (2 * TICKS_PER_SECOND) +#define COOP_BSP_SWITCH_WAIT_TICKS (10 * TICKS_PER_SECOND) /* Going back to a BSP the team has already been in needs two thirds of the living players at the trigger: inside it, or this near the player in it @@ -2987,10 +2990,37 @@ static boolean players_coop_in_bsp_switch_trigger( return FALSE; } +/* port: whether a network co-op host lets any trigger switch the BSP yet. +After a switch it waits COOP_BSP_SWITCH_SETTLE_TICKS while the players are +brought in, then until every player's machine has loaded the new BSP, so no +client falls two switches behind. A client stuck longer than +COOP_BSP_SWITCH_WAIT_TICKS no longer holds the team. */ +static boolean players_coop_bsp_switch_ready( + void) +{ + long since = game_time_get() - players_coop_state.bsp_switch_time; + struct data_iterator iterator; + struct player_datum *player; + + if (since < COOP_BSP_SWITCH_SETTLE_TICKS) + return FALSE; + if (since >= COOP_BSP_SWITCH_WAIT_TICKS) + return TRUE; + data_iterator_new(&iterator, player_data); + while ((player = data_iterator_next(&iterator)) != NULL) + { + if (player->unit_index != NONE && !network_coop_player_has_structure_bsp(iterator.datum_index)) + return FALSE; + } + + return TRUE; +} + /* port: whether a network co-op host lets the unit on this trigger switch -the BSP, which brings the whole team along. Anyone can lead the team into a -BSP it hasn't been in; going back to one needs two thirds of the team -there, so one player can't drag everyone back through the level. */ +the BSP, which brings the whole team along. Not while the unit's machine +is still loading the BSP the host has. Anyone can lead the team into a BSP +it hasn't been in; going back to one needs two thirds of the team there, +so one player can't drag everyone back through the level. */ static boolean players_coop_bsp_switch_allowed( short bsp_switch_trigger_volume_index, long unit_index) @@ -3004,6 +3034,8 @@ static boolean players_coop_bsp_switch_allowed( short living_count = 0; short gathered_count = 0; + if (!network_coop_player_has_structure_bsp(unit_get(unit_index)->unit.player_index)) + return FALSE; if (!VALID_INDEX(volume->destination_structure_bsp_index, MAXIMUM_STRUCTURE_BSPS_PER_SCENARIO) || !TEST_FLAG(players_coop_state.visited_structure_bsps, volume->destination_structure_bsp_index)) { @@ -3053,7 +3085,7 @@ static void player_teleport_on_bsp_switch( whoever crossed it, as split screen does. Only one BSP is ever loaded, so anyone left elsewhere would stand outside it, or on a trigger that switches it straight back (players_update_after_game - waits COOP_BSP_SWITCH_SETTLE_TICKS before another switch). */ + waits for players_coop_bsp_switch_ready before another switch). */ if (network_coop_active()) { outside_switch_trigger = TRUE; @@ -3093,6 +3125,10 @@ static void player_teleport_on_bsp_switch( player_index, source_unit_index, position); + /* port: a big co-op team runs out of room around the + crosser, so the rest go beside whoever is already in */ + if (!teleport_succeeded && network_coop_active()) + teleport_succeeded = player_place_beside_teammate(player_index); } else { @@ -4550,6 +4586,11 @@ void players_update_after_game( } if (VALID_INDEX(global_structure_bsp_index, MAXIMUM_STRUCTURE_BSPS_PER_SCENARIO)) SET_FLAG(players_coop_state.visited_structure_bsps, global_structure_bsp_index, TRUE); + if (players_coop_state.structure_bsp_index != global_structure_bsp_index) + { + players_coop_state.structure_bsp_index = global_structure_bsp_index; + players_coop_state.bsp_switch_time = game_time_get(); + } data_iterator_new(&iterator, player_data); while (player = data_iterator_next(&iterator)) @@ -4597,11 +4638,10 @@ void players_update_after_game( root_object_index = object_get_ultimate_parent(player->unit_index); root_object = object_get(root_object_index); /* port: a co-op client only switches BSP when the host does - (network_distributed.c), and the host not again until the players - are in the new one */ + (network_distributed.c), and the host not again until everyone + has the new one */ if (!TEST_FLAG(root_object->object.flags, _object_outside_of_map_bit) && - !(network_coop_active() && - (network_game_distributed_client() || game_time_get() < players_coop_state.bsp_switch_allowed_time))) + !(network_coop_active() && (network_game_distributed_client() || !players_coop_bsp_switch_ready()))) { scenario = global_scenario_get(); for (bsp_switch_trigger_volume_index = 0; @@ -4637,7 +4677,7 @@ void players_update_after_game( bsp_switch_trigger_volume_index; main_switch_structure_bsp( bsp_switch_trigger_volume->destination_structure_bsp_index); - players_coop_state.bsp_switch_allowed_time = game_time_get() + COOP_BSP_SWITCH_SETTLE_TICKS; + players_coop_state.bsp_switch_time = game_time_get(); } } } From b525bf85141cc94dae9bea8014338d3465d301ad Mon Sep 17 00:00:00 2001 From: xshxdex98 Date: Mon, 5 Oct 2026 08:09:47 -0500 Subject: [PATCH 51/51] Network version 16 A client's input says which structure BSP it has loaded (co-op). --- port/linux/NETCODE.md | 10 ++++++++-- port/linux/include/halo_port_limits.h | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/port/linux/NETCODE.md b/port/linux/NETCODE.md index 98d9da3a..df63df06 100644 --- a/port/linux/NETCODE.md +++ b/port/linux/NETCODE.md @@ -47,7 +47,12 @@ with ideas from VALORANT's netcode articles, keeping the 30 Hz tick: host's is once that stops, or if they drift apart while it moves; - which named objects exist, so scripted creates and deletes match. - Every machine follows the host's structure BSP. A loading zone into a + Every machine follows the host's structure BSP: a switch is sent at once + and reliably, and each client's input says which BSP it has loaded. + Until a client has the host's, the host takes none of its players' + movement and none of its loading zones, and after any switch no loading + zone switches again until every machine has the new BSP (ten seconds at + most). A loading zone into a BSP the team hasn't been in brings every player to whoever crossed it, as split screen does; one back into a BSP it has been in switches only with two thirds of the living players at it (in the trigger, or within @@ -153,7 +158,8 @@ actors on its clients and sends the flinches and deaths the host picked; version 13 drives up to 1056 of the host's AI units on its clients (co-op's extra enemies), where 12 drove 288; version 14 sends co-op's device positions and its units opening and closing; version 15 sends co-op's allegiances with -its presentation, a message of another size. +its presentation, a message of another size; version 16 has a client's input +say which structure BSP it has loaded (co-op). A client plays by its host's rules: in another's game (searching for it, in its lobby, or playing it) the developer console, the telnet console diff --git a/port/linux/include/halo_port_limits.h b/port/linux/include/halo_port_limits.h index 8f4eaa7b..7107cdcb 100644 --- a/port/linux/include/halo_port_limits.h +++ b/port/linux/include/halo_port_limits.h @@ -66,7 +66,7 @@ is newer (network_client_manager.c). A host advertises it, with its netcode, in its game's advertisement's reserved bytes (network_server_message_handler.c), which hosts built before there was a version send as zeros: version 0. Raise it with any change to what the machines send each other. */ -#define HALO_PORT_NETWORK_VERSION 15 +#define HALO_PORT_NETWORK_VERSION 16 /* ... the advertisement's reserved bytes: the version (a little-endian word), then flags */ #define HALO_PORT_ADVERTISED_VERSION_OFFSET 0