From 8724dbc986ad332c8f29c2d092be4633a7e442cf Mon Sep 17 00:00:00 2001 From: Guven <185004906+GuvenHuso@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:17:33 +0000 Subject: [PATCH 01/32] Start StageKit's games one at a time, and again when the binary is busy conftest.py's sessions() plays the scenarios side by side from a thread pool, and launch() puts a copy of the game in each scenario's folder. On CI dist/ and /tmp are on different file systems, so os.link fails and shutil.copy writes the copy. If another thread forks while that copy is open for writing, its child holds the descriptor until it runs its own program, and starting the copied game in that time fails with OSError: [Errno 26] Text file busy. That failed all five obstacle tests in CI run 40916137686 (PR #521). launch() now copies the game and starts it under one lock, so no other launch forks while a copy is open (Popen returns only once the child has run the game). The forks of generate()'s tools in other threads are not under the lock, so the start is tried up to five times, a tenth of a second apart, when it fails with ETXTBSY; any other error is raised at once. Checked on Windows, where the scenarios skip without a Linux game: the tests still collect and skip, and a stand-in for Popen showed the retry (two busy failures, then started), giving up after five, other errors raised at once, and eight threads each getting a whole copy. Not yet run on Linux; CI's Linux job runs the scenarios. Co-Authored-By: Claude Opus 5.5 --- tools/stagekit/tests/runner.py | 39 +++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/tools/stagekit/tests/runner.py b/tools/stagekit/tests/runner.py index 79ef59393f..0ae24b4e9e 100644 --- a/tools/stagekit/tests/runner.py +++ b/tools/stagekit/tests/runner.py @@ -1,12 +1,14 @@ """StageKit's tests' tools (README.md): build the generator, write a scenario's map, validate it, and play it. """ +import errno import os import re import shlex import shutil import struct import subprocess +import threading import time from pathlib import Path @@ -23,6 +25,12 @@ # a map file's first words: 'head' and the version, the Xbox's 5 (xbox_files.c's map_file_version) MAP_HEADER_SIGNATURE = 0x68656164 XBOX_MAP_VERSION = 5 +# launch's copies of the game and its starts, one at a time (conftest.py's sessions play side by side): a copy open +# for writing when another thread forks stays open in that child until it runs its program, and the game's start +# fails while it is (ETXTBSY, "Text file busy") +LAUNCHING = threading.Lock() +# the tries at starting the game, a tenth of a second apart, for the other threads' forks (generate's tools) +LAUNCH_TRIES = 5 def build(folder): @@ -142,15 +150,6 @@ def launch(machine, input, address, broadcast, test, init="", **environment): for name in ("debug.txt", "game.log"): (machine / name).unlink(missing_ok=True) (machine / "runtime").mkdir(mode=0o700, exist_ok=True) - # (the game, and the libraries it finds beside it, in machine's own folder: a desktop build keeps its - # config.toml beside the executable, so each machine has its own settings) - for source in [GAME, *GAME.parent.glob("lib*.so*")]: - target = machine / ("halo" if source == GAME else source.name) - target.unlink(missing_ok=True) - try: - os.link(source, target) - except OSError: - shutil.copy(source, target) # (none of the caller's own settings; system link on the loopback only, no internet play) environment = dict({name: value for name, value in os.environ.items() if not name.startswith("HALO_")}, HALO_NET_ONLINE="false", SDL_AUDIODRIVER="dummy", SDL_VIDEODRIVER="offscreen", HALO_NO_AUDIO="1", @@ -158,9 +157,25 @@ def launch(machine, input, address, broadcast, test, init="", **environment): HALO_SAVE_ROOT=str(machine / "save"), XDG_RUNTIME_DIR=str(machine / "runtime"), HOME=str(machine), HALO_NETWORK_TEST=test, HALO_NET_ADDRESS=address, HALO_NET_BROADCAST=broadcast, **environment) - with open(machine / "game.log", "w") as log: - return subprocess.Popen([str(machine / "halo")], cwd=machine, env=environment, stdout=log, - stderr=subprocess.STDOUT) + with LAUNCHING, open(machine / "game.log", "w") as log: + # (the game, and the libraries it finds beside it, in machine's own folder: a desktop build keeps its + # config.toml beside the executable, so each machine has its own settings; copied when they are on + # another file system, as on CI) + for source in [GAME, *GAME.parent.glob("lib*.so*")]: + target = machine / ("halo" if source == GAME else source.name) + target.unlink(missing_ok=True) + try: + os.link(source, target) + except OSError: + shutil.copy(source, target) + for attempt in range(LAUNCH_TRIES): + try: + return subprocess.Popen([str(machine / "halo")], cwd=machine, env=environment, stdout=log, + stderr=subprocess.STDOUT) + except OSError as error: + if error.errno != errno.ETXTBSY or attempt == LAUNCH_TRIES - 1: + raise + time.sleep(0.1) # the first tick every player has a unit, from which a scenario's seconds (and cues) count (network_test.c) From 0fc89f36033772f84c0fe9f6d9d4dfc81e041199 Mon Sep 17 00:00:00 2001 From: chestahh Date: Fri, 30 Oct 2026 02:29:34 +0000 Subject: [PATCH 02/32] Fix remapped menu navigation and add a camera cycle binding The main-menu UI map has a live player but its root widgets belong to NONE, so the per-player widget check incorrectly treated it as gameplay. Keep physical navigation there while retaining per-player pause ownership. Give custom layouts a separate Cycle Camera action in every gameplay camera context. Keep timing and release guards in the remapper and pass the result through the director's existing selection and client-permission checks. Save v3 layouts with 26 actions; accept older layouts without overwriting existing debug bindings. Default layouts and network contracts are unchanged. Add production-code regression coverage for menu ownership, camera entry and exit, rebinding, clearing, migration, timing, per-port state and lifecycle. Keep current keyboard/mouse camera fixes and update their harness stub. Register the integration suite in CI and update the controls documentation. Reviewed the combined implementation, interfaces, state lifetime and tests. On OpenCE testing: all 25 controller/HUD pytest cases and all 12 startup/ camera harness cases and negative controls passed with 32-bit clang. Diff whitespace checks passed. The unchanged upstream web suite has Windows path and POSIX-socket limitations; full platform validation remains for CI. No physical-controller test. Player-Note: Remapped controllers keep D-pad and Back working in the main menu. Player-Note: Bind Cycle Camera to enter and leave flying-camera mode with custom controls. (cherry picked from commit f1e6ec0a42c36735e03e3f2dc6841d0015eb267e) --- .github/workflows/build.yml | 2 +- docs/reading/controller_remapping.md | 49 ++++-- port/linux/README.md | 2 +- port/linux/game/controller_remap_game.c | 12 +- port/linux/game/controller_remap_menu.inl | 6 +- port/linux/include/controller_remap.h | 2 + port/linux/src/controller_remap.c | 15 +- port/linux/src/controller_remap_input.c | 21 +++ port/linux/src/port_config.c | 4 +- source/camera/director.c | 5 +- tools/fixtures/controller_remap_context.c | 68 +++++++++ tools/harness/tests/controller_remap_camera.c | 7 +- .../harness/tests/controller_remap_startup.c | 1 + tools/test_controller_remap.py | 93 +++++++++-- tools/test_controller_remap_context.py | 144 ++++++++++++++++++ 15 files changed, 384 insertions(+), 47 deletions(-) create mode 100644 tools/fixtures/controller_remap_context.c create mode 100644 tools/test_controller_remap_context.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 22f9604832..182cb5dadf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -96,7 +96,7 @@ jobs: # clients need the retail maps, and are skipped python -m pytest -q tools/test_dedicated_server.py python tools/test_light_storage.py - python -m pytest -q tools/test_controller_buttons.py tools/test_controller_remap.py tools/test_hud_button_style.py tools/test_hud_corrections.py + python -m pytest -q tools/test_controller_buttons.py tools/test_controller_remap.py tools/test_controller_remap_context.py tools/test_hud_button_style.py tools/test_hud_corrections.py - name: Compile cache run: ccache --show-stats diff --git a/docs/reading/controller_remapping.md b/docs/reading/controller_remapping.md index 4d256fc7a8..e71be9ddb1 100644 --- a/docs/reading/controller_remapping.md +++ b/docs/reading/controller_remapping.md @@ -32,14 +32,23 @@ Voice uses the existing shared microphone and first player's voice routing; any slot can hold its assigned Push to Talk button. Enable push-to-talk in voice settings. Keyboard and touch retain their own bindings. -Debug includes Camera Speed Up/Down, Camera / Player Switch, Camera Raise/Lower. -These reuse buttons independently of gameplay. They apply only to an already -available flying, orbiting or editor camera; they never enable a camera or bypass -the game's permissions. The switch changes whether input controls the camera or +Debug includes Camera Speed Up/Down, Camera / Player Switch, Camera Raise/Lower +and Cycle Camera. These reuse buttons independently of gameplay. Hold Cycle Camera +for one second to advance through the director's views: first person, flying, +following, then first person again during normal play. Release between view changes. +Its default is RB/Black. The existing `director_camera_switch_fast` debug setting +changes it to one cycle per press. The director still refuses cycling for clients +in another machine's game; remapping does not bypass that restriction. + +The other debug actions apply only to an already available flying, orbiting or +editor camera. Camera / Player Switch changes whether input controls the camera or player. While controlling the player with a debug camera available, only the debug switch takes priority over gameplay. While controlling the camera, the whole debug layout takes priority. Defaults match stock camera controls: D-pad Up/Down, -Right Stick, RT and LT. All can be changed or cleared. +Right Stick, RT and LT. All can be changed or cleared. Cycle Camera also works +while controlling the player; its button can still perform its gameplay action, +just as the stock RB shortcut does. Clearing Cycle Camera disables custom camera +cycling, including the old shortcut through the translated gameplay buttons. Keyboard R/F still raise and lower the camera with a custom layout. Holding the middle mouse button gives the mouse camera controls priority, with the wheel adjusting speed. Camera movement uses the player's movement and look settings. @@ -64,7 +73,10 @@ Xbox and CE retain their existing glyph renderers, sizes and anchoring. Capture, gameplay, menus and camera contexts have independent release guards. 4. [controller_remap_game.c](../../port/linux/game/controller_remap_game.c) maps physical ports to local players, reads profile and camera context, supplies - digital movement and adjusts a temporary copy of prompt preferences. + digital movement and adjusts a temporary copy of prompt preferences. Its main-menu + guard is separate from per-player widgets: the UI map has a live player, while + the main-menu root belongs to any player (`NONE`). Pause menus retain their + per-player ownership so other split-screen players can continue playing. 5. The minimal hooks in [xinput_sdl.c](../../port/linux/src/xinput_sdl.c) provide physical input, per-port chat navigation and push-to-talk. Chat's existing [network_chat.c](../../port/linux/game/network_chat.c) editor retains its normal @@ -72,7 +84,11 @@ Xbox and CE retain their existing glyph renderers, sizes and anchoring. version change is needed: existing chat packets already identify their sender, and the host already validates that sender against the sending machine. 6. Small `port:` hooks in [director.c](../../source/camera/director.c) supply camera - availability and map cleanup; [input_abstraction.c](../../source/input/input_abstraction.c) + availability and map cleanup, and pass the custom cycle request to the existing + camera selector. `controller_remap_camera_cycle` owns its physical-button timing + in the input module; the director owns camera selection and permissions, which + cannot be implemented solely in that module. No map-family selector is changed. + [input_abstraction.c](../../source/input/input_abstraction.c) applies digital movement; [hud_messaging.c](../../source/interface/hud_messaging.c) maps prompt identity and renders UNBOUND. Those private engine states cannot be accessed solely from a feature module. No map loading path is changed. @@ -80,13 +96,13 @@ Xbox and CE retain their existing glyph renderers, sizes and anchoring. ## Persistence and lifecycle `input.controller_p1` through `input.controller_p4` default to empty strings. -New custom layouts use `v2:` followed by 25 comma-separated entries in this order: +New custom layouts use `v3:` followed by 26 comma-separated entries in this order: ``` jump, grenade type, use/reload, weapon, melee, flashlight, grenade, fire, crouch, zoom, scoreboard, screenshot, forward, backward, left, right, all chat, team chat, vehicle chat, push to talk, camera faster, camera slower, camera/player switch, -camera raise, camera lower +camera raise, camera lower, cycle camera ``` Entries are physical tokens: `south,east,west,north,rb,lb,lt,rt,ls,rs,dpad_up, @@ -95,7 +111,10 @@ PlayStation and B on Switch. Tokens must be unique within gameplay/communication and separately within Debug; `unbound` may repeat. Unbound saves remain distinct from an empty setting (profile defaults). -Old ten-action strings remain readable. Their original actions take priority over +Old ten-action and 25-action `v2:` strings remain readable. The appended Cycle +Camera defaults to RB unless an old debug action already owns RB, in which case +Cycle Camera starts unbound and can be assigned in the Debug group. Existing +assignments are preserved. Ten-action strings' original actions take priority over new defaults, including Recon's use of D-pad Left. P1 retains the Up chat shortcut; other slots default to Up movement. Additional chat channels, voice and screenshot start unbound. Saving writes the new format. Custom digital movement now has the @@ -114,6 +133,10 @@ pressure, capture, disconnects, focus and context changes, semantic commands, prompt identity and pending menu state. Chat ownership and hold-over checks use the real production functions. [Camera routing tests](../../tools/harness/tests/test_controller_remap_camera.py) check stock and remapped camera input alongside keyboard and mouse controls, -including mouse priority and menu/chat blocking. Tests distinguish those contracts from physical -driver behaviour. Runtime screenshots, Xbox/CE gameplay checks and real-controller playtesting -remain separate validation steps for rendering and device-specific behaviour. +including mouse priority and menu/chat blocking. +[Context integration tests](../../tools/test_controller_remap_context.py) +combine the real widget-ownership check, remapper and director input for main-menu +navigation, pause, camera cycling, alternate bindings, timing and lifecycle guards. +Tests distinguish those contracts from physical driver behaviour. Runtime +screenshots and Xbox/CE gameplay checks complement the tests; +real-controller playtesting is still needed for device-specific behaviour. diff --git a/port/linux/README.md b/port/linux/README.md index 3aa9192b79..d2f80041d8 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -506,7 +506,7 @@ the setting for one start of the game. It has priority over the file. | `display.hud_button_style` | `classic` | `HALO_HUD_BUTTON_STYLE` | Manual high-res gameplay button style: `classic`, `gems`, `menu_gems` (Xbox Dark Gems), `xbox_modern`, `ps_classic`, `ps_gems`, `ps_dark_gems`, `ps_modern`, `switch_classic`, `switch_gems`, `switch_modern`. Existing Xbox artwork and saved tokens are unchanged. Applies to Xbox and Custom Edition prompts. | | `input.auto_button_detection` | `true` | `HALO_AUTO_BUTTON_DETECTION` | Gamepads > Button Appearance: AUTO BUTTON DETECTION restricts Button Style to the menu player's assigned controller family. Off retains all manual choices; device detection still runs. Unknown/disconnected devices use the manual style. Artwork changes require High-Res HUD in Graphics. Existing saved choices are preserved. | | `display.hud_bumper_style` | `original` | `HALO_HUD_BUMPER_STYLE` | Gamepads > Button Appearance: BUMPER STYLE offers `original` (Original Xbox black/white buttons) and `modern` (LB/RB), only for Xbox Classic, Xbox Gems and Xbox Dark Gems. Shared by local players and retained with automatic detection on. Requires High-Res HUD; other styles keep their own bumpers. | -| `input.controller_p1` | `""` | `HALO_CONTROLLER_P1` | P1 custom controller layout, including D-pad, communication, debug camera and unbound actions; empty uses the profile preset. Edit in Gamepad Setup > Button Remapping. See [format and behavior](../../docs/reading/controller_remapping.md). | +| `input.controller_p1` | `""` | `HALO_CONTROLLER_P1` | P1 custom controller layout, including D-pad, communication, debug camera and unbound actions; empty uses the profile preset. Debug includes Cycle Camera (hold RB/Black for one second by default) to enter and leave flying view. Edit in Gamepad Setup > Button Remapping. See [format and behavior](../../docs/reading/controller_remapping.md). | | `input.controller_p2` | `""` | `HALO_CONTROLLER_P2` | P2 custom controller layout; empty uses the profile preset. Edit in Gamepad Setup > Button Remapping. See [format and behavior](../../docs/reading/controller_remapping.md). | | `input.controller_p3` | `""` | `HALO_CONTROLLER_P3` | P3 custom controller layout; empty uses the profile preset. Edit in Gamepad Setup > Button Remapping. See [format and behavior](../../docs/reading/controller_remapping.md). | | `input.controller_p4` | `""` | `HALO_CONTROLLER_P4` | P4 custom controller layout; empty uses the profile preset. Edit in Gamepad Setup > Button Remapping. See [format and behavior](../../docs/reading/controller_remapping.md). | diff --git a/port/linux/game/controller_remap_game.c b/port/linux/game/controller_remap_game.c index 44d6c68524..f88b5d8adb 100644 --- a/port/linux/game/controller_remap_game.c +++ b/port/linux/game/controller_remap_game.c @@ -61,7 +61,8 @@ short controller_remap_local_player(short port) int controller_remap_playing(short port) { short local = controller_remap_local_player(port); - return remap_game_in_progress() && local != NONE && !ui_widgets_active_for_local_player(local) && + /* The main menu has a live UI-map player, but its widgets belong to NONE. */ + return remap_game_in_progress() && !main_menu_is_active() && local != NONE && !ui_widgets_active_for_local_player(local) && !console_is_active() && network_chat_controller() != port; } @@ -75,14 +76,14 @@ int controller_remap_context(short port) return 1 + camera_context[port]; } -/* The director supplies availability. This never enables a camera or - * bypasses the game's camera permissions. */ +/* The director supplies the free-camera context and retains camera selection + * and permissions. Cycle input is available in the normal player context too. */ int controller_remap_camera(short local, int available, int controlling, unsigned int *held, unsigned int *pressed) { long player = local_player_get_player_index(local); short port; - unsigned int mask = ((1u << CONTROLLER_REMAP_ACTIONS) - 1) & ~((1u << PAD_CAMERA_FASTER) - 1); + unsigned int mask = ((1u << PAD_CAMERA_CYCLE) - 1) & ~((1u << PAD_CAMERA_FASTER) - 1); *held = *pressed = 0; if (player == NONE) return 0; port = player_get(player)->local_player_index; @@ -92,7 +93,8 @@ int controller_remap_camera(short local, int available, int controlling, unsigned char layout[CONTROLLER_REMAP_ACTIONS]; if (!controller_remap_read(port, layout)) { camera_context[port] = 0; return 0; } } - if (!available || !controller_remap_connected(port)) return 0; + /* Custom cycling also owns the shortcut before a debug camera is active. */ + if (!controller_remap_connected(port)) return 0; *held = controller_remap_held(port) & mask; *pressed = controller_remap_pressed(port, mask); return 1; diff --git a/port/linux/game/controller_remap_menu.inl b/port/linux/game/controller_remap_menu.inl index f6f295cc35..8ee77b8a75 100644 --- a/port/linux/game/controller_remap_menu.inl +++ b/port/linux/game/controller_remap_menu.inl @@ -5,9 +5,9 @@ static wchar_t const *const pad_action_labels[CONTROLLER_REMAP_ACTIONS] = { L"FLASHLIGHT", L"THROW GRENADE", L"FIRE", L"CROUCH", L"ZOOM", L"SCOREBOARD", L"SCREENSHOT", L"MOVE FORWARD", L"MOVE BACKWARD", L"STRAFE LEFT", L"STRAFE RIGHT", L"ALL CHAT", L"TEAM CHAT", L"VEHICLE CHAT", L"PUSH TO TALK", - L"CAMERA SPEED UP", L"CAMERA SPEED DOWN", L"CAMERA / PLAYER SWITCH", L"CAMERA RAISE", L"CAMERA LOWER" + L"CAMERA SPEED UP", L"CAMERA SPEED DOWN", L"CAMERA / PLAYER SWITCH", L"CAMERA RAISE", L"CAMERA LOWER", L"CYCLE CAMERA" }; -static short const pad_action_groups[CONTROLLER_REMAP_ACTIONS] = {0,1,1,1,1,2,1,1,0,2,2,2,0,0,0,0,3,3,3,3,4,4,4,4,4}; +static short const pad_action_groups[CONTROLLER_REMAP_ACTIONS] = {0,1,1,1,1,2,1,1,0,2,2,2,0,0,0,0,3,3,3,3,4,4,4,4,4,4}; static struct { unsigned char buttons[4][CONTROLLER_REMAP_ACTIONS]; @@ -198,7 +198,7 @@ static void pad_editor_update(struct widget_instance *list) if (group == 3) snprintf(caption, sizeof(caption), "Choose buttons for chat and push-to-talk.\r\nChat belongs to the player who opens it.\r\nVoice uses the shared microphone and the\r\nfirst player's team and vehicle channels."); else if (group == 4) - snprintf(caption, sizeof(caption), "These buttons control an available debug camera.\r\nThey do not enable the camera.\r\nThey can reuse your gameplay buttons.\r\nCamera / Player Switch changes who you control."); + snprintf(caption, sizeof(caption), "Hold Cycle Camera for one second to change views.\r\nCamera / Player Switch changes who you control.\r\nOther actions control an available debug camera.\r\nThese buttons can reuse your gameplay buttons."); } } for (i = 0; caption[i] && i < 255; i++) text[i] = (wchar_t)(unsigned char)caption[i]; diff --git a/port/linux/include/controller_remap.h b/port/linux/include/controller_remap.h index 4fe6719edb..2c9a0de0e3 100644 --- a/port/linux/include/controller_remap.h +++ b/port/linux/include/controller_remap.h @@ -12,6 +12,7 @@ enum controller_remap_action PAD_SCOREBOARD, PAD_SCREENSHOT, PAD_FORWARD, PAD_BACKWARD, PAD_LEFT, PAD_RIGHT, PAD_CHAT, PAD_TEAM_CHAT, PAD_VEHICLE_CHAT, PAD_TALK, PAD_CAMERA_FASTER, PAD_CAMERA_SLOWER, PAD_CAMERA_SWITCH, PAD_CAMERA_UP, PAD_CAMERA_DOWN, + PAD_CAMERA_CYCLE, CONTROLLER_REMAP_ACTIONS }; @@ -41,6 +42,7 @@ int controller_remap_camera(short local_player, int available, int controlling, unsigned int controller_remap_held(short port); unsigned int controller_remap_pressed(short port, unsigned int mask); int controller_remap_connected(short port); +int controller_remap_camera_cycle(short port, int fast); int controller_remap_talk(void); void controller_remap_movement(short port, float *forward, float *strafe); struct game_input_preferences; diff --git a/port/linux/src/controller_remap.c b/port/linux/src/controller_remap.c index a8c531000e..2015085e92 100644 --- a/port/linux/src/controller_remap.c +++ b/port/linux/src/controller_remap.c @@ -14,7 +14,7 @@ static char const *const remap_names[] = {"south","east","west","north","rb","lb void controller_remap_defaults(unsigned char *buttons) { static unsigned char const defaults[CONTROLLER_REMAP_ACTIONS] = { - 0,4,2,3,1,5,6,7,14,15, 13,255,255,9,10,11, 8,255,255,255, 8,9,15,7,6 + 0,4,2,3,1,5,6,7,14,15, 13,255,255,9,10,11, 8,255,255,255, 8,9,15,7,6,4 }; memcpy(buttons, defaults, sizeof(defaults)); } @@ -60,8 +60,9 @@ int controller_remap_parse(char const *text, unsigned char *buttons) unsigned char parsed[CONTROLLER_REMAP_ACTIONS]; int action, i, count; if (!text || !*text) return 0; - count = !strncmp(text, "v2:", 3) ? CONTROLLER_REMAP_ACTIONS : CONTROLLER_REMAP_GAME_ACTIONS; - if (count == CONTROLLER_REMAP_ACTIONS) text += 3; + count = !strncmp(text, "v3:", 3) ? CONTROLLER_REMAP_ACTIONS : + !strncmp(text, "v2:", 3) ? 25 : CONTROLLER_REMAP_GAME_ACTIONS; + if (count != CONTROLLER_REMAP_GAME_ACTIONS) text += 3; controller_remap_defaults(parsed); for (action = 0; action < count; action++) { @@ -82,6 +83,10 @@ int controller_remap_parse(char const *text, unsigned char *buttons) else if (*text) return 0; } if (count == CONTROLLER_REMAP_GAME_ACTIONS) controller_remap_complete(parsed); + /* Older debug layouts keep their buttons if RB is already assigned. */ + if (count < CONTROLLER_REMAP_ACTIONS && + controller_remap_conflict(parsed, PAD_CAMERA_CYCLE, parsed[PAD_CAMERA_CYCLE]) >= 0) + parsed[PAD_CAMERA_CYCLE] = CONTROLLER_REMAP_UNBOUND; memcpy(buttons, parsed, sizeof(parsed)); return 1; } @@ -93,7 +98,7 @@ void controller_remap_format(unsigned char const *buttons, char *text, unsigned if (!size) return; *text = 0; if (size < 4) return; - strcpy(text, "v2:"); + strcpy(text, "v3:"); for (action = 0; action < CONTROLLER_REMAP_ACTIONS; action++) { char const *name = "unbound"; @@ -119,7 +124,7 @@ int controller_remap_read(short port, unsigned char *buttons) { char const *text = config_string(controller_remap_setting((short)i)); valid[i] = controller_remap_parse(text, layouts[i]); - if (valid[i] && i && strncmp(text, "v2:", 3)) + if (valid[i] && i && strncmp(text, "v2:", 3) && strncmp(text, "v3:", 3)) { layouts[i][PAD_CHAT] = CONTROLLER_REMAP_UNBOUND; layouts[i][PAD_FORWARD] = 8; diff --git a/port/linux/src/controller_remap_input.c b/port/linux/src/controller_remap_input.c index c02d71f5b5..5c5324747b 100644 --- a/port/linux/src/controller_remap_input.c +++ b/port/linux/src/controller_remap_input.c @@ -23,6 +23,7 @@ static struct { int custom, context, connected; unsigned int instance, blocked, held, pressed; + Uint64 cycle_since, cycle_count; unsigned char layout[CONTROLLER_REMAP_ACTIONS], targets[CONTROLLER_REMAP_ACTIONS]; } remap_ports[4]; @@ -165,6 +166,7 @@ int controller_remap_input(short port, XINPUT_GAMEPAD *pad, int connected) allowed = context == 3 ? ((1u << CONTROLLER_REMAP_ACTIONS) - (1u << PAD_CAMERA_FASTER)) : (1u << PAD_CAMERA_FASTER) - 1; if (context == 2) allowed |= 1u << PAD_CAMERA_SWITCH; + allowed |= 1u << PAD_CAMERA_CYCLE; if (!custom) allowed = 0; /* Stock input retains its original shortcuts. */ for (i = 0; i < CONTROLLER_REMAP_ACTIONS; i++) { @@ -175,6 +177,11 @@ int controller_remap_input(short port, XINPUT_GAMEPAD *pad, int connected) if (context == 2 && i < PAD_CAMERA_FASTER && button == layout[PAD_CAMERA_SWITCH]) continue; if ((held & ~remap_ports[port].blocked) & (1u << button)) actions |= 1u << i; } + if ((actions & (1u << PAD_CAMERA_CYCLE)) && !(remap_ports[port].held & (1u << PAD_CAMERA_CYCLE))) + { + remap_ports[port].cycle_since = SDL_GetTicks(); + remap_ports[port].cycle_count = 0; + } remap_ports[port].pressed |= actions & ~remap_ports[port].held; remap_ports[port].held = actions; if (controller_remap_pressed(port, 1u << PAD_SCREENSHOT)) platform_screenshot_request(); @@ -221,6 +228,20 @@ unsigned int controller_remap_pressed(short port, unsigned int mask) return result; } +/* Keep the stock hold-to-cycle shortcut independent of gameplay translation. + * Context changes use the same release guard as other remapped actions. */ +int controller_remap_camera_cycle(short port, int fast) +{ + Uint64 count; + unsigned int pressed = controller_remap_pressed(port, 1u << PAD_CAMERA_CYCLE); + if (!(controller_remap_held(port) & (1u << PAD_CAMERA_CYCLE))) return 0; + if (fast) return pressed != 0; + count = (SDL_GetTicks() - remap_ports[port].cycle_since) / 1000; + if (count <= remap_ports[port].cycle_count) return 0; + remap_ports[port].cycle_count = count; + return 1; +} + int controller_remap_talk(void) { short port; diff --git a/port/linux/src/port_config.c b/port/linux/src/port_config.c index 8588a61578..1b7a592f53 100644 --- a/port/linux/src/port_config.c +++ b/port/linux/src/port_config.c @@ -190,8 +190,8 @@ static const struct config_setting config_settings[] = "keep the manual choice. Device detection itself is always available." }, { "input.controller_p1", _config_string, "\"\"", "HALO_CONTROLLER_P1", _environment_value, _platform_all, "P1 custom controller layout; empty uses the profile. Edit in Gamepads >\n" - "Button Remapping. v2: stores 25 physical-button names, including unbound.\n" - "Gameplay and debug camera have separate bindings. Old ten-action layouts\n" + "Button Remapping. v3: stores 26 physical-button names, including unbound.\n" + "Gameplay and debug camera have separate bindings. Old ten-action and v2 layouts\n" "are accepted. See docs/reading/controller_remapping.md for the full order." }, { "input.controller_p2", _config_string, "\"\"", "HALO_CONTROLLER_P2", _environment_value, _platform_all, "P2 custom controller buttons, same format as controller_p1. Empty uses the profile." }, diff --git a/source/camera/director.c b/source/camera/director.c index 14747d7395..bcbaf5f77c 100644 --- a/source/camera/director.c +++ b/source/camera/director.c @@ -875,8 +875,9 @@ static boolean director_update_controls( last_ticks[local_player_index] = ticks; } - /* port: a debug binding must not also cycle the camera. */ - if (remapped) switch_camera = FALSE; + /* port: cycle the camera from its physical binding in every context; + * the director's existing camera selection still enforces permissions. */ + if (remapped) switch_camera = controller_remap_camera_cycle((short)player_index, director_camera_switch_fast); if (director->camera_proc != (director_camera_update_proc)first_person_camera_update && director->camera_proc != diff --git a/tools/fixtures/controller_remap_context.c b/tools/fixtures/controller_remap_context.c new file mode 100644 index 0000000000..e7e3322927 --- /dev/null +++ b/tools/fixtures/controller_remap_context.c @@ -0,0 +1,68 @@ +/* Fake UI/player/director state for production controller integration tests. */ + +#define NONE -1 +#define TRUE 1 +#define MAXIMUM_NUMBER_OF_LOCAL_PLAYERS 4 +#define csmemcpy memcpy +#define csmemset memset +#define match_vassert(file,line,condition,message) assert(condition) +typedef int boolean; +typedef unsigned char byte; +typedef float real; +typedef struct {real i,j;} real_vector2d; +typedef struct {real yaw,pitch;} real_euler_angles2d; +static real_vector2d player_control_port_debug_throttle[4]; +static real_euler_angles2d player_control_port_debug_facing[4]; +static void halo_linux_debug_camera_input(int *middle,int *up,int *down,float *wheel){ + *middle=*up=*down=0;*wheel=0; +} +struct widget_instance {short local_player_index;}; +static struct widget_instance root_widget = {-1}; +static struct {int initialized; struct widget_instance *active_widgets[4];} widget_globals = {1,{&root_widget,0,0,0}}; +struct game_input_preferences {unsigned char game_control_to_xbox_buttons[12];}; +static struct player_datum {short local_player_index;long unit_index;struct {int deaths;} statistics;} fake_player = {0}; +static int main_menu, console_open, chat_owner = NONE; +static int main_menu_is_active(void){return main_menu;} +static int console_is_active(void){return console_open;} +static int game_time_initialized(void){return 1;} +static int game_in_progress(void){return 1;} +static short local_player_get_next(short p){return p<0?0:NONE;} +static long local_player_get_player_index(short p){return p==0?0:NONE;} +#define player_get(p) (&fake_player) +short network_chat_controller(void){return (short)chat_owner;} +static void input_abstraction_get_local_player_preferences(short p,struct game_input_preferences *out){ + unsigned char b[CONTROLLER_REMAP_ACTIONS]; int i; memset(out,0,sizeof(*out)); controller_remap_defaults(b); + for(i=0;i<10;i++)out->game_control_to_xbox_buttons[controller_remap_controls[i]]=b[i]; +} +typedef void (*director_camera_update_proc)(void); +static void first_person_camera_update(void){} +static void following_camera_update(void){} +static void flying_camera_update(void){} +static void editor_camera_update(void){} +static void orbiting_camera_update(void){} +static struct director {director_camera_update_proc camera_proc; int debug_controls; float debug_input_scale; + struct {float delta;} debug_variables[4];char camera_data[32];} director_state = {first_person_camera_update,0,1,{{0}}}; +static struct {float dtime;} director_globals = {1.0f/30}; +static struct director *director_get(short p){return &director_state;} +static int director_camera_switch_fast; +#define TICKS_PER_SECOND 30 +enum {_gamepad_analog_button_black=4,_gamepad_analog_button_right_trigger=7,_gamepad_analog_button_left_trigger=6, + _gamepad_binary_button_right_thumb=15,_gamepad_binary_button_dpad_up=8,_gamepad_binary_button_dpad_down=9}; +enum {_gamepad_stick_left,_gamepad_stick_right}; +enum {_variable_height,_variable_roll,_variable_forward,_variable_right}; +enum {_camera_control_forward_bit,_camera_control_reverse_bit,_camera_control_left_bit,_camera_control_right_bit, + _camera_control_up_bit,_camera_control_down_bit,_camera_control_roll_left_bit,_camera_control_roll_right_bit}; +enum {_key_backspace,_key_tab,_key_w,_key_s,_key_a,_key_d,_key_r,_key_f,_key_t,_key_g}; +struct camera_control {short local_player_index;float seconds_elapsed,wheel_delta;int active; + struct {float yaw,pitch,roll;} facing_delta; struct {float i,j,k;} position_delta;}; +static struct gamepad_state {byte buttons[16];struct {short x,y;} sticks[2];} gamepad; +struct mouse_state {int x,y,wheel_delta,buttons[3];}; +static int input_has_gamepad(short p){return 1;} +static struct gamepad_state const *input_get_gamepad_state(short p){return &gamepad;} +static struct mouse_state const *input_get_mouse_state(void){return NULL;} +static int input_key_is_down(int key){return 0;} +static int passenger_camera_enabled(short p){return 0;} +#define SET_FLAG(v,b,on) ((v) = ((v)&~(1u<<(b))) | ((on)?(1u<<(b)):0)) +#define director_process_variables(p,f,w) ((void)0) +#define director_inhibit_input(p) ((void)0) +#define director_inhibit_facing(p) ((void)0) diff --git a/tools/harness/tests/controller_remap_camera.c b/tools/harness/tests/controller_remap_camera.c index eb8233c503..ea945f31c3 100644 --- a/tools/harness/tests/controller_remap_camera.c +++ b/tools/harness/tests/controller_remap_camera.c @@ -60,7 +60,12 @@ static int controller_remap_camera(short local, int available, int controlling, unsigned int *out_held, unsigned int *out_pressed) { *out_held = held; *out_pressed = pressed; - return custom && available; + return custom; +} +static int controller_remap_camera_cycle(short port, int fast) +{ + CHECK(port == 2 && fast, "camera cycle controller port and mode"); + return (pressed & FLAG(PAD_CAMERA_CYCLE)) != 0; } static int controller_remap_playing(short port) { CHECK(port == 2, "remap controller port"); return playing; } static void halo_linux_debug_camera_input(int *out_middle, int *up, int *down, float *out_wheel) diff --git a/tools/harness/tests/controller_remap_startup.c b/tools/harness/tests/controller_remap_startup.c index 488fc2d99c..192617ddf3 100644 --- a/tools/harness/tests/controller_remap_startup.c +++ b/tools/harness/tests/controller_remap_startup.c @@ -29,6 +29,7 @@ static short local_player_get_next(short local) { return local == NONE ? 0 : NON static long local_player_get_player_index(short local) { return local; } static struct player_datum *player_get(long player) { return &players[player]; } static boolean ui_widgets_active_for_local_player(short local) { return FALSE; } +static boolean main_menu_is_active(void) { return FALSE; } static boolean console_is_active(void) { return FALSE; } static short network_chat_controller(void) { return NONE; } diff --git a/tools/test_controller_remap.py b/tools/test_controller_remap.py index d565ec6f92..13a5cec5d6 100644 --- a/tools/test_controller_remap.py +++ b/tools/test_controller_remap.py @@ -35,32 +35,32 @@ def source(path): def test_policy_migration_unbound_replace_and_independent_ports(tmp_path): run_c(tmp_path, COMMON + POLICY + r''' int main(void) { - unsigned char a[25],b[25],original[25]; char text[512]; int i,j; + unsigned char a[CONTROLLER_REMAP_ACTIONS],b[CONTROLLER_REMAP_ACTIONS],original[CONTROLLER_REMAP_ACTIONS]; char text[512]; int i,j; controller_remap_defaults(original); - for(i=0;i<25;i++) for(j=0;j<15;j++) { - memcpy(a,original,25); controller_remap_assign(a,i,remap_buttons[j]); + for(i=0;i=0) {if(button<8)pad.bAnalogButtons[button]=255;else pad.wButtons=digital_masks[button-8];} + controller_remap_input(0,&pad,1);physical_values(&pad,values); + for(i=0;i<16;i++)gamepad.buttons[i]=values[i]?(gamepad.buttons[i]?2:1):0; + return director_update_controls(0,&controls); +} +''') + + +def test_main_menu_and_pause_keep_physical_buttons(tmp_path): + run_c(tmp_path, integrated_source() + r''' +int main(void) { + XINPUT_GAMEPAD pad={0},expected; + layout_save();main_menu=1; + assert(!ui_widgets_active_for_local_player(0)); /* The main menu belongs to any player. */ + assert(!controller_remap_playing(0)); + controller_remap_input(0,&pad,1); + pad.wButtons=15;pad.bAnalogButtons[1]=255;expected=pad; + controller_remap_input(0,&pad,1);assert(!memcmp(&pad,&expected,sizeof(pad))); + /* Pause uses an explicit player. Other split-screen players can still play. */ + main_menu=0;root_widget.local_player_index=0; + pad=expected;controller_remap_input(0,&pad,1);assert(!memcmp(&pad,&expected,sizeof(pad))); + root_widget.local_player_index=1;assert(controller_remap_playing(0)); + /* Holding a menu button on return to gameplay must not trigger its binding. */ + widget_globals.active_widgets[0]=NULL; + pad=expected;controller_remap_input(0,&pad,1);assert(!controller_remap_held(0)); + memset(&pad,0,sizeof(pad));controller_remap_input(0,&pad,1); + pad.wButtons=4;controller_remap_input(0,&pad,1);assert(controller_remap_held(0)&(1u< Date: Sat, 31 Oct 2026 10:28:46 +0000 Subject: [PATCH 03/32] Let each controller stick's dead zone be set, in percent A worn stick that drifts needs a larger dead zone than the game's own (9000 of 32767, about 27%), and a player who wants finer aim a smaller one. Each stick's dead zone is now a setting, input.left_stick_dead_zone and input.right_stick_dead_zone: -1 (DEFAULT, the game's own) or 0% to 50% in steps of 5. A new STICK DEAD ZONES screen under Gamepad Setup sets them, after BUTTON REMAPPING and BUTTON APPEARANCE. - source/input/input_xbox.c reads each stick's dead range from halo_stick_dead_range (port/linux/src/xinput_sdl.c) instead of GAMEPAD_STICK_DEAD_RANGE, which stays the default. - The check that the right stick, not the mouse, aims last uses the right stick's dead zone when it is set below that check's deflection. - On Android and the web the touch controls' stick goes through the same path, so the left setting applies to it too. - Gamepad Setup's screen is written again by tools/port_settings.py: eleven rows at its 27px step would put the last in the help, so it takes a 25px step and Video Setup's help place. Nothing the machines send each other changes: the dead zone only shapes a player's own input before it is predicted and sent. Checked: the settings screens' generator and the menu files are the same on testing as where this was written, so the generated XML is unchanged; the Windows build; the harness against testing. Co-Authored-By: Claude Opus 5.5 --- ..._profile_edit.gamepad_setup.dead_zones.xml | 62 +++++++++++++++++++ ...etup.player_profile_edit.gamepad_setup.xml | 31 +++++++--- port/assets/menus/menus.json | 1 + port/linux/README.md | 2 + port/linux/include/halo_linux_source_fixups.h | 4 ++ port/linux/src/port_config.c | 8 +++ port/linux/src/xinput_sdl.c | 38 +++++++++++- source/input/input_xbox.c | 10 +-- tools/port_settings.py | 23 +++++++ 9 files changed, 163 insertions(+), 16 deletions(-) create mode 100644 port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.gamepad_setup.dead_zones.xml diff --git a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.gamepad_setup.dead_zones.xml b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.gamepad_setup.dead_zones.xml new file mode 100644 index 0000000000..49292dde51 --- /dev/null +++ b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.gamepad_setup.dead_zones.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.gamepad_setup.xml b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.gamepad_setup.xml index 9f5f5f1ade..83063c961b 100644 --- a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.gamepad_setup.xml +++ b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.gamepad_setup.xml @@ -9,20 +9,21 @@ - + - - - - - - - - - + + + + + + + + + + @@ -118,6 +119,14 @@ + + + + + + + + @@ -139,6 +148,7 @@ + @@ -152,5 +162,6 @@ + diff --git a/port/assets/menus/menus.json b/port/assets/menus/menus.json index 7c54ff9c5c..803f8cbd77 100644 --- a/port/assets/menus/menus.json +++ b/port/assets/menus/menus.json @@ -48,6 +48,7 @@ "ce/main_menu.settings_select.player_setup.player_profile_edit.controller_appearance.xml", "ce/main_menu.settings_select.player_setup.player_profile_edit.controller_remapping.xml", "ce/main_menu.settings_select.player_setup.player_profile_edit.controls_setup.xml", + "ce/main_menu.settings_select.player_setup.player_profile_edit.gamepad_setup.dead_zones.xml", "ce/main_menu.settings_select.player_setup.player_profile_edit.gamepad_setup.xml", "ce/main_menu.settings_select.player_setup.player_profile_edit.mouse_settings.xml", "ce/main_menu.settings_select.player_setup.player_profile_edit.name_edit.xml", diff --git a/port/linux/README.md b/port/linux/README.md index 3aa9192b79..7356ddeeac 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -556,6 +556,8 @@ the setting for one start of the game. It has priority over the file. | `input.mouse_sensitivity` | `1.0` | `HALO_MOUSE_SENSITIVITY` | The multiplier for the mouse aim. | | `input.mouse_vertical_sensitivity` | `0.0` | `HALO_MOUSE_VERTICAL_SENSITIVITY` | The multiplier for the vertical mouse aim. `0`: the same as `input.mouse_sensitivity`. | | `input.playstation_kernel_driver` | `false` | `HALO_PLAYSTATION_KERNEL_DRIVER` | Linux: `true` drives a PlayStation controller (DualSense, DualShock 4) through the kernel's driver of it (`hid-playstation` since Linux 5.12, `hid-sony`): its buttons, sticks, triggers and rumble, with no setup, but not its lightbar. `false`: SDL's own driver, which needs access to the controller's `/dev/hidraw` device, as Steam's udev rules give it (`steam-devices`, or Steam installed). Read at start-up. | +| `input.left_stick_dead_zone` | `-1` | `HALO_LEFT_STICK_DEAD_ZONE` | How far the controller's left stick moves before it moves the player, in percent of its travel (`0` to `50`). Lower for finer control, higher for a worn stick that drifts. `-1`: the game's own (9000 of 32767, about 27%). On Android, the touch controls' stick too. Settings > Gamepads > Stick Dead Zones sets it. | +| `input.right_stick_dead_zone` | `-1` | `HALO_RIGHT_STICK_DEAD_ZONE` | The same for the right stick, which turns the view. | | `input.invert_mouse` | `false` | `HALO_MOUSE_INVERT=1` sets `true` | `true`: the vertical mouse aim is inverted. | | `input.mouse_aim_assist` | `false` | `HALO_MOUSE_AIM_ASSIST` | `true`: the magnetism of the controller also operates for the mouse. `false`: when the mouse moved after the right stick, the view is not slowed or dragged by a target. The autoaim of the bullets operates in both cases. | | `controls.` | (the table in "Controls") | `HALO_KEY_` | The keys and mouse buttons of an action, up to two, separated by a comma: `move_forward`, `move_backward`, `strafe_left`, `strafe_right`, `jump`, `crouch`, `fire`, `throw_grenade`, `melee`, `reload`, `zoom`, `switch_weapon`, `switch_grenade`, `action`, `flashlight`, `scoreboard`, `pause`, `screenshot`, `push_to_talk`, `chat`, `team_chat`, `vote_yes`, `vote_no`. Keys by their names on a US keyboard (`"W"`, `"Space"`, `"Left Ctrl"`, `"F1"`): a key is the one in that place on any keyboard, which Controls Setup shows by its own label (the US name on Android). Buttons: `"Mouse Left"`, `"Mouse Right"`, `"Mouse Middle"`, `"Mouse 4"`, `"Mouse 5"`, `"Wheel"` (either way), `"Wheel Up"`, `"Wheel Down"`. | diff --git a/port/linux/include/halo_linux_source_fixups.h b/port/linux/include/halo_linux_source_fixups.h index ca01b2e756..5d92b1b27b 100644 --- a/port/linux/include/halo_linux_source_fixups.h +++ b/port/linux/include/halo_linux_source_fixups.h @@ -123,6 +123,10 @@ void server_console_output(const char *text); int halo_server_debug_echo(void); /* ... the level its rotation plays next, NULL if none (game_engine.c) */ char const *server_director_first_level(void); +/* the deflection of a stick (0 the left, 1 the right) read as none, of +32767: input.left_stick_dead_zone and input.right_stick_dead_zone, else the +game's own (port/linux/src/xinput_sdl.c) */ +short halo_stick_dead_range(short stick); /* the mouse in the menus (source/interface/ui_widget.c) */ #include "halo_ui_pointer.h" /* the debug camera's mouse and keys (source/camera/director.c): whether the diff --git a/port/linux/src/port_config.c b/port/linux/src/port_config.c index 8588a61578..4f0069bbbf 100644 --- a/port/linux/src/port_config.c +++ b/port/linux/src/port_config.c @@ -413,6 +413,14 @@ static const struct config_setting config_settings[] = _platform_desktop, "How far the view turns up and down for the mouse's movement; 0 for the\n" "same as input.mouse_sensitivity." }, + { "input.left_stick_dead_zone", _config_integer, "-1", "HALO_LEFT_STICK_DEAD_ZONE", _environment_value, _platform_all, + "How far the controller's left stick moves before it moves the player,\n" + "in percent of its travel (0 to 50): lower for finer control, higher for\n" + "a worn stick that drifts; -1 for the game's own (27%). On Android, the\n" + "touch controls' stick too." }, + { "input.right_stick_dead_zone", _config_integer, "-1", "HALO_RIGHT_STICK_DEAD_ZONE", _environment_value, _platform_all, + "How far the controller's right stick moves before it turns the view, in\n" + "percent of its travel (0 to 50); -1 for the game's own (27%)." }, /* the keyboard and mouse's own controls (port/linux/src/xinput_sdl.c) */ { "controls.move_forward", _config_string, "\"W\"", "HALO_KEY_MOVE_FORWARD", _environment_value, _platform_all, diff --git a/port/linux/src/xinput_sdl.c b/port/linux/src/xinput_sdl.c index 0aee98db21..c7119c7211 100644 --- a/port/linux/src/xinput_sdl.c +++ b/port/linux/src/xinput_sdl.c @@ -154,6 +154,35 @@ static float mouse_sensitivity(void) return sensitivity; } +/* the game's own dead zone of each stick, of 32767 (source/input/input_xbox.c) */ +#define STICK_DEAD_RANGE 9000 +/* the most a stick's dead zone setting takes, in percent */ +#define STICK_DEAD_ZONE_MAXIMUM 50 + +/* the deflection a stick reads as none, of 32767, for the game's sticks +(stick 0 the left, 1 the right): input.left_stick_dead_zone and +input.right_stick_dead_zone as a percentage, else the game's own */ +short halo_stick_dead_range(short stick) +{ + static const char *const settings[2] = { "input.left_stick_dead_zone", "input.right_stick_dead_zone" }; + static short ranges[2] = { STICK_DEAD_RANGE, STICK_DEAD_RANGE }; + static unsigned long read_at = (unsigned long)-1; + int index; + + if (read_at != config_changes()) + { + read_at = config_changes(); + for (index = 0; index < 2; index++) + { + long percent = config_integer(settings[index]); + + ranges[index] = percent < 0 || percent > STICK_DEAD_ZONE_MAXIMUM + ? STICK_DEAD_RANGE : (short)(percent * 32767 / 100); + } + } + return ranges[stick ? 1 : 0]; +} + /* radians of yaw and pitch for the mouse motion since the last call; the game adds these to the facing change of the player on gamepad 0 */ int halo_linux_mouse_look(short gamepad_index, float *yaw, float *pitch) @@ -1642,6 +1671,7 @@ DWORD WINAPI XInputGetState(HANDLE device, PXINPUT_STATE state) struct platform_input_state input; unsigned long held; BOOL console_active; + short aiming_deflection; platform_input_read(&input, TRUE); mouse_poll(&input); @@ -1684,8 +1714,12 @@ DWORD WINAPI XInputGetState(HANDLE device, PXINPUT_STATE state) #if defined(HALO_ANDROID) || defined(HALO_WEB) touch_input_controls(&state->Gamepad, input.menus); #endif - if (abs(state->Gamepad.sThumbRX) > STICK_AIMING_DEFLECTION || - abs(state->Gamepad.sThumbRY) > STICK_AIMING_DEFLECTION) + /* (a right stick dead zone set below it aims from that) */ + aiming_deflection = halo_stick_dead_range(1); + if (aiming_deflection > STICK_AIMING_DEFLECTION) + aiming_deflection = STICK_AIMING_DEFLECTION; + if (abs(state->Gamepad.sThumbRX) > aiming_deflection || + abs(state->Gamepad.sThumbRY) > aiming_deflection) { pthread_mutex_lock(&mouse_lock); stick_aimed_ms = SDL_GetTicks(); diff --git a/source/input/input_xbox.c b/source/input/input_xbox.c index 5fdb12b581..4f3a96983f 100644 --- a/source/input/input_xbox.c +++ b/source/input/input_xbox.c @@ -961,18 +961,20 @@ static void input_get_device_states( input_globals.raw_gamepad_states[gamepad_index].sticks[_gamepad_stick_right].x = input_state.Gamepad.sThumbRX; input_globals.raw_gamepad_states[gamepad_index].sticks[_gamepad_stick_right].y = input_state.Gamepad.sThumbRY; + /* port: each stick's dead range is a setting (halo_stick_dead_range), the + game's GAMEPAD_STICK_DEAD_RANGE by default */ gamepad_state->sticks[_gamepad_stick_left].x = fix_dead_zone( input_state.Gamepad.sThumbLX, - GAMEPAD_STICK_DEAD_RANGE); + halo_stick_dead_range(_gamepad_stick_left)); gamepad_state->sticks[_gamepad_stick_left].y = fix_dead_zone( input_state.Gamepad.sThumbLY, - GAMEPAD_STICK_DEAD_RANGE); + halo_stick_dead_range(_gamepad_stick_left)); gamepad_state->sticks[_gamepad_stick_right].x = fix_dead_zone( input_state.Gamepad.sThumbRX, - GAMEPAD_STICK_DEAD_RANGE); + halo_stick_dead_range(_gamepad_stick_right)); gamepad_state->sticks[_gamepad_stick_right].y = fix_dead_zone( input_state.Gamepad.sThumbRY, - GAMEPAD_STICK_DEAD_RANGE); + halo_stick_dead_range(_gamepad_stick_right)); } else { diff --git a/tools/port_settings.py b/tools/port_settings.py index 4ff053feea..4d5a69c44d 100644 --- a/tools/port_settings.py +++ b/tools/port_settings.py @@ -282,6 +282,29 @@ }, } +# the controller's sticks' dead zones (port/linux/src/xinput_sdl.c's +# halo_stick_dead_range): config.toml's, for every profile, a screen of their +# own (a category, as Video Setup's), after the controller's screen's others +# (the game's own, about 27%, first, then 0% to 50% in steps of 5) +DEAD_ZONES = [("DEFAULT", "-1")] + [(f"{percent}%", str(percent)) for percent in range(0, 51, 5)] +SCREENS["gamepad_setup"]["categories"] = SCREENS["gamepad_setup"].get("categories", []) + [ + ("STICK DEAD ZONES", "gamepad_setup/dead_zones", + "How far each stick moves before it does\nanything."), +] +# (eleven places: a 25px step, as Server Setup's 24, puts the last at 323, so +# that the help's two lines go below it as Video Setup's do, clear of the +# buttons, 414; at 27 the last would be in the help) +SCREENS["gamepad_setup"].update({"spacing": 25, "help_top": 358, "help_height": 54}) +SCREENS["gamepad_setup/dead_zones"] = { + "screen": "dead_zone_settings_screen", "header": SCREENS["gamepad_setup"]["header"], "spacing": 30, + "rows": [ + ("LEFT STICK:", "input.left_stick_dead_zone", DEAD_ZONES, + "How far the left stick moves before you move.\nDefault: the game's own, 27%.", None), + ("RIGHT STICK:", "input.right_stick_dead_zone", DEAD_ZONES, + "How far the right stick moves before the view\nturns. Default: the game's own, 27%.", None), + ], +} + # Video Setup's categories open ordinary settings screens. Each has the same # pending edits, Defaults, OK and Cancel as the other settings screens. _video = SCREENS["video_settings"] From 12f5553c77d8a12258137f4c276d250699f53adf Mon Sep 17 00:00:00 2001 From: medic-code Date: Thu, 29 Oct 2026 21:39:43 +0100 Subject: [PATCH 04/32] Show moving dots in a server browser row while its host is timed A row being timed showed the same dash as one never probed, and a closed or live game showed CLOSED or LIVE for the whole timing. p2p_listing gains a probing flag, taken from the game's probing state in game_listed, and the ping cell shows ".", ".." and "..." stepping every 300 ms for a game with no ping whose host is being timed. A known ping stays shown while it is timed again, FAILED still comes first, and a master server's region, CLOSED, LIVE and the dash show when the host is not being timed. The dots have no colour. The cell's text moves into browser_ping_text so that the harness test tools/harness/tests/test_browser_ping_text.py can take it from the source. Checked: ninja linux builds and links; test_browser_ping_text.py passes (8 tests, four of them negative controls each failing its own case). --- port/linux/README.md | 9 +-- port/linux/game/menu_functions.c | 35 ++++++---- port/linux/src/p2p.h | 2 + port/linux/src/p2p_lobby.c | 13 +++- tools/harness/tests/browser_ping_text.c | 70 +++++++++++++++++++ tools/harness/tests/test_browser_ping_text.py | 36 ++++++++++ 6 files changed, 146 insertions(+), 19 deletions(-) create mode 100644 tools/harness/tests/browser_ping_text.c create mode 100644 tools/harness/tests/test_browser_ping_text.py diff --git a/port/linux/README.md b/port/linux/README.md index 3aa9192b79..4936a34a01 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -1130,10 +1130,11 @@ The PING column shows the round trip to each host in milliseconds. The browser times it as a join would reach the host: it makes the tunnel with the listing's invite, keeps the shortest of three pings, and closes the tunnel again. It times a few hosts at once, each again every two minutes -and on REFRESH. Joining a host whose tunnel is open uses that tunnel. A -game with a password, whose invite the listing hides, shows no ping, nor -does a host not reached; a game that is full or starting shows CLOSED. -REFRESH also asks the hosts for their listings again. +and on REFRESH; a game being timed with no ping yet shows moving dots. +Joining a host whose tunnel is open uses that tunnel. A game with a +password, whose invite the listing hides, shows no ping, nor does a host +not reached; a game that is full or starting shows CLOSED. REFRESH also +asks the hosts for their listings again. The gametype column names an OpenCE gametype's kind (SWAT, INFECTION, GUN GAME, or HC and the game engine for Hardcore: HC CTF, HC SLAYER), which the diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index efd61371e6..a83f3c9818 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -5001,6 +5001,28 @@ static void lobby_browser_game_lines(struct p2p_listing const *game, wchar_t *pl } } +/* a game's ping cell (the dots without a colour: pc_menu_text_color +colours a number only) */ +static void browser_ping_text(struct p2p_listing const *game, unsigned long now, wchar_t *text, short length) +{ + static wchar_t const dots[3][4] = { L".", L"..", L"..." }; + + if (game->failed) + browser_word(_browser_word_failed, text, length); + else if (game->ping >= 0) + usnprintf(text, length - 1, L"%d", game->ping); + else if (game->probing) + ustrncpy(text, dots[(now / 300) % 3], length - 1); + else if (game->master && game->region[0]) + region_to_wide(game->region, text, length); + else if (!game->open) + browser_word(_browser_word_closed, text, length); + else if (game->in_progress) + browser_word(_browser_word_live, text, length); + else + usnprintf(text, length - 1, L"-"); +} + /* "gamespy screen update" for the server browser: the games shown, the rows (scrolled on at their ends), the line below them, the counts over the titles, the titles' arrows; a join going on; the error panel */ @@ -5092,18 +5114,7 @@ static void lobby_browser_update(struct widget_instance *list) else { browser_row_marked[place] = FALSE; - if (game->failed) - browser_word(_browser_word_failed, text, ROW_TEXT_LENGTH); - else if (game->ping >= 0) - usnprintf(text, ROW_TEXT_LENGTH - 1, L"%d", game->ping); - else if (game->master && game->region[0]) - region_to_wide(game->region, text, ROW_TEXT_LENGTH); - else if (!game->open) - browser_word(_browser_word_closed, text, ROW_TEXT_LENGTH); - else if (game->in_progress) - browser_word(_browser_word_live, text, ROW_TEXT_LENGTH); - else - usnprintf(text, ROW_TEXT_LENGTH - 1, L"-"); + browser_ping_text(game, now, text, ROW_TEXT_LENGTH); } text_set(named(row, "server_item_ping", 0), text); /* (the lock: a game with a password; the server: a dedicated diff --git a/port/linux/src/p2p.h b/port/linux/src/p2p.h index 145eb6bab2..e64313526e 100644 --- a/port/linux/src/p2p.h +++ b/port/linux/src/p2p.h @@ -158,6 +158,8 @@ struct p2p_listing unsigned char failed; /* milliseconds, -1 if not known */ short ping; + /* its host is being probed for the ping now (a ping known stays) */ + unsigned char probing; /* it has a password: its invite is sealed with it (p2p_listing_unlock) */ unsigned char locked; unsigned char key_hash[P2P_LISTING_KEY_HASH_SIZE]; diff --git a/port/linux/src/p2p_lobby.c b/port/linux/src/p2p_lobby.c index a4429a6a81..888b532bfd 100644 --- a/port/linux/src/p2p_lobby.c +++ b/port/linux/src/p2p_lobby.c @@ -745,6 +745,15 @@ static void listing_take(const struct queued *queued, const struct listing *list shown->network_version = (unsigned short)listing->version; } +/* the game as the browser shows it */ +static void game_listed(const struct game *game, struct p2p_listing *listing) +{ + *listing = game->listing; + listing->failed = (unsigned char)identifier_failed(listing->identifier); + listing->ping = (short)game->ping; + listing->probing = (unsigned char)game->probing; +} + /* the hosts' pings: those not known lately probed, while there is room (a few refused, and the rest wait for a later pass) */ static void update_probes(void) @@ -1169,9 +1178,7 @@ int p2p_lobby_games(struct p2p_listing *games, int maximum_count) { if (!lobby.games[index].used) continue; - games[count] = lobby.games[index].listing; - games[count].failed = (unsigned char)identifier_failed(games[count].identifier); - games[count].ping = (short)lobby.games[index].ping; + game_listed(&lobby.games[index], &games[count]); count++; } pthread_mutex_unlock(&p2p_lock); diff --git a/tools/harness/tests/browser_ping_text.c b/tools/harness/tests/browser_ping_text.c new file mode 100644 index 0000000000..e7abd66c9e --- /dev/null +++ b/tools/harness/tests/browser_ping_text.c @@ -0,0 +1,70 @@ +/* +BROWSER_PING_TEXT.C (test) + +The server browser's ping cell (menu_functions.c's browser_ping_text) for a +game open ('o'), closed ('c'), live ('l'), failed ('f') or a master +server's ('m'), from test_browser_ping_text.py's listing.inc, words.inc and +under_test.inc. +*/ + +#include "harness.h" +#include "listing.inc" +#include "words.inc" + +#include + +#define usnprintf(buffer, size, ...) swprintf(buffer, size, __VA_ARGS__) +#define ustrncpy wcsncpy + +static void browser_word(short word, wchar_t *text, short length) +{ + swprintf(text, length, L"%ls", word == _browser_word_failed ? L"FAILED" : word == _browser_word_closed ? L"CLOSED" : + word == _browser_word_live ? L"LIVE" : L"?"); +} + +static void region_to_wide(char const *region, wchar_t *wide, short size) +{ + swprintf(wide, size, L"%s", region); +} + +#include "under_test.inc" + +static int shows(char state, int ping, int probing, unsigned long now, wchar_t const *expected) +{ + struct p2p_listing game; + wchar_t text[16]; + + memset(&game, 0, sizeof(game)); + game.open = state != 'c'; + game.in_progress = state == 'l'; + game.failed = state == 'f'; + game.master = state == 'm'; + strcpy(game.region, "EU"); + game.ping = (short)ping; + game.probing = (unsigned char)probing; + browser_ping_text(&game, now, text, 16); + return !wcscmp(text, expected); +} + +int main(int argc, char **argv) +{ + const char *case_name = argc > 1 ? argv[1] : ""; + + CASE("number") + CHECK(shows('o', 42, 0, 0, L"42") && shows('o', 42, 1, 700, L"42"), + "a ping shows, kept while timed again every two minutes"); + else CASE("dots") + { + CHECK(shows('o', -1, 1, 299, L".") && shows('o', -1, 1, 300, L"..") && shows('o', -1, 1, 600, L"...") && + shows('o', -1, 1, 900, L"."), "the dots step every 300 ms"); + CHECK(shows('c', -1, 1, 300, L"..") && shows('l', -1, 1, 300, L".."), "a closed or live game timed: dots"); + } + else CASE("status") + CHECK(shows('c', -1, 0, 0, L"CLOSED") && shows('l', -1, 0, 0, L"LIVE") && shows('f', 42, 1, 0, L"FAILED") && + shows('f', -1, 1, 0, L"FAILED") && shows('m', -1, 0, 0, L"EU"), + "CLOSED or LIVE when not timed, FAILED over a ping or dots, a master server's region"); + else CASE("dash") + CHECK(shows('o', -1, 0, 300, L"-"), "an open game not timed shows a dash"); + else return 2; + return 0; +} diff --git a/tools/harness/tests/test_browser_ping_text.py b/tools/harness/tests/test_browser_ping_text.py new file mode 100644 index 0000000000..89bf9d62bb --- /dev/null +++ b/tools/harness/tests/test_browser_ping_text.py @@ -0,0 +1,36 @@ +"""The server browser's ping cell (menu_functions.c's browser_ping_text).""" +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, build, enum_with, function, mutated, read, run, structure + + +def generated(fault=None): + header = read('port/linux/src/p2p.h') + menus = read('port/linux/game/menu_functions.c') + code = function(menus, 'browser_ping_text') + if fault: + code = mutated(code, *fault) + return (('listing.inc', enum_with(header, 'P2P_LISTING_NAME_SIZE') + structure(header, 'p2p_listing')), + ('words.inc', enum_with(menus, '_browser_word_of')), + ('under_test.inc', code)) + + +@pytest.mark.parametrize('case', ['number', 'dots', 'status', 'dash']) +def test_case(case): + status, output = run(build('browser_ping_text', generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize('fault,case', [ + (('now / 300', 'now / 600'), 'dots'), + (('else if (game->probing)', 'else if (game->open && !game->in_progress && game->probing)'), 'dots'), + (('if (game->failed)', 'if (0)'), 'status'), + (('else if (game->ping >= 0)', 'else if (game->ping >= 0 && !game->probing)'), 'number'), +]) +def test_negative_control(fault, case): + status, output = run(build('browser_ping_text', generated(fault)), case) + assert status == CHECK_FAILED, output From c271caa05ce854dc1d9fb24e33f80c198d586a9f Mon Sep 17 00:00:00 2001 From: medic-code Date: Fri, 30 Oct 2026 00:34:04 +0100 Subject: [PATCH 05/32] Time the server browser's hosts in the order shown update_probes walked the games in slot order, which has nothing to do with the screen, so pings filled in scattered over the list. The browser now tells the lobby the games it shows, in its order (p2p_lobby_shown, each frame, after its sort, tab and Filters), and update_probes starts the first of those that want a probe, then the next, with the same cap, refusals, PROBE_REFRESH, token and master server checks as before. Games the list hides (Filters, the other tab) are still timed, after the games shown. A column title chosen mid-probe orders the hosts timed next; probes under way finish where their rows moved to. There is no ping column to sort by. The order comes from the menu rather than from network.browser_sort because the list is filtered before it is sorted: sorting the lobby's games the same way put games the list hides ahead of the rows on screen: on macOS against the live list, sorted by name, hidden "[C] ..." games held all four probe slots for the first 15 s. Each game is started at most once a pass: a refused game stays wanted, so the top game would otherwise be picked until the refusals ran out (always on the web, where p2p_probe_start refuses, and while joining a listed game). No probe starts while listings wait to be verified, so the first wave after the browser opens follows the list, not the order the listings were verified in; a listing verified in the pass the probes start is not shown yet and goes last. tools/p2p_lobby_check.c checks the order, a re-sort, a hidden game last, a refused host passed over, and the wait. Checked: ninja linux builds and links; p2p_lobby_check passes (0 failures), and each new check fails when the code it covers is removed. --- port/linux/README.md | 6 +- port/linux/game/menu_functions.c | 1 + port/linux/src/p2p.h | 4 + port/linux/src/p2p_lobby.c | 69 +++++++++++++---- tools/p2p_lobby_check.c | 128 +++++++++++++++++++++++++++++-- 5 files changed, 184 insertions(+), 24 deletions(-) diff --git a/port/linux/README.md b/port/linux/README.md index 4936a34a01..9a52a9e8b5 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -1129,8 +1129,10 @@ the router, or forward the port: refer to "Connection"). The PING column shows the round trip to each host in milliseconds. The browser times it as a join would reach the host: it makes the tunnel with the listing's invite, keeps the shortest of three pings, and closes the -tunnel again. It times a few hosts at once, each again every two minutes -and on REFRESH; a game being timed with no ping yet shows moving dots. +tunnel again. It times a few hosts at once, top first in the list as it +is shown (its sort, tab and Filters: a new sort orders the hosts timed +next, and the games hidden come last), each again every two minutes and +on REFRESH; a game being timed with no ping yet shows moving dots. Joining a host whose tunnel is open uses that tunnel. A game with a password, whose invite the listing hides, shows no ping, nor does a host not reached; a game that is full or starting shows CLOSED. REFRESH also diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index a83f3c9818..5c4301ecca 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -5046,6 +5046,7 @@ static void lobby_browser_update(struct widget_instance *list) lobby_browser.count = (short)p2p_lobby_filter(lobby_browser.games, lobby_browser.found, &choices, lobby_browser_map_state); p2p_lobby_sort(lobby_browser.games, lobby_browser.count, lobby_browser.order, lobby_browser.reverse); + p2p_lobby_shown(lobby_browser.games, lobby_browser.count); if (focused == BROWSER_ROWS - 1 && lobby_browser.first + BROWSER_ROWS < lobby_browser.count && list_scrolls_at_end()) { lobby_browser.first++; diff --git a/port/linux/src/p2p.h b/port/linux/src/p2p.h index e64313526e..fbcf20aecd 100644 --- a/port/linux/src/p2p.h +++ b/port/linux/src/p2p.h @@ -203,6 +203,10 @@ int p2p_joined_listing_masters(unsigned char (*key_ids)[P2P_LISTING_MASTER_KEY_I /* a copy of the public games found, in the order shown (the most players first; then those not failed, the open ones, by name); returns their count */ int p2p_lobby_games(struct p2p_listing *games, int maximum_count); +/* the games the server browser shows, in its order (its sort, after its +tab and Filters): their hosts are probed in that order, those not shown +last */ +void p2p_lobby_shown(const struct p2p_listing *games, int count); /* the master servers' (port/linux/src/master_list.c, server_master.c): a fresh signed listing of the game this machine hosts for the internet, as the brokers get it (0 if it hosts none, or none public); a master's entry's diff --git a/port/linux/src/p2p_lobby.c b/port/linux/src/p2p_lobby.c index 888b532bfd..58732fcb75 100644 --- a/port/linux/src/p2p_lobby.c +++ b/port/linux/src/p2p_lobby.c @@ -61,8 +61,9 @@ subscribing, a broker sends every slot it keeps, and on a public one most are games long gone, which filled the queue and crowded out live games. Each game's host is probed for its ping (p2p_probe_start: the tunnel to it -timed, p2p.c), a few at once, again each PROBE_REFRESH and on Refresh. A -game with a password is not: its token is sealed. +timed, p2p.c), a few at once, in the order the browser shows the games +(p2p_lobby_shown), those it does not show last, again each PROBE_REFRESH +and on Refresh. A game with a password is not: its token is sealed. */ #include "platform.h" @@ -160,6 +161,8 @@ struct game int ping; int probing; unsigned long probed_time; + /* its place in the browser's list, from 1 (0: not shown) */ + int shown; /* listed by a master server (master_list.c) lately: not probed (the player's address is not told to every server listed), and when */ int master; @@ -754,30 +757,42 @@ static void game_listed(const struct game *game, struct p2p_listing *listing) listing->probing = (unsigned char)game->probing; } -/* the hosts' pings: those not known lately probed, while there is room (a -few refused, and the rest wait for a later pass) */ +/* the hosts' pings: those not known lately probed, in the order shown, +while there is room (a few refused, and the rest wait for a later pass) */ static void update_probes(void) { + unsigned char started[MAXIMUM_GAMES] = { 0 }; int refused = 0; - int index; - for (index = 0; index < MAXIMUM_GAMES && refused < P2P_MAXIMUM_PROBES; index++) + while (refused < P2P_MAXIMUM_PROBES) { - struct game *game = &lobby.games[index]; + struct game *next = NULL; + int index; - if (!game->used || !game->has_token || game->probing || game->master || - (game->probed_time && !elapsed(game->probed_time, PROBE_REFRESH))) + for (index = 0; index < MAXIMUM_GAMES; index++) { - continue; + struct game *game = &lobby.games[index]; + + if (!game->used || !game->has_token || game->probing || game->master || started[index] || + (game->probed_time && !elapsed(game->probed_time, PROBE_REFRESH))) + { + continue; + } + if (!next || (game->shown && (!next->shown || game->shown < next->shown))) + next = game; } + if (!next) + break; + /* (each game once a pass: a refused one stays wanted) */ + started[next - lobby.games] = 1; /* (set first: a probe that needs no tunnel ends at once, p2p_lobby_probed) */ - game->probing = 1; - game->probed_time = (p2p_now() - 1) | 1; - if (!p2p_probe_start(game->key_hash, game->token)) + next->probing = 1; + next->probed_time = (p2p_now() - 1) | 1; + if (!p2p_probe_start(next->key_hash, next->token)) { - game->probing = 0; - game->probed_time = 0; + next->probing = 0; + next->probed_time = 0; refused++; } } @@ -875,7 +890,9 @@ static void update_browsing(void) if (lobby.newer[index].used && elapsed(lobby.newer[index].heard_time, GAME_EXPIRY)) lobby.newer[index].used = 0; } - if (lobby.browsing) + /* (the listings still waiting are shown before the probes start, which + then start in the order shown) */ + if (lobby.browsing && !lobby.queue_count) update_probes(); } @@ -1186,6 +1203,26 @@ int p2p_lobby_games(struct p2p_listing *games, int maximum_count) return count; } +void p2p_lobby_shown(const struct p2p_listing *games, int count) +{ + int place, index; + + pthread_mutex_lock(&p2p_lock); + for (index = 0; index < MAXIMUM_GAMES; index++) + lobby.games[index].shown = 0; + for (place = 0; place < count; place++) + { + for (index = 0; index < MAXIMUM_GAMES; index++) + { + struct game *game = &lobby.games[index]; + + if (game->used && !memcmp(game->listing.identifier, games[place].identifier, P2P_IDENTIFIER_SIZE)) + game->shown = place + 1; + } + } + pthread_mutex_unlock(&p2p_lock); +} + int p2p_listing_unlock(struct p2p_listing *listing, const char *password) { unsigned char key[P2P_PASSWORD_KEY_SIZE]; diff --git a/tools/p2p_lobby_check.c b/tools/p2p_lobby_check.c index ac401e2a11..6008aa87bc 100644 --- a/tools/p2p_lobby_check.c +++ b/tools/p2p_lobby_check.c @@ -13,7 +13,9 @@ older than it each handled as they must be; a game with a password, listed locked (its token nowhere in the listing), opened by its password alone; and the hosts' pings: a game's host probed with its listing's token, its ping listed and kept, probed again on Refresh, and a game with a -password never. Prints PASS or the failures. +password never; and the probes started in the order the games are shown +(p2p_lobby_shown), those not shown last, past one refused, none while +listings wait to be checked. Prints PASS or the failures. */ #include "p2p_internal.h" @@ -28,6 +30,8 @@ password never. Prints PASS or the failures. pthread_mutex_t p2p_lock = PTHREAD_MUTEX_INITIALIZER; static unsigned long clock_now = 1000; +/* the clock's advance for each key hashed (a listing's check takes time) */ +static unsigned long check_cost; static unsigned char seed[P2P_SEED_SIZE] = { 42 }; static unsigned char signing_key[P2P_KEY_SIZE], x25519_secret[P2P_KEY_SIZE], x25519_public[P2P_KEY_SIZE]; static unsigned char published[512]; @@ -53,6 +57,7 @@ void p2p_key_hash(const unsigned char *key, unsigned char *hash) { unsigned char digest[P2P_SHA256_SIZE]; + clock_now += check_cost; p2p_sha256(key, P2P_KEY_SIZE, digest); memcpy(hash, digest, P2P_KEY_HASH_SIZE); } @@ -89,19 +94,30 @@ p2p.c, server_master.c) */ void p2p_token_listed(const unsigned char *token) { (void)token; } void server_master_changed(void) {} -/* the probes asked for (p2p.c's), and the last one's host and token: each -ends at once, its host not reached (as p2p.c's does when it needs no -tunnel) */ -static int probe_count; +/* the probes asked for (p2p.c's), the last one's host and token, and the +hosts in the order probed: each ends at once, its host not reached (as +p2p.c's does when it needs no tunnel), but refused_host's is refused; more +than 64 asked for in one pass stops the check */ +static const unsigned char *refused_host; +static int probe_count, probe_asked; static unsigned char probed_hash[P2P_KEY_HASH_SIZE], probed_token[P2P_TOKEN_SIZE]; +static unsigned char probed_hosts[16][P2P_IDENTIFIER_SIZE]; int p2p_probe_start(const unsigned char *host_hash, const unsigned char *token) { unsigned char host[P2P_IDENTIFIER_SIZE]; + if (++probe_asked > 64) + { + printf("FAIL: a game is probed again and again\n"); + exit(1); + } memcpy(probed_hash, host_hash, P2P_KEY_HASH_SIZE); memcpy(probed_token, token, P2P_TOKEN_SIZE); - probe_count++; p2p_identifier_from_hash(host_hash, host); + if (refused_host && !memcmp(host, refused_host, P2P_IDENTIFIER_SIZE)) + return 0; + memcpy(probed_hosts[probe_count % 16], host, P2P_IDENTIFIER_SIZE); + probe_count++; p2p_lobby_probed(host, -1); return 1; } @@ -110,6 +126,7 @@ int p2p_probe_start(const unsigned char *host_hash, const unsigned char *token) go of it while it checks signatures) */ static void lobby_update(const unsigned char *token, int player_count, int maximum_player_count) { + probe_asked = 0; pthread_mutex_lock(&p2p_lock); p2p_lobby_update(token, player_count, maximum_player_count); pthread_mutex_unlock(&p2p_lock); @@ -403,10 +420,109 @@ static void lobby_checks(void) "a game whose password is taken off is listed open"); } +/* the server browser: the games shown in the order's (reverse: the other +way), and of those the first count only */ +static void show(int order, int reverse, int count) +{ + struct p2p_listing all[8]; + int found = p2p_lobby_games(all, 8); + + p2p_lobby_sort(all, found, order, reverse); + p2p_lobby_shown(all, count < found ? count : found); +} + +/* four games, heard as Alpha, Bravo, Charlie, Delta (Charlie's join failed), +are shown as Bravo, Delta, Charlie, Alpha: their hosts are probed in the +order shown, past Bravo's refused, none while listings wait to be checked; +then in another order, and one not shown last */ +static void probe_order_checks(void) +{ + static const unsigned char token[P2P_TOKEN_SIZE] = { 9, 9, 9 }; + static const char *names[] = { "Alpha", "Bravo", "Charlie", "Delta" }; + static const int player_counts[] = { 2, 9, 5, 5 }, first_wave[] = { 1, 2, 0, 3 }; + unsigned char hash[P2P_KEY_HASH_SIZE], payloads[4][512], identifiers[4][P2P_IDENTIFIER_SIZE]; + char slots[4][2 * P2P_KEY_HASH_SIZE + 1]; + int sizes[4], index; + + /* (the games before expire) */ + clock_now += 91000; + lobby_update(token, 0, 16); + hosting_token = token; + for (index = 0; index < 4; index++) + { + seed[0] = (unsigned char)(index + 1); + p2p_ed25519_public(seed, signing_key, x25519_secret); + p2p_x25519(x25519_public, x25519_secret, NULL); + p2p_set_hosting_public(1); + p2p_set_game_listing(names[index], "bloodgulch", "Slayer", 2, 0, 1, 0, 0); + clock_now += 6000; + lobby_update(token, player_counts[index], 16); + memcpy(payloads[index], published, (size_t)published_size); + sizes[index] = published_size; + p2p_key_hash(x25519_public, hash); + p2p_hex(hash, P2P_KEY_HASH_SIZE, slots[index]); + p2p_identifier_from_hash(hash, identifiers[index]); + p2p_set_hosting_public(0); + lobby_update(token, 0, 16); + } + p2p_lobby_mark_failed(identifiers[2]); + /* heard in one pass, Bravo refused */ + probe_count = 0; + refused_host = identifiers[1]; + pthread_mutex_lock(&p2p_lock); + for (index = 0; index < 4; index++) + p2p_lobby_slot_heard(slots[index], payloads[index], sizes[index], 0); + pthread_mutex_unlock(&p2p_lock); + lobby_update(token, 0, 16); + refused_host = NULL; + check(probe_count == 3, "the games after one refused are probed"); + /* gone, then heard again in a burst that takes four passes to check */ + clock_now += 91000; + lobby_update(token, 0, 16); + probe_count = 0; + check_cost = 5; + pthread_mutex_lock(&p2p_lock); + for (index = 0; index < 4; index++) + p2p_lobby_slot_heard(slots[index], payloads[index], sizes[index], 0); + pthread_mutex_unlock(&p2p_lock); + for (index = 0; index < 3; index++) + { + lobby_update(token, 0, 16); + check(probe_count == 0, "no probe starts while listings wait to be checked"); + show(P2P_LOBBY_SORT_SERVERS, 0, 4); + } + lobby_update(token, 0, 16); + check_cost = 0; + check(probe_count == 4, "the four games are probed once checked"); + /* (Delta, checked in the pass the probes start, is not shown yet) */ + for (index = 0; index < 4; index++) + check(!memcmp(probed_hosts[index], identifiers[first_wave[index]], P2P_IDENTIFIER_SIZE), + "the games are probed in the order shown, one not shown yet last"); + /* sorted by name, then Refresh: Alpha, Bravo, Charlie, Delta */ + show(P2P_LOBBY_SORT_NAME, 0, 4); + probe_count = 0; + p2p_lobby_refresh(); + lobby_update(token, 0, 16); + check(probe_count == 4, "the four games are probed again on Refresh"); + for (index = 0; index < 4; index++) + check(!memcmp(probed_hosts[index], identifiers[index], P2P_IDENTIFIER_SIZE), + "the games are probed in the order the list is sorted by now"); + /* by name the other way, Alpha filtered out: Delta, Charlie, Bravo, then + Alpha */ + show(P2P_LOBBY_SORT_NAME, 1, 3); + probe_count = 0; + p2p_lobby_refresh(); + lobby_update(token, 0, 16); + for (index = 0; index < 4; index++) + check(!memcmp(probed_hosts[index], identifiers[3 - index], P2P_IDENTIFIER_SIZE), + "a game not shown is probed after those shown"); +} + int main(void) { crypto_checks(); lobby_checks(); + probe_order_checks(); printf("%s (%d failures)\n", failures ? "FAIL" : "PASS", failures); return failures != 0; } From 171ede7d2031627c0fe32e406ba4273a2dfbf0f6 Mon Sep 17 00:00:00 2001 From: medic-code Date: Thu, 29 Oct 2026 22:00:55 +0100 Subject: [PATCH 06/32] Show the server browser's re-timing after REFRESH REFRESH asked the hosts for their listings again and timed every ping again, but with games listed nothing on screen changed: a known ping stayed shown through its re-timing, came back the same on a steady link, and the line below the games kept the chosen game's details. The button looked dead. REFRESH now marks each game it times again (refreshing, taken into p2p_listing as probing is), and the mark comes off when that game's probe ends or the game goes. A master server's game is not timed, so it is not marked. A marked row shows the moving dots, uncoloured, until its new ping lands, and the line below the games says "Refreshing..." while a listed game is marked, for 10 s at most from the press. The re-timing every two minutes is not marked, so its numbers stay shown. "Refreshing..." is a browser word, added last to port_settings.py's BROWSER_WORDS, and the join screen's menu file is written again with it. Checked: ninja linux builds with no warnings in the files changed; test_browser_ping_text.py passes (10 tests), its negative controls failing their cases (dots for every probe fails "number", dots without the mark fails "refresh"); p2p_lobby_check passes, and fails when REFRESH marks nothing, marks a master server's or a password's game, or the mark outlives its probe; test_linux_port.py with test_browser_ping_text.py passes (25 passed, 4 skipped: they need the maps). --- ...multiplayer_type_select.join_game.port.xml | 1 + port/linux/README.md | 4 +- port/linux/game/menu_functions.c | 32 +++++++++- port/linux/src/p2p.h | 5 +- port/linux/src/p2p_lobby.c | 16 +++-- tools/harness/tests/browser_ping_text.c | 13 +++- tools/harness/tests/test_browser_ping_text.py | 9 +-- tools/p2p_lobby_check.c | 62 ++++++++++++++++--- tools/port_settings.py | 2 + 9 files changed, 121 insertions(+), 23 deletions(-) diff --git a/port/assets/menus/ce/main_menu.multiplayer_type_select.join_game.port.xml b/port/assets/menus/ce/main_menu.multiplayer_type_select.join_game.port.xml index ea17b29e17..8ee50f8458 100644 --- a/port/assets/menus/ce/main_menu.multiplayer_type_select.join_game.port.xml +++ b/port/assets/menus/ce/main_menu.multiplayer_type_select.join_game.port.xml @@ -103,6 +103,7 @@ + diff --git a/port/linux/README.md b/port/linux/README.md index 9a52a9e8b5..cd1354daeb 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -1132,7 +1132,9 @@ the listing's invite, keeps the shortest of three pings, and closes the tunnel again. It times a few hosts at once, top first in the list as it is shown (its sort, tab and Filters: a new sort orders the hosts timed next, and the games hidden come last), each again every two minutes and -on REFRESH; a game being timed with no ping yet shows moving dots. +on REFRESH; a game being timed with no ping yet, or timed again on +REFRESH, shows moving dots, and the line below the games says +"Refreshing..." until REFRESH's pings are in (10 seconds at most). Joining a host whose tunnel is open uses that tunnel. A game with a password, whose invite the listing hides, shows no ping, nor does a host not reached; a game that is full or starting shows CLOSED. REFRESH also diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index 5c4301ecca..1f95c172ee 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -4348,6 +4348,8 @@ join that failed; and before it says nothing answers */ #define LOBBY_BROWSER_LOOK_TIME 6000 #define LOBBY_BROWSER_FAILED_TIME 8000 #define LOBBY_BROWSER_UNREACHABLE_TIME 15000 +/* the longest the line below the rows says it is refreshing */ +#define LOBBY_BROWSER_REFRESH_TIME 10000 /* the line below the rows: two rows' worth */ #define TICKER_TEXT_LENGTH (ROW_TEXT_LENGTH * 2) /* between the parts of the line below the rows */ @@ -4407,6 +4409,7 @@ enum _browser_word_refused_link_in_use, _browser_word_refused_vpn_master, _browser_word_refused_address_full, + _browser_word_refreshing, }; /* the words p2p_join_progress gives a join that failed, and the browser's @@ -5001,6 +5004,14 @@ static void lobby_browser_game_lines(struct p2p_listing const *game, wchar_t *pl } } +/* whether a game's ping cell shows dots: its host timed with no ping yet, +or timed again after Refresh (a known ping stays while the two-minute probe +runs) */ +static boolean browser_ping_dots(struct p2p_listing const *game) +{ + return game->probing && (game->ping < 0 || game->refreshing); +} + /* a game's ping cell (the dots without a colour: pc_menu_text_color colours a number only) */ static void browser_ping_text(struct p2p_listing const *game, unsigned long now, wchar_t *text, short length) @@ -5009,10 +5020,10 @@ static void browser_ping_text(struct p2p_listing const *game, unsigned long now, if (game->failed) browser_word(_browser_word_failed, text, length); + else if (browser_ping_dots(game)) + ustrncpy(text, dots[(now / 300) % 3], length - 1); else if (game->ping >= 0) usnprintf(text, length - 1, L"%d", game->ping); - else if (game->probing) - ustrncpy(text, dots[(now / 300) % 3], length - 1); else if (game->master && game->region[0]) region_to_wide(game->region, text, length); else if (!game->open) @@ -5023,6 +5034,19 @@ static void browser_ping_text(struct p2p_listing const *game, unsigned long now, usnprintf(text, length - 1, L"-"); } +/* whether a game Refresh asked to be timed again still waits for its ping */ +static boolean browser_refreshing(void) +{ + short index; + + for (index = 0; index < lobby_browser.count; index++) + { + if (lobby_browser.games[index].refreshing) + return TRUE; + } + return FALSE; +} + /* "gamespy screen update" for the server browser: the games shown, the rows (scrolled on at their ends), the line below them, the counts over the titles, the titles' arrows; a join going on; the error panel */ @@ -5102,7 +5126,7 @@ static void lobby_browser_update(struct widget_instance *list) /* (its status: JOINING, NEWER or OLDER, in yellow; its host's ping, once probed (p2p_lobby.c), else FAILED, CLOSED, LIVE; a master server's, which is not probed, its region) */ - browser_row_pings[place] = game->failed || other_version ? -1 : game->ping; + browser_row_pings[place] = game->failed || other_version || browser_ping_dots(game) ? -1 : game->ping; browser_row_greyed[place] = other_version; browser_row_marked[place] = TRUE; if (lobby_browser.joining && !memcmp(game->identifier, lobby_browser.identifier, 6)) @@ -5216,6 +5240,8 @@ static void lobby_browser_update(struct widget_instance *list) browser_word(_browser_word_online_off, text, NUMBEROF(text)); else if (!config_boolean("network.public_lobby")) browser_word(_browser_word_lobby_off, text, NUMBEROF(text)); + else if (now - lobby_browser.begin_time < LOBBY_BROWSER_REFRESH_TIME && browser_refreshing()) + browser_word(_browser_word_refreshing, text, NUMBEROF(text)); else if (!lobby_browser.count) { /* (an old build sees none of the games: it says why) */ diff --git a/port/linux/src/p2p.h b/port/linux/src/p2p.h index fbcf20aecd..f6d453c393 100644 --- a/port/linux/src/p2p.h +++ b/port/linux/src/p2p.h @@ -158,8 +158,9 @@ struct p2p_listing unsigned char failed; /* milliseconds, -1 if not known */ short ping; - /* its host is being probed for the ping now (a ping known stays) */ - unsigned char probing; + /* its host is being probed for the ping now (a ping known stays), and + whether Refresh asked for it (until that probe ends) */ + unsigned char probing, refreshing; /* it has a password: its invite is sealed with it (p2p_listing_unlock) */ unsigned char locked; unsigned char key_hash[P2P_LISTING_KEY_HASH_SIZE]; diff --git a/port/linux/src/p2p_lobby.c b/port/linux/src/p2p_lobby.c index 58732fcb75..5760e4745c 100644 --- a/port/linux/src/p2p_lobby.c +++ b/port/linux/src/p2p_lobby.c @@ -155,12 +155,14 @@ struct game /* its invite's token (none: a password's game); its host's ping (-1: not known), whether it is being probed, and when it last was (0: due; else odd, and never ahead of the clock, which elapsed reads as - long past) */ + long past); whether Refresh asked for it to be timed again, until that + probe ends */ int has_token; unsigned char token[P2P_TOKEN_SIZE]; int ping; int probing; unsigned long probed_time; + int refreshing; /* its place in the browser's list, from 1 (0: not shown) */ int shown; /* listed by a master server (master_list.c) lately: not probed (the @@ -755,6 +757,7 @@ static void game_listed(const struct game *game, struct p2p_listing *listing) listing->failed = (unsigned char)identifier_failed(listing->identifier); listing->ping = (short)game->ping; listing->probing = (unsigned char)game->probing; + listing->refreshing = (unsigned char)game->refreshing; } /* the hosts' pings: those not known lately probed, in the order shown, @@ -907,6 +910,7 @@ void p2p_lobby_probed(const unsigned char *identifier, int ping) if (game->used && !memcmp(game->listing.identifier, identifier, P2P_IDENTIFIER_SIZE)) { game->probing = 0; + game->refreshing = 0; game->probed_time = (p2p_now() - 1) | 1; /* (one not reached this time keeps the last it had) */ if (ping >= 0) @@ -1153,11 +1157,15 @@ void p2p_lobby_refresh(void) pthread_mutex_lock(&p2p_lock); if (lobby.browsing) lobby.query_wanted = 1; - /* (the pings timed again) */ + /* (the pings timed again, a probe under way included; a master + server's game is not timed) */ for (index = 0; index < MAXIMUM_GAMES; index++) { - if (!lobby.games[index].probing) - lobby.games[index].probed_time = 0; + struct game *game = &lobby.games[index]; + + if (!game->probing) + game->probed_time = 0; + game->refreshing = game->used && game->has_token && !game->master; } pthread_mutex_unlock(&p2p_lock); master_list_refresh(); diff --git a/tools/harness/tests/browser_ping_text.c b/tools/harness/tests/browser_ping_text.c index e7abd66c9e..f51fefae45 100644 --- a/tools/harness/tests/browser_ping_text.c +++ b/tools/harness/tests/browser_ping_text.c @@ -3,7 +3,7 @@ BROWSER_PING_TEXT.C (test) The server browser's ping cell (menu_functions.c's browser_ping_text) for a game open ('o'), closed ('c'), live ('l'), failed ('f') or a master -server's ('m'), from test_browser_ping_text.py's listing.inc, words.inc and +server's ('m'), after Refresh or not, from test_browser_ping_text.py's listing.inc, words.inc and under_test.inc. */ @@ -29,6 +29,8 @@ static void region_to_wide(char const *region, wchar_t *wide, short size) #include "under_test.inc" +static int refreshed; + static int shows(char state, int ping, int probing, unsigned long now, wchar_t const *expected) { struct p2p_listing game; @@ -42,6 +44,7 @@ static int shows(char state, int ping, int probing, unsigned long now, wchar_t c strcpy(game.region, "EU"); game.ping = (short)ping; game.probing = (unsigned char)probing; + game.refreshing = (unsigned char)refreshed; browser_ping_text(&game, now, text, 16); return !wcscmp(text, expected); } @@ -53,6 +56,14 @@ int main(int argc, char **argv) CASE("number") CHECK(shows('o', 42, 0, 0, L"42") && shows('o', 42, 1, 700, L"42"), "a ping shows, kept while timed again every two minutes"); + else CASE("refresh") + { + refreshed = 1; + CHECK(shows('o', 42, 1, 300, L"..") && shows('c', 42, 1, 600, L"..."), + "a ping timed again after Refresh: dots"); + CHECK(shows('o', 42, 0, 300, L"42"), "a ping after Refresh, its host not timed yet, still shows"); + CHECK(shows('f', 42, 1, 0, L"FAILED"), "FAILED over a ping timed after Refresh"); + } else CASE("dots") { CHECK(shows('o', -1, 1, 299, L".") && shows('o', -1, 1, 300, L"..") && shows('o', -1, 1, 600, L"...") && diff --git a/tools/harness/tests/test_browser_ping_text.py b/tools/harness/tests/test_browser_ping_text.py index 89bf9d62bb..07664f34b7 100644 --- a/tools/harness/tests/test_browser_ping_text.py +++ b/tools/harness/tests/test_browser_ping_text.py @@ -11,7 +11,7 @@ def generated(fault=None): header = read('port/linux/src/p2p.h') menus = read('port/linux/game/menu_functions.c') - code = function(menus, 'browser_ping_text') + code = function(menus, 'browser_ping_dots') + function(menus, 'browser_ping_text') if fault: code = mutated(code, *fault) return (('listing.inc', enum_with(header, 'P2P_LISTING_NAME_SIZE') + structure(header, 'p2p_listing')), @@ -19,7 +19,7 @@ def generated(fault=None): ('under_test.inc', code)) -@pytest.mark.parametrize('case', ['number', 'dots', 'status', 'dash']) +@pytest.mark.parametrize('case', ['number', 'refresh', 'dots', 'status', 'dash']) def test_case(case): status, output = run(build('browser_ping_text', generated()), case) assert status == 0, output @@ -27,9 +27,10 @@ def test_case(case): @pytest.mark.parametrize('fault,case', [ (('now / 300', 'now / 600'), 'dots'), - (('else if (game->probing)', 'else if (game->open && !game->in_progress && game->probing)'), 'dots'), + (('return game->probing &&', 'return game->open && !game->in_progress && game->probing &&'), 'dots'), (('if (game->failed)', 'if (0)'), 'status'), - (('else if (game->ping >= 0)', 'else if (game->ping >= 0 && !game->probing)'), 'number'), + (('game->ping < 0 || game->refreshing', 'game->ping < 0 || 1'), 'number'), + (('game->ping < 0 || game->refreshing', 'game->ping < 0'), 'refresh'), ]) def test_negative_control(fault, case): status, output = run(build('browser_ping_text', generated(fault)), case) diff --git a/tools/p2p_lobby_check.c b/tools/p2p_lobby_check.c index 6008aa87bc..1d9e7ea5b6 100644 --- a/tools/p2p_lobby_check.c +++ b/tools/p2p_lobby_check.c @@ -12,10 +12,11 @@ slot, an older one, a retained one of long ago, a tombstone and a listing older than it each handled as they must be; a game with a password, listed locked (its token nowhere in the listing), opened by its password alone; and the hosts' pings: a game's host probed with its listing's token, -its ping listed and kept, probed again on Refresh, and a game with a -password never; and the probes started in the order the games are shown -(p2p_lobby_shown), those not shown last, past one refused, none while -listings wait to be checked. Prints PASS or the failures. +its ping listed and kept, probed again on Refresh (marked as Refresh's +until that probe ends, a probe of its own two minutes on not), and a game +with a password never; and the probes started in the order the games are +shown (p2p_lobby_shown), those not shown last, past one refused, none +while listings wait to be checked. Prints PASS or the failures. */ #include "p2p_internal.h" @@ -96,9 +97,11 @@ void server_master_changed(void) {} /* the probes asked for (p2p.c's), the last one's host and token, and the hosts in the order probed: each ends at once, its host not reached (as -p2p.c's does when it needs no tunnel), but refused_host's is refused; more -than 64 asked for in one pass stops the check */ +p2p.c's does when it needs no tunnel), but refused_host's is refused, and +none ends while probes_held; more than 64 asked for in one pass stops the +check */ static const unsigned char *refused_host; +static int probes_held; static int probe_count, probe_asked; static unsigned char probed_hash[P2P_KEY_HASH_SIZE], probed_token[P2P_TOKEN_SIZE]; static unsigned char probed_hosts[16][P2P_IDENTIFIER_SIZE]; @@ -118,7 +121,8 @@ int p2p_probe_start(const unsigned char *host_hash, const unsigned char *token) return 0; memcpy(probed_hosts[probe_count % 16], host, P2P_IDENTIFIER_SIZE); probe_count++; - p2p_lobby_probed(host, -1); + if (!probes_held) + p2p_lobby_probed(host, -1); return 1; } @@ -338,6 +342,32 @@ static void lobby_checks(void) lobby_update(token, 3, 16); check(probe_count == 2 && games(&listing) == 1 && listing.ping == 42, "Refresh times the ping again; one not reached keeps the last"); + /* Refresh's probe held open: the game marked as Refresh's until it + ends; the next probe, past two minutes on (the host heard on its 30 s), + not */ + probes_held = 1; + p2p_lobby_refresh(); + check(games(&listing) == 1 && listing.refreshing, "Refresh marks the game it times again"); + lobby_update(token, 3, 16); + check(probe_count == 3 && games(&listing) == 1 && listing.probing && listing.refreshing && listing.ping == 42, + "the game is marked while Refresh's probe is under way"); + pthread_mutex_lock(&p2p_lock); + p2p_lobby_probed(listing.identifier, 40); + pthread_mutex_unlock(&p2p_lock); + check(games(&listing) == 1 && !listing.probing && !listing.refreshing && listing.ping == 40, + "the probe's end takes the mark off"); + for (count = 0; count < 5; count++) + { + clock_now += 30000; + lobby_update(token, 3, 16); + hear(published, published_size, 0, NULL); + } + check(probe_count == 4 && games(&listing) == 1 && listing.probing && !listing.refreshing && listing.ping == 40, + "a probe two minutes on is not marked as Refresh's"); + pthread_mutex_lock(&p2p_lock); + p2p_lobby_probed(listing.identifier, 42); + pthread_mutex_unlock(&p2p_lock); + probes_held = 0; /* the older one again: no change */ hear(first, first_size, 0, NULL); check(games(&listing) == 1 && listing.in_progress, "an older listing is ignored"); @@ -399,7 +429,8 @@ static void lobby_checks(void) count = probe_count; p2p_lobby_refresh(); lobby_update(token, 3, 16); - check(probe_count == count, "a game with a password is not probed"); + check(probe_count == count && games(&listing) == 1 && !listing.refreshing, + "a game with a password is not probed, nor marked by Refresh"); { int offset, found = 0; @@ -516,6 +547,21 @@ static void probe_order_checks(void) for (index = 0; index < 4; index++) check(!memcmp(probed_hosts[index], identifiers[3 - index], P2P_IDENTIFIER_SIZE), "a game not shown is probed after those shown"); + /* Alpha listed by a master server too: neither marked by Refresh nor + timed */ + check(p2p_lobby_take_master_listing(payloads[0], sizes[0], "", "EU", 0, 1, NULL), "a master server's listing is taken"); + probe_count = 0; + p2p_lobby_refresh(); + { + struct p2p_listing all[4]; + int count = p2p_lobby_games(all, 4), marked = 0; + + for (index = 0; index < count; index++) + marked += all[index].refreshing; + check(count == 4 && marked == 3, "Refresh marks the games it times, not a master server's"); + } + lobby_update(token, 0, 16); + check(probe_count == 3, "a master server's game is not timed on Refresh"); } int main(void) diff --git a/tools/port_settings.py b/tools/port_settings.py index 4ff053feea..2479ba02ed 100644 --- a/tools/port_settings.py +++ b/tools/port_settings.py @@ -1407,6 +1407,8 @@ def _value_row(base: str, key: str, label_index: int, run: str) -> list: "The host refused: your Discord account is in the game", "The host blocks VPNs: link Discord with its master (Settings > Network Setup)", "The host refused: too many players from your address", + # (the line while Refresh's pings come in) + "Refreshing...", ] From a9257b4591c67f685ab3b3ca5d3f79866d459232 Mon Sep 17 00:00:00 2001 From: Guven <185004906+GuvenHuso@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:13:53 +0000 Subject: [PATCH 07/32] Blend the first-person weapon's own fast animations between ticks A rocket launcher's melee was drawn at 30 Hz at any frame rate. The first-person pose was blended between ticks only while no node moved more than 0.25 world units relative to the camera in a tick, a limit meant to tell another weapon's skeleton from this one moving; the rocket launcher's melee (and other wide, fast animations) moves further, so the whole pose snapped every tick of the swing. The pose now remembers the weapon and the animation graph it was of, and blends only from the same weapon's and graph's pose of the tick just before, so a weapon switch, even between two frames of a tick, still snaps while the weapon's own animations blend however far they move. A position that is not a number still snaps the pose. A node whose two rotations are about half a turn apart (the rocket launcher's barrels set back after a shot) snaps alone, since which way it turned cannot be told; the other nodes still blend. Only what is drawn changes, nothing the machines send or agree on: no network version change. Checked: ninja windows; tools/harness/tests/test_first_person_interpolation.py (8 cases, and 6 negative controls, the old 0.25 limit among them, that fail); the whole harness on Windows fails the same 632 tests as testing does (the Windows environment's) and passes the 14 new ones. Not built for Android or the web here. Co-Authored-By: Claude Opus 5.5 --- port/linux/game/render_interpolation.c | 69 +++++--- port/linux/include/halo_linux_source_fixups.h | 4 +- source/interface/first_person_weapons.c | 5 +- .../tests/first_person_interpolation.c | 148 ++++++++++++++++++ .../tests/test_first_person_interpolation.py | 66 ++++++++ 5 files changed, 269 insertions(+), 23 deletions(-) create mode 100644 tools/harness/tests/first_person_interpolation.c create mode 100644 tools/harness/tests/test_first_person_interpolation.py diff --git a/port/linux/game/render_interpolation.c b/port/linux/game/render_interpolation.c index 9b7ddc48ae..b3ddefb0a2 100644 --- a/port/linux/game/render_interpolation.c +++ b/port/linux/game/render_interpolation.c @@ -19,7 +19,10 @@ a tick of motion plausibly allows (teleports, respawns, camera cuts) snaps instead of sweeping across the world, and so does a pose whose nodes moved too far for one tick: two snapshots of different poses (a model swapped, another weapon's skeleton, a pose left from seconds before) blended node by -node stretch vertices across the screen. +node stretch vertices across the screen. The first-person weapon's two poses +are told apart by the weapon and its animation graph instead, since its own +animations (a rocket launcher's melee) move further in a tick than any +distance that would tell them from another weapon's. Particles, contrails and other effects already move every frame (game_frame), so they need nothing here. @@ -71,8 +74,11 @@ that the whole object turning moves nothing: measured in the world, a fast turn swung the head and weapon far enough to snap, and characters moved like robots.) */ #define NODE_SNAP_DISTANCE 0.4f -/* ... a first-person node may move relative to the camera */ -#define FIRST_PERSON_SNAP_DISTANCE 0.25f +/* a first-person node's two rotations within about two degrees of half a +turn apart (the quaternions' dot product): which way it turned cannot be +told, and blending either way spins it through poses it never had, as the +rocket launcher's barrels set back by half a turn after a shot */ +#define FIRST_PERSON_HALF_TURN_COSINE 0.02f /* a correction's difference left drawn after each tick (of 1) */ #define CORRECTION_DECAY 0.6f /* ... and small enough to be none */ @@ -136,6 +142,9 @@ struct interpolated_camera struct interpolated_first_person { long tick; + /* the weapon and animation graph the latest pose was of */ + long weapon_index; + long animation_graph_index; short node_count; boolean has_previous; real_matrix4x3 previous[MAXIMUM_INTERPOLATED_NODES]; @@ -895,6 +904,8 @@ camera each frame, from animation state that changes once a tick: blend the pose relative to the camera. */ void render_interpolation_first_person( short local_player_index, + long weapon_index, + long animation_graph_index, real_matrix4x3 *node_matrices, short node_count, struct render_camera const *camera) @@ -915,48 +926,66 @@ void render_interpolation_first_person( first_person = &interpolated_first_person[local_player_index]; matrix4x3_from_point_and_vectors(&camera_matrix, &camera->position, &camera->forward, &camera->up); matrix4x3_inverse(&camera_matrix, &inverse_camera); - if (first_person->tick != interpolation_tick) + if (first_person->tick != interpolation_tick || + first_person->weapon_index != weapon_index || + first_person->animation_graph_index != animation_graph_index) { /* the pose drawn last, if that was at the end of the tick just before: not one left from before the view was last away from first - person (zoomed, in a vehicle, dead, in a cinematic), seconds old */ + person (zoomed, in a vehicle, dead, in a cinematic), seconds old, and + not another weapon's or skeleton's (of as many nodes) */ first_person->has_previous = first_person->tick == interpolation_tick - 1 && - first_person->node_count == node_count; + first_person->node_count == node_count && + first_person->weapon_index == weapon_index && + first_person->animation_graph_index == animation_graph_index; memcpy(first_person->previous, first_person->latest, sizeof(first_person->previous)); first_person->tick = interpolation_tick; + first_person->weapon_index = weapon_index; + first_person->animation_graph_index = animation_graph_index; } for (node_index = 0; node_index < node_count; node_index++) matrix4x3_multiply(&inverse_camera, &node_matrices[node_index], &first_person->latest[node_index]); first_person->node_count = node_count; if (!first_person->has_previous) return; - /* a node that jumped further in the camera's frame than a tick allows: - the last pose was another weapon's skeleton (of as many nodes), not this - one moving (so written that a position not a number snaps) */ + /* a node's move is not limited: one weapon's own animations, a melee + or a reload, move as far in a tick as they were made to. Only a position + not a number (so written) snaps the pose. */ for (node_index = 0; node_index < node_count; node_index++) { real_matrix4x3 const *previous = &first_person->previous[node_index]; real_matrix4x3 const *latest = &first_person->latest[node_index]; - if (!(distance_squared(&previous->position, &latest->position) <= - FIRST_PERSON_SNAP_DISTANCE * FIRST_PERSON_SNAP_DISTANCE)) - { + if (!(distance_squared(&previous->position, &latest->position) <= REAL_MAX)) return; - } rotation_from_matrix(previous, &previous_rotations[node_index]); rotation_from_matrix(latest, &latest_rotations[node_index]); } for (node_index = 0; node_index < node_count; node_index++) { + struct interpolation_rotation const *previous_rotation = &previous_rotations[node_index]; + struct interpolation_rotation const *latest_rotation = &latest_rotations[node_index]; real_matrix4x3 blended; - matrix_blend_rotations( - &first_person->previous[node_index], - &first_person->latest[node_index], - &previous_rotations[node_index], - &latest_rotations[node_index], - interpolation_fraction, - &blended); + if (previous_rotation->is_rotation && latest_rotation->is_rotation && + fabs(previous_rotation->quaternion.i * latest_rotation->quaternion.i + + previous_rotation->quaternion.j * latest_rotation->quaternion.j + + previous_rotation->quaternion.k * latest_rotation->quaternion.k + + previous_rotation->quaternion.w * latest_rotation->quaternion.w) < FIRST_PERSON_HALF_TURN_COSINE) + { + /* half a turn: this node snaps, the others still blend */ + blended = first_person->latest[node_index]; + } + else + { + matrix_blend_rotations( + &first_person->previous[node_index], + &first_person->latest[node_index], + previous_rotation, + latest_rotation, + interpolation_fraction, + &blended); + } matrix4x3_multiply(&camera_matrix, &blended, &node_matrices[node_index]); } } diff --git a/port/linux/include/halo_linux_source_fixups.h b/port/linux/include/halo_linux_source_fixups.h index ca01b2e756..013c0cdbbc 100644 --- a/port/linux/include/halo_linux_source_fixups.h +++ b/port/linux/include/halo_linux_source_fixups.h @@ -25,8 +25,8 @@ struct real_matrix4x3 *render_interpolation_object_node_matrices(long object_ind void render_interpolation_object_point(long object_index, union real_point3d *point); struct observer_result const *render_interpolation_camera(short local_player_index, struct observer_result const *observer); -void render_interpolation_first_person(short local_player_index, struct real_matrix4x3 *node_matrices, - short node_count, struct render_camera const *camera); +void render_interpolation_first_person(short local_player_index, long weapon_index, long animation_graph_index, + struct real_matrix4x3 *node_matrices, short node_count, struct render_camera const *camera); float render_interpolation_game_time_sec(long ticks); /* whether the map being loaded, by the name it was loaded by, is a diff --git a/source/interface/first_person_weapons.c b/source/interface/first_person_weapons.c index 689e42f69f..89834cebc8 100644 --- a/source/interface/first_person_weapons.c +++ b/source/interface/first_person_weapons.c @@ -1480,9 +1480,12 @@ static void first_person_weapon_build_node_matrices( &render.camera.forward, &render.camera.up); /* the pose between the last two ticks (render_interpolation.c) */ - /* port: no more nodes than the weapon's matrices hold (a map's count) */ + /* port: no more nodes than the weapon's matrices hold (a map's count); + the weapon and its graph tell its poses from another weapon's */ render_interpolation_first_person( local_player_index, + first_person_weapon->weapon_index, + weapon_definition->weapon.interface_definition.first_person_animations.index, first_person_weapon->node_matrices, (short)MIN(animation_graph->nodes.count, MAXIMUM_NODES_PER_ANIMATION), &render.camera); diff --git a/tools/harness/tests/first_person_interpolation.c b/tools/harness/tests/first_person_interpolation.c new file mode 100644 index 0000000000..6df40d615c --- /dev/null +++ b/tools/harness/tests/first_person_interpolation.c @@ -0,0 +1,148 @@ +#include "harness.h" +#include + +#include "config.inc" + +/* One local player's first-person weapon of two nodes, drawn from a camera at +the origin looking along x (render_interpolation.c). Each tick the game poses +the nodes in the world from the camera; each frame between ticks the pose is +blended from the last tick's. */ +#define WEAPON 0x10002L +#define OTHER_WEAPON 0x10003L +#define GRAPH 0x20004L +#define OTHER_GRAPH 0x20005L +#define NODES 2 +#define PI 3.14159265f + +struct render_camera { real_point3d position; real_vector3d forward, up; }; +static void cross_product3d(real_vector3d const *a, real_vector3d const *b, real_vector3d *result) +{ + result->i = a->j * b->k - a->k * b->j; + result->j = a->k * b->i - a->i * b->k; + result->k = a->i * b->j - a->j * b->i; +} +static void set_real_point3d(real_point3d *point, real x, real y, real z) { point->x = x; point->y = y; point->z = z; } +static long interpolation_tick; +static boolean interpolation_rendering = TRUE; +static real interpolation_fraction; + +#include "under_test.inc" + +static struct render_camera camera = { { 0.0f, 0.0f, 0.0f }, { 1.0f, 0.0f, 0.0f }, { 0.0f, 0.0f, 1.0f } }; +static real_matrix4x3 nodes[NODES]; + +/* a node so far along x, turned so far about z */ +static void pose(short node, real x, real angle) +{ + real_matrix4x3 *matrix = &nodes[node]; + + matrix->scale = 1.0f; + matrix->forward.i = (real)cos(angle); + matrix->forward.j = (real)sin(angle); + matrix->forward.k = 0.0f; + matrix->left.i = -matrix->forward.j; + matrix->left.j = matrix->forward.i; + matrix->left.k = 0.0f; + matrix->up.i = 0.0f; + matrix->up.j = 0.0f; + matrix->up.k = 1.0f; + set_real_point3d(&matrix->position, x, 0.0f, 0.0f); +} + +/* a frame drawn a fraction into a tick, both nodes so far along x */ +static void frame(long tick, real fraction, long weapon, long graph, real x) +{ + interpolation_tick = tick; + interpolation_fraction = fraction; + pose(0, x, 0.0f); + pose(1, x, 0.0f); + render_interpolation_first_person(0, weapon, graph, nodes, NODES, &camera); +} + +static void expect(real x) +{ + CHECK(fabs(nodes[0].position.x - x) < 1e-4f && fabs(nodes[1].position.x - x) < 1e-4f, + "drawn at %f and %f, not %f", nodes[0].position.x, nodes[1].position.x, x); +} + +int main(int argc, char **argv) +{ + char const *case_name = argc > 1 ? argv[1] : ""; + + frame(10, 1.0f, WEAPON, GRAPH, 0.0f); + /* a melee swings the weapon further in a tick than any limit that would + tell it from another weapon: it is blended all the same */ + CASE("melee") + { + frame(11, 0.25f, WEAPON, GRAPH, 0.8f); + expect(0.2f); + frame(11, 0.75f, WEAPON, GRAPH, 0.8f); + expect(0.6f); + /* (from the tick's pose, not the last frame drawn) */ + frame(12, 0.5f, WEAPON, GRAPH, 1.6f); + expect(1.2f); + } + /* another weapon of as many nodes: drawn as it is, then blended */ + CASE("weapon") + { + frame(11, 0.5f, OTHER_WEAPON, GRAPH, 0.1f); + expect(0.1f); + frame(12, 0.5f, OTHER_WEAPON, GRAPH, 0.2f); + expect(0.15f); + } + CASE("graph") + { + frame(11, 0.5f, WEAPON, OTHER_GRAPH, 0.1f); + expect(0.1f); + } + /* a weapon changed between two frames of a tick */ + CASE("within a tick") + { + frame(11, 0.25f, WEAPON, GRAPH, 0.4f); + expect(0.1f); + frame(11, 0.75f, OTHER_WEAPON, GRAPH, 0.2f); + expect(0.2f); + frame(12, 0.5f, OTHER_WEAPON, GRAPH, 0.4f); + expect(0.3f); + } + /* the view away from first person for a while */ + CASE("gap") + { + frame(14, 0.5f, WEAPON, GRAPH, 0.4f); + expect(0.4f); + } + CASE("not a number") + { + interpolation_tick = 11; + interpolation_fraction = 0.5f; + pose(0, 0.4f, 0.0f); + pose(1, 0.4f, 0.0f); + nodes[1].position.y = NAN; + render_interpolation_first_person(0, WEAPON, GRAPH, nodes, NODES, &camera); + CHECK(nodes[0].position.x == 0.4f, "a pose with a node not a number blended"); + } + /* a node set back by half a turn snaps; the other still blends */ + CASE("half turn") + { + interpolation_tick = 11; + interpolation_fraction = 0.5f; + pose(0, 0.4f, 0.0f); + pose(1, 0.0f, PI); + render_interpolation_first_person(0, WEAPON, GRAPH, nodes, NODES, &camera); + CHECK(fabs(nodes[0].position.x - 0.2f) < 1e-4f, "the other node not blended"); + CHECK(nodes[1].forward.i < -0.999f, "half a turn blended: forward (%f, %f)", + nodes[1].forward.i, nodes[1].forward.j); + } + /* a large turn short of half a turn is blended */ + CASE("large turn") + { + interpolation_tick = 11; + interpolation_fraction = 0.5f; + pose(0, 0.0f, 0.0f); + pose(1, 0.0f, 170.0f * PI / 180.0f); + render_interpolation_first_person(0, WEAPON, GRAPH, nodes, NODES, &camera); + CHECK(fabs(atan2(nodes[1].forward.j, nodes[1].forward.i) - 85.0f * PI / 180.0f) < 1e-3f, + "170 degrees not blended halfway: forward (%f, %f)", nodes[1].forward.i, nodes[1].forward.j); + } + return 0; +} diff --git a/tools/harness/tests/test_first_person_interpolation.py b/tools/harness/tests/test_first_person_interpolation.py new file mode 100644 index 0000000000..7740eb817e --- /dev/null +++ b/tools/harness/tests/test_first_person_interpolation.py @@ -0,0 +1,66 @@ +"""The first-person weapon drawn between ticks: its own animations blended however far they move in a tick (a +rocket launcher's melee), another weapon's or skeleton's pose never blended to, and a node's half turn snapped.""" +import re +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, build, function, mutated, read, run, structure + +CASES = ['melee', 'weapon', 'graph', 'within a tick', 'gap', 'not a number', 'half turn', 'large turn'] +CONTROLS = [ + # the distance limit the weapon's identity replaced + ('<= REAL_MAX))', '<= 0.25f * 0.25f))', 'melee'), + ('first_person->node_count == node_count &&\n\t\t\tfirst_person->weapon_index == weapon_index &&', + 'first_person->node_count == node_count &&', 'weapon'), + ('first_person->weapon_index == weapon_index &&\n\t\t\tfirst_person->animation_graph_index == animation_graph_index;', + 'first_person->weapon_index == weapon_index;', 'graph'), + ('if (first_person->tick != interpolation_tick ||\n\t\tfirst_person->weapon_index != weapon_index ||\n' + '\t\tfirst_person->animation_graph_index != animation_graph_index)', + 'if (first_person->tick != interpolation_tick)', 'within a tick'), + ('first_person->has_previous = first_person->tick == interpolation_tick - 1 &&', + 'first_person->has_previous = ', 'gap'), + ('< FIRST_PERSON_HALF_TURN_COSINE)', '< 0.0f)', 'half turn'), +] + + +def generated(fault=None): + source = read('port/linux/game/render_interpolation.c') + maths = read('source/math/real_math.h') + matrices = read('source/math/matrix_math.c') + config = '\n'.join([ + '#define MAXIMUM_LOCAL_PLAYERS 4', + re.search(r'^#define MAXIMUM_INTERPOLATED_NODES .*', source, re.M).group(), + re.search(r'^#define FIRST_PERSON_HALF_TURN_COSINE .*', source, re.M).group(), + re.search(r'^#define REAL_MAX .*', maths, re.M).group(), + structure(maths, 'real_matrix4x3'), + 'typedef struct real_matrix4x3 real_matrix4x3;', + ]) + '\n' + code = '\n'.join( + [function(matrices, name) for name in + ['matrix4x3_inverse', 'matrix4x3_rotation_from_vectors', 'matrix4x3_from_point_and_vectors', + 'matrix4x3_multiply']] + + [structure(source, name) for name in + ['interpolation_quaternion', 'interpolation_rotation', 'interpolated_first_person']] + + ['static struct interpolated_first_person interpolated_first_person[MAXIMUM_LOCAL_PLAYERS];'] + + [function(source, name) for name in + ['lerp', 'point_lerp', 'vector_length', 'basis_is_rotation', 'quaternion_from_basis', + 'basis_from_quaternion', 'rotation_from_matrix', 'matrix_blend_rotations', 'distance_squared', + 'render_interpolation_first_person']]) + if fault: + code = mutated(code, *fault) + return (('config.inc', config), ('under_test.inc', code)) + + +@pytest.mark.parametrize('case', CASES) +def test_case(case): + status, output = run(build('first_person_interpolation', generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize('before,after,case', CONTROLS) +def test_negative_control(before, after, case): + status, output = run(build('first_person_interpolation', generated((before, after))), case) + assert status == CHECK_FAILED, output From 516db98eefc8ae8c0d8caac0abfd64bc8fc0b2fe Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 3 Nov 2026 21:34:26 +0100 Subject: [PATCH 08/32] Keep chapter titles at the side they are justified to on a wide screen On a screen wider than 640 columns, cinematic_render moved each title's box by its middle, scaled to the screen's width. Most chapter titles are right-justified in a box that starts near the left edge, so on 16:9 the text ended about 81% of the way across the screen, where the Xbox's 4:3 picture has it at about 93% (The Silent Cartographer's title, compared with an Xbox capture). The box now moves by the edge its text is justified to: the right edge for right-justified titles, the left edge for left-justified ones, and the middle for centered ones, as before. On 640 columns nothing moves. Only the drawing changes, nothing the machines send or agree on, so HALO_PORT_NETWORK_VERSION stays. Checked in the game on Windows at 16:9: The Silent Cartographer's title ends near the right side, as on the Xbox. test_cinematic_titles.py gains cases for each justification on a 960-column screen and on 640 columns, and negative controls that move the box by its middle again (not run locally, on Windows; CI runs them). Co-Authored-By: Claude Opus 5.5 --- port/linux/README.md | 2 +- source/cutscene/cinematics.c | 28 ++++++- tools/harness/tests/cinematic_titles.c | 78 +++++++++++++++++++- tools/harness/tests/test_cinematic_titles.py | 12 ++- 4 files changed, 109 insertions(+), 11 deletions(-) diff --git a/port/linux/README.md b/port/linux/README.md index 3aa9192b79..4a534c90b0 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -1858,7 +1858,7 @@ Other changes: | `rasterizer/rasterizer.h` | A frame holds 2048 transparent geometry groups, not 384 (`include/halo_port_capacity.h`): a co-op game with many added enemies asked for more, and the Xbox's pool drew the frame without the rest. | | `structures/structure_render.c` | The structure's visible surfaces are put in order by a radix sort, not `qsort_4byte`: the same order. | | `cache/cache_files.c`, `networking/network_client_manager.c`, `networking/network_server_manager.c`, `networking/network_server_message_handler.c`, `main/main.c`, `interface/interface.c` | Map sharing (`game/map_share.c`): a client that does not have the game's Custom Edition map asks the host for it instead of leaving, is not given time to precache it until it has it, and adds its players to a game in progress only then; the host gives the game's map its own name and its version (its hash's start), takes the requests from any machine in the game, lobby too, and does not drop a machine that downloads; the transfers run each frame, and their progress is drawn over the screen. | -| `cutscene/cinematics.c` | A scenario's titles (some Custom Edition maps show their name for the whole game) are not drawn over a menu, as the letterbox is not: the screen's overlays are drawn after the players' menus. | +| `cutscene/cinematics.c` | A scenario's titles (some Custom Edition maps show their name for the whole game) are not drawn over a menu, as the letterbox is not: the screen's overlays are drawn after the players' menus. On a screen wider than 640 columns, a title is moved so the edge it is justified to (its middle, if centered) keeps its place relative to the screen's sides, as on the Xbox's 4:3 screen: a right-justified chapter title ends near the right side, not short of it. | | `tag_files/tag_groups.h` | `TAG_BLOCK_GET_ELEMENT` returns an element in the block without a call, and calls `tag_block_get_element_with_size` for anything else. | | `game/players.c`, `networking/network_server_manager.c` | Bots (`game/bots.c`, `bots.count`): the lobby of a local Slayer game takes the bot players, each the one player of a machine of its own that no real machine is, and every tick their actions come from `game/bots.c` instead of their empty queues. Nothing changes in a system link or internet game. | | `input/input_xbox.c`, `physics/collision_usage.c` | The clock is read only for a button held, and for collision tests only when their times are shown: a read of the clock is slow in a browser. | diff --git a/source/cutscene/cinematics.c b/source/cutscene/cinematics.c index eeddaf6f50..0077bc7dcd 100644 --- a/source/cutscene/cinematics.c +++ b/source/cutscene/cinematics.c @@ -463,11 +463,31 @@ void cinematic_render( (title->shadow_color & 0x00FFFFFF)); { - /* the bounds are for 640 columns: on a wider screen move - them so they keep their place relative to its sides */ + /* port: the bounds are for 640 columns: on a wider screen + move them so the edge the text is justified to (its middle, + if centered) keeps its place relative to the screen's sides, + where the Xbox's 4:3 picture had it. Moving the middle of a + right-justified title's box (most chapter titles: a box from + near the left edge) left the text short of the right side. + The justification is draw_string.c's: 0 left, 1 right, + 2 center */ static rectangle2d wide_bounds; - short shift = (short)(((title_bounds->x0 + title_bounds->x1) / 2) * - (halo_screen_width() - 640) / 640); + short anchor; + short shift; + + switch (title->justification) + { + case 0: + anchor = title_bounds->x0; + break; + case 1: + anchor = title_bounds->x1; + break; + default: + anchor = (short)((title_bounds->x0 + title_bounds->x1) / 2); + break; + } + shift = (short)(anchor * (halo_screen_width() - 640) / 640); wide_bounds = *title_bounds; wide_bounds.x0 += shift; diff --git a/tools/harness/tests/cinematic_titles.c b/tools/harness/tests/cinematic_titles.c index 431d60c490..0156044fa7 100644 --- a/tools/harness/tests/cinematic_titles.c +++ b/tools/harness/tests/cinematic_titles.c @@ -4,8 +4,10 @@ CINEMATIC_TITLES.C (test) The real cinematic_render (cinematics.c) over a fake scenario with one title, as some Custom Edition maps show their name for the whole game: drawn in the game, not over a menu (the screen's overlays are drawn after the players' menus), and -its time going on under one. test_cinematic_titles.py takes the structures, the -slots' count (config.inc) and the code (under_test.inc). +its time going on under one. On a screen wider than 640 columns, the edge the +title is justified to (its middle, if centered) keeps its place relative to the +screen's sides, as on the Xbox's 4:3 screen. test_cinematic_titles.py takes the +structures, the slots' count (config.inc) and the code (under_test.inc). */ #include @@ -68,6 +70,7 @@ static struct string_list help_text = { { 1 } }; static long draws; static real drawn_alpha; static pixel32 shadow, drawn_shadow; +static rectangle2d drawn_bounds; static void pixel32_to_real_argb_color(pixel32 pixel, real_argb_color *color) { color->alpha = (real)((pixel >> 24) & 0xFF) / 255.0f; @@ -81,8 +84,14 @@ static void draw_string_set_draw_mode(long font_index, short style, short justif drawn_alpha = color->alpha; } static void rasterizer_text_set_shadow_color(pixel32 color) { shadow = color; } -static short halo_screen_width(void) { return 640; } -#define rasterizer_draw_unicode_string(bounds, style, color, flags, string) (draws++, drawn_shadow = shadow) +/* the screen's columns: the Xbox's 640, or a wide screen's */ +static long screen_width = 640; +static long halo_screen_width(void) { return screen_width; } +#define rasterizer_draw_unicode_string(bounds, style, color, flags, string) \ + (draws++, drawn_shadow = shadow, drawn_bounds = *(bounds)) + +/* draw_string.c's justifications, as a title's tag has them */ +enum { justified_left, justified_right, justified_center }; #include "under_test.inc" @@ -161,6 +170,67 @@ int main(int argc, char **argv) CHECK(frame() == 0, "an ended title was drawn after the menu was gone"); return 0; } + /* a 960-column screen (half again the Xbox's 640): a right-justified + title's box from near the left edge, as most chapter titles have, ends + where the Xbox's did, as a part of the screen's width (600 of 640 is + 900 of 960), not 300 columns short of it as when its middle was moved */ + CASE("right-justified-on-a-wide-screen") + { + queue_title(1000000.0f, 1.0f); + titles[0].bounds.x0 = 10; + titles[0].bounds.x1 = 600; + titles[0].justification = justified_right; + screen_width = 960; + CHECK(frame() == 1, "the title was not drawn"); + CHECK(drawn_bounds.x1 == 900, "the right-justified title's box ends at column %d of 960, not 900", + drawn_bounds.x1); + CHECK(drawn_bounds.x1 - drawn_bounds.x0 == 590, "the title's box is %d columns wide, not its 590", + drawn_bounds.x1 - drawn_bounds.x0); + CHECK(drawn_bounds.y0 == 440 && drawn_bounds.y1 == 480, "the title's box moved up or down: %d to %d", + drawn_bounds.y0, drawn_bounds.y1); + return 0; + } + /* a left-justified title starts where the Xbox's did, as a part of the + screen's width: 40 of 640 is 60 of 960 */ + CASE("left-justified-on-a-wide-screen") + { + queue_title(1000000.0f, 1.0f); + titles[0].bounds.x0 = 40; + titles[0].bounds.x1 = 600; + titles[0].justification = justified_left; + screen_width = 960; + CHECK(frame() == 1, "the title was not drawn"); + CHECK(drawn_bounds.x0 == 60, "the left-justified title's box starts at column %d of 960, not 60", + drawn_bounds.x0); + return 0; + } + /* a centered title's middle is where the Xbox's was, as a part of the + screen's width: 320 of 640 is 480 of 960 */ + CASE("centered-on-a-wide-screen") + { + queue_title(1000000.0f, 1.0f); + titles[0].bounds.x0 = 40; + titles[0].bounds.x1 = 600; + titles[0].justification = justified_center; + screen_width = 960; + CHECK(frame() == 1, "the title was not drawn"); + CHECK((drawn_bounds.x0 + drawn_bounds.x1) / 2 == 480, + "the centered title's box is centered on column %d of 960, not 480", + (drawn_bounds.x0 + drawn_bounds.x1) / 2); + return 0; + } + /* on the Xbox's 640 columns the title's box is the tag's */ + CASE("unmoved-on-640-columns") + { + queue_title(1000000.0f, 1.0f); + titles[0].bounds.x0 = 10; + titles[0].bounds.x1 = 600; + titles[0].justification = justified_right; + CHECK(frame() == 1, "the title was not drawn"); + CHECK(drawn_bounds.x0 == 10 && drawn_bounds.x1 == 600, "the title's box is %d to %d on 640 columns, not 10 to 600", + drawn_bounds.x0, drawn_bounds.x1); + return 0; + } fprintf(stderr, "unknown case: %s\n", case_name); return 2; } diff --git a/tools/harness/tests/test_cinematic_titles.py b/tools/harness/tests/test_cinematic_titles.py index 4750a6d112..042b1b3336 100644 --- a/tools/harness/tests/test_cinematic_titles.py +++ b/tools/harness/tests/test_cinematic_titles.py @@ -1,5 +1,5 @@ """The real cinematic_render (cinematics.c) over a fake scenario: a scenario's title is drawn in the game, not over a -menu, and its time goes on under one.""" +menu, and its time goes on under one; on a wide screen, the edge it is justified to keeps its place.""" import sys from pathlib import Path @@ -9,7 +9,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from harness import CHECK_FAILED, build, constant, function, mutated, read, run, structure # noqa: E402 -CASES = ["drawn-in-the-game", "not-over-a-menu", "time-goes-on-under-a-menu"] +CASES = ["drawn-in-the-game", "not-over-a-menu", "time-goes-on-under-a-menu", "right-justified-on-a-wide-screen", + "left-justified-on-a-wide-screen", "centered-on-a-wide-screen", "unmoved-on-640-columns"] # a fault in cinematic_render and the case that must catch it NEGATIVE_CONTROLS = { @@ -17,6 +18,13 @@ "time-stops-under-a-menu": (("active_title->time += game_time_get_paused()", "active_title->time += ui_widgets_active() || game_time_get_paused()"), "time-goes-on-under-a-menu"), + # the box's middle moved, whatever the justification, as before the fix + "right-justified-moved-by-its-middle": (("anchor = title_bounds->x1;", + "anchor = (short)((title_bounds->x0 + title_bounds->x1) / 2);"), + "right-justified-on-a-wide-screen"), + "left-justified-moved-by-its-middle": (("anchor = title_bounds->x0;", + "anchor = (short)((title_bounds->x0 + title_bounds->x1) / 2);"), + "left-justified-on-a-wide-screen"), } From 5cd9bc79c474b3a774698902f20356ee673cff34 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 3 Nov 2026 22:01:13 +0100 Subject: [PATCH 09/32] Draw a scope's zoom blur to the left and top edges of the screen The shader translator moves every vertex half a pixel right and down to turn Direct3D's pixel centers into OpenGL's, half a pixel of the game's 480 lines (nv2a_vsh.c). On a screen drawn at more pixels than that, a screen effect pass's quad then started that far inside its target's left and top edges, and the pixels there were never drawn. Each pass of a scope's zoom warp read their stale contents and drew them farther in: a dark band down the left of the blur and along its top, which the right and bottom edges, clamped to drawn pixels, did not have. rasterizer_screen_effect_cover_target moves each pass's quad back by that half pixel, a pixel of the viewport it draws into (the whole surface for the second render-primary surface, the window for the primary), and its texture coordinates with it, now floats: every pixel samples where it did, and the quad covers the whole viewport. Only the drawing changes, nothing the machines send or agree on, so HALO_PORT_NETWORK_VERSION stays. Checked: the new harness test screen_effect_cover (a 16:9 screen, the Xbox's 640x480 and a four-player split screen, with four negative controls) and the existing screen_effect_passes pass, built with clang on Windows; the Windows release build compiles without warnings; in the game on Windows, the scope's blur reaches the left and top edges. Co-Authored-By: Claude Opus 5.5 --- port/linux/README.md | 1 + .../xbox/rasterizer_xbox_screen_effect.c | 83 ++++++++-- tools/harness/tests/screen_effect_cover.c | 148 ++++++++++++++++++ .../harness/tests/test_screen_effect_cover.py | 47 ++++++ 4 files changed, 263 insertions(+), 16 deletions(-) create mode 100644 tools/harness/tests/screen_effect_cover.c create mode 100644 tools/harness/tests/test_screen_effect_cover.py diff --git a/port/linux/README.md b/port/linux/README.md index 3aa9192b79..d43af70d6a 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -1840,6 +1840,7 @@ Other changes: | `saved games/player_profile.h`, `interface/player_ui.c` | A sixth button layout, the Master Chief Collection's Bumper Jumper (`profile.button_preset` 5), and a seventh, Fishsticks (6: zoom on the left trigger, melee on the right stick's button, grenade throw on the right bumper, crouch on B, grenade switch on the pad's left, which then does not move you), and an eighth, Recon (7: the standard buttons with action and reload on the right bumper, grenade switch on the left bumper, flashlight on X). A layout the build does not know (a later build's) gets the standard buttons, not none, which moved start and back and stopped the game. | | `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. | | `rasterizer/xbox/rasterizer_xbox_shadows.c` | With larger shadow maps (`display.shadow_resolution`), the blur's taps stay half a texel apart, and more passes widen it to cover the same part of the map as on the Xbox. | +| `rasterizer/xbox/rasterizer_xbox_screen_effect.c` | A screen effect's passes (a scope's zoom blur) cover the whole screen when it is drawn at more pixels than the Xbox's: each pass's quad, and its texture coordinates with it, is moved back by the half pixel of the game's 480 lines that the shader translator adds (`src/nv2a_vsh.c`). Without that, the first pixels on the left and top were never drawn, and the zoom's warp spread their stale contents into a dark band down the left and along the top of the blur. | | `rasterizer/rasterizer.c` | The 128 KB of vertices of the debug display of a model's vertices are static, not on the stack: the compiler inlines the function in `render_model`, and the Windows build probed the stack a page at a time for every model drawn. | | `render/render.c` | The 3D view of each window is tone mapped (`display.hdr`) and antialiased (`display.anti_aliasing`) before the HUD is drawn. | | `render/render.c` | `render_initialize` reads the structure culling tests from `display.structure_culling`. | diff --git a/source/rasterizer/xbox/rasterizer_xbox_screen_effect.c b/source/rasterizer/xbox/rasterizer_xbox_screen_effect.c index 0032593ce6..5c03591019 100644 --- a/source/rasterizer/xbox/rasterizer_xbox_screen_effect.c +++ b/source/rasterizer/xbox/rasterizer_xbox_screen_effect.c @@ -549,6 +549,46 @@ static boolean rasterizer_screen_effect_video_borders( global_d3d_device, D3DRS_SRCBLEND, D3DBLEND_INVSRCALPHA) >= 0 && success; return success; } + +/* port: the shader translator turns Direct3D's pixel centers (on integers) +into OpenGL's (on halves) by moving every vertex half a pixel right and down, +a pixel of the game's 480 lines (nv2a_vsh.c). The screen is drawn at several +of the display's pixels to one of those (halo_screen_scale), so a pass's quad +started half a game pixel inside its target's left and top edges, and the +display's pixels there were never drawn: each pass of a scope's zoom warp read +their stale contents and drew them farther in, a dark band down the left of +the blur and along its top (the right and bottom edges are clamped to drawn +pixels). Move the quad back by that half pixel, a pixel of the viewport it +draws into (the whole surface for the second render-primary surface, the +window for the primary: rasterizer_set_target), and return how far its +texture coordinates, in the viewport's pixels, move with it, so that every +pixel samples where it did and the quad covers the whole viewport. */ +static real rasterizer_screen_effect_cover_target( + short destination_target, + short viewport_width, + short viewport_height, + real_rectangle2d *vertex_bounds) +{ + real half_pixel_x; + real half_pixel_y; + + if (destination_target == _rasterizer_target_screen_effect) + { + half_pixel_x = 1.0f / (real)halo_screen_width(); + half_pixel_y = 1.0f / 480.0f; + } + else + { + half_pixel_x = 1.0f / (real)viewport_width; + half_pixel_y = 1.0f / (real)viewport_height; + } + vertex_bounds->x0 -= half_pixel_x; + vertex_bounds->x1 -= half_pixel_x; + vertex_bounds->y0 += half_pixel_y; + vertex_bounds->y1 += half_pixel_y; + + return 0.5f; +} /* ---------- public code */ void _rasterizer_screen_effect( @@ -582,6 +622,8 @@ void _rasterizer_screen_effect( short viewport_width; short viewport_height; real_rectangle2d vertex_bounds; + /* port: rasterizer_screen_effect_cover_target */ + real texel_shift; unsigned long light_enhancement_input; unsigned long desaturation_input; struct rasterizer_cinematic_screen_effect_parameters scaled_parameters; @@ -1124,6 +1166,15 @@ void _rasterizer_screen_effect( vertex_bounds.x1 = vertex_bounds.y0 = 1.0f; } + /* port: the quad covers its whole viewport on a screen drawn at + more pixels than the Xbox's; its texture coordinates are floats, + moved with it (rasterizer_screen_effect_cover_target) */ + texel_shift = rasterizer_screen_effect_cover_target( + destination_target, + viewport_width, + viewport_height, + &vertex_bounds); + if (IDirect3DDevice8_Begin( global_d3d_device, D3DPT_TRIANGLEFAN) >= 0 && success) @@ -1138,11 +1189,11 @@ void _rasterizer_screen_effect( "IDirect3DDevice8_Begin(global_d3d_device, D3DPT_TRIANGLEFAN)"); } - if (IDirect3DDevice8_SetVertexData2s( + if (IDirect3DDevice8_SetVertexData2f( global_d3d_device, 4, - 0, - viewport_height) >= 0 && success) + -texel_shift, + (real)viewport_height - texel_shift) >= 0 && success) { success = TRUE; } @@ -1151,7 +1202,7 @@ void _rasterizer_screen_effect( success = FALSE; rasterizer_error( 0, - "IDirect3DDevice8_SetVertexData2s(global_d3d_device, 4, 0, viewport_height)"); + "IDirect3DDevice8_SetVertexData2f(global_d3d_device, 4, -texel_shift, viewport_height - texel_shift)"); } if (IDirect3DDevice8_SetVertexData2f( @@ -1170,11 +1221,11 @@ void _rasterizer_screen_effect( "IDirect3DDevice8_SetVertexData2f(global_d3d_device, VSDE_VERTEX, vertex_bounds.x0, vertex_bounds.y1)"); } - if (IDirect3DDevice8_SetVertexData2s( + if (IDirect3DDevice8_SetVertexData2f( global_d3d_device, 4, - viewport_width, - viewport_height) >= 0 && success) + (real)viewport_width - texel_shift, + (real)viewport_height - texel_shift) >= 0 && success) { success = TRUE; } @@ -1183,7 +1234,7 @@ void _rasterizer_screen_effect( success = FALSE; rasterizer_error( 0, - "IDirect3DDevice8_SetVertexData2s(global_d3d_device, 4, viewport_width, viewport_height)"); + "IDirect3DDevice8_SetVertexData2f(global_d3d_device, 4, viewport_width - texel_shift, viewport_height - texel_shift)"); } if (IDirect3DDevice8_SetVertexData2f( @@ -1202,11 +1253,11 @@ void _rasterizer_screen_effect( "IDirect3DDevice8_SetVertexData2f(global_d3d_device, VSDE_VERTEX, vertex_bounds.x1, vertex_bounds.y1)"); } - if (IDirect3DDevice8_SetVertexData2s( + if (IDirect3DDevice8_SetVertexData2f( global_d3d_device, 4, - viewport_width, - 0) >= 0 && success) + (real)viewport_width - texel_shift, + -texel_shift) >= 0 && success) { success = TRUE; } @@ -1215,7 +1266,7 @@ void _rasterizer_screen_effect( success = FALSE; rasterizer_error( 0, - "IDirect3DDevice8_SetVertexData2s(global_d3d_device, 4, viewport_width, 0)"); + "IDirect3DDevice8_SetVertexData2f(global_d3d_device, 4, viewport_width - texel_shift, -texel_shift)"); } if (IDirect3DDevice8_SetVertexData2f( @@ -1234,11 +1285,11 @@ void _rasterizer_screen_effect( "IDirect3DDevice8_SetVertexData2f(global_d3d_device, VSDE_VERTEX, vertex_bounds.x1, vertex_bounds.y0)"); } - if (IDirect3DDevice8_SetVertexData2s( + if (IDirect3DDevice8_SetVertexData2f( global_d3d_device, 4, - 0, - 0) >= 0 && success) + -texel_shift, + -texel_shift) >= 0 && success) { success = TRUE; } @@ -1247,7 +1298,7 @@ void _rasterizer_screen_effect( success = FALSE; rasterizer_error( 0, - "IDirect3DDevice8_SetVertexData2s(global_d3d_device, 4, 0, 0)"); + "IDirect3DDevice8_SetVertexData2f(global_d3d_device, 4, -texel_shift, -texel_shift)"); } if (IDirect3DDevice8_SetVertexData2f( diff --git a/tools/harness/tests/screen_effect_cover.c b/tools/harness/tests/screen_effect_cover.c new file mode 100644 index 0000000000..5465fb1e36 --- /dev/null +++ b/tools/harness/tests/screen_effect_cover.c @@ -0,0 +1,148 @@ +/* +SCREEN_EFFECT_COVER.C (test) + +The real rasterizer_screen_effect_cover_target (rasterizer_xbox_screen_effect.c): +a screen effect pass's quad, once the shader translator has moved it half a game +pixel right and down (nv2a_vsh.c), covers the whole viewport it draws into, from +its left and top edges, and each place on it has the texture coordinate it had +before. Before, the quad started half a game pixel in, and on a screen drawn at +several pixels to the game's one the first pixels on the left and top were never +drawn: a scope's zoom warp spread their stale contents into a dark band. The +draw gives the quad's corners the texture coordinates 0 and the viewport's size, +less the shift the function returns; checked_quad does the same. +test_screen_effect_cover.py takes the target's number (config.inc) and the code +(under_test.inc). +*/ + +#include +#include "harness.h" + +typedef struct { real x0, x1, y0, y1; } real_rectangle2d; + +#include "config.inc" + +/* the screen's columns: a 16:9 screen's, or the Xbox's 640 */ +static long screen_width = 854; +static long halo_screen_width(void) { return screen_width; } + +#include "under_test.inc" + +enum { SCREEN_HEIGHT = 480 }; +enum { _rasterizer_target_render_primary = 0 }; + +/* the viewport a pass draws into, in the game's pixels */ +struct viewport { real x, y, width, height; }; + +/* a vertex where the translator puts it, in the game's pixels: Direct3D's +viewport transform, then OpenGL's half pixel */ +static real screen_x(real ndc, struct viewport const *viewport) +{ + return viewport->x + (ndc + 1.0f) * 0.5f * viewport->width + 0.5f; +} +static real screen_y(real ndc, struct viewport const *viewport) +{ + return viewport->y + (1.0f - ndc) * 0.5f * viewport->height + 0.5f; +} + +/* a pass's quad from the stock bounds the game sets: it covers left to right +and top to bottom (the game's pixels), and the texture coordinate at each place +across it is the stock quad's there */ +static void checked_quad( + short destination, + short viewport_width, + short viewport_height, + real_rectangle2d stock, + struct viewport const *viewport, + real left, + real top, + real right, + real bottom) +{ + real_rectangle2d bounds = stock; + real shift = rasterizer_screen_effect_cover_target(destination, viewport_width, viewport_height, &bounds); + real x0 = screen_x(bounds.x0, viewport), x1 = screen_x(bounds.x1, viewport); + real y0 = screen_y(bounds.y0, viewport), y1 = screen_y(bounds.y1, viewport); + real stock_x0 = screen_x(stock.x0, viewport), stock_x1 = screen_x(stock.x1, viewport); + real stock_y0 = screen_y(stock.y0, viewport), stock_y1 = screen_y(stock.y1, viewport); + int step; + + CHECK(fabsf(x0 - left) < 0.001f && fabsf(x1 - right) < 0.001f, + "the quad covers columns %g to %g, not %g to %g", x0, x1, left, right); + CHECK(fabsf(y0 - top) < 0.001f && fabsf(y1 - bottom) < 0.001f, + "the quad covers lines %g to %g, not %g to %g", y0, y1, top, bottom); + for (step = 0; step <= 8; step++) + { + real x = left + (right - left) * (real)step / 8.0f; + real y = top + (bottom - top) * (real)step / 8.0f; + real u = -shift + (x - x0) / (x1 - x0) * (real)viewport_width; + real v = -shift + (y - y0) / (y1 - y0) * (real)viewport_height; + real stock_u = (x - stock_x0) / (stock_x1 - stock_x0) * (real)viewport_width; + real stock_v = (y - stock_y0) / (stock_y1 - stock_y0) * (real)viewport_height; + + CHECK(fabsf(u - stock_u) < 0.001f, "column %g samples %g, not the stock quad's %g", x, u, stock_u); + CHECK(fabsf(v - stock_v) < 0.001f, "line %g samples %g, not the stock quad's %g", y, v, stock_v); + } +} + +/* the stock bounds of a pass that covers its viewport (-1 to 1), and of a +split screen's first pass, its window in the whole surface (the game's) */ +static real_rectangle2d whole_viewport(void) +{ + real_rectangle2d bounds; + + bounds.x0 = bounds.y1 = -1.0f; + bounds.x1 = bounds.y0 = 1.0f; + return bounds; +} +static real_rectangle2d window_in_surface(short x0, short y0, short x1, short y1) +{ + real_rectangle2d bounds; + + bounds.x0 = 2 * x0 * (1.0f / (real)halo_screen_width()) - 1.0f; + bounds.x1 = 2 * x1 * (1.0f / (real)halo_screen_width()) - 1.0f; + bounds.y0 = -2 * y0 * (1.0f / 480.0f) + 1.0f; + bounds.y1 = -2 * y1 * (1.0f / 480.0f) + 1.0f; + return bounds; +} + +int main(int argc, char **argv) +{ + const char *case_name = argc > 1 ? argv[1] : ""; + + /* one window on a 16:9 screen: the passes into the second surface and + into the primary, and a single pass, cover the whole screen */ + CASE("covers-the-screen") + { + struct viewport screen = { 0.0f, 0.0f, 854.0f, 480.0f }; + + checked_quad(_rasterizer_target_screen_effect, 854, 480, whole_viewport(), &screen, 0, 0, 854, 480); + checked_quad(_rasterizer_target_render_primary, 854, 480, whole_viewport(), &screen, 0, 0, 854, 480); + checked_quad(NONE, 854, 480, whole_viewport(), &screen, 0, 0, 854, 480); + return 0; + } + /* the Xbox's 640x480: the same */ + CASE("covers-at-the-xbox-size") + { + struct viewport screen = { 0.0f, 0.0f, 640.0f, 480.0f }; + + screen_width = 640; + checked_quad(_rasterizer_target_screen_effect, 640, 480, whole_viewport(), &screen, 0, 0, 640, 480); + checked_quad(_rasterizer_target_render_primary, 640, 480, whole_viewport(), &screen, 0, 0, 640, 480); + return 0; + } + /* four players: the bottom right window's first pass draws its part of + the whole second surface, the second pass the window's viewport of the + primary */ + CASE("split-screen") + { + struct viewport surface = { 0.0f, 0.0f, 854.0f, 480.0f }; + struct viewport window = { 427.0f, 240.0f, 427.0f, 240.0f }; + + checked_quad(_rasterizer_target_screen_effect, 427, 240, window_in_surface(427, 240, 854, 480), &surface, + 427, 240, 854, 480); + checked_quad(_rasterizer_target_render_primary, 427, 240, whole_viewport(), &window, 427, 240, 854, 480); + return 0; + } + fprintf(stderr, "unknown case '%s'\n", case_name); + return 2; +} diff --git a/tools/harness/tests/test_screen_effect_cover.py b/tools/harness/tests/test_screen_effect_cover.py new file mode 100644 index 0000000000..86ec6a833d --- /dev/null +++ b/tools/harness/tests/test_screen_effect_cover.py @@ -0,0 +1,47 @@ +"""The real rasterizer_screen_effect_cover_target (rasterizer_xbox_screen_effect.c): a screen effect pass's quad covers +the whole viewport it draws into, from its left and top edges, on a screen of any width and in split screen, and each +place on it samples the texture where it did before.""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, build, constant, function, mutated, read, run # noqa: E402 + +CASES = ["covers-the-screen", "covers-at-the-xbox-size", "split-screen"] + +# a fault in rasterizer_screen_effect_cover_target and the case that must catch it +NEGATIVE_CONTROLS = { + # the quad left where the translator puts it: half a game pixel inside the left and top edges + "not-moved": (("half_pixel_x = 1.0f / (real)viewport_width;", "half_pixel_x = 0.0f;"), "covers-the-screen"), + "moved-down": (("vertex_bounds->y0 += half_pixel_y;", "vertex_bounds->y0 -= half_pixel_y;"), + "covers-the-screen"), + # the quad moved but not its texture coordinates: every pass samples half a pixel over + "texture-not-moved": (("return 0.5f;", "return 0.0f;"), "covers-the-screen"), + # the window's pixel for the whole surface's + "window-for-the-surface": (("destination_target == _rasterizer_target_screen_effect", "FALSE"), "split-screen"), +} + + +def generated(fault=None): + source = read("source/rasterizer/xbox/rasterizer_xbox_screen_effect.c") + config = "enum { _rasterizer_target_screen_effect = %d };\n" % constant(source, "_rasterizer_target_screen_effect") + code = function(source, "rasterizer_screen_effect_cover_target") + if fault: + code = mutated(code, *fault) + return (("config.inc", config), ("under_test.inc", code + "\n")) + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("screen_effect_cover", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, case = NEGATIVE_CONTROLS[control] + status, output = run(build("screen_effect_cover", generated(fault)), case) + assert status == CHECK_FAILED, f"the quad with a fault ({control}) passed '{case}': the test cannot see it" From 28a3492f630458de9abcb60840bb15c223c562d8 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 3 Nov 2026 22:21:10 +0100 Subject: [PATCH 10/32] Size the Xbox scope's 2x and 10x to the stock glyphs, not their sprites hud_corrections.py resized the supplied 2x and 10x pictures to fill their sprite cells ([0,0,31,24] and [0,24,43,48]), but the Xbox's glyphs sit inside those with empty texels around them: 2x covers 21x14 texels and 10x 32x14 (alpha >= 128, measured in the Xbox maps), where the high-res labels covered 30x18 and 42x18, a third larger than on the Xbox. The pictures are now placed on the stock glyph bounds, still resized proportionally: 10x within one high-res pixel of them, 2x within three (its supplied glyphs are a little wider for their height than the stock's). Only sniper__hud_reticles_scope__0.png and its layout entry change; the rocket reticle and the Custom Edition scope rebuild unchanged. Custom Edition's own 2x is left as it is: its stock size was not measured. Checked: python tools/hud_corrections.py regenerates the picture; tools/test_hud_corrections.py passes (5), with a new test of the labels' visible bounds that the previous picture fails by 35 and 42 high-res pixels; in the game on Windows, the pistol's and the sniper rifle's labels are the Xbox's size. Co-Authored-By: Claude Opus 5.5 --- docs/reading/hud_corrections.md | 10 ++++++++-- port/assets/hud/corrections/README.md | 4 +++- port/assets/hud/layout.json | 16 ++++++++-------- .../hud/sniper__hud_reticles_scope__0.png | Bin 45603 -> 33739 bytes tools/hud_corrections.py | 11 ++++++++++- tools/test_hud_corrections.py | 18 ++++++++++++++++++ 6 files changed, 47 insertions(+), 12 deletions(-) diff --git a/docs/reading/hud_corrections.md b/docs/reading/hud_corrections.md index a5ef638010..fb61da932c 100644 --- a/docs/reading/hud_corrections.md +++ b/docs/reading/hud_corrections.md @@ -2,7 +2,8 @@ With High-Res HUD enabled, the stock rocket reticle, sniper reticle and supplied zoom labels use the contributor's [original pictures](../../port/assets/hud/corrections/README.md). -The sniper reticle retains the low-resolution artwork's visible size and center. +The sniper reticle and the Xbox's 2x and 10x zoom labels retain the +low-resolution artwork's visible size and center. Input, simulation, zoom magnification and sprite geometry are unchanged. ## Reading route @@ -12,6 +13,10 @@ Input, simulation, zoom magnification and sprite geometry are unchanged. converts black-background luminance to premultiplied white coverage and scales proportionally. It never redraws shapes or substitutes a font. Visible reticle bounds match the stock 9x9 texel footprint within one generated high-res pixel. + The Xbox zoom labels are placed on their stock glyphs, not their sprites, + which have empty texels around the glyphs: 10x matches the stock 32x14 + texels within one high-res pixel, 2x the stock 21x14 within three (the + supplied 2x is a little wider for its height). 2. Xbox uses its existing texture override and atlas. The setting and original pixel signature gate replacement in [hud_hires.c](../../port/linux/src/hud_hires.c). CE's rocket uses the existing compatible-sheet registration. @@ -31,6 +36,7 @@ or weapon behavior change. Placeholders belong to the existing map lifetime and are released by `hud_hires_tags_unloaded`. [Correction tests](../../tools/test_hud_corrections.py) verify original source -hashes, exact supplied-pixel packing, the stock visible reticle footprint and +hashes, exact supplied-pixel packing, the stock visible footprints of the reticle +and the Xbox zoom labels, and CE's frame selection, including disabled, custom-art and failed-match fallbacks. Actual maps and renderers additionally require runtime checks. diff --git a/port/assets/hud/corrections/README.md b/port/assets/hud/corrections/README.md index 49462442ca..90b5bcdbac 100644 --- a/port/assets/hud/corrections/README.md +++ b/port/assets/hud/corrections/README.md @@ -9,7 +9,9 @@ The owner explicitly requested using these pictures directly, without tracing, redrawing, font substitution or geometric reconstruction. `tools/hud_corrections.py` packs the rocket, sniper reticle, 2x and 10x pictures into the existing texture cells. Black becomes transparent; grayscale edges retain their antialiasing. -Resizing is proportional. The game tints the resulting monochrome coverage. +Resizing is proportional. The reticle and the Xbox 2x and 10x are sized to +the stock artwork's visible glyphs, not to their padded sprite cells. The game +tints the resulting monochrome coverage. CE's existing 8x artwork remains untouched, as explicitly requested. Run `python tools/hud_corrections.py` from the repository root. It requires diff --git a/port/assets/hud/layout.json b/port/assets/hud/layout.json index 2d07f43d25..02f3dc2645 100644 --- a/port/assets/hud/layout.json +++ b/port/assets/hud/layout.json @@ -3311,10 +3311,10 @@ "cells": [ { "xbox": [ - 0, - 0, - 31, - 24 + 4.125, + 4.875, + 26.75, + 19.0 ], "source_png": "corrections/scope.png", "crop": [ @@ -3327,10 +3327,10 @@ }, { "xbox": [ - 0, - 24, - 43, - 48 + 5.25, + 28.75, + 38.5, + 43.125 ], "source_png": "corrections/scope.png", "crop": [ diff --git a/port/assets/hud/sniper__hud_reticles_scope__0.png b/port/assets/hud/sniper__hud_reticles_scope__0.png index 3bd4aa19c09a5b820dfd207b02dbedcfb39b6e15..9ddb9ff9f348950bd08f8a277f53fba1005e1f24 100644 GIT binary patch literal 33739 zcmeEv_g~I$8~(c?q+~0i&>$m1DH=vgR8~V8R6@u|rFA!K(xT8LD=ng;Y1b`Eq*NLd zEoslYy1&PHKhN_Ae1G}=^u1o5N9yi9uIoIH^Ei+5yspnhV?$k0;aS2A!-(qZZQ9N- z{P>ce87G9ldfZBf7{)(Df7AM%Zk27-rq0*Q)1^8-sE)n9`QCON^KtqaQ;6m3RN;^3<5WV->5s8jDs{zm;1xE@+(SxJm2HH>f5&lG#=CVtBb) zzjxT}h}e`#9o!seBe9(Wk6PApBA*u5h|rDyACH%6w!fw@FUPXtY?W@7vW#S=Tr?eA zdvVkJCw(y@LjIi!EGdSMVP3AC+tV!mm|ed>oDsw$&R!>7cjCn!UoGjn?m^GFf%*0L z*P&Rg>GE?#p#t*+8 zN;7=$H6DNMSr&fz-M$t0Y);eheNW6jHg*q6PCWmn*;&oh#Ka`gB;nhO5BT1%PB+QL zd~~(CIr!KTrIQZJ#YOPM`bI`Z#n#tv-n!h-yK zQ^qY>x^(HM;^N}K0E{bFu6)GTYoiPumdv=k!c^Ae=Tts=OZ{7&E5$|5;mI8A?d`8$ zyLN3S#E#+cJ%B!6|Q#;dTQXoa}GCi?O$nO{su<8^Qw<#6o2~kDd6hWs}mDv z&6zVNZFiE%6wmBKcjl3m`DDDU?{sU%xQ^k`_qSrlu3fvXM?^#5l(-*rh89Q$=kPY&lERy|NP2Z4}EEvWrT`T&5!Tj zznfWL;6d+hkKd_iQDd#x>9+14{^9{v*uXc%e)~|Zi{tVdV2bd>C>mPf1glgkB@aPv59oMSi5$&-e`uF z^rQo;W^KjtnC!-wAFOxd)BdM;tl%UeLEakK%;_Kged0xSe0+Sf7;E5-R7-1XYp;7w z^qZf@tX5q}8L9nf+VQAL=&*9c){u#()S{Nbm}btHF=O4_z(#Ff0p4t>+{HX^#b6D6 zjTzmEw_6-u_#11lV>!?5I^Sr&Uw|o@8{V-9i;a853;DmA1e>^waWtbUz-U5{z+*m# zDW$VtwdypNrpB%r5(vt!O~R_EtX{o(q%)8;Y>OKNY?>b&bdr0e_Irxh*$wmZYTLz< zyWDeX@S~{ zv#j?NIh7}&SdoXhebPI$Lh0rfIs6{CEM%Vd0OQmjvHyu#A}s6LIgFIN(V^CsmXoobpD(UfC=+j8Z#wXz$*=eEs}djMmd~3FnGw z@V4$p2@LMN{(~Q0=>XO<=(jFolj!IxrF6q_)vQmx0%Ipet~vhe?bSJJUG(m4Uo;qy zcc8ciE1LiQ{rl*oS(1{Hkx@}mF!wSz{{HM=of}^4S!QHlV6b@M!iC?{k5r}O{aWta zxikOX_IR0;K=<|w`Sr)~J_^g0EgK!~F4hziyW<4;Upb8|pL%hVAIwj{SIfr5#ib0uv2?~|dv|yD zN|*rKHt&Fe&%Zx1jFb|lsUnZX9mQPqRjI@Mwk@1?2`lerp3d#U2$eUA{_*^Hy^v~L zJKm!7rJq}cQ&f+DG&caYee%SK6NfP9J?QT%I&s0*rrsCgZ?GaTtj2hN>HSt#R;Ijm z?OFqcvA;AJGd?%}4U&%ib=*PG<7*@dBk|PA5uZ#Hu7CK5@fV|~r^gv@6_&IeIF*{8 z4~%GNAFlZr!?sh>kLtjqOayvi-CZ!JS=C3a;d8ezOS7eo)4QUtO?K^(@^l8=cG6kC%KSqn`xa2=t z81_joP7F343Ll9wd{BsA`+}8FN>BlkKEr^iPs7jSIHx?|<8TVXUL!+8!)3r^{NO8) z?Je#XiKRT9W90;JA1&*WW$G8yc00g(l1vhk5)u+PU4@EyKVF<{G{lVEpZK`6b}KH2 zOBL5*<=zi=Hx_Gp4-c$3RaWd)xKz6<55L!25~myZ1IB=fy*gLjeU+xBW(;Q8YT6Ch za&uXdMMjrt6d45WFX`Ryv(%^jqS+eBt9Ttu+dTx6AU;iXb#=>xZ-+#M{O4hLE#N2c zKKHg6>m&WvtB&uu|A2XzFsY%bq;i+wb|vS1`}X~!hsnoq*pK1h-~h9H2bbyGB{;y1 z7=i)crHSzz{2gv>Vrpu-c$)CL%VCx5JbD}JOy}k=H&;3oIlH>LhSrw37d?v)e1{h} zi=lf}Qnl;dOl!|=+qZA;vbdO!XuEFJs#QH-jWkDk%JW8hQ{+O~XQY|O{2jvi-q%1~ z@VI2+!Wc>f5g74va=jC>W}d1I3~o@m_YMQgdiop2{-+h|@Ve^DmoM*V!sIQ$z_U+* z=?-juoqzRKNBNg8U%J{dYP~HiEiLK2N~f>vYH#RYhpA-^cKt3SSLpp)@zeoap(E8Z zGQ-Qu?$;#5bs9jo54Z;ctc97GnO@swJ<*+R>*hLj|M?W5KevSDO%bqg=kW67#^ai} za~{H6zS}Gp-;6O)5+UmIqalo_wXXk`EMC005Fz&)CKd?xv(qg*&7b2W`%AWd8h@{< ze%f*v!0K56?|as8bmF7%-0eFtKq6JgS-fchqW}whubyOb|)( zIwJG@C!hr;xS2>!5k^5lf%Nr{FnRfD!e@_dWZvtVT)X^gz~XG4|5&lP!&!Npth|Ke zRZ}nH&QlL$5Pn z=B`=`i=mToCX#n^z47w9;&(nu!~jM~!d(qzri=%Qd2c(b8w;| zwbbRMrly|w^YP|N)@XMO_eXho`An>6dy6_q({nH@EXNKIH3=|0eKXH(%Zss?2;>Qf z1{yKNDsalye$I$p6@RVBz(WBa!f8aSzn>8?4m}A_Novh0@#AX|<^zX#^zG0o9UYx> z2&6PaxHw?7zf!@Tk=o4!0m3{rVyw4q+t$f5acgU9qbZ|SeH>+c)*yVD<S#8uTTwc@a{`S|7dDPHL_U=J&H$7H;0yDU5d=&g&VMA$0@+aZqg`sXq6JXbs; z13Xv6NI{4;#$2Uw4obBI%fkREis6?6fKvDF-IIkq4d$pdGVPo$79g|eiEaK>vZebE z9z4iPEHDCFEP@kQ=6VixX^wo=9GQA^@M}(EvD?{5rpq08d=fE#9u}PZ9uF5H$mi(j z6SPZ9sNU(~6`4ho>f$Ccn-Q%GF<>u48z(Yl|4fPF#v6)*&I3+1g`8nD-d5ycy8pqi ziydOT`phv$lU3OpE&JrRx)8*rSmn6)VhJ7eUf1fV>gcM^`0K0|1s`2Z^umw%^2n{N z)Aoy70T$%}4D7RxxW*7;LyoU`Zn{reVPwPeAi&i@&`X$U$aTik{^v$c4IIs7J%*#B zW9ul$aN+M0gO~e;5&B;vqoDZK*3W?k%kX;Afkia z8s0WuST4&?}(<;z2kOn_s709X<0!k7u7n7UcZ za-<%w1BMuN>((tL#C`AsZC~lwrF!pVZ4M9oV1Ulw@ml`~0>1!4TaN4QndcS*mg|i% zie*s=U2szIP~}QxWo4JP$}~r?yeJu|{c&QKO3U%ap2gAX9xfw8y{&jiuhK^`rJC~K zkhE?AOJmO~6Suo!@fQ&N{{MqdnSt9TAuCWQ+yesOOU9zev zNAYaObmQYg8rMJF`=f~HP)sjgjfYJJL&$mfd~zt;uEkl+63?m8Z11;JQ;YGLy?;Jn zAnV187pn5|@?%v5Z9JtpT>SIJB8*bLD79ncC+c7bokVin#m>o$56%(-Gf#0Xo&;}A zHCqyPUQ9|OGCDe1F6mXdkP@(QAN&hQla>B%ITDjuvjVd}>QpMqF4fl8>*!gYVmP;*e+mYQO9FIPG%w6lHejh|2>f-)$^o@1 zY;(!7?ujP60(K*2p1Za|Xy8BF#;Je}2nn;B5VM;b&R7f8p zB5vHcA)WH`h>1XfM+axb`}~`qW*#wjtY+hJw6BUuGj3=3SVxW=fki#8IGo;C9HYq^ z0?azV=Pi(f(5iqD1K)}Ot3yd%UMFg-FpogqBCs`L6a*OwNRdnM?~pm;CRJb%gC!S- z*;O~pvYF1*%(QDCvFEhOFlH$y@*4VIVGVhfGSKn!4IDzxJFnEF9;h-OrqGK)xfZvP zI~>EUho;cFTAHl-rdxJXZl{Gs7$bFx(W)+gFbWYzh!_q*nVXy2^OTenCkQxp_}cC0 z(WC#i#$fUWVTMQWjT^`>=W2KkJkG#C%)N(2zEs)0)*RW9GWhRCC;$akzY=zuZQ0*B z^}W!eC4(z++WVjIm9d)}p=gSP1700u#9?2^cdKt+Ms_nZ)OO z{63;5Y~}5h!!hLL3gY-?Tud%PPv1B;4fprx4-C( zeQKBO1So-f-!fH|AAT~?Q2d%oZmoskgXF}VPmTH`ZO8)+!E6Z8bms>bT<)vT50M5^ z5>ucA1L@w9+#Nm;(P~SPb*%)tAv5xzDLQuS*yF6MEKX;AexNR{f$(+m$TdUVQg1Fo zE~cjmlMNSHN&*&JuaYq2gvpItT4|HhnX>x*qK~)9O9Hh*JY8b`mIU8Z!}Lag`gua< zdYX?F|JUB$9s^vpGB-D$_btupnq^jv${AS3UtDl9I60!^VPw`sMB<9(Hf3vo3B==N zN%|=5&*|H;V*KWi;|t=-wz`G4*Rm45$EU@-)Uyhi!YXO8v8Bf(*Fe_|l((sp+8$g<6JE_%s!n1UeD-(H)S2f^eB z#JA~ov;BsZ^-gI4$95wHKZ-0wK8;hJpjMZV>m(@Iu}z2p;u@+yZHG6GU{HKU{PJ9b z(FAKK^^HjsmpOUX54;2``V{XZk0ec#a#+AZNaq`3JS57z7^~=R2-11vz<~o0Z5B%F z-e~eXg4a@7w{G1J$Qn9^3uRg_+=VDM4MS4RxyhL1OJogS)Cb1$`)YwG$AAheE?Kgq zF09Kvud`Mm^$naEf3~E*s8N16pQVXaX-ED}&rl0CLSe?!mHkKpi$z%;WXkqY%JJQD ztlHg$TmY&%U9MI=H)O`GmmV)=&uHxrq12QnlTt|v9MxaHe%%<=>2&5y3Ra#7eGLX> zhorD<(GhL0Ra4lMIgXMA=#O?O^Ht0FOH%2a=cdg`2+7|6EF}9)>1M~UP3Te z+U7LAEhx7C_ERDSW|#qnL$-c{vVqobAmX)aj`SXc_3@)PUg#xoB!jWG|W!A@+! zc>e&hDJ`c<^r}tGu1TqA@8=M0=6N&h6*K0xptJ>qHpU4&kyIB%H3h8ZU5+Y}lTLyT zIZz=`rV8LulbVKhatea#F6hLP>)bkQko|QQUMgH(_T)kUuJ!}`>Zpf@M;5-WbLkpN z&<05VLt3S1F?Xi$+1I6|r8^NFyv(BuBfOp5@ks(QLJLZ_NMRs?3(p|W>IW+to}Qk=bbtNp zX}wlS^U5_~aNN(gH=ZZbf@m3YHA(u=S$%NhHW0xz$dBXU%#;6)tgJb3ol?s(A*QrF z1x&*ZkA;M>*ImfGfwLFsK5MYhdX$%`;?^Iq+;;~)yNl(@j6?n7HE??S7lFNP?aSdg z@QJz%6J185iShaCo7DC_{IAz&`OUML)kjt0mG%0qIIY>~cr9-z`*Ae4NY$!HgE%pD zPkS3P{!}ITkgcO1|CtG$FGzXacv%Y?X za|S;;3JjXf-e%6Jo@cRu;1St^kCTXJ&nZUv!xe77l`T@VbMPP?v(X3p)v@eV-=N?s zAi)G^gPYGu%VXvw0n(m42M0KQ^g6sR(UiTfOB@!G10{AO2}1(I`HxD_D=rB?M)dG- zBUb>h)Jm?(RG2aHfJ1mZWcl(4Wmjg(WQLKPICJJqv56BW3i12CbWfHEUN@`7Hl+Au zj{I!96ipB@S4!&&7ENzyCjKJ=c?FlTBpvgBMRdpB^+^Zc56Pb|IW5?kadqlQ*-1H+ zK%lr1rH-NrB(CNzvwFnp+gq4gPkll`NMqH+FLu(9aQz4-+H*Povf8RLoE|)#vS4t_ zc?>R(b+sZ-DhIup&>A#B>Vm#L40e3@{!%`ty@ik_1c6_DESU^ z+*#+ros!|-a(MoJdyC@`DAvMoNgDBUsYUfmT(L0p7+lg+KY4-wzXpu!i%pZ)c@A{U zs}>NySwfS-B1Q4v_b0aJ0f(W72Qx&Moj7^&q$U?KyDJFUWpLOgPbmZJl?#|Ry}h!q&N zOwRr!x)gB%^dc9;%f1%EH6H2j5x+~OFIeQ^2uq)-Oo4k0awd6h>o$H1`@;mL(sQE%;pe{P#*hXiGzVaW zCxYFDX;K@4#*bsf+l>NGsujoVP=y=fow7f4E<7~(8q)pWysQ=aazF`&>3HdyKB0$* zdz`+uDpQh&Di`LlKP`pp;a+ZpeSvs_C5Ta>{~jI0t96qU3c;w2vgEt3=ef^KG+NwF zG?PLV6_I9og@Z)w0n4QHf@JDvc;xN)x3YVe?0CBYPeL%wJp9Vp#C<{2RS*5+doe=* zqaMR~WEtP+>Fn3CW_eO-;C@bU-74~;ko8xu&Yo70#Uc_$Wl7{6p4Af1py*{mezaV1Y2WZ`z zE`Ps20lz3Ck8m#$^j)=vs$ryn-}CkLwZ>J&Vy8!w@F=I})xmY`gm zP{GvNt9KXtWaW$Z&e3Zq(aJgY^YvL^QcMi~QGGzySo2J+5e(=2hTB14LF9c+Ksnqi zQN2iiv%mvn?zl&vju<~Cb}`~^W2lkFV8h#sQ{{z05A=N>r9GG^ekE^u8}H>>;T5b3 zmvP8yMa7#zT=qlKaUm1Y*4BPkc*!%-^yLZ|(O1~WXahS`-Y&l5RP<#8bEI{p2s~g8 z-$9~Klkjz~zh`ShR1R*9Kf!Pra~Oih^?Q4Oep^6Ck;xr__S{rL*`->)E;y+p#TNLL z3Qxs42LRKTu>TQM&Q+NPI*uD8!XSVKH$i;$N9KmGpme4qhBWuOt0Umt+9Ee~aN?0O z#pjl^AqAsiaOHy-TuX{OkgqudBr#~yx3t1hnb^G3koYK7_81)=Jn6ZZ7S!j5Gi03< z5{PqibEAj9H}4)rP5%mc1Qi13ATFH#desA*iv$&r=JyE8=+AJrT8T`d0mzCM#H`8D zvR9l8>4$ynq^f%NAc9}6J@6j^SgSl&6Hx=&SUZng4%7!~hDcxDIp=LgbhLCOoQ^&O zgTgHSw71~H?e5{N8;Y@n6Aq3UUFOdmdYVL~Fkan|2l~H${o04;v8pP&k$7kf;t!P$ zhX;$HT$2nBRyk=3m)_C2dpoXt;%z=wDgK>y8K-F{!Z4{=|1j%EgSs9%b?@=ct961r z4wifvno#M{bIuI{#~@HZhYmLH1E)Fxx|7%R{u4ey!ZeK48y;g@8fWADkE2O%Om&~H zBp+n%SvGV3NRLC|4%54+PpN?_M=iOzBadqrbiMF*7x7^LDuQVkfsgC-*lni#-MEDz z%Da9ahpn$oGZ5n9&0CSHaetnd@;(5EI|&6dQ3<(g|6idna)r;IKR-rsvCT1Dv3xCK zhJ5ZIK9+^IvfQrFb0mhOBw*+cI6Yk)I)_c$6?^rRaCi-HS#^>7hOpz-tCmCHtbau_ zrh1}54|!BsrO2W8YGvn8@cT$+*Ph26%|Vlg_0^hLa}25M-pckT@IQW3^M} z0@6~w6b3*7b#wor%N?%A`4>V_Z%09yJIw1m!2P?dOu}vnuA@r2Ls6va$$v;dU`khR zLYj0dra%E4&p^mk2~EG_JccqV4M@8~bdhx^f<2zAy{(`&4XF)MTGE1uXy2*#GIr{N z5G4caf-xv-XlNLIh~Dm?g-px}L4iwFXa%3xyma8HRm$bc1_#Ii%_tpkN6AYrM)vD6 zLDWn`wfj1dZ16JbE&1Lu z6k|#6no#a-6rZn4RNQ;GGhd3Q%YwNIX|vGDQ$JG3_x;(yuarC7r)CoQ>A6j6e02!9 z8Aq!#PXTESh~c%K3SFS>5e7~VwQM=QVE}^6uKDe*+bwFF`;(CWu9Jj!0GFe6Jrxp1Xml&_KxtVO5RXQLJAKy9~(xE?1*sMiqeNScci~IM#F1}C|OU$ zpH2o#sH@Apf#MKwjv`4MF*vkB+_<{LJ=MCvX1L9)(t5NjJden7Qe&rlm*m}z7JzZ2 z!Vg-3-%-6HdU*^ccNP!_wroCc>khISNG}miRA@)Z>r%3xCSY|k{zp`1w3f?ZaoZ^a zy%CU86?+TB(Mu!7iOMX{9#L}G1Haerl0S7Y>fE?XhW?Z z1_f4LO?wF@)w?8GGi{}O{Nhev)aV*;FKyk3!w?Yl&HAX1V$F$$g5b=|OttNIu3fFr z^zY0_jTf7XG%h@E__rculCbL5n?0M3CX!WwKg@@1^7wU_mFzt~(inkvu!{X;V^~_T zIiO2CGe@v;$Vpk;yPp`F5oXxE3lWE>%O09q7g6N)U>83HaAcs37^oiVV!o*X1+>8` zY$|mkk=%t)PdX7mW-{tV&e?{TF%sWZDe@@h4*a?dTD>84U*8=W8}!*op>t#%RySSIA4T?OjO$KkW*WvAFk;7q*Cj-1M93G=3RbLHGG{FU{`~M z$V3JAJ*Xw!!c6T`PJco@UAPx@EPyP*R-zCYl0S`?PV%Byg9*ga{=k#~@6@sG57}`k zoHJ%H zF1mtJXa%C&|HWLr`+{H@OlO#28TBi(Sk51kN-M)Q$9k7F zkrkC?k=;)_H2g;A)hk)IkgVSHk;I`y1(-c4eD%x8FkpCO8zK21yc~yXU(3vI-&+s z>V(E7f7lglC;#$LhsDgxu&_*~hTq0E#LDQ)mg;b%jW5+_)@zBMf`2=WnYgZj; z$fAY=ipk`M#2u(s5eZ?IvU9~-y(yx*q4c~jLOHOH`g01lY5pA)0A+Y~kYw3as2`Hf z?FNFYJlO6msWM;Rn6ekpi74cSU)f7eY)GF=!j=;j>nkE3wXI-Xns;}`o~&~)o+5@P z8_P2vYN4PMvP0A%TcypPBq&A8Oy`R-jz^I@X5k(49=bA!U>&dvTm?-$_CV&Jv`px4 zdW?Da-sa+g6f*bWxanBn1mtpg$}1x7h^N z52WBVEBX#=`>z5#cA2h#38zIgT-M-}4~_Xeq}2-A-RG=)_X(Ii1SZ2TQjy8^TT6%_K`?OlbuHqzvyw>~Gl}`UY5=tw9b!j$99QnYT53Wn`PSlkWbZ9N z$!(x=jvC_9F9nUpxNn#?r8#tjZKpKk@Y@N*lRuoxk1@VWnn}fD>xK^*O~8q`xhO4b8-g z2Vwf5C=A$hdOxc4q?Qv&q!tcR5|DAMtAHBu1d;8GJqwKlb3-@Z`$EKpm!lMGaQ?0Y zjNPu^GE^B63g(dkUNl1K&sDTO2j#qT^;$5morD0o+kgAoA`qKYTDIJhv7-p}pOl8C z0gVP?FQT8TvcwVK3lMe4Q>mE+)uXkTNc5YUqV#9g!_m*a6P~1i(qK8|$HX;JHXI$M zi)aAMB~;@Dd>Eh3QI~|`4P+ai;7Ru$DJ%<6>PFP-q+%3hU+Y}K>Y>!wms&Y^1}%1h0(l7GkCvQf3^sfc zYdr(Tfv4TTe=t>{!@^+5(P5OF9@7MI(E%Tz8Yi#bWokm?q6V)Z!~XiJY|8Ru!aki= zGbB;Q{{`<%r5b^z8v+*Puf6N~M=d-`VQkfqLNrDY#>SSuUN!sRc^%g9I7kQ$7zWmwgd>an3?kvY>D_Sksx?N z@pagZW(0(KqUyi=*!Y6+@%ox5^LRoA-+{jMIhcy{$)V>Trclcqq3T-T6Z*M$`CL=t z<&Ml#qZD%Blq0>b&rX<0Hw@XPHNK|WAy-)?A4^DjD78K!{%*l9>O1ZQXPSnG=M@l+ z#UQ@fm9?Y3fIo)ryI?`#FuveN-VGjU(K)KX1)1hZO?UyMi1mc82RnkHqP~MH;XLs` z=_@XE{7}QuH+XUFdD%?}Wz>Y6Lq%t@rY{eFTZ%mfB>aZkkgj|J6tA1)6NLJoU4W~ zC;``Q$d8FdAF(jcT^?|IVn%nMw!Ex&;0q`9#C(uqAof;@-;JP74VAJrzOXku&nPv# zusBZKw!GCqaJAOeFW;xk?8w#7U5xG_q!)QOF#XL05=?{T$9cu18`2w=-5qdljyT?6& zHg&s-y17c`e07AMP1K+@m`oo?=`y;L}_^u$j!QDv?{rB_Dc`+nvVjiOmqbMOJA+^_W z8+B}=H4HzI8dc%`sU41>91Bx;SGgzl>B3KFOcsC^t>kM9UA0nejOdmg8x zo;kK*7*xkjOdd6_;4g0})13_ZZ?)<*Y~UI1p#T~VztO~>!$eVFQ(s;*8VRv6ml0@6 zQ9FsoM=1h4qR!jbG;B26hjisHmi%KS2CtD3RsWf34)ZU75vT*1 z;@vt*&q#koOwG1i^<&<6Dp_M!$R_;x1zC}#chF4#`_2??@D-8Kb`QT<5q? z7zSQ2-N7Fy`+}ryUY|To&ke}j^l@j=;nGBB1R}~=zw%ZLfz9e}bT@aB(EJkt6mNLy zh5lC=wW*7&dxxm}1@1e*Xvi}Z&)!k@CbX6a8_2nvdc#B@qhJULJ<%yjsUVeBS_ zA#J3RFi)5yD)N-nNvfQsMZIo1pgjw}MQt6>d2`%pDk*uS9tNq+&BiThp#VSFiR1u* zUj^D-C&J#hxO}Ni*bMCt5qt`|om9ZF2%H%PT_+l`67g6y+I~TGidw1z8B?-x@V9}( zhFN&dAv8}yxcp4ql}cfn{V1k_pVeL#{*&FlYZ@};$8h{1o@z*177)Ps$furwot!90 z?vy41Nd?UI@=~?N>DB!5+-@kvO zATz9NlEh$2lOcULc*fdan}CM=(VhpIBh6Hsh7PemPBA_-R9I%wF{~+d$#-;*W!wU* zJW;hUp(KViAjbWv86@j^$k9x#^3!$;LC6!u_t2N@)eT{T8k}U}`bM%oKA}=OcR-3; z&!TQd?~>l{7k(w^^}mB~#U07xb-1pV@!p0#U=oVNt-n2x`|DM z^&{p(<#-gT@Q_Z$B6n!n68O(o1~5%50StJwnEN6&Hnwh~)BwAVMP^Fn3c&0Qw_@YG znTX7;w5@>Z#gOZLcJMJilFa+}%htR>W(C*?2Q&DFe0zYV(ui6%U|{PTrMGm6+(E4b zk_JyOq9O`w00Ih09}-d>CKDuviOuJd*4T$eL{nga_^RlJ*!s6Zy-$eM;v1m5htVie z0yD?OrU7uVFX2Fa?=9?`91q|ied{3kAc~0(K?ey;GOrFK9jRXn{oRY4Tjl7F=bc0g z2_WuHo_KSwlCs(5nv9%21gty=vs+2%dPe*LD5YKW{cGnf_Kb;(nho+DX8lLqA$h%; zejWS?WNfwe(1zIE($GJ@O9yy|5e1fZ~(X@Bl>89$?LfmI~$<08{hmAU{SH|pMgwhc8G;6?h# zEGVbKW)@3KRtg@oG|qPR-p8>*3*Z_J;A>7wLBaXd{Q>Z)r1Cw{g=F~5?LX$r%TB9C z10ra{DyI0vZ|c3Eej6~d8;L_uA?D7hnn=tC7lyo8`P;mg7Dy#e4@gVr2~w>Qa+`+|5s?d_MG)~Hpocq+J1hZ&d1D)x^;hJ)kT#c*Vz=AvV2-8( zGL<9fI$8_z(KnE1HiLrQ4g8{j`qtOn+ zA{xlqu42M|Lz@d76>E)}CJuN2$Fo49*vUgb;KM20ENeiA5AjJx)C$r6A#mBQqjp?j z+iWi(z2wR9X9nb+TEq5hpw$(TV6gT}P54zuqq$#^x=B%;7;qa-N+8tRChgUHlsJ;I zv>lU91eBz?0jnVCoshyP(i6GItCIBiuzY-m2zCZ))!XQ_q&wX$OKmsszgvDfN#=kJ zDti=7K!+oNmIq>InBKANkT2vAHg%EJ;^})+@AUyVpqI_1hR0&GjXi`r+*=^!Bgn{% zJAe!ZPre@%Kz>Y2kQfN*lo|B`5+8j!b=dxJgJ24G6hkQY0x8f}=qx!k0y(RhjF!s4 zPAI7jW<;MynuAIPQkw^j)GS7#Ctml$)tu8^(ZIHkh=QcPQJF*!M*5hasD@AHyx+no z{99rZS3PaTK^AYn&4T;o34a}OS?B`Z)GkRIqqh15g&(mDTJK7IcDw`xi%S0pE_!t% zn~OT~FP`mCL0I!i!!oUNjOr4?qKvW+Xq)mOYmZIez2cs(Mz#=ydaX6e5LK%{K z5k1=b_m`CppCc975ejZA1d&cuyHo9q^3$N-9S2}N!Um%Qh^I?|9~&F7Fo^lRiL}2I z4F4{AV(yS9A`Wf_!)e+)w17*^CM=#DiTVR|6{m12k&1WF&L46O+P2)ZWs)psgs3uw zEt+fO50!6W1kNfk{EUs~-)hEpy#?zmxd4$X)b81e1;x16cdZl zKv0$tZ5CTLA3N)cNL}87O6ylZB3SK~OvB4Thl1co#NcdzhF}DQ=>79le9%~pqYRWe z@@X@$DfbBgg#-%@^`F<=J@&bE?w|)T62RVIBlYj7m}fpPu_3A82v{C%?1Q_xs7>j4 zSWZoHmoHtqG!a#`W8{E4D2w)*+R?_l$vH6w?cgpHyyz2;F|8M%Sv1djt7qv)TNW@o z(UckNfac>-RqqV=T0t8#sl-ObeFA^l6z052WPUm2*+f2|X3{Ra63CKZGGpZhD0Dsk zIhKXc^CJ~)45T&D94&3yE1_bBh)KGdN43x2ZfC$!`M|QTL3TrU}CqS<|(_?zhkKYt;jjaVQQ%$ zpC%t--*iz^s)+wK%JV_efT&#S+vOT2RBKEocFXc>MWh3P|7 z-GMUUEuiZ^yd1D(v9t2vcsM1^E1Ju$B5zm$%a7wE4p(mWCkB7_!#sTBYqg&$ew#cC z2=VM7Sv$<>i73Hojg2Nf(oc#vF@U@)$-+)jEedp|2JI22ZLd0rG5GTlQ)rp();S;G zuZtd5%WtE49Vjd*msM3&If6X0Kh~3I2c(8*{PPX10QFMi74CCXRV63pKfUzkr;ULq zQxkLCHcoUOxeQDQ(erX(WAE(mR2)LS1099V4Ecag74xA7Sw&iK)YGQ0pYK`ECz32; ze*FsH&Fp?;c2r>keSVuU8HAGrpN`BqtsR}qYg+WA{X?GhExURwx3JwfHBWQ)PcJz3 z{~!PNGw_n*bJ2j&=YBy^wS~Gcg59d}_i)Udx^M6@|1RGaZGn#wB()=18@?g5P!8IU z9<~h+6pl(deoN4B@%&bpyZ|N~T>LL%RFaPS@i`^g!+x37$E71|iWFe!*Pvj*4W=M* z#9!{$rKOSJ;8Y1|WE;Tce2^a4i!|(*3;#h&cqt zu(_Y148nFt1b_k?d-Q1Q7oNBLH!n$0c04D6Wld96l&@hko-xLVcab*mqG9NCBszH$+;% zLV)SmcJ<_QL2kRWju1aqBOV#=4>UY4!=pPuvkY&u%sa|R15&B3ONnm@+>`cZmo|D! zg%7bq)W%a$AQ33~5Pdq&se*#spL*~xTidaQSYjLx(pk3h`FuQ<3>Lr-#jwmN%c1>N z;G!$RLo1PcG$M&Doi5xpIA5HYztjqRSmA%oCfMxeiKL3et&BN|c+WkUMh@sHNz2sh zSlh_>WQG@k;A2fgQAEzb*1II6Qmcu5B%+-Ji=66Wt-1b5G#^z0s3{MR22!{c2 zSF~rD@zJqLNJbYyuq74Y0KqmYk7&d6n>j|<(MagCMC9^?Y!;-5$gD>oYxejMkD?P5 zv`2jWi2M%Rb=+duB2%|uV-%Zq#}Zy6ugWQU@~m8SN`J1y0C-jyOllfxcmL44ZcRBa zsw+1k%5ZB`N9?5PJ-1!e1wTGbJ-pP2O1e39nEymjd`M0rDI_RBX=~^4zg*gG1FXD{ zM?yP+KfX+El%`!9n4Pq_gMw_FAz)@_OfN#~49~blor6`xo2e01sVPLZH(**vV)Y54 zb+92PyjcMZmB2rbqBT|8qQTZs_@3P2@jgWmG1;>YMnRi>tFRgn@nU94dVXBY$Q+>h z9Ky^cD0!KrLM%j-g1LVnlhTRX2CLi$8x7bK;j>%(0+y6MOI!Rv)-4e?s8i%5NoXKt z<#HGJA+HQDFrnip!PQV^J6l9Z;QX6ctM6c&`WMQ3cs)&2q(E9|B%j}>$6v#Fc4z%a zEvZ6O_e+8k!@y3Wmv}-B25i(1AUKIxuJLjH>ITnTAO(eGC(QL(*ng5~msGvL!>SZ0 z7nu1U`a)cCU9sj&Dk_tl0nfy#OfLW^qqGs6oSSxrb23lSveb<^)fn$kj`GCH*)-5a zL)|&7lv&mJ*Jj?v-EbC5~)ebAE?Z2z@{+5g}ND<=fCyL%AI{^7S zP^g}Is2UGVRm3Y{Y8@w0B?3?QLrT+JT#i~mNoP(f&*gFq!8&2ThcwWWYD5g1HhGDJ zwVvLvFV0h?U`Z_kCdv5vr`~|8F9Sb#b3_31k)2;yK^g#z2aZSet1nRWsK)9~4!Hyq zC)J&*c^#kG1*L^|Up!av zUwtR6Wh6;5&~)UavrHhJ54984O(zW9GQGW>bW**t%jld1&e7)OTPQxf8HnmiyW6n% zlyd*=NK5N>K03xsg$OE1wt(Xm=C3B7TK-_v^-!1G>7|OoIw7a*LJ^oK1tR)O(FFT^ z)B*9Gmi$;J{aFuztrwh+c$7$MN2T44Y)|+^r&Me}Y;%N5P^*~@UD*4Sx}M-a6x_cg%rh5QjyZp{Q(+g%9&Ey&VgMucivF??1a}TubKr^T zZJXjMG3rcFsJo6RRu1h!ZgFeOI#=8XM=7;K*!oLpGYsw-f+2*YnQ8OH;C74A!9Wn)aDQfCkGr zvNaLVV7U9qL#QV;7G(k22?$c6i5H9?;|g=7_7Xh88ZZi5N?obD(Tl!_KPB8WaU3<; zN-mg#;xe6a^}pMl$BZMMcG1o~s*RbmgOUCBlcG&VOV?0`qOCWj)w>w)*h6(ToOAOM zGSdb+Xopvica!(OpI&g+vxg>8lX7$l(IE)Xd`z}0RZhuMD6xYLga(_AiEC*1%L>QiS2O%N5CwmIrc z^9ahz?@hn2=`+TV>Vgr_qOMf1uttQo)pS@FWsxE%T?FThhDUK7vKz!7fQ!+9?sEoL z4VL7~Y|6;nf~Jrwo1a((aE-vAfI|zz;d<9w?qy-N9f)I7!HB2Y!u}W zIe27wf3ksm-sOHV*j}AzW{p11&mvii!hgx>9kRY-ZjRKF%0s~rT2j#8k#eu2UG8Y2 zU5kgNj6c~r&)W+&a;u0hxYFzqX3$REDDSfg!y#Z^^&-*O=vn+e#BW%*_{_+TXz zkQwlZE$_E{0ac5pwkg7Q_}$2nv6EV;FF&2OK+;YUO0?%!&nfq?xgV`ufXs0jymT$z z^)yyXZfR)fw`FS(TafUPuB&Hx#f}w-mH})0o6J%|cZGhS5NdO-V0tj?mx{B6j0Gks@=A#mfW_#!NVhn9IFwZksL=;>0%M&1X=w6=jqGnh$)H* zWSSJnN!7c(a;)!AG-afq?QoPVDR|6F_y;nEmFBO;HU}G{!S^}E3Swdee~pKj7o?O| zfJ|4RKQ0ZGexC*dfKJCHkMCgd%P?8=VSuBy(B{mQTeJ!0s3UYu-^`1zynjVJ`XqT;mgryK%upRW%jhfQQQ4#XK z*rgfKF<0+)5vyW<(3XA|N5@POFgNefzHeL0qqaL8*LcXR7*Y%L!o*J!1XxsY|K(T+_+>!1J?Hg(Y*>P6t&E{ zU?<`_80O<)v6lmGhI~P?e~GXrE$F%-)$-|#dmN7MkgQhtVt6xI*0C!aDbgy?k2dsW zID4zm0S=_7zbq=19+QFh&28HG+C9${3j?Y7!*yOGY#XLi3|%Ls{5aCbg!yk}mCm?>5GI4S zE~}TLErYo8Bg`q_WB9g;cnDq@yxtTsFmtUOov?!_K1PPbbWQ zioB0~agBG@B6ZGmU=W(~#7Iy*P?r^|d)K=zZUm4!X;9w^ZICAfkk?&OZtHXR&vc0M z)s&8vAxFeaEQkt-!?RrZPo~^Khd3Fc9GnTii}*WY+b7mBc?^TpohY3bosvpDVoqxX zq*W$u7t5o~X}c%4Z7g9KwEybtz`Hc}oBoc;PD z^_9(Vl729z0*o{YtBL&|4s@0)>I^ApINn7h2P^f}YY7R#ytZG;>u8{L*hW2ttmgt! z586UN@C+oMj4br?&66)7Ai!FcAuT)8iD_h8Adq^aYlT=of#5otJ(zReSur5y=7Bku zQ>bqDlhO<-Qid>f8y#0~`E-s>Sc4#szwX#&>tcbT0AF;>WU7Kz{_)kRriUSo!`f)G z9sU@Wd&rcNcz{5?8T1*R`4#g<=cX7VdBSdxOrVl66%mFEgQb^2a=wS@^Cra+ERLEJ z%j=)8NIFk4eX}IB_&djq52AS0=d!o zMIgg5SB6G$e`ng4O#84DscXrsZ>-j$I#x1kG#yLJ>!M50=*eOE!Qv^ez+cT18E6ra zI};4{pIz6lpysdE<&Bpx#$p$QxZ(flObXciF}Q3MDgW4~JG0u~6hG;}X#kXvQxh94 z<8}kNJa+pMB$v4M0ctXYmhZj_S#dITc3)|_}daNz?N+tSU6s3S;~c=7w-kVBJPJZ{P9VW&2aD?ouMjIo92 z*9xI>gbWmZf(3UqJo85n06m8b&E5c-)2rEzb}rF?@=PkIQj-N89w=RMV=6` z%%$bSFIebzY-oJ64d^}*EX)!|*&V{^n;E$2-j1;^g_*=RTJJD9K&bTe(-SNU6R0*cv?<Vu?ITY_PoOVZ&X%jPr_{-boKToA z`QQ*Qv6yTNN`ljN4>nwD_c5`xtQKdTVpP-^(W9Zz+J(rn?D89Oj5yc0ZNMb606W*)q-snPKl zIufazLg)Ea!YJlK*n}$dw>IJ(6o(>uV;VEj!vQUBS(>QQoRo4rRSc0rI&?wro@S=M z409N;k-;yGfJ&3^kmP=%o(|lh_V5!MAFOQmTjG_!@5zn-q|gxr*A=2cnY>?6G`NZ* z(YJsDvQRpxCuT2CbvUvqHU1SXbadKrQakX1baX8&x)_-Mq42{dL}Nm_pCkzKq9~x{ z>*|is#vZi$(x8n2&sp42oRcLbpa~J$tkHZP0&GczjKh&?A$&E@e|fd;&xa4h;D8Iw zr`NkBSScHal#?<>0fNreAZY|{vAM6jnRfRPcis;WBk-f0V2Grp#eOm)!40pFobqa? zY6&C3xpV25fV7oyCic5iWR53V>2~Fq?z;K}h9dp9T>mx%LK>Od$Q> zi}D?>?-L2Cltt$!&=FaHR#mmdD*R|p2-INesNqzSPo`pkEy6tX_d@WHX%$greX9(e zuIPSLsWpdA762bxg*K8{m(I9^eR~PK$q;$TFxp*0ckm&Qt#9ixMT}hEHuXu(YL$Y` zL0BT*acXE!UuGOSit4?OjFww*22=A}m_hOLUohr#bGc7y`a310!(0;^?WiMkkP%A4 z?Wjg6!uwruih!m>V)G->mvDVL(=f)fi!DzQ+N<5~5Qpi_skejhBLb9Dzg&27MF?%2 zp=hy&lqHfX7O-!L5A)#>RwXk?r(Kk;G9k0o>*vh8y!m~IQpHAAlqR)5QY5DhnI>{7rDH>1{gR{xGE0`i;ZY1%pMJxLv*poe6FY}TJ`c@c5M=5J(?mrtzB;s_i z6>0QHAGYJl381Igri~v^C56`HuD37v&*yD_&sWCyCb|S&p*`#yzev$_b{{HCNfOX< zFk~=iFq6qN+3PF*n@h^juM^|PTV5iNUFWX|X_?xdoHfRtK8&|?#}>q=)Ei5Ck-)27 z9oJ-p4592FXjwus0eknpFvJKnO?iD28A@Ae4T{R^9;R-f8$`CNr=L|(f2GyfO=nFY zyil#Q2Ip1lAWII!ui{|mf&9_{3e1eufjJD0Wu4xy7xBAXbx3bNo0uM+@Dw$@leGa> zXv3P_-7WpzTp~bb4I(0XYf@KI`}2WbZyCr@N%KVX&YToligM@p-F< zS8)Nr8JWzsS4+2{#vYPkI8=b;b-5?lF^G1|5vqg!Q{(Ie>u!Z@Iz9cZwOO26$ev&U zT6*0_s1FX^T|YU-a^aatlpWHb6iJB$nIW;di1A3feAc_3*Fap<(D^`LSo(rd?fyE1WF7e{6aC=E+?>eK!i?VLw`s=Z?^yu4lK>*nwdz-wYCe*`}ZiV zcd*&rkP>#BiIcHaO=hJRpfVTHV>L34a5N#4_!S^2XdE~kfWtPeDVHJ58U?nsLf(wf z2^^^-b*$)>d*qtfKTEe@Jz^+7CteCR@8Gv@K`sk%c@Gfx5KtkHx+w_1M1~Rh7@SVM zy6QJF!=^e6qA}D?We5m6{LIU_rR*#a$ck=Bz6d4K@XmKJM!aIl&8B+W{}hF@ZbMJ%9j2BIyeAv%^1`gJSp&2 zg`w;DwIpfdDR8~mm1BLL-0!wgGBJV1OiE@lN;%zRX;`9qOOb5qg913LMrD-F&K)?* zC(ZbXQh6G2Oj?4Ew!xy`H5f*UTyn*Ni{{P1SUEf*yJbwB_4P!?=kRY7NIJ4Aoo1p) zSGvntXg6skWqY00A){}a$ULMX_@n~R@l<{$#6y}F_Sk0TIIotEVB~$XFueLRdTPjR zs?sN?nl}6*~WBg8ol?SN>LG z-iJ?%!o)k4B8t?>79nd<%Al3VC`o11n^6*`CZ|whW{e3bV+k>-wAYl1)1tKX8Qz>{k^_o?99Zeax= zjJlAjLj3U3QAw^STZ_lUtp>XrF%J5PEL=&JIY=9M(L+(~a~QKW%pa}YV*D>+&S7jx zp~h)JiT#zua%+$dQEQm7hjpplJiGst!#79*c*Jy+u!x=S3-KI!-GAR_TK@T@7Nw1mQt@V4u#g#KsQhew3&82|rb#io*oE zB*RljBvLRGy{kc_djPY;v+}BirbW}um9DUJLifu|hBGXm>XDz>Xjwz>n&AOffw|&k z(rk^}ZrFSAj$H^B6QK`FG1t*}<;Bfcy_hL7*@n2mn*$!+uI_03E(}?b5GpKJ7ZTba z0BAbW_jnFHAjw&x&jT($2e`$#ZHrnGhq|f}_6DSXnsXWVO`sBpVp)h{Y}qkUx#2pd zZ|b^H_Bf`!)K=JrWVw+7K}xVkL$|G)nNVKC!5!iH6%?{n6u$abjvPliY21IoKYW;y z!6vXEJ3sItJkbfAwP#t~un4*4#Mg&H)u!ipKZyy$4DlCk)gd_eOyC_S44cn6W-jHp+6 zb%DEv8SE$<*=a~dczax#c}f(z#tn>d0Zg?BY4!TB&GFNkTi4esLzLzN>Tn&9x|@{u zL9IT2zeM<%QHq;EjM8m63`e!8L1v6wx>z9uft9z~@I3LRr%6h4G%MnDiOJ+a5~GFQ z=J-ZQSm^;S@c)f?WwI!*dBz1rJ^1)_`Ypu~E^uUX=Q$Erk5ZeVNH32#z{M(u5*z3g z>?#J)q+4IzbT$5}00><=SQnd@3q+3P9ylk&s2ZLk$%ziDyO)PAz?V3}W9$+0Ooyp7 z4UQ&70Oy5fM2+}^dT7wJBkvc)M!*K!GI9FJQ2w1b*%hmI*fQD7LF*FoF*~GP#L9?3 zj{hxq1e|&E-n;p7FmX5NRYJEb+%uj#3UUq7WCCf#25)$rPwwLxXl$9(c+^%~tG{_Q zpUdmp+S+#6iJH8XG7&+MZUVxweH{##tk5REtqmNil9if3KXpA5ykt? zsv|`vg`(i=r6zGQTO|ur2c$8HyvAit5&5Y*;h{+sKj-#VVFA*)2C!(adP?Ep?9kHS zz1mv`noX=q0DT=ysuTYd(!B^_Ny?pPG<)BRC$-W@2nZK41oa*tXj{q$2LfIlU#Z`T>#3X!(G1u@5`W47-u!_$hbK{&4Oud`7<@SHrRg+rZ2_ zlS5}HQ%Ro&WgDXp=haanQN{hprM*J7Y^MJ5jJvcC*0^krnav`0<#9_QZHS$Qz~O!D z18lSuT-6mq#ne_Kq8^L<(xSMPXq!;Ea%M;wznI>sD(gf?_S3PamK9|uHGF(=RS!E; zv^SGHtKA+)%0Wv~epixzYKmi0_}N1oVQ;+ny3M6--Wfos1#}sqPM0dLS%zpy;FgjA zB%{|T82wk`og>%utUqWBixk#oAxl2W`pcs!bzmZ*6gg7DDLxr*Y&E$ghGX zk)~v4(}Jy}5Qz5m^3f5oh0AdGo*9iu731Zsk}a@;>7kjcgx;W&%S?^n9Wc8mr&J*1 z@vq6F2DSM^wu}0!#>3+-+$lZ;i=_p717i6F-35YiOO4td>iiK_*h6r&y=>Zw@u;Kv zs4h>0vm8FA-t@@K0vOM9kYfOgr^30|G4^G_n#zD1p|mD{10xPOISeWZ4b)ICu-k}m z1GOcL54ET|qo4}VuDTN5tUzE*M-FOE{2EuOL^|MWnD-WZk+#GNZ3 z4dyL$0ES7b`BGGmTGcQnT>CuTb5RfLhebBi$_4YIFbs=Cvnj3Mfp!xnB&)1+iHQUDDqFvS z4fJx%i?PN9X}AdX@4G`GOjQFQSUpwt41dTWhm!*C(}sHBHo3K5 zN>2||CEtU3Fc7dBl^a?l5CZzo??Bb+gQ5q4(+LR}sc(k6?oFm9ka_5F7)3-Nf*2;ady_DTRn`Fih; zLrx?f%w@}{wccWNZ#EdKNeSv^#hqsOVN_|sDQobL+^$oP@L%%X(*GyF?V5kS)z% zNeerDWw+FEzpZ`$N*ojSaxt2o#XNO&)Sk++3yOOQ&hhq0XqXm-u>|rDq@GO!7Ds@9 z`^*3dnY9PRA9#vzZAJgUxk^!5GU`2$c;m&8Z{Y_*k1>Ws4sD#1r8(asZ_Uz}hu@!k zqnrA2)m+ESx{f79rlr0SUhQ$Q2RN(*r|TiPNlR;bq`benBd*|MmO|g1)!W5x4O3)` z5U&yEufwxjIqU3tD*g7JAM_=JBOfB#pNHpiNxAR##V2H%FlGj3ctt@U@A`s&b!D_} zMp)#!y*gNKHjELBy>4@@Y4LQCWE0AT#keI>4&@$W4a=j)(FnP{E$Z(&+f>>DlxO-Z z4iq0O;&l@wJNXHz#}dDH|1&AC-#w_R!h0n8x$z+8n*h&yhB=`Aj!(J=j1GTz)z_?kkY(6$;6TU=hJ7e8&Lv6~S#sQlc5+I*BkKUi>O`F1*y{ zYXeD@Ab?ju+1G?kGaV+HU;^J#6)*ct#Eqghz1<8(* zh3X&mxsxjn)WTPUwTuj~7Nazb6e5vbxLF`C-xFEs`T9WButG8Xk88wiaoOZ6`nl_s z>_2O^D|2BMpO>`+sipwczvV@@ep)wlY?D(9n+WdC!G;`9L}ZpYSUqPTwH>rQ<~8-i z@ZJj0`Tks0Nt%;GxoJ|3)k-N8)^Gv|#$W`IMojLiUC64SO&#EHFF6@c=x?3%(i<)Q zPXy0_l7~ib`KzhX`#FeOl#C>y>n=fbvAn}Y^S9DQqm36A!G#69@LNzU_^8_<56(n_ zDPNng<8n#}TROZ1s=r;3zW$(6n#kg_VODjlFP)GC=Vtw>e__>W5UYE@>Nk+3qVNC@ z`oS+}XMC|9;EEIo%{a&o@t*Evi`0CVMJ0;9|321jluO`dYZB>uq{{aWbQq zP?oTAUWm)>{^h3?5YbnM%3_<{C2ZtgUiaeBnlBU~{+oragmN3eFkxFPCvF+n<7tG*4l7#S`*%xt18zF^iB5p_O=OY^$U0V7AB*J}=TIxPC-%H(>;r10zisVaMvaP< zD|}sa>|S*OryoP%mBpaHI=UxN_rYHfv-}7Yj;u&R@#i(xaz z^;#j4ivGdt5=H=kKSC@3flo~ip!R=YBDe1@z|Ur>x)1v%1N%@$Y9em(71Apxps+^Q zBwgWgE-iA2xsonRSaK{B0i&FfmmcX$PO*E4;p!j~3an+(;#>6h1ayqlvTozo#zG?n z8#zHX%fW<9S;{cH%~F&RSmaS=Ld`#`mhVs+%~O%5TF*)JVY^(i=3_L97_JLI2LuFU zTM6p-&mv{Z2V-BIZ8_R~J=vMK@GXy3 z*{b`pNejsQPYJkkiI?0NG{>8dLA{GI=bLi9V8F3WJ2ctb#B@C%d4O_JzIzZzFX)O7 zamYgh)i93Dz{JF)05Uf!G*LP0BT#weUh|GA^LT4I0l}ZLP9A_CMKvTaIg1|i>Hh1h zpL7RYkj6f8e_u6f;^ml>oFBFetOIX>GNPUowmHpc>tA>oBk){EnkdY!v(!a_WGz2J zy760_+hp)CSiP_hW<|0A{r#+nhS7R*qR``8zX*O)Ymjbx@q@v2B)%5MEOhBh@WeuQ z!FnT|G>v5aH@AeoU%lOS*@{1(LZWm+1yya}-^CxV)ur#X_xCYq%@L zb-e2n*2v9}34P;sIw^k|EHA2>u4L6|5V6lV{!TAFg>^Qyv+RMmN>m>%ki`lMF`9LY&h%53U zlMrD-38>q5klt;Am7N&Un^P40A;BN*tyEd$IZ12xGe5jv&c_nXr@B5797if^00^*G z!*@}_8z3(=0>nm(7kkRzxmE2N0*)H0DpXi+kfLx{`DiTW;XMi~+*xQDgW2dVTdD|> zrNBIG)ZsOm^-M>!J`^>1AiPd@*EO9v3!pv@VSy9U^_V1?(0u-MsN-x&8EmVEZ6#nD z4ogs+iGXD-^U`v1#&0-k#Yt?Ex7o1v!!?G5Uj0#Z#bf@#x5qmGth0vKv2FO+b9($` zL`0;=<2c#5C31TllICJ&nq&{(F`#F+N7Iq}h)&V6;YWxBlY@RT+8LhN;FYs4zOrG< zXna`y$~jwjbZs1x^4 zp3MFNCxj!sgQujp4S&J|cz;$`Iw!@OjhKHk$apAAgkxyM!VX<^R}>f>xO{7XYA6gs zlGIf9W3)>R%7ww`1U^8NZ2{;jZ%mhZ9gB1+Hi!HNZ?=`3 literal 45603 zcmd?S`CrXn7e4%MP$3Cv)?f@NpG1Q+Poh*LC1ofgq?AH+BAJ>*M5IY%Dk4J~g(4ah znM;EPL$gkGI_J4o?(g^c51wD1{p$WSIOn|g-fOznwbtJE30oVB3FBnPF$^xrenvzH-}(Y;hZyE=$8uAnHG!XgS~fnSAI)u=vct(3yet1Ikv} zJLjBvJ@ea|*%!X<+_BH=;SS!7M5|r1&y>6j`aQEk`<&{D)S0InXMG!(&{*J|EXU?< z4B2sDVVvsvnX*?d*QuZ0d4kE6OKJ@yq^Q@wi4&8Amh$2gn@59Abd%Ml^JddyEmb8Ws(<#l^*V_0Ym5Hh zi5ERN%g4^`3;KS^d$_m7_p!_EwKwV8>@zPV#)TWJ#h3fYmlnRPoSJyX=2{IN8i#M! z*UXz5WuA6-o$B9R`U5{T{%yrFMoM~>e5uc2C6UuJmp97eN4!E!V%!+~n(vzRaGk2; znV0UHrH|UFNR0a-f5LS7{?%L7<7buUiZP{4yB~g@W>DJNJNN}Zbq);IwEHD^I6bgc zkyv-mGar@v(GF~x4({`jz8agcg?)BJ2YjZ zsy#Im@DIfs7q;~u$hS(jf7#-zV`dlUq82~@tW`R;&sM^0d4+PNQS?)-;LgYRJ6=?F z=Ng`h&3e|<9jI|(rFmMl=gi3`*drl@rA^(J5~qj?grAFXRNjl_@G zO3l9bW@}jS#;TwF2fprpILwxR`1!Ya3x85&Ywz#~*Hcs08XwoZGPGD258Hi2K&jEL z;m{`OqxKyt{;TXH(y?xQctj+vRYp|E#KFPg>`Qlz?K#d1N)|@;$e&DA!F*>dKOJW| z^Yp{S&cc87UJMPWT+%Bb__eBhnM#b z23=0_5B#hzLXr^I6HuCb!W3V|HJZ0E2iYid6a0k59msd}UUKmbwlHJu&3B?D3o$(} z?CFji=P3K3tf7yCLHceG{@~t|7k{5D7q7o6XuC6Vu+X9@R6Dv!Y9tbSG&bB=8Jgkq z*d?Xw3WoU;LbT#6efHp~?D94w%f*r@2>~?)~*SK(N?aiF* z?CiJLKz}`3q0m$nt9fUw?A{bynjZ9P!I2}jibfEyU|6w|26q_aB8D&q6n`od6rbbp z>wf4IhDone+P_*@ek8xqEhS4haGN|+d+S(gFckU*h6dIx(Y_uw2I+c1>X1Upq-A(} zu!BTYYEYk9qXj$5o;yG1Q0umF?9Sflb1-Z7RO}PBc7s~HR{+K~jDr{~7K;ZNs9;-d z&C|5y_+q9rE{WBJCp)EsScl|(GY#6&HJa9GLiw*SK7IP+{N(HItc;8dL%V`T`PvlL z>Qs)mm3~s?J=OB7HSL~S342^`aAdBh3A__)~N}`EUtmRBk z+4i3YqI)3UCT%rLuIh?-;X`3&cbq`cDj)1cENsLPYSsVu@81UxA3oGtvSbPAGi+?3 zXi0Ky0qna_?X+5E)|UADudm5x3uL`ooBW?qVST58zP`Te1`JmWH#fKX&!0b!7v$&D zG!}^!WL&J>)v#0vGdqYT9m=AcXtNEo!?$CaW|pUCst@VFnP3W&;F6%Hkyk^WwnA*C zSYpY5)T7M2eXnh>pCs&?H*b3F+`02sK|#UgP0~W8J{;?}UYzzr7Xyp({>UE3XTajj z>?9^@+_;Vb*(D?;C1Fl(2FsQ$vxez%mHC;XhXP}!s~BI~o{)L#)~%g-*K%`nKW)Cd zuJ1s;La-LRO`k*43n*|BObkK}m*<0;>R^2QhVkXg7d`wu_E^cL=%z=F%jZ6tQIxZf z!Ajok$jMDjO??U@@YhZJMR*47gyFU!U@!tDfgH$IOD!?2t= z`TMph4P8vA_n%WUN-7tMuEwZ=)IWX2c>n(WRyarO)is!(ir1j^r9YTgC7d|aAO5Pi z3F9`#*}V@iI3a~e204QDTN<2~GFVY$#}NEoTzQLc*?RNrT&eAiCN0fbmhRnadd-ra!hK5FVUS6KwVhrqa{*xz9E@EXHIfuMD840<7Zk^}--VN_647()+ z;VR#3y_ZsA3XwvyNY}6-5<5;ZU2mWwuc9#~>a!cdPGJ1_@nb8LaDh#y+uDhf;Y#6f z@TN76j*fWW!-qLJIl+HFZcBPoQBfhpe~5AC55*IcZPv9p7hbw*Bsy%hq8Qs?t8NBo z! z>5)nzhm%!KrfyF-_tHHZ60}$VU~rLWNf7({F-0z?)qppce{kTBld9x+ zBk^ZuQGrs6J3^*;=l+oEA6f&MAndJ%{F5$UzI@nZqKlVs5%ePgZtW&?-8128F<;>D zPG(*bg5`g(0|W=yH5>)`Bls^wT2&V}CU7x4S}&d$!ZzTR^4N+~FyO>thtCghaNE{U zc)d4dl4Y(7OcuiW2!-DJ*rlS~PnSgXM>2FnodVO)bC0h~Mrrx!TjZOf&V?JNoPxoS zpG7QMt!gxm>6$kAgdZ%SWTFA5OOe;D$n){^^gMobdoDb?Yr5XaO+5Yg@zBBcy^Y<0 z@==Yh_Y34b+MwEzS-YKo=_N17z^5v~ZzN*yT6vr#-VCgS$Em~@y!e=U4@RD8cq;G) zA>awjn4&v^?3TOhUc*L`_lYpP%ey2XK{7ht$Ur?Wi`QSstt&1rHcGhK9-(ns<@1Yd zRXMmHMD)kdryP7rE>x>^-;=L!e$tTfb7%Q7zACI9pF=)n2aWC9w_k@YyvNh<=KW`v zh!}~R?%lh$w=(4Ky(Gl$ksn%xgKyf~+vf_+r~zbS+#Vfi(zbyVV0Q)i`T1XAQ^ER4 z>b_z^>je)5a1I`hd<+|_Ck0r=8mdVWn>cYI@XRbXR@#S0FPgg9FvPzc9XP?878oIg z^+}M9chAtw<)wNq|gIvM^(KOn%4A>G&|AFe6~4NGOrt#^f-eOv%vvu+f z#8(;yo@yD>oZ79QRrfQP%Ngu7;C!FRVviaVIbaeSJf4Y-R&gd%dBf!JRvl`7O4@as4L1(-p(Z+fLuS(QD{gDVpv|?H&cIz}JQIdD6l1!QGM6qteFbZn zGx@}`j~_o?z~d!5XC1pKlsEoO_VdNOyLa#Y2mH%Q?D_E$qj2OizOq;&Kiv$1hx)G9 zcMiB+)n@@)&ph+;3f`Rib+=Zj&n2i<#=9MhcL=I-qv1?tvE?wHspG<*LSwcg{N?SA zm1RN&y2Q5fAv#;a5}nR@AP&?(uiv)&$;va#L5o+L+MKkcMUQp{cOI-C_U8RA9C=t> zU2R7`bM5pNi?`P>SR9haPdDCB9bqg_!A_0BMQz*gIhQcIgZZxN<=MA@Y~)0RO3YeB znm-VL1=!vweRP_w?dNyrY8 zD~2Mb|Dw%Ur9aqw#*Nz(U+CiO?2J#Ci>#*L)ql<*PHO+K+X~16&||8#u*5dLs%_*3 zZob>`rnPrJEX1@%zd?}cyd_|JtE%7H;fb2nlkTa-nXAV#FC!!YXYgL^pnRa7!bCWL zeI;YEfgXP>tskri;EsB;y?gfTv4taZbLf?V8v&Z)y`eo~4PC?6*|TS-*8XyQ)oBTz ze+>Q=`O6Q8jRL6ebF*!ep~u)X1e-;rK3|}HW8(xu1D^6VSPbn~kP5atxy=ho(<@aJ z{~`En)1GSkG+$13cR8P+kvJSfZed|zCoBfuT40%Uk@dU3Ktu(5TPqTiMt*HGkb`A8 zsz{vNTzcM{13%R{x{)E>5pJBumhyOYLL9WIy-}wKp>`0k{4!7P0!y_f{m%fVYPd z9Nrp|^|{!xMsrt=^KAs&tqY|^EptQ_HZV^%EoK3g{iZyoEiFc0R!&aNq^BV3BSBT6EvaAQrS2Hfr# z%FMVU!vj-JnDS=_73BmlC+iv{)t^3YTQLo?#76%_euQtNtH_rwLnSeh{@G%ouux>2 z*8u?WwHD}P{q42$PtImXzc4eN-XcCB#aX#SJ`)b{KR^el-J&bw8AfaFcv~Q-5sJgd z%}1&NlG&Zvs^tjnmqQ5Dj6f?nEj>Lw8n92>7m7_D(sq3X5E^gPy{VRG8;rECVs-4% zmPY_sSAE|ET2c;2>Fw>^2`^ltctB5~^7ZT2qI#2PH3e#KEhdG)gAOC(h1ecr!Ekkn z^g#IOL1YbZGiAmENKN@O5{_f=I@8yu%JC>d!l=#ygF?3mz`?d+AujA$hFinM+Dc5<&%?tbT&ENv9)`Y!vI=@T z@H_w6F;%KQk3IN-G=bS@SvT;3GDkPDpU&Q6$ka`fFYQjiNWmb4if25@_z@$)EnqiB zsOxjFrdAB-QL4+di|}Ig2pogtge{;iBgGR#y26egJt}m1=F5n~X_V!~VzgjjGDmhL z7v=0_)DGOSyP}9`XDku%7aqW*VWZQ@f@!~ zEYI5?XXh>l!Uy<*>{88F-XD->?s&NlBf0J&5WwYfLqb}Ex*s#K5qaY8EI+hb^lAM#t1Vi2ZfW_-%YCz zKuHj~uOO;CaZ#%SCa-vbL6{hL>k$6j8tBr0%){v%MSj00U;jMESpRVM*M#%u&$o4U zc4i?TivvP-T)uzY1wMdg#2_p{7H-1gjk$O3tUE5(#$^6XSsPu~o4a8}!HskTO@9m> zc`@M~=jL;t;CCGZ0|UUkA$%drBDbpS_l9R}aR83#z?LmrnnuBu^wCX7semgku-6M# zFYm!mbfE4b^1$5~WYfjHLUD-#wl3=bCu2lfPW%c1uj?aGQBe~R#6*FyktIB7J4AHe zO#k)sF2Rwu*IZk*=)y{&{47Xb7Xm0$nOrQmW8PDEc@R68daBCF)3C4t1!kYX+I;>G zOY7LjHc4N_u7ZbHKu|hM<1(a2Y|e1=%QJH*Iw#6ogsfTv5Ny3_)haDbP0jll_`ET^ zy~){!uuY5Mn>(Pl@JvQBjCd_y!-@{qTGe=1FI?|I()PU-@aPI1Tl?XIH;08cX@|t_ z^uE3%QaO&7601^M5550+H*ep{C_7L~0Y z=cJ!)zWV_XKFNT^w(I7KHPX&9e$|fLoi*b#E1c;AO~vJ zY4N+)jPQ*m$62LofZlTpm;f&LIOGCuL}yW(ew<0)u_2SHT8qc=a{)z6L!u#^bLAp) zb1!43Wx(>nANg+p+x2A!b}Q)E#Z|ChqLMN(xeaDt3-P>1d+$u%spIZ+?kl{QlZ%VX zFW579-nrj=lrRq@1>OKPn24AdOlB4X-+_pv521giH^5D}AD)#QK325U{F>Q`@Jf0hQZ``k$R$#SZF_yq4d3=LhQeY0 z=9pbC?k2DuqF|Alqj$9h-TkjGA;d+2tW8Jovzd_=HM)0FiAc93P zwBgNQP-EMfO|mGEtoZFpB{JULH!81 z1YVCDx12TjZJ=NvAC7m)egB2PwSYhe4<0yhfU+IVkLomw7_}K`PwS=jzTO*gig~-o0Znu^ zH#ZZ>fw#B-$Glpu!M4tt+oAi75W0*%LN;e!Yv|vC4%k_1vn; zi8t(sHJbn>xR37_-fZ3bSZ^YXsu9?h0uRj`LUFE0e-LrW37-$=^44)#38p&2ytj8BDEf%An&n^lEXp%obJC~iF!8en zBIFrL2>pM)zx^+>2Ns+c=VM&o3o9%`)d{Kry5{Tpb?er#3wZ-PZatE{!dt`Y$YR?# zd1aeSYQ}C%Wr{Tz=ndgieB4oqo+|sx5vkmtde>fc+E}bOacXz5|A-#=NJ4yM1D>*> zV979K&yKbFDp3~j0E)Ri;p#LHJgA;k0$QPvCim`OxU`e$Bv~+{$O$Qu_`{Wet%!hf zNz7k(t|y_TcTjKMcX=dpt3W|73*oT4v0h1y({;0kSZ9Wv^%OV{k|vBCge?){O36~h z^gT1J$byMc0!R4$SW-~W%QLykY{=>U**Ze?X66VgxIf-_qHKwgV&|fkNy;Dh zt-$g~dYq6UBu4^=*=DzFnOHt6#>y_HFN`+J%ZLkL%7-ZKxnd#lI6q9JJNTjCOtvT&g~y9+c_+<2yV zB4jcUp62B#ByEtE%{V6Ru{^V>m!lgy4YqFn)G^*isQcnvMdVl+^il92 zgnIt_b@S@B+8Kt(t$gIQ5P%1-Q96$R6LO(`BIwePv40$<@7(i;{}f+f>7q%}Sz5|4 zNw8(NwEKsz=D45pZ~|Ra4+x_P{jU}EghxYaF|WCCh98@J8s#Q45>Fte2m3K@=N7$U zS;Off8dZr$hiPFa!e?O2s$BZULp2P%DIdTyuI|0UxPl z04cosjG-THH&LmrsRz$j2!@V58vS6GvqN+kYbVYqZvj!MxrGdBBq&4}ICiVd1gQFy z#(zx-q-rR2 zY#!UzI3fk>QiW{euTP{+B&0zw4DT5HcCa)5*n!P>wHKJfp596WUJwjPF1YU7U+3Ow zBG}tSU|1WIBJ>YZa$D2h=ReIvsEDs9^I&V|=i1L%hvC5n27?NKF9)xRF$SAW%P+yP z?4$j{ZiCZBR&W>y_qc0lqkt`3;3p)80*4IAvA`qct<&xQEMN9~yUSsv<3u))T9Kvz zyUJc!IcH6sLA~~FRIc7sR#vKGz?Un6juxz+lv^QY2;h#=SCQFLrK%i@+^JAODqVb- z6{AQQAXrZdN}VACPwMBQmH-ldvSRMerifRIkquEj%v0F7d(qKX`dQ`C)LG(~$8~@i zxTf2$-4Rxnt$IFa0dtp$H6p`-MivT7<0&XpMV++%EI-kV!2?Bykl*H}|wAj+Xsk%3WvJz{6z7fgxg zTtkpyJ%wIv2a;n~LJE7q7u;-H{SpqP@9Vs?dq1Pjk%g&9E!&o%N$BNy2Nod4?CW3u zingBIomGUC+!5g6NVw#O)?q!1_>dqp!PS!n;58|r1mGr5oD({$J5H@a41r^ipV^ZT%QU7$PLVvl#0zDLNTDi)@n z8aQMKn}h1VK)fecm+gLvkC6|pGY#X|QAzAAq8z?W&Q*&RwFk663TzMIODS4;?-fUd zq?Y0YjES<)dBWhb;=&SAfP%Czg!vDJVoPXe01mgoI1Em+&Uh8^4q{U_f#Wm52*!Z* z+s}JfT(@`xokECX^`q;AgCAWma=%XN+ha`Aa=zM9`Fy+3{e^0PrY8=jcw{s=4$8eA&dxF zK4hu~5WGB5=Bt720;RXkdzO;f^C|VjQ6dA0Qk} z9#IX3x9P>jP0}xr-VC8m3N&W|cVMfV7w5mpJ<`^siXMb)@-^!Jvi7r{ytaEpi~&{; z+`kQ67-Wv6n65~+8vHnz754rfwD&r2R$BiZfcBGp;st+#PXxjfEjPnJ`dFWckc&co z-+2(B9S2ofUbDKtD*`4E)<1(0qi7I9Ip~`#zWSMV(gk}XHo5sf*MhOyNtjLTye1Ar zdUTUC{@abc1WAGXQbt*0N&VlQDcckNpjtIZEIcuI#Pvob>_YMN7kD>ys;Y222sNS6 z>x;o0Myx2RYS4VeMygj+cC-Wk1`R%~IQ86H?c`7Hke#ZUni>LW1)|5F)QX1k1L{E* zRPhD+bpTrjew0kR>^kGOK;*DeRgdHvF^GID;=Gm*Q&qu-g>8q0O(PeH3@-yBKaDLa zIHPnVmXxFfgTRCZI8Wnfg&TJD?gB;Gs$|dPrw}Gw@=`RA!69)0;frVFwI*wbuYK_| zdOTnQQZwS04TfmR=uwzBZ~pxGcD8n^CxQ_?e0eAzAm+8iQ$^&Ah5_8E$8QEs#R0@@ z01zo9Fobq`9u|$Ab>=7hG{w>J7?caE?8qHgathOh*F4droeAdz$c(JkI5-3uj9|hm zLmNX_I+Z^=PN;-<2VK~N7hnsGhxln_DkU@Gx-ABxJ)GVnYuvVV>sAH5z^;u5>EDn^ zeG6~Vhglt&%|xQT5A1_0T%>w#5`1}E2;800j!4zkZ_@!ck2kszv>W3}NK^BaP-4>rTo9h#l5FH4&-P=}q zc>yAqFrH#Ci{+G9rScYxk{SO@JUn;rsh!tPE#t%7Oo4{f#~gttLwLxm;4_pF1bGh~ zYky2B7$w*!@|;1Fn|`IZ-AZ)-!5oM@`;LDZ&?DYIYH%ad!B+z~hD;;c2Vny%U zXxKL~RKS+MX%)zYtxtzxq_P;GklF2J_VUl3A9Dqxk_B498aXWOUfgCi>Fr`rI8o4v zn8SafB@%T%{^OGrf>q&nneyzNh<2!*8p8kWl`q|}W~q5}6gU}5;3A2cB_9d$*lJ|_ zf4wmwQ7Fj5o8*iZ?E!ilBS_!@)+5%i8iIX@kZ@15C}+5olby3p?^=sLWBU+&P7~pX zig&}nYmr@j)mfC-84Oj&g!IMm*fG4eeEZ#6pWT7=RoNB5< zF}EW$2w)J?K#3N|dXKSm9=16E!8shG6z)WyT7X`KMJt{~dzv4X2PSjF2RILEws~&8 z_PD^gnad~Z@WDa;2Kd7y9rD;BP3h6(lIF~wJ^MvzX(?qcvE$ay?NsM2Q+9ZSE~9r; zji?!2I-b6vwy$4Jw-L&E4)X%e+lT}tYu*^@5=D?pD+PqKJ@!@IF#= z2H+5Hm_>Dr#~BwS&x&AH9ptb6l{S$mRaTv|Sh$K7z3@K%(b>+h7qfcwCdZa@{Q#Vq@ES((Paz9=+yC3hR949#ZOAERiJ#O=FgU>3_WTg_L- zhN6)f@_G&Ei|TJHptvS=28@{D*oj-#43pEOLd-*`UdXO>A_C|_l|%$1H$~Bc`ow|+ z0^W%+x>ruA=^{-*ixHOBTX%0ML-5YZ@YirZ2#{Y9=OV?{_T9vGP{pMFNkHc} za4@IfAC-i!tr25b=j4{HBHuUASzpK-&43VG_huaF&NSnF7*ivjQBsfnte&3isMF-> z7>ZDb(h?OQ#^xAJM2-$O#vQHRQr(v~`m4h>5#K<#iU)*wNB)op>qHag_~|Ubr^!|o zRk9snZAx$JagtR6{eA4c_>`L-hr~!8psjqWm@44y5&2mmxR#0;R*g;ZatcCN# zQdxt~GtUa|8+%v@Do&lv)RiH#8m(fe3H}(UXYodWs2ZL8C2)>;H$e-krf0AMI@C%(7JY6|5LR)sjm?5l}-6I%lu*7 z(FuzMyQ7|=zT5*R#~lQbYz3W4)!jT-?D*#WpRm>7t#v56EETXlXL>xCFGI>0O6#F< zK@~8XdWw-NW7P?%DmNg95O}V>3gC@XWv|VmJktG~tAa;*%GC=A=M$Z6_Ho=oF7NK# zDsT+Q!k+FC+mZUjQWez-%JL|o2Gp(gD{|Pn=2Y0BzL9^8D~jCjirf^$>=Q{I^Fp@5 zlTsTLw>=48A;X{Rxqpdu9@+4SM;5Ca34u_#meP5uK7t*gq{QliLdd%mU+^_ueJE{F zmnAjire};4sy!v{8wnud%4EPPaNoK}4T^l^rKazCsnv|B8jA~3CpwTN>OyHM{VX&| zECtHl0M(yw+U)kF`2j4npV$TZWa@#7&;Rn{s&*TK8hWxPlZr#k&lierK~igZIf12E z9dHuK(<}}20)asrZEP5NNzDqNyv{jZk4){XCbc48P|qzd@{Mv{M+THr@ z`f50$j}I7z@*;vW1ji%dGvjoJET)X31esC;J$T1sZ$E8h7hags0CEa}l#*tw2>opC zQn!W7=jX#Ds!Be4bwQ}9@v?$KEF8+u*nwVC3t$E3j-;Z(#)p$5_tP}8n}e@)kN!?oi88$q)jA%)HVc53~J)Ttq-%=8gbDf8~p&kR9l`B^!JKQnep4lsRA^YP_!uF0xBFHlk=VEu? zes|L*l^eupLffd%9vY1zo@&3~n0g)KNnd}?mbJR@26lKiALHX*ip3C9WYW;FzRrst zm#YSz!SkX&u#h{|Kl3B%}`zr;MGh8t;G*Qm6F)Wm-XN~Fw<@S6sBM3=W03QlAR|E0l=HoC!{TiX=vJZZ zGoNym?-;ZhA+MY>cI}b=>#(C2!8Z_a%>!~@iZu4sV(T)4;~)SzU0q#!z&zLli&@w7 znc1HgU;)8A#IbwqRherFSitl8dbEH%OEeA79LSkbH z>9*^@s8~3+cZ(>RqVsSA0MJ!ZyAltF=?id)qTY>Oigf zbzXvyCAs^SD(@?vLCOZZaxDlN^hFTpn7+lX!pntDYr*%UF9wExml~$bwiv$}6R+D} zxoS0-r6LYS808RXfSp@-NnkOrT&~s3DFcYYWc~c=;|*rp_x07=nz;Fly)ND7T~%LL z7TC)Yd#gqbEf(WgbVSGavO3dcqsz3dpq5aiAyI&i`NQ1d&cb^5flQz<<9Ej++!i6e z6MJ(N0k#zZl5{h3$L_&-Vw>W|TB22A<{K|)6(?-n@vDX@u2aV5BM3^lQK8`_pk4?B zTy!}8<^>L3dz}z2&?el4J$}2gJ;5C31nk(dY?jpG{`^-?U1&H#CcA8Sv`2H)K?BjP z?(=T=hW^+%Pi0|VIW;PdPBn~@OR_fv*xn_*Me(yl!#24su7O@pTSg2Tw!0Pp#T@B^ zYrL~EZ&~VoFGM4_(0hOtA?`t|)7;{G&23sqmFUHzFpUCu2U}QRkN<_s5UbArDrq3g zW-mXzh7|nWPqX^dj`_zBe!qZ)l0P++146xawRWiJypm$}t^? ztP#y0fEZnHu<9y;3c?OxlrI)ru>1@kC)!)INh7@yJ<4q4ABA9 z7lMG44%7F{xdZQw669mV(d6FmsYxeN=@=yicXZp0g%&`)h#Az(jJPy$51ZM;Sh*Z* zvEUMOL3LJ|Dup)iPsZa$x@H-n>tmGpT@Y?$%?c_`{kdDUYMIf}3CO-E&6|5NwW_Vf ze}S-s_paC2J&FS43Jwk-ogh9Uq|`u~+oMwXtb&dd0PCBj>U)|r|2z_W%4OlFL+I7f zP-+~p{#UKVpfmEH6kgH z=QI#e0g7hQ_W!Cht?Q2**b9z>nk{Lc^{5O98(QMlq!`H>{4(E99S8pO{VNt*?a1ETkhQZxWlL>B!RI3H@KQhDNR*2Pv{ z?$)Bs6Zjrw1KIJLalvb<{AQsh{Dh8OA$Rb%6lqR%NJ-b zl!vm)+!{kCK9OC~#>1dllq!)`Q$+xM?~*&zml^a}6k5D!q!J*9a3!^bU|TviIEkX` zF<##Swowb@0AvlLjAXH1eVo*w&K`?|jan~Ix_=pA&%+_Q2F7L6f{9V*`-$@a?5#kd zHRE5m^?skB4Q-Y_#M;m!6woy#E)$XErI)+iDHI5D6^=QevI`Y*#7vP-6xgqS+_|i1 zp=boE08rZC+P;TKwiSSL7WPgUt=LHqrkR{Wy9njr$c)%%&KfuhVeN=1QVKJbk29)n69%eRncMa*}o|hpn4wJH(aYgwG_@B^F88sFbHwK z4g5&2A)O7N{}2Q4KZMxqT;I5%0XQ)mk2)m#>^<6yP~vU4+TKuTEg6bJIyMD9#s-iU z0pA=kv+C(N-^Vs>!S3+IFe_@BCHfdr$^^f6>QweyEINL&`xcM9%~L_M{qL*-aasYq zAKu&_-oU;Gkis0QH=WaL^tw)wngL8-u+Ts?16y3R?T}q6DQ`Wlz<2|gjGg7`3w_Hj_ISs{O z!c=-FV}~t#{XEAX*`OCSGbku1P$lX*okXl*THcEu$A&Mx26>^Oj9O!XvgPIVMnx&O zz;1Q8b^yxVZf6Gr*Ij;W6Cs;Zebscye@rL8?~2*miIP)aGb_N z$p$DxAMN0Qv{;%0%>_nSq1ZiNYQLLi#{fN;x*`C^$^y0)@v&n*PJ^XUrbSV8v>gR| zicCDWF(lg8&-K(4I;u}Zg^uuC`KKFjYeSeqsCVZ1IWP?9TMc-^h13@MzupO49dKKv zM=IO=wVMkac*8XScTlSBp&bUT9emP!M5(9nLm;slpFUI99HH#@P>NLGhPq;Q+O{|} zdt;~mJ75F9=w9(%>)_KwVTRFQ+_tb|?eMBkvl)*`Eu?WXvnlPdgPE!t%{!^Qf463};_}UQT<9bZR8P5R05SOYlZZV4vxZ`)Y z7JkIcFDOG5@dXM98^X4zv<1vPLOkT|`hwsmIB6pAhCSY?Q+*#lHCiHhCP)jqnZ+YQ zh*GOldFsCo@hW2%}5o4&<@E^2r=7jmRo18EC!-%LYXM`dZZ`6{I z4p8=*50^7vc+68@314Fw1@smYH9{Telqmy+ZKbH!!*w-Zt>nuOc|Qfa1HwTCvtlPH zUWN%P3D`D^jGinMBCD;_3l36x(Scd83riR&<3aW z;aypZ%TK!_9xtBvn<{!pL`VFc1UNS;k4nm-gJVo|Qy{_wo%<`45Uj*QKRo#&xXz{1 z;%yde{J(`5*t$b^k=QnZ| zqjSiF(7>%#t~%pp(NxK!3b#L|3zy^$#|gCb3+~woA)+8+y&@kIDq0kN^z6*#P~Plp z2zv`yO3o+?Eh35{ZsA;8OCLqi;ee3=V%A>nrDXOk9$fWBOLI8C;AcIs1MM=kGzXv- za|Iy##+QfO;EUVV!!=efp?+#QA_1fs>I#qi?WHKgx<4QB(k`Ec_CV%(;X9Z|Z)wpH zUDd&8;ZMVV5V5GH3+b}BRG&nKu@sgplIEHf!?6pdYgmZxBuZ=>0ygzXOrAUPRj{N? z(G3(aLgH26?P7ckObf+rYV>N=jt*Oi$9Jv~N-i?NZv4XOVZ2%T#4tRNW(KNgJ`xL_ zEivF;F@hb)!zEHeFxr|%9RLz!d6LmHf0T(zDA!+UORAr! z1fTK$7IMgOWA^8v>9+IunTEtYjIH2t?kyl={EH6(fAV8>$TcaofNS;StjMr4SaZ|7 z5MaEU1WI9*7y;s;BOs+2?i}SFR?c^eS(Bh54`46U9XO1Ny1pkE6bHnNlmIOke*;r&@zOAePA;c|}`AQZk)vO{z#c(;IJALFD|uo$ZK zqs&34JaC`{os!L0v{&%262sZO8^YA|SREJ#GGqYl70Gdh0C+g9?eSRiP)eYKiDDPt$+~Y~&u`D0iweT+RQ9m$Ucj{*u zMa4wY6NF|T{1y@LL4fy|y8!+JX6yl!PB$PdqJ$I~s`8rmrfsuL(27PxCp`l*5J6rw zQ)+GaeeMj$#&?Igmd^X%)rl{=@oH9Er#0hi(s z(&0l<%sKAiB>BREk9l-XCUmooo9OV40KzoFi9yX zDdNb;1x}7)PmP~*N3JR=P1h0%Ou9nN) z`YyvyyoXiAUgnW+!klKyb~jzSAG<@YtAFl3ARVxkp9l$%B~WD&QFY3zQz-|dUv#1l zi!AIDBz|(m z^ho5QuDF(eH=GX~fw&8Bo(YP%;1byujP)z18j}_4cc*H6b&zgoOwj#zbIGWfk?o;> z?Fl#tP-t;I&OJXz;B)ErEWGJ6Rj^@?KcUt{_EAljGsZN1pB37P;KeGS?{uUVbEWJu z^~4m~KBY&viDv+<5_8>ZTk7F5qwKj9u{dA<7|Ycn$XlWU1>o=zZq^N?%LQ^M(DvyVJZPKec;D#;UP31WZZ@2-f8n|=9Py1}e@k=E1)EpYz1-J)& zf;EJ|4A8$N3qzBzk02z4+i(hCH489JI!y=RMFS+@Qzf$zhT_f&go4c+qRY9kR$|~M zl9AwC36bh_whRF?uKauWTQ&kmJJ2n8Q^K?$x`dH%VmW@0LGTo_?Ue5$yu%?)hw33I zR4-R>M#@&a%x2s4Vu!k2#bBTpbGMHPe)#-ZF4xcmI z@DaPP?}W12sdfQOX86pOGSOa z#aD5)eAI;pK@i?2utJtW0Fwe1c5J@QwQ~{G7T1G8EMXt1;|&u6VtBG$c%Fue=AmYC8HE7vW*E4)C)u{+!!zusWhqa~1?LfBLbqUWiOlPXn7{>6FDAUnLfc?V%Ig|TZ5^!-#VEkrQcig zR%pcnl%R>}P2QIHT1Rw@T}9MJhr=?W7RfM?am~2mW9&2$$juG{R>U;Nbq+iOh5( zh+0p;5VzAoD^LNjbzu&N;oyq(CK7aFdm;W_a5Xubf z%pdsXMJ_|5@I15GpsliuU5OnTt;`_yB%o7EYL#ABQY%7B3~Zh9H3$(+q0;vY&ZFZc zPNVqwMTu(Tbn-%7I&iP~2;!yjIF{8*FrZlv6l?9-DE z;!$r}s9iKx=V<1TXi7 z13$CJr?%%z3y>0+RJCUcn zfzP3qWeTQKuW|2s-_}|T8c*EN_{mGT)>A2ghyLyD0Lh{#P5>Fu0e&Px zX^s8wx&#I2;6iBr?c=8~*M6iE)FDr#5M6AkVR_zgvjKQdOmr`LtTd@igp88T!gH+c zd085dfTqdOp~$liX61nrBbNC5U2I%LZ$~PLk2!8+~}t7L)nFYzQs{n1~{v z9fw0XGBYV$&H4y%2>{?L>S3oG6>AGwU&5QWh3@PkE)Z{A4|JJA?&mtQGTFysW*JrU ziB75B3(7|qpMFGkJunt-B}u5`(8;SpilPLUslG*F7iOK3z+L#Q%nk05dJwP(>wKiE z;mBYmDhFBX=OnXpU@v<&ZQ4Y4%0V8vIMqsox#x4FOCrQYg4dw#Lee?dAI$(iE-1+A zIx;HZt{fq{?1%1ZBb-ljfF5o@q${woy(Zyw0xG%;c+SwDN1Z5O4@;sr5m zvlaS`yvqhAFNdm)t&FKk_l*1MhDbCBTaD_zbQw~Q5V zJgl8=bo>NF9llnWfEH3kx=#}oo+7#H9O^g$79m9@s+2AiJdYIpbjj4|XD`9?VO@BD zJn|jFg;Y)Zi<~(=<4?@ljrn`1+*ob+Lmn_tD*Ics{KUAsbn%GDv64-gIQp-?!Fu6Z zg>wu?7wo!zfUe+!lDwehQX)i>4QW<)--3SYUxY!ZHcJMJn{g&0I9a`OdvIv}H)$Nf zYlXQWVX49XI#RNNM;I>)Bwm%zXxeIt6HXNbF@%JT(pVR0h8*K@(8t@G8RJU!%hq3EK@>gNU z>3rY{h?^S6si+6dT;O^FR||LDo)+48fBqGza`kjpb$+9_k15X@YDGn$(ax^Vh@MqP z4enb#j2npeHmuI$Aw+*k;^A^bI_PDF&2x)ORd4a{N7iwFp(S=rSV%|+{xTD@KeEQs z^vq;fEO=^-M#u!dnyv^r@^_5kT={n?&;I+;Y_na&?j#0AzFaCpb5uVtsb4uc43LgCca2^GYL03VF=d8778 zo=z|Y6L5m}mFW9s~gLGPSq?whfZ1`2Ob-R07>-qBM-+G7Q)Xe9I8u5w?Ev+2?JtxW>vKA9xc8 z2*=~yVdMxTQn&$`=`t5H^hrzt{qlQ;BmTZ-mC#6cPdSq;^Or~|SUVLwzV^b0tOh{& zj7tDKp`n1Z;@Dv0`f?a7X!Aqx7w<@^5KezX3A^@Z@n2kq>r=6osxtQF&_mwRz9WSHjH< z+92DY@HNY#4ubt{E#Rpyk|40X}$uU3_b2PXuTkqf{bUzQ0BQab8 z_3YnWF97BV^*5Dj1;+qJfd)s8huWD{CAEOE_hExxh%q;Y(cxbPBXEZ5ChKe|V>Wu^ zH_SnJfLRTIULnANz)2Y|d0*;Ws)KLFBhGs`t!+DqLzi+OR!N}bHn3jYkGDSa!a3OB z1neF)Lt&c;`O;O$_m`|I`z9SX*6`+GL;%6r0SptkMb3VwEgE@|!y*ymU#w>|?{1!v zkO2_SsZq#|_glWqbosLh87Yqc?fjP~OSpAib4gl+SJA~jl!>UEoNRB{o_g01ohC@< z$Rl8544stNTj7QnkK6X6H$~IWOkA#@?X8)@@xo8mp5e=~7gTkeWuh#|`nF_?U1t4w zZqAWDgVERh|Ls3T6AT68nMbw4fCd}-<{#a(4q6BX^5OnOt^H9CgBM^!_Ar7Pv(p75 zpMUX(7O1e|UOoJP3=n{=Tg$%+E*6c*eZex3X8wsc%>`0}E83l#Mk z8XVwX;N&g>Ng}P6q~<={RUyxZ!*%#80F4Z~A9>CFlLc`o^D?e{+PT%J^1~Ud|CH|5yBPIsF z!#_eS48+w8gQavF@ELqKHA0hVVqYS z=h#0!Z+i@%#(TJr$^a3XC|o(wbKq5iTj`b$;`#{j;YfwFuWUmmKND&+C3)fKzPurD z3Li$Of~cQ$$n|lq@MM+xQaQTF5&Lxu?i33hW$fwZ2x&~AjE}wKXvgfx?x_|f=-L52 z^^Fp9xH!6dkf1%X!%+K?jK49WTnAlmDZ=7q%7*z&hN?JlCiXN3Re(YYKj32W;HBwG z-seserIklfIp6e^D7z3OrkRuLL?kY_TkyIL7w*%2{BTI1dq=NC0(oy5wYk%IAmr|M zi100{CFrBt0lte!o9$SACa~l^C@^G#!<299Jl5HsYnf-!&3Dv{aE*w5v>SC&*=HlM zH>2kaaf1Z0^Ee8~M9CPhg84Quac#K&CE-1B)aGd^DkpP0Bzm4)?l5~H9UOwaL0U#N z3(8Y|Q{OtGiYIap90~uV_4sI(MwjE)*Mm2BFVwxdSgQ~Q04@k)Aa|nmQq-r+i6RWt z=M1a>sPK8+SUg%jbiOBJIsw3R<%_Hlx98u>SJ@Rt99uf{8t4Re3_JP)p!t^gDN4Kn zd8n+Iu_`Pne1GCEZ=DSD0-N+xiXZ7BO4a#BKfz9B~ zfn-@~Q$U_bvxL{djz;X@J&*nDP#b+Wz;bcsBL#I>B;M5kjVtS-{Q|SNHg!lR*W&r& zk)M8V*mgnP!<4rp%91C;zTHi{hm80U)T#qAhpj}-mOiye&;lU~-K5@iCt_Zd1F*cr zE`wmg@jT0gr~LEZmbt-XDIuXd_<&4>=wg+NNF{(__)he}^Wcna=5VmeWxs}POJKRF z5Cu6I+wBnA0UsZqZnWB*N*WvJc+Z?t+&$Ni-9_-k|AD{?>KH;|iOE&R9XRr917p^W z*6@xeBusDvzo-O6=Kx7OgP+D*o^QVzF>RBX{R=FeZbh;DYzTJ+D|SaNNSGq_a5%0Vy_DslxA2lW%S_5eH0Eo-?c z+H75U5=%J@yO}Z7MDy%)_(4dVcqVM-iAV!?`hQ>$Zdku03H<=U;B?B1ii!;J&liE8 z5s`v6IO>E3d(A4OM1Z=%j9(-r^;lH@exf_6xO1xE8Gca4*x2wZkwnzcDUp4+1QmT` zTH-st2W-T&{pp4U>Z_Rm&_zvSt0l}1eJ|vgub7$bj*1Og4D~d8hJW-xL<$OHO+7Ha z&Pj?2-c+tYuCki;2Fi=S5UC9oh&}Jx8ayX6U%*y1&i_w)-{FpR`-lBCB2rP&QYsB8l%@s}DJ?XljCP(p zr8HGm6G_W>N>5gLY>}b}4Gl_5sR)T^8Y$%Fe$Vr+-|_wp?{U12j>Gd5_xJi<*Jqrc z^E0knSHzEd%G@6eyA}t?wg4*FG@A8v7`iZ~k0)HUfqcc1@5OoO&5YbW5|hB0Silk0 zyfPqM!$EYF>zsM&t|Z%t*sw3c$nzwaL7k*w&pR%3vTNgE!>YEW%7nMGGOO0Ac>=6)EvhGe^e}=BJ}pn%KCWoZr%7A_N_65 z`dTv{KOeQ#2uZqK^ZozpCD-M}57?Khe`+r{>MN544Q3r%ZsgbxH-=(B>EAH911AN& zXAxTKv3N^0JmQUk;1Yja4-O2giUJOY0pJ8vp3Cg@FJNt;blygvK*U5npmUksbeFm+k5YwIDrsbSO8-&%LfFu(64 z{;+l!g3W0klH?U)j+e0EoUzY~TflI5%4o>_sENx|FKZi3JWP=%U8+tFxKTm@IkN(kyaM} z5>&(Bn_(#S0O$(=*pGPsu0^8cZg7i`vLu%Okk?55{8a16$;|YRb5x`O{jZ>Ig2Q!5 z7STT5X0aaGhNAdGF_Pu-Z{jU=V?DEU^S7lAnTb1|OYySbr{*;v;4Pa{+vD*6m`Y>|VYvRk1;M;uMP&Vy2Ab4kDOW-o*jA z)kMyy-%Qmv*Jeo1prIkVKuZ9$d;&i9{tRzP@seoD$_m#|cE8Og5sEL)y8)Tm?}_PQ z<+~?c>hVv^VM2}TQ0~6+>p6T_R~!wYE5Q5*b8(;K5r;5P3EK@B#%ITpN`OsOXy*{q zn8!0_8+?SMBX;E_Fn<9TEl2-^A-X#-1@f;~ec=8_dRSl^)wv{je!0UF1Wth%mLTkCEPSpnXK0?ko?>iQ6Na1M& z*X1Pln}W8`mjcou5M%lc2e?0fLUeFjwRt%ok7qEn_>M2O67xG*4+KePQI%5ORD%>; z6G!`io(Th^-ilgCgi9>oBa46nT({UZX+OsUgF5pf8sZlG^-K7&0JL-cjEqIiI5R5g z`q!b9R2MScutH0^DIP^00pLG}Qj6^}fF8M{V;a*kR`>Z?cUsdWb?^SV#Q;E1jb*gd z@E$nEIfWcCS(H9x`?&R7Gy8l0zV_#34?00~X5gpT@%agD9IbPM59&`r0HXnoAif|k zN}3TUYcI^kl7s;bn;bQ9=G*Tio&LqUDjHpnXyH@tPfJ{_khI0Mb*U}mA{@N9W+2WFpEm|ZrDSxAR`3|!SxHyO8ZhSNE}89O zsN29t?KB3;a)(`PPo@o^`37~V|s0tP4lX2$>0rOynY zTVDD-Lfm)be8%=r^7QRCr#S*D3sO z71y|=nLDNbIu#hIdFWivv=$}i4w6JG$7ARjUY;!o9jWROWdv40sDCrLg(XjN^sP@_ zf|zQD*5^yeqm)6&;}AQ}*0~fof46A-6(HOq$Q?#h3;@_b`8wVrV%Tz%cv)QQrULHW zdOb&tK-@>(yRVTr7}+s)$D-!db*nV&nJ!Kn=4CRxST5{8)|6E^&#D)3-)z-|PER#p zcL9oQq1t@5$=#|r>vT~Vsv#~@gsHL!Hxq$@37v7xRgRGlbm1%lmNFzo6rs#(Y=c$f zY@)Dg6z2SCtfeua9-&V>H0N}L-6tkWtf1O2fXTiv_=q*>HXNuX9;gjDgL6m4G%NnL=QP89KEe zj5|KlT!Pi-z#*3HaQIN?uR?f&DQpGTN=ny%4;MP@!T%-{u~;Lrda_BN@WlDU_tq>h z0z{U~+cng*C+B>s3!U^)5 zurk1Kv;Zf+TW@@SGe-?aI2`Kjy^YB0se@dErfu3^DYVtd$2+kG7a!5>SZ46@HQt`{ z;kD(07Qz;cLrakH50#9c-Hg4>W3Rj1+}p9fm=~j<`I_^6Ti%LI`t(@qg&paybMvNb zTprMQJ+%J}Rtr`shU~NLDBi$CZU%Ua({ibeiP&X4W;olf2jWb^s2Jh{S0fn>{tPiba%FQ`vUk`lK*VKfhI zLhKMx&N|1aJjB9PSU~fUHzRzyahQeEQPQdFTl4eHLwYqMgNTTMoZ?`pK?{TxS2mY z!3+mX765`|+3r}IeGt0NHfxv6TKUX7`mBgUD%uiziuH}p>2B=vnU(`4%n5srjhKo@ zOoc%6rZ${ZJpK{i3UZD|JFGqEznLZk;@0PS7=1&nM{wozh+e2E+)ieMNC%iY9ex$3 zrr>#VN$j|ueSW?$iG!*pO1j$ET}CM=`r)Y>l_YGb?k4DuhAhBDGUVa}GuGAC<~Rng z(k&}tTPI{}F(Sf5@GZE#$Dx%t5)%-1)O3C^`|e|b100ZJ#r23Q@$LvLG;Q>%INQK&!kcq9au z;si}#-Wr|a$*H}=GjgKYAqyhV6KWmbuWt4EE*=L)A(w*Hl6*5nzu!&t`)ROn z(ecL)DX&xO$juglFC`}cpU~$~u;U9QUMYnFI&5Zt(AwDQs6ATspAJ~V&@fTT1BRT% zjMef_zgUc4hQMp+h*TOa=i!LC%SeDnAkF;*1Fo%cia0iHh$;^A1L-t$9y|OoygLI< z%smHdi;s;%(@Gu`iT8Fv*5^Kb<*Sqg8DNyfZa=p54q9S9cxV#C(PMJ~k|bW2q%WMj zu6{?Y_&`s=&RIf&!$N|>{l$OO?l#|J?QCV)RmAx-lxcUPKTXZ})=fYF54ro(>k1o= zLp%sT&5Xy3H=>MSUx@oqXQxbX$ZxyT4pfA zSUkrp#Ge1RxbG?W#~FRSP+F#U{*l_PYZtICB&`7MqEF-ZIP;T5fRHqE+W~{mPC07R z4`}Nz7`z`$^uqZ@#rnnH|2F6zi+YIGX5ovZ9eg5D?V+;)#$n^I(WDC{MGmarz&@c0 zaAM^ixp0(zGCN{p>#_HF&3r^HmPWa(Qp8!K zmC4BQX8ooz$E{&Lu-2r$IPjwgFa%ivvxduh2e)McnEvDq8r*QvMI)ycnav3@TY}^l zrsOK`eo~$#>!r@Q`Vo6YC6xCp^C%XDn6CZ^)$lk9M+$h=Q#pajwlB! z25IJidN2#P5;rDC=E7lPP*ftL>=$b9gf!M>{;UzEJBskeEc=alcHl>4PVWR0M0@7%Rk&`Xr758axgSS zrM&r1a?2K}s9t(*+B>R_s}0pbT4K<94eYe2Vd>-`VVRul%IM7kR0X1?4LiJ8+`G{j zT`jMR461{rjJqaQkBK~V_cLgDIi1uQ-GozRA9jUmp4oua*VK(ujvKrHXUY)+rxm&@kdrd z&wvKzkWE-pwSq{+u|p0G`HGNT&9&$9lvPzZdTFm0IMsChAvGv(Zgzud4tpAyMv%~?y_g%$`!xE{KfFbSS@Azcw|s_p!lv*djy_<+ zH-o1!vCfGWO%pkz0)khqvGbNR1TS=|Lu7P6;?QB<_ZdDKRbS@A1dIlD+B@{DwDKN# zsJ-jRz33=gP9(_*h@3*8D5nLNpoP0r|$9fqZBj?aN}0$(U^JT(j(O5TKsg>`oefn&J%Ssfrxgc(udN+aYZqkefB1PYdy2RKSE zn>`{t<0Y%wObV_Hc|Qvi{GQ$zmF*xZ2TM`};(=*g@P91bxQYxY2Qpg^jcKXD6l2z{ z(5-_-fj-uQ-5Kk-V@ys0**&-#cB?=Y-9g7p2aH@YyWh3IX89Xx`76I7eJ~FzGvqiM z;)}R8889Kj(#^-bS`19gwVa9;#0;PMrE91Z+gsFU_43_T)>PIID$Nei6DO4WbvF$i zcc@QS=3`;2oPp(VtJ1)@+o*c>3X<$mCK zEFNQ)9%zk-#3#)lXG9*X2=hn;KJXB*1nOTMjKjRqO%D4?0L}Sqh?Q8@+}ib6^cTk& z$<=@-bdXG2XJ9>Q5LQuw;vS@e+M&PW~*l`|a5^ zXy+v9O!66GYP`<-=*7+!JKVw0C*Y%Y03ad%x3#nSk>LgMt|gthd%|=8vceTm9Tu-O*ktHlcaTIpn!S`*D}Auzl8jETX;RI z%kwPXNNW%q7Z$nzkimPsg}q&1)oV%qf*-5z%>r7Zt7czw2_TG>0p<#Dpa5$!$sTZ1 zh(+j9+!Z8`8ram$C<%Xm=$y>5I*cI^0V7_*e0$B>}jX14qNmY=iJjgx808d=i|4U|kn^3hVwJ z*`==F2MFAa*y`rUiDK-li&9eS;{nBGqCmm)^8pmmGpz%kAN}HxvVzG|7}$yIPP~q+ zI}{-U-$1>7XPW@*+tfFWHXCOi{nP;?NmwNru`D@d1U@8uKrvLG8is0gJYn~`;q1=? zcyuO&hlhm0^}d?;d| zRt|KO%sVCv?6h|nQhj)f-R4cbE;$>@vBQx*6`Mn*7};?|A#+jk0+oq&PapN83!^*M z!JdDgG=@B>utnw_T7#UFE)}WhJ1UWghwe3%JPiGQubp@f_>lSxLuCQo#35t({y3}& z#=*~8A{&edYBr8nc|XB3^5O%M?|Jwl8pB2$wv}vA#=DcA0G{j7I?c-~owuh%uA2h6 zLY80vj@~6Ig5wGRS^v<@YyhIz0nxCY#AHIL!lq?Ur_(6VkMaS05EEVSpkDVi2s$=T z-Zv~42R%w&-U|seI1_{pKveZnpP>cQ0ail+RS-;|$9EQx1L`O$kIzs%*`U0-=g{lJ zIiMZYD6A^s_b+hAv!j*O+EE-JhDDTtQEX9)w%0t?ZuM(FH?Th1qbN%U;y8bWVjj9V-!%6eLEvk1#;zhG;6#`uX8*DZ>VOJp8O`G^Y8VJ z@2D~`she+%vziAlgJo8&>o>U9YyJo5EixmTo_;i z%(dx5)Y+gU7D-))MJy!_^&Vb2tCO>{Ix@6!I+W zZm|CYY(XwK{gRp3`Ed1t2~3t>`V3({ec zBzGN34vFRoZ*bzLJU=LW^oX7oDYC3|A+kuT;R`~995%f7G1LG_j${Rk!8y9DlyiDu z`v>ZGI1N=k52eN1l|6VX54STOf_+q93Pu165uNw`ym$me$Sv?1@3VGeOQ*O_+$QeG zfx;N1m4)DfCVwLul!8rOyQY)?JMn@LS~LbFFj*ydP!a^VxZ#?4SAXoM5mcgGGF6!a z2qCz8pIB#kr(a;RPxu9ix+a%Szk=5T?D}yq?gV60_BT0zSFa`Ip52?{etmjpS zY^lxi#AJNR7~t*JUac^DH~*4?*p^O~hX?ESpd@m*Rzw*ty~&`>!QifIeNI+d8KJ2H zV*hSZW{`IEqvSmF#-`p8<(WojPmEcF`$_)SkpS{GjuW(&&COAF?x=OgScw)U%Q?oG z7i9rOC@-B=;3Mh($X6GBo^y2-j1`pkM%|EwkfOe9#GPP6JRGJKB!4L~A9BpZ;pAXa zR2vO`4G$ZP9|XPAEV=BFfQNVxl#ta@CCT1yiJHp`zD6wW)v7UY(EdR<*#x@*0uBgt zFSJ|rnUwL#QLd#2^%vu#y^0#?704H?>O-$i_o`l}X{31)j)yLeqazGao^}NyMEY`N z6ZP?nz3tYcpU#;w7maA8Wwim$3*BU~34Jj~oS?O@g}`ZD;5X#mX0VEyn0C}fC* zJF0%u{RGSf{`ju-0E;G}(H0;@Ctq=QmPjOXrd-C$Lioq4q}cM9{munY8inc6xt^Oo z8kkHK9E(Z&=6QBa8|wln%q}iQ);Iy=qA-yqPJNG5OY-rTZ7gA8nvFRkwvR4vlo`

Q3+$@|ZjyL=Q-m!aa$`zaOwm4aD01PiX3s&Fa8 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 diff --git a/tools/hud_corrections.py b/tools/hud_corrections.py index 473578768a..eef0da7840 100644 --- a/tools/hud_corrections.py +++ b/tools/hud_corrections.py @@ -23,7 +23,16 @@ def scope_cells(kind): if kind == 'ce': return [cell([0, 0, 25, 18], 'scope', [50, 26, 188, 112]), cell([51.5, 0.5, 61.5, 10.5], 'scope', [241, 12, 301, 73])] - rectangles = [[0, 0, 31, 24], [0, 24, 43, 48], [35.5, 3.5, 45.5, 13.5]] + # The Xbox zoom labels' stock glyphs (alpha >= 128) do not fill their + # sprites [0,0,31,24] and [0,24,43,48]: 2x covers [5,5,26,19] (21x14 + # texels) and 10x [6,29,38,43] (32x14), with the rest of each sprite + # empty. Filling the sprite drew them a third larger than the Xbox's. + # These placements put the supplied glyphs on the stock bounds: 10x + # within one output pixel; 2x within three, its supplied glyphs being + # wider for their height (1.65) than the stock's (1.50), which a + # proportional resize cannot change. + rectangles = [[4.125, 4.875, 26.75, 19.0], [5.25, 28.75, 38.5, 43.125], + [35.5, 3.5, 45.5, 13.5]] return [cell(rect, 'scope', crop) for rect, crop in zip(rectangles, [[50, 26, 188, 112], [50, 168, 247, 253], [241, 12, 301, 73]])] diff --git a/tools/test_hud_corrections.py b/tools/test_hud_corrections.py index 832e7f0fa9..853e66a34a 100644 --- a/tools/test_hud_corrections.py +++ b/tools/test_hud_corrections.py @@ -77,6 +77,24 @@ def test_sniper_reticle_keeps_stock_visible_size_and_position(): assert np.all(np.abs(actual - np.array(bounds)*scale) <= 1), name +def test_xbox_zoom_labels_keep_stock_visible_size_and_position(): + # Measured stock alpha bounds (>=128) of the Xbox 2x and 10x, which sit + # inside their sprites with empty texels around them: filling the + # sprites drew them a third larger. The supplied 2x is wider for its + # height than the stock's, so a proportional resize leaves its edges up + # to three high-res pixels (3/8 texel) from the stock's; 10x, one. + alpha = Image.open(ASSETS / 'sniper__hud_reticles_scope__0.png').getchannel('A') + for label, sprite, bounds, tolerance in [ + ('2x', [0, 0, 31, 24], [5, 5, 26, 19], 3), + ('10x', [0, 24, 43, 48], [6, 29, 38, 43], 1), + ]: + x0,y0,x1,y1 = [n*8 for n in sprite] + visible = np.asarray(alpha.crop((x0,y0,x1,y1))) >= 128 + ys,xs = np.where(visible) + actual = np.array([x0+xs.min(), y0+ys.min(), x0+xs.max()+1, y0+ys.max()+1]) + assert np.all(np.abs(actual - np.array(bounds)*8) <= tolerance), (label, actual.tolist()) + + def test_ce_keeps_8x_and_only_selects_verified_scope(tmp_path): body = function((ROOT / 'port/linux/game/hud_hires_tags.c').read_text(), 'hud_hires_crosshair_bitmap') run_c(tmp_path, r''' From 8468b4c347a5675946d4a53873398e47c6878ade Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 3 Nov 2026 23:15:55 +0100 Subject: [PATCH 11/32] Keep a z bias's depth units and slope as the Xbox's at any depth and size The Pelican's side light (vehicles\pelican\shaders\lights, an additive decal) sank into the hull in slivers, where on the Xbox it is a whole glow. A z bias of 8 is a polygon offset of 8 depth units and 2 times the depth's slope across a pixel (D3DDevice_SetRenderState_ZBias), and the native builds weakened both: - With display.reversed_depth (the default on the desktop) the depth buffer holds floats, whose polygon offset units are a part of the depth itself, which reversed is small far away: at ten world units the 8 units moved a decal a hundredth of what the Xbox's 24-bit units did. The vertex programs now add the units in the Xbox's measure (depth_bias, each 1 / viewport_scale.z), and the polygon offset keeps only the slope. - The slope term is the depth's change across a pixel, and a screen drawn at several pixels to the game's one has that many times less depth across each: at 1080 lines it was 2.25 times weaker, and on a surface seen at an angle it is most of the bias. The factor is now multiplied by the bound target's scale, as the Xbox took it across its own pixels. At 640x480 with display.reversed_depth off nothing changes, and nothing the machines send changes. Checked: the Windows release build compiles without warnings; in the game on Windows (RTX 5070 Ti, 1080 lines), the Pelicans' red lights in a30's opening cutscene are whole glows, as in an Xbox capture (xemu), where they were cut into slivers. Co-Authored-By: Claude Opus 5.5 --- port/linux/README.md | 2 +- port/linux/src/d3d8_gl.c | 29 ++++++++++++++++++++++++++--- port/linux/src/nv2a_vsh.c | 12 ++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/port/linux/README.md b/port/linux/README.md index 3aa9192b79..72eab92c6f 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -535,7 +535,7 @@ the setting for one start of the game. It has priority over the file. | `display.shield_glow_intensity` | `default` | `HALO_SHIELD_GLOW_INTENSITY` | The shield glow's reach and brightness: `default`, or `light_show` (three times as far, and at full strength from a third of the flare: a light is no brighter than full). The units glowing are those near any local player's camera, so each of a split screen's views has the same. Video Setup's Extra Effects screen sets it. In a network game, the host's `network.shield_effects` decides, unless it is `players` (each player's own) or the game is co-op (always each player's own). | | `display.overshield_effect` | `false` | `HALO_OVERSHIELD_EFFECT` | `true`: the overshield's flare rises up the body from the feet as it charges, with a soft edge (2 seconds, the charge's time from a full shield; 3 from a shield that was down; 2 from any other), then stays lit, whole, for as long as the overshield lasts, its energy moving at 30% of its speed, in its own color (the powerup's, 253, 78, 116), whatever shield color its player chose; the shield's own flares are not drawn over it, and its glow (`display.shield_glow`) rises and stays with it. With `display.viewmodel_shield` it comes up on the first-person arms as the rise reaches them. A hit on it plays what the projectile plays on an Elite's shield (its sound, sparks and light), when the map has that effect and it only shows and sounds. `false`: the overshield as the stock game draws it. Only this machine's drawing and sounds change (`overshield_effect.c`; the rise is a fade in the vertex shader, `d3d8_gl_set_height_fade`). Video Setup's Extra Effects screen sets it. In a network game, the host's `network.shield_effects` decides, unless it is `players` (each player's own) or the game is co-op (always each player's own). | | `display.model_detail` | `"stock"` | `HALO_MODEL_DETAIL` | Which level of detail (of up to five) each model is drawn at. The game measures a model by its size in the lines of its 480-line screen, at any resolution, so at 1440 lines a model three times larger than the levels were made for is drawn as the Xbox drew it. `"stock"`: as on the Xbox. `"native"`: measured in the lines of the display (not supersampling's), so more detail is kept at a distance at high resolutions, and small far models that the Xbox left out are drawn. `"highest"`: measured as `"native"` for which models are drawn, and those drawn at their most detailed level. With either, a part that has no geometry at the level chosen draws its most detailed level below that has some, and a level made for a model's inside (one for a size over 1000 pixels, as the top level of a10's cryotubes) is drawn only while the camera is inside the model. Models whose map gives one level (the trees and rocks of b30) do not change. On Android, which draws 480 lines, `"native"` is `"stock"`. | -| `display.reversed_depth` | `true` | `HALO_REVERSED_DEPTH` | Not on Android or in a browser. `true`: the depth buffers hold 32-bit floats, reversed (1 at the near plane, 0 at the far), so that depth is about as precise far away as near. With the Xbox's 24 bits and its near plane of 0.0625 world units, surfaces a few millimetres apart (the light strips on Blood Gulch's base, 0.0016 to 0.013 world units in front of their walls, with no depth bias) are run together past some tens of world units and flicker through each other, which the Xbox's 480 lines hid. The renderer turns the game's depth around (its tests, clears, depth range and polygon offsets), and the vertex programs take the reversed depth from their projection's rows (w's row less z's), not from w - z, which would lose the precision at a distance. `false`: 24 bits, as the Xbox's. Read at start. | +| `display.reversed_depth` | `true` | `HALO_REVERSED_DEPTH` | Not on Android or in a browser. `true`: the depth buffers hold 32-bit floats, reversed (1 at the near plane, 0 at the far), so that depth is about as precise far away as near. With the Xbox's 24 bits and its near plane of 0.0625 world units, surfaces a few millimetres apart (the light strips on Blood Gulch's base, 0.0016 to 0.013 world units in front of their walls, with no depth bias) are run together past some tens of world units and flicker through each other, which the Xbox's 480 lines hid. The renderer turns the game's depth around (its tests, clears, depth range and polygon offsets), and the vertex programs take the reversed depth from their projection's rows (w's row less z's), not from w - z, which would lose the precision at a distance. A z bias (decals, and lights drawn on a model, such as the Pelican's) keeps the Xbox's 24-bit depth units, added by the vertex programs: a floating-point buffer's polygon offset measures its units by the depth itself, a hundredth of the Xbox's at ten world units, and those lights sank into the hull. With either setting, a z bias's slope term is taken across the game's 480-line pixels, as on the Xbox, not across the display's smaller ones. `false`: 24 bits, as the Xbox's. Read at start. | | `display.hdr` | `false` | `HALO_HDR` | `true`: the back buffer that the 3D view is drawn into holds half floats (`GL_RGBA16F`) in place of 8 bits a channel, so that the light the game adds up (blended lights, glows, fog, particles) is kept past white in place of being clipped. Before the HUD and the menus, a tone mapping pass brings it back into the display's range (`display.hdr_tone_mapping`). The HUD and the menus do not change. On Android, only with a GPU that draws into half floats (OpenGL ES 3.2, `GL_EXT_color_buffer_half_float` or `GL_EXT_color_buffer_float`); otherwise off, with a line in the log. A change applies from the next frame. Video Setup's GRAPHICS screen sets it (HDR) and `display.hdr_output` (HDR DISPLAY). Refer to "HDR" in "What operates". | | `display.hdr_exposure` | `1.0` | `HALO_HDR_EXPOSURE` | With `display.hdr`, the light of the 3D view is multiplied by this before the tone mapping, from `0.25` to `4.0`. | | `display.hdr_tone_mapping` | `"gt7"` | `HALO_HDR_TONE_MAPPING` | With `display.hdr`, the curve that brings the light into the display's range. `"gt7"`: Polyphony Digital's Gran Turismo 7 tone mapping (`port/third_party/gt7_tone_mapping`, MIT), in Rec.2020 at the light's brightness in nits: each channel along its curve to the display's peak, blended with that curve's brightness at the colour's own hue (ICtCp), whose chroma fades out as the light nears the peak, so that the brightest light goes to white, as on film and as the Xbox's 8 bits clipped it, in place of staying saturated. Without an HDR display, as GT7's SDR (white at 250 nits). Video Setup's Extra Effects screen sets it (TONE CURVE), taking effect from the next frame. `"neutral"`: the picture is as without HDR up to 80% of white, and the light past it rolls off to white in place of being clipped. `"aces"`: the ACES filmic curve (Narkowicz's fit), with more contrast and darker shadows. `"reinhard"`: the extended Reinhard curve on luminance, softer and darker. | diff --git a/port/linux/src/d3d8_gl.c b/port/linux/src/d3d8_gl.c index 86ab3604cc..5e0c9b4d21 100644 --- a/port/linux/src/d3d8_gl.c +++ b/port/linux/src/d3d8_gl.c @@ -566,6 +566,8 @@ struct draw_uniforms float screen_offset; float texture_lod_bias[4]; float height_fade[4]; + /* display.reversed_depth: a z bias's depth units (nv2a_vsh.c) */ + float depth_bias; }; struct program_entry @@ -584,6 +586,7 @@ struct program_entry GLint texture_lod_bias; GLint screen_offset; GLint height_fade; + GLint depth_bias; /* nv2a_pixel_shader_key's source_blend's color, and what it last had */ GLint blend_constant; float blend_constant_value[4]; @@ -3973,6 +3976,7 @@ static struct program_entry *program_get(GLuint vertex_shader, GLuint fragment_s entry->texture_lod_bias = glGetUniformLocation(entry->program, "texture_lod_bias"); entry->screen_offset = glGetUniformLocation(entry->program, "screen_offset"); entry->height_fade = glGetUniformLocation(entry->program, "height_fade"); + entry->depth_bias = glGetUniformLocation(entry->program, "depth_bias"); entry->blend_constant = glGetUniformLocation(entry->program, "blend_constant"); entry->model_lights = glGetUniformLocation(entry->program, "model_lights"); for (stage = 0; stage < D3DTSS_MAXSTAGES; stage++) @@ -4767,11 +4771,20 @@ static void apply_raster_state(BOOL has_depth) offset[0] = dword_to_float(rs[D3DRS_POLYGONOFFSETZSLOPESCALE]); offset[1] = dword_to_float(rs[D3DRS_POLYGONOFFSETZOFFSET]); - /* (display.reversed_depth: toward the camera is up) */ + /* The slope is the depth's change across a pixel, and a target drawn + at several pixels to the game's one (render_target_get) has that many + times less depth across each: the Xbox's slope term, taken across its + own pixels, is that many times the scale. On a surface seen at an + angle it is most of the bias (the Pelican's lights on its curved hull + sank in at 1080 lines with it a 2.25th as large). */ + offset[0] *= target_scale[0] > target_scale[1] ? target_scale[0] : target_scale[1]; + /* (display.reversed_depth: toward the camera is up, and the units, + which a floating-point depth buffer measures by the depth itself, + are the vertex program's, in the Xbox's measure: depth_bias) */ if (xgpu_reversed_depth) { offset[0] = -offset[0]; - offset[1] = -offset[1]; + offset[1] = 0.0f; } if (memcmp(gl_state.polygon_offset, offset, sizeof(offset))) { @@ -4806,7 +4819,7 @@ static void gl_check_errors(const char *where) /* the uniforms of the latest draws, converted from these inputs; the serial counts the conversions */ -#define DRAW_UNIFORM_INPUT_COUNT (4 + 4 + 16 + 1 + 16 + 2 + 4 + 1 + 1 + 7 * D3DTSS_MAXSTAGES + 4) +#define DRAW_UNIFORM_INPUT_COUNT (4 + 4 + 16 + 1 + 16 + 2 + 4 + 1 + 1 + 7 * D3DTSS_MAXSTAGES + 4 + 2) /* the height fade of the draws that follow (d3d8_gl_set_height_fade): off while its scale is zero */ @@ -5125,6 +5138,8 @@ static struct program_entry *prepare_draw(BOOL immediate) } memcpy(&inputs[count], height_fade_value, sizeof(height_fade_value)); count += 4; + inputs[count++] = D3D__RenderState[D3DRS_SOLIDOFFSETENABLE]; + inputs[count++] = D3D__RenderState[D3DRS_POLYGONOFFSETZOFFSET]; if (!draw_uniforms_serial || !words_equal(inputs, draw_uniform_inputs, sizeof(inputs))) { struct draw_uniforms *converted = &draw_uniforms; @@ -5164,6 +5179,13 @@ static struct program_entry *prepare_draw(BOOL immediate) } converted->screen_offset = (float)UI_OFFSET; memcpy(converted->height_fade, height_fade_value, sizeof(converted->height_fade)); + /* (display.reversed_depth: the z bias's units, the Xbox's depth + units toward the camera, in the depth the vertex program writes: + one is 1 / viewport_scale.z, the depth buffer's range over the + viewport's) */ + converted->depth_bias = xgpu_reversed_depth && D3D__RenderState[D3DRS_SOLIDOFFSETENABLE] && + device.viewport_scale[2] != 0.0f ? + -dword_to_float(D3D__RenderState[D3DRS_POLYGONOFFSETZOFFSET]) / device.viewport_scale[2] : 0.0f; } } /* and a program that has had them since needs none of them */ @@ -5186,6 +5208,7 @@ static struct program_entry *prepare_draw(BOOL immediate) uniform_float(entry->screen_offset, &entry->uniforms.screen_offset, draw_uniforms.screen_offset); uniform_vec4(entry->texture_lod_bias, entry->uniforms.texture_lod_bias, draw_uniforms.texture_lod_bias, 1); uniform_vec4(entry->height_fade, entry->uniforms.height_fade, draw_uniforms.height_fade, 1); + uniform_float(entry->depth_bias, &entry->uniforms.depth_bias, draw_uniforms.depth_bias); return entry; } diff --git a/port/linux/src/nv2a_vsh.c b/port/linux/src/nv2a_vsh.c index 40c7f95a28..905a977107 100644 --- a/port/linux/src/nv2a_vsh.c +++ b/port/linux/src/nv2a_vsh.c @@ -346,6 +346,9 @@ static const char shader_prologue[] = /* a fade above a height on the model (d3d8_gl_set_height_fade): off while w is zero */ "uniform vec4 height_fade;\n" + /* D3DRS_ZBIAS's depth units with display.reversed_depth, in the + depth the vertex program writes (d3d8_gl.c, draw_uniforms) */ + "uniform float depth_bias;\n" "out vec4 xD0;\n" "out vec4 xD1;\n" "out vec4 xB0;\n" @@ -672,6 +675,15 @@ char *nv2a_vertex_shader_to_glsl(const DWORD *instructions, unsigned long instru { xgpu_text_append(&text, "\tposition.z = position.w - position.z;\n"); } + /* A z bias moves a decal's depth toward the camera by a number of the + Xbox's 24-bit depth units. A polygon offset's units in a floating-point + depth buffer are a part of the depth itself, which reversed is small far + away: 8 of them moved a decal a hundredth of what the Xbox did at ten + world units, and an additive light on a curved hull (the Pelican's) + sank into it in slivers. So the units are added here, in the Xbox's + measure; the polygon offset keeps only the slope (d3d8_gl.c). */ + if (xgpu_reversed_depth) + xgpu_text_append(&text, "\tposition.z += depth_bias * position.w;\n"); xgpu_text_append(&text, /* 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 From 6f1a31d6f3ae15b0dbffa911839263b147ebb118 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 3 Nov 2026 23:16:07 +0100 Subject: [PATCH 12/32] Test each lens flare's occlusion in a slot of its own The Pelicans' thrusters and the lights of a corridor blinked out for a moment and came back, where on the Xbox they stay lit. The game tests each flare at its place in the frame's list and reads the test back by that place at the start of the next frame. The Xbox waited for the tests there, so the place's test was the flare's; the native builds read the counts two or more frames late without waiting (d3d8_gl.c, visibility_read_batch), and the list changes as flares come into view and go out of it, so a flare was read the count of the one at its place frames before. A hidden flare's count puts a flare's visibility to none at once, and it comes back over some ticks. Each flare now keeps a slot, found by its own visibility (lens_flare_occlusion_test_results_get), for as long as it goes on being tested, and is read from there; a slot just taken is not read for four frames, until the flare's own counts have come. A flare that finds no slot is tested at its place in the list, past the slots, as before. Only the drawing changes, nothing the machines send. Checked: the new harness test test_lens_flare_occlusion.py (the real code over a fake GPU that reads counts two frames late, the flares' order changing each frame) passes, built with clang on Windows; the code before this change fails it (a visible flare put out to 0), as do three other faults; in the game on Windows (RTX 5070 Ti), the thrusters in a30's opening cutscene and a corridor's lights stay lit. Co-Authored-By: Claude Opus 5.5 --- port/linux/README.md | 1 + source/rasterizer/rasterizer_lights.c | 121 ++++++++++- tools/harness/tests/lens_flare_occlusion.c | 195 ++++++++++++++++++ .../tests/test_lens_flare_occlusion.py | 66 ++++++ 4 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 tools/harness/tests/lens_flare_occlusion.c create mode 100644 tools/harness/tests/test_lens_flare_occlusion.py diff --git a/port/linux/README.md b/port/linux/README.md index 3aa9192b79..1c8015fc8d 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -1840,6 +1840,7 @@ Other changes: | `saved games/player_profile.h`, `interface/player_ui.c` | A sixth button layout, the Master Chief Collection's Bumper Jumper (`profile.button_preset` 5), and a seventh, Fishsticks (6: zoom on the left trigger, melee on the right stick's button, grenade throw on the right bumper, crouch on B, grenade switch on the pad's left, which then does not move you), and an eighth, Recon (7: the standard buttons with action and reload on the right bumper, grenade switch on the left bumper, flashlight on X). A layout the build does not know (a later build's) gets the standard buttons, not none, which moved start and back and stopped the game. | | `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. | | `rasterizer/xbox/rasterizer_xbox_shadows.c` | With larger shadow maps (`display.shadow_resolution`), the blur's taps stay half a texel apart, and more passes widen it to cover the same part of the map as on the Xbox. | +| `rasterizer/rasterizer_lights.c` | Each lens flare's occlusion test has a slot of its own, kept while the flare goes on being tested, and the flare's visibility is read from there. The Xbox waited for each test at the start of the next frame, so the test at each place in the frame's list of flares was that flare's; the native builds read the counts two or more frames late, and as flares come into view and go out of it, a flare was read another's count, and a hidden one's put it out (the Pelicans' thrusters, a corridor's lights). A slot just taken is not read until the flare's own counts are. | | `rasterizer/rasterizer.c` | The 128 KB of vertices of the debug display of a model's vertices are static, not on the stack: the compiler inlines the function in `render_model`, and the Windows build probed the stack a page at a time for every model drawn. | | `render/render.c` | The 3D view of each window is tone mapped (`display.hdr`) and antialiased (`display.anti_aliasing`) before the HUD is drawn. | | `render/render.c` | `render_initialize` reads the structure culling tests from `display.structure_culling`. | diff --git a/source/rasterizer/rasterizer_lights.c b/source/rasterizer/rasterizer_lights.c index e2da30f7dc..397e89a320 100644 --- a/source/rasterizer/rasterizer_lights.c +++ b/source/rasterizer/rasterizer_lights.c @@ -360,6 +360,50 @@ static struct rasterizer_lens_flare_submit_parameters local_lens_flare_parameter static long local_lens_flare_count = 0; extern short global_screenshot_count; +/* port: each lens flare's occlusion test in a slot of its own. + +The Xbox read the tests at the start of the next frame, waiting for them +(rasterizer_widget_get_occlusion_test_result), so the test at each place in +the frame's list of flares was the flare's at that place. The native builds +read the counts two or more frames late without waiting (d3d8_gl.c, +visibility_read_batch), and the list changes as flares come into view and go +out of it (rasterizer_lens_flare_submit): a flare was read the count of the +one at its place frames before, and a hidden one's put it out at once, to +come back over some ticks (the Pelicans' thrusters, the lights of a corridor +walked along). So each flare keeps a slot, found by its own visibility +(lens_flare_occlusion_test_results_get), while it goes on being tested, and +its count is read from there; a slot just taken holds the last flare's counts +until the new one's are read, and is not read before. A flare that finds no +slot is tested at its place in the list, past the slots, as before. */ +enum +{ + LENS_FLARE_OCCLUSION_SLOT_COUNT = 2048, + /* the slots looked at for a flare's, from its hash */ + LENS_FLARE_OCCLUSION_SLOT_PROBES = 32, + /* the frames a flare no longer tested keeps its slot */ + LENS_FLARE_OCCLUSION_SLOT_KEPT_FRAMES = 30, + /* the frames from a slot's taking to its first count of the flare's own: + the native builds read a count two frames after its test, more while the + GPU is behind (d3d8_gl.c) */ + LENS_FLARE_OCCLUSION_SLOT_SETTLE_FRAMES = 4, +}; + +struct lens_flare_occlusion_slot +{ + /* the flare's visibility (lens_flare_occlusion_test_results_get), which + is its own; NULL for none */ + byte const *visibility; + short light_identifier; + unsigned long taken_frame; + unsigned long tested_frame; +}; + +static struct lens_flare_occlusion_slot lens_flare_occlusion_slots[LENS_FLARE_OCCLUSION_SLOT_COUNT]; +/* the occlusion test of each flare in the frame's list: a slot, or its place +past them (lens_flare_occlusion_test_index) */ +static short local_lens_flare_occlusion_tests[MAXIMUM_LENS_FLARES_PER_FRAME]; +static unsigned long lens_flare_occlusion_frame; + /* ---------- private code */ /* port: a map's BSP lens flare marker past what the BSP has; said once */ @@ -434,6 +478,66 @@ static byte *lens_flare_occlusion_test_results_get( [lens_flare_parameters->compressed_window_index & _lens_flare_window_index_mask]; } +/* port: the occlusion test of the flare at lens_flare_index in the frame's +list: its slot (above), the one it had if it has one, else one no flare has +been tested in for a while; else its place past the slots */ +static short lens_flare_occlusion_test_index( + short lens_flare_index) +{ + struct rasterizer_lens_flare_submit_parameters *lens_flare_parameters= lens_flare_parameters_get(lens_flare_index); + byte const *visibility= lens_flare_occlusion_test_results_get(lens_flare_parameters); + short light_identifier= lens_flare_parameters->light_identifier; + unsigned long hash= ((unsigned long)visibility ^ (unsigned short)light_identifier) * 2654435761UL; + short free_slot= NONE; + short probe; + + for (probe= 0; probe>16) + probe) & (LENS_FLARE_OCCLUSION_SLOT_COUNT-1)); + struct lens_flare_occlusion_slot *slot= &lens_flare_occlusion_slots[slot_index]; + + if (slot->visibility==visibility && slot->light_identifier==light_identifier) + { + slot->tested_frame= lens_flare_occlusion_frame; + return slot_index; + } + if (free_slot==NONE && + (!slot->visibility || + lens_flare_occlusion_frame-slot->tested_frame>LENS_FLARE_OCCLUSION_SLOT_KEPT_FRAMES)) + { + free_slot= slot_index; + } + } + + if (free_slot==NONE) + return (short)(LENS_FLARE_OCCLUSION_SLOT_COUNT+lens_flare_index); + + lens_flare_occlusion_slots[free_slot].visibility= visibility; + lens_flare_occlusion_slots[free_slot].light_identifier= light_identifier; + lens_flare_occlusion_slots[free_slot].taken_frame= lens_flare_occlusion_frame; + lens_flare_occlusion_slots[free_slot].tested_frame= lens_flare_occlusion_frame; + + return free_slot; +} + +/* port: the visible pixels of the latest of the flare's own occlusion tests +read, NONE while none is (its slot just taken) */ +static long lens_flare_occlusion_visible_pixels( + short lens_flare_index) +{ + short test_index= local_lens_flare_occlusion_tests[lens_flare_index]; + + if (test_index==NONE) + return NONE; + if (test_indexlight_identifier==NONE) { @@ -660,9 +768,13 @@ void rasterizer_lights_begin_for_new_frame( if (lens_flare_parameters->internal__occlusion_pixels>0) { - long visible_pixels= rasterizer_widget_get_occlusion_test_result(lens_flare_index); + /* port: the flare's own test's (lens_flare_occlusion_visible_pixels); + with none read yet, its visibility stays as it is */ + long visible_pixels= lens_flare_occlusion_visible_pixels(lens_flare_index); long occlusion_pixels= lens_flare_parameters->internal__occlusion_pixels; + if (visible_pixels==NONE) + continue; latest_visibility= (byte)MIN(255, (255*visible_pixels + (occlusion_pixels>>1))/occlusion_pixels); } else @@ -708,6 +820,8 @@ void rasterizer_lights_begin_for_new_frame( local_lens_flare_count= 0; } + /* port: (lens_flare_occlusion_test_index) */ + lens_flare_occlusion_frame++; rasterizer_profile_end(_rasterizer_profile_lens_flare_occlusion_query); @@ -906,11 +1020,14 @@ void rasterizer_lens_flares_submit_occlusion_tests( break; } + /* port: in the flare's own test (lens_flare_occlusion_test_index) */ + local_lens_flare_occlusion_tests[lens_flare_index] = + lens_flare_occlusion_test_index(lens_flare_index); lens_flare_parameters->internal__occlusion_pixels = rasterizer_widget_submit_occlusion_test( &occlusion_point, occlusion_radius, - lens_flare_index); + local_lens_flare_occlusion_tests[lens_flare_index]); if (first_person) viewmodel_projection_end(); diff --git a/tools/harness/tests/lens_flare_occlusion.c b/tools/harness/tests/lens_flare_occlusion.c new file mode 100644 index 0000000000..c880c9b284 --- /dev/null +++ b/tools/harness/tests/lens_flare_occlusion.c @@ -0,0 +1,195 @@ +/* +LENS_FLARE_OCCLUSION.C (test) + +The real lens flare occlusion bookkeeping of rasterizer_lights.c +(lens_flare_occlusion_test_index, lens_flare_occlusion_visible_pixels, +rasterizer_lights_begin_for_new_frame) over a fake GPU that reads each test's +count two frames after it, as the native builds do (d3d8_gl.c, +visibility_read_batch). Each flare's count must be its own: with the flares' +order in the frame's list changing (they come into view and go out of it), a +count read by the place in the list was another flare's, and a hidden one's +put a visible flare out (the Pelicans' thrusters blinking). A slot just taken +is not read until the flare's own counts are. test_lens_flare_occlusion.py +takes the enums, structures and code from the sources (config.inc, +under_test.inc). +*/ + +#include +#include "harness.h" + +typedef unsigned short word; +#define TICKS_PER_SECOND 30 +#define MIN(a, b) ((a) < (b) ? (a) : (b)) +#define PIN(n, low, high) ((n) < (low) ? (low) : ((n) > (high) ? (high) : (n))) + +/* the limits of the arrays the flares' visibility is kept in */ +enum +{ + MAXIMUM_WINDOWS = 4, + MAXIMUM_LENS_FLARES_PER_FRAME = 1024, + MAXIMUM_LIGHTS_PER_MAP = 64, + MAXIMUM_LENS_FLARES_PER_LIGHT = 8, + MAXIMUM_QUEUED_LENS_FLARES = 8, + MAXIMUM_LENS_FLARE_MARKERS_PER_STRUCTURE = 65536, +}; +struct lens_flare_definition; + +#include "config.inc" + +static struct lens_flare_occlusion_test_results local_lens_flare_occlusion_test_results[MAXIMUM_LIGHTS_PER_MAP]; +static byte local_lens_flare_occlusion_test_results2[MAXIMUM_LENS_FLARE_MARKERS_PER_STRUCTURE+MAXIMUM_QUEUED_LENS_FLARES][MAXIMUM_WINDOWS]; +static struct rasterizer_lens_flare_submit_parameters local_lens_flare_parameters[MAXIMUM_LENS_FLARES_PER_FRAME]; +static long local_lens_flare_count; +static struct lens_flare_occlusion_slot lens_flare_occlusion_slots[LENS_FLARE_OCCLUSION_SLOT_COUNT]; +static short local_lens_flare_occlusion_tests[MAXIMUM_LENS_FLARES_PER_FRAME]; +static unsigned long lens_flare_occlusion_frame; + +static struct { boolean draw_lens_flares; } rasterizer_debug_options = { TRUE }; +#define rasterizer_profile_begin(profile) ((void)0) +#define rasterizer_profile_end(profile) ((void)0) +#define screenshot_in_progress() FALSE +/* a frame a tick (the fades move as they do at 30 frames a second) */ +#define main_get_seconds_elapsed() (1.0f / 30.0f) + +/* the fake GPU: a test's count is read two frames after the frame it was +made in, into its index's place, as the native builds read them */ +enum { TEST_INDICES = 4096, LATENCY = 2, MAXIMUM_TESTS = 64 }; +static long known[TEST_INDICES]; +static struct { long index, count; } made[LATENCY + 1][MAXIMUM_TESTS]; +static long made_count[LATENCY + 1]; +static unsigned long frame; +static long rasterizer_widget_get_occlusion_test_result(long index) { return known[index % TEST_INDICES]; } + +#include "under_test.inc" + +/* the flares: a BSP marker each (their visibility in +local_lens_flare_occlusion_test_results2), and how many of their 100 pixels +show */ +enum { FLARES = 6, PIXELS = 100 }; +static long const shown[FLARES] = { 100, 0, 100, 0, 100, 0 }; + +static byte visibility_of(short flare) +{ + return local_lens_flare_occlusion_test_results2[flare + MAXIMUM_QUEUED_LENS_FLARES][0]; +} + +/* one frame: the start of it (last frame's counts used), then the flares in +this order queued and tested, then the GPU reads what it has */ +static void run_frame(short const *order, short count) +{ + short index; + short slot = (short)(frame % (LATENCY + 1)); + + rasterizer_lights_begin_for_new_frame(); + made_count[slot] = 0; + for (index = 0; index < count; index++) + { + struct rasterizer_lens_flare_submit_parameters *parameters = &local_lens_flare_parameters[index]; + short flare = order[index]; + + memset(parameters, 0, sizeof(*parameters)); + /* (a BSP marker's, as rasterizer_lens_flare_submit has them) */ + parameters->light_identifier = NONE; + parameters->light_index = (short)_lens_flare_dynamic_light_flag; + parameters->lens_flare_index = (short)(flare + MAXIMUM_QUEUED_LENS_FLARES); + parameters->compressed_window_index = 0; + local_lens_flare_count = index + 1; + local_lens_flare_occlusion_tests[index] = NONE; + /* (as rasterizer_lens_flares_submit_occlusion_tests tests it) */ + local_lens_flare_occlusion_tests[index] = lens_flare_occlusion_test_index(index); + parameters->internal__occlusion_pixels = PIXELS; + CHECK(made_count[slot] < MAXIMUM_TESTS, "too many tests"); + made[slot][made_count[slot]].index = local_lens_flare_occlusion_tests[index]; + made[slot][made_count[slot]++].count = shown[flare]; + } + /* (at the present: the frame's made LATENCY frames ago is read) */ + if (frame >= LATENCY) + { + short read = (short)((frame - LATENCY) % (LATENCY + 1)); + + for (index = 0; index < made_count[read]; index++) + known[made[read][index].index % TEST_INDICES] = made[read][index].count; + } + frame++; +} + +int main(int argc, char **argv) +{ + const char *case_name = argc > 1 ? argv[1] : ""; + short index; + + /* the flares come into view and go out of it, in a different order each + frame: the visible ones are never put out by a hidden one's count, and + the hidden ones never shown */ + CASE("own-counts") + { + static short const orders[4][FLARES] = { + { 0, 1, 2, 3, 4, 5 }, + { 1, 0, 3, 2, 5, 4 }, + { 3, 4, 5, 0, 1, 2 }, + { 5, 2, 1, 4, 0, 3 }, + }; + static short const counts[4] = { 6, 5, 6, 4 }; + long round; + + for (round = 0; round < 200; round++) + { + run_frame(orders[round % 4], counts[round % 4]); + if (round < 20) + continue; + for (index = 0; index < FLARES; index++) + { + if (shown[index]) + CHECK(visibility_of(index) > 200, "visible flare %d at %d in frame %ld", index, + visibility_of(index), round); + else + CHECK(visibility_of(index) == 0, "hidden flare %d shown at %d in frame %ld", index, + visibility_of(index), round); + } + } + return 0; + } + /* a flare keeps its slot from frame to frame, wherever it is in the list, + and two flares of one frame never share one */ + CASE("stable-slots") + { + static short const first[3] = { 0, 1, 2 }; + static short const second[3] = { 2, 0, 1 }; + short slots[3]; + + run_frame(first, 3); + for (index = 0; index < 3; index++) + slots[first[index]] = local_lens_flare_occlusion_tests[index]; + CHECK(slots[0] != slots[1] && slots[1] != slots[2] && slots[0] != slots[2], "two flares share a slot"); + run_frame(second, 3); + for (index = 0; index < 3; index++) + CHECK(local_lens_flare_occlusion_tests[index] == slots[second[index]], + "flare %d moved from slot %d to %d", second[index], slots[second[index]], + local_lens_flare_occlusion_tests[index]); + return 0; + } + /* a slot just taken holds another flare's counts: it is not read for the + frames until the new flare's own are */ + CASE("settles") + { + static short const one[1] = { 0 }; + short slot; + unsigned long taken; + + run_frame(one, 1); + slot = local_lens_flare_occlusion_tests[0]; + taken = lens_flare_occlusion_slots[slot].taken_frame; + known[slot] = 12345; + for (index = 0; index < LENS_FLARE_OCCLUSION_SLOT_SETTLE_FRAMES; index++) + { + lens_flare_occlusion_frame = taken + (unsigned long)index; + CHECK(lens_flare_occlusion_visible_pixels(0) == NONE, "slot read %d frames after it was taken", index); + } + lens_flare_occlusion_frame = taken + LENS_FLARE_OCCLUSION_SLOT_SETTLE_FRAMES; + CHECK(lens_flare_occlusion_visible_pixels(0) == 12345, "slot not read once settled"); + CHECK(LENS_FLARE_OCCLUSION_SLOT_SETTLE_FRAMES > LATENCY, "settles before the GPU's counts arrive"); + return 0; + } + fprintf(stderr, "unknown case '%s'\n", case_name); + return 2; +} diff --git a/tools/harness/tests/test_lens_flare_occlusion.py b/tools/harness/tests/test_lens_flare_occlusion.py new file mode 100644 index 0000000000..51a81771ef --- /dev/null +++ b/tools/harness/tests/test_lens_flare_occlusion.py @@ -0,0 +1,66 @@ +"""The real lens flare occlusion bookkeeping (rasterizer_lights.c) over a fake GPU that reads counts two frames late: +each flare is read its own test's count, wherever it is in the frame's list, and a slot just taken is not read until +the flare's own counts are.""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, build, enum_with, function, mutated, read, run, structure # noqa: E402 + +CASES = ["own-counts", "stable-slots", "settles"] +FUNCTIONS = ("lens_flare_parameters_get", "lens_flare_occlusion_test_results_get", "lens_flare_occlusion_test_index", + "lens_flare_occlusion_visible_pixels", "rasterizer_lights_begin_for_new_frame") + +# a fault (in config.inc or a function) and the case that must catch it +NEGATIVE_CONTROLS = { + # tested and read at the flare's place in the frame's list: the code before the slots + "tested-by-place": (("\tshort probe;\n\n", "\tshort probe;\n\n\treturn lens_flare_index;\n"), + "lens_flare_occlusion_test_index", "own-counts"), + # read by the flare's place in the frame's list, its test in its slot + "read-by-place": (("return rasterizer_widget_get_occlusion_test_result(test_index);", + "return rasterizer_widget_get_occlusion_test_result(lens_flare_index);"), + "lens_flare_occlusion_visible_pixels", "own-counts"), + # a flare never finds the slot it had + "slot-not-kept": (("if (slot->visibility==visibility && slot->light_identifier==light_identifier)", + "if (FALSE)"), "lens_flare_occlusion_test_index", "stable-slots"), + # a slot just taken read at once + "no-settling": (("LENS_FLARE_OCCLUSION_SLOT_SETTLE_FRAMES = 4", "LENS_FLARE_OCCLUSION_SLOT_SETTLE_FRAMES = 0"), + "config", "settles"), +} + + +def generated(fault=None, where=None): + source = read("source/rasterizer/rasterizer_lights.c") + config = "\n".join([ + enum_with(source, "_lens_flare_first_person_weapon_flag"), + enum_with(source, "_lens_flare_dynamic_light_flag"), + enum_with(source, "LENS_FLARE_OCCLUSION_SLOT_COUNT"), + structure(source, "lens_flare_occlusion_test_results"), + structure(source, "rasterizer_lens_flare_submit_parameters"), + structure(source, "lens_flare_occlusion_slot"), + ]) + "\n" + if fault and where == "config": + config = mutated(config, *fault) + code = "" + for name in FUNCTIONS: + text = function(source, name) + if fault and where == name: + text = mutated(text, *fault) + code += text + "\n" + return (("config.inc", config), ("under_test.inc", code)) + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("lens_flare_occlusion", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, where, case = NEGATIVE_CONTROLS[control] + status, output = run(build("lens_flare_occlusion", generated(fault, where)), case) + assert status == CHECK_FAILED, f"the occlusion code with a fault ({control}) passed '{case}': the test cannot see it" From 83302b278bb4d2a30a34decee9e36e19a376540c Mon Sep 17 00:00:00 2001 From: Guven <185004906+GuvenHuso@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:19:06 +0000 Subject: [PATCH 13/32] Find custom maps in any case and play long entries from the server page Two faults met running a dedicated server on Linux with Custom Edition maps copied from Windows: - A custom map whose file has capitals (cmt_Snow_Grove.map) could not be played. The maps' list names each map in lower case, and the rotation's check then looked for custom_maps/cmt_snow_grove.map exactly, which a case-sensitive file system does not have ("there is no Custom Edition map cmt_snow_grove"). map_file now falls back to the folder's entry of that name in any case (posix_find_entry_case_insensitive), as the game's own platform_translate_path already does when it loads the map. This covers custom_maps, paths.custom_maps and Custom Edition's own folder. - PLAY NOW on the page refused a rotation entry with many rules, such as custom_maps\tsce_multiplayerv1:team_slayer+ff=off+team_vehicles=custom+ ...+vehicle_respawn_time=60 (about 170 characters), with "map is needed (no spaces)". The page sends such an entry whole as the map, and POST /api/rotation/play read it into 128 bytes, though an entry may be 255 characters. The check is now rotation_play_line, which takes a rotation entry's length and says so when a map is longer. The protocol simulator and docs/dedicated_server.md say the same. Nothing the machines send each other changes, so the network version stays. Checked: new harness cases resolve-case (test_server_rotation.py) and rotation-play (test_server_http.py), each with a negative control (custom-maps-case-sensitive, play-entries-short). On Windows the two files' 161 tests passed before; now 164 pass and one is skipped: custom-maps-case-sensitive, as NTFS finds a file in any case (it runs on Linux). server_http.c and server_rotation.c compile clean in the Windows build. The other tests that read these files (test_server_toml.py, test_deploy_gametypes.py, test_server_sim.py) fail the same way here before and after the change. Co-Authored-By: Claude Opus 5.5 --- docs/dedicated_server.md | 2 +- port/linux/src/server_http.c | 38 +++++++++------ port/linux/src/server_rotation.c | 11 +++++ tools/harness/tests/server_http.c | 44 +++++++++++++++++ tools/harness/tests/server_rotation.c | 53 +++++++++++++++++++++ tools/harness/tests/test_server_http.py | 9 ++-- tools/harness/tests/test_server_rotation.py | 14 ++++++ tools/server_sim.py | 5 +- 8 files changed, 155 insertions(+), 21 deletions(-) diff --git a/docs/dedicated_server.md b/docs/dedicated_server.md index 349c945f2b..859d544e00 100644 --- a/docs/dedicated_server.md +++ b/docs/dedicated_server.md @@ -564,7 +564,7 @@ are checked in all. `/api/login` takes `POST` alone (any other method: | `GET /api/rotation` | viewer | `{"ok":true,"index":0,"entries":[…],"next":}` | | `PUT /api/rotation` | admin | `{"entries":["bloodgulch:ctf",…]}` → the rotation, or `400 rejected` with `"error":{…,"rejected":[{"index":1,"entry":"…","reason":"…"}]}` and nothing changed | | `POST /api/rotation/next` | moderator | `{}`: `sv_map_next` | -| `POST /api/rotation/play` | moderator | `{"map":"…","gametype":"…"}`: `sv_map` (a map or gametype that cannot be played: `400 failed`) | +| `POST /api/rotation/play` | moderator | `{"map":"…","gametype":"…"}`: `sv_map` (`map` may be a whole rotation entry, `map:gametype+rule=value`, 255 characters at most, as the page sends one with rules; a map or gametype that cannot be played: `400 failed`) | | `GET /api/gametypes` | viewer | `{"ok":true,"builtin":["slayer",…],"schema":{"fields":[…]},"files":[{"name":…,"base":…,"description":…,"error":null}]}` (`builtin`: the Xbox's gametypes, then OpenCE's; a file that cannot be read has `base` and `description` empty and `error` saying why) | | `GET /api/gametypes/{name}` | viewer | `{"ok":true,"name":…,"text":""}` | | `PUT /api/gametypes/{name}` | admin | `{"text":""}` → `{"ok":true}`, or `400 invalid` with `line` and `message` (a name is `a`–`z`, `0`–`9`, `_` and `-`, 32 at most) | diff --git a/port/linux/src/server_http.c b/port/linux/src/server_http.c index 1bbfc3022a..e927467519 100644 --- a/port/linux/src/server_http.c +++ b/port/linux/src/server_http.c @@ -29,6 +29,7 @@ rate limited, and its password guesses locked out. #include "server_log.h" #include "server_master.h" #include "server_netlist.h" +#include "server_rotation.h" #include "server_toml.h" #include @@ -1905,6 +1906,23 @@ static int body_string(const char *body, int length, const char *key, char *valu return found; } +/* POST /api/rotation/play's sv_map line, from its map (a map's name, or a +rotation entry's whole text, map:gametype+rule=value, as the page sends an +entry with rules: up to a rotation entry's length) and its gametype; NULL, +else why it cannot be played */ +static const char *rotation_play_line(const char *body, int length, char *line, int size) +{ + char map[SERVER_ENTRY_TEXT_SIZE], gametype[64] = ""; + + if (!body_string(body, length, "map", map, sizeof(map)) || !map[0] || strchr(map, ' ') || strchr(map, '"')) + return "map is needed: a map or a rotation entry, 255 characters at most, with no spaces"; + body_string(body, length, "gametype", gametype, sizeof(gametype)); + if (strchr(gametype, ' ') || strchr(gametype, '"')) + return "a gametype holds no space"; + snprintf(line, (size_t)size, "sv_map %s %s", map, gametype); + return NULL; +} + static void serve_asset(struct connection *connection) { const char *path = connection->request.path; @@ -3475,23 +3493,13 @@ static void ticket_api(struct server_ticket *ticket) } else if (!strcmp(path, "/api/rotation/play") && !strcmp(method, "POST")) { - char map[128], gametype[64] = "", line[SERVER_COMMAND_LINE_SIZE]; + char line[SERVER_COMMAND_LINE_SIZE]; + const char *refused = rotation_play_line(body, length, line, sizeof(line)); - if (!body_string(body, length, "map", map, sizeof(map)) || !map[0] || strchr(map, ' ') || strchr(map, '"')) - { - ticket_error(ticket, 400, "schema", "map is needed (no spaces)"); - } + if (refused) + ticket_error(ticket, 400, "schema", refused); else - { - body_string(body, length, "gametype", gametype, sizeof(gametype)); - if (strchr(gametype, ' ') || strchr(gametype, '"')) - ticket_error(ticket, 400, "schema", "a gametype holds no space"); - else - { - snprintf(line, sizeof(line), "sv_map %s %s", map, gametype); - ticket_command(ticket, line); - } - } + ticket_command(ticket, line); } else if (!strcmp(path, "/api/maps") && !strcmp(method, "GET")) { diff --git a/port/linux/src/server_rotation.c b/port/linux/src/server_rotation.c index 278a15265a..21b3f550f7 100644 --- a/port/linux/src/server_rotation.c +++ b/port/linux/src/server_rotation.c @@ -586,6 +586,8 @@ first of its spellings found, into path */ static int map_file(const char *folder, const char *name, char *path, int size) { char xbox_path[512]; + char file_name[300]; + char on_disk[300]; long version, type; if (folder[0] == 'd' && folder[1] == ':') @@ -596,6 +598,15 @@ static int map_file(const char *folder, const char *name, char *path, int size) else { snprintf(path, (size_t)size, "%s/%s.map", folder, name); + /* (a file named in other letters' case, cmt_Snow_Grove.map: the maps' + list names each in lower case, and the game finds it in any case, as + platform_translate_path does) */ + snprintf(file_name, sizeof(file_name), "%s.map", name); + if (!server_map_header(path, &version, &type) && + posix_find_entry_case_insensitive(folder, file_name, on_disk, sizeof(on_disk))) + { + snprintf(path, (size_t)size, "%s/%s", folder, on_disk); + } } return server_map_header(path, &version, &type); } diff --git a/tools/harness/tests/server_http.c b/tools/harness/tests/server_http.c index 1c69883349..64e1389323 100644 --- a/tools/harness/tests/server_http.c +++ b/tools/harness/tests/server_http.c @@ -1894,6 +1894,50 @@ int main(int argc, char **argv) answered.status); return 0; } + /* POST /api/rotation/play's sv_map line: a map and gametype, or a + rotation entry's whole text, which the page sends for an entry with + rules, as long as a rotation entry may be */ + CASE("rotation-play") + { + static const char entry_text[] = "custom_maps/tsce_multiplayerv1:team_slayer+ff=off+team_vehicles=custom+" + "warthogs=4+rocket_warthogs=4+ghosts=4+scorpions=4+banshees=4+gun_turrets=4+vehicle_respawn_time=60"; + char body[1024], line[512], expected[512], longest[SERVER_ENTRY_TEXT_SIZE + 1]; + const char *refused; + + snprintf(body, sizeof(body), "{\"map\":\"bloodgulch\",\"gametype\":\"ctf\"}"); + refused = rotation_play_line(body, (int)strlen(body), line, sizeof(line)); + CHECK(!refused && !strcmp(line, "sv_map bloodgulch ctf"), "a map and gametype: %s", refused ? refused : line); + CHECK(sizeof(entry_text) - 1 > 127, "the entry is %d characters", (int)sizeof(entry_text) - 1); + snprintf(body, sizeof(body), "{\"map\":\"%s\"}", entry_text); + snprintf(expected, sizeof(expected), "sv_map %s ", entry_text); + refused = rotation_play_line(body, (int)strlen(body), line, sizeof(line)); + CHECK(!refused && !strcmp(line, expected), "an entry of %d characters: %s", (int)sizeof(entry_text) - 1, + refused ? refused : line); + /* (a rotation entry's most, 255 characters, and not one more) */ + memset(longest, 'a', SERVER_ENTRY_TEXT_SIZE - 1); + longest[SERVER_ENTRY_TEXT_SIZE - 1] = 0; + snprintf(body, sizeof(body), "{\"map\":\"%s\"}", longest); + refused = rotation_play_line(body, (int)strlen(body), line, sizeof(line)); + CHECK(!refused && strlen(line) == 7 + SERVER_ENTRY_TEXT_SIZE - 1 + 1, "255 characters: %s", + refused ? refused : line); + longest[SERVER_ENTRY_TEXT_SIZE - 1] = 'a'; + longest[SERVER_ENTRY_TEXT_SIZE] = 0; + snprintf(body, sizeof(body), "{\"map\":\"%s\"}", longest); + refused = rotation_play_line(body, (int)strlen(body), line, sizeof(line)); + CHECK(refused && strstr(refused, "255 characters at most"), "256 characters: %s", refused ? refused : line); + /* (the refusals: no map, a space in it or in the gametype) */ + snprintf(body, sizeof(body), "{\"gametype\":\"ctf\"}"); + CHECK(rotation_play_line(body, (int)strlen(body), line, sizeof(line)), "no map was taken"); + snprintf(body, sizeof(body), "{\"map\":\"\"}"); + CHECK(rotation_play_line(body, (int)strlen(body), line, sizeof(line)), "an empty map was taken"); + snprintf(body, sizeof(body), "{\"map\":\"bloodgulch ctf\"}"); + CHECK(rotation_play_line(body, (int)strlen(body), line, sizeof(line)), "a map with a space was taken"); + snprintf(body, sizeof(body), "{\"map\":\"bloodgulch\",\"gametype\":\"ctf sv_stop\"}"); + refused = rotation_play_line(body, (int)strlen(body), line, sizeof(line)); + CHECK(refused && !strcmp(refused, "a gametype holds no space"), "a gametype with a space: %s", + refused ? refused : line); + return 0; + } /* a /ws upgrade counts against its address's allowance, as other requests do: 429 past it */ CASE("ws-allowance") diff --git a/tools/harness/tests/server_rotation.c b/tools/harness/tests/server_rotation.c index e1bfbd9485..f3d83e51a4 100644 --- a/tools/harness/tests/server_rotation.c +++ b/tools/harness/tests/server_rotation.c @@ -68,6 +68,28 @@ void posix_directory_close(void *directory) closedir(directory); } +/* the entry of a folder named as name is, in any letters' case */ +int posix_find_entry_case_insensitive(const char *directory, const char *name, char *result, + unsigned long result_size) +{ + DIR *handle = opendir(directory); + struct dirent *found; + int matched = 0; + + if (!handle) + return 0; + while (!matched && (found = readdir(handle))) + { + if (!strcasecmp(found->d_name, name) && strlen(found->d_name) < result_size) + { + snprintf(result, (size_t)result_size, "%s", found->d_name); + matched = 1; + } + } + closedir(handle); + return matched; +} + #include "under_test.inc" static struct server_entry entry; @@ -752,6 +774,37 @@ int main(int argc, char **argv) CHECK(!access(path, R_OK), "the maps were not written"); return 0; } + /* a Custom Edition map whose file has capitals (cmt_Snow_Grove.map) is + found by the name the maps' list gives it, in lower case, in each of + custom_maps, paths.custom_maps and Custom Edition's own folder */ + CASE("resolve-case") + { + int told = 0; + + make_root(argv[0], case_name); + write_maps(); + write_map("custom_maps/Mixed_Case.map", 609, 1, 0x800, "daeh", "toof"); + server_maps_each(tell_map, &told); + CHECK(strstr(maps_told, "mixed_case custom_maps\\mixed_case custom;"), "%s", maps_told); + CHECK(resolved("mixed_case:ctf"), "mixed_case: %s", entry.rejected); + CHECK(entry.map_kind == _server_map_custom && !strcmp(entry.level, "custom_maps\\mixed_case"), + "kind %d, level %s", entry.map_kind, entry.level); + CHECK(resolved("custom_maps\\mixed_case:ctf+ff=off"), "custom_maps\\mixed_case: %s", entry.rejected); + CHECK(resolved("custom_maps\\MIXED_CASE"), "custom_maps\\MIXED_CASE: %s", entry.rejected); + CHECK(!resolved("custom_maps\\mixed_cases") && entry.map_missing, "a name of no file was found"); + make_folder("elsewhere"); + write_map("elsewhere/Other_Case.map", 609, 1, 0x800, "daeh", "toof"); + snprintf(custom_maps_setting, sizeof(custom_maps_setting), "%s/elsewhere", data_root); + CHECK(resolved("custom_maps\\other_case"), "a map in paths.custom_maps: %s", entry.rejected); + custom_maps_setting[0] = 0; + make_folder("ce"); + make_folder("ce/maps"); + write_map("ce/maps/CE_Case.map", 609, 1, 0x800, "daeh", "toof"); + snprintf(custom_edition_root, sizeof(custom_edition_root), "%s/ce", data_root); + CHECK(resolved("ce_case"), "a map in Custom Edition's folder: %s", entry.rejected); + custom_edition_root[0] = 0; + return 0; + } /* a map file of another game's version is not played as the Xbox's or Custom Edition's (server_maps_each does not list it) */ CASE("resolve-version") diff --git a/tools/harness/tests/test_server_http.py b/tools/harness/tests/test_server_http.py index 463d724d47..144ea4816d 100644 --- a/tools/harness/tests/test_server_http.py +++ b/tools/harness/tests/test_server_http.py @@ -20,7 +20,7 @@ "forwarded", "addresses", "origin", "query", "compare", "frames", "frame-refusals", "fragments", "content-length-duplicate", "output", "pipelining", "address-table", "eviction", "deadlines", "close-order", "bare-lf", "auth-csrf", "auth-denied", "route-roles", "session-expiry", "session-cap", "lockout", "login-budget", - "empty-password", "password-change", "fetch-site", "ws-allowance", "owner-local", + "empty-password", "password-change", "fetch-site", "ws-allowance", "owner-local", "rotation-play", ] FUNCTIONS = ( "sha1_rotate", "sha1", "text_equal_case", "constant_time_equal", "address_text", "parse_address", "hex_digit_value", @@ -28,7 +28,8 @@ "query_number", "connection_is_local", "address_record", "address_allows_request", "address_locked", "address_login_failed", "session_hash", "session_find", "session_new", "passwords_check", "password_role", "request_host", - "origin_matches", "ws_frame", "ws_close", "ws_read_frames", "request_session", "body_string", "serve_login", + "origin_matches", "ws_frame", "ws_close", "ws_read_frames", "request_session", "body_string", "rotation_play_line", + "serve_login", "route_needs_game", "route_role", "handle_request", "server_http_capabilities", "handle_upgrade", "connection_take", "connection_to_evict", "connection_overdue", @@ -199,6 +200,7 @@ "if (owner_elsewhere)"), "password_role", "owner-local"), "owner-password-change-unseen": (('"server.web_owner_password" };', '"server.web_password" };'), "passwords_check", "owner-local"), + "play-entries-short": (("char map[SERVER_ENTRY_TEXT_SIZE]", "char map[128]"), "rotation_play_line", "rotation-play"), } @@ -211,7 +213,8 @@ def generated(fault=None, faulty_function=None): config += enum_with(source, "_ticket_api") + "\n" config += enum_with(read("port/linux/src/server_console.h"), "_server_role_none") + "\n" config += f"enum {{ P2P_SHA256_SIZE = {constant(read('port/linux/src/p2p_internal.h'), 'P2P_SHA256_SIZE')}, " - config += f"SERVER_EVENT_GROUP_SIZE = {constant(read('port/linux/src/server_events.h'), 'SERVER_EVENT_GROUP_SIZE')} }};\n" + config += f"SERVER_EVENT_GROUP_SIZE = {constant(read('port/linux/src/server_events.h'), 'SERVER_EVENT_GROUP_SIZE')}, " + config += f"SERVER_ENTRY_TEXT_SIZE = {constant(read('port/linux/src/server_rotation.h'), 'SERVER_ENTRY_TEXT_SIZE')} }};\n" config += "struct push;\n" for name in STRUCTURES: config += structure(source, name) + "\n" diff --git a/tools/harness/tests/test_server_rotation.py b/tools/harness/tests/test_server_rotation.py index 151cb95143..4dd20abdb3 100644 --- a/tools/harness/tests/test_server_rotation.py +++ b/tools/harness/tests/test_server_rotation.py @@ -3,6 +3,7 @@ maps found on disk from small cache file headers the test writes.""" import sys +import tempfile from pathlib import Path import pytest @@ -17,6 +18,7 @@ "long-map-level", "rotation-cut-entry", "resolve-version", + "resolve-case", ] # a fault in server_rotation.c and the case that must catch it @@ -52,7 +54,11 @@ "missing-unflagged": (("entry->map_name, entry->map_name, entry->map_name);\n\t\tentry->map_missing = 1;", "entry->map_name, entry->map_name, entry->map_name);"), "resolve"), "campaign-listed": (("type != 1 ||", ""), "maps-each"), + "custom-maps-case-sensitive": (("posix_find_entry_case_insensitive(folder, file_name, on_disk, sizeof(on_disk))", + "0"), "resolve-case"), } +# (controls a case-insensitive file system cannot see: it finds Mixed_Case.map by any spelling) +CASE_CONTROLS = {"custom-maps-case-sensitive"} def generated(fault=None): @@ -71,8 +77,16 @@ def test_case(case): assert status == 0, output +def case_insensitive_files(): + with tempfile.TemporaryDirectory() as folder: + Path(folder, "Case").touch() + return Path(folder, "case").exists() + + @pytest.mark.parametrize("control", NEGATIVE_CONTROLS) def test_negative_control(control): + if control in CASE_CONTROLS and case_insensitive_files(): + pytest.skip("this file system finds a file in any case") fault, case = NEGATIVE_CONTROLS[control] status, output = run(build("server_rotation", generated(fault)), case) assert status == CHECK_FAILED, f"server_rotation.c with a fault ({control}) passed '{case}': the test cannot see it" diff --git a/tools/server_sim.py b/tools/server_sim.py index f782eb5af0..0c2bdebd2f 100644 --- a/tools/server_sim.py +++ b/tools/server_sim.py @@ -2472,8 +2472,9 @@ def h_rotation_next(api, request, session, body): @route("POST", r"/api/rotation/play", "moderator") def h_rotation_play(api, request, session, body): map_text = body.get("map") - if not isinstance(map_text, str) or not map_text or " " in map_text or '"' in map_text: - raise CommandError("schema", "map is needed (no spaces)", field="map") + if not isinstance(map_text, str) or not map_text or len(map_text) > 255 or " " in map_text or '"' in map_text: + raise CommandError("schema", "map is needed: a map or a rotation entry, 255 characters at most, with no spaces", + field="map") gametype = body.get("gametype") if isinstance(body.get("gametype"), str) else "" if " " in gametype or '"' in gametype: raise CommandError("schema", "a gametype holds no space", field="gametype") From 0e7233f3b1c01f669fbd070c4b07a1e53ba869e2 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 4 Nov 2026 00:14:29 +0100 Subject: [PATCH 14/32] Center an object's shadow where the frame draws it between ticks Driving a vehicle, its shadow's trailing side flickered all the time, most of all a Ghost's. With Smooth Motion (display.interpolation) each frame draws the world between the last two ticks, and the model drawn into an object's shadow map is drawn so (object_get_node_matrices), but the map was centered at the object's bounding sphere as the latest tick left it (object_get_bounding_sphere). A fast vehicle stood up to a tick's travel off its map's center, by as much as each frame's blend, and the map's edge and corner fade cut its shadow's trailing side by that much, frame by frame. render_object_shadow_begin now moves the center to where the frame draws the object (render_interpolation_object_point, as the nav points do). With Smooth Motion off, or on a tick, nothing changes; only the drawing changes, nothing the machines send. Checked: the new harness test test_shadow_center.py passes, built with clang on Windows, and fails with the center left at the tick; in the game on Windows, a Ghost's shadow no longer flickers while driving. A point of the shadow that now and then goes missing at speed does so with Smooth Motion off too, and on the Xbox (checked in xemu): the original's corner fade. Co-Authored-By: Claude Opus 5.5 --- port/linux/README.md | 1 + source/render/render_objects.c | 7 ++ tools/harness/tests/shadow_center.c | 111 ++++++++++++++++++++++ tools/harness/tests/test_shadow_center.py | 41 ++++++++ 4 files changed, 160 insertions(+) create mode 100644 tools/harness/tests/shadow_center.c create mode 100644 tools/harness/tests/test_shadow_center.py diff --git a/port/linux/README.md b/port/linux/README.md index 3aa9192b79..4a9dde842d 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -1849,6 +1849,7 @@ Other changes: | `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. | | `interface/first_person_weapons.c` | The first-person hands are drawn with the unit's shield modifier shader, as its body is (`display.viewmodel_shield`). | | `render/render_objects.c`, `objects/objects.c` | In first person, your own body is drawn from the waist down with the pose `first_person_legs.c` makes (`display.first_person_legs`); the waist it found and the mesh `object_mesh.c` read are forgotten on a new map. | +| `render/render_objects.c` | An object's shadow map is centered where the frame draws the object between the ticks (`display.interpolation`, `render_interpolation_object_point`), as its model is drawn into the map, not where the tick left it: a fast vehicle (a Ghost) stood up to a tick's travel off its map's center, and the map's edge and corner fade cut its shadow's trailing side, flickering. | | `objects/object_lights.c` | Glows the port lights itself (`shield_glow.c`): dynamic lights attached to an object's node, colored and sized each frame by their maker, drawn with a light definition the map has. | | `render/render_objects.c`, `interface/first_person_weapons.c`, `rasterizer/xbox/rasterizer_xbox_plasma_energy.c`, `items/projectiles.c` | An overshield as `overshield_effect.c` draws it (`display.overshield_effect`): in its own color, its intensity full, its energy slower, faded above the height it has risen to; a hit on it plays the projectile's effect for an Elite's energy shield in place of its own (only the effect: the projectile's response is its own). | | `render/render_objects.c`, `interface/first_person_weapons.c`, `rasterizer/xbox/rasterizer_xbox_plasma_energy.c` | A local player's shield plasma is drawn in the chosen armor color (`display.shield_color`, `shield_color.c`), on the body and on the first-person arms. | diff --git a/source/render/render_objects.c b/source/render/render_objects.c index 212348c5b7..022195bbed 100644 --- a/source/render/render_objects.c +++ b/source/render/render_objects.c @@ -796,6 +796,13 @@ static boolean render_object_shadow_begin( real shadow_intensity = level_of_detail; object_get_bounding_sphere(data->object_index, ¢er, &radius); + /* port: where this frame draws the object, between the ticks + (render_interpolation.c), not where the tick left it: the shadow map is + centered there and the model drawn into it as drawn, so a fast vehicle + (a Ghost) stood up to a tick's travel off its map's center, and the map's + edge and corner fade cut its shadow's trailing side by as much as each + frame's blend, flickering */ + render_interpolation_object_point(data->object_index, ¢er); perpendicular3d(&data->lighting->shadow_vector, &shadow_right); normalize3d(&shadow_right); diff --git a/tools/harness/tests/shadow_center.c b/tools/harness/tests/shadow_center.c new file mode 100644 index 0000000000..9ffbd01c41 --- /dev/null +++ b/tools/harness/tests/shadow_center.c @@ -0,0 +1,111 @@ +/* +SHADOW_CENTER.C (test) + +The real render_object_shadow_begin (render_objects.c): an object's shadow map +is centered where the frame draws the object (render_interpolation.c, Smooth +Motion's blend between the ticks), as its model is drawn into the map, not +where the tick left it. Centered at the tick, a fast vehicle (a Ghost) stood +up to a tick's travel off its map's center, and the map's edge and corner fade +cut its shadow's trailing side, flickering frame by frame. +test_shadow_center.py takes the structure and the code (under_test.inc). +*/ + +#include "harness.h" + +typedef unsigned short word; +typedef struct { real scale; real_vector3d forward, left, up; real_point3d position; } real_matrix4x3; +union real_rgb_color { struct { real red, green, blue; }; real n[3]; }; +struct render_lighting { real_vector3d shadow_vector; union real_rgb_color shadow_color; }; + +#include "config.inc" + +/* the object, a vehicle: not a unit with camouflage */ +enum { _object_mask_unit = 3 }; +struct object_datum { struct { long type; } object; }; +struct unit_datum { struct { real active_camouflage; } unit; }; +static struct object_datum object_data; +static struct unit_datum unit_data; +#define object_get(object_index) (&object_data) +#define unit_get(object_index) (&unit_data) + +/* where the tick left the object's center, and how far this frame draws it +from there (render_interpolation_object_point) */ +static real_point3d tick_center = { 10.0f, 20.0f, 3.0f }; +static real_vector3d drawn_offset; +static void object_get_bounding_sphere(long object_index, real_point3d *center, real *radius) +{ + *center = tick_center; + *radius = 1.5f; +} +static void render_interpolation_object_point(long object_index, real_point3d *point) +{ + point->x += drawn_offset.i; + point->y += drawn_offset.j; + point->z += drawn_offset.k; +} + +static void perpendicular3d(real_vector3d const *vector, real_vector3d *perpendicular) +{ + perpendicular->i = 1.0f; + perpendicular->j = 0.0f; + perpendicular->k = 0.0f; +} +#define normalize3d(vector) ((void)0) +static void matrix4x3_from_point_and_vectors(real_matrix4x3 *matrix, real_point3d const *point, + real_vector3d const *forward, real_vector3d const *up) +{ + matrix->position = *point; +} + +/* the shadow map's center, as the rasterizer is given it */ +static real_point3d map_center; +static boolean rasterizer_environment_shadow_begin(long object_index, real_matrix4x3 const *matrix, + union real_rgb_color const *color, real radius, real *bounding_radius) +{ + map_center = matrix->position; + *bounding_radius = radius; + return TRUE; +} + +#include "under_test.inc" + +static boolean near(real a, real b) { return a - b < 0.0001f && b - a < 0.0001f; } + +int main(int argc, char **argv) +{ + const char *case_name = argc > 1 ? argv[1] : ""; + struct render_lighting lighting = { { 0.0f, 0.0f, -1.0f }, { { 0.5f, 0.5f, 0.5f } } }; + struct object_render_data data; + + memset(&data, 0, sizeof(data)); + data.object_index = 7; + data.lighting = &lighting; + + /* a frame between ticks draws the vehicle half a world unit back from + the tick: the map is centered there */ + CASE("centered-where-drawn") + { + drawn_offset.i = -0.5f; + drawn_offset.j = 0.25f; + CHECK(render_object_shadow_begin(&data, 1.0f), "the shadow was not begun"); + CHECK(near(map_center.x, 9.5f) && near(map_center.y, 20.25f) && near(map_center.z, 3.0f), + "the shadow map is centered at %g %g %g, not where the vehicle is drawn (9.5 20.25 3)", + map_center.x, map_center.y, map_center.z); + CHECK(near(data.shadow_matrix.position.x, 9.5f) && near(data.shadow_matrix.position.y, 20.25f), + "the shadow's projection is centered at %g %g, not where the map is", + data.shadow_matrix.position.x, data.shadow_matrix.position.y); + return 0; + } + /* drawn at the tick (Smooth Motion off, or a frame on a tick): the tick's + center, as on the Xbox */ + CASE("at-the-tick") + { + CHECK(render_object_shadow_begin(&data, 1.0f), "the shadow was not begun"); + CHECK(near(map_center.x, 10.0f) && near(map_center.y, 20.0f) && near(map_center.z, 3.0f), + "the shadow map is centered at %g %g %g, not the tick's 10 20 3", map_center.x, map_center.y, + map_center.z); + return 0; + } + fprintf(stderr, "unknown case '%s'\n", case_name); + return 2; +} diff --git a/tools/harness/tests/test_shadow_center.py b/tools/harness/tests/test_shadow_center.py new file mode 100644 index 0000000000..92389df9d3 --- /dev/null +++ b/tools/harness/tests/test_shadow_center.py @@ -0,0 +1,41 @@ +"""The real render_object_shadow_begin (render_objects.c): an object's shadow map is centered where the frame draws the +object between the ticks, as its model is drawn into the map, not where the tick left it.""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, build, function, mutated, read, run, structure # noqa: E402 + +CASES = ["centered-where-drawn", "at-the-tick"] + +# a fault in render_object_shadow_begin and the case that must catch it +NEGATIVE_CONTROLS = { + # centered where the tick left the object, as before + "centered-at-the-tick": (("render_interpolation_object_point(data->object_index, ¢er);", ""), + "centered-where-drawn"), +} + + +def generated(fault=None): + source = read("source/render/render_objects.c") + config = structure(source, "object_render_data") + "\n" + code = function(source, "render_object_shadow_begin") + if fault: + code = mutated(code, *fault) + return (("config.inc", config), ("under_test.inc", code + "\n")) + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("shadow_center", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, case = NEGATIVE_CONTROLS[control] + status, output = run(build("shadow_center", generated(fault)), case) + assert status == CHECK_FAILED, f"the shadow with a fault ({control}) passed '{case}': the test cannot see it" From be2d27c7e72bba200b1f75e176a9b5bfeb3a6f49 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 4 Nov 2026 02:18:46 +0100 Subject: [PATCH 15/32] Draw a tick's camera shake on every frame until the next tick Firing shook the view less at high frame rates, and the assault rifle hardly at all. A shake or impulse just started is drawn at its full scale once (its just-started flag is cleared by the draw), and each draw counts its time left down by the ticks since the last; the Xbox drew a frame a tick, so the full scale showed for a tick and each later scale for a tick. With Smooth Motion the frames between ticks drew the scale after the tick's, and the full scale only on the tick's first frame: an eighth of the tick at 240 frames a second, and the rifle's short kick was over before it could be seen. player_effect_get_camera_effect_matrix now keeps each local player's effect as the first frame after a tick made it, and the frames after it until the next tick draw it again; the continuous shake looping sounds add each frame is still used up, as those frames would. Its direction was already the tick's (the seed made from the tick). With one frame a tick nothing changes; only the drawing changes, nothing the machines send. Checked: the Windows release build compiles without warnings; in the game on Windows (RTX 5070 Ti, Smooth Motion, high frame rate), firing the assault rifle and the sniper rifle shakes the view as with Smooth Motion off. Co-Authored-By: Claude Opus 5.5 --- port/linux/README.md | 1 + source/effects/player_effects.c | 43 +++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/port/linux/README.md b/port/linux/README.md index 3aa9192b79..b0cc00b38b 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -1825,6 +1825,7 @@ Other changes: | `rasterizer/xbox/rasterizer_xbox_environment_fog.c` | A local pointer gets its value from the file-scope array with the same name. | | `game/player_control.c` | The mouse aims the player on controller 1 directly. | | `sound/game_sound.c` | The game calculates the obstruction of each sound one time for each tick, not for each frame. | +| `effects/player_effects.c` | A camera shake or impulse is drawn as the first frame after a tick made it by every frame until the next tick, as the Xbox drew it for the tick: its full scale showed only on that first frame, an eighth of the tick at 240 frames a second, so firing shook the view a fraction as much, and the assault rifle's short kick hardly at all. | | `sound/sound_manager.c` | A track of a looping sound that is gone stops, even in the millisecond it started (`refresh_sound`). The Xbox never drew three frames in one millisecond; the port can (the null renderer, no frame limit), and then read the deleted looping sound and crashed. | | `cseries/errors.c` | `debug.txt` stays open between lines. A line that is the same as the line before it is counted, not written again (`last line repeated N times`). At 64 MB, `debug.txt` becomes `debug.old.txt` and a new `debug.txt` starts. | | `networking/`, `game/`, `interface/`, `bungie_net/network/` and the pools of objects, effects and sounds | The system link limits and the memory for them. | diff --git a/source/effects/player_effects.c b/source/effects/player_effects.c index 196aa300bf..8f2dddc2b2 100644 --- a/source/effects/player_effects.c +++ b/source/effects/player_effects.c @@ -300,6 +300,15 @@ static void get_shake_matrix( static struct player_effect_globals_definition *player_effect_globals; +/* port: each local player's camera effect as the first frame of a tick made +it (the tick plus one, 0 for none), which the frames after it until the next +tick draw again (player_effect_get_camera_effect_matrix) */ +static struct +{ + long tick; + real_matrix4x3 matrix; +} camera_effect_of_tick[MAXIMUM_LOCAL_PLAYERS]; + static short render_screen_flash_type_map[NUMBER_OF_SCREEN_FLASH_TYPES] = { _render_screen_flash_type_none, @@ -346,6 +355,8 @@ void player_effect_initialize_for_new_map( csmemset(player_effect_globals, 0, sizeof(struct player_effect_globals_definition)); player_effect_globals->screen_fade.ticks = NONE; player_effect_globals->reference_time = game_time_get(); + /* port: (player_effect_get_camera_effect_matrix) */ + csmemset(camera_effect_of_tick, 0, sizeof(camera_effect_of_tick)); return; } @@ -906,6 +917,32 @@ void player_effect_get_camera_effect_matrix( *local_seed = tick_seed; game_time_get(); + /* port: the Xbox drew a frame a tick, so a shake or impulse just + started showed at its full scale for a tick, and each later tick's + scale for a tick. A frame between ticks drew the scale after the + tick's (its time left counted down by the tick's first frame), and + the full scale only on that first frame: at 240 frames a second an + eighth of the tick, so firing shook the view a fraction as much, the + assault rifle's short kick hardly at all. The frames after a tick's + first draw its effect again, as the Xbox drew it for the tick; the + continuous shake looping sounds add each frame is still used up, as + that frame would (game_sound.c). */ + if (local_player_index >= 0 && local_player_index < MAXIMUM_LOCAL_PLAYERS && + camera_effect_of_tick[local_player_index].tick == game_time_get() + 1) + { + struct player_effect_datum *effect = player_effect_get(local_player_index); + + *matrix = camera_effect_of_tick[local_player_index].matrix; + if (!TEST_FLAG(player_effect_globals->global_flags, _scripted_player_effect_active_bit) && + effect->camera_shake_time_left > 0) + { + effect->continuous_effect_timer = 0; + csmemset(&effect->continuous_effect, 0, sizeof(effect->continuous_effect)); + } + *local_seed = saved_local_seed; + return; + } + if (TEST_FLAG(player_effect_globals->global_flags, _scripted_player_effect_active_bit)) { struct scripted_player_effect_definition *scripted_effect = @@ -1120,6 +1157,12 @@ void player_effect_get_camera_effect_matrix( matrix4x3_multiply(matrix, &effect_matrix, matrix); } } + /* port: (above) */ + if (local_player_index >= 0 && local_player_index < MAXIMUM_LOCAL_PLAYERS) + { + camera_effect_of_tick[local_player_index].tick = game_time_get() + 1; + camera_effect_of_tick[local_player_index].matrix = *matrix; + } *local_seed = saved_local_seed; } From 2d1bc04502c470d5f93f85a740dca00c8f8d6829 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 4 Nov 2026 02:18:58 +0100 Subject: [PATCH 16/32] Age contrail points a tick at a time The Shade's and the Spirit's plasma contrails were shorter and weaker at high frame rates. A contrail point steps through its states, and a state that ends drops the rest of the step's time (contrail_update_ points), so on the Xbox, a frame a tick, each state lasted at least a tick. The Shade's gun (the Spirit's fires the same projectile, vehicles\ c gun turret\c gun turret) has points of states of 0.01, 0.05 and 0.005 seconds: they lived four ticks, 133 ms, there, and aged every frame at 240 frames a second about half that, the trail half as long. contrails_update ages the points on the first frame after each tick, for all the time since, as it already emitted new points there; the texture animation stays every frame. With one frame a tick nothing changes; only the drawing changes, nothing the machines send. Checked: the Windows release build compiles without warnings; in the game on Windows (RTX 5070 Ti, high frame rate), the Shade's and the Spirit's contrails are as long as the Xbox's. Co-Authored-By: Claude Opus 5.5 --- port/linux/README.md | 1 + source/effects/contrails.c | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/port/linux/README.md b/port/linux/README.md index 3aa9192b79..f6b72416b6 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -1827,6 +1827,7 @@ Other changes: | `sound/game_sound.c` | The game calculates the obstruction of each sound one time for each tick, not for each frame. | | `sound/sound_manager.c` | A track of a looping sound that is gone stops, even in the millisecond it started (`refresh_sound`). The Xbox never drew three frames in one millisecond; the port can (the null renderer, no frame limit), and then read the deleted looping sound and crashed. | | `cseries/errors.c` | `debug.txt` stays open between lines. A line that is the same as the line before it is counted, not written again (`last line repeated N times`). At 64 MB, `debug.txt` becomes `debug.old.txt` and a new `debug.txt` starts. | +| `effects/contrails.c` | Contrail points age a tick at a time, on the first frame after each tick, as on the Xbox, where each of a point's states lasted at least a frame: a Shade's or a Spirit's points (states of 0.01, 0.05 and 0.005 seconds) lived 133 ms there, and about half that aged each frame at 240 frames a second. | | `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`), adds the tags of the menus to the menus' map (`game/menu_tags.c`), and plays the mods' tags (`game/mods.c`). | diff --git a/source/effects/contrails.c b/source/effects/contrails.c index 59529eec1e..3153d04907 100644 --- a/source/effects/contrails.c +++ b/source/effects/contrails.c @@ -465,7 +465,14 @@ void contrails_update( texture_animation_v = texture_animation_v * contrail->density; contrail->texture_offset_v = texture_animation_v * frame_dt + contrail->texture_offset_v; - contrail_update_points(contrail_index, dt); + /* port: the points age a tick at a time too, as on the Xbox, which + drew a frame a tick: a point's state that ends drops the rest of the + frame's time (contrail_update_points), so each state lasted at least a + tick. A Shade's or a Spirit's points (states of 0.01, 0.05 and 0.005 + seconds) lived four ticks, 133 ms, and aged a frame at a time at 240 + frames a second they lived about half that, the trail half as long */ + if (point_dt > 0.0f) + contrail_update_points(contrail_index, point_dt); for (instance_index = 0; instance_index < MAXIMUM_CONTRAIL_INSTANCES; From ced1e1f2de8b9e2cba3f69b1df7700b11dd3ef31 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 4 Nov 2026 02:19:07 +0100 Subject: [PATCH 17/32] Step a flag's cloth a tick at a time and draw it with its pole The capture the flag flag did not behave as on the Xbox at high frame rates. Each step of its cloth (flag_update) moves every vertex to where its neighbours hold it, a relaxation whose stiffness is in the number of steps, and the native builds stepped it every frame: eight steps a tick at 240 frames a second held it eight times as stiff, and it hung and flapped unlike the Xbox's, which stepped a frame a tick. And the cloth hangs from its object's markers where the tick left them, while the frame draws the object between the ticks, so a running carrier's flag rode ahead of its pole. flags_update steps the cloth on the first frame after each tick, for all the time since the last step, and flag_render_proper draws it (its vertices and centroid) as far from there as its object is drawn (render_interpolation_object_point). With one frame a tick nothing changes; only the drawing changes, nothing the machines send. Checked: the Windows release build compiles without warnings; in the game on Windows (RTX 5070 Ti, high frame rate), a capture the flag flag hangs and flaps as on the Xbox, standing and carried. Co-Authored-By: Claude Opus 5.5 --- port/linux/README.md | 1 + source/objects/widgets/flags.c | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/port/linux/README.md b/port/linux/README.md index 3aa9192b79..b082772104 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -1846,6 +1846,7 @@ Other changes: | `models/models.c` | A model's size, which chooses its level of detail and whether it is drawn, is scaled as `display.model_detail` says (`halo_model_detail_scale`, `src/d3d8_gl.c`), and each permutation draws the level `game/model_detail.c` chooses from the model's (stock: the model's). | | `interface/hud.c` | In multiplayer, players' names are drawn above their heads (`display.player_names`, `display.player_name_scale`). | | `text/draw_string.c`, `interface/terminal.c`, `interface/virtual_keyboard.c` | Streamer mode's words are hidden in every string drawn or measured (`game/text_filter.c`), but in the console's line and the virtual keyboard's text being typed. | +| `objects/widgets/flags.c` | A flag's cloth steps a tick at a time, as on the Xbox, not every frame (eight steps a tick at 240 frames a second held it eight times as stiff), and is drawn as far from where its object stood after the tick as the object is drawn (`render_interpolation_object_point`), so that it stays on a running carrier's pole. | | `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. | | `interface/first_person_weapons.c` | The first-person hands are drawn with the unit's shield modifier shader, as its body is (`display.viewmodel_shield`). | | `render/render_objects.c`, `objects/objects.c` | In first person, your own body is drawn from the waist down with the pose `first_person_legs.c` makes (`display.first_person_legs`); the waist it found and the mesh `object_mesh.c` read are forgotten on a new map. | diff --git a/source/objects/widgets/flags.c b/source/objects/widgets/flags.c index 62ef73c1d8..78ec14d74c 100644 --- a/source/objects/widgets/flags.c +++ b/source/objects/widgets/flags.c @@ -264,6 +264,23 @@ void flags_update( real delta) { long flag_index; + /* port: the cloth steps a tick at a time, as on the Xbox, which drew a + frame a tick. Each step moves every vertex to where its neighbours hold + it (flag_update), and the native builds stepped every frame, several a + tick (port/linux/game/render_interpolation.c): eight steps a tick at 240 + frames a second held the cloth eight times as stiff, and it hung and + flapped unlike the Xbox's. Step on the first frame after each tick, for + all the time since the last step; the frames between draw it where its + object is drawn (flag_render_proper). */ + static long last_step_game_time = NONE; + static real pending_delta = 0.0f; + + pending_delta += delta; + if (last_step_game_time == game_time_get()) + return; + last_step_game_time = game_time_get(); + delta = pending_delta; + pending_delta = 0.0f; for (flag_index = data_next_index(flag_data, NONE); flag_index != NONE; @@ -769,6 +786,17 @@ void flag_render_proper( short i; short x; short y; + /* port: the cloth hangs from its object's markers where the tick + left them (flags_update), and the frame draws the object between the + ticks (render_interpolation.c): drawn as far from there as the object + is, so that it stays on a running carrier's pole */ + real_point3d drawn = flag->first_attachment; + real_vector3d drawn_offset; + + render_interpolation_object_point(flag->object_index, &drawn); + drawn_offset.i = drawn.x - flag->first_attachment.x; + drawn_offset.j = drawn.y - flag->first_attachment.y; + drawn_offset.k = drawn.z - flag->first_attachment.z; object_definition_get(object_get(flag->object_index)->definition_index); vertices = rasterizer_dynamic_vertices_lock(vertex_buffer_index); @@ -796,6 +824,10 @@ void flag_render_proper( normalize3d(&vertex.normal); vertex.position = point->position; + /* port: (drawn_offset, above) */ + vertex.position.x += drawn_offset.i; + vertex.position.y += drawn_offset.j; + vertex.position.z += drawn_offset.k; vertex.texcoord.x = x * one_over_width; vertex.texcoord.y = y * one_over_height; rasterizer_geometry_compress_vertices( @@ -912,6 +944,10 @@ void flag_render_proper( corner00->position.y) * 0.25f; centroid.z = (corner11->position.z + corner01->position.z + corner10->position.z + corner00->position.z) * 0.25f; + /* port: (drawn_offset, above) */ + centroid.x += drawn_offset.i; + centroid.y += drawn_offset.j; + centroid.z += drawn_offset.k; csmemset(¶meters, 0, sizeof(parameters)); parameters.unique_identifier = 1; From 8b0e740c659f11db62aa77dc423f073db87bec1b Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 4 Nov 2026 02:19:15 +0100 Subject: [PATCH 18/32] Shine an empty-handed player's flashlight from the drawn pose With no weapon in hand, your flashlight stepped at 30 a second while the view moved every frame. A flashlight on a first-person weapon is placed at the weapon's flashlight marker, posed against the drawn camera each frame (first_person_weapon_center_flashlight); with none, the light stayed where the tick left it. The port already moved another player's beam, in a match, to the marker on the pose the frame draws, but only in a network game. Any flashlight not on a first-person weapon now shines from that marker, in a local game too. Only the drawing changes, nothing the machines send. Checked: the Windows release build compiles without warnings; in the game on Windows (RTX 5070 Ti, high frame rate), the flashlight with no weapon in hand (The Pillar of Autumn's start) moves with the view. Co-Authored-By: Claude Opus 5.5 --- port/linux/README.md | 1 + source/objects/object_lights.c | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/port/linux/README.md b/port/linux/README.md index 3aa9192b79..6541832f43 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -1856,6 +1856,7 @@ Other changes: | `rasterizer/xbox/rasterizer_xbox_decals.c` | Decals drawn one after another with the same blend function and bitmap are one draw, with each decal's color in its vertices, not a draw each: a long fight leaves hundreds in view. Within a run of decals whose blend functions commute (multiply, add, min, max, reverse subtract), they are drawn by bitmap, so more of them share a draw. | | `rasterizer/xbox/rasterizer_xbox_widgets.c`, `objects/widgets/light_volumes.c` | A light volume's sprites (a plasma bolt's glow, a couple of hundred) are one draw. | | `rasterizer/rasterizer.h` | A frame holds 2048 transparent geometry groups, not 384 (`include/halo_port_capacity.h`): a co-op game with many added enemies asked for more, and the Xbox's pool drew the frame without the rest. | +| `objects/object_lights.c` | A flashlight not on a first-person weapon (your own with no weapon in hand, as another player's in a match) shines from the marker on the pose the frame draws (`render_interpolation.c`), not from where the tick left it, which stepped at 30 a second. | | `structures/structure_render.c` | The structure's visible surfaces are put in order by a radix sort, not `qsort_4byte`: the same order. | | `cache/cache_files.c`, `networking/network_client_manager.c`, `networking/network_server_manager.c`, `networking/network_server_message_handler.c`, `main/main.c`, `interface/interface.c` | Map sharing (`game/map_share.c`): a client that does not have the game's Custom Edition map asks the host for it instead of leaving, is not given time to precache it until it has it, and adds its players to a game in progress only then; the host gives the game's map its own name and its version (its hash's start), takes the requests from any machine in the game, lobby too, and does not drop a machine that downloads; the transfers run each frame, and their progress is drawn over the screen. | | `cutscene/cinematics.c` | A scenario's titles (some Custom Edition maps show their name for the whole game) are not drawn over a menu, as the letterbox is not: the screen's overlays are drawn after the players' menus. | diff --git a/source/objects/object_lights.c b/source/objects/object_lights.c index 714d904b6b..4050b5dce1 100644 --- a/source/objects/object_lights.c +++ b/source/objects/object_lights.c @@ -1189,10 +1189,13 @@ void lights_preprocess_scene( &light_parameters.forward, &light_parameters.up); - /* port: another player's beam, in a match, from - the marker on the pose the frame draws: the light's - own place is the tick's, so it stepped */ - if (!centered && game_connection() != _game_connection_local) + /* port: a beam not on a first-person weapon + (another player's, or your own with no weapon in + hand), from the marker on the pose the frame draws + (render_interpolation.c): the light's own place is + the tick's, so it stepped at 30 a second while the + view moved every frame */ + if (!centered) { struct object_marker markers[1]; From 2a2dfdb3b701ab5fe3d506b807e3109fc43b5f9b Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 4 Nov 2026 02:32:12 +0100 Subject: [PATCH 19/32] Age contrail points a step a tick below 30 frames a second too Aging the points on the first frame after each tick, for all the time since, kept them as the Xbox's at 30 frames a second and above, where a frame has one tick at most. Below 30 a frame can have two ticks, and the points aged both in one step: a state that ends drops the rest of the step's time, so they lived longer, the trail brighter, than the Xbox's. They now age a step for each tick since the last aging, each a tick's share of the time, as the Xbox's frames aged them, at any frame rate. Checked: the Windows release build compiles without warnings. Co-Authored-By: Claude Opus 5.5 --- source/effects/contrails.c | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/source/effects/contrails.c b/source/effects/contrails.c index 3153d04907..f3f84adfa6 100644 --- a/source/effects/contrails.c +++ b/source/effects/contrails.c @@ -376,10 +376,14 @@ void contrails_update( static long last_point_game_time = NONE; static real pending_point_dt = 0.0f; real point_dt; + /* port: the ticks the points age over (contrail_update_points, below) */ + long point_ticks = 0; pending_point_dt += dt; if (last_point_game_time != game_time_get()) { + point_ticks = last_point_game_time == NONE ? 1 : game_time_get() - last_point_game_time; + point_ticks = PIN(point_ticks, 1, TICKS_PER_SECOND); last_point_game_time = game_time_get(); point_dt = pending_point_dt; pending_point_dt = 0.0f; @@ -470,9 +474,16 @@ void contrails_update( frame's time (contrail_update_points), so each state lasted at least a tick. A Shade's or a Spirit's points (states of 0.01, 0.05 and 0.005 seconds) lived four ticks, 133 ms, and aged a frame at a time at 240 - frames a second they lived about half that, the trail half as long */ + frames a second they lived about half that, the trail half as long and + dim; below 30 frames a second, a frame of two ticks aged them in one + step, and they lived longer. A step for each tick since the last */ if (point_dt > 0.0f) - contrail_update_points(contrail_index, point_dt); + { + long tick; + + for (tick = 0; tick < point_ticks; tick++) + contrail_update_points(contrail_index, point_dt / (real)point_ticks); + } for (instance_index = 0; instance_index < MAXIMUM_CONTRAIL_INSTANCES; From eb0c7f5431b79b83d8283203f735e7f01f318482 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 4 Nov 2026 02:32:47 +0100 Subject: [PATCH 20/32] Draw a flag's cloth as far as its marker moved since the cloth's step The cloth is stepped on the first frame after a tick, and game_frame, which steps it, reads the pose that frame draws (render_interpolation. c): it hangs from its first attachment marker as that frame drew it. The frames after it were drawn as far from there as the flag's object is drawn from where the tick left it, which is the other way, and a running carrier's cloth trailed its pole by about a tick's travel. It is now drawn as far from there as that marker has moved since. Checked: the Windows release build compiles without warnings. Co-Authored-By: Claude Opus 5.5 --- port/linux/README.md | 2 +- source/objects/widgets/flags.c | 30 +++++++++++++++++++++--------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/port/linux/README.md b/port/linux/README.md index b082772104..71f7ce77e5 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -1846,7 +1846,7 @@ Other changes: | `models/models.c` | A model's size, which chooses its level of detail and whether it is drawn, is scaled as `display.model_detail` says (`halo_model_detail_scale`, `src/d3d8_gl.c`), and each permutation draws the level `game/model_detail.c` chooses from the model's (stock: the model's). | | `interface/hud.c` | In multiplayer, players' names are drawn above their heads (`display.player_names`, `display.player_name_scale`). | | `text/draw_string.c`, `interface/terminal.c`, `interface/virtual_keyboard.c` | Streamer mode's words are hidden in every string drawn or measured (`game/text_filter.c`), but in the console's line and the virtual keyboard's text being typed. | -| `objects/widgets/flags.c` | A flag's cloth steps a tick at a time, as on the Xbox, not every frame (eight steps a tick at 240 frames a second held it eight times as stiff), and is drawn as far from where its object stood after the tick as the object is drawn (`render_interpolation_object_point`), so that it stays on a running carrier's pole. | +| `objects/widgets/flags.c` | A flag's cloth steps a tick at a time, as on the Xbox, not every frame (eight steps a tick at 240 frames a second held it eight times as stiff), and is drawn as far from where the frame that stepped it hung it as its first attachment marker has moved since, so that it stays on a running carrier's pole. | | `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. | | `interface/first_person_weapons.c` | The first-person hands are drawn with the unit's shield modifier shader, as its body is (`display.viewmodel_shield`). | | `render/render_objects.c`, `objects/objects.c` | In first person, your own body is drawn from the waist down with the pose `first_person_legs.c` makes (`display.first_person_legs`); the waist it found and the mesh `object_mesh.c` read are forgotten on a new map. | diff --git a/source/objects/widgets/flags.c b/source/objects/widgets/flags.c index 78ec14d74c..2d4b297842 100644 --- a/source/objects/widgets/flags.c +++ b/source/objects/widgets/flags.c @@ -786,17 +786,29 @@ void flag_render_proper( short i; short x; short y; - /* port: the cloth hangs from its object's markers where the tick - left them (flags_update), and the frame draws the object between the - ticks (render_interpolation.c): drawn as far from there as the object - is, so that it stays on a running carrier's pole */ - real_point3d drawn = flag->first_attachment; + /* port: the cloth hangs from its first attachment marker where the + frame that stepped it drew it (flags_update, on the first frame after + a tick; game_frame reads the drawn pose, render_interpolation.c), and + later frames draw its object further on: drawn as far from there as + that marker is now, so that it stays on a running carrier's pole */ real_vector3d drawn_offset; - render_interpolation_object_point(flag->object_index, &drawn); - drawn_offset.i = drawn.x - flag->first_attachment.x; - drawn_offset.j = drawn.y - flag->first_attachment.y; - drawn_offset.k = drawn.z - flag->first_attachment.z; + drawn_offset.i = drawn_offset.j = drawn_offset.k = 0.0f; + if (definition->attachment_points.count > 0) + { + struct object_marker marker; + + if (object_get_marker_by_name( + flag->object_index, + TAG_BLOCK_GET_ELEMENT(&definition->attachment_points, 0, struct flag_attachment_point)->marker_name, + &marker, + 1) > 0) + { + drawn_offset.i = marker.matrix.position.x - flag->first_attachment.x; + drawn_offset.j = marker.matrix.position.y - flag->first_attachment.y; + drawn_offset.k = marker.matrix.position.z - flag->first_attachment.z; + } + } object_definition_get(object_get(flag->object_index)->definition_index); vertices = rasterizer_dynamic_vertices_lock(vertex_buffer_index); From e77fd03cdabca13688c5c360d169521f0a561ddb Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 4 Nov 2026 02:40:31 +0100 Subject: [PATCH 21/32] Revert "Age contrail points a step a tick below 30 frames a second too" This reverts commit 2a2dfdb3b701ab5fe3d506b807e3109fc43b5f9b. Below 30 frames a second the points age again in one step for all the ticks a frame has, as before it; at 30 frames a second and above they still age a tick at a time (2d1bc045). Co-Authored-By: Claude Opus 5.5 --- source/effects/contrails.c | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/source/effects/contrails.c b/source/effects/contrails.c index f3f84adfa6..3153d04907 100644 --- a/source/effects/contrails.c +++ b/source/effects/contrails.c @@ -376,14 +376,10 @@ void contrails_update( static long last_point_game_time = NONE; static real pending_point_dt = 0.0f; real point_dt; - /* port: the ticks the points age over (contrail_update_points, below) */ - long point_ticks = 0; pending_point_dt += dt; if (last_point_game_time != game_time_get()) { - point_ticks = last_point_game_time == NONE ? 1 : game_time_get() - last_point_game_time; - point_ticks = PIN(point_ticks, 1, TICKS_PER_SECOND); last_point_game_time = game_time_get(); point_dt = pending_point_dt; pending_point_dt = 0.0f; @@ -474,16 +470,9 @@ void contrails_update( frame's time (contrail_update_points), so each state lasted at least a tick. A Shade's or a Spirit's points (states of 0.01, 0.05 and 0.005 seconds) lived four ticks, 133 ms, and aged a frame at a time at 240 - frames a second they lived about half that, the trail half as long and - dim; below 30 frames a second, a frame of two ticks aged them in one - step, and they lived longer. A step for each tick since the last */ + frames a second they lived about half that, the trail half as long */ if (point_dt > 0.0f) - { - long tick; - - for (tick = 0; tick < point_ticks; tick++) - contrail_update_points(contrail_index, point_dt / (real)point_ticks); - } + contrail_update_points(contrail_index, point_dt); for (instance_index = 0; instance_index < MAXIMUM_CONTRAIL_INSTANCES; From cdd43c65feae9f13a3204917e083cc807941cfaa Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 4 Nov 2026 03:37:06 +0100 Subject: [PATCH 22/32] Read a reused lens flare slot only once the new flare's own count has come The review of the slots found two gaps. A slot just taken was left unread for four frames, but the renderer keeps a slot's earlier count for as long as the GPU is behind, and through the tests it drops with every batch waiting to be read (d3d8_gl.c): after four frames a flare could still be read the count of the flare that had the slot before it. And a flare that found no slot was tested at its place in the list past the slots, which brought back the mismatch the slots are for. The renderer now numbers the tests made at each index as they end, dropped ones too, and keeps with each index's count the number of the test it was read from (xgpu_visibility_tests_ended, xgpu_visibility_test_counted): in the desktop's batches, without queries, and with OpenGL ES's atomic counters and its queries. A flare taking a slot notes the number its first test there will have, and the slot is read only once a count of that test or a later one has come. A flare that finds no slot is tested at an index whose counts are never read, so that it is still drawn while on screen, and keeps its visibility. With the occlusion tests off (rasterizer_lens_flares_occlusion) no test is made, and every flare is read 1 as before. Checked: test_lens_flare_occlusion.py has new cases for a slot reused while the GPU is 12 frames behind and while it drops the new flare's first 20 tests, for every slot taken, for split screen, for a map change with the old map's counts still to come, and for the occlusion tests off. The previous revision fails the four for reuse, dropped tests, no slot and the map change (a hidden flare shown at 64 five frames after taking a visible one's slot; a visible flare put out to 0); eleven faults are caught. test_visibility_readback.py's new case checks the numbering through the real Begin, End and Get functions with a GPU three frames behind and a dropped test; two more faults are caught. Built with clang on Windows without warnings; d3d8_gl.c also compiled as the web and Android builds see it (HALO_GLES, with Khronos's headers) with no diagnostics. Co-Authored-By: Claude Opus 5.5 --- port/linux/README.md | 2 +- port/linux/src/d3d8_gl.c | 38 +- port/linux/src/xgpu.h | 8 + source/rasterizer/rasterizer_lights.c | 51 ++- tools/harness/tests/lens_flare_occlusion.c | 388 ++++++++++++++---- .../tests/test_lens_flare_occlusion.py | 93 +++-- .../harness/tests/test_visibility_readback.py | 58 +-- tools/harness/tests/visibility_readback.c | 98 +++++ 8 files changed, 582 insertions(+), 154 deletions(-) diff --git a/port/linux/README.md b/port/linux/README.md index 1c8015fc8d..e364af22f3 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -1840,7 +1840,7 @@ Other changes: | `saved games/player_profile.h`, `interface/player_ui.c` | A sixth button layout, the Master Chief Collection's Bumper Jumper (`profile.button_preset` 5), and a seventh, Fishsticks (6: zoom on the left trigger, melee on the right stick's button, grenade throw on the right bumper, crouch on B, grenade switch on the pad's left, which then does not move you), and an eighth, Recon (7: the standard buttons with action and reload on the right bumper, grenade switch on the left bumper, flashlight on X). A layout the build does not know (a later build's) gets the standard buttons, not none, which moved start and back and stopped the game. | | `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. | | `rasterizer/xbox/rasterizer_xbox_shadows.c` | With larger shadow maps (`display.shadow_resolution`), the blur's taps stay half a texel apart, and more passes widen it to cover the same part of the map as on the Xbox. | -| `rasterizer/rasterizer_lights.c` | Each lens flare's occlusion test has a slot of its own, kept while the flare goes on being tested, and the flare's visibility is read from there. The Xbox waited for each test at the start of the next frame, so the test at each place in the frame's list of flares was that flare's; the native builds read the counts two or more frames late, and as flares come into view and go out of it, a flare was read another's count, and a hidden one's put it out (the Pelicans' thrusters, a corridor's lights). A slot just taken is not read until the flare's own counts are. | +| `rasterizer/rasterizer_lights.c` | Each lens flare's occlusion test has a slot of its own, kept while the flare goes on being tested, and the flare's visibility is read from there. The Xbox waited for each test at the start of the next frame, so the test at each place in the frame's list of flares was that flare's; the native builds read the counts two or more frames late, and as flares come into view and go out of it, a flare was read another's count, and a hidden one's put it out (the Pelicans' thrusters, a corridor's lights). A slot just taken gives the last flare's count while the GPU is behind or drops tests, so it is read only once a count of one of the new flare's own tests has come: the renderer numbers each index's tests and says which one a count is from (`xgpu_visibility_tests_ended`, `xgpu_visibility_test_counted`, `src/d3d8_gl.c`). A flare that finds no slot is tested where no count is read, and keeps its visibility. | | `rasterizer/rasterizer.c` | The 128 KB of vertices of the debug display of a model's vertices are static, not on the stack: the compiler inlines the function in `render_model`, and the Windows build probed the stack a page at a time for every model drawn. | | `render/render.c` | The 3D view of each window is tone mapped (`display.hdr`) and antialiased (`display.anti_aliasing`) before the HUD is drawn. | | `render/render.c` | `render_initialize` reads the structure culling tests from `display.structure_culling`. | diff --git a/port/linux/src/d3d8_gl.c b/port/linux/src/d3d8_gl.c index 86ab3604cc..d964a08cbe 100644 --- a/port/linux/src/d3d8_gl.c +++ b/port/linux/src/d3d8_gl.c @@ -998,13 +998,14 @@ vertices. */ /* what a test's place in the buffer holds until the GPU copies its count there: more than any test covers (8K with 4x MSAA has 133 million samples) */ #define VISIBILITY_UNCOPIED 0xFFFFFFFFUL -/* a frame's tests: their queries (kept until every count is read), the slot -and area of each, and (once the frame is presented) the fence its copies -wait on */ +/* a frame's tests: their queries (kept until every count is read), the +slot, number (visibility_ended) and area of each, and (once the frame is +presented) the fence its copies wait on */ struct visibility_batch { GLuint queries[VISIBILITY_TEST_SLOTS]; unsigned short slots[VISIBILITY_TEST_SLOTS]; + ULONGLONG tests[VISIBILITY_TEST_SLOTS]; float areas[VISIBILITY_TEST_SLOTS]; unsigned long count, read, frame; ULONGLONG serial; @@ -1099,6 +1100,8 @@ struct gl_device GLuint queries[VISIBILITY_TEST_SLOTS]; GLuint scratch_query; BOOL visibility_unread[VISIBILITY_TEST_SLOTS]; + /* the number of the test each slot's query is (visibility_ended) */ + ULONGLONG query_tests[VISIBILITY_TEST_SLOTS]; #endif BOOL query_pending[VISIBILITY_TEST_SLOTS]; /* the pixels each of the game's pixels covered in the test's target @@ -1110,6 +1113,12 @@ struct gl_device is incomplete, so until a test's count has arrived the slot's earlier count stands. A frame of The Library's lights makes hundreds of tests. */ GLuint visibility_known[VISIBILITY_TEST_SLOTS]; + /* the tests ended at each slot so far, dropped ones too, and the number + (from 1) of the one whose count the slot gives, 0 for none: the latest + read, which can be frames older than the slot's latest test + (xgpu_visibility_tests_ended) */ + ULONGLONG visibility_ended[VISIBILITY_TEST_SLOTS]; + ULONGLONG visibility_counted[VISIBILITY_TEST_SLOTS]; #ifdef HALO_GLES /* with atomic counters: one counter per test, used as a ring */ GLuint visibility_counters; @@ -1126,6 +1135,7 @@ struct gl_device the latest count known, as the desktop query readback gives. */ GLuint counter_snapshots[STREAM_BUFFER_RING]; unsigned short ring_tests[STREAM_BUFFER_RING][VISIBILITY_TEST_SLOTS][2]; + ULONGLONG ring_test_numbers[STREAM_BUFFER_RING][VISIBILITY_TEST_SLOTS]; unsigned long ring_test_count[STREAM_BUFFER_RING]; GLuint counter_values[VISIBILITY_TEST_SLOTS]; GLuint visibility_latest[VISIBILITY_TEST_SLOTS]; @@ -3249,6 +3259,7 @@ void WINAPI D3DDevice_BeginVisibilityTest(void) HRESULT WINAPI D3DDevice_EndVisibilityTest(DWORD index) { + ULONGLONG test; #ifdef HALO_GLES GLuint scratch; #endif @@ -3257,6 +3268,8 @@ HRESULT WINAPI D3DDevice_EndVisibilityTest(DWORD index) return S_OK; device.visibility_test_active = FALSE; index %= VISIBILITY_TEST_SLOTS; + /* (numbered whether its count will come or not) */ + test = ++device.visibility_ended[index]; #ifdef HALO_GLES if (xgpu_capabilities.atomic_counters) { @@ -3268,6 +3281,7 @@ HRESULT WINAPI D3DDevice_EndVisibilityTest(DWORD index) { device.ring_tests[device.buffer_ring][*count][0] = (unsigned short)index; device.ring_tests[device.buffer_ring][*count][1] = (unsigned short)device.counter_active; + device.ring_test_numbers[device.buffer_ring][*count] = test; (*count)++; } device.query_pending[index] = TRUE; @@ -3279,6 +3293,7 @@ HRESULT WINAPI D3DDevice_EndVisibilityTest(DWORD index) if (device.visibility_unavailable) { device.visibility_known[index] = VISIBILITY_ALL_SAMPLES; + device.visibility_counted[index] = test; device.query_pending[index] = TRUE; } return S_OK; @@ -3296,6 +3311,7 @@ HRESULT WINAPI D3DDevice_EndVisibilityTest(DWORD index) struct visibility_batch *batch = &device.visibility_batches[device.visibility_batch]; batch->slots[batch->count] = (unsigned short)index; + batch->tests[batch->count] = test; batch->areas[batch->count++] = device.query_area[index]; device.query_pending[index] = TRUE; return S_OK; @@ -3305,6 +3321,7 @@ HRESULT WINAPI D3DDevice_EndVisibilityTest(DWORD index) scratch = device.scratch_query; device.scratch_query = device.queries[index]; device.queries[index] = scratch; + device.query_tests[index] = test; device.query_pending[index] = TRUE; device.visibility_unread[index] = TRUE; return S_OK; @@ -3348,6 +3365,7 @@ HRESULT WINAPI D3DDevice_GetVisibilityTestResult(DWORD index, UINT *result, ULON if (samples) samples = VISIBILITY_ALL_SAMPLES; device.visibility_known[index] = samples; + device.visibility_counted[index] = device.query_tests[index]; device.visibility_unread[index] = FALSE; } } @@ -3357,6 +3375,17 @@ HRESULT WINAPI D3DDevice_GetVisibilityTestResult(DWORD index, UINT *result, ULON return S_OK; } +/* (xgpu.h) */ +unsigned long long xgpu_visibility_tests_ended(unsigned long index) +{ + return device.visibility_ended[index % VISIBILITY_TEST_SLOTS]; +} + +unsigned long long xgpu_visibility_test_counted(unsigned long index) +{ + return device.visibility_counted[index % VISIBILITY_TEST_SLOTS]; +} + /* ---------- render and texture stage state */ void D3DFASTCALL D3DDevice_SetRenderState_Simple(DWORD method, DWORD value) @@ -7587,6 +7616,7 @@ static void visibility_read_batch(void) } device.visibility_known[batch->slots[test]] = area > 1.0f ? (GLuint)(samples / area + 0.5f) : samples; + device.visibility_counted[batch->slots[test]] = batch->tests[test]; device.visibility_stats.samples++; device.visibility_stats.nonzero += samples != 0; if (samples > device.visibility_stats.maximum) @@ -7876,6 +7906,8 @@ void WINAPI D3DDevice_Present(CONST RECT *source_rectangle, CONST RECT *destinat { device.visibility_latest[device.ring_tests[ring][test][0]] = device.counter_values[device.ring_tests[ring][test][1]]; + device.visibility_counted[device.ring_tests[ring][test][0]] = + device.ring_test_numbers[ring][test]; } device.ring_test_count[ring] = 0; } diff --git a/port/linux/src/xgpu.h b/port/linux/src/xgpu.h index e6567fd356..380a0ffc25 100644 --- a/port/linux/src/xgpu.h +++ b/port/linux/src/xgpu.h @@ -348,6 +348,14 @@ void xgpu_static_buffers_remove(const void *buffers, long count, unsigned long s /* Android: size bytes of vertices or indices from start, as above */ void xgpu_static_range_add(const void *start, unsigned long size, int indices); void xgpu_static_range_remove(const void *start, int indices); +/* the visibility tests ended at an index so far, and the number (from 1) of +the one whose count GetVisibilityTestResult last gave there, 0 for none. A +test's count is read two or more frames after it, and never for one dropped +(no batch free), and until then the index gives an earlier test's: a lens +flare's slot is read only once a count of the flare's own tests has come +(rasterizer_lights.c; d3d8_gl.c) */ +unsigned long long xgpu_visibility_tests_ended(unsigned long index); +unsigned long long xgpu_visibility_test_counted(unsigned long index); /* fills the corners x0, y0 to x1, y1 of the window (row 0 at the bottom) with color, as it is (an HDR display's scRGB framebuffer: linear, 1.0 at 80 nits), or only the ring ring[0], ring[1] (its centre in the window), from diff --git a/source/rasterizer/rasterizer_lights.c b/source/rasterizer/rasterizer_lights.c index 397e89a320..62501a6051 100644 --- a/source/rasterizer/rasterizer_lights.c +++ b/source/rasterizer/rasterizer_lights.c @@ -372,9 +372,12 @@ one at its place frames before, and a hidden one's put it out at once, to come back over some ticks (the Pelicans' thrusters, the lights of a corridor walked along). So each flare keeps a slot, found by its own visibility (lens_flare_occlusion_test_results_get), while it goes on being tested, and -its count is read from there; a slot just taken holds the last flare's counts -until the new one's are read, and is not read before. A flare that finds no -slot is tested at its place in the list, past the slots, as before. */ +its count is read from there. A slot just taken gives the count of the last +flare's test for as long as the GPU is behind, and through the tests it drops +with no batch free (d3d8_gl.c), so it is read only once a count of one of the +new flare's own tests has come (xgpu_visibility_test_counted). A flare that +finds no slot is tested where no count is read, and its visibility stays as +it is. */ enum { LENS_FLARE_OCCLUSION_SLOT_COUNT = 2048, @@ -382,10 +385,9 @@ enum LENS_FLARE_OCCLUSION_SLOT_PROBES = 32, /* the frames a flare no longer tested keeps its slot */ LENS_FLARE_OCCLUSION_SLOT_KEPT_FRAMES = 30, - /* the frames from a slot's taking to its first count of the flare's own: - the native builds read a count two frames after its test, more while the - GPU is behind (d3d8_gl.c) */ - LENS_FLARE_OCCLUSION_SLOT_SETTLE_FRAMES = 4, + /* where a flare with no slot is tested, its counts never read: past the + slots, short of the transparent geometry's test (0xfff) */ + LENS_FLARE_OCCLUSION_UNREAD_INDEX = LENS_FLARE_OCCLUSION_SLOT_COUNT, }; struct lens_flare_occlusion_slot @@ -394,16 +396,23 @@ struct lens_flare_occlusion_slot is its own; NULL for none */ byte const *visibility; short light_identifier; - unsigned long taken_frame; unsigned long tested_frame; + /* the number of the flare's first test in the slot + (xgpu_visibility_tests_ended): a count of a test before it is another + flare's */ + unsigned long long first_test; }; static struct lens_flare_occlusion_slot lens_flare_occlusion_slots[LENS_FLARE_OCCLUSION_SLOT_COUNT]; -/* the occlusion test of each flare in the frame's list: a slot, or its place -past them (lens_flare_occlusion_test_index) */ +/* the occlusion test of each flare in the frame's list: a slot, or +LENS_FLARE_OCCLUSION_UNREAD_INDEX (lens_flare_occlusion_test_index) */ static short local_lens_flare_occlusion_tests[MAXIMUM_LENS_FLARES_PER_FRAME]; static unsigned long lens_flare_occlusion_frame; +/* port: (d3d8_gl.c, xgpu.h) */ +unsigned long long xgpu_visibility_tests_ended(unsigned long index); +unsigned long long xgpu_visibility_test_counted(unsigned long index); + /* ---------- private code */ /* port: a map's BSP lens flare marker past what the BSP has; said once */ @@ -480,7 +489,7 @@ static byte *lens_flare_occlusion_test_results_get( /* port: the occlusion test of the flare at lens_flare_index in the frame's list: its slot (above), the one it had if it has one, else one no flare has -been tested in for a while; else its place past the slots */ +been tested in for a while; else LENS_FLARE_OCCLUSION_UNREAD_INDEX */ static short lens_flare_occlusion_test_index( short lens_flare_index) { @@ -510,32 +519,38 @@ static short lens_flare_occlusion_test_index( } if (free_slot==NONE) - return (short)(LENS_FLARE_OCCLUSION_SLOT_COUNT+lens_flare_index); + return LENS_FLARE_OCCLUSION_UNREAD_INDEX; lens_flare_occlusion_slots[free_slot].visibility= visibility; lens_flare_occlusion_slots[free_slot].light_identifier= light_identifier; - lens_flare_occlusion_slots[free_slot].taken_frame= lens_flare_occlusion_frame; lens_flare_occlusion_slots[free_slot].tested_frame= lens_flare_occlusion_frame; + /* (every test the slot has after this is the flare's) */ + lens_flare_occlusion_slots[free_slot].first_test= xgpu_visibility_tests_ended(free_slot) + 1; return free_slot; } /* port: the visible pixels of the latest of the flare's own occlusion tests -read, NONE while none is (its slot just taken) */ +read, NONE while none is (its slot just taken, or none found) */ static long lens_flare_occlusion_visible_pixels( short lens_flare_index) { short test_index= local_lens_flare_occlusion_tests[lens_flare_index]; + long visible_pixels; - if (test_index==NONE) + if (test_index==NONE || test_index>=LENS_FLARE_OCCLUSION_SLOT_COUNT) return NONE; - if (test_index