diff --git a/AGENTS.md b/AGENTS.md index babed4d..4bcf39b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,9 +5,13 @@ This repository ports the Halo: Combat Evolved decompilation (Xbox build sources are the decompilation; the port adds a platform layer that implements the Xbox APIs the game calls, and features the Xbox game did not have (a new netcode, internet play, the PC version's menus, Custom Edition -maps, touch controls). The project is published as OpenCE. +maps, touch controls). The project is published as OpenCE; this fork of +it is HaloCustoms, the game of halocustoms.org, which plays with OpenCE's +players and servers. -Start with [README.md](README.md). The other documents are listed at the +Start with [README.md](README.md), then [FORK.md](FORK.md), which says what +the fork changes and what it keeps of OpenCE's (its network version, its +services after HaloCustoms' own). The other documents are listed at the end. ## Layout @@ -25,7 +29,7 @@ end. | `port/assets/` | What the builds embed or ship: high-res HUD (`hud/`), fonts, titles, menus (`menus/`), icons, network brokers and master servers, the dedicated server's web page (`server_ui/`) | | `port/third_party/` | Vendored libraries, each with a README naming its upstream, version and checksum | | `port/tools/` | Stand-alone tools (`cache_file_report.c`) | -| `tools/` | Build scripts (`linux_build.py`, `windows_build.py`, `android_build.py`, `web_build.py`, `ci_build.py`, `web_serve.py`), generators (`ce_menus.py`, `port_settings.py`, `hud_assets.py`, `title_assets.py`, `embed_assets.py`, `server_ui_embed.py`, `xdk_headers.py`), the dedicated server's protocol simulator and remote console (`server_sim.py`, `opence_rcon.py`), tests, and `stagekit/` (scenarios staged and instrumented on the real engine, no retail assets) | +| `tools/` | Build scripts (`linux_build.py`, `windows_build.py`, `android_build.py`, `web_build.py`, `ci_build.py`, `web_serve.py`), generators (`ce_menus.py`, `port_settings.py`, `hud_assets.py`, `title_assets.py`, `embed_assets.py` with its check of a menu set, `menu_check.py`, `server_ui_embed.py`, `xdk_headers.py`), the dedicated server's protocol simulator and remote console (`server_sim.py`, `opence_rcon.py`), tests, and `stagekit/` (scenarios staged and instrumented on the real engine, no retail assets) | | `docs/` | Longer design notes (`custom_edition_caches.md`, `mods.md`, `dedicated_server.md`, `master_protocol.md`) | | `pgo/` | Profile-guided optimisation profiles | | `deploy/` | Running a dedicated server: systemd, Docker, Pterodactyl, Helm, PufferPanel, MCSManager, example gametypes | @@ -35,7 +39,7 @@ end. ## Building ```sh -python configure.py # debug build; add --release, --portable, --profile, --pgo=off, --lto=off +python configure.py # debug build; add --release, --portable, --profile, --pgo=off, --lto=off, --menus-overlay DIR ninja linux # build/linux/halo ninja android # the Android guest image and native libraries ninja android_apk # the APK (Gradle) @@ -230,6 +234,7 @@ The decompilation's upstream, punpckhdq/halo, is AGPL 3.0 from 17 October | Document | About | | --- | --- | | [README.md](README.md) | Downloads, game data, building, build options, profiling builds | +| [FORK.md](FORK.md) | The HaloCustoms fork: what it adds, its services (master, brokers, release feed), its releases and their keys, what it keeps of OpenCE's | | [port/linux/README.md](port/linux/README.md) | The Linux build, controls, menus, every setting, updates, frame rate, field of view, system link, internet play, voice chat, the server browser, map sharing, security, map checks, how the port operates, the game source changes | | [port/linux/NETCODE.md](port/linux/NETCODE.md) | The distributed netcode, network versions, joining a game in progress, map sharing, transport, testing | | [port/windows/README.md](port/windows/README.md) | The Windows build, its headers, inline functions, crash reports | diff --git a/FORK.md b/FORK.md new file mode 100644 index 0000000..f576349 --- /dev/null +++ b/FORK.md @@ -0,0 +1,229 @@ +# HaloCustoms + +HaloCustoms is a fork of [OpenCE](https://github.com/OpenCommunityEdition/OpenCE), +the port of the Halo: Combat Evolved decompilation, made for the HaloCustoms +community site (halocustoms.org). It is OpenCE with the site's accounts, stats, +ranks, server passes, friends and menus added. Everything in OpenCE's own documents +([README.md](README.md), [AGENTS.md](AGENTS.md), `port/*/README.md`) still +applies; this file says only what is different here. + +## Licence and credit + +- Like OpenCE, this fork is `AGPL-3.0-or-later` ([LICENSE](LICENSE)). Every build + the site serves or hands out comes with its source: the site's `/source` page + publishes the changes against the OpenCE release the build starts from. +- The game's code is the decompilation of Halo: Combat Evolved + (punpckhdq/halo, AGPL 3.0; bnunu/halo-1, CC0), ported by OpenCE's + contributors: see the README's "Licence" section and [AUTHORS.md](AUTHORS.md). + Their notices stay as they are. +- Players need their own copy of the game's data. Nothing of Halo's is in this + repository. + +## Staying compatible with OpenCE + +- `HALO_PORT_NETWORK_VERSION` is OpenCE's (41 at build-343), so HaloCustoms + players and servers play with stock OpenCE ones. Don't raise it here unless + OpenCE does. +- Each HaloCustoms change to the game's look or behaviour is a setting, or an + element of the menu files that only the site's menu theme uses: without them + the game and its menus are OpenCE's. + +## Theme fonts + +AGENTS.md's fonts rule (Overpass and the OpenCE font only) is about what +OpenCE itself ships. In this fork a menu set or overlay may also bring +fonts of its own for the game's text (port/assets/menus/README.md, "Theme +fonts": ``), each a static TrueType font under the +SIL Open Font License with its `-OFL.txt` beside it, which the +builds check (`tools/menu_check.py`), embed and ship the licence of. The +stock set gives none: without a theme, the stock fonts draw as before. + +## Services + +HaloCustoms runs its own master server, MQTT broker and release feed, and the +game asks them before OpenCE's, which stay after them so that HaloCustoms and +OpenCE players keep finding and playing with each other +(`port/linux/src/halocustoms_services.h`; the network version and the MQTT +topics, `hceu/3/#` and `opence/web/1/room/#`, are OpenCE's): + +- Master servers: `https://halocustoms.org/master` first, in a release + (which is given its key: "Releases"), then OpenCE's + (`master_list.c`'s built-in list; `port/assets/network/masters.txt` names + none, so both are asked). A dedicated server publishes to the same + masters. The web build reads its site's `masters.json`, which the site's + installer writes. +- Brokers: `halocustoms.org:1883` first, then OpenCE's + (`opence.milenko.org`) and two public ones, four in all + (`port/assets/network/brokers.txt`, `p2p_signal.c`). The web build reaches + `wss://halocustoms.org/mqtt` and the public ones over their secure + WebSockets (OpenCE's has none): `p2p_signal.c`'s `DEFAULT_WEB_BROKERS`, + `port/web/site/net.js`. +- Updates: the desktop updater (`port/linux/src/updater.c`) and Android's + (`Updater.java`) ask `https://halocustoms.org/updates/releases/latest` (a + release as GitHub's releases/latest gives one, `tag_name` `build-`), + take a release only as its manifest, signed offline with the release key, + says it ("Releases"), download from + `https://halocustoms.org/updates/releases/download/build-/`, and check the + zip against the manifest's size and SHA-256. The server kits in `deploy/` + install from the same feed, checking the feed's own digest for now. + +## Releases + +A release is built with, in the environment of the whole build +(`python tools/ci_build.py release`, or `configure.py` and +`ninja`): + +- `HALO_BUILD_NUMBER=`: its number, its tag in the release feed + (`build-`), which the updaters compare. Its labels and `--version` name + it `HaloCustoms build-343-hc (based on OpenCE build-343)` + (`port/linux/include/halocustoms_build.h`). A build without it never looks + for updates. +- `HALOCUSTOMS_MASTER_KEY=ed25519:<64 hex>`: HaloCustoms' master's + list-signing key (production makes it with `php artisan master:keygen`), + which builds the master into the list (`tools/linux_build.py`'s + `master_key_defines`; configure stops on a key not in that form). Without + it a build asks OpenCE's master alone, unless its `masters.txt` names + HaloCustoms' (`https://halocustoms.org/master ed25519:<64 hex>`, then + OpenCE's line), which a release can ship instead. +- `HALOCUSTOMS_RELEASE_KEY=ed25519:<64 hex>`: the release key's public + half, pinned in the desktop updater (`updater.c`, by `tools/linux_build.py`'s + `release_key_defines`) and the Android app (`BuildConfig`, by + `tools/android_build.py`'s Gradle command). Configure stops on a key not in + that form. A numbered build without it never updates itself. +- Android: every release signed with the same key + (`HALO_ANDROID_KEYSTORE`), which an update must share with the app it + replaces. + +The number and the keys are read when the build is configured, so configure +and build a release in one environment (`ci_build.py` does both): a later +configure without them, as ninja's own when a build script changes, makes +a build without them again. + +### Signed releases (contract 1) + +A release is signed offline with the release key, an Ed25519 key that +whoever releases makes and keeps on a machine of their own, never on the +site's server: a break-in at the site cannot then send players' games code, +because they take a release only as its signed manifest says it. The +manifest is UTF-8 with LF line endings, exactly: + +``` +halocustoms-release 1 +tag build- +network +asset +``` + +with an asset line for each of the release's zips, in increasing byte order +of their names (the feed's, `halo--.zip`). +The signature is Ed25519 over its exact bytes, `ed25519:<128 hex>`, and the +key id the first 8 bytes of the SHA-256 of the 32-byte public key, in hex. +The feed gives the three with the release (`halocustoms_manifest`, +`halocustoms_signature`, `halocustoms_key_id`). The games check the key id +and the signature with the pinned key, read the manifest (not the feed's +JSON: no leniency, a manifest of any other form is refused), require its +tag to be the feed's `tag_name` and newer than their own build, download +from the address of the tag, and check the zip's size and SHA-256; any +failure means no update. `tools/harness/tests/release_vectors.json` is the +contract's test vector (RFC 8032's test key, never a release's), of which +the site keeps a byte-identical copy. + +`tools/halocustoms_release_sign.py` signs, with OpenSSL 3's Ed25519 (as +OpenCE's `tools/release_sign.sh`: nothing to install, and an implementation +other than the game's own, which the tests compare it with): + +```sh +python tools/halocustoms_release_sign.py new-key ~/halocustoms-release.seed # once: prints the public key +python tools/halocustoms_release_sign.py sign ~/halocustoms-release.seed \ + halo-linux-release.zip halo-windows-release.zip halo-android-release.zip > release.json +python tools/halocustoms_release_sign.py verify ed25519: release.json +``` + +`new-key` writes the seed (64 hex digits) to a new file only its owner may +read (0400) and prints the public key, which the builds pin +(`HALOCUSTOMS_RELEASE_KEY`); keep the seed offline, and a copy of it safe: +a lost key means a new one, and builds pinning it, which the old builds +never take. `sign` prints the feed's three members as one JSON object; it +refuses names not of the feed, a name twice, an empty file, and a seed file +others may read. + +To publish a release to the feed, zip each `dist/halo--release` +folder as the workflow does (`halo--release.zip`), sign the zips, +and in the site's folder run +`php artisan opence:publish-local-release --network-version --signature=` +with the same zips, number and network version: the site makes the manifest +again from them, publishes nothing its signature does not check, and the +feed then offers the release to every game of a lower number that pins the +key. + +Each build tells the servers it joins that its source is +`halocustoms.org/source` (`tools/linux_build.py`'s `HALOCUSTOMS_SOURCE`), +with its label. + +For local tests, the dev site's master is +`http://localhost:8000/master ed25519:0a225185cc37f803b544c378e7a5880f4b60e59d59100249bae9b0aa78cdf21f`: +name it in a `masters.txt` beside `config.toml`, never in a build. + +## Branches and remotes + +- `halocustoms` is the fork's branch. It starts at OpenCE's `build-343` tag + (commit fc9deb0). +- `upstream` is OpenCE on GitHub, for fetching only: its push URL is disabled, + and the fork has no remote to publish to. +- To take OpenCE's newer work: `git fetch upstream`, then `git merge` the + release tag (or `upstream/main`), build, and run the tests below. +- The clone is shallow, as the checkout it was made from was; + `git fetch --unshallow upstream` fetches the rest of OpenCE's history. + +## Building for the site + +The site's `/play` page runs a web release with the site's menu theme built in: + +```sh +python tools/ci_build.py web release --menus-overlay ~/Documents/Projects/WebDev/opence-hub/resources/play/menus +python configure.py --portable --release # the configuration put back +``` + +Zip `dist/halo-web-release` (with `version.json` at its root), then, in the +site's folder, `php artisan opence:install-local-web-build build-343-hc`, +and publish the source again for `/source`: +`git diff build-343 --binary > ~/Documents/Projects/WebDev/opence-hub/resources/opence/halocustoms-build-343.patch`. + +## Tests + +As in [AGENTS.md](AGENTS.md). HaloCustoms' own harness tests are +`tools/harness/tests/test_player_link.py`, `test_server_results.py`, +`test_halocustoms_kills.py`, `test_halocustoms_matches.py`, +`test_halocustoms_friends.py` (the friends' list and the presence, +`port/linux/src/halocustoms_friends.c`, the FRIENDS screen and the main +menu's FRIENDS) and +`test_updater_feed.py` (a release's signed manifest, the fail-closed cases and the zip's checks, with the +Android updater's code on the same manifests), `tools/test_halocustoms_release_sign.py` (the signing tool), and +`test_master_protocol.py`'s release cases (the master built in with a key); +`tools/test_halocustoms_services.py` checks that the services' addresses +agree everywhere they are written, and `tools/test_windows_resources.py` +the Windows executable's version information; `test_menu_fonts.py` and +`test_hires_run_layout.py` a theme's fonts. + +## Friends + +Members add each other as friends on the site; the game shows them (the +main menu's FRIENDS, and the HALOCUSTOMS screen's) and tells the site where +player 1's profile plays, for its friends alone (`halocustoms.presence`). It +is all between the game and the site, over HTTPS (`docs/master_protocol.md`, +"Identity masters"): nothing new passes between game machines, so the +network version stays OpenCE's. A friend's public game is joined by its +host-signed listing, which the game checks again, as the server browser +joins one. + +## Not done yet + +- HaloCustoms' master's production key does not exist yet: releases are + built without it (OpenCE's master alone) until production makes it. The + release key is made by whoever releases (`new-key`), and builds pin it. +- The server kits (Docker, Pterodactyl) check the feed's digest, not the + release's signed manifest: checking the signature there is later work. +- PufferPanel's and MCSManager's kits download + `https://halocustoms.org/updates/releases/latest/download/halo-linux-release.zip`, + the latest release's zip at an address that stays the same, as GitHub + serves one; the site's feed has no such address yet. diff --git a/README.md b/README.md index 9287b50..1b19710 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,28 @@ -# OpenCE - Halo: Combat Evolved for Linux, Windows, Android and the web +# HaloCustoms - Halo: Combat Evolved for Linux, Windows, Android and the web

- OpenCE + HaloCustoms

- Download - Play in browser - Discord + Download + Play in browser + Source + Based on OpenCE

-**OpenCE** (Open Community Edition) is an open source port of the Halo: Combat Evolved decompilation (Halo CE) based on an early Xbox development build (2342) and forked from +**HaloCustoms** is the game of the HaloCustoms community ([halocustoms.org](https://halocustoms.org)): [OpenCE](https://github.com/OpenCommunityEdition/OpenCE) with the site's accounts, stats, ranks, server passes and menus added, which plays with OpenCE's players and servers ([FORK.md](FORK.md) says what is different). **OpenCE** (Open Community Edition) is an open source port of the Halo: Combat Evolved decompilation (Halo CE) based on an early Xbox development build (2342) and forked from [bnunu/halo-1](https://github.com/bnunu/halo-1). The project aims to enhance the existing game and adds many features, such as 128-player multiplayer, online campaign co-op, mod packs, field-of-view settings, a server browser, peer-to-peer hosting, automatic map downloads, voice chat, support for Custom Edition maps and much more! The game runs natively on **Windows, Linux, Android** and in the **web browser** with nothing to install. A macOS port is in progress. Worth noting that it is not an emulator, **the game's own code is compiled for each platform** with a new layer in place of the Xbox's hardware. Runs on OpenGL (Android is on OpenGL ES and WebGL 2 in the browser). Sound, input, gamepads are all handled through SDL3. The game currently draws a frame every refresh of your display with Smooth Motion enabled, while the simulation runs at 30 ticks just like the original Xbox. The game running on Windows -

OpenCE running on Windows (release build)

+

The game running on Windows (OpenCE's release build)

## A copy of Halo: Combat Evolved is required -OpenCE does not include the game data. You **must provide your own Xbox disc image** (`.xiso` or `.iso`) of Halo: Combat Evolved. Every version of the game works. The maps of the European (PAL) version were made for a slower console - the game changes them to play as the North American (NTSC) maps do, so players of the two versions can play together. +HaloCustoms does not include the game data. You **must provide your own Xbox disc image** (`.xiso` or `.iso`) of Halo: Combat Evolved. Every version of the game works. The maps of the European (PAL) version were made for a slower console - the game changes them to play as the North American (NTSC) maps do, so players of the two versions can play together. 1. Start the game (`halo.exe` on Windows and `halo` on Linux). 2. At the first start you will get a pop up message asking for the disc image. Select it in the @@ -43,49 +44,44 @@ storage for the site - nothing is uploaded ([port/web/README.md](port/web/README ## Download -GitHub Actions builds the game for each commit. - -Builds for latest release: +HaloCustoms' builds come from [halocustoms.org](https://halocustoms.org/downloads). +Its release feed, `https://halocustoms.org/updates/releases/latest`, names +the latest release (`build-`), whose files are at +`https://halocustoms.org/updates/releases/download/build-/`, each +with its SHA-256 in the feed: | Platform | Release | Debug | Guide | | --- | --- | --- | --- | -| Linux | [halo-linux-release.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-release.zip) | [halo-linux-debug.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-debug.zip) | [Linux](port/linux/README.md) | -| Windows | [halo-windows-release.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-windows-release.zip) | [halo-windows-debug.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-windows-debug.zip) | [Windows](port/windows/README.md) | -| Android | [halo-android-release.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-android-release.zip) | [halo-android-debug.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-android-debug.zip) | [Android](port/android/README.md) | -| Web | [halocombatevolved.com](https://halocombatevolved.com/) (or [halo-web-release.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-web-release.zip) for your own server) | [halo-web-debug.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-web-debug.zip) | [Web](port/web/README.md) | +| Linux | `halo-linux-release.zip` | `halo-linux-debug.zip` | [Linux](port/linux/README.md) | +| Windows | `halo-windows-release.zip` | `halo-windows-debug.zip` | [Windows](port/windows/README.md) | +| Android | `halo-android-release.zip` | `halo-android-debug.zip` | [Android](port/android/README.md) | +| Web | [halocustoms.org/play](https://halocustoms.org/play) | | [Web](port/web/README.md) | Release build is recommended for regular play. The debug build stops at the first failed check (assertion) and writes it to the log, so use it when you want to find and report problems. -The game updates itself. At start-up it looks for a newer release, and asks -if you want to install it. Refer to "Updates" in +The game updates itself. At start-up it looks for a newer release in the +feed, and asks if you want to install it; it checks the download against +the feed's SHA-256 before it installs it. Refer to "Updates" in [port/linux/README.md](port/linux/README.md#updates). The web site offers each new build when it is out. -Each build of the `main` branch that passes on all four platforms is a new -release, and the web site then plays it. The [Releases](https://github.com/OpenCommunityEdition/OpenCE/releases) -page keeps the last five releases. If the latest build has a problem, get -an older build from that page. - -Each release also has `SHA256SUMS`, the SHA-256 of each of its files, and -`SHA256SUMS.sig`, that file's Ed25519 signature by the project's release -key, whose public key is [deploy/release-key.pub](deploy/release-key.pub). -`sha256sum -c --ignore-missing SHA256SUMS` checks the downloads, and -`tools/release_sign.sh verify SHA256SUMS` (OpenSSL 3) the signature. +Every build the site hands out comes with its source, on its +[/source](https://halocustoms.org/source) page. OpenCE's own builds (its +[releases](https://github.com/OpenCommunityEdition/OpenCE/releases), signed +with its release key, and its AUR package) are OpenCE's, not HaloCustoms'. The Linux README also covers the controls, every setting and the multiplayer features. These are almost the same on all platforms. The settings are in `config.toml` next to the game, and many are in the menus too. -The Arch-based distribution release is available on the [AUR](https://aur.archlinux.org/packages/opence-bin). - ## Multiplayer - **Supports up to 128 players** on up to 128 machines and up to 4 players on each device in split screen. - **Every platform plays together:** Windows, Linux, Android and the browser all support cross-play! On a local network, the native builds can play LAN. Browser players can play each other in rooms over WebRTC ([port/web/README.md](port/web/README.md)). - **Peer-to-peer internet hosting:** join through the server browser or an invite link. A host lists a game as public in the server browser, or keeps it private for players with the invite link. With the Discord integration, the host can also send the invite through Discord. - **Online co-op campaign:** with settings for friendly fire, player collisions and the number of enemies as a multiplier based on per player or static. -- **New gametypes:** SWAT, Infection, Gun Game and Hardcore (Slayer, CTF, Oddball and King), in Select Gametype's OPENCE bank; Edit Gametypes' OPENCE OPTIONS gives any gametype their rules ([port/linux/README.md](port/linux/README.md), "OpenCE gametypes"). +- **New gametypes:** SWAT, Infection, Gun Game and Hardcore (Slayer, CTF, Oddball and King), in Select Gametype's HALOCUSTOMS bank; Edit Gametypes' HALOCUSTOMS OPTIONS gives any gametype their rules ([port/linux/README.md](port/linux/README.md), "HaloCustoms gametypes"). - **Voice Chat:** push to talk (V) or an open microphone (can be changed in the menu's audio settings) - **Map sharing:** a player who does not have the game's Custom Edition map downloads it from the other players. - **Moderation:** the host can kick and ban players from the scoreboard menu or the `kick` and `ban` console commands. Players can vote to kick. @@ -192,6 +188,7 @@ Give these options to `configure.py`: | `--pgo=off` | No profile-guided optimization. | | `--pgo=train` | Records a new optimization profile. Refer to "Optimization profiles". | | `--profile` | A profiling build. It records CPU times. Refer to "Profiling builds". | +| `--menus-overlay DIR` | The builds embed the menu files of the folder `DIR`. Each file replaces the stock menu file of the same path, or is added to the stock files. Use this option for a site that has its own menus. Refer to [port/assets/menus/README.md](port/assets/menus/README.md). | | `--web-emcc PATH` | The Emscripten `emcc` for `ninja web` (else the one on the PATH, or `~/emsdk`'s). | Without `--portable`, the Linux and Windows builds use all the instructions @@ -282,6 +279,9 @@ A profiling build plays with normal builds. It does not use ## Credits +HaloCustoms is OpenCE's work, changed for the HaloCustoms community. +OpenCE's credits: + The decompilation is of the Xbox build 2342 (`cachebeta.exe`, SHA-256 `4cc87b45f721270392a96f1674ed2b5cd4a7bb4355faeab4531d1cf1884d9520`). OpenCE starts from the decompilation of @@ -292,11 +292,11 @@ high-res redraws are in [AUTHORS.md](AUTHORS.md). The main contributors are Jona ## Licence -OpenCE is free software: you can give it to others and change it under the -terms of the GNU Affero General Public License as published by the Free -Software Foundation, version 3 of the License or (at your option) any later -version (`AGPL-3.0-or-later`). It comes with no warranty. The full text is in -[LICENSE](LICENSE). +Like OpenCE, HaloCustoms is free software: you can give it to others and +change it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, version 3 of the License or (at +your option) any later version (`AGPL-3.0-or-later`). It comes with no +warranty. The full text is in [LICENSE](LICENSE). - **From the change.** The AGPL applies from the commit that made the change, and to each release built from that commit or a later one. Earlier @@ -312,16 +312,17 @@ version (`AGPL-3.0-or-later`). It comes with no warranty. The full text is in - **Hosts and servers.** If you change the game and let other persons play with your version over a network (for example, as the host of an internet game, or on a web site), the AGPL asks you to offer them the source of - your version (section 13). The game as released has its source here, and - each release's source is its tag (`build-`). + your version (section 13). Each HaloCustoms build's source is on the + site's [/source](https://halocustoms.org/source) page. - **Third-party code, fonts and art** keep their own licences, which are next to them (`port/third_party`, `port/assets/fonts`, `port/assets/icons`, `port/assets/text_filter`). The release downloads and the web site's `licenses.txt` carry their notices. -- **Halo is not covered.** The licence covers the work of OpenCE's authors. - It gives no rights in Halo, its maps or anything else that Microsoft owns. +- **Halo is not covered.** The licence covers the work of OpenCE's and + HaloCustoms' authors. It gives no rights in Halo, its maps or anything + else that Microsoft owns. ## Disclaimer -OpenCE (Open Community Edition) is an unofficial fan project. It is not endorsed by or affiliated with Microsoft, Xbox Game Studios, Halo Studios, Activision or Bungie. Halo and Halo: Combat Evolved are trademarks of Microsoft Corporation; Halo © Microsoft Corporation. +HaloCustoms, like OpenCE (Open Community Edition), is an unofficial fan project. It is not endorsed by or affiliated with Microsoft, Xbox Game Studios, Halo Studios, Activision or Bungie. Halo and Halo: Combat Evolved are trademarks of Microsoft Corporation; Halo © Microsoft Corporation. No game data is included. You must provide your own copy of the game. diff --git a/configure.py b/configure.py index 1ea8989..50cc790 100644 --- a/configure.py +++ b/configure.py @@ -12,6 +12,7 @@ from types import SimpleNamespace from tools import ninja_syntax from tools.android_build import android_configure_inputs, generate_android_build +from tools.embed_assets import command_argument, menu_overlay_folder from tools.linux_build import check_profile_options, generate_linux_build, linux_configure_inputs from tools.web_build import generate_web_build, web_configure_inputs from tools.windows_build import generate_windows_build, windows_configure_inputs @@ -62,6 +63,13 @@ parser.add_argument( "the Debian packages and SDL's source (build/linux/third_party, tools/linux_sysroot.py), and needs CMake, " "pkgconf and wayland-scanner, not the 32-bit SDL 3", ) +parser.add_argument( + "--menus-overlay", + metavar="DIR", + help="embed the menu files of DIR (its .xml and .png files) in place of, and with, port/assets/menus' " + "files: a folder laid out like it (ce/...), for a site's own look; the build checks the merged set " + "(port/assets/menus/README.md)", +) parser.add_argument( "--pgo", nargs="?", @@ -131,6 +139,18 @@ try: check_profile_options(args.profile, args.pgo) except ValueError as error: parser.error(str(error)) +# (a menu overlay: its folder as an absolute path, refused if it is not one) +menus_overlay = None +if args.menus_overlay is not None: + try: + menus_overlay = menu_overlay_folder(args.menus_overlay) + except ValueError as error: + parser.error(f"--menus-overlay {error}") +# (the arguments as ninja's command lines take them, for rerunning this) +try: + configure_args = [command_argument(arg) for arg in sys.argv[1:]] +except ValueError as error: + parser.error(str(error)) # the settings the builds read sln = SimpleNamespace( @@ -141,6 +161,7 @@ sln = SimpleNamespace( port_profile=args.profile, port_lto=args.lto, port_portable=args.portable, + port_menus_overlay=menus_overlay, port_pgo=args.pgo, port_pgo_profile=args.pgo_profile, android_ndk=args.android_ndk, @@ -162,10 +183,7 @@ n.newline() configure_script = Path(os.path.relpath(os.path.abspath(sys.argv[0]))) n.comment("The arguments passed to configure.py, for rerunning it.") -n.variable( - "configure_args", - [f'"{arg}"' if any(ch.isspace() for ch in arg) else arg for arg in sys.argv[1:]], -) +n.variable("configure_args", configure_args) n.variable("python", f'"{sys.executable}"') n.newline() @@ -177,7 +195,7 @@ generate_web_build(n, sln) n.comment("Reconfigure on change") n.rule( name="configure", - command=f"$python {configure_script} $configure_args", + command=f"$python {command_argument(str(configure_script))} $configure_args", generator=True, description=f"RUN {configure_script}", ) diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 9cee5bc..2e40dda 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -1,12 +1,15 @@ # syntax=docker/dockerfile:1 # -# The dedicated server (docs/dedicated_server.md) as a container image, made -# from a release's halo-linux-release.zip (the portable build: halo, its -# libSDL3.so.0, brokers.txt, masters.txt): +# The HaloCustoms dedicated server (based on OpenCE: docs/dedicated_server.md) +# as a container image, made from a HaloCustoms release's halo-linux-release.zip +# (the portable build: halo, its libSDL3.so.0, brokers.txt, masters.txt). +# HaloCustoms has no image registry: build the image yourself, from the +# latest release of HaloCustoms' release feed, one of its tags, a zip's URL +# or a zip beside this Dockerfile: # -# docker build -t opence-server deploy/docker -# docker build -t opence-server --build-arg RELEASE=https://github.com/OpenCommunityEdition/OpenCE/releases/download/build-206/halo-linux-release.zip deploy/docker -# docker build -t opence-server --build-arg RELEASE=halo-linux-release.zip deploy/docker (a zip in deploy/docker) +# docker build -t halocustoms-server deploy/docker +# docker build -t halocustoms-server --build-arg RELEASE=build-15 deploy/docker +# docker build -t halocustoms-server --build-arg RELEASE=halo-linux-release.zip deploy/docker (a zip in deploy/docker) # # The game data is never in the image: mount the folder that holds maps/ at # /data, read-only. /state is the only folder the server writes (config.toml, @@ -15,11 +18,32 @@ FROM debian:bookworm-slim AS release RUN apt-get update \ - && apt-get install -y --no-install-recommends unzip \ + && apt-get install -y --no-install-recommends unzip curl ca-certificates jq \ && rm -rf /var/lib/apt/lists/* -# a URL, or a file beside this Dockerfile -ARG RELEASE=https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-release.zip -ADD ${RELEASE} /tmp/halo-linux-release.zip +# "latest", a release's tag (build-15), a zip's https URL, or a zip beside +# this Dockerfile +ARG RELEASE=latest +# HaloCustoms' release feed (port/linux/src/halocustoms_services.h): +# /latest gives the latest release as GitHub's releases/latest does +# (its tag_name, and its assets' digest), and a release's zips are at +# /download//; the latest's is checked against its digest +ARG RELEASES=https://halocustoms.org/updates/releases +COPY . /tmp/context +RUN set -e; \ + asset=halo-linux-release.zip; digest=; \ + case "$RELEASE" in \ + https://*) url="$RELEASE" ;; \ + *.zip) cp "/tmp/context/$RELEASE" /tmp/$asset; url= ;; \ + latest) release="$(curl -fsSL --retry 3 "$RELEASES/latest")"; \ + tag="$(printf '%s' "$release" | jq -r '.tag_name // empty')"; \ + echo "$tag" | grep -Eq '^build-[0-9]+$'; \ + digest="$(printf '%s' "$release" | jq -r --arg name "$asset" \ + '[.assets[]? | select(.name == $name) | .digest // empty][0] // empty' | sed -n 's/^sha256://p')"; \ + url="$RELEASES/download/$tag/$asset" ;; \ + *) echo "$RELEASE" | grep -Eq '^[A-Za-z0-9._-]+$'; url="$RELEASES/download/$RELEASE/$asset" ;; \ + esac; \ + if [ ! -f /tmp/$asset ]; then curl -fL --retry 3 -o /tmp/$asset "$url"; fi; \ + if [ -n "$digest" ]; then echo "$digest /tmp/$asset" | sha256sum -c -; fi COPY healthz.sh /opt/opence/healthz.sh RUN unzip -q /tmp/halo-linux-release.zip -d /opt/opence \ && test -f /opt/opence/halo \ diff --git a/deploy/docker/README.md b/deploy/docker/README.md index 8d2b9e9..78a6b22 100644 --- a/deploy/docker/README.md +++ b/deploy/docker/README.md @@ -1,11 +1,16 @@ # The dedicated server in a container -`ghcr.io/opencommunityedition/opence-server` is the dedicated server -(`docs/dedicated_server.md`) as a container image: a release's -`halo-linux-release.zip` on Debian 12 with the 32-bit C runtime, run as -`halo --server` by the user 10001. CI builds it from each release -(`.github/workflows/server-image.yml`) and signs it with cosign; the tags are -`latest`, the release's (`build-206`) and `sha-`. +The Dockerfile beside this file makes the HaloCustoms dedicated server +(based on OpenCE: `docs/dedicated_server.md`) a container image: a +HaloCustoms release's `halo-linux-release.zip` on Debian 12 with the 32-bit +C runtime, run as `halo --server` by the user 10001. + +HaloCustoms has no image registry: there is no image to pull. Build it +yourself from a release ("Building it", below), as `halocustoms-server`, +which the commands here, `compose.yaml` and the Helm chart use (push it to +a registry of your own for the chart). OpenCE's images +(`ghcr.io/opencommunityedition/opence-server`) are stock OpenCE, without +HaloCustoms' services. The game data is never in the image. Mount the folder that holds `maps/` (from your own copy of the game: port/linux/README.md, "Game data") at @@ -22,7 +27,7 @@ docker run -d --name halo --restart unless-stopped \ -e HALO_SERVER_NAME="My server" \ -e HALO_SERVER_ROTATION="bloodgulch:ctf, sidewinder:team_slayer" \ -e HALO_SERVER_WEB_PASSWORD=changeme-please \ - ghcr.io/opencommunityedition/opence-server:latest + halocustoms-server docker logs -f halo ``` @@ -103,20 +108,30 @@ frames run. With `HALO_SERVER_WEB_PORT=0` it always passes. ## Building it ```sh -docker build -t opence-server deploy/docker # the latest release -docker build -t opence-server --build-arg RELEASE=https://github.com/OpenCommunityEdition/OpenCE/releases/download/build-206/halo-linux-release.zip deploy/docker +docker build -t halocustoms-server deploy/docker # the latest release +docker build -t halocustoms-server --build-arg RELEASE=build-15 deploy/docker # a release by its tag ``` +The build asks HaloCustoms' release feed (`https://halocustoms.org/updates/releases`, +`--build-arg RELEASES=`: `port/linux/src/halocustoms_services.h`) for +the latest release, downloads its `halo-linux-release.zip` from +`/download//`, and checks it against the SHA-256 digest the +feed gives it (the build stops if it differs). A tag's zip is downloaded +from there with no check. `RELEASE` may also be a zip's URL. The digest is +the feed's own: unlike the game's updater, the build does not yet check the +release's signed manifest with the release key (FORK.md, "Releases"), which +is later work, so check the image you build. + A build of your own (`python tools/ci_build.py linux release` leaves `dist/halo-linux-release/`) goes in as a zip beside the Dockerfile: ```sh (cd dist/halo-linux-release && zip -qr ../../deploy/docker/halo-linux-release.zip .) -docker build -t opence-server --build-arg RELEASE=halo-linux-release.zip deploy/docker +docker build -t halocustoms-server --build-arg RELEASE=halo-linux-release.zip deploy/docker ``` -`RELEASE` is a URL or a file beside the Dockerfile. The image is 32-bit -x86 and runs on amd64 hosts only. +`RELEASE` is `latest`, a tag, a URL or a file beside the Dockerfile. The +image is 32-bit x86 and runs on amd64 hosts only. ## Networking @@ -125,10 +140,5 @@ to one, and the server needs no hole punching of its own. System link (UDP 5150 and 5151, TCP 5150 and 5153) finds other machines only with `--network host`. -Verify an image's signature: - -```sh -cosign verify ghcr.io/opencommunityedition/opence-server:latest \ - --certificate-identity-regexp '^https://github\.com/OpenCommunityEdition/OpenCE/\.github/workflows/server-image\.yml@' \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com -``` +An image you build yourself is not signed; OpenCE's own images are, with +cosign, by OpenCE's workflow (`.github/workflows/server-image.yml`). diff --git a/deploy/docker/compose.yaml b/deploy/docker/compose.yaml index b030282..accea4f 100644 --- a/deploy/docker/compose.yaml +++ b/deploy/docker/compose.yaml @@ -1,4 +1,5 @@ -# The dedicated server with Docker Compose (deploy/docker/README.md): +# The HaloCustoms dedicated server with Docker Compose (deploy/docker/README.md), +# from the image built with `docker build -t halocustoms-server deploy/docker`: # # docker compose -f deploy/docker/compose.yaml up -d # docker compose -f deploy/docker/compose.yaml --profile https up -d (with Caddy in front of the web port) @@ -10,7 +11,9 @@ name: opence services: server: - image: ${OPENCE_IMAGE:-ghcr.io/opencommunityedition/opence-server:latest} + # (the image built from a HaloCustoms release: HaloCustoms has no + # registry, deploy/docker/README.md, "Building it") + image: ${OPENCE_IMAGE:-halocustoms-server} restart: unless-stopped read_only: true cap_drop: [ALL] diff --git a/deploy/helm/opence-server/Chart.yaml b/deploy/helm/opence-server/Chart.yaml index b2aa99f..4e84eef 100644 --- a/deploy/helm/opence-server/Chart.yaml +++ b/deploy/helm/opence-server/Chart.yaml @@ -1,16 +1,17 @@ apiVersion: v2 name: opence-server description: >- - The OpenCE dedicated server: a headless Halo: Combat Evolved host that plays games one after - another, with no window, sound or player of its own. The game data (maps) is mounted from a - volume you provide. + The HaloCustoms dedicated server, based on OpenCE: a headless Halo: Combat Evolved host that + plays games one after another, with no window, sound or player of its own. The game data (maps) + is mounted from a volume you provide. type: application version: 0.1.0 kubeVersion: ">=1.29.0-0" # Informational only: which image runs is set by image.repository, image.tag and image.digest. appVersion: "latest" -home: https://github.com/OpenCommunityEdition/OpenCE +home: https://halocustoms.org sources: + - https://halocustoms.org/source - https://github.com/OpenCommunityEdition/OpenCE/tree/main/deploy/helm/opence-server keywords: - halo diff --git a/deploy/helm/opence-server/README.md b/deploy/helm/opence-server/README.md index c28d9a3..ed167a0 100644 --- a/deploy/helm/opence-server/README.md +++ b/deploy/helm/opence-server/README.md @@ -1,10 +1,17 @@ # opence-server -The OpenCE dedicated server (`docs/dedicated_server.md`) on Kubernetes: one -pod of the image `ghcr.io/opencommunityedition/opence-server` +The HaloCustoms dedicated server, based on OpenCE (`docs/dedicated_server.md`), +on Kubernetes: one pod of the image built from a HaloCustoms release (`deploy/docker`), its tunnel port behind a UDP Service, its web page behind a ClusterIP Service, probes on `GET /healthz`, and a NetworkPolicy. +HaloCustoms has no image registry: build the image (`deploy/docker/README.md`, +"Building it"), push it to a registry your cluster pulls from, and give it +as `image.repository` (the default, `halocustoms-server`, is the name the +build gives it, which a cluster finds only if it was loaded into its nodes, +as kind's and minikube's `image load` do). The chart's name stays +`opence-server`, as installs of it name their resources after it. + The game data is never in the image or the chart: give a volume that holds `maps/` from your own copy of the game. diff --git a/deploy/helm/opence-server/templates/NOTES.txt b/deploy/helm/opence-server/templates/NOTES.txt index 03dfd06..77a751a 100644 --- a/deploy/helm/opence-server/templates/NOTES.txt +++ b/deploy/helm/opence-server/templates/NOTES.txt @@ -1,4 +1,4 @@ -OpenCE dedicated server {{ .Chart.Version }}: {{ include "opence-server.fullname" . }} in namespace {{ .Release.Namespace }}. +HaloCustoms dedicated server (based on OpenCE) {{ .Chart.Version }}: {{ include "opence-server.fullname" . }} in namespace {{ .Release.Namespace }}. Rotation: {{ join ", " .Values.server.rotation | default "the server's default" }}. {{- if kindIs "invalid" .Values.server.public }} diff --git a/deploy/helm/opence-server/tests/deployment_test.yaml b/deploy/helm/opence-server/tests/deployment_test.yaml index beea2a6..c982c4d 100644 --- a/deploy/helm/opence-server/tests/deployment_test.yaml +++ b/deploy/helm/opence-server/tests/deployment_test.yaml @@ -214,8 +214,8 @@ tests: asserts: - equal: path: 'spec.template.spec.containers[0].image' - value: ghcr.io/opencommunityedition/opence-server:build-206@sha256:2222222222222222222222222222222222222222222222222222222222222222 + value: halocustoms-server:build-206@sha256:2222222222222222222222222222222222222222222222222222222222222222 - it: uses the appVersion as the default tag asserts: - - equal: { path: 'spec.template.spec.containers[0].image', value: "ghcr.io/opencommunityedition/opence-server:latest" } + - equal: { path: 'spec.template.spec.containers[0].image', value: "halocustoms-server:latest" } diff --git a/deploy/helm/opence-server/values.yaml b/deploy/helm/opence-server/values.yaml index 4488c08..a2ddac3 100644 --- a/deploy/helm/opence-server/values.yaml +++ b/deploy/helm/opence-server/values.yaml @@ -1,11 +1,14 @@ -# opence-server: the OpenCE dedicated server (docs/dedicated_server.md). One replica: the server -# owns its tunnel port and the address players reach it at. +# opence-server: the HaloCustoms dedicated server, based on OpenCE (docs/dedicated_server.md). One +# replica: the server owns its tunnel port and the address players reach it at. nameOverride: "" fullnameOverride: "" image: - repository: ghcr.io/opencommunityedition/opence-server + # HaloCustoms has no image registry: build the image from a HaloCustoms release + # (deploy/docker/README.md, "Building it") and push it to a registry your cluster pulls from, + # then name it here (registry.example.org/halocustoms-server). + repository: halocustoms-server # Empty: the chart's appVersion (latest). A release's tag (build-206) or sha- pins it; # @sha256:... as Argo CD Image Updater writes it works too. tag: "" @@ -50,7 +53,7 @@ customMaps: # setting is then config.toml's, or the server's default (named below), and can be changed from # the web page. server: - # server.name: 15 characters of printable ASCII (default "OpenCE Server"). + # server.name: 15 characters of printable ASCII (default "HaloCustoms"). name: "" # server.rotation: map[:gametype[+rule=value...]] entries, played in turn, e.g. # [bloodgulch:ctf, sidewinder:team_slayer, prisoner:slayer] (the default). diff --git a/deploy/mcsmanager/README.md b/deploy/mcsmanager/README.md index d5770dc..d98688f 100644 --- a/deploy/mcsmanager/README.md +++ b/deploy/mcsmanager/README.md @@ -1,8 +1,10 @@ # The dedicated server on MCSManager `mcsmanager-config.json` holds two MCSManager quick-install entries for the -dedicated server (`docs/dedicated_server.md`). Each unzips a release's -`halo-linux-release.zip` into the instance's folder and runs +dedicated server, HaloCustoms' (based on OpenCE: `docs/dedicated_server.md`). +Each unzips a HaloCustoms release's `halo-linux-release.zip` (the latest, +from `https://halocustoms.org/updates/releases/latest/download/`) into the +instance's folder and runs `./halo --server` there, stopped with `quit`. | Entry | How it runs | diff --git a/deploy/mcsmanager/mcsmanager-config.json b/deploy/mcsmanager/mcsmanager-config.json index ecc3343..bc60c5c 100644 --- a/deploy/mcsmanager/mcsmanager-config.json +++ b/deploy/mcsmanager/mcsmanager-config.json @@ -1,5 +1,5 @@ { - "_comment": "MCSManager quick-install entries for the OpenCE dedicated server (docs/dedicated_server.md). Each package unzips the release into the instance's folder; put the maps/ folder of your own copy of the game beside halo before the first start. Settings: config.toml beside halo, written at the first start, or HALO_* variables.", + "_comment": "MCSManager quick-install entries for the HaloCustoms dedicated server, based on OpenCE (docs/dedicated_server.md). Each package unzips the release into the instance's folder; put the maps/ folder of your own copy of the game beside halo before the first start. Settings: config.toml beside halo, written at the first start, or HALO_* variables.", "languages": [ { "label": "English", @@ -10,17 +10,17 @@ { "language": "en_us", "platform": "Linux", - "title": "OpenCE Dedicated Server", + "title": "HaloCustoms Dedicated Server", "category": "Halo: Combat Evolved", "runtime": "Linux x86-64 with 32-bit libraries", "hardware": "1 CPU core, 600 MB of memory", "size": "45 MB, and the game data you upload", "author": "OpenCE", - "targetLink": "https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-release.zip", + "targetLink": "https://halocustoms.org/updates/releases/latest/download/halo-linux-release.zip", "description": "The dedicated server run on the node itself, which needs the 32-bit C runtime (Debian and Ubuntu: dpkg --add-architecture i386 && apt install libc6:i386). Open UDP 2302; the web page is on http://127.0.0.1:8080/.", "remark": "Host", "setupInfo": { - "nickname": "OpenCE Dedicated Server", + "nickname": "HaloCustoms Dedicated Server", "startCommand": "./halo --server", "stopCommand": "quit", "ie": "utf-8", @@ -65,17 +65,17 @@ { "language": "en_us", "platform": "Linux", - "title": "OpenCE Dedicated Server", + "title": "HaloCustoms Dedicated Server", "category": "Halo: Combat Evolved", "runtime": "Linux x86-64 with 32-bit libraries", "hardware": "1 CPU core, 600 MB of memory", "size": "45 MB, and the game data you upload", "author": "OpenCE", - "targetLink": "https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-release.zip", + "targetLink": "https://halocustoms.org/updates/releases/latest/download/halo-linux-release.zip", "description": "The dedicated server in a container of ghcr.io/ptero-eggs/games:source (Debian 12 with the 32-bit libraries), the instance's folder mounted as its working folder; the save root (with the map cache) and the logs are kept in it, as the container is made again at each start. Publishes UDP 2302 and the web page on the node's 127.0.0.1:8080 (each instance on a node needs ports of its own).", "remark": "Docker", "setupInfo": { - "nickname": "OpenCE Dedicated Server", + "nickname": "HaloCustoms Dedicated Server", "startCommand": "./halo --server", "stopCommand": "quit", "ie": "utf-8", diff --git a/deploy/pterodactyl/README.md b/deploy/pterodactyl/README.md index 81b5852..df7991d 100644 --- a/deploy/pterodactyl/README.md +++ b/deploy/pterodactyl/README.md @@ -1,8 +1,10 @@ # The dedicated server on Pterodactyl and Pelican `egg-opence-dedicated-server.json` is an egg (`PTDL_v2`) that installs a -release of OpenCE from GitHub and runs it as a dedicated server -(`docs/dedicated_server.md`). Pelican imports the same file. +release of HaloCustoms (based on OpenCE) from HaloCustoms' release feed and +runs it as a dedicated server (`docs/dedicated_server.md`). Pelican imports +the same file. (The file keeps OpenCE's name, which panels that imported it +know it by.) ## Importing it @@ -63,7 +65,14 @@ change it. The startup command sets `HALO_NET_TUNNEL_PORT` to the primary allocation's port and `HALO_LOG_ROOT` to `logs/`. Two variables are the administrator's only: Web UI bind (`0.0.0.0`, as the allocation's address is the node's, -not the container's) and GitHub repository (`OpenCommunityEdition/OpenCE`). +not the container's) and Release feed (`HALOCUSTOMS_RELEASES`: HaloCustoms' +release feed, `https://halocustoms.org/updates/releases`, whose `latest` +gives the latest release as GitHub's releases/latest does, and whose +`download//` holds a release's zips). The latest release's zip is +checked against the SHA-256 digest the feed gives it. The digest is the +feed's own: unlike the game's updater, the installation does not yet check +the release's signed manifest with the release key (FORK.md, "Releases"), +which is later work. ## The web page diff --git a/deploy/pterodactyl/egg-opence-dedicated-server.json b/deploy/pterodactyl/egg-opence-dedicated-server.json index 1268c81..8cbc42a 100644 --- a/deploy/pterodactyl/egg-opence-dedicated-server.json +++ b/deploy/pterodactyl/egg-opence-dedicated-server.json @@ -5,9 +5,9 @@ "update_url": null }, "exported_at": "2026-10-24T00:00:00+00:00", - "name": "OpenCE Dedicated Server", + "name": "HaloCustoms Dedicated Server", "author": "opence@halocombatevolved.com", - "description": "A dedicated server of OpenCE, the port of Halo: Combat Evolved: hosts multiplayer games one after another, with no window, sound or player of its own. Installs a release from GitHub. The game data is yours to upload: put the maps/ folder of your own copy of the game in the server's folder over SFTP; the server does not start without it.", + "description": "A dedicated server of HaloCustoms (based on OpenCE, the port of Halo: Combat Evolved): hosts multiplayer games one after another, with no window, sound or player of its own. Installs a release from HaloCustoms' release feed. The game data is yours to upload: put the maps/ folder of your own copy of the game in the server's folder over SFTP; the server does not start without it.", "features": [], "docker_images": { "Debian 12 with 32-bit libraries (Pterodactyl)": "ghcr.io/ptero-eggs/games:source", @@ -23,7 +23,7 @@ }, "scripts": { "installation": { - "script": "#!/bin/bash\n# The OpenCE dedicated server egg's installation script (Pterodactyl and\n# Pelican), run as root in ghcr.io/ptero-eggs/installers:debian with the\n# server's folder at /mnt/server. tools/egg_build.py copies it into\n# egg-opence-dedicated-server.json.\n#\n# OPENCE_RELEASE: \"latest\" or a release tag (build-206).\n# OPENCE_REPOSITORY: the GitHub repository (owner/name) to download from.\n#\n# A reinstall replaces the release's files and keeps the owner's:\n# config.toml, init.txt, publisher.key, gametypes/, custom_maps/, maps/ and\n# logs/. The panel ignores this script's exit code, so each failure says\n# what went wrong.\n\nset -u\n\nSERVER_DIR=\"${SERVER_DIR:-/mnt/server}\"\nRELEASE=\"${OPENCE_RELEASE:-latest}\"\nREPOSITORY=\"${OPENCE_REPOSITORY:-OpenCommunityEdition/OpenCE}\"\nGITHUB_URL=\"${OPENCE_GITHUB_URL:-https://github.com}\"\nGITHUB_API=\"${OPENCE_GITHUB_API:-https://api.github.com}\"\nASSET=halo-linux-release.zip\n\nfail() {\n\techo \"INSTALL FAILED: $*\"\n\texit 1\n}\n\nif ! [[ \"$REPOSITORY\" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then\n\tfail \"the repository \\\"$REPOSITORY\\\" is not owner/name\"\nfi\nif [ -z \"$RELEASE\" ] || [ \"$RELEASE\" = latest ]; then\n\t# (no API call, so no rate limit)\n\turl=\"$GITHUB_URL/$REPOSITORY/releases/latest/download/$ASSET\"\nelse\n\tif ! [[ \"$RELEASE\" =~ ^[A-Za-z0-9._-]+$ ]]; then\n\t\tfail \"the release \\\"$RELEASE\\\" is not a tag name (build-206) or latest\"\n\tfi\n\techo \"Looking up release $RELEASE of $REPOSITORY\"\n\trelease_json=\"$(curl -fsSL --retry 3 -H 'Accept: application/vnd.github+json' \\\n\t\t\"$GITHUB_API/repos/$REPOSITORY/releases/tags/$RELEASE\")\" \\\n\t\t|| fail \"no release $RELEASE in $REPOSITORY (or GitHub's API did not answer)\"\n\turl=\"$(printf '%s' \"$release_json\" | jq -r --arg name \"$ASSET\" \\\n\t\t'[.assets[]? | select(.name == $name) | .browser_download_url][0] // empty')\"\n\t[ -n \"$url\" ] || fail \"release $RELEASE has no $ASSET\"\nfi\n\nmkdir -p \"$SERVER_DIR\" || fail \"cannot make $SERVER_DIR\"\ncd \"$SERVER_DIR\" || fail \"cannot enter $SERVER_DIR\"\n\nwork=\"$(mktemp -d)\" || fail \"cannot make a temporary folder\"\ntrap 'rm -rf \"$work\"' EXIT\n\necho \"Downloading $url\"\ncurl -fL --retry 3 --progress-bar -o \"$work/$ASSET\" \"$url\" || fail \"the download of $url failed\"\nunzip -tq \"$work/$ASSET\" > /dev/null || fail \"$ASSET is not a zip file\"\nmkdir \"$work/release\"\nunzip -q \"$work/$ASSET\" -d \"$work/release\" || fail \"cannot unzip $ASSET\"\n[ -f \"$work/release/halo\" ] || fail \"$ASSET has no halo executable\"\n\n# the release's files, over the old release's; the owner's are never in it,\n# and gametypes/ only adds the examples not already there\nfor entry in \"$work/release\"/* \"$work/release\"/.[!.]*; do\n\t[ -e \"$entry\" ] || continue\n\tname=\"$(basename \"$entry\")\"\n\tcase \"$name\" in\n\tconfig.toml | init.txt | publisher.key | custom_maps | maps | logs)\n\t\tcontinue\n\t\t;;\n\tgametypes)\n\t\tmkdir -p gametypes\n\t\tcp -rn \"$entry\"/. gametypes/\n\t\tcontinue\n\t\t;;\n\tesac\n\trm -rf \"./$name\"\n\tcp -a \"$entry\" \"./$name\" || fail \"cannot copy $name\"\ndone\n\nchmod 0755 halo\nif [ ! -f halo-server ]; then\n\tprintf '#!/bin/sh\\nexec \"$(dirname \"$0\")/halo\" --server \"$@\"\\n' > halo-server\nfi\nchmod 0755 halo-server\nmkdir -p maps custom_maps gametypes logs\n\necho \"Checking that the server runs\"\nif version=\"$(HOME=\"$work\" HALO_SAVE_ROOT=\"$work/saves\" timeout 30 ./halo --version 2>&1)\"; then\n\techo \"$version\"\nelse\n\techo \"WARNING: ./halo --version did not run. The server needs the 32-bit C runtime\"\n\techo \"WARNING: (the egg's ghcr.io/ptero-eggs/games:source image has it):\"\n\techo \"$version\"\nfi\n\nif [ ! -f maps/ui.map ]; then\n\techo \"No game data yet: upload your maps/ folder (ui.map and the multiplayer maps)\"\n\techo \"over SFTP into the server's folder before starting it.\"\nfi\necho \"Installation completed\"\n", + "script": "#!/bin/bash\n# The HaloCustoms dedicated server egg's installation script (Pterodactyl and\n# Pelican; HaloCustoms is based on OpenCE), run as root in\n# ghcr.io/ptero-eggs/installers:debian with the server's folder at\n# /mnt/server. tools/egg_build.py copies it into\n# egg-opence-dedicated-server.json.\n#\n# OPENCE_RELEASE: \"latest\" or a release tag (build-15).\n# HALOCUSTOMS_RELEASES: HaloCustoms' release feed: /latest gives the\n# latest release as GitHub's releases/latest does, and a release's zips are\n# at /download// (port/linux/src/halocustoms_services.h).\n#\n# A reinstall replaces the release's files and keeps the owner's:\n# config.toml, init.txt, publisher.key, gametypes/, custom_maps/, maps/ and\n# logs/. The panel ignores this script's exit code, so each failure says\n# what went wrong.\n\nset -u\n\nSERVER_DIR=\"${SERVER_DIR:-/mnt/server}\"\nRELEASE=\"${OPENCE_RELEASE:-latest}\"\nRELEASES=\"${HALOCUSTOMS_RELEASES:-https://halocustoms.org/updates/releases}\"\nASSET=halo-linux-release.zip\nDIGEST=\n\nfail() {\n\techo \"INSTALL FAILED: $*\"\n\texit 1\n}\n\nif ! [[ \"$RELEASES\" =~ ^https?://[A-Za-z0-9.:/_-]+$ ]]; then\n\tfail \"the release feed \\\"$RELEASES\\\" is not an http(s) address\"\nfi\nif [ -z \"$RELEASE\" ] || [ \"$RELEASE\" = latest ]; then\n\techo \"Looking up the latest release in $RELEASES\"\n\trelease_json=\"$(curl -fsSL --retry 3 -H 'Accept: application/json' \"$RELEASES/latest\")\" \\\n\t\t|| fail \"no latest release in $RELEASES (or the feed did not answer)\"\n\tRELEASE=\"$(printf '%s' \"$release_json\" | jq -r '.tag_name // empty')\"\n\t[[ \"$RELEASE\" =~ ^build-[0-9]+$ ]] || fail \"the feed's latest release has no tag build-\"\n\t# (its zip's SHA-256, which the download is checked against)\n\tDIGEST=\"$(printf '%s' \"$release_json\" | jq -r --arg name \"$ASSET\" \\\n\t\t'[.assets[]? | select(.name == $name) | .digest // empty][0] // empty' | sed -n 's/^sha256://p')\"\n\t[ -n \"$DIGEST\" ] || fail \"release $RELEASE has no $ASSET\"\nelif ! [[ \"$RELEASE\" =~ ^[A-Za-z0-9._-]+$ ]]; then\n\tfail \"the release \\\"$RELEASE\\\" is not a tag name (build-15) or latest\"\nfi\nurl=\"$RELEASES/download/$RELEASE/$ASSET\"\n\nmkdir -p \"$SERVER_DIR\" || fail \"cannot make $SERVER_DIR\"\ncd \"$SERVER_DIR\" || fail \"cannot enter $SERVER_DIR\"\n\nwork=\"$(mktemp -d)\" || fail \"cannot make a temporary folder\"\ntrap 'rm -rf \"$work\"' EXIT\n\necho \"Downloading $url\"\ncurl -fL --retry 3 --progress-bar -o \"$work/$ASSET\" \"$url\" || fail \"no release $RELEASE in $RELEASES (the download of $url failed)\"\nif [ -n \"$DIGEST\" ]; then\n\techo \"$DIGEST $work/$ASSET\" | sha256sum -c --quiet - > /dev/null 2>&1 \\\n\t\t|| fail \"the download of $url is not release $RELEASE's: its SHA-256 is not the feed's\"\nfi\nunzip -tq \"$work/$ASSET\" > /dev/null || fail \"$ASSET is not a zip file\"\nmkdir \"$work/release\"\nunzip -q \"$work/$ASSET\" -d \"$work/release\" || fail \"cannot unzip $ASSET\"\n[ -f \"$work/release/halo\" ] || fail \"$ASSET has no halo executable\"\n\n# the release's files, over the old release's; the owner's are never in it,\n# and gametypes/ only adds the examples not already there\nfor entry in \"$work/release\"/* \"$work/release\"/.[!.]*; do\n\t[ -e \"$entry\" ] || continue\n\tname=\"$(basename \"$entry\")\"\n\tcase \"$name\" in\n\tconfig.toml | init.txt | publisher.key | custom_maps | maps | logs)\n\t\tcontinue\n\t\t;;\n\tgametypes)\n\t\tmkdir -p gametypes\n\t\tcp -rn \"$entry\"/. gametypes/\n\t\tcontinue\n\t\t;;\n\tesac\n\trm -rf \"./$name\"\n\tcp -a \"$entry\" \"./$name\" || fail \"cannot copy $name\"\ndone\n\nchmod 0755 halo\nif [ ! -f halo-server ]; then\n\tprintf '#!/bin/sh\\nexec \"$(dirname \"$0\")/halo\" --server \"$@\"\\n' > halo-server\nfi\nchmod 0755 halo-server\nmkdir -p maps custom_maps gametypes logs\n\necho \"Checking that the server runs\"\nif version=\"$(HOME=\"$work\" HALO_SAVE_ROOT=\"$work/saves\" timeout 30 ./halo --version 2>&1)\"; then\n\techo \"$version\"\nelse\n\techo \"WARNING: ./halo --version did not run. The server needs the 32-bit C runtime\"\n\techo \"WARNING: (the egg's ghcr.io/ptero-eggs/games:source image has it):\"\n\techo \"$version\"\nfi\n\nif [ ! -f maps/ui.map ]; then\n\techo \"No game data yet: upload your maps/ folder (ui.map and the multiplayer maps)\"\n\techo \"over SFTP into the server's folder before starting it.\"\nfi\necho \"Installation completed\"\n", "container": "ghcr.io/ptero-eggs/installers:debian", "entrypoint": "bash" } @@ -31,7 +31,7 @@ "variables": [ { "name": "Release", - "description": "A release tag (build-206) or latest. A change takes effect on Reinstall (Settings tab), which keeps config.toml, init.txt, gametypes/, custom_maps/ and maps/.", + "description": "A release tag (build-15) or latest. A change takes effect on Reinstall (Settings tab), which keeps config.toml, init.txt, gametypes/, custom_maps/ and maps/.", "env_variable": "OPENCE_RELEASE", "default_value": "latest", "user_viewable": true, @@ -43,7 +43,7 @@ "name": "Server name", "description": "The game's name in the lobby and the server browser: 15 characters of printable ASCII.", "env_variable": "HALO_SERVER_NAME", - "default_value": "OpenCE Server", + "default_value": "HaloCustoms", "user_viewable": true, "user_editable": true, "rules": "required|string|max:15|regex:/^[\\x20-\\x7E]+$/", @@ -190,13 +190,13 @@ "field_type": "text" }, { - "name": "GitHub repository", - "description": "owner/name the installation downloads the release from.", - "env_variable": "OPENCE_REPOSITORY", - "default_value": "OpenCommunityEdition/OpenCE", + "name": "Release feed", + "description": "HaloCustoms' release feed the installation downloads the release from: /latest gives the latest release, and a release's zips are at /download//.", + "env_variable": "HALOCUSTOMS_RELEASES", + "default_value": "https://halocustoms.org/updates/releases", "user_viewable": false, "user_editable": false, - "rules": "required|string|max:100|regex:/^[A-Za-z0-9._-]+\\/[A-Za-z0-9._-]+$/", + "rules": "required|string|max:200|regex:/^https?:\\/\\/[A-Za-z0-9.:\\/_-]+$/", "field_type": "text" } ] diff --git a/deploy/pterodactyl/install.sh b/deploy/pterodactyl/install.sh index 0fb394a..47def24 100755 --- a/deploy/pterodactyl/install.sh +++ b/deploy/pterodactyl/install.sh @@ -1,11 +1,14 @@ #!/bin/bash -# The OpenCE dedicated server egg's installation script (Pterodactyl and -# Pelican), run as root in ghcr.io/ptero-eggs/installers:debian with the -# server's folder at /mnt/server. tools/egg_build.py copies it into +# The HaloCustoms dedicated server egg's installation script (Pterodactyl and +# Pelican; HaloCustoms is based on OpenCE), run as root in +# ghcr.io/ptero-eggs/installers:debian with the server's folder at +# /mnt/server. tools/egg_build.py copies it into # egg-opence-dedicated-server.json. # -# OPENCE_RELEASE: "latest" or a release tag (build-206). -# OPENCE_REPOSITORY: the GitHub repository (owner/name) to download from. +# OPENCE_RELEASE: "latest" or a release tag (build-15). +# HALOCUSTOMS_RELEASES: HaloCustoms' release feed: /latest gives the +# latest release as GitHub's releases/latest does, and a release's zips are +# at /download// (port/linux/src/halocustoms_services.h). # # A reinstall replaces the release's files and keeps the owner's: # config.toml, init.txt, publisher.key, gametypes/, custom_maps/, maps/ and @@ -16,34 +19,32 @@ set -u SERVER_DIR="${SERVER_DIR:-/mnt/server}" RELEASE="${OPENCE_RELEASE:-latest}" -REPOSITORY="${OPENCE_REPOSITORY:-OpenCommunityEdition/OpenCE}" -GITHUB_URL="${OPENCE_GITHUB_URL:-https://github.com}" -GITHUB_API="${OPENCE_GITHUB_API:-https://api.github.com}" +RELEASES="${HALOCUSTOMS_RELEASES:-https://halocustoms.org/updates/releases}" ASSET=halo-linux-release.zip +DIGEST= fail() { echo "INSTALL FAILED: $*" exit 1 } -if ! [[ "$REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then - fail "the repository \"$REPOSITORY\" is not owner/name" +if ! [[ "$RELEASES" =~ ^https?://[A-Za-z0-9.:/_-]+$ ]]; then + fail "the release feed \"$RELEASES\" is not an http(s) address" fi if [ -z "$RELEASE" ] || [ "$RELEASE" = latest ]; then - # (no API call, so no rate limit) - url="$GITHUB_URL/$REPOSITORY/releases/latest/download/$ASSET" -else - if ! [[ "$RELEASE" =~ ^[A-Za-z0-9._-]+$ ]]; then - fail "the release \"$RELEASE\" is not a tag name (build-206) or latest" - fi - echo "Looking up release $RELEASE of $REPOSITORY" - release_json="$(curl -fsSL --retry 3 -H 'Accept: application/vnd.github+json' \ - "$GITHUB_API/repos/$REPOSITORY/releases/tags/$RELEASE")" \ - || fail "no release $RELEASE in $REPOSITORY (or GitHub's API did not answer)" - url="$(printf '%s' "$release_json" | jq -r --arg name "$ASSET" \ - '[.assets[]? | select(.name == $name) | .browser_download_url][0] // empty')" - [ -n "$url" ] || fail "release $RELEASE has no $ASSET" + echo "Looking up the latest release in $RELEASES" + release_json="$(curl -fsSL --retry 3 -H 'Accept: application/json' "$RELEASES/latest")" \ + || fail "no latest release in $RELEASES (or the feed did not answer)" + RELEASE="$(printf '%s' "$release_json" | jq -r '.tag_name // empty')" + [[ "$RELEASE" =~ ^build-[0-9]+$ ]] || fail "the feed's latest release has no tag build-" + # (its zip's SHA-256, which the download is checked against) + DIGEST="$(printf '%s' "$release_json" | jq -r --arg name "$ASSET" \ + '[.assets[]? | select(.name == $name) | .digest // empty][0] // empty' | sed -n 's/^sha256://p')" + [ -n "$DIGEST" ] || fail "release $RELEASE has no $ASSET" +elif ! [[ "$RELEASE" =~ ^[A-Za-z0-9._-]+$ ]]; then + fail "the release \"$RELEASE\" is not a tag name (build-15) or latest" fi +url="$RELEASES/download/$RELEASE/$ASSET" mkdir -p "$SERVER_DIR" || fail "cannot make $SERVER_DIR" cd "$SERVER_DIR" || fail "cannot enter $SERVER_DIR" @@ -52,7 +53,11 @@ work="$(mktemp -d)" || fail "cannot make a temporary folder" trap 'rm -rf "$work"' EXIT echo "Downloading $url" -curl -fL --retry 3 --progress-bar -o "$work/$ASSET" "$url" || fail "the download of $url failed" +curl -fL --retry 3 --progress-bar -o "$work/$ASSET" "$url" || fail "no release $RELEASE in $RELEASES (the download of $url failed)" +if [ -n "$DIGEST" ]; then + echo "$DIGEST $work/$ASSET" | sha256sum -c --quiet - > /dev/null 2>&1 \ + || fail "the download of $url is not release $RELEASE's: its SHA-256 is not the feed's" +fi unzip -tq "$work/$ASSET" > /dev/null || fail "$ASSET is not a zip file" mkdir "$work/release" unzip -q "$work/$ASSET" -d "$work/release" || fail "cannot unzip $ASSET" diff --git a/deploy/pufferpanel/README.md b/deploy/pufferpanel/README.md index e14eadb..81b0023 100644 --- a/deploy/pufferpanel/README.md +++ b/deploy/pufferpanel/README.md @@ -2,7 +2,10 @@ `template.json` is a PufferPanel template for the dedicated server (`docs/dedicated_server.md`), run in PufferPanel's host environment: it -downloads a release's `halo-linux-release.zip` into the server's folder, +downloads a HaloCustoms release's `halo-linux-release.zip` (based on OpenCE; +the latest, from `https://halocustoms.org/updates/releases/latest/download/`, +or a release's own, `.../updates/releases/download//`) into the +server's folder, runs `./halo --server` there, and stops it by writing `quit` to its standard input. diff --git a/deploy/pufferpanel/template.json b/deploy/pufferpanel/template.json index 5066268..e9e98d3 100644 --- a/deploy/pufferpanel/template.json +++ b/deploy/pufferpanel/template.json @@ -1,6 +1,6 @@ { "type": "opence", - "display": "OpenCE Dedicated Server", + "display": "HaloCustoms Dedicated Server", "install": [ { "type": "download", @@ -45,9 +45,9 @@ "release_url": { "type": "string", "display": "Release", - "desc": "The release's halo-linux-release.zip: .../releases/latest/download/halo-linux-release.zip, or .../releases/download//halo-linux-release.zip. Used when the server is installed again.", + "desc": "A HaloCustoms release's halo-linux-release.zip: .../updates/releases/latest/download/halo-linux-release.zip (the latest), or .../updates/releases/download//halo-linux-release.zip (a release's, build-). Used when the server is installed again.", "required": true, - "value": "https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-release.zip", + "value": "https://halocustoms.org/updates/releases/latest/download/halo-linux-release.zip", "userEdit": true }, "port": { @@ -63,7 +63,7 @@ "display": "Server name", "desc": "The game's name in the server browser: 15 characters of printable ASCII.", "required": true, - "value": "OpenCE Server", + "value": "HaloCustoms", "userEdit": true }, "rotation": { diff --git a/deploy/systemd/README.md b/deploy/systemd/README.md index 8612e71..a4a2978 100644 --- a/deploy/systemd/README.md +++ b/deploy/systemd/README.md @@ -1,13 +1,15 @@ # The dedicated server under systemd -`halo-server@.service` runs the dedicated server (`docs/dedicated_server.md`) -as a templated unit: one instance for each folder -`/opt/opence/servers/`, all sharing one release in `/opt/opence/bin` -and one read-only copy of the game data in `/opt/opence/data`. +`halo-server@.service` runs the HaloCustoms dedicated server, based on OpenCE +(`docs/dedicated_server.md`), as a templated unit: one instance for each +folder `/opt/opence/servers/`, all sharing one release in +`/opt/opence/bin` and one read-only copy of the game data in +`/opt/opence/data`. (The folders keep OpenCE's names, which installs of the +unit already have.) | Path | What | | --- | --- | -| `/opt/opence/bin/` | The release (`halo-linux-release.zip`, unzipped) | +| `/opt/opence/bin/` | The release (`halo-linux-release.zip`, unzipped: HaloCustoms' latest is at `https://halocustoms.org/updates/releases/latest`, its zip at `https://halocustoms.org/updates/releases/download//halo-linux-release.zip`) | | `/opt/opence/data/` | The game data, only read: `maps/`, `custom_maps/`, `gametypes/` | | `/opt/opence/servers//` | The instance's own, the only folder it may write: `config.toml`, `init.txt`, `publisher.key`, `server.env`, `saves/` (the map cache), `logs/` | diff --git a/deploy/systemd/halo-server@.service b/deploy/systemd/halo-server@.service index 1516be5..74214d7 100644 --- a/deploy/systemd/halo-server@.service +++ b/deploy/systemd/halo-server@.service @@ -31,8 +31,8 @@ # 2 (no usable maps) and 3 (bad configuration) do not, as a restart would # fail the same way. [Unit] -Description=OpenCE dedicated server %i -Documentation=https://github.com/OpenCommunityEdition/OpenCE/blob/main/docs/dedicated_server.md +Description=HaloCustoms dedicated server %i (based on OpenCE) +Documentation=https://halocustoms.org/source https://github.com/OpenCommunityEdition/OpenCE/blob/main/docs/dedicated_server.md After=network-online.target Wants=network-online.target diff --git a/docs/dedicated_server.md b/docs/dedicated_server.md index 859d544..1f05244 100644 --- a/docs/dedicated_server.md +++ b/docs/dedicated_server.md @@ -40,7 +40,7 @@ status's `pid`, `instance`...). | --- | --- | | `--server` | Dedicated server mode (as `HALO_SERVER=1` or `server.enabled = true`) | | `--config ` | The settings file; default `config.toml` beside the executable. `HALO_CONFIG_FILE`, when it is set, wins over `--config` | -| `--version` | Prints `OpenCE build- (network version )` and exits 0 | +| `--version` | Prints `HaloCustoms build--hc (based on OpenCE build-) (network version )` (`HaloCustoms local build (based on OpenCE build-)` for a build without a HaloCustoms number) and exits 0 | | `--keygen` | Writes a new publisher key (`server.publisher_key_file`) unless one exists, prints its public key and key id, exits 0; exits 3 when the key cannot be made, read or written | | `--sign ` | Prints the publisher key's Ed25519 signature of the text, in hex (an application's proof of key), exits 0; exits 3 without a text or a usable key | | `--probe []` | A look at a server, for a list site that shows its state: joins nothing and never starts a server (whatever `--server`, `HALO_SERVER` or `server.enabled` say). It finds the invite's game over its tunnel (without an invite, the LAN's first) and prints it as one line of JSON, the game of the invite's host alone (never another game that answers), and exits 0; when nothing answers in 30 seconds it prints `{"ok":false,"error":"no game answered in 30 seconds"}` and exits 1. Internet play is as set (`network.online`, on by default), which an invite needs | @@ -54,12 +54,12 @@ settings file is read, never completed or made. `--probe` wins over A probe's line: ```json -{"ok":true,"name":"OpenCE Server","map":"bloodgulch","gametype":"ctf","players":5,"max_players":16, +{"ok":true,"name":"HaloCustoms","map":"bloodgulch","gametype":"ctf","players":5,"max_players":16, "teams":true,"in_progress":true,"open":true,"dedicated":true,"network_version":38,"kind":0} ``` `gametype` is the game engine's name (`ctf`, `slayer`, `oddball`, `king`, -`race`, or `none`), `kind` the OpenCE gametype's kind the host sends (0 for +`race`, or `none`), `kind` the HaloCustoms gametype's kind the host sends (0 for none), `open` whether it takes players now. The release has a `halo-server` script beside `halo`, which runs @@ -220,13 +220,13 @@ these settings, which `sv_settings` and `GET /api/settings` list: `server.idle_minutes`, `server.team_start`, `server.motd`, `server.frame_rate`, `server.web_password`, `server.web_moderator_password`, `server.log_format`, -`server.log_addresses`, `server.status_file`, `server.auto_update`, +`server.log_addresses`, `server.status_file`, `server.results_file`, `server.auto_update`, `server.coop_difficulty`, `server.region`, `server.master_publish`, `server.chat_filter`, `server.chat_filter_slurs`, `server.chat_filter_swearing`, `server.chat_filter_insults`, `server.master_publish_address`, `server.metrics`, `server.vpn_policy`, `server.vpn_fail`, `server.vpn_check_lan`, `server.link_min_age_days`, -`server.link_one_seat`, `server.update_check_minutes`, +`server.link_one_seat`, `server.link_ask`, `server.link_required`, `server.update_check_minutes`, `server.update_game_minutes`, `server.max_machines_per_address`, `server.afk_kick_minutes`, `server.afk_kick_when_full`, `server.afk_kick_full_minutes`, `server.coop_bsp_switch`, @@ -241,7 +241,8 @@ these settings, which `sv_settings` and `GET /api/settings` list: `network.coop_friendly_fire`, `network.coop_player_collisions`, `network.coop_ally_friendly_fire`, `network.coop_team_wipe`, `network.shield_effects` and `network.fire_origins`. Of them, -`server.status_file`, `server.auto_update` and `server.log_addresses` need +`server.status_file`, `server.results_file`, `server.auto_update`, `server.log_addresses` and +`server.link_required` need the owner role: from the web's admin, `sv_set` of one of them (`POST /api/command`) answers `403 forbidden`, and `PUT /api/settings` leaves it out, in its `errors` with the code `forbidden` and the message ` @@ -356,11 +357,17 @@ to everyone, logged and a `coop_bsp_switch` event names who crossed. ### Updates `server.auto_update` is `install` by default (the Docker image's, `check`: -its image is updated instead). The server asks GitHub for the latest -release `server.update_check_minutes` (30) after the last check (the first -two minutes after it starts), and when there is a newer one says so (a -`warning` event) and, with `install`, installs it on a thread of its own. -The release's notes tell its network version: one of the same version +its image is updated instead). The server asks HaloCustoms' release feed +(`https://halocustoms.org/updates/releases/latest`) for the latest release +`server.update_check_minutes` (30) after the last check (the first two +minutes after it starts), and when there is a newer one says so (a +`warning` event, which names both as the feed does: `build-`) and, with +`install`, installs it on a thread of its own, once its zip matches the +size and SHA-256 of the release's manifest, which must be signed with the +release key the build pins (FORK.md, "Releases"; a release whose manifest +does not check is not taken). Builds made without a number +(`HALO_BUILD_NUMBER`) or a release key (`HALOCUSTOMS_RELEASE_KEY`) never +look. The release's manifest tells its network version: one of the same version restarts the server (exit code 5) once it has been empty for 10 minutes; one of a new version, which its players' games no longer join, tells the players "This server updates after this game" and restarts it once the game @@ -398,9 +405,10 @@ ranges refused besides, one a line (`203.0.113.0/24`, `198.51.100.7`, or internet play peer's endpoint, `distributed_real_address`): a LAN's machines are not, unless `server.vpn_check_lan` (for tests) says so. -A pass is taken from the masters the server publishes to alone (with -`server.master_publish` and a publisher key): a server that publishes -nowhere takes none, and a player from a listed address joins it only with +A pass is taken from the masters the server publishes to (with +`server.master_publish` and a publisher key) and its link masters +("Accounts") alone: a server that publishes nowhere and has no link master +takes none, and a player from a listed address joins it only with `server.vpn_policy` `off`. With a pass, the server also refuses an account younger than `server.link_min_age_days` (365 by default: a year, from the account's id; "This server needs a Discord account a year old to join over @@ -416,6 +424,107 @@ the machine says of itself, and `ban` bans the account too. Each refusal is logged (`refusing a machine @
: vpn …`) and is a `join_refused` event. An older game is told the game is closed. +### Accounts + +A server can take the passes of a site's accounts as well, whose players +sign in at the site in the game (HaloCustoms: the main menu's HALOCUSTOMS): +its *link masters*, named in `server.link_masters_file` (empty by default; +masters.txt's form, an address and an `ed25519:` key a line, both keys +while a site changes its key; beside `config.toml` unless a full path, read +at the start and by `sv_reload`). The server publishes nowhere to them and +asks them nothing: their passes check as a published master's do +("The pass at the door" in `docs/master_protocol.md`), without a publisher +key, and their accounts are verified accounts as Discord's are (`discord_id` +and `discord_name` hold the account's id and name, a ban by the id keeps it +out, and so does `sv_ban_address discord:`). A link master's pass lets a +player in from a VPN's address too, as a published master's does +(`server.link_min_age_days` counts the account's age from its id, which a +site's own servers set to 0 to 7). + +A game that holds a site's pass but shows none says so in its join request +("The account hint" there): a server with `server.link_ask` (on by default) +refuses it once, quietly (`_rejection_code_vpn_link_required`, with no +event, no count and no log line but `debug.txt`'s, once a machine of an +address and hardware id in 30 seconds), and the game joins again at once +with its pass: every member who joins is verified, and a player without an +account is a guest. A stock game sends no hint and is not asked. + +With `server.link_required` (off by default; the owner's to change), the +server takes only players with a link master's account that counts: a pass +that checked, bound to the player's internet play tunnel, or shown from an +address the internet does not route (a LAN's; a proof without a tunnel's +binding could be replayed from anywhere else). It refuses, each refusal a +`join_refused` event and counted in `/api/vpn`'s `refused`: + +| Reason | Code | When | The game says | +| --- | --- | --- | --- | +| `account_required` | 8 | It shows no pass (and is not being asked for the one it hints at) | A game that hinted at the site's pass: "The host asked for your HaloCustoms account: sign in again"; one that offered no account: the VPN's words (below) | +| `account_invalid` | 10 | Its pass does not check, is not a link master's, or is unbound from an address the internet routes | The site's pass: "The host refused your HaloCustoms account: sign in again"; a Discord pass of a master that lists the server: "The host takes only HaloCustoms accounts: sign in to join" | +| `account_in_use` | 12 | The account is already playing on another machine | "The host refused: your HaloCustoms account is already playing" | + +A game refused with 8 shows its pass (the site's first after a hint, else +its Discord passes and the site's last), so an updated game joins a ranked +server at once when its player is signed in. The game says a refusal by +what it showed, as a refusal carries only its code: a game that offered no +account (its player not signed in at the site, or sharing it with no server) +cannot tell this server's 8 from a VPN block's, and says "The host blocks +VPNs: link Discord..." as for one (a Discord pass it then shows is refused +with 10, which it then says in the site's words). A Discord account's +refusals, and a build before these words, show the VPN's words. These checks +come before the VPN policy's, which then judges the machine as above; a site's +pass refused by that policy for its age (`server.link_min_age_days`) is "The +host refused: your HaloCustoms account is too new". + +The account of a machine (its link master's that counts) is in the +players' fields for the owner, or every role with `server.log_addresses` +(`/api/players`, `sv_players --json`, the status): `"account":{"master": +"","id":"","name":"…"}`, else `null`. +A machine's account is credited to its player that joined first: the other +players of a split screen are its guests (`null`). `game_ended` has a second +text for the readers who see addresses, whose players carry it too. The log +names the account (` shows the account of the link master +`), not its id. + +A site that runs servers for its members should run each as a user of its +own, not the site's: a game process able to read the site's files could +read its identity key. + +### Results + +With `server.results_file` (empty by default; the owner's to change), each +multiplayer game that ends (co-op's not) adds a line of JSON to the file, a +path of the server's machine, with a newline, in one write at its end (a +reader takes the lines up to the last newline; a write that broke off, the +disk full say, is taken back, and a line after a part of one still starts +a line of its own). The file is made readable by its owner and group alone +(`0640`: its players' accounts are the owner's to see): + +```json +{"v":1,"game":"--","build":"build-300","map":"bloodgulch", + "level":"levels\test\bloodgulch\bloodgulch","gametype":"slayer","variant":"Team Slayer","teams":true, + "reason":"score","started":1794313800,"ended":1794314400,"duration":600,"team_scores":[50,47], + "players":[{"slot":0,"name":"P3L42 Nelly","team":0,"score":2,"kills":14,"deaths":6,"assists":3,"headshots":4, + "melee_kills":1,"best_streak":5,"left":false, + "account":{"master":"39f713d0a644253f","id":"1569683039846400042","name":"Nelly"}}, …]} +``` + +`map` and `gametype` are the rotation entry's, `level` the map's own name +(its last part is the map a player's game reports), `variant` the +gametype's name as the game shows it, `reason` as `game_ended`'s, `started` +and `ended` Unix seconds, `duration` the seconds played, `team_scores` the +two teams' (`[]` without teams). `players` has every player in the game at +its end, then every player who left during it (`"left":true`), each with the +tallies last seen (once a second) and the account it is credited with: a +link master's account that counts, the machine's first player's alone, and +no other player of that machine's while one of it plays (another machine +given its index after its last player left is credited with its own); +`null` for the others. A player who left and joined again has a row of +each, and a player who joins into the place of one who left starts tallies +of their own. Headshots, +melee kills and the best streak are the server's own count of each player's +kills, as a player's game counts its own. The line has no address, hardware +id or Discord account's; a game nobody played adds none. + A dedicated server's text chat is its `network.text_chat`'s, listed or not: `on` by default (lines with links refused), `links`, or `off`, live (`sv_set network.text_chat off`). A game a player hosts has no text chat @@ -554,7 +663,7 @@ are checked in all. `/api/login` takes `POST` alone (any other method: | `GET /healthz` | none | `200 {"state":"lobby"}` (or `loading`, `playing`, `postgame`) while the server hosts and its frames run; `503` with `{"state":"starting"}`, `{"state":"stalled"}` (no frame for a minute) or the other state otherwise | | `GET /metrics` | none (`server.metrics`) | Prometheus text | | `GET /api/status` | viewer | The status (below) | -| `GET /api/players` | viewer | `{"ok":true,"players":[…]}` (with `address` and `hwid` for the owner, or every role with `server.log_addresses`; and `discord_id`, `discord_name` and `discord_verified` with them: a verified account's, else what the machine says, `""` for none) | +| `GET /api/players` | viewer | `{"ok":true,"players":[…]}` (with `address` and `hwid` for the owner, or every role with `server.log_addresses`; and `discord_id`, `discord_name` and `discord_verified` with them: a verified account's, else what the machine says, `""` for none; and `account`, a link master's account, else `null`: "Accounts") | | `POST /api/players/{slot}/kick` | moderator | `{"name":""}` → `{"ok":true,"lines":[…]}`, or `409 changed` when the slot holds another player | | `POST /api/players/{slot}/ban` | moderator | `{"name":…,"duration":"1d","reason":"…"}` (no duration: for ever, which a moderator's ban cannot be: `400 failed`) | | `POST /api/players/{slot}/mute` | moderator | `{"name":…,"minutes":10}` | @@ -565,7 +674,7 @@ are checked in all. `/api/login` takes `POST` alone (any other method: | `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` (`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` | viewer | `{"ok":true,"builtin":["slayer",…],"schema":{"fields":[…]},"files":[{"name":…,"base":…,"description":…,"error":null}]}` (`builtin`: the Xbox's gametypes, then HaloCustoms' (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) | | `DELETE /api/gametypes/{name}` | admin | `{"ok":true}` | @@ -592,7 +701,7 @@ events) before they were asked for. ### The status ```json -{"ok":true,"time":1760900000123,"name":"OpenCE Server","state":"playing", +{"ok":true,"time":1760900000123,"name":"HaloCustoms","state":"playing", "build":"build-206","network_version":38,"uptime":3600,"games_hosted":4, "map":"bloodgulch","level":"levels\\test\\bloodgulch\\bloodgulch","gametype":"ctf","variant":"CTF","teams":true, "players":5,"max_players":16,"machines":5,"countdown":-1,"game_seconds":312,"team_scores":[2,1], @@ -636,14 +745,16 @@ with the owner role, or `server.log_addresses`, also after it joins), for every role: `{"build":282,"flavor":"release","version":"OpenCE Linux | build 282 (release)","repository":"OpenCommunityEdition/OpenCE","mods":["trollface 1.0"]}` (`build` 0 for a build CI did not number, `repository` empty when the build -does not know it, `mods` the mods it plays in their load order, each its -folder and its version), or `null` from a game that does not tell it. It is -what the player's game says, which a changed build can make anything. +does not know it, and `halocustoms.org/source` from HaloCustoms' builds, made +from the source the site publishes; `mods` the mods it plays in their load +order, each its folder and its version), or `null` from a game that does not +tell it. It is what the player's game says, which a changed build can make +anything. A setting is -`{"name":"server.name","type":"string","value":"OpenCE Server","default":"OpenCE Server","comment":"…","env":"HALO_SERVER_NAME","pinned":false,"secret":false,"choices":null,"min":null,"max":null}`; +`{"name":"server.name","type":"string","value":"HaloCustoms","default":"HaloCustoms","comment":"…","env":"HALO_SERVER_NAME","pinned":false,"secret":false,"choices":null,"min":null,"max":null}`; `type` is `boolean`, `integer`, `real` or `string`, and `value` and -`default` are of that type in JSON (`true`, `16`, `"OpenCE Server"`); a +`default` are of that type in JSON (`true`, `16`, `"HaloCustoms"`); a reader should also take their text (`"true"`, `"16"`), which older servers sent. `choices` lists a word setting's words, `min` and `max` bound a number's, each `null` where there is none (the table above). `env` is @@ -664,8 +775,8 @@ engine whose table it is; `type`: `integer`, `boolean` or `choice`). Every frame is text holding one JSON object. The server speaks first: ``` -→ {"type":"hello","protocol":1,"server":"OpenCE Server","build":"build-206","network_version":38, - "capabilities":["events","status","rotation","settings","bans","owner_web","ban_address","stop","event_addresses","identity","vpn","link"]} +→ {"type":"hello","protocol":1,"server":"HaloCustoms","build":"build-206","network_version":38, + "capabilities":["events","status","rotation","settings","bans","owner_web","ban_address","stop","event_addresses","identity","vpn","link","account","results"]} ← {"type":"auth","password":"…","by":"paul"} or {"type":"auth","token":""} → {"type":"auth","ok":true,"role":"admin"} → {"type":"auth","ok":false,"error":{"code":"denied"}} and the connection closes (1008) @@ -688,8 +799,10 @@ A connection signed in with the page's cookie (on the same origin) needs no The `capabilities` say what the server has beyond the first release's (`events`, `status`, `rotation`, `settings`, `bans`): `owner_web`, `ban_address`, `stop`, `event_addresses` and `identity` ("Panels and -fleets"), `vpn` (the VPN policy, `/api/vpn`, `join_refused`) and `link` -(verified Discord accounts: `discord_*` in players, bans by `discord_id`); +fleets"), `vpn` (the VPN policy, `/api/vpn`, `join_refused`), `link` +(verified Discord accounts: `discord_*` in players, bans by `discord_id`), +`account` (link masters' accounts: `account` in players, `game_ended`'s +addressed text, `server.link_required`) and `results` (`server.results_file`); a client checks for one before it uses it. The owner's password signs in from the server's own machine alone ("Signing in"). A command is a `line`, or a `command` and its `args`, which the server @@ -740,10 +853,10 @@ Every event has `seq` (rising, never reused while the server runs; a | `lobby_opened` | `map, gametype, variant` (the gametype's name as the game shows it), `teams, index` (the rotation's entry) | | `countdown` | `seconds` (-1: paused) | | `game_started` | `map, gametype, variant, teams, team_scores[]` (team games), `players[]` | -| `game_ended` | `map, gametype, variant, teams, reason` (`score`, `time`, `idle`, `empty`, `command`, `next`, `update`: a new network version's install, `vote`: the players' vote to end it), `duration` (seconds), `team_scores[]` (team games), `players[]` | +| `game_ended` | `map, gametype, variant, teams, reason` (`score`, `time`, `idle`, `empty`, `command`, `next`, `update`: a new network version's install, `vote`: the players' vote to end it), `duration` (seconds), `team_scores[]` (team games), `players[]` (for the owner, or every role with `server.log_addresses`, as the owner sees them: with their addresses and `account`, "Accounts") | | `postgame` | `seconds` | | `player_joined` | `slot, name, team, machine`; `address, hwid, discord_id, discord_name, discord_verified` for the owner, or every role with `server.log_addresses` | -| `join_refused` | `name` (the machine's), `reason` (`vpn_link_required`, `vpn_blocked`, `vpn_link_invalid`, `vpn_link_too_new`, `vpn_link_in_use`, `address_full`); `address, hwid` as `player_joined`'s | +| `join_refused` | `name` (the machine's), `reason` (`vpn_link_required`, `vpn_blocked`, `vpn_link_invalid`, `vpn_link_too_new`, `vpn_link_in_use`, `address_full`, and `server.link_required`'s `account_required`, `account_invalid`, `account_in_use`); `address, hwid` as `player_joined`'s | | `player_left` | `slot, name, reason` (`left`; `kicked` and `banned` when the server's `kick`, `ban` or `sv_ban_address` dropped the machine, `banned` for a speed hack's drop too, `afk` for a player away); `address, hwid` (as it joined) as `player_joined`'s | | `afk_kick` | `players` (the machine's players' names), `seconds` (away) | | `coop_bsp_switch` | `from, to` (the level's parts), `players[]` (the names of those who crossed) | @@ -810,7 +923,7 @@ a rotation that long is saved whole and read back after a restart. `accumulation`, `juggernaut`, `king`, `team_king`, `king_pro`, `crazy_king`, `race`, `team_race`, `rally`, `team_rally`, and Halo PC's names `ffa`, `ffa_slayer`, `teamslayer`, `tslayer`, `koth`, `team_koth`, - `iron_ctf`, `teamrace`, `teamoddball`), an OpenCE gametype's (`swat`, + `iron_ctf`, `teamrace`, `teamoddball`), a HaloCustoms gametype's (`swat`, `infection`, `gun_game`, `hardcore`, `hardcore_ctf`, `hardcore_oddball`, `hardcore_king`), a gametype file's name (`gametypes/.toml`), or `file=` (a gametype a player's game saved, below). A gametype's @@ -844,7 +957,7 @@ stops with exit code 3 (2 when no rotation map is there), or plays ## Gametype files A gametype file, `gametypes/.toml` (the name `a`–`z`, `0`–`9`, `_` -and `-`), is a built-in or OpenCE gametype (`base`) and rules over it: +and `-`), is a built-in or HaloCustoms gametype (`base`) and rules over it: ```toml base = "team_king" # the gametype it starts from @@ -902,6 +1015,8 @@ lists; | `bans.txt`, `cheaters.txt`, `debug.txt` | In `paths.logs`, else the data root | | `vpn_allow.txt`, `vpn_block.txt`, `netlist-.bin` | Beside `bans.txt`: ranges never refused as a VPN's, ranges refused besides, and the masters' netlists held ("VPNs and linked Discord accounts") | | `status.json` (`server.status_file`) | The status, written every 2 seconds | +| The results (`server.results_file`) | A line of JSON added for each multiplayer game ended ("Results") | +| The link masters (`server.link_masters_file`) | The sites whose accounts' passes the server takes ("Accounts"): beside `config.toml` unless a full path | | A password's file (`HALO_SERVER_WEB_PASSWORD_FILE`, `HALO_SERVER_WEB_OWNER_PASSWORD_FILE`, `HALO_SERVER_WEB_MODERATOR_PASSWORD_FILE`, `HALO_SERVER_WEB_VIEWER_PASSWORD_FILE`, `HALO_SERVER_PASSWORD_FILE`) | A container's secret: when the plain variable is not set, the file's first line (its CR and LF off) is the setting's value, which is then pinned as the plain variable's would be (`sv_set` and `PUT /api/settings` refuse it, `409 pinned`). Read once, at the start; a file that cannot be read is logged, and the setting is `config.toml`'s | ## Releases diff --git a/docs/master_protocol.md b/docs/master_protocol.md index 13bfa11..99641bd 100644 --- a/docs/master_protocol.md +++ b/docs/master_protocol.md @@ -73,6 +73,9 @@ as the release's) leaves the official master, built into the game (`master_list_read_defaults` in `port/linux/src/master_list.c`) and the web page (`servers.js`): `https://master.opencommunityedition.com ed25519:89f4eda71229f11f3538679f49ef7fb0c2b407b380093f7f4fe02105b92225aa`. +HaloCustoms releases have HaloCustoms' master built in before it +(`https://halocustoms.org/master`, `port/linux/src/halocustoms_services.h`; +its key is given to the release's build: FORK.md, "Releases"), and ask both. The web build reads its site's `masters.json` before the official master. The URL is an origin with no path, or a path the master is served under as it is (a proxy must not rewrite paths, since the path is signed). @@ -648,11 +651,13 @@ another server: the tunnel's secret differs. A server takes a pass only if: it reads as above; its master is one the server publishes to (the masters of `server.masters_file`, with -`server.master_publish` and a publisher key) and its signature checks with +`server.master_publish` and a publisher key) or one of its link masters +(`server.link_masters_file`: below) and its signature checks with that master's pinned key; it was issued no more than 300 seconds ahead of the server's clock and expires no more than 300 seconds behind it; and the proof checks with the pass's player key. Then the player is a verified -Discord account, and a server may ban the account (`docs/dedicated_server.md`). +Discord account (a link master's account), and a server may ban the account +(`docs/dedicated_server.md`). The game sends its pass only when a server asks for it, by refusing the join with `_rejection_code_vpn_link_required` (8): it joins again at once @@ -671,6 +676,99 @@ with no list: the game then shows each pass it holds in turn, after a refusal with `_rejection_code_vpn_link_invalid` (10) the next. Masters do not share links: each master's word on an account is its own. +### Identity masters + +An identity master is a site whose own accounts servers take: HaloCustoms, +whose members sign in at the site in the game, a profile at a time. It +gives passes of the same form, signed with its own key (its key file is of +`master.key`'s form, outside its web root), for the game's player key; their +"Discord account" fields hold the site's account: its id (shaped as a +snowflake: the milliseconds since 2015-01-01 of the account's profile's +creation above 22 bits of the profile's id) and its display name. It serves +none of a master's endpoints: no listing, no list, no netlist, no link page. +A server takes its passes as a *link master*'s (`server.link_masters_file`, +in `masters.txt`'s form), publishing nothing to it; a game holds it in a +slot of its own, outside `masters.txt` (the server browser and the Discord +screens know nothing of it). + +The site answers: + +| Request | Answer | +| --- | --- | +| `GET /api/play/identity` | `200 {"key":"ed25519:<64 hex>","key_id":"<16 hex>","next":{"key","key_id"}\|null,"pass_ttl":86400}` (`Cache-Control: public, max-age=300`; `next`: the key it changes to), or `404 not_found` | +| `GET /api/play/pass?key=<64 hex>&ts=&sig=<128 hex>`, with the profile's token in `X-HaloCustoms-Token` (the web build: the page's own session, a same-origin request) | `200`, `application/octet-stream`: the pass, 24 hours; or an error in the form below: `400 schema`, `401 unknown_token`, `clock`, `bad_signature`, `403 cross_site`, `link_revoked` (the member is banned), `404 not_found`, `429 rate_limited` (30 a minute per key and per token) | + +The site also keeps its members' friends (halocustoms.org's Friends page), +which the game shows (the main menu's FRIENDS, and the HALOCUSTOMS screen's: +`port/linux/src/halocustoms_friends.h`). Player 1's profile's token goes in +`X-HaloCustoms-Token`; the web build reads the list with the page's own +session (a same-origin request), and sends the rest with the link token its +session's `GET /api/play/me` gives it. No game machine sends another anything +for it: the network version is unchanged. + +| Request | Answer | +| --- | --- | +| `GET /api/play/friends` | `200 {"friends":[{"name","gamertag","tag","status":"playing"\|"menus"\|"offline","game":{...}\|null}],"friends_total","online","requests":[{"id","name","gamertag","tag","sent"}],"pending","presence":"friends"\|"nobody","interval":30,"ttl":120}`: fifty friends at most, the online first, and twenty requests, the newest first (the counts are of all). A friend's `game`, only while they are in a public game: `{"name","map","gametype","players","max_players","password","dedicated","network_version","server_id","listing"}`, `listing` the base64 of the host-signed listing it is in (this document's "The listing"), which the game checks again before it joins it; `dedicated` and `server_id` are the site's own master's word, when it lists the game. A friend's presence is given to their friends alone, while they do not appear offline. `401`, `403` (a suspended member, or a session's request from another site's page: `cross_site`), `429` | +| `POST /api/play/presence`, `{"state":"menus"}`, `{"state":"playing"}`, or `{"state":"playing","game":{"listing":"","map"?,"gametype"?,"players"?,"max_players"?}}` | A heartbeat, every `interval` seconds (30): `200 {"shown","online","pending","interval":30,"ttl":120}` (`shown` false: the member appears offline, and nothing was kept). `listing` is the host-signed listing of the public game the game is in (one it hosts that the server browser lists, or one it joined from the browser): the site refuses one that does not read or check, or a tombstone (`422`), and takes the game's name and flags from it; `map`, `gametype` and the counts may be the game's own word on what it plays now (printable ASCII, 32 and 24 characters). A game in private is `playing` with no `game`. The site forgets a presence `ttl` seconds after its last heartbeat. `401`, `403`, `422`, `429` (6 a minute per token, with `Retry-After`) | +| `DELETE /api/play/presence` | Offline now: `200 {"shown":false}`. Only the presence this token told goes (another of the member's games may have told one since) | +| `POST /api/play/friends/requests//accept`, `.../decline` | A request to the member answered: `200 {"accepted"\|"declined":true,"friends_total","pending"}`; `404` for one that is not theirs to answer; `409 {"code":"friends_full"}` when either has as many friends as there can be (200) | + +`sig` signs `opence-pass-get-1`, the key id of the site's key the request +asks for (`halocustoms.identity_key`'s first, which pins it, else the +answer's `key`), the player's public key and `ts`, as for a master. The +game takes a pass only if it names one of the site's keys (the pinned, or +the answer's `key` and `next`) and its own player key, and that key's +signature checks; it asks for player 1's profile's at the start, at a +sign-in, when another profile becomes player 1's and at the pass's half +life, and forgets it at the profile's sign-out. + +### The account hint + +A game shows its pass only to a server that asks; a server cannot know +which joining games hold an identity master's. So a game that shows no pass +but holds an identity master's (and shares it: `halocustoms.share_account` +`"asked"`, the default) writes a hint into the join request's proof, the +pass staying zero: + +| Offset | Size | Field | +| --- | --- | --- | +| 0 | 8 | `"OPCEHINT"` | +| 8 | 1 | Format: 1 | +| 9 | 1 | The count of key ids: 1 to 4 | +| 10 | 8 × count | The masters' key ids, raw (the game names the identity master's alone) | +| … | | Zeros, to the proof's 64 bytes | + +A server reads a hint only beside a pass of zeros, and takes one only of +this format whose bytes after the key ids are zeros. One whose link master +the hint names (and with `server.link_ask`) refuses the join once with +`_rejection_code_vpn_link_required`, quietly, and the game joins again at +once with that master's pass: the game shows it first after a hint, then +its Discord passes in turn, and the identity master's last when it gave no +hint. A server that does not take the master asks nothing, and the hint +shows it only that the player is the site's member (as the rank before a +member's name shows it anyway). + +A request carries one pass or the hint, not both: a game that would show a +Discord pass unasked (`network.link_share` `"always"`) while it holds an +identity master's pass that it shares when asked sends the hint instead, so +that a server that takes the identity master's accounts can ask for it (shown +a Discord pass, it could not): such a server gets the identity master's pass, +another that asks for a pass (for its VPN policy) gets it too and then, once +refused, the Discord passes in turn; one that asks nothing sees neither. + +The game says a final refusal by what it showed: asked for a pass after it +hinted, or its identity master's pass refused (10), too new (11) or in use +(12), in the identity master's words; a Discord pass of a master that lists +the server (which the server takes) refused with 10, as a server that takes +only a site's accounts. Asked for a pass when it offered none, it cannot tell +a server that takes only accounts from one that blocks VPNs (both refuse with +8, and a refusal carries only its code), and says the VPN's words. + +A stock server reads the proof only when the pass is shown (it decides +"shown" from the pass's bytes alone), and a stock game fills the request +with zeros and the pass and proof only together: the hint is a compatible +extension of network version 41's join request, whose size is unchanged. + ## Other endpoints | Request | Who | Answer | diff --git a/port/android/README.md b/port/android/README.md index fb4e823..fce9dea 100644 --- a/port/android/README.md +++ b/port/android/README.md @@ -299,9 +299,12 @@ drop it, and when it hosts, its players leave. ## Updates -The app from GitHub Actions can update itself, as on Linux (refer to -"Updates" in [port/linux/README.md](../linux/README.md#updates)). When you -select "Yes": +A HaloCustoms release of the app can update itself, as on Linux (refer to +"Updates" in [port/linux/README.md](../linux/README.md#updates)): it takes a +release only when its manifest is signed with the release key the app pins +(`HALOCUSTOMS_RELEASE_KEY` when the build is configured: `Updater.java`, +`ReleaseManifest.java`, `Ed25519.java`), and checks the download against the +manifest's size and SHA-256. When you select "Yes": 1. The app downloads the new version. 2. The package installer of Android opens. At the first update, Android asks diff --git a/port/android/app/build.gradle b/port/android/app/build.gradle index 97b4757..ef78493 100644 --- a/port/android/app/build.gradle +++ b/port/android/app/build.gradle @@ -6,12 +6,23 @@ plugins { // native libraries (jniLibs) and the guest image (assets) def staged = new File(rootDir, "../../build/android") -// a build of main knows its number (tools/ci_build.py), which names its -// release and which the self-updater (Updater.java) compares; others have -// none and never look for updates +// a HaloCustoms release knows its number (HALO_BUILD_NUMBER, given when it +// is built: FORK.md, "Releases"), which names its release in HaloCustoms' +// release feed and which the self-updater (Updater.java) compares; others +// have none and never look for updates def buildNumberText = System.getenv("HALO_BUILD_NUMBER") ?: "0" def buildNumber = buildNumberText.isInteger() ? buildNumberText.toInteger() : 0 +// HaloCustoms' release key (FORK.md, "Releases"): the public half of the +// offline key its releases' manifests are signed with, which the updater +// (Updater.java, ReleaseManifest.java) takes a release only with. The +// configured build gives it (tools/android_build.py: -PhalocustomsReleaseKey, +// from HALOCUSTOMS_RELEASE_KEY when it is configured), or the environment's +// when Gradle is run by hand; an app without it never updates itself +def releaseKey = (project.findProperty("halocustomsReleaseKey") ?: System.getenv("HALOCUSTOMS_RELEASE_KEY") ?: "").trim() +if (releaseKey && !(releaseKey ==~ /ed25519:[0-9a-f]{64}/)) + throw new GradleException("HALOCUSTOMS_RELEASE_KEY is not \"ed25519:\" and 64 lowercase hex digits") + // the workflow's signing key (.github/workflows/build.yml): every build of // main signed with it can install over the one before; without it, the // debug key of the computer that builds @@ -26,8 +37,9 @@ android { minSdk 28 targetSdk 35 versionCode Math.max(1, buildNumber) - versionName buildNumber > 0 ? "build " + buildNumber : "1.0" + versionName buildNumber > 0 ? "HaloCustoms build " + buildNumber : "1.0" buildConfigField "int", "HALO_BUILD_NUMBER", "${buildNumber}" + buildConfigField "String", "HALOCUSTOMS_RELEASE_KEY", "\"${releaseKey}\"" ndk { abiFilters "arm64-v8a" } diff --git a/port/android/app/src/main/java/com/halo/decomp/Ed25519.java b/port/android/app/src/main/java/com/halo/decomp/Ed25519.java new file mode 100644 index 0000000..54ccff3 --- /dev/null +++ b/port/android/app/src/main/java/com/halo/decomp/Ed25519.java @@ -0,0 +1,146 @@ +package com.halo.decomp; + +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +/** + * Ed25519 signatures checked (RFC 8032, 5.1.7), for the updater's release + * manifests (ReleaseManifest): Android has no Ed25519 of its own before + * Android 13. Only checking, with nothing secret in it, so in plain + * BigInteger arithmetic. As strict as the native builds' Monocypher + * (p2p_ed25519_verify): R is compared as the encoding of [S]B - [k]A, so only + * its canonical encoding passes; S must be below the group's order; and a key + * that is not a point, or is of small order, checks nothing. + */ +final class Ed25519 { + private static final BigInteger P = BigInteger.ONE.shiftLeft(255).subtract(BigInteger.valueOf(19)); + private static final BigInteger ORDER = + BigInteger.ONE.shiftLeft(252).add(new BigInteger("27742317777372353535851937790883648493")); + private static final BigInteger D = + BigInteger.valueOf(-121665).multiply(BigInteger.valueOf(121666).modInverse(P)).mod(P); + private static final BigInteger SQRT_MINUS_ONE = + BigInteger.valueOf(2).modPow(P.subtract(BigInteger.ONE).shiftRight(2), P); + private static final BigInteger TWO = BigInteger.valueOf(2); + /** points in extended coordinates (X, Y, Z, T), x = X/Z, y = Y/Z, x * y = T/Z */ + private static final BigInteger[] IDENTITY = {BigInteger.ZERO, BigInteger.ONE, BigInteger.ONE, BigInteger.ZERO}; + private static final BigInteger[] BASE = decode(new byte[] { + 0x58, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, + 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66}); + + private Ed25519() { + } + + /** whether the signature (64 bytes) is the key's (32 bytes), of the message */ + static boolean verify(byte[] key, byte[] message, byte[] signature) { + if (key == null || message == null || signature == null || key.length != 32 || signature.length != 64) + return false; + BigInteger[] a = decode(key); + if (a == null || smallOrder(a)) + return false; + byte[] r = Arrays.copyOfRange(signature, 0, 32); + BigInteger s = littleEndian(Arrays.copyOfRange(signature, 32, 64)); + if (s.compareTo(ORDER) >= 0) + return false; + BigInteger k = littleEndian(sha512(r, key, message)).mod(ORDER); + byte[] expected = encode(add(multiply(BASE, s), negate(multiply(a, k)))); + return MessageDigest.isEqual(expected, r); + } + + private static BigInteger littleEndian(byte[] bytes) { + byte[] big = new byte[bytes.length + 1]; + + for (int index = 0; index < bytes.length; index++) + big[bytes.length - index] = bytes[index]; + return new BigInteger(big); + } + + private static byte[] sha512(byte[]... parts) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-512"); + + for (byte[] part : parts) + digest.update(part); + return digest.digest(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("no SHA-512 here", e); + } + } + + /** the point of a canonical encoding (RFC 8032, 5.1.3), null if it is none */ + private static BigInteger[] decode(byte[] encoded) { + byte[] bytes = encoded.clone(); + int sign = (bytes[31] >> 7) & 1; + + bytes[31] &= 0x7F; + BigInteger y = littleEndian(bytes); + if (y.compareTo(P) >= 0) + return null; + BigInteger y2 = y.multiply(y).mod(P); + BigInteger x2 = y2.subtract(BigInteger.ONE).multiply(D.multiply(y2).add(BigInteger.ONE).modInverse(P)).mod(P); + if (x2.signum() == 0) { + if (sign == 1) + return null; + return new BigInteger[] {BigInteger.ZERO, y, BigInteger.ONE, BigInteger.ZERO}; + } + BigInteger x = x2.modPow(P.add(BigInteger.valueOf(3)).shiftRight(3), P); + if (!x.multiply(x).subtract(x2).mod(P).equals(BigInteger.ZERO)) + x = x.multiply(SQRT_MINUS_ONE).mod(P); + if (!x.multiply(x).subtract(x2).mod(P).equals(BigInteger.ZERO)) + return null; + if (x.testBit(0) != (sign == 1)) + x = P.subtract(x); + return new BigInteger[] {x, y, BigInteger.ONE, x.multiply(y).mod(P)}; + } + + private static byte[] encode(BigInteger[] point) { + BigInteger inverse = point[2].modInverse(P); + BigInteger x = point[0].multiply(inverse).mod(P); + BigInteger y = point[1].multiply(inverse).mod(P); + byte[] big = y.toByteArray(); + byte[] bytes = new byte[32]; + + for (int index = 0; index < 32 && index < big.length; index++) + bytes[index] = big[big.length - 1 - index]; + if (x.testBit(0)) + bytes[31] |= (byte) 0x80; + return bytes; + } + + /** (RFC 8032, 5.1.4: the addition of the twisted Edwards curve, a doubling too) */ + private static BigInteger[] add(BigInteger[] p, BigInteger[] q) { + BigInteger a = p[1].subtract(p[0]).multiply(q[1].subtract(q[0])).mod(P); + BigInteger b = p[1].add(p[0]).multiply(q[1].add(q[0])).mod(P); + BigInteger c = TWO.multiply(p[3]).multiply(q[3]).multiply(D).mod(P); + BigInteger d = TWO.multiply(p[2]).multiply(q[2]).mod(P); + BigInteger e = b.subtract(a), f = d.subtract(c), g = d.add(c), h = b.add(a); + + return new BigInteger[] {e.multiply(f).mod(P), g.multiply(h).mod(P), f.multiply(g).mod(P), + e.multiply(h).mod(P)}; + } + + private static BigInteger[] negate(BigInteger[] point) { + return new BigInteger[] {P.subtract(point[0]).mod(P), point[1], point[2], P.subtract(point[3]).mod(P)}; + } + + private static BigInteger[] multiply(BigInteger[] point, BigInteger scalar) { + BigInteger[] result = IDENTITY; + + for (int bit = scalar.bitLength() - 1; bit >= 0; bit--) { + result = add(result, result); + if (scalar.testBit(bit)) + result = add(result, point); + } + return result; + } + + /** whether 8 times the point is the identity (a key anyone can sign for) */ + private static boolean smallOrder(BigInteger[] point) { + BigInteger[] eight = add(point, point); + + eight = add(eight, eight); + eight = add(eight, eight); + return eight[0].mod(P).signum() == 0 && eight[1].subtract(eight[2]).mod(P).signum() == 0; + } +} diff --git a/port/android/app/src/main/java/com/halo/decomp/ReleaseManifest.java b/port/android/app/src/main/java/com/halo/decomp/ReleaseManifest.java new file mode 100644 index 0000000..4efd741 --- /dev/null +++ b/port/android/app/src/main/java/com/halo/decomp/ReleaseManifest.java @@ -0,0 +1,225 @@ +package com.halo.decomp; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * A HaloCustoms release's signed manifest (contract 1, FORK.md "Releases"), + * read as strictly as the desktop updater reads it + * (port/linux/src/updater.c's updater_manifest_read; test_updater_feed.py + * gives both the same manifests), and the whole check of a feed's release + * with the app's pinned release key (Updater.java). The manifest is exactly + * + *
+ * halocustoms-release 1
+ * tag build-<n>
+ * network <network version>
+ * asset <name> <size> <SHA-256 in lowercase hex>
+ * 
+ * + * with an asset line for each of the release's files, in increasing byte + * order of their names, each line ended by a line feed. + */ +final class ReleaseManifest { + static final int MAXIMUM = 4096; + static final int LINE_MAXIMUM = 200; + static final int ASSETS_MAXIMUM = 16; + static final int NAME_MAXIMUM = 64; + static final long BUILD_MAXIMUM = 999999999L; + static final long NETWORK_MAXIMUM = 65535L; + /** the most a download may be (Updater's MAXIMUM_UPDATE_SIZE, updater.c's) */ + static final long SIZE_MAXIMUM = 256L * 1024 * 1024; + + final int build; + final int networkVersion; + /** this build's file: whether the release has it, its size and its SHA-256 (lowercase hex, null without it) */ + final boolean hasAsset; + final long assetSize; + final String assetSha256; + + private ReleaseManifest(int build, int networkVersion, boolean hasAsset, long assetSize, String assetSha256) { + this.build = build; + this.networkVersion = networkVersion; + this.hasAsset = hasAsset; + this.assetSize = assetSize; + this.assetSha256 = assetSha256; + } + + /** a manifest's bytes read, for this build's file (asset): IllegalArgumentException, and why, if they are not one */ + static ReleaseManifest read(byte[] text, String asset) { + if (text.length == 0 || text.length > MAXIMUM || text[text.length - 1] != '\n') + throw new IllegalArgumentException("its manifest is empty, longer than " + MAXIMUM + + " bytes or not ended by a line feed"); + int build = 0, network = 0, number = 0, at = 0; + boolean hasAsset = false; + long assetSize = 0; + String assetSha256 = null, previous = null; + + while (at < text.length) { + int end = at; + + while (text[end] != '\n') + end++; + number++; + for (int index = at; index < end; index++) { + if ((text[index] & 0xFF) < 0x20 || (text[index] & 0xFF) > 0x7E) + throw new IllegalArgumentException("its manifest's line " + number + + " holds a byte that is not printable ASCII"); + } + if (end - at > LINE_MAXIMUM) + throw new IllegalArgumentException("its manifest's line " + number + " is longer than " + + LINE_MAXIMUM + " bytes"); + String[] fields = fields(new String(text, at, end - at, StandardCharsets.US_ASCII), 4); + at = end + 1; + if (number == 1) { + if (fields == null || fields.length != 2 || !fields[0].equals("halocustoms-release") + || !fields[1].equals("1")) + throw new IllegalArgumentException("its manifest is not one of contract 1"); + } else if (number == 2) { + long value = fields == null || fields.length != 2 || !fields[0].equals("tag") + || !fields[1].startsWith("build-") ? -1 : decimal(fields[1].substring(6), BUILD_MAXIMUM); + if (value < 0) + throw new IllegalArgumentException("its manifest's second line is not its tag, build-"); + build = (int) value; + } else if (number == 3) { + long value = fields == null || fields.length != 2 || !fields[0].equals("network") ? -1 + : decimal(fields[1], NETWORK_MAXIMUM); + if (value < 0) + throw new IllegalArgumentException("its manifest's third line is not its network version"); + network = (int) value; + } else { + if (fields == null || fields.length != 4 || !fields[0].equals("asset") || !assetName(fields[1])) + throw new IllegalArgumentException("its manifest's line " + number + " is not an asset"); + if (number - 3 > ASSETS_MAXIMUM) + throw new IllegalArgumentException("its manifest lists more than " + ASSETS_MAXIMUM + " assets"); + if (previous != null && fields[1].compareTo(previous) <= 0) + throw new IllegalArgumentException("its manifest's assets are not in increasing order of their " + + "names (" + fields[1] + ")"); + long size = decimal(fields[2], SIZE_MAXIMUM); + if (size < 0 || !lowercaseHex(fields[3], 64)) + throw new IllegalArgumentException("its manifest's " + fields[1] + " has no size from 1 to " + + SIZE_MAXIMUM + " bytes, or no SHA-256 in 64 lowercase hexadecimal digits"); + if (fields[1].equals(asset)) { + hasAsset = true; + assetSize = size; + assetSha256 = fields[3]; + } + previous = fields[1]; + } + } + if (number < 4) + throw new IllegalArgumentException("its manifest lists no asset"); + return new ReleaseManifest(build, network, hasAsset, assetSize, assetSha256); + } + + /** + * The feed's release checked with the pinned release key ("ed25519:<64 hex>", "" when the app pins none: + * then none is taken): its manifest signed with the key (whose id, the first 8 bytes of its SHA-256, the + * release gives), read, of the feed's tag and listing this build's file. The manifest, or + * IllegalArgumentException and why. + */ + static ReleaseManifest check(String tag, String manifest, String signature, String keyId, String key, String asset) { + if (key == null || key.isEmpty()) + throw new IllegalArgumentException("this app pins no release key (HALOCUSTOMS_RELEASE_KEY) to check it with"); + if (!key.startsWith("ed25519:") || !lowercaseHex(key.substring(8), 64)) + throw new IllegalArgumentException("this app's release key is not ed25519:<64 hex digits>"); + if (tag == null || manifest == null || signature == null || keyId == null) + throw new IllegalArgumentException("the release has no tag, signed manifest, signature or key id"); + byte[] keyBytes = bytes(key.substring(8)); + byte[] id = bytes(keyId.length() == 16 && hex(keyId) ? keyId : ""); + if (id.length != 8 || !MessageDigest.isEqual(id, java.util.Arrays.copyOf(sha256(keyBytes), 8))) + throw new IllegalArgumentException("it is signed with another key (" + keyId + "), not this app's"); + if (!signature.startsWith("ed25519:") || signature.length() != 8 + 128 || !hex(signature.substring(8))) + throw new IllegalArgumentException("its signature is not ed25519:<128 hexadecimal digits>"); + byte[] text = manifest.getBytes(StandardCharsets.UTF_8); + if (!Ed25519.verify(keyBytes, text, bytes(signature.substring(8)))) + throw new IllegalArgumentException("its manifest's signature does not check with this app's release key"); + ReleaseManifest read = read(text, asset); + if (!tag.equals("build-" + read.build)) + throw new IllegalArgumentException("its manifest is build-" + read.build + "'s, not the feed's " + tag); + if (!read.hasAsset) + throw new IllegalArgumentException("build-" + read.build + " has no " + asset); + return read; + } + + /** a line's fields, each between single spaces, at most maximum of them: null if one is empty or there are more */ + private static String[] fields(String line, int maximum) { + String[] fields = line.split(" ", -1); + + if (fields.length > maximum) + return null; + for (String field : fields) { + if (field.isEmpty()) + return null; + } + return fields; + } + + /** a decimal number from 1 to maximum, with no sign or leading zero: -1 if it is not one */ + private static long decimal(String text, long maximum) { + if (text.isEmpty() || text.length() > 18 || text.charAt(0) == '0') + return -1; + for (int index = 0; index < text.length(); index++) { + if (text.charAt(index) < '0' || text.charAt(index) > '9') + return -1; + } + long value = Long.parseLong(text); + return value <= maximum ? value : -1; + } + + /** whether a name is one a manifest's asset may have (updater.c's updater_asset_name) */ + private static boolean assetName(String name) { + if (name.length() < 5 || name.length() > NAME_MAXIMUM || !name.endsWith(".zip")) + return false; + char first = name.charAt(0); + if (!((first >= 'a' && first <= 'z') || (first >= '0' && first <= '9'))) + return false; + for (int index = 0; index < name.length(); index++) { + char character = name.charAt(index); + + if (!((character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') + || character == '.' || character == '_' || character == '-')) + return false; + } + return true; + } + + private static boolean lowercaseHex(String text, int length) { + if (text.length() != length) + return false; + for (int index = 0; index < length; index++) { + char character = text.charAt(index); + + if (!((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f'))) + return false; + } + return true; + } + + private static boolean hex(String text) { + if (text.length() % 2 != 0) + return false; + for (int index = 0; index < text.length(); index++) { + if (Character.digit(text.charAt(index), 16) < 0 || text.charAt(index) > 'f') + return false; + } + return true; + } + + static byte[] bytes(String hex) { + byte[] bytes = new byte[hex.length() / 2]; + + for (int index = 0; index < bytes.length; index++) + bytes[index] = (byte) Integer.parseInt(hex.substring(2 * index, 2 * index + 2), 16); + return bytes; + } + + static byte[] sha256(byte[] data) { + try { + return MessageDigest.getInstance("SHA-256").digest(data); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("no SHA-256 here", e); + } + } +} diff --git a/port/android/app/src/main/java/com/halo/decomp/Updater.java b/port/android/app/src/main/java/com/halo/decomp/Updater.java index 266a98e..d95cb86 100644 --- a/port/android/app/src/main/java/com/halo/decomp/Updater.java +++ b/port/android/app/src/main/java/com/halo/decomp/Updater.java @@ -21,6 +21,7 @@ import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.ArrayList; import java.util.List; import java.util.zip.ZipEntry; @@ -29,25 +30,40 @@ import java.util.zip.ZipInputStream; /** * The app's self-updater, as the desktop games' (port/linux/src/updater.c). * - * A build of the main branch made by GitHub Actions knows its build number - * (BuildConfig.HALO_BUILD_NUMBER, the workflow's run number, which names its - * release: build-number); other builds have none and never look. When - * update.auto in config.toml is true (the default), the game asks GitHub for - * the latest release when it starts, on a thread of its own, and if it is - * newer asks the player whether to update: + * A HaloCustoms release knows its build number + * (BuildConfig.HALO_BUILD_NUMBER, given when it is built, which names its + * release in HaloCustoms' release feed: build-number); other builds have none + * and never look. When update.auto in config.toml is true (the default), the + * game asks the feed (FEED: port/linux/src/halocustoms_services.h's, as + * GitHub's releases/latest gives a release) for the latest release when it + * starts, on a thread of its own, and if it is newer asks the player whether + * to update: * * - Yes: the release's app (halo-android-release.zip or -debug.zip) is - * downloaded and handed to Android's package installer, which replaces the + * downloaded, checked against the size and SHA-256 its signed manifest + * gives it, and handed to Android's package installer, which replaces the * game (closing it) and offers to open the new version. * - No: nothing, until the next start. * - Do not ask again: after the player confirms it, update.auto = false is * written to config.toml. * - * Every build of main is signed with the same key (the workflow's), which an - * app must keep for Android to install a new version over it. + * A release is taken only when its manifest is signed with the release key + * the app pins (BuildConfig.HALOCUSTOMS_RELEASE_KEY, given when it is built: + * ReleaseManifest, contract 1, as the desktop games' updater.c); an app that + * pins none never looks. Android installs a new version over the app only if + * both are signed with the same key: every HaloCustoms release must be + * (FORK.md, "Releases"). */ final class Updater { - private static final String REPOSITORY = "OpenCommunityEdition/OpenCE"; + /** HaloCustoms' release feed, and where its releases' zips are: + * halocustoms_services.h's HALOCUSTOMS_RELEASES_URL and + * HALOCUSTOMS_DOWNLOADS_URL (tools/test_halocustoms_services.py) */ + private static final String FEED = "https://halocustoms.org/updates/releases/latest"; + private static final String DOWNLOADS = "https://halocustoms.org/updates/releases/download/"; + /** the latest release's app's size and SHA-256 (lowercase hex), as its + * signed manifest gives them */ + private static volatile long latestSize; + private static volatile String latestDigest; private static final String USER_AGENT = "halo-ce-universal-updater"; private static final int TIMEOUT_MILLISECONDS = 20000; /** the most a download (a release's zip, about 35 MB) or the app in it may be */ @@ -62,6 +78,11 @@ final class Updater { if (BuildConfig.HALO_BUILD_NUMBER <= 0 || config == null || !autoUpdate(config)) return; + if (BuildConfig.HALOCUSTOMS_RELEASE_KEY.isEmpty()) { + android.util.Log.i("halo", "update: not looked for: this app pins no release key " + + "(HALOCUSTOMS_RELEASE_KEY), and takes no release it cannot check"); + return; + } new Thread(() -> { int latest = latestRelease(); @@ -156,16 +177,28 @@ final class Updater { } private static byte[] readAll(InputStream stream) throws IOException { + return readAll(stream, Integer.MAX_VALUE); + } + + /** a stream's bytes, no more than maximum of them */ + private static byte[] readAll(InputStream stream, int maximum) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); byte[] buffer = new byte[16384]; int count; - while ((count = stream.read(buffer)) > 0) + while ((count = stream.read(buffer)) > 0) { + if (out.size() > maximum - count) + throw new IOException("the answer is larger than " + maximum + " bytes"); out.write(buffer, 0, count); + } return out.toByteArray(); } - /* ---------- GitHub */ + /* ---------- the release feed */ + + private static String asset() { + return "halo-android-" + (BuildConfig.DEBUG ? "debug" : "release") + ".zip"; + } private static HttpURLConnection open(String address) throws IOException { HttpURLConnection connection = (HttpURLConnection) new URL(address).openConnection(); @@ -177,19 +210,30 @@ final class Updater { return connection; } - /** the build number of GitHub's latest release, 0 if there is none */ + /** the build number of the feed's latest release, 0 if there is none to + * take (ReleaseManifest.check: its manifest signed with the pinned key, + * of the feed's tag, listing this app's zip); its zip's size and SHA-256 + * kept, as the manifest gives them */ private static int latestRelease() { try { - HttpURLConnection connection = open("https://api.github.com/repos/" + REPOSITORY + "/releases/latest"); + HttpURLConnection connection = open(FEED); - connection.setRequestProperty("Accept", "application/vnd.github+json"); + connection.setRequestProperty("Accept", "application/json"); try (InputStream stream = connection.getInputStream()) { - String tag = new JSONObject(new String(readAll(stream), StandardCharsets.UTF_8)).optString("tag_name"); - - return tag.startsWith("build-") ? Integer.parseInt(tag.substring(6)) : 0; + JSONObject release = new JSONObject(new String(readAll(stream, 1024 * 1024), StandardCharsets.UTF_8)); + ReleaseManifest manifest = ReleaseManifest.check(release.optString("tag_name", null), + release.optString("halocustoms_manifest", null), release.optString("halocustoms_signature", null), + release.optString("halocustoms_key_id", null), BuildConfig.HALOCUSTOMS_RELEASE_KEY, asset()); + + latestSize = manifest.assetSize; + latestDigest = manifest.assetSha256; + return manifest.build; } finally { connection.disconnect(); } + } catch (IllegalArgumentException e) { + android.util.Log.i("halo", "update: the feed's latest release is not taken: " + e.getMessage()); + return 0; } catch (Exception e) { android.util.Log.i("halo", "update: could not check for a new version: " + e); return 0; @@ -202,8 +246,8 @@ final class Updater { if (activity.isFinishing()) return; new AlertDialog.Builder(activity) - .setTitle("Halo: new version") - .setMessage("A new version of Halo was detected (build " + latest + "; this is build " + .setTitle("HaloCustoms: new version") + .setMessage("A new version of HaloCustoms was detected (build " + latest + "; this is build " + BuildConfig.HALO_BUILD_NUMBER + ").\n\nDo you want to update? The game will close and start " + "the new version.") .setCancelable(false) @@ -215,7 +259,7 @@ final class Updater { private static void confirmNever(Activity activity) { new AlertDialog.Builder(activity) - .setTitle("Halo: new version") + .setTitle("HaloCustoms: new version") .setMessage("Stop asking about new versions?\n\nTo ask again, set auto = true in the [update] section " + "of config.toml.") .setCancelable(false) @@ -232,7 +276,9 @@ final class Updater { /* ---------- updating */ private static void update(Activity activity, int latest) { - String asset = "halo-android-" + (BuildConfig.DEBUG ? "debug" : "release") + ".zip"; + String asset = asset(); + String digest = latestDigest; + long size = latestSize; File directory = new File(activity.getCacheDir(), UpdateProvider.DIRECTORY); LinearLayout layout = new LinearLayout(activity); TextView status = new TextView(activity); @@ -247,7 +293,7 @@ final class Updater { layout.addView(status); layout.addView(bar); AlertDialog progress = new AlertDialog.Builder(activity) - .setTitle("Halo: new version") + .setTitle("HaloCustoms: new version") .setView(layout) .setCancelable(false) .show(); @@ -258,12 +304,15 @@ final class Updater { File zip = new File(directory, "update.zip"); File apk = new File(directory, UpdateProvider.APK); - download("https://github.com/" + REPOSITORY + "/releases/download/build-" + latest + "/" + asset, zip, + download(DOWNLOADS + "build-" + latest + "/" + asset, zip, size, (received, total) -> activity.runOnUiThread(() -> { bar.setProgress(total > 0 ? (int) (received * 1000 / total) : 0); status.setText("Downloading build " + latest + "... (" + (received >> 20) + " of " + (total >> 20) + " MB)"); })); + if (digest == null || zip.length() != size || !digest.equals(sha256(zip))) + throw new IOException("the download is not the release's: its size or SHA-256 is not its " + + "signed manifest's"); extractApk(zip, apk); zip.delete(); activity.runOnUiThread(() -> { @@ -275,7 +324,7 @@ final class Updater { activity.runOnUiThread(() -> { progress.dismiss(); new AlertDialog.Builder(activity) - .setTitle("Halo: new version") + .setTitle("HaloCustoms: new version") .setMessage("The update failed:\n\n" + e.getMessage()) .setPositiveButton("OK", null) .show(); @@ -284,11 +333,30 @@ final class Updater { }, "update download").start(); } + /** a file's SHA-256, in lowercase hex */ + private static String sha256(File file) throws IOException { + try (InputStream in = new FileInputStream(file)) { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] buffer = new byte[65536]; + int count; + + while ((count = in.read(buffer)) > 0) + digest.update(buffer, 0, count); + StringBuilder hex = new StringBuilder(); + for (byte value : digest.digest()) + hex.append(String.format(java.util.Locale.ROOT, "%02x", value)); + return hex.toString(); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IOException("no SHA-256 here: " + e); + } + } + private interface Progress { void report(long received, long total); } - private static void download(String address, File file, Progress progress) throws IOException { + /** the address's answer into file, no more than maximum bytes of it */ + private static void download(String address, File file, long maximum, Progress progress) throws IOException { HttpURLConnection connection = open(address); try { @@ -305,8 +373,8 @@ final class Updater { while ((count = in.read(buffer)) > 0) { received += count; - if (received > MAXIMUM_UPDATE_SIZE) - throw new IOException("the download is larger than expected"); + if (received > Math.min(maximum, MAXIMUM_UPDATE_SIZE)) + throw new IOException("the download is larger than its signed manifest says"); out.write(buffer, 0, count); if (received - reported >= 256 * 1024 || received == total) { progress.report(received, total); diff --git a/port/android/app/src/main/res/values/strings.xml b/port/android/app/src/main/res/values/strings.xml index 6fc82d5..4b40427 100644 --- a/port/android/app/src/main/res/values/strings.xml +++ b/port/android/app/src/main/res/values/strings.xml @@ -1,4 +1,6 @@ - Halo + + HaloCustoms diff --git a/port/android/guest/runtime/guest_host.h b/port/android/guest/runtime/guest_host.h index cdbf669..97be8ff 100644 --- a/port/android/guest/runtime/guest_host.h +++ b/port/android/guest/runtime/guest_host.h @@ -156,5 +156,15 @@ status, the headers ("Name: value" lines) and up to body_capacity bytes of the body (its size in body_size), else 0 and why in error */ int host_https_get(const char *url, int *status, char *headers, int headers_size, char *body, int body_capacity, int *body_size, char *error, int error_size); +/* a request of the HaloCustoms site (port/linux/src/halocustoms_account.c): +the method ("GET", "POST"), extra header lines sent as they are (each ended +by "\r\n"; NULL: none: the account's token, or a link code's secret) and a +body of body_size bytes (NULL: none); the answer's body's size, with the +status and the body in answer (one of more than answer_capacity bytes is no +answer), else -1 and why in answer, ended. (Eight arguments, all in +registers: the guest's ABI and the host's place a ninth and later on the +stack differently) */ +int host_https_request(const char *method, const char *url, const char *headers, const char *body, int body_size, + int *status, char *answer, int answer_capacity); #endif diff --git a/port/android/host/host_https.c b/port/android/host/host_https.c index e0f82b5..c7d9ddd 100644 --- a/port/android/host/host_https.c +++ b/port/android/host/host_https.c @@ -2,7 +2,9 @@ HOST_HTTPS.C The server browser's requests to the master servers (host_https_get, -port/android/guest/runtime/guest_host.h; port/linux/src/master_list.c), made +port/android/guest/runtime/guest_host.h; port/linux/src/master_list.c) and +the HaloCustoms account's to its site (host_https_request; +port/linux/src/halocustoms_account.c), made in the host with the desktop's HTTPS client (port/linux/src/posix_update.c's update_https_request, Mbed TLS, the system's certificate authorities): the guest has no TLS of its own, and its memory is the host's to read and write. @@ -33,3 +35,30 @@ int host_https_get(const char *url, int *status, char *headers, int headers_size free(response.body); return 1; } + +int host_https_request(const char *method, const char *url, const char *headers, const char *body, int body_size, + int *status, char *answer, int answer_capacity) +{ + struct update_response response; + char error[256] = ""; + int size; + + *status = 0; + if (answer_capacity <= 0) + return -1; + /* (the header lines as they are given: the account's token, or a link + code's secret, goes in one, and in no address) */ + if (!update_https_request(method, url, headers, body && body_size > 0 ? body : NULL, + (unsigned long)(body && body_size > 0 ? body_size : 0), (unsigned long)answer_capacity, &response, error, + sizeof(error))) + { + snprintf(answer, (size_t)answer_capacity, "%s", error[0] ? error : "no answer"); + return -1; + } + *status = response.status; + size = response.body_size > (unsigned long)answer_capacity ? answer_capacity : (int)response.body_size; + if (response.body && size > 0) + memcpy(answer, response.body, (size_t)size); + free(response.body); + return size; +} diff --git a/port/android/host_imports.list b/port/android/host_imports.list index 8d6a4b2..1b4a949 100644 --- a/port/android/host_imports.list +++ b/port/android/host_imports.list @@ -67,5 +67,6 @@ host_touch_scene host_touch_bindings host_touch_devices host_https_get +host_https_request host_errno diff --git a/port/assets/menus/NON_HANDDRAWN.md b/port/assets/menus/NON_HANDDRAWN.md index 9b483e2..093e580 100644 --- a/port/assets/menus/NON_HANDDRAWN.md +++ b/port/assets/menus/NON_HANDDRAWN.md @@ -62,7 +62,7 @@ until it is redrawn. | `ce/shell/main_menu/settings_select/player_setup/player_profile_edit/profile_options__6.png` | 512x256 | `shell/main_menu/settings_select/player_setup/player_profile_edit/profile_options__6.png` | | `ce/shell/main_menu/settings_select/player_setup/player_profile_edit/profile_options__8.png` | 512x256 | `shell/main_menu/settings_select/player_setup/player_profile_edit/profile_options__8.png` | -13 of 183 frames are placeholders. +13 of 186 frames are placeholders. The PC version's `ui\gamespy` and `ui\ticker` fonts are drawn with `ui\small_ui`, which the Xbox's map has. diff --git a/port/assets/menus/README.md b/port/assets/menus/README.md index 2015adc..af20a42 100644 --- a/port/assets/menus/README.md +++ b/port/assets/menus/README.md @@ -30,6 +30,37 @@ file, the line and the problem, and the game uses the Xbox's menus. The setting `display.menus = "xbox"` also uses them. `debug.menu_open` starts on one screen (by its name, `main_menu/settings_select/...`), for looking at it. +To build the game with another set, `python configure.py --menus-overlay DIR` +embeds the files of `DIR`, a folder laid out like this one (its files under +`ce/`), in place of and with these (`tools/embed_assets.py`). Its `.xml` and +`.png` files are embedded: one with the same path as a file in `menus.json` +replaces it, and the others (new screens, the pictures they draw) come after +them, in the order of their paths. Its other files are left out, and the +build names them (but not its dot files and folders, nor a `manifest.json` +at its top); a link to a folder is followed. A folder with no such file +under `ce/` is refused: an empty one, or one a level too high. Nothing is +copied at run time. The HaloCustoms site builds its look in this way (the +web build's `version.json` then says `"embedded_menus": true`). + +The build checks the merged set as the game does when it loads it +(`tools/menu_check.py`), once for each platform: every XML file reads (as +Expat reads it: nothing comes before the XML declaration), no widget, bitmap +or string list is named twice, every name, function and game data input a +file uses is there, and every picture a frame names is a file of the set, an +8-bit RGBA PNG that is not interlaced, and a whole multiple of the frame's +width and of its height. It stops with each problem and its file. Without +the check, the game would log the first problem when it is run and use the +Xbox's menus. + +With an overlay, the stock pictures that no XML file of the merged set names +any more are not embedded (the one the game's code names itself, +`ce/port/blank__0.png`, always is). A `menus` folder next to `config.toml` +still replaces any file of such a game, but it must bring its own copy of a +stock picture it names that the build left out. ninja embeds the files again +when a file or a folder of the overlay changes: the step writes the depfile +that names them. Where a path has a character that a depfile cannot hold (a +`;` or a quote, say), it embeds them on every build, and says so. + ## Writing them again `tools/ce_menus.py` writes every file in `ce/` and `menus.json` again, @@ -66,7 +97,8 @@ with the command above. A file holds one `` element (`root` names the main menu's widget, in one file; `main_menu` if none does). It holds ``, `` and -`` elements. All files share one set of names. Wherever a name is +`` elements, and a theme's colours ("Theme colours", below). All +files share one set of names. Wherever a name is asked for, one with a backslash is a tag of the map instead (`ui\shell\bitmaps\white`). @@ -89,6 +121,16 @@ instead, from `x` and `y` in the widget (0 if not given). A list's item shows frame 1 while it has the focus and frame 0 otherwise, if the bitmap has exactly two frames; the game's functions choose others. +`caps="L,R"` keeps the ends of a bitmap's pictures as they are, whatever +the width of the widget: the L units at a picture's left and the R at its +right are drawn one unit to a unit, and its middle is stretched or squeezed +across the rest (a bar, a button or a card that a stretched widget makes +wider: "Anchors", below). L and R are whole numbers in the frames' units +(their `width`, not the PNG's pixels), and leave a middle in every frame +(L + R less than its width); the frames are PNGs. A widget narrower than +L + R draws the picture as it would without them, cut. The height is drawn +as without them. + ### `` ```xml @@ -112,7 +154,8 @@ without one (the port's own) is always the file's. A screen, or a part of one. Its children are `` elements in it, or `` for one defined elsewhere (a widget can -be a child of many). +be a child of many). A `` may have an `anchor`, which places it in +place of its widget's own ("Anchors", below). | Attribute | Value | | --- | --- | @@ -120,6 +163,8 @@ be a child of many). | `type` | `container` (the default), `text`, `spinner` or `column_list` | | `x`, `y` | Where it is in its parent (a `` inside another) | | `left`, `top`, `width`, `height` | Its area; 0, 0, 640 and 480 if not given | +| `anchor` | Where it is placed on a screen wider than 640: `center` (the default), `left`, `right`, `stretch` or `fluid` ("Anchors", below) | +| `fill` | `halocustoms_xp`: it draws its picture across as much of its width as player 1's profile's experience toward its next level, from its left (all of it at the top rank, none signed out): a bar's fill, over a widget that draws its track | | `flags` | Widget flags (below), separated by spaces | | `bitmap` | Its background: a `` | | `string_list`, `string_index` | Its text: a `` and the number in it | @@ -127,7 +172,7 @@ be a child of many). | `font` | `large`, `small`, `terminal`, or a font tag of the map | | `color` | The text's colour, `#RRGGBB` or `#AARRGGBB`. Focused text is white. | | `align`, `text_x`, `text_y` | `left`, `right` or `center`, and where the text starts in it | -| `text_flags` | `editable`, `password`, `flashing`, `no_focus_test` | +| `text_flags` | `editable`, `password`, `flashing`, `no_focus_test`; the port's `wrap`: the text breaks at spaces to stay within its box (from `text_x` to its right edge; a word longer than a line breaks where it reaches the edge), and lines below the box are not drawn. Without it the text's lines are its own (`\n`), as the game draws them | | `list_flags` | `items_in_code`, `items_from_strings` (a spinner showing its strings), `one_tooltip`, `single_preview` | | `strings`, `setting`, `values` | A spinner's values as shown, separated by `\|`; its `config.toml` setting and the values written for each (`port setting load` and `port setting save`) | | `header_bitmap`, `footer_bitmap`, `header_bounds`, `footer_bounds` | A list's header and footer: a `` and "top left bottom right" | @@ -196,8 +241,31 @@ help of the row chosen; HDR Calibration's `port hdr calibration` (a shown; Controls Setup's `controls ...` bind the keyboard and mouse's controls (Enter takes the next key or button pressed; left and right choose which of two; Escape cancels, Delete clears); Change Color's -`color picker ...` (the Xbox's names) set the profile's colour. The others -do nothing yet. +`color picker ...` (the Xbox's names) set the profile's colour. The +HaloCustoms account's, of player 1's profile: `halocustoms profile edited` +(Network Setup's HALOCUSTOMS ACCOUNT, before it opens the screen) has it +show the account of the profile being edited instead, and `halocustoms +screen close` (on its list's `deleted`) player 1's again; `halocustoms +screen init` (on its list's `created`) asks the site for the member's +profile again when it is older than a minute; `halocustoms link toggle` +signs in, cancels or signs out, by the account's state (on the web it opens +the site, or asks again); `halocustoms open site` opens the member's player +page; `halocustoms account update` (a ``) fills in the rows and the +sign-in button's caption, and `halocustoms rank text` (a ``) shows the +rank the site gave player 1's profile. The main menu's account panel +(`main_menu.halocustoms_panel.xml`, a child of `main_menu/main_menu`): +`halocustoms panel update` (a `` on `main_menu/halocustoms_panel/panel`) +fills in its `line_0` to `line_7` (texts of its own, with the words of +`main_menu/halocustoms_panel/words`: signed in, the member's name, rank, +experience, kills, deaths, games, wins and kills for each death, `K/D` to +two decimals, the kills with no deaths; signed out or a guest, two lines +and the others blank) and hides it with no site; once a run, +while player 1's profile is signed out, it posts the right thumb's press, +which the main menu's `halocustoms welcome open` takes to open the welcome +(`main_menu.halocustoms_welcome.xml`: a screen cannot open while the main +menu draws). The welcome's `halocustoms welcome create` and `halocustoms +welcome sign in` begin the sign-in (fail: no screen opens), and `halocustoms +welcome guest` keeps the profile from the welcome. The others do nothing yet. ### ``, ``, `` @@ -207,6 +275,161 @@ if_failed="true"/>`: a widget for a `branch` handler. ``: text replaced in the widget's (the controller's number). +### Anchors + +The screens are laid out in 640x480 units, as for a 4:3 display. A wider +display has E more units at either side, E = (its width in units - 640) / 2: +0 at 4:3, 106 at 16:9 (852 units), 249 at 2560x1080, at most 640 (480 on +Android); its height is always 480. Without anchors the 640 columns are in +the middle, and only a flat fill over the whole screen (a dim, a gradient +at most 16 units wide) is drawn across it. A widget's `anchor`, or a +``'s for that place, places it from its parent's edges as they are +drawn, the parent's left edge moved by L and its right by R (by -E and +E +for a screen's): + +- `left` moves it by L: it keeps its distance from the parent's left edge. +- `right` moves it by R. +- `center`, as with none, moves it by the middle of the two: it keeps its + place in the middle. +- `stretch` moves its left edge by L and its right edge by R: it is R - L + wider (2E across a screen). Its picture is drawn across it (keep its ends + with `caps`), a text's box is that much wider, and a spinner's arrows are + at its new sides. +- `fluid` keeps its middle at the same part of the parent's width: it moves + by L + (R - L) x f, f being where its middle is across the parent's area + as laid out (0 at its left edge, 1 at its right). A row of fluid buttons + spreads across the parent. + +x and the area stay in the 640 units: an anchor moves the widget, and what +is in it moves with it. A widget is wider only if it is stretched itself, so +a child's anchor moves it only in a parent wider than laid out: a stretched +widget, or a screen. A screen (a widget opened as one) is the whole display +for its children, unless it is anchored `left`, `right` or `fluid` itself: +it then moves as a whole, and its children are placed from its edges. Its +own picture stays in the middle 640 columns unless it is stretched. + +```xml + + + + + + + +``` + +A widget's edges are its area (`left`, `width`) where it is placed. A column +list's description (`description=`) is placed from its list: anchor the +description's own widget, and stretch the list (a stock list covers the +whole 640x480, and has no picture) for it to reach the display's sides. A +widget the game makes in place of another (a handler's `replace`) is placed +as it was. The mouse and touch find widgets where they are drawn; the d-pad +moves through a screen in its order, as before (a row of items is stepped +with `left_right_tabs_items`). On Android, a touch that starts in the +system's edge-gesture strip at either side does not tap: keep what is +pressed a little in from the display's sides. In a quarter of a split +screen, the columns are its 320, and E its margin. A place the game's code +gives a widget as it runs (the server browser's counts) is anchored as the +file's would be, and `fluid`'s move is to the nearest unit. + +`tools/menu_check.py`'s `placed` gives where each widget of each screen is +drawn on a display of a width, as the game places it, and `off_the_display` +names the widgets the mouse and touch press (a key's buttons, spinners, +lists' items, the items of a widget that tabs through its children) whose +middle is off the display at 4:3, 16:9 or 21:9. `tools/test_touch_menu.py` +checks this set, and a menu overlay's, merged with it as a build merges it, +with `HALO_MENUS_OVERLAY=`. + +### Theme colours + +A set's text colours, which a menu overlay can change without replacing the +files whose widgets have them: elements directly inside ``, in any +file (an overlay's own `ce/theme.xml`, say). Each colour is `#AARRGGBB` or +`#RRGGBB` (opaque), as a widget's `color` is. The files are read in the +set's order (an overlay's files that replace none after the others), each +in its own order: of two that say the same, the later wins. Without them +the menus are drawn as they always were. + +```xml + + +``` + +- ``: a text colour of the set's widgets + that is `from`, all four of its components, is `to`: every widget's + `color`, and the white a widget with its own `text` or `strings` and no + `color` has. The game maps them as it builds its tags, so help, values, + rows and headers follow. A set maps 32 colours at most. The game draws + text that is exactly white (`#FFFFFF`, before or after the map) in its + menus' white, a light grey (`#CCCCCC`), as it always has. +- ``: the colour of focused text, of every text box + and spinner the focus is on, which the game draws in its light grey + otherwise. Its alpha is not used: focused text keeps its own (and fades). +- The server browser's own colours of its rows' text, as ``'s + (their alpha not used either): `` (a ping under 80 ms, + `#5ED38C`), `` (under 160 ms, `#E8C547`), + `` (`#E86A5A`), `` (a row's JOINING, NEWER or + OLDER, `#E8C547`) and `` (a game of another network version, + `#8A8F99`). + +The game has no other text colour of its own in the menus: the help, the +values, the rows and the headers are their widgets'. A colour that is not +one, a 33rd colour mapped, or another attribute is the set's problem, as +any is (`tools/menu_check.py` names it; the game logs it and keeps the +Xbox's menus). + +### Theme fonts + +A set's own fonts for the text the game draws itself (labels, values, help, +rows: the high-res text, `port/linux/src/text_hires.c`), which a menu +overlay can bring without replacing any file: `` elements directly +inside ``, in any file (an overlay's `ce/theme.xml`, beside its +colours). Without them the stock fonts (`port/assets/fonts/fonts.json`) +draw the text exactly as they always have. + +```xml + + + +``` + +- `tag`: a font tag the high-res text draws, one of `fonts.json`'s + (`ui\large_ui`, `ui\interstate`, `ui\small_ui`), in any case. A later + `` of a tag replaces an earlier one. +- `file`: the font's path in the menus' files, as a frame's `png` is + (`ce/...`, relative, no `..`; an overlay's fonts go in `ce/fonts/`): a + static TrueType font, a `.ttf` of TrueType outlines (`glyf`, `loca`) with + no variations (no `fvar` or `gvar`: stb_truetype draws a variable font's + default alone, so give a static instance, as fontTools' instancer makes), + not a collection or CFF outlines, of at most 4 MiB, its table directory and + tables in it, with `cmap`, `glyf`, `head`, `hhea`, `hmtx`, `loca` and + `maxp`. +- Its licence beside it: `-OFL.txt`, the family being the font's + file name up to its first `-` (`ce/fonts/Exo2-OFL.txt` for + `ce/fonts/Exo2-SemiBold.ttf`), the SIL Open Font License 1.1's text. A + build embeds an overlay's `.ttf` files and these licences with its + menus, and ships the licences: the web build's `licenses.txt`, a release + folder's copies (`tools/ci_build.py`). + +The game lays text out as it always has, from the map's font tag's own +character widths: the columns, the help lines and `tools/port_settings.py`'s +measures stay as they are. A theme's font is sized so that its capitals are +as tall as the tag's, and each run of characters (a line's, split at a gap +of more than three of the tag's spaces, a tab stop's or a column's) is laid +out with the font's own advances (its kerning too, from its `kern` table), +a gap between characters as the theme's space is to the tag's, narrowed as +a whole where it is wider than the tag's run, and placed in the tag's +extent as the text is justified (left, right, centred: +`source/rasterizer/rasterizer_text.c`'s `hires_run_layout`). A wide font is +drawn narrower to fit; a narrow one keeps its own widths. + +A font that is not one, a tag the high-res text does not draw, or a missing +licence is the set's problem, as a colour is (`tools/menu_check.py` names +it; the game logs it and keeps the Xbox's menus and the stock fonts). +OpenCE ships only Overpass and the OpenCE font (`port/assets/fonts/README.md`); +in the HaloCustoms fork a theme may bring fonts of its own under the SIL Open +Font License, each with its licence beside it (FORK.md). + ## The art `tools/ce_menus.py` draws each frame from its redraw in `svg/` (copied from @@ -217,5 +440,8 @@ its frame (`--pictures`: only where, not the picture, is read); `NON_HANDDRAWN.md` lists both. It needs `rsvg-convert`, Pillow, NumPy and SciPy. -Port-owned settings screens can be regenerated without PC tags with -`python tools/ce_menus.py --settings-only`; new screens are registered for embedding. +The port's own screens (the settings screens, and its multiplayer, Mods and +HaloCustoms screens) can be written again without the PC version's tags with +`python tools/ce_menus.py --settings-only`, which registers new screens in +`menus.json`; their pictures (`port_svg/`, `ce/port/`) and the PC version's +files only the full run writes. diff --git a/port/assets/menus/ce/bitmaps.xml b/port/assets/menus/ce/bitmaps.xml index 0ed899c..6e5ee6d 100644 --- a/port/assets/menus/ce/bitmaps.xml +++ b/port/assets/menus/ce/bitmaps.xml @@ -314,6 +314,9 @@ + + + @@ -382,6 +385,14 @@ + + + + + + + + diff --git a/port/assets/menus/ce/main_menu.halocustoms.xml b/port/assets/menus/ce/main_menu.halocustoms.xml new file mode 100644 index 0000000..97ef9f3 --- /dev/null +++ b/port/assets/menus/ce/main_menu.halocustoms.xml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/port/assets/menus/ce/main_menu.halocustoms_bitmaps.xml b/port/assets/menus/ce/main_menu.halocustoms_bitmaps.xml new file mode 100644 index 0000000..945a8a3 --- /dev/null +++ b/port/assets/menus/ce/main_menu.halocustoms_bitmaps.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/port/assets/menus/ce/main_menu.halocustoms_friends.xml b/port/assets/menus/ce/main_menu.halocustoms_friends.xml new file mode 100644 index 0000000..a0689bc --- /dev/null +++ b/port/assets/menus/ce/main_menu.halocustoms_friends.xml @@ -0,0 +1,213 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/port/assets/menus/ce/main_menu.halocustoms_panel.xml b/port/assets/menus/ce/main_menu.halocustoms_panel.xml new file mode 100644 index 0000000..67e1a17 --- /dev/null +++ b/port/assets/menus/ce/main_menu.halocustoms_panel.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/port/assets/menus/ce/main_menu.halocustoms_welcome.xml b/port/assets/menus/ce/main_menu.halocustoms_welcome.xml new file mode 100644 index 0000000..c7bb36d --- /dev/null +++ b/port/assets/menus/ce/main_menu.halocustoms_welcome.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 8ee50f8..927769f 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 @@ -104,6 +104,11 @@ + + + + + diff --git a/port/assets/menus/ce/main_menu.settings_select.multiplayer_setup.opence_options_edit.port.xml b/port/assets/menus/ce/main_menu.settings_select.multiplayer_setup.opence_options_edit.port.xml index a072a5c..1619a30 100644 --- a/port/assets/menus/ce/main_menu.settings_select.multiplayer_setup.opence_options_edit.port.xml +++ b/port/assets/menus/ce/main_menu.settings_select.multiplayer_setup.opence_options_edit.port.xml @@ -5,7 +5,7 @@ - + @@ -238,7 +238,7 @@ - + diff --git a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.xml b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.xml index 60b53b7..501b895 100644 --- a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.xml +++ b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.xml @@ -14,18 +14,20 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + @@ -109,6 +111,14 @@ + + + + + + + + @@ -129,6 +139,7 @@ + @@ -141,5 +152,6 @@ + diff --git a/port/assets/menus/ce/main_menu.xml b/port/assets/menus/ce/main_menu.xml index 650ec30..2e8b80c 100644 --- a/port/assets/menus/ce/main_menu.xml +++ b/port/assets/menus/ce/main_menu.xml @@ -14,9 +14,11 @@ + + @@ -52,12 +54,14 @@ - - - - - - + + + + + + + + diff --git a/port/assets/menus/ce/port/main_menu/halocustoms/header_halocustoms.png b/port/assets/menus/ce/port/main_menu/halocustoms/header_halocustoms.png new file mode 100644 index 0000000000000000000000000000000000000000..238549ea16dae7e2adedf6b6e9781dc047f3dc37 GIT binary patch literal 69427 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 literal 0 HcmV?d00001 diff --git a/port/assets/menus/ce/port/main_menu/menu_friends__1.png b/port/assets/menus/ce/port/main_menu/menu_friends__1.png new file mode 100644 index 0000000000000000000000000000000000000000..581efd91a9189dd768e8fafe055bd4fb0e3eb0e9 GIT binary patch literal 50401 zcmeAS@N?(olHy`uVBq!ia0y~yU}0cjU}WH6V_;y=Rb^3OU^ueE)5S5Q;?|qLm2*;# zm)`HJ^LM#(X4Si6mu{Fhsc^hg;1r5cowP{f(xk0@s)15o$^A>!&)&FpLvF8R^!aUO zUUK&5D{Lm^^yk>ydK=emOX=LE6FF(?L^aomOpHPd;tkvkP1X!aiOhRLQ}1;Y-;)>o z{_O0VHLqT+dt+PogT+YO*!uImjva-M=X|!_uN)E*5;8}?XWD+7ihq1*^LO_DJiniJ z$EH1H_M4ube{=uQUia-k%|+GQv!A)?KRz(&x|L1-hkK7D-rJnKUmIO%bFbz`eN*zj zs~gLY@4afcf5)Ex!MEr9-eg&~r?SrId70I-pQhFp=R*@$f3}>sbNl6wuP^jIwx0Iq z+sQTFzr#(`<}<9j`$$IKvEriMug@`2)^&v^pSLAj?BVl&mJ?p-A7%FK`K`Sfg=_qt zSw+VvZ-2vRsHLTKF6v5eiL2|;lhXfAn?Xv|D=R?8fz1zP{{vl-0w&{f3L) zZ40~nliB^#udjvX~WNy(NoE9>;md|mtO;7Xfs zYmem@-jhAAc%k?4s$|Ku9kcQj%zLkUetmLe$@X>8tB?I_<-LFS z>BkcfzCKJZ+f{gnb9eIUp9V6o-#$CM>2V#)jvX?m!;bHr>Uh&q_ufwCZ-c4@x)rS<8jt#(|!wehM1-(2R~mQTfQGDcf6-)Nh;c~$kLdk3?x&t3d| z(d3=y#Ez%mydiU4FhG_;RP-uiKww~Doy5P>W|7gk8>Z#1<5MVhi`_oa%H+qJTMvFt z+!Z!g?2T;B8#DIv`cgSU?dKYg`aWGKykLrc-WF@-Z1vBHg?>4uckXU1KVF-XxOw+g zw{q*S!+wj}4p-ij6o{UC{iezF?|PQ^&pz&Xc}whg^`hq&|9<;;Y`ygE^UTwEkL63Q z-6y?&?G3f)-kXjsxBZgj+73)_})E{dc$-6=+@*4*>`OVH-|3y@AA&` zI`6e5o4;Dc9<_M;#p-rmj+iXt?M;lUlou>oWW@kVE_Y@Amn+^Wj+O7s z!m>}?am$LCCx5nod)%z}d)p0v``lXd`kL0m>uIxh9De=uXYdEX2HU6aPbMthF8n|R7_3TK3-5$=0SC?*H=Y8%{f9Jqb|AW=y{na-O^S55Km0O&+ zd&NT|_hV(nd(RZ>wML7dds%Yk>%$xR-yXZS<=bpMJAI1b&Z$q6H+O95)cN=B!}fBU zy7H+zx5>{gX48@0|JcxvPr+up&3~r6<(~_7f4;NHe*Ue84`QnR*x6prez9Vkxf50Sg22v`!03t z;GUng|E-P%GdHJH>@i<-+f8+PqKBZo8(({yi&k;n?9WUR^KSb+`+4%r^ttEWAFaE? zTUMoax~}^8Nlr@ZSDPT-U>WckoPvB`*ndj zoBHv@=Q+pE>|D|Fb}x;M@b<;hXBYo8eyUJD6??$=^!@e?I}If_ zF|HDZrv&E5^~>Apx9|LClXvb#yh+QcXIU9HUa~~yPiPiy*_+C2>RQsZAw5ypMmFcj zp_P$`V{EqaH3pa7Q7^8`GpS#9w_5t{Yxf6_46o&C+?9AO!@NCR`Cf#{g>8o?JT)}v z5`XTzz0)q@SJvL1-G0&+dngX^X=t<&W%$Ai>?c)9ad_7xb3mT zpSgmChCZjO)r;Z|SMKYw+??a5-}g5ue%^&S@A}Hm7v8)7YJ2~={`*Iha~`i$EzZmB z{&-vOYI3C3u}3>pWu_l`&G%s1I$!IV?WZ@m*5~D1-2b}!@jlo6df%SEj0zWfe(_GF zZqLKQ>wSM;Z0noLUUq=zM&I1cCq6`&{Mx=l{cP;=r}Ij7t-Bn&@0@;IdSUm+m+OrC z&-I>WJC^Z)I(r5-R%3d z!1=D^PpKm24Pt@)8{+tu>PoLK>r=D)e(#j{v9!eEojXtK$3(xl_wCHOy~WSN&z+rm zeaXQa8tYZp{LU$OQSf=kj=~nnV5}z16+-9T{b@c6%J-_zfPL2@L zo@so1_T}Qo_US9-o_Am0KJWIf!^v@Ts;#B=E5Dd_u3>G2Mz_iPq%Xw`-(MZvq1zXE z<&DaXEIGdOmvz6~=luSBQr+Ec{@4B9@47Vq{4=xS^yIp?H}Yh5n;u@D`2PN>jhh>D zXRbRerqjRno#)XP^>ZIuJ-=i4eTl(t{an*`r{B4!AHKEkZp7O!R@r73@^aqIN#d}2 z&ap%A?ObDLvjcr|7{DbgsHhc9pZ;;b=-vP8Wz`?m2j5H)b?y)>IrLVPqqd`T%Go`QQ#x|D7PkECxDiNZiz$!0rnHOS)4lNS zpE$GYxru&ja@Oh3ynOoErX^Pm;@_zBZT|gs?XmOLSB~0TSNnY-xo3)N`hkECIWxaG zMJ~_NW=mMT<7--Ctyc23tG7z_e7>{J{;pVdXWqs%o!@JhzCOJ&CVX48HPf*tH?}$- z`t=lGx@o@mqZ(WfL@-653`T16kVO!}k0__uvi!A*tZ z^+YYBKYl>Y?a#EpY2u5s{?^<$ajfvx2{!Qt z$v(jsOL88y7bh_ueq5G3CG#Fj`SG``rLR|b&zQepu5rSSi*t=N5|1TlL|jpoaN|4P z{#oWd&p(r6rO&Tg8vLq!_j%5{XS;U(*;`y(`{GN~!#j~y?^56Fu6tv@+IsfyyC1(l zXE+nPnfrP1p33XJ^Eqyv-`Qg6xOc%^;|0bF?dKZi?YpydDG$&8M6Y9aZfLGE{&iX6 z`r6>TF?Y&xqsrD@{#jYM=x)0vZrNk+|NK{xQ?|S0@pC`Eyw2EOR&nzBVv~p) zF6zH8bSs+`{`_35)9;$jaAvbj+nYJ6nX;Gnp6>m(vpT4J?KDICceXD@Vizw@S#v5P zr+jW{^5eU2409FflUgss0;FB&w&G=lh;npT3&O=~mL_ zt84N;tI92oovWO1`f=8|JIe|)T6oTDPAjhGx-ol$S;D1UvE>TMTYP`MKiaF@@IHd+ zX`zSZ+n1YKwX99P?e3TrIgfkVganII#nH2K_kAxp_E{`Lyf0el8rLn^&09A2uABVy zT*J{XY|{)Sx)e8_i1SG8J1^O9ZLxmkL%mp|^Wx7>T)1%W?4CHY>5m^6&%L{+yuGJE zx!G`MiNXHEe9y~uia*WxIQifl;VtGND*f@#d0s@yHhUN`FWl^!+u7H9#agZV;`%mk z>7N^;=l?R)_B(yFQes+ zwnOk~r*J6~xIGJLF6=$_pT%7KYyS?r`Ja?x#6QYwuX}42(Kh>bi1fKG;RUw?vYR)z z`0$pu&USTL{!y!2NA8O#%2bCXiqBrRV{V~*_?5Sbv$y*lZYz9g=_X))Lt|U& z;};vZ?J>KwC$WA}&K>u=YWs3zteMxAi%nVadt0WUU4N#j73TA-3K)$neRGo!K}6-JMCP9CEMYP`<6tWPnnUtdCSiqRgd>e z{K?w!ZqJs3rh=A_Cgz@B9XaE9^qJYhGxei;t#x{8Umj1EaXnsK!>hFTw&jjrpC$7g zVhs6q8;X3H@iF`2V#eZJNqfh+ld62P14C*8?SDC@qIzvCf?J@ zyu1HC_>>$wt!&%e#m8s;xiRH+IPdly|7JKlIr%-Y;!VH6X<#0A`dWqK?vIk?=as9j zJb$etwNiSzfznRSTN*1RC#&gMr@g!B{OG~~&JPy5Ok^L3Z+yi7tDZNX{UQJCH^1ip z+pd3w`DO?HZ9ZP!R{#3L-L@#+<$UtH&XyYAj`2P9+t(>)j(Oa|+XdSf&;KyL>#^O-&)ovNugyF8l${z|iY2?Y?^xfq zBCd*@iDEYHQ~N#(emHt|n||?8nWV0Y`HR=(c1xdYU;12hVP?^h{kI?N>NqB7)t4dk zWyOMZuG@_qo&`y>G37abT%Q{F@7&JRX|j@uP0{*(>pV4ztsiWe)4u)b^Q~8H?w_3# zX|??Hj{L7P|GnR_xpLo|8*S@ZBZV~O&+IRfcm@+SVyp&*^ zWUyb|nz`+zMscRP>iYT@TLMpJhjsk;n)Ijoa4y$A&yF@5`MnQ!ZOxq6dvWgTu-C`? zet)+3Yh`XfZH@8rqS%?%+jkt5uv)WmPZ`6KLkC_i`nPh8tUs64t#gLkTb>r$w4GZt z;l2{{+VV@LJFYyMUF832U8w1ul0%ybV!240Z~KJ_hh|)nQQVrnWT{_6qSXP3wK@%5U7N)hmN~i}WnJ^_exmi``mV=Z zEyoL3WSXkCp1%06^KkF+9Vc1tTkTo*@R3OV1Cf}5isB!dUak-tY(??)BbMF{$9*QC^{jB8tC5_^x(*FB%c{J0I&aSlm#mWcY<>9W z!sGGbekrmyKi+zHDqZ}zz#3l#KGi0cExk8C$Ta@iR2Gwz!6N&-@M`ls6;9E9H`lEe zYjX=$&kxBIEN#-955H`nQ|iS)nUZW%^* zmQ7mpz^D6hSxZX?i+|P6z#Yd=ecdX)*s+2s(Y}D=_+O2B-YRtmS69}l;4XaGlwY^c z{n*cB-t%k!xlJ=JSxEo4)6%o5ne!<4W`O#^_KxdpQnlt`yR~cfjCqfEyR8pruh;ARX4MliPhDYwV%n~ZA_?7R zOkU{=EOnOtxiLkz;Cs0{=QPK8?sV*$a0?}*XgH9rCAgCqS9EtyktCD5EJrHzU$}96t~acbIn-HrwA~e zbNyqIntYM_o{qKgS^j5_9?jrAH+7-mw#oZSByLNz)$V_{#NYq;u1!~O1)kf!n=epD zVw#MaqBU3Rn;R1+tUowWeW_8J;j`?G>1IlRZJKSo`Bv>+~~zU}%;N$F-`nbpbbHgED%JNbH%cV+LJb!#W8ubg;AaPLGj zSwpRp?nxOQz3ObLOC55|E>tUZ?p0wr6}lw(3&Xw87#00vKVCkZ5y}Sd+tNQjc#fX3^bJzrN+%hxN-RSeG*|LI zxR&amYCpBOc6VWX+8=!@*1r6VGtYnhPL98Sjp5z1*qP}k4cK=roEfI0*+22~%z10v zq$($|1#No6aUIX8sa;|$MtKOaJ;c{u)N`~W07?B!eRgdcfKjdHh@&41_^#{H_{mb<4s_O&& zia%E#CSPAxA-X5rsp!YThx%SK)b|*F~$DA`O=x@!WXG_Az7$gO08Jbj{CCZ|F?<9(?4;^A)nHMe4CnRv2&i zy#I2-jJ7=|r&PgtClZveCnWTIovt`@t&sSNj4xUs~IxF>P`OH`Mtu<8F2haNO zic|05;^S+dmN2K!I(~_z(DInIdD`iok30YQY%b=tj);EttL$d}i%!XZtGyU+WWAkX zegC1ww33B-OBe4Kb9B8J@bBGR>oXBnS*dfnAKE{Ez{=DG>W*IZzUW60o`w^b>AqAJpPb1N4zzwcx06?@Km)rW3;;H-Jg!P}BK+x*I- z4;MMaKB=w#_(ScX?&W=RGw=B<*OO&9BBfGxY@(PggZYgwW%c|qGxApZPPgB9?^5dQ z;?&SB|C3|ZzpHSY8~?XwhPAuyY!_DTex-;hn=12-o{Akj^7KU3i6cR*IkDVpx4Yyz zpYmf&>7Mm`U(Oue{AJe<_O7|`Nb}f6S!?x0r~e-l)p{4k)38$XEK|UykIV0VT(|w^ z3;P_E=N@_~ik6JMd19S!T+TG{&QV%f&g2ojMRU&+55)%>g8w649A3QU-TYpjlb`1{ zxNLgNaMrI+BG$>9C-U*Krx}~)j~fqSl~m52JG-~?PfgC|sIovG&Wh994bKUc385Ss{0=)tmQkpLN84+_iB-8P9Qnc3+_m_sr9a6OuAMXb47^ zt9)G1c zTzAOdi0^wYoANz?*FFp~nQ=68;Vgaquh*acIo|kD-uUf*v;O%x4KgzzlQGrsG3m7%chbyiNa`hh9t zhNp}5L_Jr(Q7-ps$?m&U?eyXHR@Gvu+#ljal`WAsW}r`Ee-8+e$G(MFEDlAB{Kc! zf0w{Q72g;0a%xmex9IXQbS@FubkXxj(&V|ab3dj{znX{s2_xGQ7KK|YF?)lHKi-$@!-G27dBVpcYGXuZ3H#+uD_2;P4F9aWiiF z^7OOH^aUk`59b{2K74fA1D5r=c1`zJvz$%Ipa03C%5=}^w)xWQ{QtgMP|UVaH!`d$ zc}+1}#s1`P{W=nNk1u0glhilo#jPDVZ%>FXubThcv8GUESx%1MX{n|ZwZ77Gb8X6Q zNyRxzdn`Np!#s%Fs%S~r1#c7UXGio`2v+Pc3BNVrn)pNC>A^*3K6uE*OF!K&&Efle z$Ll#ZM>EAsxBas?`DkwO^_PnzR@!r4`?_{r`Y>7P1pA4q%XvK(#F_E~MRT4zGknYTwTaeUv}Rr^4#g~g`J;MfkKMlH4o=A&{x{doWBA8y9uf`4+WH(!W<{BbhdeLqgYnl6_0Exismo^$H5sJFb}*wXTXa~TWk z{_7lSsm-Ag41#+!>UPZ33!J*nY>&5{d(HLsS1;~%ZI#H42`o^}75=C{aqZ;WCdF?y z&Re)3z|K9^LjAh?M=PoID-Ru6ypOfU|FgNxt2Os+q}u#=CNHyKWqZkV@Yk;AweccZ z#px%WznJx4&X?i`H!L0N#sqKA-4(5aj6}H2wP4 zytPep&WRkH{_*oZiMF&I3vQlJc{u0G=L${ZqzP;Z($Q=6Zy#~K-hNdyg*)&>@CGrt z{M?ub{#=W4GZSm}!WsRlr5d}prM0Cysf)a-EVpf1x1HsA(U<+Dk;|2Xj+t_EWL=T; zULIL+ZQIHf-=+1moDKyq`px-g*2CCqFHa=r?s)NW*-cO1i?tgbwXmp&iaBWXxts6t zyL;w}TwkO8;qrewOYePM)FZO0-d=6vnu(JZ|F4_np(o&5SRHjl<@;i*4#DWpl{ME7 zs5PxzVC8n%B;4U5=dL;yapo@f3Yp`pJ!SNC8bHGu3$hqM^Hp-}4(WfK^}c_jkfHC|N#_S3X#|C$ewX z%tvQar#XH)u}kdevpp;4b+6l6l%w)SqPZ$GaDDEl;-@MljBhHAm#TG4X#N~wq0ao` z)SuFcKDrmpPJXHTtlD(_>A&e5n`Vk#_qi7%wbSc>PSZVC+ud9GCUGgO<9NK3@$dbI z*Nm+XI4=9g|Nrc?=b;(Dqx!;Q%~X_U7Ufuqtqjw6s+6%I$Zg%}PTm)Zvt3Qac{Z&H ze315%%P;Lp{p6g4FQ4}a?=*?ga(f(A?zgn(!G}4w^w!_VHEa(%$Z(@d@BFd zR_k5NHfgvYeNA0=rPtpm)gKd69tU(c-mql6lW^x&V%$FMw(AEBVsFhq9_=mmt9-Ye zZEE(z&a(@D?lj&b=KZB|@|z2er?!T@cpkORp4~G>&Uv$>_JYgrINV+xVXi)SBCqCV zT2D7$yTrjiXMSvt`KE8tUOespwkeSee}7%xIC0wj$xpWQAN1_Go-pT^(7L(G4hk#X zV^7uE`>M3QuvwekdGGS9i*CtX%S}^X8M9|eEwG(4qsm=I^6d%XV1;)x1-@@t@-68S z`>rmA>Oj7PH|~|{A%+LKxU|5HACss5$`{ptSGH*~hzxq=Z{%8^@n?IdfcZzi3?HMX z`&->?5;v>vaA+2qbZL#_1eX_|{)K+Je^0zQ-gs`AiskVOp7%n;8&$7My6i1X`MYhi zc~^`X%X9PC-9Th0yl)EU3s7PS#O&6-?gWE7TwK#Wq3xs zHUHu#T|N7QI1 zn!V}c3|YoW%H3?7ioVH)ZZ(3c-al=sUsY>fPBTi-i&$wmFY}+}rTHs2->oT|_2J;d zB{g|dK3SF?b1p1Xh}F4%s%AQCZ^y%!>A?o?CR{c-_4TTe!6QGFl7!72CI30^9@KL_ zo$qBR@%cw)@T>i^m4E7liHS}M-{Uw(Z4b9g@61hmj!zW1wCq_?%EGg5Njh#j%~hVw zdaGx7XI2`UgVCPP6{byE2UEWa=ev0{33KdQ6F%`~m8p5&=E{>y7jNF0EGRa$weIdB z|J^?gx9>Y48EyUSxZ!%yDKGY~oxb%D*X6Xs0s1P2S9YKIbim{Cn&XDatV@*mUU=pn zzr@IW=G86c2@@oWSfz`9);-#+_&)SQlIqtp505_fWQm`1@#Mp%hf6JZ+?v0pcUMm; zd0epTy}P=VU$fz+ZPL=GUq<&Xw41~e)TPq$!gw<0*O`ajORo{VqN*d}HdXUr^%tRo z@p$^I%Gs71nOu&(%&_rLW3Rvag&r~#L>m5E&ivT^!N(ubW=3!K-<|#W zuXkULN8<+mSvLe!E-l$p*W)#>e)I9?yd_3btq*wK_bzZvyv?Kkike77!l zsp6gJ>Q!R4AYgX?gn6ItZHet*+_kx4+6@PVtiZFobMDDn&RWlzUEF7{w%PYbl_#?R zTi5-aHPV}NzDMT0*^$0fEcxEEMZfiy&Ae14`8(%FdGR8N7;n+*n{VwXJ${KR%k}B4 z)K}}x`ch`P%~3HrVEmP^&V=v?bmio-ko7G zjbjt<;=g6Tp8Ya>d{0v6xVC7;pV0J~&E9>h=RT}zpPckylfuGnlS&y}HW_BLeV%mn zSnPq$iIX19QJ;3AMo9T>;H5;z<(FOul`P6g>U#59tN%&qiR?5@*1)E6mf#v`&g{Mi zCiQtiGh|cPI;1xRZqqrsJvKh?InRdNRhl|qR_bwGnEP;Ra)s`$L)jaHl(vYjiME!y zP^x`heZ?*vCW+Q3Q0m}CT$1b^r~{N8`ba?|zAw-l@#gD!ekURHdvSGZ-v(VFd! zP2Pz4zb>W!?x zw-^U;O$hdUTFN}peD>b>7Yp6QQ(qVT_Ga-+NlW=Ygi+Y z4|Q(@oZNe1_uTJ4Ep)X1THihOVtd^@?@k5o)8Azbp1jae6S zp3C#<3_+*F=PB*|=7&XR)o(a+;p0Aw#izf%TKque^t4^c>#knu-+t!qWWVcwGek8R zTn}vAe{9o_`J&ew=5qypn&mCRF>`05*u?#&uKiA2eG58GLTe26aQaE#c{+Q?a(9oA zn5g5X!X^IQhZn!m?KvziD%-F8E7gx3sf4EWTufK&z zd35u97bk1gEnWpjFTC%q&segs^VEqc8;)OANu46&xZGsY%gv0+TihayojTXBDCcjA z>D$+Fa?P2bbdA!$#KKDDrH5-Wq8D}5>S@nB5ozq3vr#8z`r-Rejm5LJP1Be(>zvtF ziHi0tJy4r%O)?UWqPDDdFNcJm>qO1M^roUH32Q|I=J@(U~!R2Cv-N$|J?kLstAh zJ}21y@AllH7f(fceuw!n-L(@<^Qe4x{4j4{;qtZ%3;v|c%#OXvjHV z$0jeo_b1LZ1T9^BvWj8h$z6O>ysjSSE=($$qIT_nb#%efj>aX6B%Ka*9=4pXU99m# zp+Yc5-O2i7eYMcK;zwWk=Jnluoc2aJIL2P>`H8@p~W_(z(w{Jp}$*()*QUzyKmisSZTRv_5YCYZuE5*L8Q;cElu`rYUD0{&FYDq&^ z7pRT7!osxj{{`3ok{$m%1^*UHynE{Y@!(_i$ua!aX^Kk{HYNP=Hv1#y+y#dyRhZiiSXb>i&gnQ-w3$9vTxgo zE_03a;IEGl?tIy>PkOUpmYH$G*vBmV?!d(aE8FWmUUU}h+(v$8$bLWq_%lgwBH$38a5y22^)gW=NYuAo6W{2x9 zSx;IWG_r0}%0Ce`ecJ?6#;U6QvoE&Jed*x3XR*-kKb0rj>ckzVdrMZk12Dr(&YMZQSh1(@2%}SZzOA+`hRcJGly`obDBGj z74V#v-xYWJ(Tsg9GNE&|1v9L_@J_3gw_4tCyRE0h>iRU*Y|jtvlXl8?xxX zYOM$NQ5#=X?qaLMzaOpGT$8&jBB-k7-@KT4B@5+$Uz)Y_R&(UF7ZKC%J$xKByYu3= ztFm5C`|ce~wwU-YYVL0J)^###t(m_so-DE4abxeg{YJ&BLqa!fdgmc#n-cX!bdO+- z!)>J}uY<)7-3gXazgIB1L0a;h(4w{*5`jfh2lg3W(!0#aP$3;61|LbE^(xX?o1j)bsnMc(1>8*tJ^9?XiOd>nvH#SM%9q zZag^=|1bAm@t*UxpM$RbKWYAn_xYtM6C=0W?%U#H9=)tz{^3?4G z^pT&j@>*d|o!LcaD{mWTZIBaw`Nn(JPCuX1PcwF8+;b`WAG`b9kN=T+dQtrky@Kb?Ah|D3<)_f7dby(jYi+x+(g9zs&zIv7qUh z4@>1|Y|19Ps@KUKHg_wIWzEY{hec<_dQLUSFcoCwd~osG?i@=%akwK)wLX+Z-1iE-|k#+ zdEHF){*;M3*Dzn0x+uBKSE(Z0%Wm7!q;`&58=0IurDs@NIesWWE7WaQ(ae*M;gM{P z*Hq5;_dUD6`9jf7_lt*ECOireDu`P5I{j_(`RKf7yJmkc{#hzsd@^dmO=*eQbM`*o zusAzshnra2F>$_6KjMF${}Z^bBuZv!Q`Ev=+ox$Kve;kt$>0_5y6iIh5lhBtDZV9V z*Dx3GtaGW@eC6#d@x<@fI^O`{R{&^lH{wNlp2c&G~!FioeHyp7<}9 zyu@j8%R~)cr^!>&rmXz$8_iSyo+C}QYnt+|IIgMN1Mi4dOYHx<^v<7K(iP7>>pk9O z>l^;<*PEll?v@#4e~iv&&%5|p|M{=anNqTIioOW`xBj0~Tdz#>xG!@y zg#9J+wml_tBzPj^n-tEjY1>nLz~cME-rbhZly#+j{VWUTHtnzokzFStyLUliqWPI1 z39+L4xnb-4_nh`)n0P4erN-gdFB<}~xSL(?6$xM8_f&1(*GbwtA4*OynmT=RxZJ0c zi*sf?ygm8H_UZ2v&wti?+!iW#-hcnR>wnL`fAp7YQB#s-ROf?bO&*16rgMYpUzy52 zb79!c#pkuoX5yyiWA7R7?>bW@r0lUh&9X-M$LsGK|35ojX3pQ7e&v=}QHR+X4SO;3 zm?NsI-(9}ko4Kg=gQwij8_6{vufKoX_vFqm=|N3RyQ~yA8&xhCZkABYI(Xsj| zljamzmiMCf4R=L3x-anj825oIuB+nNvZvJ+hT*xHES{4OE&4E%afA2~r6Y!i76uh+ zge=|?`;+vi&vX79&GV;o+Wsw*g!jLC{Bzm!PhIi5`~B~K)$p%y4fYch z)V!+Fc8g)10fU*S>Vkc9%N>@gxp(*$WY0SK@K(-`YLn^h#%D9VeNXkDc&EHqt@Z7e zrCh(R{d`>ZZq=MG5|y*WV~_Ug@9L7?|6Od~vxDnDeRywwqIQ1`KmQiDEbmo(s-_35 zVjJ{Qmv3NdQ+@MnPB*s|L+s6c^A;L=oZdKLedMOP=IjHpMoC}F@14-TUnQ&`%Y8g$ z&HVqn+t0*&H1odt;h9I?jCJ9^&QG1vdF*TU!pVXUx+j|)I@s_=Qc+yi_1C>Ul0TB} zt_+0^g|9pq@bmw_gP)n1ul~0^k^4Z6@42#GZ(-r2mmAb9)x#7nTyjZX(sxmAe(}G_ z%U>$}=kiVNDQH>wO_X7eXlSm^bKb5;`yX1y{8nE7#Pt0Brt~?d?`m&Sj{00FYrQL{ zxBK6xwR=A5@0k$(r<=d{_xhSC?E*R+W>U|rP8)iCHh!pcJ#^3OmH)r6rHFCIbTTVE zi=Q+h*ni7KOZNjht&Ks=apIT6w@9RQ&CqXiJ*hf<&bJ2!F^L8zxYs>+1S&@B5BKib z@bUSA=F`9ayqs)U5^nc??t}VIuYc_N|LXb=U6ydQ)+3JZj6}>F(!^g{tvFT}V{Bux za?0E@hnDC)(_*l6n)CU<=Uq!Hv|nG-;wjLsd>*}Q?F}F2L~X|dpIMnc-8sGE-_G^V ze!bt{di~!s^_{=P?a!z$n|LMd*3Pp_-vqSSY);!-rTu8pt}VxxRJ6tjzME}W;$_Of zvwRY7y~xjb(G}P3*YL*Izm)o|cf;2C+Jl0y7J=S1`9fXq*e3m6l~LW{Ka)|EDeu(; zw?Z*NDUGQT9=uNO%2pB2KK%RSEh-A#tk5;(zxBiYkzedDe>`^LHQxg>W+S=BX0Z$Z z@qhXk%@LS+Bt3BdMrD<=SND%G_Pwx$Rbcd2x|FRoDB(Hy-djBU=oamjM zrx_-$dHA4DTVcl$15;bmmAvQo_N4zgaJur|`kEGYgCmB|wr7~V_w-(=5)^m@yKKW!D^XKsP zkIV9_?eu>h-~M^u{ptzrs@nf|`>j}aol7IscFpQbil3dj_j)9Wag?}rq=ap4l=&$h zK7XZ`+}*Xur?y;HU@;BSBN`J$ti)2^G$?NqeVTq-K8XR=es%UoYq^m*>7^()=4 zE$?8fSoQl;cJ_bRgwe|I|L-T9{&&{>KdbrOZtjgM{&_F^$NoWDaR0p1|5=}NYB(;O z=rTE`?%mcWf4F)33{<86N%T(Lb#vX6^S`z|FMfQ#nmK=O#*urUR~+tt&v0S0`SIT& z50-|1Jahk-+`iAp@15O!|97R__QzNI-k+(}l>YwnQ@PS6DKXc;9c4;0Qm>Y2mn3t# z9xzB@J}i2#DLZu~vx7he+o|`L<~Aza|5UT?|HkW|mYx1y@bRYudz{w)@;2p*_BGP$ z|F8A`bacJnOpnN~D%QD6dNzA4`}$lcy~#Hbqetwr{hJKaU&~5U% z|4W>h`HSaqC-=W_7C9I=G3MCi|K0gd&#vD)@A=ig&imQCHOpto#TtlJI};PyKr<>+;`x z;>Jgd9!0hLhh_b+`s3Op?(dJZSyLzQTC@ zPxkz0*5}k8+lw!A@4J2Ovq(egj*{NJ|G&)s@$7w_pm|id6Yn0;!fiXR=Y*B5zOtxH z$zx&iX{WEBWA8ur`D|z)%=me2+C%eFOZGjh;|wRAiQ#mTFtOJ%J={9ynNaTpnSby1 zHs1dC@cQT6=glAOZ%CdvX=2^_`QIeN>i_SqfAk7>xw}N-^|DHzGetLpS_6*!XaV{xF2%*_k|fxHs)&d^j(@H@bBjQ=e}2e*iT!U6q_~ewfEMv()s5y zpB{{vIg#;J$Q<251MlnE@z8|Jx;*qxf8(eB0pAq;JWRiL!o21>_s+O3r`RgX|&LhFO&(JB@%e0Jd&b^7wb@m?r-gtc4AG&Gt zdecMx`x>Y3|K}DL@SX4Wo!ey z%fT^3JLlY~*H34pi~o#y8@sODp+xm}Rl37TR()0N*ykb^ z%cfoz{B!kp#Q&@BpUjT0dAc)p-dhVzDfNH%SKiye@lO5Tf!D!?FI>E2zdx$ka!$}7 zfkj8EbMA%s`S0UT{nM5DFKeBuYWZVR_kTTyHSBKxZ_oZYZTtO0o7;2dW<;)bW4Ui} zsN?6gDb<(e9QI4U{vmqaS=0N!mEt19UrtWi|F&n}{=na>px*bh-}U1E;-??Y5_or( zw)hCYOK`BdwO{hxq#5St<33z&xxUHsUUus*c}_P!#*COUfd^@V5`ufo_ptx(zJF$R ze3j|>mCXf-$ub=OU+C8SHLZ?}KGaiCtn!(4Rk8ffn7MsT(*L$;_@!C?$(zvkfc;Iw zPBWkQ9cl8f-~aL3_jQ@~&R2V69c)jQNflhKTlaV2^v^NDF(Pv!^lweid~`|r%fe*V z6FRkry9IqtTd7y>VU3AN`?G%4nuYW3)>fG<`uIb>o;m;LJMp5&bDGZ;sqDYXSgfAS z`sK|W*LK;aY@4(9e@5-Q=6UzKT=-h13w|q%CO!@NJxTXzWy`VW&xJlM=JuY$GTZb$ ztZnP3&!ZFwEm-LC&-s9F@I8a= zt`4h(j}@gV>$+*YFP(Dg!Dfc(|NF#0_2gITtzX3)Xm}*|deP(W`zAb<`+Vas;=Q$gif?!Rc{gXl zEuZuQZ$qwpRKIsZSKl(e_}_WQn#aC-US#K;X=;#-UR=y#QIyq?B4}ZLy-2I!HE;dQ z^s16$|6AuY*Kd0Ws%KuBe{7quy0ql6hWG6Qnb$49@B5mzZg-Ks_|DMz0sdit?ro5bb_vxKA-+s+eb`bNrS9-oJskN#4b@7g0)p-xES^r;4R%Y@eLz925dUvwq(v|aePI~?SS$5?=!ziWb z&ThV2+wJ;iXYtF0Kk>h6P^i&l+Qz97dn7&}FvKbE;--i1RW>u<(C}k;@W}Ala?@M! zfrVzuSM*k_u;{;V@&9+ma-|r~*PP`gEnAZ3^miQF^YU)=rXS1r)<<@oUC-?oUGu5p z`Q}+gCz7K4)&!*UUQZEg_`U3T(cSEI_FGk_&b#s8g1X$}kLT@TOyYm6{e8o~PuV-4%@i-*IfrTT^S9eS9ICGr=ilht zXRAP!Oy6ww|9Is1{zH$y%RgA@&cE^J zmnq+TbQS$&Qj6B}=FE?s(~CcHuYd4qdfeHq z*WV@oUuo69|4UHb-(LTZt@^Su9;Zv68|<-5{r6EdyzuO*{-wT4`fZOLuK%Vg_wx-S zpMBAc-m?{HsnXAn`RrOXuXUAu_Y>`=u7ye&4J9R~S{e`LPrAGLRbc)KwUU^aZHh^k z`(zo!+;4ao2>J+#NtGlwtz}GXD%3nC*W>(jhur$UM(8MYgzP;w8HLOL^__O`R`672 z@&C}=Wanyj;$ppTJvly<5L;()D>< z&4rUP?tc}S|MTkaiWi6ZKYwYpm@`}V?f<`q`wFD$KP8{Pcm7xZzFFdXpQ`13(+)Yk zFHL@W&0XW2kM_nt@I1F<)87jWSCY3fO@DYi?sWIMd&iG1Pv|>-zqY~u?@m#gY?qZstdOIA4!_;PG>3OqiSfx8H-KrEgb3cfL**|6k31sFowXy`bTzNQ3f;sw25c?+b6)uk^Q6Da_w|MU;opCreQn5$4UCtQ zPuu*P`IEb;Lo~VQ-jC^@H4@L9y0hK!zC_rfBS%dGcWqpz-Q$W;2f z`1a1f@%26WMSL2+ZXY<OUp*%wOfx(8aY1w21wv=hOe2YybVveY{67 zDu2QX%U}6|HSk8)qW!xL zUflnWeb4Lse~RVdkN=-}w79?UgZ`e$_WzDNw%N7nea+9StCv5V=fSy6){#_aTX}+819W*R> z!g4Z);q>O%>R<2cb`&biwsZ4)(+N|(j(U*F6{UhxX|Y*6r;0Iz49Rt`p~`^MCwu`@eGimv!gmoW<5yI9qJ+{N23&@bCRK z8K#;N;qM5$SmceZ$`-Z&@3>UgF?y zsac_ap5@nf`TsjsTk}&!ru_9@236Svjd#~KGqP-b^uw_0!Td#BQVw&KO}qSrUopKp z5z6q#!n$V80=*>h<2npG9$x&;By-TBB{!ZaKua2SI)KH@KmUK9cKfRRw2-%w%ft8F z|M31LymR~trU`6~^nF@7e=g&?)%~$I81zz?<}55{>X3N1zf$x3SM5{p|9n!M_v_yM zC$aOZS3Pr_-|N>P|JV86{1Q{aJ>fB@m!@;pyY*fFcYAu#<*10>j}t`xe*Axk-R_6# zyj7cDU3qY9KlG&HzSUR}nu^Q{|Zx z_e|uOwDXfigd~GS#x@QL>=qDU$*&V{(pk~yd_sVqS@N3e|J|t^Ru)5 zwEO;Zxp|v@^8fGgpELEGzV^~z)1Ln0KPtWZ#eXN^!%@1)YL?sMd3q;InJKKmS!J?t z(x>z!PgaWy&tB}$HZVVA7IkL*)cgB5lK-2v)vw(c2wmQ(E_m@K*L6ns3AK+Sf7)~Z zEW7>mzd2{7_ruLXZ^c;(H(R|ucgN=M{?ij{&z4%>u6dh~Z2EKI{fGWqPv>v8l6|nh zCeJi<&ivw+JA=D_pTE1ss_nG?os)CxKM3?+y}89W{p87ix8-+C4UZ`me7yG5LEYTg zBlQkHr+;3-%-TCJq-psZ56qcB_?|E^}y93W=u0C_{WX5csuW~tYe+(x) zchtVQ;r$JUuw&VW&wl#1`&0ewz9map5`;>EFZ8bAPpFff+Ocy+D+9m(h7IMNALF$_ z>k~mIzZ~Vdz{yZnvBk!|VGomk)8SC|sRiI|s5&MucG<*R|fw-(&ZOBmPgX|GZ|)sp1N9f3)297ZiNw`Zv8tVm|lt z-@fM~dz()m_}9<>X`)qgS)ctso%mh(;*TcB|JTW@``jC|Q<$qh{eyh+kMd*t=ef(i ze(v@6=A4iDOQ!k!_H*-^ut+d^uH+9T)kpWYe%h{S=#e&A9BeNp6?DuBeFh@&aQ7u=4iGu#VqVE#}jeoRS|EQ4NQOqv?mn;6} z+j5!C=M24KJHAG(i#qzf=11bbFaPwD>`Sdw?oF~Kd!W2Lv^ zg~%?(qYX7r3})~D{vhkq%lP8ES?hxP;`O7?y%cdg*fR6u`sT>{oJ?go4RHz+VqW<> z1YcyB(0B3SMze%PH-CJu4lqA;zy7z>F_y=RX8n$b77KHN7!UB7pPv_3Qc^a1t5~A0 zHRG-q`z>20WXD&ZZQK8Il67Wr6ax?I=6RJT-?iR(S3Af3T}_4Qt52fK*;fl3sjez6 z^?Iu~zrS%Re{{@y#~b&!lf~t0m<=?f%|j;G zuYdEu@0Y#6pUpw+`}Ryb<8`iH^L=_4Prb6z77anwNpqQUo33>|)OS;Q&FIoN#pL=V z^Rqh5e(eqi{{4)t2?`I8`f$v9N6Y2M0(+j!Df{!{O(lc3aq|AvEDd6g4mBtLf1k;? zA#p~@&i|afZ6{fZn|2w#x*lkD!1c{M!(&!%)BoFFVThRZKmO%^QOI<}!FApYPVr0= zTDcgmr9GBcP+>V6`0qs;Q~flrur>Nk4RhHXp7R(yd9`=9c+v0k{MY8ZDCS+iV_J6k zZ`t{^UC!yhEB8I%`qumR-CWr~&-x!Twf>ZE`Z4qS$Bm13FX;OJbo$3lul4ja)$_WK z{mlK6FDa_$W*_-wz4^aF)%N`o{~dTP^vUEV9}(Q5oZ%iNr)$l!<^`igxC>*i%I5b$ zi!B(vN`7{;-~4e!SbhGpiw|$r#?)BtdaHZ>SzGb8SBJ~|7!MZAc$t$^Gy8t{;Utv9yvD(7hxKFM&OG?&$Dox%lc zk(3L!`~IC%N+`GARsDCm_pvqarE}x^!fw`{`zf9GB+@+QjMQ^y(~e7uMpxJhn2%3z zuP$`Zt`gLf{{P4zO5iBR3>KeRe{9A34VOt&Z+rQE)_+)%U(Uh(Db-3>`i9|*(-&){ zm6z>&*e&c}q|Kl`@&6>%jM&TrdmJZhjdgS1Uo^wHU&ikD$_Jvxf6j(~o^-eFxXHwK zm0#ZY|K9O((^VHydApK4&i1*zKYsET{ZDrH+b19YpJ(3ZXR~)aVqv|~ruyU@w?|mt z*07@61sU2A({JCE@)XfMRxia_xWx75{flgizfQWz5c@fjeSvM17K@45dzJ$iwms?J zJ}dP3?KnPx-yat5PCwppKs5Wd^%C=2cdZ{@V>;`>-S$3H)DnGrh znyA^h;}J_h4~Npz$+G6@#+Uz_&(peZs@X5D&%y9+O+%q8^gO9IY`>3n_<4QV=c=RR z>(usa_sIi-0sr2~-A?3vI!Qgdtzvuak*5_uicEXXY1Vw_-uZ56_}e(q|BdTE-AJ?f z-FfZz@zpCsK3rL1wX>}IY|FpP{PSx>Sp|PouYZ13dHxYxa!yfbO zNy)8l|FZDqJ8eFOJuhe4Jg=`1J#Tq`nM=Ia1=;Kw-j%uxk2W5AzW=-0ny)uK87^=# zgh9u)j=nrln|iqF|Dj`FPds>UyCC;x^_0*5t#uig9u#kOoFcE$+W5?@UwdVDZNlWU zRt3-5;)+DY|NeMgc~@)S>vQduPhu>de{I{)^?v3uumAC^b&u`#)!cuqZePA`e~0(~ zU)dG+SkvFgcgJ;{PYGb&H|6E)RSX>yW7OY-_tb6@NQmCFC;D>Czw@H*4cbo`c!Po? zSf*Y5UtHywC-x~nEBnS_?WD$7lY93R|2|;kTfd6A{>jumua4h8(ETdZ(m-ie)*slY z)y%_-KTF@*vNNSwntdwcmVkflxsm%;uPa-;N3yeY!@0UWzpV6rPQQO@cG>Em7Xp3% z->t2A7-R9?R=bEHskGv~hZq`ft+w|V!W$1n0*`50(muO}fT?+IF zR(4ffw&>ORDH`?Rf(wHGw`Z)Zxi-x58Yk%bWfjo&I6d{||Tfyu5n-?LMhmbYSaF#A)T=+MZb(Wo-X9QzC#b10rzB#RD)+3QKl5slIC(pHT zaj3b*_lv$!W{?sr?alpIP@YbKb6xLf_WEvd-VxmHq!~dBIz8zE4-J z<)1y&w>$grc;5!ew_onvzU5+Nvh?qnaLZp)UEN&wEx+xg>7hAGV20v3-d7(=pVrx0 zI19CEz9`o0FmR2U(Do;4a?#C%T*0lc1mFwYyP3W7j%7#wjaRx*V$HDa8Ou$kul21J zuQykoW3OvSpVJ-td*=F2PvU>&{(Dn1$L#m@jD3$QV}AeDf9SgUjt=vm*Zm(?a=$hD zb8U9zz3cYf>2tG>|2;nc;llZK_fM{VKVR$5oB5wMuCHY?f0ya!xA?rc`ZM^Z@E{Qz^ryC^RO)c=cl3TGnGD^@!s)f=JbM_Hx1UgN2&4KoO~=_)#Co| zuKJHZ)A=^deEN@lukPOH1=74Pd;abF)&EGx{l`)E6t2FG*K1n+^g9G^rf!)Q%=7)7 zYXy^pvhDHrX%9Y3+uhp*?GRh&&fcH+uRfCD(uE7tBbOXFk$d&{1~G}Xt#>bhE^r_NygEpkQkmPpk8hFx!T1QN-(ku?rYj0 z{c34|=PO`}*udmj<(!28O@9osJNM-x~aXlf^mpk4>)DDz`}! z<}&P%VKp;&{@GN{W>MHb-+w&|XIO;fie*pJ6MSfL-}PPf9B29J9{v4qverFk@_bzN zbnBfT>-SBS&i^mO|M$u79~Z>uJ?ofK{bSbwkt00e5=@UA+?sM!<~%Lg|3{8Pv6$WF zW}tx8v;!+RvhoyIcC2-Lm(^(6lD%C-cipjiSJqIL4T}~v1xvMx@OEt7eWxh&@cu_C z1;3~Nxbxn2!|$s5qM{7JN9#Tw-d(Iwdtdj?r#FAa7bq+^leO17{Cli{_LrKCL6QqD zvwX<-aC5=?D6gF>7o3}D()~B}u>Ee~5Sv$~-T$Uqw|6B(E&3Psg~P>fz2cAhx*65_ zCVwRbf6PiRxSU_lfB&Pc{*$(Uc6lDbIt-`J^vYZ{Ptm>2+9&h3JiE>K>@PmC!g~d; z`2r8WWSMSO@M_{lvFfCr0+yHD$MrgdKVL4DT@C3`bzN@X;jA1teKl)%$Q+J&^EP}G zu0LeERb4Br^ZpMN{`zhvDnaiy&l7F? zEquI6>?PG^&1G9SMSM!%t@j3{5y{NY4n8P9e%>`{oZ%LIc7%Armgd9h3nrP zzkk5{Y?qLOSZ>KWm#>~X%(_jwJkuB?EI#k3mAn4y&(3W}TQ&3gAD&57bl_IiO=;S* zZna$r!_3RdyEeU3*Mh8YY%*TSEFhFLEBadUx%07xziQtu>xvDl`f>An@$cz;n!GQ z=DQ#6Jk7ShftxR#FTHK^9H9pr9tjk5^iF&*;aGu~;8%~3J5|jEhBH|UI1=BBx$Woe z5`eV=T#rWXpSVdfHf+r^W4*)wzstS*H`hP@M%HW5IiEH)?Ecv&U#+`7ieLPjoX2#@ zN8RgPJybv4Utbw45N>yUuXsA!|C_t??CM%x-mf*6&$#UK`2qqP5QPw>!M${n0=F(Z2dW=`~ds1B>>ARj+&G@}bZE>0|fXt+$@5 zH)ww^y7>Q#Ra`*zru)3-7JXlL;a7iq@r9nc_u>_w((j#TFFUMYd3`2B{nx2`9+sLH zs2=@M9yEtx+GU0{1`G`Di3}P$zP6Ze;yZAp;FgFi!{L{9DK!?iMf_vE#F*}QA-iV! z)z41mY5y<(t$Fl*Uwi+xlYs{_JD&Y}wfD~U+3$bc6E8f?y)NNB>+Tlb(zuducSJhx zRyRil*510Y;!5-PpZC9>KQH(PG^BU@-r4y+LT_6w-W?RId-l5W^a=6WdhJYxoh6Hl zHG}^zwP2cacxOxblPwL`3Vcr~H7Xi2tH>VRsQ#K^jr@VV&rUS4F`Ox3xM%oe<`omz zTK^RfWCAYBbw%kGv&1B1UG}^f|HnZ7SM>Wwi~7ZEfBG@M z;&7`nhXk*pu0(3=+V6>z^KFdc^B4$o{6MF2?NL+=oemEIo{Y{g*o~ zH*oXad@ax&$+O`)kP~aQ&TXx_6?V%{hEbepA82g*WBZ=p;_sduyt_d@ zOUi*&Jy7h!{I{EQ9%!$BDtZ6!p?Pa+9=`dtM&cj$Ifn34`)4Xl>k5cDQduD89em`s z4##u0txtBJU)&MAWNM_VzgG1>=F300{E{iA{@-fxqT5x@25CwS z5|_jb9g2?JpFMR;L&s;s*9<=vF)!fl`x?W!;4-6!a*FNLnb$ZO->vUM1nr$E+uv{N zjJ9IZ6IhX0|3~0^{qOyczu)^S(7*mr!}?teqUv4s|2^gY{m?tb#PsJycIDA|&usMT zjEwhe{#1SL!@Elin+{cX-uWFQcCJ0=LG;49YWwV1y&X%16yLo%sXfU_({%aFsC(8@ zn=ahxDJ>9ta6s*C|3Aa>{eSI0%>Vy|yT*h)EHfitq@16Sm+l0Y&j>y;lmtqQ8 zA`e;iOH1;#&u~_&{Nt(~$r50@$<8&X`HL09gjR;eIF1d7ytLEnRu``<g^N(-j zGyd@++2)V){?qb*zTSU+*k5L^!RHL^b8>(F#!h41lQg%x?oIZ&c*g?^8SlA2@!oP> z#r>7+-i`OBRu`{*aGhoUvx01%S&y#sTyMy(F%;2VeXRUYVacJQC>dT6_l7xhS3h=_ zfBb;|ubzC(=lu`2|2x|L^Mm{TL(|P4|J=WA?^nw`4ky;|cPSq)J=Bn=#__^Atx4xH zqd|Md865`R4!;St(j4w=P7SxGF|S#%!s0Htfvn}HzOSZ(`MdqW$<9%KH$A`0<|uBz z`Rq1FzeW4LOq-}Nv;I8K&r6E)l$%`1l_U_WNH2`0E}QSKK>Ucf5RWgZTenwKe~Q{R?J% zp3Qne`r+J9)jw~m7BJ4?kUQ$LQDVb_1(v(+v-j#e+f=}IZ&OyoanX+!C;eEa*Po13 z%04A6boYNhar)NP-#7i+ zw0h^SW8FJr!tP|Bzj?ki{B7c&4}p^!JH*V~%U*R{p7h_}kGYfaqNblrQu30(B`43x zT@<@mblzw7iEO4j7c&`bx(xKnkNp<7$N1;Uf!@B5a(LTk{f^&)a(|=WKYC|ZG%xe3 z@#aVa9^=`6b$<$PJ};rxe7=2Qu*38#o8qkJ+HIGI=Et-zS|9dx-`>r(Tz6rv$D$J(OcHonbrmWC_gL)F zwBw9ZS$%T$gC*X)8dpAU_GM47UU7)ozvxS&x@DD_%z54LP4DaLWc6#`ezhqM?PE|s z5k3Df@Be3Uasjg?|F3=MWL_u2+rEh1vALq))-%g3>JRyvZ*T-o*sCDMZePTpIA!N# zZN`vf*m9m72SUC~m-;{V;rhQ~g%>|;PpWt>b@gfKS?Q?58_J$PadP~eX2J2O%Xxu~ z*L#r#=R-v&9^yJ7IC0;yPeEn}xGWgjm*xEXw*1GHm`&68v;^m{?E9#ex35TFeC`{= z*nQ{j$?xk;|G(L~V!~&${fqPbEZt8t{kvVg^Lu;zxm@+%vfJa%e!c%WY2E+c-#mh?^^LG&H}TC_l{fsnf_n6{@Y#sC&l*v*!HE_URd*}dVW1?ctWc0`JMkJ zNm%5XWIL7kF|4R@Gf=?V+aho{%)eYjWu%Xg3Ej=SCAc8`5!=;WfOrS#pc{h0>#fPxO{11~e6%`|(~&IOFHrqh<%z*UUL@TQg&MS+T|R zwVR)}$Di|k{(8%ujPS<{56W~~95-3&3(sH5p9B15t?LR-qbulkktlcVpgf$UjnX(elZzOTGIH7+5|k+V?Z+JbgZ` zOWF3RQNyclHV0#g+fiH_U@f%^s>k-fzWQ}?Ls?rrx1rR^(j^*4ST1s2Z0RsCRgJ34 znQKsbC}hFZcZD}vR#q%K=e4+8Dy7}=pHGQf&(Vt)oMuURIx^qXylBa^t%PZfMQYR0 zj{g@Pyq_aQw~_pM^>fA5NqJ1p(r%d;={3IAwYJ=^YE=tl3u z|6Tr_eShctV)^Qx_VUlW>mUBy^CC68@Z--JObyP9wA=Qd5&6%$hhv{^KU3Tqi645> zKOLA&qThwC#w=x<=@%jCJo{CB_I zq33dTTXyfg`Ooy*y!AQ_`ZimoM1Q>gwsC#j+vVp!$Ul<*^YYy(Cbst9E7x0B>N1|& zm$Q1#=G>iYr@LQ1#1yb6((Fg0fv#Y^=COJsrxFuGfh3Ld3p<^R!)EDy%1=>r@cg;4 z&_aL5F{8Wt3zwQKP}m-K^yzAg?ah<9uQN{I`Mve`k0*KikDqqX2x9-m)3#$yk@`L7 zm@By)4pJ6u4A>L+m zPKt!O_7^f`2r5qr4&f-s_@OWH)#8d*JzsQ~+l+ECl?(O1Ki*w^dH#Bx2LIZr&8vUR zOOHEMr(b%xI_Q={^xLpI=gM^DE2lKq{}JGS^UNTwM)i8lf89F;aZmqydorI$y&a=` z(}92Hi?^IBK=_QL;ppl?z2tYe>NM)a$Zi@ znR_7R@Qgf;%e<*Zp=!d(x3;b{y7|G!EaltH2Qz!RlNS_o1k7Mj`nkRL&+i}^VWlO> za~ReYC$BO4Wk21>(3|tZ(qsP))gP}}X2D$dblxeZ8(iP*jxF5MWO(PcLET;B8}a(z zMBfC(%zv@T_IB!pf78Ez{-~@w`R<=D@%5(L*R~hO>qLt2?9HhCw|_?JEnJo{`|7gbq9xZnlvE$R&+zT&{pwcVZ5Vo zr_B2J_w^f}&(3?6c21a=;dIxZb3C1V6O$H5TSn;%TzJGHU}L#Se#4yK^4`o+o17QC zlwtL{$U38w!t>|5S;QxM&LKUC07xNr$OI_afT>>hVlO^<(h4q7^Xd5-2Z86Xw7-^J4cG=RdQy3 zU1i@n_vE67@ht}aOaA_ud*}bt*mEEE@;?6TQk^RC_AHyFrIr5C7u)+dCv+LkSN^$^ z>wp7Gz?u*yftvgu{3XmjA9^30`QTT0vFM(CQ;*EQ-+2$0uC`d-Z*xlcYf|IaK;4(I zu*Dg3zJ@Y9JIcWBw%X$wgMh!u$)EohN<5js`oGDrblsG1!OOIAlyV-;T;Ox8?`&X_ zQIOicxmyEIy))$*za-g)8u1i*PG_W{A5zYr|ogayVu>>J&TL`eO-oW->%{+;r9-h?JDgz(^?reJHBi4V>rChasK z@n`?1s&D)Mip8DFD!R{K`|0G>kT(|7Kbo2s+;m-hcVkc0v03L<6`YCfo*U+Ws_p+K zF4?um%a-p5gr%0}wNehzx7aeYBbahG8_spv)M#t8D8p<|^Cc_)?3McW%}Y;RdH&)r z%XyVUEp5I+lRw>Brm7a}JY#}av*)BaMh~X0)DLBPA;EZI*MzNXB0H)ukHq8Gi zY_~;d>6hZG-{imal~ggDv1Uv%ySLZF@KN{2{HE)w7wdIH>QD1A-Crum(ZXSOYtP5} zx2|jc{)w*md}4jux_|S|fBads^}y}89ZGX2CS6&5w(~6CTGrRmi3}ZwT?{Y%Nx!?| z&t28~HZjLn!kTeg!cT7x=;HBQc(Hp?R8Owyq@PKD(zX6gS@h@q)RoicoS#4CWyFjg zRrk-fK26ESM`qrLZ}9B+S;{Y*?&CgdCNoFy`x#M-N;|JvFMBp~meAbgywge<+)~1v z4EKe4r`%0ZOWSeDs9_(+zq5->_C9k+|Do+_?^t|)s!6Mgo6y$B}y|M&NQl8l|W;Bo5fH$SJ}KP0Uudp+i^tW|T`vHiM+ zukK!3s{cDnym``0`T9A{@2fie<6mep+}-DYO3So_X~wgT+9gl^?w)g4-*Wr?*;76K z>)gL@?#40qGDA~S|D^rd8GqupPXGA-@7q0JSM812q}0x~zwCXMOv!VjxB}Md`=4^= z?Y{lKGj;Cdn%uKX=Vgk`+&V>}WPSOU|1;hP3Uw(nBqV>^JDn+K+V%D8uKb5i(L8Wk z{^OL-#ki#sE2jLZ58fiUNO+Nd?UB8vs%9c$4aE`?N@1T=eNJwt0CIj=q z{qHJ8)|i%n78;&;!<@^STl|#e?QG?(eOI3S`JXcFvRKpERm>_L|Gz!1DcRost5*Nn zw5?p0i9y`jg&h=f3Rkc^O8x0>0}U`x*^|{;a4zqf{Tq@;>I@ zn>i2XYag~YzH@rrvrFZ5;`cwT@!nAsy8P2h^~x{%j_bb>YRH!RzQ3M%J$K&s?00D? zx0XL$TJwL?J1(EUwi@FICmtCA8C%Q~Hqgve|P0nSy2irb86W((s?TQmx7h8!pI5xzpWW(x20yR<-tnC2{>PJZ zgco?TpJ>}X#XrTJVMo}-+BW&%HkW+&$-E{g+ak|M&ld*WBLvyU3s- z^vBZb9q*mwj@2sv+5f9--?N?5*B@K?>5H1X*yXefGsPOFyw+3o;N#AX43<@^yD;m{ zuJ^WEmfP4jeN-q|V8sfXyx@+Qpyhsc>-qHanGAZ*FXuPqacMrA^Ls_-m5g|y4W)fA zdah{)7q_xLm??K9=Gc49)L&Z+GKDXP&Ddi3z5dq}{r8%em)l-mY{0qr4F?Bruubk| zp|h8NJz^0!`Rf10r;l&4`1Xm^ChDC~2zu%Dran}Y!R++!_oq}39r&TYl!JAWMAlNL zM#oojsSgwutWHo+k+xu*G!fLuI>Z&p&~5qm_EzJtJYkN8}M*wThWY#Zk6elIEY#mxBA?=|1h%t&;1X%^VV$~3-^IXgp$(j^mpFV_uL<+Y^HPLzz^|B4KLTc zkze9;(Wi29zUsQOS@!UOOl@{i)r$qlhk*++g++OkPZ~UX~@|w=$@9L&3-&eCD zD^ow{pP_)}Vsq~eH>YI=E8Pm3XCWgd{O8WxJ0JV^HRkUUSk2%uJ?Y^ui+p{zQY)t5Bs? z@HEb?{-0&c-{*Ocj)lh^H*UE)@3Fhwvp_GG{VxBj%jRXozdWWQYRhntDZ=N*jfE;b zn$Ny1%45E5ee8`wOGDvHr_u({0n>YS=oP}-OVfV6`o#5_ZL!OjHBWBLR<`mGD!vlc zn=$VN=)me@K9eVU+_YS(Z=q7%`bJNZUz3`{3Cw5V{(8>?hn-_ zVb`8Wz2!IO2sc3I9PuWs^ zYsF0lB@8cY)*GHWb9D04vx0(Ow%eaQT>nvQ-;YbH_2-!MJdZmT`u@M;zFPIG;?F+! z_Z3HS=H(x*2tD=h_~G65rk*hMlX6qH4-SX(l{WGErW@);x1=Lz>( z3G?9p`XR}|XWO=x>h%U+j=vQu|00sz?axQW?efaoSbOup*R-uU&B6zx zEJ}~DS&QzA2>Uh3F7!mxG=I)zb1$kFPyC-(tjr;lE|@D=VE=h}&4=szyI=2_^RU>~ zH^b&%*Vp3cnopg)e1EUZ&ru9}B{s)_ImT$2<@NJ_Lmnmff4tzP^7Ud}`^+z#W?6Rk z-@EBqU%k#^JB!O&;n|D&5EF@K-xqHAIj_2+j|}=&UPEtW$k$8`K*0p?KiY|7dnmM&A!du|F*YneQLh@guRiqoc2{tpN=#a?O&XWo^JIM z3Fuh+^c91r$I+XUPKYvo3Af)@y>QFsRZ0$$!rHkDMV6ksI{p5#zy9?n*yrC^QMl&v z!m$0RFTI_e=RIDzT1;u_CfDmnKH0o)u`-Rf_q?;_&(q`k4_&VRA~1i=+}b<^$(P6P z9sRw3SKyUb;nH~-4t0{33Dj7ba&+aL zDZgWNzRzBFsEzq9$BLFW91`wVwywyy#{S{xkMFu=8T`l8jtTZSORyWv3;*C8Aw8cqPE1m;RBaX1)OZ=E zT5BTkewM)d&`a}WRqhI>zl~F^)tLA$=+g0HOZ=;nHme&mPMQ?t>=hPuim!d$nfMQ? zx8lq^V@vLNo!q!?l4HbD;{zKMjNO|Jw5>H#9?PGe=+}5Pw@RLwOgNWGTBc~zNYt^?AYhWEtuD6 zUCeK<|Ec)zjE?)aLY2=mBaQdGpQYR?^p!tq&Ih-mC-u*dAGK6i(H*h5@t3BU+Sd2b zai2#$ixVIBvTgU-Sz^rBr(bG#wc&y#laiXI zyX>R|n$LLK^F8$PeS#LNT=U3T@J4Z>#~W3}qY2_u1Si^B=F6#W?EJ;~DmCHF^ykX+ zgZZ7DLIZp)f32UJeCX@nE6JA!_-8OIU6ZIOoT0Yl|8F_DiZ36&B}E@U{Ll2;zYnK(c5Ys{?9ZZy_G>il zI|{zGJvlIAPPN{P-}??E|9>q1k=_0Fz89fKSGh8jK9kW2)t@9TlseDqq>p&<-#Puo zXRFG*-}4xR2Ql|tnY#baPwO8?4!4(?pD&N?D%u$O<;P$4pGW-nwf8^V^v&xyN2JI0 zQ|lTUoexY~`X}Vf!^8Z=|MP2S>35y!spFBIxPI=G;FFt-Z$ABfyzOaqlJZPhg(kU- zK+hTXn|BF5J2oT!@H>Vbf~o%#c;w>EY88FPk+9K`Ek6}Vf*bCTYWm`aTRhE26s+g#CxISSn#?f6PDzB(U=&t=}_^nt)edO z`~R!%skk%e;qNm>hvzpQ^gpSnzw^n`T~3p3?pVIAbbjUc^vW;$f5+B+U+LR-h4uLr z4l}RLYqPa|c(i$BPG3(k^jmb|+F6tHZ=a-H_n679>Col&tJV6a@j0H$Nk8jFXB@2m zp(6itcKwmL&)9Tir$2YLSC`1B*kQLe{h7dqxuL&yT=;OM#rgmG^3QS2S0>43*M7P0 z5jE#P35^~DZsGiCv1LTBU6m= z{)->8f99J@?XeSJ5()3A+wO2sf6CQm28>6%=Kc)L3*g@DFmvjKY_C6iTz2;g{q=9n zx)FGPa*Dgdlsz9m@Z}y`vdsA3_r)@&n%pX;q+PJ_(&%?D3ensl$?XtxF0-fg(hW{I zPt8V=U6(x8vvZtGX5!F|KO&z;LYe;VWD<47e8#b+iqIwR`_E7gArJ&$Cm%gC4;5PfjQS55PvEqZC8j+1u0 zdGMdk3X8G8QWj1Uiwz#%gdcD znT!4&FaMxk|J?S@UCAGNnezfyFD~YJVwrTDqbg|58Btp^<;(m3y8n51nB6i;W7V?`0r|ZV$Af&D$S|C*KcXfQWw|PH*&YiG%|Lq&$;urNQzw8aNuC=RO z_P4yPe#XWB$0zlcsJB*GoZQ)-*R!$h>#_g6M{m?8UC7hvYiQUczx1e7xRS)utwAom zNo6J_ixQb;?Me>1;=L}$U=34%1aIt;y6bH-(l#1<&;1a8z2^NC54Oy#B_f@k9gjRB z`rbvKIzLw+_QbKAg&xQ4>Z49SyOpZ$Fv(|npVyyq?F&pl_C@bVHF4S|{NYy6jcn74 zM|O7Dn{~fve{>r}^PTBH#YE_8+SMe+<5}w5>?)CBvDmZ$GUU zzs)5fJ@;e0#_zv2@>$^}p?Xz?-Wj~Nf*6&}-Lvx!a5T)=)HwMvdzan;`#%it{k^Po z|LzQp&%3@|oI}p~P_X?M>p$o8?;d47d}a2!@LwTw60`cw%m@rS@}}+4+|pu$!z!2l zeX0IA_j`Tk`VNuh`O~$ot*w4i?IHhH{85_1DuKD`I|VPYPU2p|JXg5Re;>QQ%&Y4g zcB@Hg4LpU>t_Tz$+- zsV432vb7h?c3*OyyKtH6t-iG9&E6}xZyNM>oph);!F|i3{cOOe3q z+rk>su5*6`{QJ8us7d_F(lrweCdEmdbSXNQylJMz!ZNm{`p2Vt{vG1G5XxCD`rKps ze* zG#DWmx97>Ss88?s_=6eD78vJQyE`QBZMa)gvfE|L+0^7~UPme?#P1Y3yv^muuU&_= zjH{|OH(s}9Qkl-N#jip`kApvV%Z&NcdXK9ebL+H==`FmufU$>5I6?oIiZQ#|?;@>6 zl{LvsJtE$7KO~$`ovv`U$-YI zL>j)@y<+)iRih}S1rh(uFQ4N)uqRN(E$8!o--{jl3+3h0zUwqhUuSyar5~flnI6l- z@!qvRGL|?>?z|?t>`07A4?2uvSoJc zS8nMk2W4KcZCU4H-!1$g{eO!!pTA`lS9R&_eRo#npHFALcHmuA%lSQj?LSG||CD<- z!)@8&FVgO2MtcS4iZvI{n0GnUPV;r>djW|}%&V9i{OwPQ@B4oL^Xm6Mb^4bi{h91) zvb)-0H)HOGntB68vw7Ec&M{kN)%|a^ve%hM_3M^AU~MmZ_5c0XRNjcNf9p?!c7a+v z1}{fDrySE;s9vC2rdhyoLn1McdGc2oUcvdwuRNChH-C5I#Www=+oY+ z&GM7wZky8%ueo0MPUD@Xy}yz1rr5q+vm1Uc$FPI&rsh#)JSHS?PwW zOBgzy?nxK&tXY_vb??rq2{EU*&MtE)R+;kTP>=0Ip$dKuA>Q=LnzNeEWPBPY9$aB# z&M4?GQ7nU{?xmjH*S*^dZeOk5IVW}l!@pD7J6=AwJMq`rjIYF0`l#A+ml=`z4_4&* zWnXMm&eHJd)R|Tka%4%%Y92=c;W@%{8eN=uVkU2h-_`B^?~`rKFa5ixeD8l{iu<<4 zd#B$MnJDKaFI3+=u-dNr^j+ES&*#o6eST_Kc72AVV7OLXLta#zb4}TuR9j(Jj|ohh zCKhD#U3anJo13;VSoC!D{EQy~I!DyaZ~W->FF4MB@6`4AOo7&$g=2)zrE9PBJ+fQw z;lcl(58o+1e^@49roVA^u3qdK;ZG?B+NlNh6B9a&k8n0QKYA1YqBX|t<4ex=|6kpI z9N533+uuEnPe^dl&Nm{`Pp`~;vU!`QkK30EvxNG!3g2X2T%+i$^w4wmycf43f8O;x z@%=$>_o>*LGX8a6VAGIOO`nmhM!%^;CkVK$7M0!r8(Cl0&dA% zo!H@d(d>h68As8dM~Mk0FNDQfguaVTKBsIm)muz9({OsxuYbVtOElONvoam&U5E&X9 zlee$*sD$B}MqgEHMyUy5o0zs&D5Ub0CVzPS{o}&@AG7vlnlA^P%68L!fA??o!t~nB zT(Yf6HL4ELMu%Ago_np^Aa*=l-NEx(>4xOZ>#hHFTlCL(w8VndU=HWG#%=|Ub*w+9 zR#!X-fA{R5cwj*wc`Yekyma#B*_G-$5R`tp@r9l*7_g) z;jgUNHL1@@X+ljR_ro+RwI4-tg6#^qhg#2^`>5Q$^{4PNFVjgLi8h-5#NRzgzF%Xk zzcWofe9aF3-C4rw`{GXhoOt}6*4uQx4=1YSA04d!%_;ZimEf|qyjtfy7QW_I+w3K< z?TmEN+{X7^Q+h?r89f$vh`VszKM~(x#Sm|EBK+S6$9KP<%RigkF0^(^^{%Cdgp4OG zonw?N5S{pHf$Iz_|JPjIXLe4qQ;TF?Pr!zoSEM#Ydo6v! zTds2bd*9+mDFsehrq*mK3ooT`E!V!7vpnO=4UL3JMzQuLm7gCb`{h3f>QOh^y8nKS zPVuA|7rp5>lG~3m*M>E8g4- zRJv*L((BvPjWebib+6Pee%5iWW8sq`D}SM*LaIt!6^=S-Yz@X|n3fr;H(Ijq6+Vz~ zjx!-*^#}d;kAy*o#Fc*CH#wa##wzW8|DH+D|J-5!`GoJh{ko>)J7QPsUY6)sh5lqT zK5%EjtmZR4|ARQ2!vsVQItR@;ox{+skYvGl-J1D;q9jUh_2(9hetJcPpwo)ej)nw_p%!d8m%*!9G==r+EqGn0(r0+LW7XE+Y zckeg6KTFXDve*aIW zUoNxrT5SBK?qyM$>u0S#6p`53DEfS<>}CCAHDmyzLM*`QljyLyE8U$o=tHo-k5IU+4G^Np|GUKt#j)O ziNqe^9z(ZYgP_DG3uihtU+fS(^YH8krhC$Ij!U{voH(>B^jF3zEtR*6s#KrLWb73Z zTz1becdF*0BZpa1rCLt1s2w!yJ+vfn+2bQ$G8f0*K3?+2WTNes*PSWJkJ#5KeRR25 z({uV*euUz~x|Y8j^A8m2_H|TWo>H*opnNZjy;_c&+u93nyn9kyj7^UPO3vK0?Yd#t zJi|j7R;e~zJv_%2ne4dZuf!7A)F{rOX2I{b$?~40w^2BMjoaRLGX>0oGyKAeX7Vno zjGCTtdr{H51yduu%tXIF>z%M#gJ;nyCxT1L)v`d~o^62)R_dD~lu(MXrj43l^pLvu_^;yy{ z@q7yJVaxX7HuLsiWfoiGT4pwN|P{GW>*N z%%{D_-(J3Zu7C1O`_sSre6FRtSE{-n{GQjb8gtTqCRev`X`(-`}q; zJL{J?eRrR+%jLE7GI!Ja{k2PY`!qWA_;YU#_clS+qQ4tJq z)2IgDRQ3DM|M_y~W@<4+1UjaI&Ig%ea*uOQujJ9c9Ea+9mM0|Jzglh~zM0Lzy)eEf zFTdhaMA5pP!jG;zob)JGX{NK(giOQe-yAEGUR|hLeAPA1s_)r?DhI>7&0e3+{OIqU zXmVYu@A8_2`EGWv8Ceu%&w9On)nekO!D+i;Ug*!e4|VRDhjC2a)R}UDpS|nj{~t_K ze>(`W*fbfo@y`Fff>VQ8mO=kTh~f0i@3*WjwM}$VV!FqgWMb3z!uw0+k`r5U6ueUe zh3C9Fz_G>h^Zyz9v;@ya9APQmax5+6j7TTvqDii=mz+&bQ{2CQWya-2nE_7^dM{h+ z9^hROZ}#}nhHW_#bK9>O@b?^8Cd+%TD61n?mg9c#-PnKU>Z;ER9ez^sxNqfob_ukQL;DJ{Q1Ogv!|CT&#r%)a}q8ztZ?_(5x`SD-Tm^`2O$MX z70(Nn-PBB&b>Dn~UC>QQ{h!9phu{A=7HcS!VqW=Vo5`kI_Mfc_C3GzJz5K|;Z5Gb9 zW&Y>Zru9av9VQ;Vqq1I5Vq>dB*JAI>J~9^?+b)Qod(C6P*fHt2Mym3C>kU&R-4_<# z;8^okg5^QCSWDTl{mI9k%qYG$Cqk%xAKUaVOK(27o%3O)<9)w=`85+K&y`>ZYr_HpSd-&MH zIbzoL3pPgA8~;u()Oi~6<$l+%-#6;sJ)6|H<51kDMNYe>E*`CAc4-F<<0qjbm(gpY5%xNz2x(*)!4OaZb&0r~myY zm1iB@bVqK1E>mJ(jOu|RwOTQk;=gQ?F*gsK^a+evCh*=} zY-y^nGeCx6;{W>U^6kozMR(Z$%%51fYFAp>ahXc-X{CycZA+zpIWaMEalbHq(f+{O zqlR-YOMH{~CUK<=6ZBjTEMR38;)(pWZp-PPuhv=36x`QwCOC5W`IYPDz55yQ>OXVi z&9{+d<*D=fd(NEj&=uTu)9LVwmp>C5Y)>0VFVLTV!0s%=KBf=J5^-y{T$exi;q|X* z+jhlYE}nauJijoe*GoBY>@#I*Qfiwd`c&$Iy6N9Slav+wI>l#Oc5JfBSkG7%5Ffdw z`EtP}zu0Ks=50|88^toUq}LbC(^XJk>;7|Q`1k38mTo=kUr+SabWU|(TM*b^ow3%z zM?l4?sFAPq!&)}!rtrp36OGqg3EdG=zufum-+MuaUo%8MtIeBHyY74F$u~Q~Z-^YP z-xg&eWF(rPz3c*SfPl@3kk)5aZxU7=*L8a3U?t;xD(uMmEmvI?c=q{Ou>Rec$1A~{ z^s$rCF}88r14gggY7AEzj4Bi^zq^0bYzns;i(V&>f47ug$Omh-X#SNR=@uvRIJ@uf zUt0bBe0!Oc$Jsi`hN8uFI>}S2oSZ*$J^R{W z3Z4EE8D}{zFx+^tl`Uv(sgPo@ntR4%^VPK_X{~?4s`nkW-FR!6_v)+tb6Sfpnwq@6 zy~``Nv?$n@@eK37b@#W3J(cNOe#R%7@zk2LNvD}s2pc8;4dx9B|L=O_cm1yCUUQEK z>Wl68ab{Ba<+}X4_daji_jk|dou2jT%2$&4RezM`{d>RfuI7TAS7w{uxSATXB!4V_ zFOj|L|Llgt%Mwfua~zgvG@PdSXl%;3^eKAZJJ zutXj2mcQ>0U*9l2?&-aGN2Ayk9R901{FhzuTX2?gi9iP9CYK}9u6$fKe|Dv1xSpS+ z6vMo2^<;*l?5~xWOz*S5U45`oe?a#g+I5|y4 zdNe#Ibjv?nW>nR+SR_K|#aFqqQr;yjzmM|2y7zxS`?6F2JKrqPd1m_OJ8&eM+i!#`d$9h4^ zoh@6ML1ZDTPEhl;U0HYiVlMDb$UkDN7W7S5`16DNJd1C9^w|7D*dnTCYyGPpiQ3R$ z#IRo;Yz%XSqDuI%F%=UMQ6vS`7( z>ek1NrCXc!pWIk+UnC*oY|%=_x{FeOuGc=(+9QXSF{@&;1%8afa*4qb;F}Ws4(qewNBNiR*6ub;e_!WQahZ(t`DW zj!by`{PUkr0?JRgCPdh5wU=JRs=*Z`s9(2gAFqS?ykG0tKVG&8$hP+Rp1YcP;}!Sm z+m_#XeCvAT1^fKVbycG2>jW#0Y^jg7c>B;~@j<_NnQM2%uQ_TOeIQ<4jMGfm$orIe z>EHR8ueX+Mi{PGp?a?mhxpz$J-%U+AF)858pNJb8Cl>8{{=f9Vji&n5!k^V7zDrs% zS>{Z*{6hWP5|*>dr|!SWUiE)l*uVXIzu#YW^}m6&eoKc1ql3$L!JJjh8=loO@BhK` zZF%j~-?}Hh(766yqDr3kRs^85%-4I zA%L-oX{C?*icAv~&I=d68+Ctqxw2~3xy;hgN-L{)5B7yD}2y4+VU zJsA!*2sAIB{(RQI^;>`0GcEcxuintv7_P8=XQPU+O@Q6mo zLWSJF_qrE5ij*E&#yZL9ch19iO1;3lfJ@4F>%<|efEg_#co^!TwGhMxDYEm0! zTe~cC``(peSFa@et7$y6?r-GHzVr|VvzA$NOV@~B>|4%p%eP}nkdr7x5e8AMaTFWheWQ%$B9%8xkA#3EFRSXZSl^@Yfm#{)TD3zQ*BC6a+ju z!p>ikd$q4%#`0s`@4fq_1cK%NNT=|q|DKU+@$S_1ZNGxUw|TGpT@|P;$iMNOrOpZF zfCVSC>b?YcX-r$V-*8HbM)MDq1L~%K{}(jy{{2{A`|IKP^xSWzJoPL-GlkA4?Eg_x zxkx29V4v{`_3ItCc}qg3v9~b}X^mF0XU2gYxYweRazPhl$=eSbg@hMj;qF56) zy!@ekbrx&DcXRQVeZRWHnEyTyot;04|IVT+--Z>YSMR=-Trusj#J06D$2oL&^6lcC z#_~yhrd;tawpu@{$3His+u>$_V2c(m*+*Vth*g`r|9{qn_HsnwKnHwaS zU09QjZm*xha8uH0)#U7^NUq|w(+uuE=UJ_8DzkrIh>(enkKnDS2E*K3j#z0qV_Co8 zYsr6qZ=L$O%E;;e_SCf=u`_}Wt*z6`W_o964!uajS%*z=(*xago5x9h!qZChU7p7qVXC0VQf zz!up9>;6uT+)`DunEiLx3sZ$xDh*RFiI$$(dbjI-vjK~C@WV3<%|{+YGjIvq+O|cc z=6^j`?AzX7CX%(E-rbm<{%-U3-2Lvk*>mo#j$L-Tgnz4N!?!c1%|tI>sbWc8cfG3f z-#b~xCwqS$skTvwJ|IzBeZcU?_bvZzT%PP`e<%D!`~02EmH)e5TQU9@s=IWq=Dq#t zUg1Kfh7LJP?u0n@gl)U-Ze!$mRsH;Q!y@wu_hS^>vrnkAon)=FRuECR!I(H-U|Z=0 zQ=SbMi~nt1mgi>d5p?9y)hW#qPMgYpT-#!QMWBXN>LXkj_`)?*Yzxt zDhZx&Pa@~Fd;Zd@kOWOeRS}ie@WAc6R;?}Fnj66Kx#eX-n?P>jg*A6yy^r2tbbImb zec`&9wo6}KH{B?f^JLc|i9;n}e+swmf7cm$C0SkhOXv$V#+vMwulgrq8eTJO6nyCU zX_xpy2c?>$@q11zV(7S>;^5Fzx+vj`FWW(ug&qGtubUU07pt`?(zbEq8kU*U*UGQk zzO}UVT-JP%*SD@NoAUZ;=dt3+6N-2Oa+k`vlwbRO^0VUa%}ZHYS6x-Aultf3<~)({ zXX(f1N7WRjKQeVqYMaKyyY)q?ZJCNgqsb4KUd<=RCcIK{uF=v^R9<=gR>!gn0*Z?j zS?$dXqP{uxZ+N%w=*Id5%ja+0zb}95b1&|1jrUL5Uf%wCjc;;!e5}Nrw&UymoBR9g zo28aV{Jpz&>8ImGYd*~H+v>6FS3Mb_Qa+>>SES!?aqVMF7CFxr@T2a(*q_t9ULsRO0=%65S-T#5J?mF~z@NJC zAM5At`8@skystk0)gQ4i2-?Pp9oWO?uzlBEKjs_V%g-O@^}6ZYrLf=N#PX&Itd8F& zNEj+H|7MB#$q=@9{iUU|=1DgzJ}bDx9J^)inVO6*R}NTSKM=X>>#B~170)zR zvdvFmtg)}O{amBMK3u0woeZSA3&swB0;XGRlPsS~$W+hSnx)7FK=KjCyU%6gR64Dom6bXDK#*jPp zYV=1d+hv=cC(C~Ls`&2qHHHgQr5H-5ZkX#RessoHTr^ch%{MFgMv zo@KcqvO>+@TWmf1gViVBroLpX3}}7Ryk76U@)5QZdeskiJTVn}bTY+Z<~By3-zVFp zrt}>5dQ#{6W7YcY$-FjRHkAQ6Qju!$)$5|XNGUTcl3dz>B1|XTcY&Uyv`o_?D@T^Xw5fy^W%%^ z0|U$icd9(uzx&%6Bb&9y|Ngz_^y>fTTmNJJg41s;Luu*n_3=*iB@Q=SR_t@M;LN+P z(4m;kl;pUfCBR5PSwLjkUaM_g4%>IiF^elCySS{ZPT76s)Xjir5p(yh`@r(?kV|et zz3hbM1vzgdE^K>X-tb$~HvRj0*6HiR-QVQ@1)2wb(iE|7 z*)Nw^uXDU}l4I>87m=kJhkV`kF&Qu^sx3%S_{kM|+j~9Ji>XShlxC^_@@`!3cQk-| zAETm7_}68e3@^6)zt_Jw*#4L%%aqv-Nlh{hLVKmEt~J^4oY>^5S6b~N8Ib3eamVR1 zqt#chZw(pg6Q(oWO!Ysk%hfG?Uw_t?JX?+n*$ZNRvbcG!ZJ2c}Fi+ zCP|ST`pxzID|vS}b#ZJzFKchzu|3m>Tm7r8L{-Db!5jZKIUrX?K=bXaFrP`M`{Dr^&JCreDOX~{9q((WBNqa8D=)|d< z4gSB_pG87|y|E|pD6ta{~XG-cA?Y$lQ1uSRR~)(5VaUi@nE@x@B}r6#P;+WLQkyC3r@fr+xBth)pPGMg-!Hyz$LBje{w8r} zmeP%e3BreZnTi}Huq;`-{({JXk`~X8S8l%U_++9mOG)kRxszcn3b9k))J=F@eKPm4 z=)T&}YvEtM_&QDNQM#yIv%TROR}s^(vM&K=PQ2vw>e6Ul!1LecxEx5l2)h_ zdadHX{qOaw5bODS!WPV9yLRxp{lOtMB^~Bjk94FR7Dfsdf9z~{Q0(-tk|C2Z_e1TL z;Lx|1e$_{C6r5sS_tAoTLd|otWwnRiR_M%(&0m_wD6vPjdA-rJ2d&4wJTyFQof9}> z9fT4U=Zi2N2?*z18~0kXve8h4LrEp|$Qx6!Cu%?L)ibAzboD)(QK&+`v^5yj@Q_RGGts}Fo- zX_)rp?Mz9BiA=qWmKBEQgdWDX|9Z8b=fZog9N`7OA8z60Yp7YqQT=|ogn7qChwn!> z7vEzM;p`}_ZisQ+e<^6?ZL1^PoZB2W#XhfEb~pTX@mk%~rR$tusMXvGS$MYY+lC3c z%Ez~DV_%b^A7`(9{lcx<9ikDE*#)=OzFW??inU61q2l%RsW)D1_15v)`#)vX%LWz} z`v+U@HM24@JB75EKl(m{^NE&&lS2lp#qxA9fo0`a|I9Sqe_n3kU%91w({p8?RGfMA z<=%!!>+79ct{ux4v5J`cLdKTc;cWlLt5uuM?w30Ct|8`C-TyB~n*Z-;-}c~t{=5Yb zR7K7Q7+l%%|9R=xdhWTa{;gm4&-e$pkhT4n$zb2|@Hxk2GX{kRmOR(@3H{p6-lAXg zUhZ4C!>M^VlRQ4dtMZ4QAUyzFRn~`MR6`*wWQr?^bSoEZcmC+n4!6Q8m-T`*www`tE-H#sh7ecYf6Au}DzdWqF7#GK{W3TG`+sG<{+>P#dj^A+UAzYrPTKg|aHXti zRBD)ED4;fWE;&B-^-3qxmsPQr4@dT{aWUo=PGOxA@*59)SnU9wXX%e-04KR(vZ%6c$Az|DE5(G>Bv zP1+}RIs2Jwcm`ZzObwXwkt=nzM*P#L59?X~|L1PAZL{*xEuqHXv0`v0H5-s#WLQ!bngHLMSc=j;45VzR&Gw9Q&1=DNtQS#8k_Z+Oz! zBX0egcWsN~mFD?=vJEp>HkrF;l8WX@uM!|>;#7S92}BWrVjg0U%3Bq z@&Ba%l?ysFf9<;^q+rh4bnX4c-+w&4J^4QTKYH8oTw>YDX;TjvonxAD*2ZF%uTg8% ziw#?o>-Sz|aDMvW_uq$aS8n~0nECr$JHubLed3imay@TE%-nA5`*$bS@kV~bY}+I2 zY$jCjp4e}`pP%i@Nd|jKXFev+pzT}|F2+v`uH0fYt(Cka8fe2Nn|OU|vPIp!?eV>H zSm!(T{VmcB-nDD3-?cf`tPZdR*#9`S;tx^Rp)!*j%dpc4OMDsO9Tr=a+vjYqP)Z{Nb&M z^9RqnRsB=7zv?o&W-p9%_Pp8smsk1JhOYg-1V!{ zIJPYpR{iV1SM87`xJzKqT$Rd#C@vGNE*1GTo>qJhj`*_$a9-HrIx&2M!H*}r_e$pf z<(zPJ^^NJeOIFAyF5lNBdzoQpkjm=Xd-d}!Dg+6vU3$K3{_-$y``C@~x16*2EyS(v ztkTG@4VZe{zJH&RSeA5K$)0;{4{!8UGVe8-vZjtDDMUm=hh3kc^Y!I7opsWzj|^EY zc?E7emo&)me41IYtJ0)n>8|g4=DFr|2`v8Kd8_Z*y4H8QJhB_$>C%)8V|33Tt@8w@7rZxYy z*?;^w??v`&g$w(`ZSC6>;ZY68ptGQZ~e7|$@@3t$`|JF{k zOE5eTS$+QYuWO(EY8P$a8~?j3?|T4eoVVMbC#{#}mG7+c-I8`QH}H|%t$SOW9qKi| z##Wbq$XAyv_xY>NWX#a<;_vKq#y^ZA^BI4*Gw3(Q*Kz&%VLE|Lq+|KnYoSTJ?D3NK zxJ$$ZChko;VE6N0gs8`-zZZb}j7`QH?dMW^%2fB*OY>93#9Uuz`l# za)!N3UY!BVCHWdZAGli`o#i&$%&wyQ=J(I>>NhU)?0&cE#(R;(`3r7Kd)PR1J7#S> z5V7Z1eNE~Gkv&ekT&MIp=7|`pcC1>Tao+yCflTw1l?gLrEl%t;SP}E5UMBo_4#R}k z3_JOHCQjn7n|#2c5lD9-D2t5`9X^$gIE@^ZEe~ludutN!#2g(;adaGhv&0d z?XQ*O9Q}Xh^4ZK~=Ix~o7jA7iy}@dy-LfKvYqGO{ zO1vxN<<+%#J1_NX(FKMGJ<|{Os}Jnw+;IPgN?&f)w_{UkCnqE~$~xV(3Y2ZS&Lko7 zu+(uwZ~k$^`|lLlT53Ohzx;*g-8=IWPVrfPS-#rszHa;N#+SZr-<)>++aoQ0Z~fZ2 zjIs~EA2)MYZ)}oZt8~Dv{+{Y*r&+om4?k!)~^0$@Be%L{^H25 z^Eu8iC@Af@uEQ|<+s^rEj5h8E?3H!a7C0-`FPb1C$lnyPZNi@Kq3`NaZ}4ndy?WM1 z21^g#G_I!H*ZWSHIrx6HSP^z0^9yI2;h|d%D%VB#zm{s5S`u@z&vZs^wZL*Fms|&- z^p*ejeZT)f#_H`~dvk%8n;95%(!Bquv)E5(Xu94}!Sz%BddF>96>SynBs+y^ye`?% zOAm$%mH+;A{_m?Pl@~IWuKTg<^3($tmhRnFc1vSTj?L81yVjL$n|faCkN1bNF1Z)4 z6fV3LREv&~N!*`vTWKDn{XRzx=B9Uo$7EB=9ZtMjd2aEKbd9a&Z$IC-SA3)O_4jL~ zDz#2ez33ljQT1P^Ve9+r`@;K<@4bKdtJng|LuRjXZ-)I!ZE1VEQFgiLlUvh*E}x8M zbUnFz|N3QBCi9(iDoyLxYM(i(FzvP1X2-S{f7g|r;oHz;ur<-weBP~%yY+%B>t`L> z8>V*NXUCb_8~DwBIeb--nEBn`GInWPRFns6;y+`*dHm0Rt+tbQdb{~rxM9UY?~?{M z52zeoXMVTkI-{d-(=~hVz1K>)HLu^(H*-i@x7?Ixg8DOI%dEP!+pDiXDf_Q8kEy`1 zdS3SF&2OuYpRc=M`sqM+#;-k_(M)S36^djkspH>T?p}TVl-Rec+4ECotKF*K&-VX(`oa3@`;YEePhb7tC4r4WaBur% zhQ5D7ZS$Fam^R#J{lMAqpYgD~)5reOgkPF(^5dB6gBmt9=DK^d$Sa2jAGzAaZo2+k zsekduy;H*k+Fne3(0)PIKP}d3HPg!^7xt7Gwl~Qub3cd9BrB*OE$ENvDdp7djId;+C_ry}ZKK^;R0@=|=ts1;9 zHrGjn2*1&C-tS{zW4o^Hk?M!j@(erNekdt-94c~@5ZxyI=fuH4furoFqt>j;d;jj; z_ivH66s&Il+$R67(6c{#^?!!8TTQbA*biO||5yCs>S4nx2NtnOiY?gIoYbQBxpos% z#vc}~^)@d&{+aIf+mIeNSLXWGzC-KMtoGbce#6GX+7vgRZThyA?wN7Fvr_Ihzq`q} za9(uWZ2{f6mdx`z-#_0L(>$>t=lIeGN8TA$%8Pw6j9PH(R>6g`eM|2zj@Mt~bB_6X z=2A4~HoGzCw$s~y z_q&<8nMKSSY}VH2WxKI2n;ezE>FCZ9^D}*0?OW~ppY2m0zuEi$(AkF(>zQ{}^Oe_H zUiA&EyRB9-9oTYh%0!me?-z2s*x~$BrMm4( zxa2-TEys@{Z41+G)-t;`Jyb2w{t@r{JN<+I5grDmL)B}U8Rp!7{!f?bpFrX%7L5t| zJVg~;nX3;+xAs)K{9Pigv3>8BT-P=0=J1rdvU#tZ%-P0laP8{cZKsUmD|`KG0xq$A z%{5I55BQfAf4WFkJC$SenymC;Y&aN$8Qdbc^%HZ#VBNJk!4Koxd`B z`7WV5Vapj5Bw1!BOMhklBJeuUYaY|?JAS48pG_)OYyZ2hoFkCG`fT=jksr6!4c=r| z+q`9)y#Dq1J4$~(vL$}kU6Lc<*7}C+|K?PdDY`C;*SE}j`@QDOzYiHTwi&jOf9+!m zBJ-~?&sqB3bnD}T%zO+lzJK<=e*4_zV~^!-WvRYl{iSm_>Kh9~jp)LP!e82df3Pv0 zZ}+2$WBW%;@5?bkOJM7*EB z^!NXD_kTY6_i7&hq`mupy|(My5MdnqNc7EmhCh*iC7-e_%$a5!Z*nzCB0tN_VfxXH zW$*pwXFjxw->WwL*5`tsTkmgwy>ov4`;`^3|7SK;{hwP?`*P;|KPI!Tt-ti|PUQyq z`3{A=UuK$L+GKf;q2khC&IPHZH(o?qe9oD(^>^-C{b=i`pF55nNxxn7a;{@)-d)+j zulmYXy={?Rz`FYH1K&;T_PQLuo*wKEaOVE=`jvoPY=m{arR?hGwf~mQ)7b0yU)?(zNx_+>pPD(?fX()>Mpmxtg{PDPye)E6Ko7I00y}z^Wqb^gPz^&4I z_V=GfvplOUS^xTv?3U+Qv+W-Is+fIpk@fn8t#)(mPfu(58@8t8f9L<|-RiGfE*LIO zuK#8EY;u0BeG>Z=^R4=~T^!5z&)xF;S&j6*e$U4CEni=K&iht3_13!FM{ibN53p%} zbYZW#-P+@9+rR7V3jP1n#k#p($&W$cqI-=JLqqgr1~~<}@SVRI%A?s&3E&n=Kzq`JY^-9W$m2<8;a38qEb$+cvPHw=ZaQV6mSx08(bk+$a=q5kn zVqBjceYLaU=c}Y>&b;cQ8&|WQoOUZ?>DpTda`)}u={wzM-+kWl`oScp((jyYdVk|;5AOR{70qSz z>visDsb5b;d3H+$e48n3o?dtUT3)WvQ}zSm_nkiQWV~f(xNLf{X-bsyf~}7)Z8Z$t z@ZSFSRb@N1ylcOcHhxg-dwc8u?mq3>;!>SM+i&G-|GnyeLFeoBEm1q$eI5TVtu>v^ z^CR$VMbzm>JFeP`$2_0D?fD+v=XqP-I?dj<`SoMn^$U0ZDv7#U&wS$8{PfRjW=}i& zacTXsWa+mFJHDO&ebo2wo%303$GyzX{h7J-+Vo)SxY{M_zZ&P>UAK6`ZlxLN^U5z* zFRs6LmbreuhTXn&$v@!f`F+cy8J|sN_#FC9?7_FyhoxKevu6FMnP4h<(@Nn>=FIJE zyJDr%rN2BhH8?TlQ}*mxS%072$~yA;xR2|W0&VUG>pUC&^E~)}?c^kR#_eiM+c@IS zgf;oIE!NIm!nsi2$Xs6L`|8BnRiCHlUfq1YZ^m}fWupHd+iySLXvO&9%H?E#HKwp% zvJAIgmHnT2>v?A5+jbLv1BL|66E|EkY!3WeCC~ERF~z>~Q$2&t>sudO|IV?0r4|_0 zbmph&#r0A1cGq9K^(n@o_TuTf9o3JXUi(_%|M~Urb@MlWso4=;|FyRD=924|e|?^= zw&zvpzjW?Mndk5B{*HUjpL_oImY2yf%KSU~p4Hy^SNn2iz0jNOoI8qV%J?T(k#aw)J1Umi_)(`t6&4KTG{ewch^dHWzSEUn;LB2TJC$Wi^M~}?yMNc3-}zUg|Bd7PwhH~`L(`1* zTx)ZfoA9c+XQ{%cJ1krbf3pA1sSo)7H=1RSLqyL0NUo~roqJyYdwKiQQR_JomDBRS z``+*4-&*3k=I@-!01k%ub!)%Of4#n*;e)loAMHa|+{?dSwtaWEc>c>t=YQVF{q>01 zf6v$J7j9O6;A#`!>OZ~dZ>5;~?R{TruGQVYcK-J#mERB7zT5u2biQqu)Q$LGd#o$d z*W9&_zxufGdf1=mjsI`HE#>*wX~F2=B7dIcK#*_4R)+2KIbMXfhqFcI%A4$JivBDw zn=SQxwUxxr=sA)<*0OGXuOj-f$+Mwu6N|-NtBY)l|F(F}=9#h9@9WzwSKPdFzixld zcEWr0U+>(~b9>JJUei!ll)UmjW6h?$zdzS-bmzXPO;;CI|EuuhrjzwL-vv6pOSU9z zW}W$8Fk)}{yXst<=>6N}Zn=GCYVZDk*7olA?|ZJsXP= zjkmwZ#>n&UZF?m8=IXisbzA)z@60Ux{KseZ*3O2db#v@DGn}~}zWvtgo5#QFSZBT# z=igC&ZC-iy`*4Qt_cyL~G#&)cb-nm)%xS=1@Scn7w_})e#=_Nl_2+6Xn9KjTD|+Uh zw&=%8jwe~)n&dCu$~n2X`J(sj7m9D6)RtOxFbDKol*}*Re|YEl&ll?MudAv3-*EN9 z()T;R{rxqW{~W92`NYdzAA_vFMa}+K_3--8Y{ufj~zzg$%P{%%kIyUX+Z#sA&D{{6-0x9&!D(cU-XtNOOu&As0q zadOAb)5~L}zeLLII9{Cn+g6JG{_?fc?@4}se)rbzuZzlcpk?~^Y@vjCycgKbvzwnT zW&B*@npXLtRz~HH>4#{pwCh2F@~N(meAFE7yJf%KBKG6#cZK^`-9B&ImC|4n|2AIg z$l9Nt=87L?{QdWdc~9`O<+3_k6aRlL&b_f+x#8-zn5_Lv&0?~Sd#~&Lez$M_I+nQe z`aCE0)gE|%`+NBQ{(9Yvne2bFh2tV#E?c1KmzWwz{S$g-^`Q`ui96uD+ zyY)?P{`;GDpX>HK+4A$<|j60j>)>S1wxGsLTI>Y*|=$doPSLNqPdi>A&Wm9p&;PPZ{QKAUZ&%5rXNr@}vi}xm z?%#9z+n;aBGTZ0=-ZTA9^K_qQccz})a&3C{`L+4`mp{JWZu9yb*RS_He_#do{K*X7 zjPGux9N4)fUFXf_*vs$F+nRr&nO{tLaE*c%jf-R%u{}|YECbuEHP3S5>icerMHdcF7qxe)(@T z&MnEb{+seJ+rB=*dhgrIe++;9RhPf`^zUc>3vKT=>Dt$~zkjh+9a|pbC({xKiB?gEuY`Jn||MO7kl4+wf5Fa!KNGDc9+-NP5OS~_@%bz zw>~B3pO?3LbnokKyW_WQAfre{Qa>n`Qrh z-nVEaktaqI2f>M5~T zOP~L~f9Lm{;GmbS^Y*j|tOyAWdU^D);kL(S+a7<{zWed$+}S&X%Wq9|)WdvVwVc7kd1$?w(+vB(Ynn2ZzZIy8zq`~Jf?(%YH@mUaH^;{ykAxPZQgf&PM!#l z(De16|J5D4AiwRxI;-mUo4-dDpEX?$au&nj0Xrlfi!dB;z4QFzqu^7oRed#|&v$?O zY0H9*dw-j(i}SWz$Z%nX)vrCec8d>1I2Ky|uglwgOjUIIx~)p-!VHWI3@cR2ql?x* zd+rtWWErbiW}nd6sZ-5A?tj31z4TV>ago;0QpH-}i++^@L&3x*auZwo~oh&su^?GLJ#CfU)3=IqplOuQCv=>@BYuZx@ z%gg+1*KeN8`v30k>_3b3wx(OU8LpRqADOLx{j%xHAG7p(j(zP5IIb=7<>9e=>JQ)dkdnTc@cr|GIS*ELz zXQrk6x_kUp{spn#m%3`Ye5HFn8s1fT$-ebp%)IM9pL6DGi)ZeiRZ*H=Gb_(8R{N~! z*=&oaX4g$TwSzNLpR3K?P*-1+vuoS6!w=Ta`Ep}VcBbaFBxBu8i_-QOo$0tIP_(Yy zz+G`>&4J_RY>%Jj6WLj^DDRl}y3?EN)E_OH$6=Qt`{8A0cB9DoO^jJXw~X$OYNdynVn*qmloae+`E1AefP&r zayNHgb(>bW^WDizfAx1y+5N}edzXaDWoEuQ$Bj zL?$ol@XGu7YL~lqt;6<+B{x@Xex>vL{r`1aR;)W$cYk(O?Yc?qzt+UQ`dYtoyOd?- zD!uFtJ;zc%Z&9;c6z#L0cONqgL&J5ArBS-p+YKZOOwF&C1!=3;9+OUv?TXiX!6Q? z%Hh(?;USm0*8hC^`0uXQp+`mECx6E^y-t6VsWnRBu zJ-b`hufM@%v-DFIy`9I!pLqT9(9(Ti%frObaL?w~dd9V@0!*rT85yo#wcBTL>A1?- z?Z-}Y&Qp51cP?|3S>5*f^7rCzmhO_w*>P`uNO(K%!i87w+&FV<$Yv`*FmgWx5qR*Uz)c=w&X&FsdvWv7`|EM{<+vLc#S#O7*MurAKd&SEA$6;qL;1I# z@=v^e6))X2%bMp@gz=-BKY#7FN&3`(>ebHE&t+z<_IJP3oArh%BRB8YhfBWyF3yY7 zH@W!lt>4aQ*}cWq(hv8P^8dI!^_BB8pMQ=F3_HG@R!eUEi!2v zy;fd!OXS>|BikBIeEqnNvA$fF{esv_HA&A~0neO5C%)uY7C#si67u|RU9I7I`^lM( z!o2sYy^6oS{2Xf}_5bg)S=&-Ji>>n8#1MXnoqk|>@wRN^jW1#r^v@1W>u%4A=luPB*Y7{cufDG6VqTkC|^;e1e{PX#3{lfEavjzO_42+TA->x_B?v5+Te;Mx= z2rw|1JiHd4y=p^~r0TkJi@`l@RyWnS9P zG*5qDdL)LY}mi>~m zFR8tozGR}^8FLvH1_s}nQVY*jn~mOFnpJh{@-i`vHx&i*?4B*lj(YTXmesE3ua&3n zId1j4rZgt&9iz#r?2qO(Ugy5bonr}kD0)fb>hI`Bhi5H&;{7WGRBB$_&RFv0`nGL8 z=kNcJ+4=11*WL9#?``81Bor7Jd|Zwfzv}XN8~Urz*!owgZTem-EAyAly+J$FW6n6A z-fjLeR#cLMx1!xu*Qmkm>Xx}zZttx+pRi?tDsx@0`!5&mt6GJ@)f*T1>+Lzc-0aH| zl~r$FRln?+wsPP0Ejl^hc-#dT7@AH;mUe#*{r!CFn+_d_m2tf^gRsqN0b zc{;| zo>-@Pd9qij!=(t(_nZ0KcbyljKfK)Imql)?z^yHNs%9aHr`y>tL89s#uIt}@lArq^&)nGR{ioI0S6{l7-Z9#- z_S~chg9FZ|J(TYzWL{l-a+>sIEzkd_L<3xJ{XII-ZW)(|VC*&<;DLm(h{@Q|wpvpK!UrC3^_my8yw>)5FWN?_gny+3X(BD^X?#8gy*X4uquO8<2U0>Xn7sjgm zO;6V6b5%vj+P8II|L>_ipH(zt-tZJvfAru z_}MtIomL?)Lya#x8q022)K|4?a-m~-b`kU0>xTPWS4Dlaw_d5OHEE)7_Lbnbp|j)i ztQTHh?!)_YjqcCao7?j@Z1s29 zo;+!r=dEkS)6Z*)m2}VL=5ZH&nIiuB z@1%Vz-QKb({N2OGcje&3nTpec7WG@2py!uuS*j#^rYD=dZs>+4}l!+SJU@SC_tB zcK!S3z}zymBOlq`Z%Lk1x8aFgy^h}(#>wke9njxfaMO05(hci1yVCS_-(G*`C#!YH zTKOG6^7gL@I`}i-cC?AWH#^VR#T#E9e8^iCqQJmlqU>MVe0AG~Gg5*K99naiMJu(+ zf0+4N(thpxcYm_20~>k!?6hq6{m84|HQ~CtPhz;s`SKk*A}={lu9JLH(!E^thV^T! z**oeit9<+?v0gsKpLmwn;-~#m!`kX6e$~^2yH&scU{V!bdCxBG5`WwE+a9WQ@9%EE zo%r>a`Sf#_-@C7ic3NJ(yz}15xce77&c~i#>HXa3#j34UcVf>!U%WWpScfab{^-+{ zFU5D8-B7l%brEz}5E=Vjbid6Pm(@M5_FVt=$2a-=i?!;j_r1BS75MeOk9Aw$t9st> zq)-2)s`n@8_v&1*uUPVsOXlCRdB67<+`L-vU+S#dq-nMGe|pJc+jCF$KV@bxX7>Ge z{Ibjc*(dGe=I&9gie?S_>#w$QpJm9xlmAy&>E7LXuP$@h@3T``?0%hjxARrznd7dd z>QA=j*;jZ5AHFJ3biH}^HRGS^3=AeaYt~2``=RZ+w}?dT&xfW@(dV#tgyQ2|07;yjJgxy2JBu z?VUH9owxTDU+xTw`yX)jcTUFM-Xjy=O+WN5*>A7o?PD|3*8Z3kAJ;2i`d4gz=xf(S z-d4iT{=T_%;)L68Q-3}IhK5;tTw`YIL}x9S87F16Z2qp7F2}@gt(v{fLU+TIy`Sn| zMXlVB`m(Hl&jNYh6}##mT%Es~?{)R_+iYz$o7T@dbMMfFy*Gn13o;hhP7kf$lUD5| zlknl!oxZPTCvF~D{dv)(Fjl|0^4`LKFVEZht!S(L=?7NQp-bf1=eoPky8O{J{zk|3 zZ*OM4N?|;5^q1fN=}Tg!{Qaq^ncMwE!Jk)v!Qt}hona|&t>=2}x3r&lbK1x2nbvc! z)y|%|>$;))^_kgG2b|^XLoa>LHjS*^aNR36dDng4o%Ke;T z+s&_8|29~QE{vRFE6km>Y~Lg?_b9nf6Fk>#lTcw22=zK` zzH+mv+=8mA-?HD7`1u4F96tB+C#^jfmHF$-jCqrm?D^ty?aLk&&i&_iU48Os1M|L^ zMeo~Jy^C3w8^0xZ;%jxq{>U$#Z~vO`TUeCL{dzg+sl2G6?KO>b#`P~=UN#NdvG-!y zEyG`yw*z0FH(k77zL%BUmbLe4<*q;4Jmck39^ub7LDjnGIia~TmvTE#J0G6AvZAhu zfg$R^B*y)2lGR757cC4|Yj1x2CS_Ju`7w{rp;vERU#1rOvG95+qw@de=K0(Ie*JLq zocQc2s|SkfXJ7c)b7=1QBVWD0{aNgEE@x@2O5E=~C)=ybJf*bfTs!~jVR3G3w(Dd$ zj*1i8RNb{MWEC{6eHQcd`_%sKsb`rw9`PJ6-|4NKHHU%W?LP+whO_$X-~67XTwQzW zrS$)DORvW_Hwi`EoK$TZf9XoV+Ja9PpWJ>L`ek13WqqeS<`=&vo!^ns@?dq8z=xWB zHdQ|UMbn!;a;-92sQL0%!{o*HbAB(HyK4G*vn}`BVyg76d`xP!4xhZ{c3{X7TgkeQ zUGL6s=ecbZeBF57i;%@N@m%MOMfL<-d?I%5Y4FtA{eLGuG5NnA&il2-_Wh-O0mr@5Ue4-flljDWe)$)PmH$^P*w$w^ z|72!^%GW#3S-VRr7wl3!d1jAX@ZZvN9M@LqdNtd-&Ng-Gb-9y=4ln2 z|Nh*`Su;;><5dWr_3!AG)vMp|3F+m_XV3Za|1%@Qy!i?Y4Ql&0s$XV%dr1Fuq5qc8 zPW9z!F}Lr8oGSWbu}EUUsTf|4k`)^#PT#s+&;BvfTdu3zRuiAaJ+hxEtug!21IKUa zj~{(c`o@^!XTIvX zuk4RMdv(?qmtwyKqVxZLowd%cVs`K85`zHNXE&#>+9dPhxy*xSM~)te`^WVlo4H}` z=ThNx;iE|l9xPq*ZDYahbs~1JRd2j6TV)$_VF{13|~K8 z39)(?Savt@v1zeb{7sT&aGL9Hyo&qrq@#Pzv7KVtwe|VxeI3HLK1J4Db`sTJyZll8 z{?C?^3*N7Kk@TK9bkEj@?{sc6q$F-;=nCGv^#A!Yq2_<>wOT6ccD^J;e z85lC8>cUoUzIXj3OY~pYOTv=M8$W(AT>D^^ug^P|9S6IghF;(M>THS4h3+a)Imn)z7FXQF_0LwMwfp?!A%r|D{q#cR9Z6KXqnj!}N*>*HhIy_!u6S z?u)XLW9K^Ww=>A{Wx4(Ph(#J#&Ubh{`ghcoSN>}8++x#tPb(`MO+JVgebssUIlaiz zHnyk4rra%5-z~B$^wXYWYgbIazov|*X}RZ9XPv~#hFc=5YOD_wMotjjU!!Y(>`O$o z-;QHvPuH=Pc>O9*UH|jK(iLZRAAf56dF#r5@~TzG78l4a@!6#`^5>*`|h-q8l}AY z4cEPdb+27BePI1GYHs3(!pQI8C2uFZ+N@N&*(!R?JMO$qx00rMHSQ}o`gqX;{t$lc zncGf&_jtt@BXLFP@*2qz|NCi~k(U4MEVuJMxX&E(c^%K{|G%@Y8X0w(N>%<4yCjy` z7qee|`Xi53-;On0%62MXu z?p60KuP@m2dS+!mvR-o5r#t!bBa2JBt;~P!)3~<2EzkVeiA8J-AFRdhznwm_B==az zfxXS;x0ZAre!K53U-c?y$<5l+%4$!h7l<;NY`(bvirn^JdIuye66Tj*o>dcAfAjA9 z?f14E{d|WtBlYs~tp4bq(I3uD*(A!GbAF}q;XCTKHs^7`Sm z7T1a{NBd9t^4$B~N7g$W8KKeFqb^?hw42doqm}Wp-RYbPZ>%F0fB7FSa_&{&_iNg_ zvX>n_YF4{!^ZD@4x=IWTFQ)$cFfDEMviFxxE)+M3I=6rCqZMVRHh<0V4PI+`f4VHg ztZRK=%`(=dM;ZNlIBT;0%l#J)31q$PQ*f5t=NzuL@3y}7^ZR=L+WZZ#FXCCt(VkuV zZd>g|zms1By6x({%GA5Miv!Zc9BxaN?wY&$=1Mm2|MhDp zCRh0^W7f4P+`F;ZLH^Nf7Y2s4b~8-Yp1hHC{Ibh!qq>Pk?@wzj-PO37bHUBI>$iS7 z941%2bXkAtm!D4W*tX1KuU?xH_RoxA>g;3lP0D`lkGmY`8vI3ey5F_}$1hW5Yyau( z^JQVUu*m9v`}xV)iO1eCE?zACwqjZ`&)V&aGgtkUoS$(WH0Zl?6~nB%C)lKmE}xGo zV|abab)sG2WwHF{c@C#Pe!Be9ch{=L=hOY3-7oo48NJNuV&L0V|Km@5h`-UPa?M`T zSo*rwuK7RN znvoYRzB=Nvzt~s1cPt&b43p3PnRCUgp(f6I^OFDbt)k^${;zTtU!`)@{BFsj(D#cc zy*|wS{?p2u#T*O^TEBd$I;gCB-XgO;vtsWzHpw$`S3Ryx(ed9J^}X`URn`k!Zu#4f z?_E-FcKxN8E$iLWWtDP38cYlsijO~UjR}gLmaMW)J>}WLZ>c-C*uFlT^HMNShRfl!#LuR;oyxU`eXOHC zeqMfzL2T{uM?9Hd*R=}YWxG&3U;L6sxAC7^ zZ2Wggum?g_Zg%~jsXcS%d7H%g$6Wt}BQHlLcQG<$7&gCt^W=in?{zWt>qB-&XGJ$; zKis5Sr&`V!b@H6$&25ZNV!YKq-S3sX7F7K7)E~o_H$r-^{5|<*YsvMhY8Cxj-(~UJ zgQkm`A2pj+Yi+>Duz<1T>+jIOC8sxwExakUW7X>yZq|C&=k@S!&bHeA!)BdPLzwZN zgM57XTk8E{zN@XQdw9vteCn*f#nZc@4qkQl-g#EYTYEq6flE9u|F7FyZJ(Te)uHjq z!_se`C0TR5mb^c?)i}5DZD{5qxt-GSt2$2?a$U)AZLXWS?CO7`*)PLpE?ZHjRF}P} z?y~K*rgMz{Lw+2UIeTW$$^@TmmA_N0(YAG47cDvXxxVDGSblw_PqEFlDHd!0-O(@g?OpP+t7NO^^_I8>v&%j6 z&hDxSsOrlXWo%eBrQc*r?cTi+Z|C2>`To7ZRd0W1jNj%m?dZvDdp88g4K(JlmU@yw*tb{uBAlZ&Mbf#=O06 z6&)g!<6ye@-TV350@$tYmOcFPvLfn$?X?o4_NY~H2aA=y4yZo zesgx~i)FfX2fzRHjkZ}Ia((*WxNa%qzxxB@W1EWwQTbSlfz~W=Bq#DPh0q_-s@NW)h34dTba{t2__sk3<~jC7eS5p*$MZ< zP0yP(Jni29F!)r3;;vc7+K-Mp2tUq#{muDX)e;YG=F-y^Wu2ct1TDB|VSDD{snrq5 zD_?gq+*kIh_xloZRJ?whZ<Q_zT z3Mp1S+1kxj2CJs$G88WJEWa*2FE>`Q&-GWmZdPmM%sg>J3U4BAe&zJvFrQa-$Mk+8c?5SI1bS>)W|GM-?)^#xx@VS9Og zENY0ASBkAV@I7Ju)G2M&^OsJ#7v8^eOQU*aI&+Hmm74Q~M*iK-EVFe4S8kdF8qny~ zx^nd$%Le@iTt8Fqx_`M=^)qMVm%7Lei_$U+V}8H>*YquFR@KkR`TggX{;iIw`&F-G zYrE?rN7l7R^YSn1#4|A*n9XaTyq5EaQqCF6cFnpVs&&S$X zEb@%#SH+#zzx~^F{;|fTc4xcE$-ZxFT)8gPJ>PDAX3x39ySA>3pOyLT9B1Z|qv5_v z3&JO!ob11E$BWi8S=?7V7ajD~+)^=N7h9UhmfN<~Zs)R@xbk1W`22J0znIfzKYJHi z9o%#2(z-ykt?PJReqZ-GtoG*eRL}qAm;Q$?W}RT>8D6Ny!N9P7n~U-6BS*d;n)tWS z{O#JG^LuQs7jF#nPg^9l)KTeQaPnpAS1+e|cuDc?;B`=oy}fhiw)q=Bv+ljUM4L(O z)0qc?Z)XN9cu>TfzSsPIfnEN)ci*S_{`TqGEG};xp7(3Uy>+*)&f0NkljzB+qaW91 zuhq~9+wo}bj2R&2<$?ow3_CS0sb@)kc_;ZnTj=s`(QT=Z5*Ei?zNo#${IpraM$M}A zKEbDa|BJu;Z<@Gq+SaJ)>!TLu3crea9#c9id)6k`opSO~>HODzZ3gwR@0{}gSav-% zcJ`Kvh^nfliC=1i8m72ij^1VVd-LJ)8yj9Nd0zItp?}ArH3{?TKki?=`+mIKt%TdF z>-zbBMC0d26FSRJ$>n_e#Z56ZaWujk9;({X8OuA*;!Fx-@fZ# zns>2u$^Y=%zR@=Ii)Pdc9#i7FG>MVHH1OcsGu4YeZ=AQLy+7#8%feLu+j9#H4?J9a z>$%vnRjZc&@;D#TU?`t+mfYrj|Cx%s?a;c~UM zLB*|&>1+4etaSV`x6Fsj=7-_T-S>8GiT}Uzd45gbERDA}oJHl0bu9v~xUUvfi7Z>M zUwYk|d9BVab>Z7-i=M^q%*xk)G5Ostog*KMEayHsYhStX^?mJv8?U=Qe>r|tL+Ezx zcKx!K{wilfZY=or%2Jv7kfW60Z$lj@4sZ3|owZ+}g`gzv}lu$r^_>zn0H z^0UTmtyArN^Y-rSFUO~5HQu=R{Ihlc3xA!U4+&j6CpoR$bFe`1+WxjRYd_q&y5&jF zzwGk;^$YIZ@|vdov7kE3_tl$S?(^4t+4uOIUHn_uTBQ)jQ1iF)cQcMhfBk!QGb{cCkZVO9Ep<>JD3 zlP2a(2-o-__4?vL-mq)l1;1s#B|UGjS8CPosMxpT{H%{5*}K^OI84eY54mKV>8Ee< zXhC+^?6SE!uY5K*Rr0o(`5($zT#@T`bmgLX!Xas8cl@9Hy8J-4*(dnXB)e@T2g0M5#P{9FJKq-Z^UaM# zsp`kS%BRc%JpGyWB*{QoRbK7&p}ZGs8D?c1c>QFDm{!pA-fY(wi|Xat?{c{? zFsx{N{wynUndhgSov(z2bz7EcecSl{&J+KcCBr5{TI`TevrGF8L;&rAjNl;bY=-u-JA zQN-Jqw}pAK?2E5|{9}W4vRnhB(gI!Q?>xTzXqClj#&uSU;$`P7P8G}aV|9<>e(b>= zIw8q(?q55-%M~GwZ<@XRl8%4bJJDL*e!=}+*ShX9|BKjA^6Rs%i1(%WrC)!ayA$&F z_JjJ(KC2~d_r__QyPoC!we0*4oxKMVejdHZduwIyET6sI-y@bR%{t)|>YA6Ozq80F zbd~zsiES}!*9sq&ev@#$A%AjqY3ru%VwxA{mwo-oXq>CP(@jl!&zl#YXa1ZsO`SO^ z_0k)!yB^PvbMl)MwfBJr(6i$XD7;>J>3`_`m;Yn`*3EUT+An2xJ3iQ7?5djecczs` zmTgb+@s(m?$f#JE*_#?0Y#lpaJ7nUQmUIq=Ir0tbFS0N*IKO_I@jb0RWMy7pDz}+m zA;0y-9S2TU-~GscCo_E--;ez+>-xhB6sDG6o^@l(vfcOnmtQiT__DIUL`VE}kJo-( zo5{Ow30=RUacPdqMbV3DvB5^Z8$XM$ZJM05eNNqsQg8#q>erutC%(itg)Q25p zis+ry|6_Z&pVl7>TC}!s$?RtS0N>MI>08%iPnQ1}vis9UzNL??L`6#;h5T-9R#_^Z z`u4Jku9xZFkSlL2u1#80vUcwL4PglnK1?`MGwJ$H+beNDqc(8epC7np?w+5UC9|(L z=Poaq%k+L}{bB2jh2GrBm)Uo-{aLWI%B*y+#M7^vrkl*`K9{>w+I!06E7OW&mw9{+ zx@@X;);Bn`=-&TV+4UKFdriI^eY5q-@7n2+qMI0Es$ba(pY{3aR#z9M*ioFZX=^_R z14C)rN!xt~CguFrvc0r!;{I*IUw`~;yLS8H(%OCYT~n5>`d4aO{&UXfqqp@kqt>+h zFL`B@S+@DE{_blx&%LXQf5K?x=JP=Ha$eAM?^v&;K~)FEx87N*u`27poABLv+xK!^ zPW$MQKL3A6;EYqx&SsS??k)ZHQ-4j-^UQs&v*L?9D>X}3zV-Ez*($0cdj9B)M=xSF ztW28BbGcFWb8ip_qu=Fw7u0iCo|)MDZHZ3J z>Z57Kn=a1t@%??(YPHT%m5cs6J{|ln&%dq6(Uo-_@8e6&x>fR`#jJPl>l}U4)}J+h zr{DIfU-o*jf9oH9W?|r9Sg;T@0cNgydY66a^QzUa_c|!Mn?5>q?bAu$)p>8f-bo27 zJNrfdY{h+>8`(>Pb9bFz`*hU-In8aaZ%7fY@CP~7|FKimD^_P;DY#wjs0xJ~!o9QvU& zICIZl!!TXjtHP1LZyZm3Q!Rflfq&-fiD`T4x7|N+ZQFbeoA7yeyi^E>;EZ#?XG`RsQG>(@b3(5**zN`7(45mJm0*7@7v9v7i53M zU1m3keYdjCWXr)>M$xM$-DZf<66>4r@BV?w|3B8u-e-QZX^R3wgVp7W&$;iGRxV?A zTC~t>U58eV;Ks*YThz0?xAxi1x4&6$w&kGQl-G@Eb2flRLM<=fKV7;bQu&_x=U-u_ z-+B7iY_9iS@_y&vZI2n_@}ED?nLhJf{x&P=%vG`Ba+m*py%x0f&U>+n+IC?I-kFy?3A1>vbPh zmcM@&m4E;Cu4`Re&#aTMTlGJD9lJ`P#Y?8Lb=zw_SM8tr(t)|Y$8hpK+dT*C+^SLw z+=H&$=j=Zm_ar3bWA)F~`rRNmUB|N3_B{O#a5yi5!kOm8jMnuO~;R(WaOJ7ZDC#BHfEJR-;XGb*R8DPt7 zE6@7DSF7#yrPegc%wg5tS*zA>KXfWdmrK*{{Ok9bKf@0*JmdZqy;CmZNDf2!+zz4I z`i!}?j*xvA#6b|3p3 zZj!dE{Na{)GP75eYdAaTd6hO#Su6DFh1>1I?DhRJJ7TmSPr9ddc}?2Rz2E8%#!dR- zA3J;R>EOMWm+a)-aO(4(<-d-8aI4-WSHJ(^{%yjO>Mj{S;Ge!^`;IHg>oRT5)LCcT z)wJFELGQlp)&<*LF7=+e^>E20;rh#m4@*mHcdv4kIr;SK<%3(d%@1C9i``)9@pbnf z?f&$*YWkAAnQP9~zcw_tHB(;io;8y(ELfFSdw%U~jjR8qiyqZqQ?|AF)U++gxRrrn zmdAJ7eK&;9o}3}}C1kPNk?pk?{YrB`33}d@Jam(L*RQ{;)?x~%!YnPvz>~Y;oeP6b)x&Pa}_rO)In7OgH z#AnK>&tCLzPX68>bLZ{;bH9uAuF%eDyEVgVOFJ#rdPIA7ZrfJ1{@23&m-a24U1DXl zwcX?Y{J;gj>X}PUSv{M$ZRhLGi>1e=IWsY2n61q2ZOvG@yzO51XO~&33dLUD`7IPx^}D?(^M3Uze<# zx^>QmT`yaNdPRbB>wm3(Q)&9*)U6d;qM!d;@wxto&HPoZfvYULOpXhm4O`OW`7@1E zBXg3eri!iR%acd%f6xr}%bYlS-87TyvD0T>$y?{-s;0PZvr(`1)xx7zQakx}Z^_Tt zEBlmJB+Y)K&*QYJlftsS!M4Irw`_luYc=uagSllsm-wxI{b{UOwbawk_E}WSVbA(U z>-OeVW&J36dnxyr*y`|Yj9-3tXjh9hcHfzw-R}vN582 z(V}&xFSRD8g&SBWoLRbf;_I1Pmi#ro^_6FGFldv@0d$+E3cDFY_&r8 z?B&Asc~L6UGkq@2P?!3?$1g_gr7K$n!-6(W0sVhnX}%Nnyo+ZgZRGL_@lcE2VX=PW zpB-It*?X=myZl!FrE8QU6T>JN4S~@R7?==PuoHrg3s@qiP|N7*MXU3qmcChRu&FH6^{u`VnI_fqb6xq$$KiFo^7-3>C=3+4y z=lbItS>Nkh&3pUr)tkcI`~RDW*l&Aqe_zsveEVGw@~=OyU3dQPw^w`Rg@01-^TO5|Nr*F&fmN5e82lc-g=D?fV|&{5hx;|3OH@&hok4na`}BA9dcn(Y4>q;(F@Mj=S&n-nF})daUia zmEE20yEk9&vfD8KbhP*G&iUse&s2SsWA`hL?EGKGd2G+w(hC0XbrXvtrs!NbmL#b0 zVO`_L8@Wj(>(15cpM09feBYHX_u|&OTo1g{H>!WD?)}Wr=-OAc`}ww>*HPC#-@3M9 zUazBV_mTZJ{~y|}dhfq?nzzCBck=6J3O|`^)BUw@ht*{v2D_K>^V{+`xws8Byx8}? zC^NqV_{@IOh zBA9nGxa+&r?3n)g{`$XnPX3?Q{d&ih+SlC@92Nyjsy$7_RR2}`8gvC8Jg||cUw!rY z{TvTYC`A6yX*j*zQ%p>;bGPgM^G|oaKeqAei}!I&_ut1RT-m;R+ugY9i(cE@&c1W5 zGP#}W+lQI8qAk+mEC#!Gu2cWM;ab`6Jz`Q^Rr_bH`uFhjeSC*Zul~|wyxUc8 zW1GId=X-*=dhxZlRwA>n{%&7=;QdL<1qV*%zWZI)m!mIMXC6A~{oT|JCg=06Y!VmhglU$=1fdaliep@4s)l_wp~fUl0Dq96y?-JWuAwqP=UD+8$k_ZzJ&FK;}EO zn>RF1Mx@xZaUK)Uja#|T&Q|!zm+qY61O~qFW1l9no=a30`Pi+nN2Xiw!!DsJyK}XH zMLVafRLaeYwx9m!LYLwG^+q+_>)w7b`<*=R@x61E`O_Wv9cL|H$o=xl;e>s4X-{`; z<4$NhpHo{P*DZ9}eH*7s+xgWJS8E#?s#6~3Y*&AsZlCYFzG2sIL$-%KwRPtMA6#gw zooCLTUVp?cr&8V_;^XF;x_`fyhb@%1d6>V>arc1@Y;3PeORB_wr>H9=-1~doHpH_` zSm&+UgnMuQS=~SWOOJW&-Z`K1avlnNxXCLKaQn#Ju#M-I&Xu^;Rq1%M?7-+klAMm?*^G4C?wL<*D z|L)%C*nGG8cB5Q%FK>MX|Mfq6t2_9!<+u6#-#Xb}V%_Vx3{7(#zRj)qur$ZHT)6pt z+Ws7I`@IJo8V4npS6NJdBy#GxyFbJGdDAtn zaQ|mJ5#zAHY@W4Z>u%q^X_fC%7hii`Thy-gc?18eQ?jf6w*QU1!uM7sqg${%{l7p% zQSR4L-u|Mw-0KTxoUq-*Zjhj!Zp)wWOQz!DXQ{aG+Fb7W;te~m7c-|E+c57=s?)CQ zkW$ai|27BGEBd_}%0(&)n5URKs!llF``2#fm-IcizRb6mFW)xTJ*Yn4{MNr>x!cbd zwoZFsu*RXjQq)SAgYW%szuzenm2YSNK9d(I^7-4<0$#priJZ&Ye`g1N*z;yFbNV*R zSGF7H^4>UWn5@3-cKFu0J1)Nc8OQu*?wcd8zLnKlTW`#c-WZ(Fw)4x`xy)}^pI0be zyKu0u?pylydwvY^cV0)WzJ6PYA#=yRa}8dL_N&JmFecX4?_2PhJ^1&phoavZOJfA@ z=)F-}7EzMJe&^oy;y2;%F2;SoH|=?p{pHjnZQWfi)}n2vY=7uf9Dlt&=lspM-_u?n zXL^us|E-u^zGmg^^ADqLmGb<%p~n^Rxhq^hLEyoGL_IH?ve|#8vlP$hc>3zG8K;|N z*7G3i)<+Ty3yS%UD?|%CURZpC&-Tu`L;KsGWUPO_FL%=BjLGj6qW4Xwe8ogw&NEH^MBX0UEdKqVZV6#UWJ9V1#ix0A1u9kecRzF6* zwOMZ~c}4ccOY5!odFKDUu#o?o@;p}i0+t!Sej72aHCrHbX04y25o_Yv_t!;KxF^-+ z{{Fsl=Ke5C$I2)7t(iY?>@sY6WO8A{+SX4xZj7Hkd}cUh#Wlh9{`b4vF64y2z2E1$ zaTjxQ5>M3zF0D@))w?&oh{*l+Zt*LIYW?E{`ft9*l>e&bW}Kh2yS(byZBB)^Y%)64 z2^s3EUOkP=`E8K-@{Q}xb*`Sb_ark2J=AF6y}%qAP-T|=A_rV3^}GHqefRI?`6%gi z8w>aEd$l$6T8Q?8N8AhxZv0RE!E~VJK#zXGf`rAb*AzbfUKYT^*jQt(ZdYAO?4eIxIG0xRY(CvzR{YX@nfkFS?ll^=>?VZO&?yw#{+K^IEsCRVZ z6yI&P8oa)4@HWbs{QbCO{DXHQVwUZJ4G(^vD&Q0UofmL;;YEG7^+rGL?v($^k}3JT zPHGeDQ@!W)%qfad;oD7gR#o^OjApv8WmuUYY!WN4Qg_u@)35H?uD0VdZDSs5iCnhy zTWjVb@#R*&`$=i5)q*b?(+?h2NPAd%^?c6s^Q&3n`Q@8St{c6toSJU>?LDt!gKTS< zSjgY<$v^lf&&#r(=CJHu|I>dx{*4>iR5qF})b(KESpBb(`;eFXoy=Jao%3wXw3(&f zYOQEY5`Mtu#VaDskp6p51M?gmfi>^{-uWGO?ShTjvAF1rvS7uT>{h&tQ+spY#D*$H zZ`r@AG;&|x@9(~I#8~(ie_p~E@OJIPeOin@-)q9zs#o2McGh~lVf#M5cinHR%liC_ zZr6pCB}qp&UOq7KurUemDe z@Ynd}eX1w#Z`WV(=imi#hRy#JL=-9>@XzA)kPXh^K6PEUdIgt5cgEYpOUm^NUz=6u zN6+5P{9@zt8wJaiF8Z}Dv^~~dz4u$~`j@e{F1RYVdOj%cD%4N=9eKOsSKPGk=5801 zlV2R`l&yJf@J}wZb;qW@EVq-}T336^vse^NaAru@ezM+BX+Co~TMEme%@2NSKfbxH zDg5rtD<=xY48pcAIu?E5@m@8q&nuE|FKiRNWT|f%b!{iJL&}Lg#qW9Q8ge`f#6k}m zs59t=-go>IF8y_{N*#kj@w)!mrzXDtD)n4)(V-g>-_D#{{FHa@RSuK0yZ5iFJfE}s zd8_e(Cju$YqL`jMd^sn(bHdJ(FXCo0biep_e>d;d|CQTlJ@=77|v=qyRg8H>*)-Rd6wK#j* zm$m2l^=9p5WZKkSJbRz%iZ>vQ`28&$uR)v8-> zoV#{L;!Yk3J$IW0u^}gCPJh5Jyzy6!*sT{+k6yje{8)+4Y4*&0FJ{_?)aV%OzV~e1 zR;`(-o#$}>2?(_ z&60|LOVn83|Fut3D;Y-MB+{$C++CG>A}!Ss}@ ze1|B;^=4@nDovXjOmDeeW(X=*3$pCFwNqsI1-Q%WJ#H?E7COxk=}~xi&Z{4L zj_miEa69xv#DT4c&s*KU$-h4P^s$w)9^7zwnN5TS;4oLUHw8{_`3$@?2kP zPguET^QSXyoVk^8hhMK+pL_AMR^tf+&9&=O-+jK->ln+zq!aXAKIro6y%FmFxMhCr z`8(t4dw-Uvy|3cd{*pSckQxwttzmz=;DVUD$8FR-x&BU_;9&Um`=Z)=Y#N+Xn%JA9 zYafV*DNgxQ+%$#3z-Y<6$Nh8KathNvH#E%sbzJ=7AMPDHc`v6ZE_n2P>o&>TYY*-` zSbSr7{;ALF%h~2wUO!j5WBWGF56aH38kTa`v@6`U3A?WM*5&o<|2c1t?0G#iahr%i zwqwJ7S>@!ohNx@)mhIe|+W7iD7l*uFb#K1)&t(szq?)k3j3`@!N^R`CE6?F#*m z4;N*}m;CL%R>Ce+F(+pI%e^ zUv#iCJ22s370;!kYl?5Ko^HnBz<%<-*~$MKKFvRHc-8-ANgL4u?FUH?m$-KL8Mz#s zn;dB1)=(jI*>c*!z)3BSbUYTUU^?L-5NFyfsWzc9^zhqXQQ1qx8MdiRU=s81nO6GF zXTri@Zs8MR$@Nm3CS=*2-@(7?F@KtO(6XCC|E{g~Rnq7`_>(7Mix<;#i)w}!GXFk& zP+4d3HBm{ti+TOJjS`EN8M!5L-L+u*`JglR*Pdew+Rrsi3Xc)DTy104uF4fR<$_*S zTJ*f@bCyh58K2$wW~S-CJ8q1h3K~j6U$Qm%I~OjNzA|?P!wt92ePQonj~rO3_0PDo zp7qC+zZNy`ZZ()TFKRl-m#4YIbHQcCk}Zr$^J2ww4JG&VUjF|3W=MQ+gIfLmvlq;6 zNiF1Qbr5#ca9G)z6Y0*!cIK{jY3mXexA|@7L_4C6EV#+$QC*NT^`E4|+EsG=j8oq- zy_{(p@qX==T7kX#>f&tI?f6+{y!$l0x9n%}<+at>aSieF{x5ni9rdC^v5=|uyUoEh zUOMj=^4Mux3pzZQ`}W9(s#n=kVW#Z|-%j`y6Fz6l*V0Gv`HA;;+?Skpzo*ylc5@N? zaaXyHYt>s;&B%W~``Oy9lT>(a+8a;#dr(T-%uDS7-ZNUB8*Q0&cZjlDL$%J5SAHqqwvE9u6Lc2KT6R)-$*CcIErM_V@g? zxrtjtW7p{$`Lp+Kzid2LI(tIw<2^ev4^7y)@!G`M#i!{BXK-QHZ`<$F z6t)Jx?y;S{G5^?!+f0m~SL7CJP1%~5ZzkyVVD_?aJK4Pdq%ge`+JEZi`p}a17RNVE zS6LUM%utYhGWvT>*73LT=1qGh{0z7uWy)b-w(Qu3r70XnXKgk=EUnyNzh|B6d2WMk zZgZ!cTeJ1x)-Bp6Vy_x0D?11%-e`H0d19RdgO0$m-}hKU|7%LD^a|o{ZttxBduMaV z_Y?2Kb>^GbZmOU2HqKX4WHY;MuceEqz@lcxJ=MECnS2?$SOk8{<(<8`oI&n)m41 zoLYsTcOSPY9C%Q%tY-h*&u5qY|MElcyk@9p(QbtU>6>bgYM=YEV%~e3X93qAnTm93 zN%3&s@blg(eD!NdWB5nk=B_l^JJ%Qv-suW1C_8I#;#uS~-;cJ-nP0rHjB#c^UdiLM zdT-GC%PJRy8LqAVdTi}_UG{m~R3=}}m2gNjtBe=9J|~V*_Wb6343bNd|Cj&%z{(Kt zhtXVM%eOk|GjcsFzk1>?uh{f|rKx=nFMEslk($!qify?9udlIfJaa?;)42n;Kg?u0 zF!NB=4Yj;3-cQ^9y^9Dfxxw39?@DH&~MB0c0AmgyFaa+ESDnRy>|J`QWF+NF5!fEeODP7 zJ}`;?`>m1T{i$A}vYVk>UBTWHBvmaaI>yVrMapI|I_ zYOUl?nTe4TyEpbHKT%X(ka%L+y}1kf4w`l3CACcY(cRQ4n6}RM;6z6wR{y&P&6{7( z>V6g0H=C)%r12An(dQa3A@vh6>V?ItYPS}gv{9VCv-AIrRTYOg7CpGpP|6gS`{q~D z)C<#Izgi`F_Ce*;S8-FPsJVxjzuNljx-f^3`-#8_-Hv}n+ZJ3{-KUVB$aF@S?TFc- z66=?FJkj~9j_m(hz3IhHf64?$tl2Ey+)H>E9$jW zgETf;uif^44$CbQwhqTLd}Sm?qx1cS@Q0gxRrgHPKBi$L8g46m*V`XlQVM^L>`qkI!mZZN97D z=+?xY-XP2z9eDfuGS(YMr)&!`c`Y`h@=f;^(}Vh=;S4uEOKsX;c1`&O#Be~nswa7AVHu4QkoCp^hq-y>IOeXU|q{BI_X6{_BUp8smfI;*>e z;rvH-OY0d2FSspC$k6lrz%t8i!bO%}Qw3AjoDf;C=0tSTzEBSTUt*`DvRR5(JTl!{ zKT$VcF#v2vG8@@B8g{=H@Ki;^My|X^+tXUQ_ zA7|qBkBPh-xzEdYz1A2t7nE84;L!(XhU@3{?T)|t zy!4X;Q|y>Q}YbzpV{Pk=$4A?>EuU z^ff-PNXR*O=XpM+2@We89p1d2qo~|8Wvd9w&O5)?-}oE5qmN5ESx>+~geCTUOY4fH z`hvvSceZzky}UZn_tefskyX`4jsL&jC-R__pVJ7<=iv7>IXGRoA#d&S74fSK!RByV8z?%zne|eE1g!| zllsNf7R1=f>czE`b+E}}!V!rX>BnOYA zZ-&|y)BBquj<-*AoZYe^xF&+xse0!>-RxcKe(_)5a{EHM7~@sR64z}hb!oZP8`s@9 z{`qb5`OexKsfwvDqJv%dc#ZPUiLG~QnqyRSc*6Isd@LejuQM2!{_OdES6l#;*ds#z z>YuFlwDD7TU?8zv@ZH);|1;grs9sEI5lUfox}nlCK|p1eVy-Xu0Upn%5n0Z+7Ph7~ z3bSy&kZE>sXS9@_8dKz=$61;&;rx&1rT#o@O;*mk@^TJj%bgHE;B@Chu~R@q!+qAy z^vZY2O#%u+6N8PC=N~nDR%}$?%vYTxFKKhMdUf2knXlHXH#u=M3Wjky3NcN8t^B*@ z*hBS#xgSt<&o@iKl;8>8pKSUC1GEqFO)Yh-h+yQbn- z*Khe<50(BdxqQprtt!k6E)y-4@_i)G!YejB{4QF0VR?Xt$*+HBc$Vf=H;c@AE9dR}O{Pg$KuzTRqWQR!ZypDra%9)c*BDu+bX{qn=QKGRJg#vNvic(!f!42C!+ji z%}Vyq4{~k&7E^iYc1P6H9db`YUhH9AGn3J7^%48s{;$3r)O&kGEP5hC)|q|NSEYqt zT+?tdk^793gDTTPjn&sh!@oT!-OzSw|7D>q?+SV6zJE}*Ug2`2yQ#S43&ZzzOCEkd zZMB-I*5f{tCy%e_gk7_@hJ~AFFIc@f^3Chhf0?AbKUv2IT$4Kct4U!&V1e+)X8jpV zC$`#m2&6Pz(Kx=bRV@C%#g!jkBrliI$Y(tge~6xr0wlr zKAt)AM(si4Jp$i~FPbj<^?RA-wY$e3MSHHP2z@_w&9msuO4mP@YsU35UE<-`(09j> z@s-%MU7`!NtowKMR}4QxgSR-tkJZdq|L=PL-$FuH;=D9N!F$OT-IMzj(wsO13r!VM zoE|is;7s)5n4{|%94z*9!kf>JBL3vO>WaxXVUZHm61=3!nJ3fEy*DK5wOFH)nsM>d z=6&m=KCaPTd^yK)Q4;@izVZz2b90%C&P@p1kYc3$NlV#w{WitPO6+b@n;5|&G-Bl^!WVVm~aV;va-jEWo5Ik6*4>3 zS?8`<2@!O5#NRu8|T%lLc=zmJ#$a->3@SCjp+wx7hb3`W_$ipha+6*|E6U#1(wzM zGbv_l_{$CF50pADw*U=irdYlB3RHCG&HNOoo) Ix`>Jw%+RqVOJ<)krn zqST!$m-r^+{B;#Cx}9Wv$n2o7{z3DTkCtRB3T)VY-}^JuqIcy*%LOmpJjC~=Q{bH3 z9rX^DCli&tO`;>_$VhKxUdv1>s1>ML8TGf%+rBk>0 zJ$aR~-}Lu+)r;|>%&j}uoMv%b;l{vM%lPaE^LJ*;aI^MRpVtcQT@%2RqI^qb21h!B zh7tP{*MtLIQI0Qqx=$2wuT(!bC0Mdcnos+0>80o-hf;wAZi~1{CpQ!`*?gb*&g|NU z?AtybGbgs4Iedss^1yb^}I>DyQg1<_6>Im(0Fx_WPtg!71?O`6S8bN47_= zdz`dszEQ%}v)W6p>=T`*9JY*K-B>rW&f~Vxp?*eIvrAi=B$aL9Rf$D2<&upaPv@RnWv=U z6j90&%3Ptc?uzA_Md2N0O_ptLiqZ`X^E{X>H>yrN*?i)PLU5A|Q;YkA-j%E0^>6!j z_@l@bmjH$eVUAM?K?OoC8(ZuH7}O;iCNkW+x^wf~&mZn6zxg9PVdliiJ2vv4$Ytx7 ze5ukHZlp2u<0&PD+zA`GK4@la=A0FB-AHV8jjva$P4i?0k1ebAb3Ia2$YC%%;4o8# z;m)kXUhPpI z!p}Tha%r2QZp--}n^#Q|JgXqc%;1$TFIh8%nIo~~jsabeJ2Ftw@3#Rg%I=Xbuw*KVCKGv%J4)?cn9cAe`zjLeQ z8-)WbHpcVb&A9*4>gV=T`#CSUw*2;QHeb9kUq#W!{a&4M;gPzW^EG#c|4$QGq~O;m zAkov)VHmNTV;|oNPL9Qop4k2fW>jvNw5ux5@$Bjb{@&wd-K*^0G!+FkGAt~LIw<%* z;auI;kZPtSp(%nHEJnvp%y1A=e##@MS#;V%*)>j5F`YGk%ePuZk9iHdQv@eWh_QKf z=1tDSt1G|%Vdmi6!&soUfnkoi=na!i2MUxGW9A8jDOIfE5WL2A^k5D%!?7DtOM|xb zTQOxarL)N{TL%R-Tu&1qS3V9>%mlpMPhT5H_Zy;I-QyS_LS@mhSUj9B0sKD z=-A2V&!Tv!=1PpUAyfaSb7hNGNqqUHQ*P%a3uWA-?DTs z+q3N8FQ!6!L>(MwIyO3H|Fl`Vu=#P#)CFnt1TweFmmWJWHo)d%OSK{W(cx|R|8Kng>Rcu5wX=WXVh8g-*@EmI zGBO??c1)1DqoG>3eg4WVN;fJ?6f!+Obo2A^L{0YEydcIoBEeZ-?04i%j{BeY*01H# z{i$FZ@-{^l;wc2NyG2jFXR~2`5@PZ&cv(Q zdP$w^&W>*y{Vv_ymz1KgYlWxZG?fXL_pr2a6ASv)kTh*yDshtLHre()6 z7u(2I$1lD7XZg)&Q4U9$zd0w*mAcM3%J3oYoGec-%UsLvIak;Dx~VlzP-^cv!aHfp zi5yO!sMIBsdye=S&R=+No#Ux9@tRZ2*4JE}a=qzQpK{W-SN~lEBLz3#y54z4P)3WV zy=MVS(qY^5b*s&~Vwo0}^#*uP^IZ5uao`TtyzVE zk94=Z=2VpV_DZ{Ry{Rde^h(Q3qRiG}@zZx)l($(wGh2~kqvbPS1@jgax3k_}9y)(N zTd{7jRAhC_lE}QGWGWfSbm^4Tr^D*v9ZY=vPCw^2ZG83j-@3hp@eUH)6^A(99?|;0 zR)KG(+)RN8f!(zp9IsC7i;DJsacy(B#SLcJ)bckx*Le6+PO^JlIL`3;+re`-&-I@$ zHr!8ETK|oOqx0+E$r2uFGd9O3IQS^2T#~bpUbl2zR*VGW>S+^Lw=J3IA8NK>mS~L2 z!ZxSENP+8j>~dDhX)j{SU+Ve5g+t15^-1m1W?}0#i)$@-&@*?++DX^muB|-z#fXXj z*1}Ek-(S6c7r|hBzE1j7zNcT(hUeNRFR=-6Y!2TlvNJFxbHWZ+F-IwO=3CDU7iYD; z(Ve9?Rj*m#kjl)tGj4L{sm~CIa((lrrDAEewRPZEL-$v?3s%HmmnrjRWM{~4oq2rQ zhlY}~Gcy})Hfo55znZ*mrm4x6^VKoV?79r%{@e$q)kdwVX11Sg?65G~c;Y%C4Od1O`?ESue-)X(X;`KY-1O}n&>ujRLSOf@^D`*w2kv*^Dni(gHuUc4!tIZS-rOV)3~3}Jh73)6C| zZ633#@-fbkz8|OZt$5+wC6UMC!aY`g=DDvgG$H%G6pQBe6OWh0yDx7rGnk~3%w)ot zY2vE&=Bka;uLqtSeMv4K~;!5h{$iC>%FzHYF0!1^i zhLj^mrWLGG&^oZwT{M@cJK_Q}Ysl|aIZEYRZDAY-1@t&vL@RqI`ZLa4DeQgn%n3^c z?gMvj?Du3XUArpkRs)BEfko;D0Sgn3DS{3hk{xT6WRf;)N#jW5bS(IFd)srqHox}g za(jx^m4&5Q)io@(K5+1mycRy!s6fK)JwH>5lV5Pdk=5~nUdb6VYggS|F23Pw-qp?o zCMmUQwj~`WCmop4u;JY?yR96GO+gM0Y|9y5O|oD8>$``B<5lfimkXUc9vVIglXCUr z+$Az2K04H-;vyz7o%P+H?!5XL@V zisjM2U0wzJPA4QACFktSzWLxBW5NN3gcnDewfWbmED}~e74W1}$v|1Nk=fyCWp2s? z0p{gLHn5z!H~p$ZF-viq-GT!qj`BBK9@%NgF+ZH4f9yQdj*IsHSvX$v?c4hAqsgJ# z*W2EwWOdeO+5GHh`=+6|CY`z3z{$fw#Zkr0qie+_b?duTd>l7t>UAE>sa?pHx6+W| zkDTBG2Zn|iA!W9rh@2>=Z(Ri|#aFJCOn&6(Y{0(k+C{a56Cn$ii1AGQyXlsbh0myvFyN_4?DzJEs<$Hfri<+;NP@Rl(`ZKKUR6>x-{stQ~LP?tFJ` zVr=mAo=8`32ZoK?H?pkgTA#oF`szmqvv*I8eVBc0Rph)U=R;y@<+GSiv2rY6&0e9r zf)u(IU=2RR?Px+F$SbYlo?^n0*x z$Hyj5vj+uNZ#Z4j_|(?S$Z<}{__jiBk)UI%+1@s$t;aTOyqE9)#&hn|nP={4H(tLv z`QDx5?vl3Nddx?b9Y475e8%Z}(l-u&^66Z?!&8Cd!m%i&)Lz|bwo5s9QW%{CmiRUt zduIPaz+v*rpHt5$*fJ*>ZF32m-?Aa^<3pd{PiDsPw+cVX+M*=Kdc?#vrA)!$ZRrVa zm$|iHvfsZ+zA9&Ny`J~a=|iS>H}5>P>z#z6)!fa?UPsuSS(m$F`}UqY(}J#UN;<=O zYOfjpuII-?4R`G8>poJKw~_zmA5+%unO6Iy)@Vfi4%_yp$?}_}TiSyOZ<%wrCTy~0 zPD);Pie*!kRLy;>FB2!{2EPpVzGJD_#5T(+R7=HJt@Er!ONRu@ zLRKjy6Uh$C#fl4N8a=X-iA*_i?RVdf!c(G_vm%6gvlg#Zf4eof!D(3-<0+SCEZ3BT zviVt9oV;|M7@3NS3a5H)lw|TcDR?NMQ!{mShqmQ-Zco0h>^0H)HhuYm%Ys|<9{4(SXg_cYPImHHYpK`C zmf^AK>$Js|pIBImwBmQJ>)Y1xu2^-q*p!x#tLs}Qa7+mNc544zc5Mg7Z+o0f1QJx8 z)VZb_s;Flqy~q%@>bqdl^vrnMy$_ZQ+Z{h}aJVn9V0sX_V77bwufOx(yZ`Z(SZI*H zt5dp3f~8PdNiOM#ScgwXfQyQMS;K}$J^Y3n)U!+kvY*}CQOq90W6)g8uy)!L`C9+j z7w_-a9Ne*1MEmTeVEdEd7u%egncd|$I2Z5#e>vzUzd)v7xU$e>R`*r50uvflByECP z!X_N=`BLK`p>34=AS!m(d}hs*tx)_}ZzBNB{A@J#t~6c(vn;NQSJvGh!S1x2?aSq$6?O&G_G9?@zl~ z4l~>cDtvmVN9tDk+7)xWBc0ig|6KZGn#+=9C#Qc+d;DI6WyMm*=<880b@f+g?)aZx zJ@@AE`Cm`|yYT(>8hr`vj*H*-PJf~~Vg19qznDsFKe(}nzi8X)?d37IVt(6&Y}ds4+Pw!&hbarcU zyJ>FS4I8&@ZihR=)(SP7`ev<_HU75wnmQMoM7cs{{7dy^mftor`E8Fmel*Fx{oBI) zEi)OGb2j~e6V_rE7NSTb#FJtd(P?2)7q9nJeZ)PTFh)H#0o!u{WL zM9w#D>)BB2IOCppHn+ptKk=9U`!^f1+D|#4u6*c)$)Plt2ZoQ9IL^si zgF(u`&2r&MHYO8oK8}v4OPVbnl7bAu4i|X(`c#w}tfs83QCZGwEqeR-y%h6VZok_@ zFKI>=9TW7F70nZ|Tr7TM#nHknL7CQzuBHv)jS5{Sk{+}j5YjMRRap?F|NhsVxz}RO z&1F$)dGj}CrRNE$3tEc3Mz@01eq7SJaBa$ZofD}s2}Kj7KlMgv#eY3Ysmd_J32r=7JfHs5Q1orsrYTaj ze7oqxNk&SAA*#x+m~2yPQWRP`FIOGC_)2v7kFHE}mrxfj4lRz57d1^6CUCYYHdp@G zb4HUxH6wbSxa8#{>orBA;uxZMGlJb#|J%cKp~-MAL#~I1ltFKkP1u&l4O5Ihc^QfR zE}w3w-M;Xy&o8E3bHCL6KRj*1z3O8}{yA+rvVY6nlw>Ot(^h?TlQkWcEiXdryvtTy z~0-t7bZCQNn%Kmh&{c-XE>v^qV0PJj<-3n|T)dx|#BQD9uz22A zbIeV|S}$zrg*T2h4oPcsTpX;0mLzezPB_LJbobTA9*5JPb*vP9nbw+_Wtr``u#!V5 z)#PUF1+EJLAKkakJ<;(*XJPJJ7tx;1`Z)$BOClZBCj6F4-Yg;(ef^)2xyQP1qLxRu z1%%c5Mx6Fwd(I{i{(ReIDJ}~Oy=Th#cbD%<EkDTw-bGeRZ}lmn3iGzr}g(Ye~u7 zQxdC6!(whHB;Hw)W!KBu$$aawWT#~3)ki#MJroz|D&wj0Wu80JHG7wL&T7-Qo;!77E<69u>^avr z^`hwWQ&sxkF5lOGy|t@Q-|F{F@ij}=ZM}5;LfJl3mhMn<+c4gP$=Aekb1uxAwdiW7 zPqLHL+6A)PJ&YWqJWpIMed0dJf3uY12ELn@*e>+4*kmy7*z)VY;q-s}G0a=yw`qMj zx$4(^wPi|GH`S7EH$<=3SiW*$hf3|I{)Cfim7na59&TC@X3KvsJgkR}|Di;ib~KlO zw^7>EJ#%gdHR_y@nZOh&uWx+m#_w%mteXTU6wkE1CoQ6N>eQ+S6EE*i2tCTMjm7(_ z#uA<*9$`;!pWdlF+xhjmxf7a|)SEKgGB|ZD?^>}h*m%*OgUd%mf<5@$Q;sOX3&$iZ zbDCCu{JCxMzE4}3Sj?7Q_n5}xCjLh{(@lomL@)cQhl7fdmkyJYk6Uuu>*{1PEgezb zD=yKi5^hyYIK-v+{%+BhxEK%pKsUDLqx1WWcf~4*fm{EI1FIe5DiS z^C3I#ll|F_$k+m7sipq|x|i{O+q3Xq+2UshjypKtj8;}^njze;SaC?7BiAh=ReblC zBU@E|cWLdoSocO^b^S6~DO>Xg$zQvcf6;d5&eFYbt=hh|DyxF|#0g0TmlKyS)g>nQ zv?=-YD$VQ=yvEYm+dI)b=P#SziIs*53j#e(pKy=mPEr1Cu`sCd>@vnprJmr74wScoVnoEam!fDNKM3j$x7DAb9)&YG8tq~9nfSt#4*FC zu`v0hYtV(nCL8~$HzXL_CATPY1o9@FJNeb`M#G`0PZ~^kdc+pIe8ZAc-@v=$<~hGJ zH@I8Z&63@4=je3ktAeAgLr;+^4-yM(1N5=$do+UOe(>?Z?F2>JmlsoC44a#y7WDn%$WvR;&qB|y*!G~a z`<_9wYUI+8nAHtEzeJ_qPyVizm2jEkLF|0%(C2(9FIUf8!X&h9qX~n6#>Eacd14H@B{0ba*i-opI+SgWRa2z0NLPY7eGr-qwj>*?!{W z`-zJ`@7%iT^%TMDa~T~NeOI&D8NS)O;M)DwZuQT%1>8Bg`nHL<*k*=LmncNhbJ;Qib(9@oIPg089*KsIDD7{XuWjZJ%U;b##x{C~7 zP2ZlMyXxL9$)(9}HRp3nDpfw%v&p*n)p16v6U!G(n5M&_Q=OKhBgA~ZVNT8cZY4j_ z&RmxG6sIWVmdhY_|mGL%)qMJGPR+j zhao-CLe1nGp zc0WJ!RCdpDo}88)vfEekL`qD`J(HsMHeqdplzsn7)~pjZ1@?C`C6{LHaq1IP3FT?X zsr~HJV^Fwp|DhH^|9{19%DhUJ7Hpfuq||Hmwh0$YFtnc2*}JXPp|MSYC*i7zu0q_* zw=d2Wzx#Bi!uM@eure{t#|#x!NR`en)vY2t$W@q%-b7Uy(&8Uh3l7B zTd&=`x%SkvCHD@;z1nPivX1}Hw~xMWxn*obr3~b^2R1DaI=?jU{32$ZQy$Luy|eZ? zTsfK$DHyi(x0QE;g|yLTe+~v^|LTJdom`oha#SUmdwXNl?xkqI*wD-PmZ8XOu0UA# zZwZF$+xBdD`s%MZo9L67>9792mi@syyX(?~x@WZJU-^!`IV!d%CNos%!>BOx4wTh6<4+RVdPk% zsKO$D`RU13osXI{N|l*;b6vfvMQ54!gew~Vr`S4fE3BHhDe&o9 z)mr)I@78j5PE2MKY}9BzGO1Gd;fDj^OeP0fULNcFlc#dR!l^H$xlEuHNvNiVacWqhsmDkLo@_tx?^Q*g=&jlw~HcFnDHE~jW zdfWHKTd#GS$}PO>vtWYb%(INa4JkJ&c6gYrYMsA7uCsUz)9wylN7LW)3e_AIpUC>?=eO+Sgy!w*IFwi3()n)c zG@)V7O9uu{rRai;FBWavR_3z?F}jv+G+kcx>&D^*W=(oQubpOIG4xb2N=hgY5ogQVB)yvQ%6_>6H~xTZq9&n4t#1^qRd4>(qAK(4%lppySeDg{JY0EFBBN? zif~T*A-ph9LCs0H_od+TTb#4*Gr8KPN}joE6&Cn{K_Zl0IhGkp{w-vvN6gP*w z(^|(mV=?33`!g*zd33yau;Yia@miOCZI?|}&1mA#y7}w&_Q>q+tWTv&XSM09Klgoi z@2yAN7mWSO-g3WcnjEI}Y{ehOodR?Ix&DkO{=Yw?_ zgL!fmZB_Xo;*rA>>?3OO>AqE3!lZ?=90{B}stx?C*%Pa7MP#nx*)^%5Gg~Srv-{I2 zdl6n%lUNy1ow5@x7T+wg9(`vvtL+T&nV!HVAoXLtUufC(>|2rArq&m>W~J;{*3CP2 z<9xMqL0A47GC$b#`+@7X;tc1Xi?;Kp{@2ira2GDRpLM-g%*?juM2@?`!q+}cI}|6V zIdAGsJol5wVr|29##6i+tQVX)E52KC2+K$QeX%I$)6%0%6(748&VBoTyHKL#vEToC zxsoDYo6c>ODvj$IDww3VF5$^Dmf#F@o0ijaqPj1($EQK9J6v|j|6C0|tT}g=fkn-z(BAB#&IAWlX2V-6bha*t6kMRlD9oTNY;tmv zgV2Gs{aY_)T&eYn`?={`tD9sYZ|JA|(DiH%lbIfMsWunB%H?O|(6X)F*>TH&cTUY+ z_Sa6jtKMx{TR%HG-MWW^GfVW@3cejber#2k_GOK+2lEETfD;dLwp1sE|Gda^Nl-)O z^-eJfZe?cP+Y)=&>`I+BsUJ;JXgCxp&3CV(WbfAKd6AWIdTXv2nk`Go?)CXGX_3Sw z6-Ft^P3tBY=e*iIaRRe)x}!srN3%{V>w*U#HD6S)T1B$Uoj7nfIm3P7d4)C2@8>pb z^Zm!Hq-i#>Xka%Lr;_R+f{ zK5S&IX-|WFmQa|o+nE!iw&57(}J)HG>}h(I>mmle#nuiUNA-G1+TAL}LqkG0zC^7h}*lP#Tn z=n|-IzvsFyBP=%hbk*yckZQD+&d)}Fl=PpWD%j`>Jt`Q62e{|Zu?B@fzGds zb;`?kOrN%nbLX<|=4VIeb#oW5Vq`bYb?Vxg9B9Z~7UHzy@D9$FD;g^TlU*V$rA>Sf zoG_kh*x+fVmOX<>n5mbk!}rcCe%8fTv$uUS?0azNfb14mR}Ht=Pv`HcO}%K+lE%_9 zt+vVQ2?Nr1jq@mHD)bz-EV8$4nM{5~*=zuFGS(Cidmes*U?UOL|nZ zuw*m8So!}w(}!e*Kc)=!1({qdaz2b2?l1qdPw;EbcyncO@Pm`75AH}YE3hx_I^J?% zUC-WEjfOmiy-zrwaky>GIcaD0)uv=`$h-opxGT9nll&q)Z48CCu97>f<*;OfTr`7; zsg&9kyGbenrVHFAZf?>%@5AUY^XaVE1Kmta(XN&qN_{ms_onPUA1-)Ot2({@-8+-! z2DfXQTf!E;>DsXN;uKLH%iN64w5=;jW;F^nYRp)naCp|erWqD;Qr=!(PwRP%pE%8B zSgiWLCvDEE1J|qeH%auxRvla^F`@DBe94ziwn8^McQ_ujJJr9OL9p>S(<}iECKtJa zEi74c79C>}bDFg)IsH9%G4mPA@5&Vsg2z)F&iZ`fH&MQBk&)|vJYe3`h_BavALYzR zxX2UQTWxYR{d3xK zC;V1Lk@-THK+T^yXMLaVV-MN$$T@CmsKYX`IVV^7YMq$fCBV6I%ghwlFzMJS?si#| zMK-;BJW(d*MdrqX)hk5roUPuran8msEz^$J--t7BxbS{q)#`@(=j}XruAX@kr1{Aq z*;}EPaYvEdlg!fHQ|hhN4#YJrT-a+cZ9>JYgvlXmw30vXWSyaP;2_h~um;hl0tcJL zVIN(RFZ<7ESZQ(aV*iB(P01OjQ+^lNDooMTaQY_W$}7Qr@AWJh232N<^fxR&Hw)jq z#pHT5S%DQRi{!3X|oXadWER2?_44F{7ZGg#edRGvY{Ifx^kaY%L?DHXl0Xoi^{=UOqREz zGFWfz;QGVmXufx@X~$N!p5~+NYuGHRQhO#G2+n@HVVlNV(?AE?=1nU9J1_Kb6l!H~ zIQ+#FZggBStG}*JC*Pio9Zmp`$ix6aCNlre_@T|o^QTxra&i-B-7Abr7V^W_M z8m_SAJ+!&by^HSv_k)T;W`_4p`V1ZW{>w9bQu9#ixZY56?8WUzUMm!57wt>&{wG}S zSfS#WoFfh{XKKbCTvxfVqKy6gMUw5N&nPvGw-^Y^&VOUPQW^if^dbG-yE+XTa;7an(i z8k*eR!kp-6<8t9LEWc(1Wt?L!vB=8~&>FIcT+NjdMmX70kwwcU1;R^NC2b&ZYDVv_1E z=3n)R5t%{D7ybUFxcbONkqZ-hS6+Pab=S43E6m(Z*}9sFYy|=z)RaxGtv{EOoY}*w zaN(rZg&YT#1=WlROj}kTTjR+v{fT|&%CDcUWc&|(TF>Hinaw1-OKrP^r-SpWz13kK z`xS~GT#h?qlrgjG!Q%g?tlHQuw0Y_txXILiF5U5N+Udfh-u#bmgl1dpx|Og;XL9kf zqsONEJv<`r|L92a`A0M3|LDxC`2YN)4?9PNzx2wDX*I`oRa~08Jmuh}RlV&=o+oay zsBX?;XHog|Z`(wr$-4wHuF7+6ESuuDcK;bGvxmnR968=t35lh!d6n^dpY};SH$@@v zB*R)EkM5AK!l8Ii<$cN z-jr?gPDJe$`D;8sTfK_W-C?2U+)18)BUV>lJ)>87`Jl$~%JaL|Eecgw%A>t*UmKT& zc{#6a$Upr|MTd|71!wwy;_TFL<=_vc<=r&p*8Ry}v=d{+)5fr`6IK zTYoUkmw5a(iD`9B)bctfi7oA1(yzUjKeJDh^ygYLLv`84lE;56HvWtY694a9aFYaeLI^pM>bNusz<@dP!zOXj4|8V)IwaSfb@*mYDH>zeY zXSo^D!6~pTX;whi8P97Br_v7yInKD@aZfLJ-Eqb^&O^Hw{J6o$@Zpf9U(+QC6R!iW z0x!J0oUn$WN=k)MMAAgHKxjVqD^C+6r>+NXyZ$|Tr+j46<@&F=6~`wU@@2j>yv|xO z+cJ4|7ss*rfAxQ?bf15;c&WnWSwDolj;ab+dBm)Fl(RQws`3RMHf!c!u8YIY;w%Gn@ZeSpBh0 z^4G038_wGV&R=I__@eH@{@<&AES+C36R)yfOPev6L79KS=IJsa3?~&_Ca8V5(Uhks zd@XpzLZPLLw6Yi+!m`(Sg;Z@R%zAu(5n6&N_kNL4aCaNv9(?6{d_&b`!#!Vk%^B|h%zAJ|L|VR5YX0Ao?jNK7 zzZ9-e(*87!;pA_FSRNB!(L|RA48s3rcfDqwVUWS1?0eU+>Z;G<98(U<9Ww&AF>DC= z%*fu$QW&Cjw)^I%o|+Fjy%WR)G17i2Tm9Lt%XkvU(pvxt$M^UTc%-c(FD z8}#Gy`onwgR~W|`ZV^7ru;IqR#Byc9FaQ5@)csv9e^{I$!}|7Do8Z5*y(iBLKU&^Y z;Q1-vzVC4SccVRDrY+v_XH}_;r2CPp9TS6=#{6Zny!Tzj?)Ub4&HU5X$JdJL{azj4 zZoW^#*>Ilo!v9N;e_Z+hYwZsS#!FfLtM3)AEeyT*##ArB;oSqz3%22pZ@jM*eOK|; zbjQn8rZIlc3sPO(cKi}p2gc7;4_N21*FDIdXkW|}Uv>WZha3I>)a3qN^32(GElrv4 zoV0;X=0nO4yZ+Cmr){|35YRU>-*;}d}X-Gl2c@Q^k9_pnp7{wPlgRb zIhQ&+y4Alotq|;To3!-hFZG3du`SysMzUAu#3W8eCuFTUoFmzKwMKE5t@C^cKT=el%H*53c#C)=h?n_s!F zbn($=*7ZF9%eU*C{Hv+xlKpyvr+CsD*V1DRW($r*>dsp8HH;xDp8chW$Ias*`w#Rn zGcXtgw)g*hw)4`xQ_2@+b_RN;F!zKStKDFEEg^E^*mld@uFc_@GY>|tHL@z@5O za*hy&-3 z<@P+=Z`U{fYTQ=QQwKtLSY}u(3Q`CUY*_qvg}=xK%Y6n74xC30w&YAXaHM0FY>bvj z-121h#kuWl9oOX#rEZrw-1~dyG-aDd&z<7RlzzlEPLYjy5bNoO8E18c+Ts?3VlKo~@g(55oq=o!gul zx9Cjhyq6~1qhF%*^51K)=I`B5rFi!ILcy^3dfj(5f4A=V{QG`K@cVx} zcK`PO)2jRIy}ZDU^U>S;krF}eq-3-e`(&Ws>6aY`{Jz&Yh>r2Xp!KwnxrRk zHSw!{;M%5(#@v&dj@d*z?s-`x^XvWlgLCtDF>a6D*_W(8E#OJ_@`9k%JKmho3VXkc z>AE~iW?9X%NE_`9A4?W*nkKe(>p`pTdymhQ9y$_s<>$HTg4f&cc82eLW3=aqQ(SyP zOSJxRP}FDddz@$UX1Y*`n_D)8}Qp|IS%|yyI`zrk`>y-;6Up}o4pmnlG-#r4LD!yF~E0w$g?ozn1Qo%P4A)MM?R`>x%P zQVim+D2WRGI%}T$>Faezl8?ItT*zwvA^ZKowESJH<#(zq*CkdJdMuuoS-8wlA;xh+ zW?SEy6%tI*ebY{SYCH14RQR!J;8)EVZVEjy{3*;|{;+H@W0Z|+cMUz?T(I+b&yFv* z8Tc3&W-~C{;dro_AuMOx3(xymJ5`K?f8XeH3_PeADH_#t`bEjeiG@6A6$fyKGWS0SI1V_`-A zOyLzNS{=%UulmwbN~QXuTFqFDrtA+@&?-tZU|l>Ti-~<(SexjMio|a_->X$zUHw;F zYsUw$8?LRm zCDhDaU-@9t++$BU>o4RaOF6$=T_JbHeQW2}H4N$Am%jbob7uw@vtoBQuV^w?fspsA z;4SwKWIS%EIJM1xisL5EX{P?0*f`3}4)ho+SA3FpcCoplw2aNr|3X0W$-gE_>r#6f zA|J~6NhnHr7_=JOuX!bZ^60A@*T3k7Klm`ek|F-{gReWZKmB`uaZ|stvdk~p@&{Y* zmuSDM{QmgIiTjlj`#-imFIbkE-tmLu+#=3JEPf7Q(u^l2DhM;n7Hd4RhzsVmnezJ0 zszjZn$G+hriBFOZmy4Mkk^FdGDJg4>NZy`bpU&+rc)a~?=kB<#LU*1pUR5pZcbnZ*-_cz;JHHr(K?=jsoTTGDoMRk0$b^Ueig7Y6uWp>_J zXKHZBR{x-G{rlV2&{_Y7anJkZ@<%7zo%5MgaXRj0v)lD5mAM!0?djTR%&>)> zq2uPG8@HB;w;lKy(foa%$o6#xb%ntOSL4EubVd6;K9{7>5U{@S&_TB)c~Peq9a*#` zUzy4JuL{?^Ka2Cvv+7)PaLUom<4`fKa{RdU`h#tsw>(e3{$;|i_-kM0C?EX&UsQzo z$M3lI>H9zH-6_85%C~R6^IV29+jR`d_EFi3wkDMSoE7zPvZ$G7juo3J)3qnLrAuY^ zNUr&Rc;27rcPrQ#4sbu+!GBClxXWBR+Nlx?d49$|vOpQ$Mdn}C3-ooPSmF3VB$ZhNK!7e{qJhfIO{=eT({(r{3 zO{t<&%=gqPH*>Q(L{BhTVB>mX+K!eJ9PKQIo!usE(yymCrmZ!-Rp0HkHQa!!r;Wvs z(f31o{5<36(=HN*kLK4ZzpJ~h{b5(3L-EGhiwYe+`)+@5Z~b0D{r~0ihkw_9NUT|L z_Qcv<77Jec9&nWBaAIT%6J@+P@8+`ZkR}DitFs(!Qwxs1oUfeDT#{_~`pErg^%$86 z($SN@RKGuVw*EnA#amwYf~%@hx&`qT2P3)v&)1!>y1_t_!)@R4E6?+^qgP87Z+$*# zrNi+n%`J_Ny3yK#bIwlmey>+h^pa5fdR?#1yKA76Ip@27 z@A%6V^L>Af^19#ac7MFN|4aNw@BVoQP9AgX{?t>j;P&r>Us!ln-h0z@U~ZiE#gZb1 zu9d=!X_ov92eOX5vVM1<^#8xdKXL*?)w)xyby-3++&lV<+!!q+6v}uTrdb!-7EP00 z-?8x;i`#$otvj5GmUbo;mWKZfSJ?A2wc_N{)fQC;ua%gFRZq|U)wTbdfZVQc272{^ z{lcdA(+>AaX6+38BGK*}aeLA~HW8`Cf1G_*M9(eNHe-@r9V{JM$HWlkP@%~e#SkZO z+}*Lla+?psfy@gFIjrV7NbC-{Ceb0h;p$a~Q+iw6|JG!iwbli_HeA?ZcQf?$>PJf# zwr<2wR z*A@FU8hm?IH$8{#i3*3#JRSEXF)vQ*9awnMK_Y4O59_C@Q;)>J{Gde)Hiib+^IB|+t-e*GC6qcjIBLm zaKjFb1u6HrjkdbD`f3=kIo=V;zMLWD{cE#-Nrbw|%s1H@ZYjzP?!BG~N1jy8h^=n_ z?lM_n&queIpV#;EA9xwE0;ZrcQNW!74Ry3;j4c*wEkgl#fxLy zJ~Lyx_Do=A*fL##`S&t8k%XzWS0olQFwutwf`{Hk2@TT@z7a|3*otGC5DgsYmDVUt-+@XM`bfE$QUp@ zyDJ@bca^Eh=4&PY{Nl3(>OV*q{=dGTr~Hy?MSgstXx<<5_lMs9TrPj4y8g-a51kWl zR=qp&HOp|PWqD)F&%GR5%u?;99G}V)ptd2zXUgZR^8cnq7ySPJx6o$cGTu-(fmc`5 zf?swhTy8mWjHy*?%be#o9xdK8k&B&ai`d#1AzvnMxzzgV*T19P8V6@Iy<*+w!Lk2w zUvd23;{l(N4(?i(Ymyb>SpTZ-&Oi6KX7)$j6HK>0tt$xcQ7ZcA6|})5A)j|41N)g~ z#>8AE(|dm5H8YOoNqN-AFh7{d%t1{t$vAr_h{=S zO**whdi6T)s;x0Wh1=W=8dO+YW~`OEZEKuwoG89sdG~g2$7L6_?-dE2Pd3Vu-PpOW zaMMPI6|SKYD#6mM5_f+*V@mX|b~N26_ep>=QARMiU~gNCgWcxxH%bjjSL!a8oyeG# zpwl?%Pj=y_r1NuF{v3aQ{Ph0|*&nw0?%0xhw|DwO)&GB@Kklx7^0(r<{VxW&+?XwE zFSD;uW1H}`kKHiff?G(~szWP-%G2Mvtt)j3aAmyp`$)Hig7ATCWt0D(%^&pg@8ww^ zGfnE7Yk=Vb+XJk>_oqn=K8oSTkC^)d|g{{t0&PnW1a~HX|#$EkqcmC6bIX_E|O3kYO$X9dtZTN#%%?wMt zxBW<%@i1w=%ZE>lX(p=M)|t+V%-!)^OXlb9{G-m-V;_lLn%Z$u{=Zbs-P-WSyWan{ z-1Gd3nwo3*;*SSw*6M}z=6h8->dZ^Izw@U1?ND8fH4+`o^A0~Y4SzgM?ZI>t=gs}q z!EFMHA{SqHT*#R$;NuagTc&hl$~wdEYu8O$yH#=X%b3Cq5&?VO&%biqakrjVA}^b^ z)W_`juITgE=c#M2V))CEv6j){Zri+X=5mKt*MC1<_`b39MO5BS>FO0?_Tq^y^Q*3W ztUE0t+x5*dvd(1lvs5OlDQg9Ks&Z;1S$|yg4(ESxl$YU(GQ)EjqqR&+QlK>6$_v-;c9uzHnK8Jv+@*TH>PARPU?WuBRA;wv^3KJ)p8S66_}Sf68k9GGBjqPP}WU6uZg&?^S!Ao_oCG)&H-bE7ptLnsv8Y@m2bx zRcB94H8S3A&~U_oVZ$!lM}037uO=CB6-ly&x(H7__&u&S`TzaxkJet7IrLFH;DEyo zZHwJ9C2Mc2H|lJh!aLz)PBo{S=M#}#({!u-PCi||>gK(J60vTrTfOWn_nT-oPP)xn zBrvV!%Ke}>yS^O^Gc_o9y>=BlH&aCBf&X9L{`l4Wq<+J;7YcvxPh&LLeQnonm+0ed zrK{i0Eq`=NzQTClx1fywx88pI^}678eT{hE|A+IBoVWWGYV+QyoryvDmR8>lPlk}< z*T&X&XWHnk5&G}P{=mkNfvkjko_;l)PvH$<~$F)k5e{Cr6DI3{a;wg&Smfj)IBDU&0>wRjNvk+zlvaFg>0d3Fs?quJ zz>d|@-^1mOZ?rzQanmQ;@W;!d`yPE*KL6MI`e)cPaL~GaktM#VoXO9%G@czyDhVw!~=U{()k#E^y z=JeVBeq(;sSMO=sA1XG=3Ny^E?DxGg_k}}f>H^NHf}Dl_*PAJHD)74fy>KVgT=zv(i^R|0 zYhEnh5an8H`M_mi&cem{qFU;rr|Mp>NMCq!bA#n~{ZFstCUd^bTv%ysbmqxa$Lp3J z-dXW4g!pdv@VtCsx%ZJyY2S%Sg2LvHjh^q?V-ofBzG$@WPyOVNHFu`2uHGEpes+iP zkKON%o<3jtKVIQO`5vkAps60Fk8FQkV+(8%SNRsLFg9e&1`!OHWSzpQEvQMK@#E`a4o@>yN(t zH8W?@;U%VK?HZ4|EZ^QpOJ-~k5LR3lWWUKhVS$6^KmJ~WUmMFGMaQi;S#SAnKil@d z-T6mvGbM+(IZje?6E@luIHQFl-9zA~x1rL}juY=SV?;PJj=1eSn&BmV@uup->UW3d z*8lXaxXo?)Q+;mm(^cBNT(f@WNA`uMi*DO_Ddoj2NTye_?m$&#~A1RsZ<=HZSnz|Nh!6`>p>zx$XaN zvVTaZVC;9aHac{Rzux%XzqZ$fOXDj8UZ=k=nX@bW>%8i`vav)SBk%f>`-}_)>-r{h^TaaU z{9srzr}G8JW1sn*7EgnIGk8}^N$D1<79ZCsaK6b|F;(T(`IFjJrHgJbGXMM2dCKOy zoX3VG{<<7Tjbs_V{xx5^%eQvEWor0AwM{A8d*p&|OleBl``+<)q3%NC4|dl!_!t+s zIVV)KMmeq){CL)Uj(pUQ=)7a6HE&glF0^~KzJ03p@@w(l3TA=MX6<T@LE}bT>V9 zWUlh}PR^B_0eVL>x&K6NROdBWAu;76n=SXTV}5Q6&ML@8f3h!KxZiEntmz1@e#X|(Q7hu4taop5Q<_djly`*k%9l1KYC+<&8b0*jZ`^*bgy;Gmi8EKV z*B|Qr|3mhNRp6131N!U}rKB0fr^*N@)LZUQVw-2uW%n|_ib0o4Sw-Q`%HJQm>`U$U z{cDwRcx}eyJ$LGw#T}_P9ZQT)S-biEjJT0{Q239)uX#rUn$O?)?{i#)L$u+c-O6u) zvd+DGU2`O(6t9*}kWlV@#vd|| z7CCBM4*K=+*2blItAcqh{*hcE{^NCA3tLK+qoR^ooJ+=4HwK|yoaQonY^O@pd^fcy zyK2=pxBS#DzML0k*}wDaboZrgtqk<{zPZoyR>RAG%4ZE0bnA!j*kNHfWr>hb@89P! zRe!IqzF+v2`>>fj1H<9uJr;#oj7Q!$h6+xZ;KklBXOX2x(e!tJBnr-^>LuyLCWJ>Y z$UQQQd~p2U`}3-aJm*AK1jOl1{XXl*)+3+KKRLOm%=Y!%gW64Ht9NEE)mY2x*tIZk zZM%2k;}5T%*jsGvK5f$YX6pYJ4eT=u|1+0Ap7xb(_48y++1P^%?^m+tCA_@Eq36)r zweWnG9Loi!7!Ovq1@nDxTNOCm>P>m9+rXLL3Ykeg`9(e2esrT>g>#y(aCJMATpt$>Olif0{6+L!ykUfHO~Hj zBKyNJac73Tst;#PuQ_S?>o()&4_x7krJSC+pE$5}U*7WshqG)P#SYx-8mwM@|9nZS z+Uoheg3@y)+yXWKnJca?yx6nk(!q_<4zC`(xV|kfGVyNx{SxlDPt)a&%#P!5TDsfz zn)I4K7t9~)UY9$VmC9P-{j`3+#Q% zizn>R_hc$gvdE*hQ)Yf`tbY7Ycl%?}=f$#n zU+e9$*=T)BX%!m_?>U}=uaO5b-s%e6y3qTByWp6!_uq;QeQN`&+GW2iR8{|9J=Kw$ z$*a^p+T?(bafVd}qXtu-qGEy5j0*XATRM_mMcjP+-Y;$tkoh`aN+DpGWrTwQNBu9} zimS}#dkR?oe=GX4?RLTU@_W6r9pzrEY;@)1@bpz~JmJ{5&q`?1Mh2&#BP{GQ=4k!u zHGlYO{|~hq+soW{+;5y%vF>sa8;8a$m&{@n+rwZTbxQmMtHn^Y$??ep@PjVAX!UzPF36TfTmC%Q@obYyOA56XR<>zWrz_ z^YeH9;n_{du^#=S7#f2S+h#JxeK3OOVh` zDq8r!<*&AOQrl5s)t~xnI6h6RUnhNZ-P|Lc2l!*k1w{l?HuC;G{C)0yiEl!4M2w<8 zil;Mf5#6yqpzGYyv<%?|Zx?*(@3q|jM^L7NVc!a|;38fIR>kTKs@5SsoDT0MW$4&y zZg!cq$N!XF)Gbv#uH`8w)>?5a@Lj5ZYu76K+d8lOzU~fcSDkO`m3h<1pw#u|(N#B& z%`Bbz#%JCZZ*^;bC%5aGefwWXhkmFPm$lu$D0ux2F7rF3 zGVXHKZ09x0x2U8xu2Ryt{YG}_t0+;cweAvu_vO#1W!>1U@+&UR>)oC1rXNDpQ-8-j zzMy?|mBovIYdeDbz3Ynt42&|Q9FKE;>1pU^>d3hzW!Y$ayW^O{w2mwj)wl@NTVEJ! zrX+XQO<5d#ow?z|f!-aTcE|OypTBN=*JDP_KTDh6t-%%Vu82&Y9PHzLsv@Q3;zYw^ z*JX~SUY9-iRQuzfFK7S1y#CnteYMc}x;K(G{{N0_3AlgdhPQE(lgaYucjrHy`a0sL zbbR~zYQr-k%i?sStre#H($ZNd{4m-sX5HKc&niQ!^_}MJaj^(sf8F`aKk0b+o!;ra zhaB&0yu0?x)EzTAiwbm7o)|R#J81rxcY5!6{?q;zy81Il1+Fgs%Tv%Kq`x{*Q7sB@bWCyc%%B=*`Sm7H@41 zYfSIm_;1?uhp*y)@XRZ5bk=Y@nKb{CX-C7f2DS;NvkMO0eCCp+88vm|owdi?G*0f1 zl4j|8u(et7dtC2gecO_M$6elCtGidldM&>6YGc8xZ8;`Qlboh1%u*ClNLsAy%BUzk zF;Bsv=-`=z%aarsSTzo>`Y*lu!dA{NA2<#yv!6UUV2wNjgZ{p)N8j}=R{e4C#q1O7 ze$02i8egRM`~IB_;g}f)74K%`+O1PReu|+xqTzN}qsvT>1^0fhUy*UgF03a-j3=V( z!fG|M6YKc=m!vI`c9s_|kA&yU^jk7=JX zpF5M`*She>>+Ju3u8423T;}k==iP~#5apdxJ8oIbmOJ?6Nffu8!rTbn-SZk6ExvxX zlHcOKj%kC{j1N!j&Hl|frr?>7+4xxbxA>w5o=nF%__m2TtF;_%G4Q-Lf1d19@%O3f zZ#5gV*6}lbuu%ASw*AAj6VI8k#GM0?LQ>XhO+LS^LUZU=H(S-oiF%nh40n; zMOc$!S{S>69-r9KNT(PRZ_?(aC zcY3DFx(MxllN_q?uz`7lZh2`z`M*2NJ07ol{Jd;Uinho8f37v(?CXW*|Geq{Xytl8 zgS#ii9URT&58t|-e|&5HUe4`z>R8v=O?Nu7|9j|;=lgB?)a&1JTWpNxlRulxXQ+3A z>rd|X$IJeI%CvcEIQdtKR%q~B_M0K94vxWJ1shm$w|uUw-RND#m~rY})62EfzOHz^ zqixRcAcpE_b&WFYKehNiF??5hC{C&U#OkO)M@U;F5j#fGyYkwu@9VNKWeq@ z3wXTg{M2t^OlA)j_$z(AV;~{Lpj)0HtN44T^whcGMkP)U7A3sB9j3kfltMtr^55N^ zk9P`Hsh3Icx+JK6c*Sv6dfo9{@kbMD|Mu=EK2wqM$T&;UpyfL8g98wcj8A_@r;wx19My8u8uq-^jHqSk!%gOGu&b*&Dj4NJURxfzey}aO;w)70X zqgAhLHx!6{(#!a2WgWHhMYx0Lmx;?i?1@TnxZ6@>$t8y;K3)H}SM0L;yhGx3UpZ~Q?tEVOt9$u|nV(wM&beH$sN8gt z@5PO~+w7O0&r9NRxZ>Vm`$=Tp-+A*7^`GCeB#A$1Z{4=_e!Y2IYw?Lc zzfQV;+_?XHUCo=*%MCTp_h~J=sua(_FyFE<`2T~yKi2O5Z)a2W-n-!D{2lV;`yX1} zDbl;OhNj?DzhYy5rOm|FucP-0oLD%dM(~Yejs! zxWqI|qU>4IyC$vtGyC5JL+9k=+>V7CoKJRXe`$MNaQSGC^ZsYAo;F8EF5Lf>XWy55 z>K|JD>qO7nR|-ilU-o>@l%3_jUvAs{#+-Euhm)SZ7^kMTP^P%Owx9}=lAYAdH9NgL z6|Oj(OW}yh5MF50q_CUsz=?nVCT`ryGA}1adx4C8ZKA1dvzN- zWpzuhOp_FNYj*Zb{=-l2A8cN9mUE)^p`cAILauKLBBdBg*GJkGi}55G8XjPqAo}`i zd&K?K(-Iu7rX_DSX7Gr)akT2CU+qWLVN8AXShh$miP9spPch*L=aFqHnt=r(NgEh}rUd+GC49 zx#bVHK7aJ-+?E@E+~p7a{{JxlfuX4Xuiwuv*4ybzUiGp$DdgSZ^zUf~r_NB5 z%DnvF<;6c<^w)9BuX`k7#x>(osP*bLo!&`hFy<*lX`WTsqy<;S2w(V z(7e0lbVDdBpO^k`%X>A<>mrtEL<>EUuNRU3A795hzoKmO>koS(+`M0jZ3|heADC8i zz0BqB@780dE?f>iQ7w1;^6o6w6*n3e%zGK0BhVHc6SKNiq~!h8-{H3Z*QS5?Q*GbB z{+#c>*E644$DX#Ss+zmy(gNM%ldtUmC-6>o{^G!;`~Othe6#-v4*1`D0#l*`rgv*<1P_p0cmv&->Zl|KM`< zHKm;g_Do)^9r6E`bm7(A^2etuF=lDK4Nx#)+Om+@_UO(2H9QMVHs5#9>(jX+?kDxb zWtq|x-D|)9pPK&Q#Mbunv4)A93;6C9{VY2FzB&Go_`h$}9}n`|Jw0&uXqx@@TYjb1 zN?FzZ3r_GVuU67l{8{b9bMleBvH&j|OHWLoqFTmj0T&KV6TuRdfUba30bwtWalY_k zN@{teA(9<1=SU;lfrE|QX6)r@ex{|T{zq+L@c8}FPu9!VSoDk0ybBTxZ#b74Gs;LV zYl-4Z^K_KfTKa3BlzWOo%a5;xjDK&0@LCDXKKb(hhWg-^*atifMp^uRZ-dy<8}1ca z$JHs$zrFolHVu=6!`y2Rkz5iU%Rqpuk{C-1ttl1%{bB?Xw z;_D>#fBb&G{r)U3Nw!N{12}xdoop6}H@+6H5V#;@@?wth`ck83F^j{t)UwT}{1Bt{ zG4NG$$+u}TD;tCx_=^7jmD?WMr+oTXtorgBfBwxsUS0RF(5Coe;ur2o|8-9mZ2WvD z{12n|AM4PrKEXZCpVk<6y=8fuc_Hd2^P+d@r|&OY>L)AVcX`6AJ(&{{+78}78~e4g zV!grTNyaIU4L$7zWGW=0)T%htG&)3pnLFt-Ky>nZ`0$Bwcg{7?@+Yl4^CEw*xcvSVZ=YXy);YiCd{vOv#~I&3>XSM&S{MYAIXwhCip&^R zB(r+3EKxntq2P0kNr2Dsho$!qb3W%|>WnhY=PG6|U--lDLC~}mn;A?6on>pYI8#=d z=Xr%H#h)#AwZFH%+P_vs_Q-$Un;{GgQ!30p#wQth8h7_u88qw_ZP+<0&f(hO`>J2< z)~Rmznp3)JT}a^0QysQ%tN*_|d;RgL*L^zjiT6(z{(idS*}c_;A)-@ugg?|(XuTzI zBu{4crv81Le>vpmU(7SE@qM##w&-iWo-~g$&kr|10_Bf#g{l%}2EV zJ04u?&nVy+!J8n+a+c%d@89lq{CoWM17809{=4k4p-k0>Ur$&6`+rOL$CvGYqyM~F z?)M<%VuQMalv|(L_lcnsmUYF%os82AeeZi!AhYf7?zV|5Gk$74S?vFK=ly^AAA}`ghNosstacwQN&QF?oGHxbgMlSI(y|9+;7`&x`S{flJul8Sc zLaK*4Q^ebeZ4(!>uG+aLs{ZPGS5s?OlZeY~S|zV{d4FG3x>sl8LEV$~yb*j)>hGUV ze`f#f`|E|ru7uy({^sB+)kppN%YOc{JMX*U}o#&puz3TSW zpG{A;E!&nW_)VwtjV0p*Gv)gK`Vap8pa1=V_-4K7Rkc64uI>MQFSo=z<@b|#PVpSR zGq2Yl_f1*5a7vAjiAYFG!%8V;o_^t1h7DZx_a80uNNp+c{1^Lme@{T&hqmtM`A?Q5 zpJsku|Hb^{!EVE!@l)oWd-LU#TXwvVpV>A;pz*ZFg{_T%e+A|vAWNZ9tC ze&O);;Ot3%)YrNF3|PL@p~~*&DI4txVznX(IS#qoG8tG|IJoknH>kvyGwzuw*i=^B zm3ubrGKYpkfTZ9{2hP7Ie;1zL)cU{f=jVzyyX%Sin+mDzM?^qQ8Ce$9GTXWm;n@@%Q8J0_m%Nw{*w(t^T|3`oncsRsD4; zesI?OD*p#c>+=q4N~xF%p3pg=t7G_H=-;FZ+LM^pMm>*>j$4&6)nM-Lpe+LbBM*r* zg*8sQd62Q*>}&B&0q!TSpF6pRWXW+9O!)UcJytJu|G(z=-mP&Pdizf9n^Z3wlRovx z?gi288_w2Vd%^+3Cg^ zEZ%?a$}iDXP`Q2n>OO_4R-rDQ#($^#9~jqvwyoH%Q~BfZ`&!kwebXn(H&47M{$@uF zYrFg&!Q)fc8V3Kw&N}a_ z+#HuD{kXpB|2vs0&yE+ZEQs6L_VMracm6lOzBIP@3R)^(m{j~OODzM=B=GpJu`B3>X%sibr&bqSFgMC_1}Eui^ptVGI3;toYyn2<>HF_so&Ee za6q}?`jr*`gQon=W@E7waQR!s%OuBoggrkZZ|kqa@rR!Of8qaluTpKRm6flL?FHru zD?{eqoD{68lJP%ib((sbcao&cU)okAx|LaNk$EB%p=cDtFtqsVY ztf@26b^VD4+wbj(X*?5O$u$4h`+BDRCV@+=Hoj!aaP@0hbJh0hq$klz#;FTZ4^3fO z_GG?t0+$o_+BivF4qc{YZ|uGA7_*6Fgz|U0|nbJ73pG)pV~eB{u&*(jS|eZYuhTdVjn9{?OTyiRP8PkN>?*4?A(cPT#J=^Tzpwd0eHB z>StWMUvS&8VM~sg-xYhQ+ioE`?*!Oc{C@3|6KoMu;rvo^_y7C_yv~P2q|`5zGatyb z?@wR2qqckQ`}GeP7%VIb^h_C6W#uO)h2JsQ5g73J)b~c6hgmE#oD1trlbo^~|EAkA z^76!q{@x>T=Th*F9y5lw(c%xLzBanOFV8zckF(CRZK?UItG{DE-8Yo^Q?+M%w~)jA zTK;!8^6y{qw`)6YX8h#2U9*4U{KdEa9k{z=XIaL|Ex85mg~?w$^ULqv-4nHS{_9&N z6=k+1PTkLEPkp4+;A#@Yy5!`!C1?3Q%s8Rk`ry3QkFD&x|FJMLOJzrLvdR=H%763Z z{PR}-IB)y38>{P*nlAr8Tz{}Wd$aogJD06JcdM8hD`pn#T3(S5KFPP<@Pg3I@3s?t z-}o@5{;!Q=)KWMwQ}dGiZ^=K0PQLuduqO&!9k%a`597~Df2Vo&PB8a8-3C9ofbA^audWK01^T!ep6${+)os9<_-OsRzi;fd zyH;-6$hyE*;eze#UppmcCucjvH8RX#W#sGQF8R}b|4_2hbdOK^+^hbpZoKOFDc|-f z6Fax!glV=f%YQqcvk9u7sJ>|5G-J)6)z>cHQk}WfltGr;J@6RQ&!0MTb`**!?|7B6 zH+$*IbK)AxGW`4#7%e>-j=BX_@yJ#SaUOC03oX7K`uvBTfkAJtBEwx4#pUPJ-_QFjSm^RZG-bWg^dlSh*sR>L zWb!Y&l4*VdOT{FXELB{5$KN1=^}>p?tHVl?beFd8?`clHwC?}>lk3~H7uvHr30pfX zmS&E45u3l8wfyb#DYtw6?62pT&)87^iLu7${p5Myb+LB*3bL# z>-|(d7KSIq=O3Q=|1JCU+CO_f``y{{IBV*ws#MA-j&@hRQ@zh%OHYqREm3H^I>-v3#R!P(+G zE#aqaYj5lBcy;5{rvne-tC{BibC*AoeE7+HudDZ0|5$a7;YH*9Ma&bd;u9DgPj8r? zoUXKTv&Emv&Z;IMZV8nO_Mbmj{J7~mC+b?MZGr5%_)c-Pd-Z9L->iRps#K=(ap{gE z?=ws0wgnYDOV?O)zE6}ZU8Lx|l!op9(oglbpIVeWwli7ut+@92?~3=&KeT#l+`K3` z+mbz}XNBAEh3Tvv>i-2ecK)hgd_r3F= zQwRg|PKJb)P2~y>jybju)7Sr)zN)}=;(MMd^J_;PC*Cj9URV2a;on~KUsv@X6#LIT z#KItF-`IPY{#|7@z)yJq&Xvx=-gbdZ}8)s_VnxT zf3A){u)OZoiznzQPP$D-YG3 zKT&VaarshOz`RVRNjG>N-l=kkt6H)9RmMt}w&q{|TdmB5)ckGw&w5Mjz25cfzUmp( zi!I@+qx`SzJ|Z7?_r3Oso2&nRi+d3rwcc+y75x zpTSw<;49z5Om;HszkYPFOE)TMS;|Mn^4x`2yA$vGJFHm!PxU8zW8L-G9WQgbW4KVSTlbsO!A(NF31Uqz874RsHEl9n*24IZ!}8@V z4U@J2?uVfqrWcest+*yk4%YDcUtjx2La&nX05ih_Ay$U2DRqVkr9zRbmPIi$zEY}s zx>RgD{zm*)d4_*q-9McC_4@zok$C4|sCGzcKk2il` z_%y`)a#+iiK6Nn{i@?0KfBxK!KfHdm-g@2#sne5lch{Z29^0Or&3o+hkLviF2UhO? zU-##lPts!B(rqjk)~f3%te+&f?Be^SOmEo|XEk^-sY%W-Y2jq|VM=sr;(VaL&QAPy zT!4?|js2Nd>-<;G$YXdbx!`Ql`DI_;7*#vWiaLt0O1XKlIafS+Qm14%u0ilCrkO))Ec-u6As>E}-H0tY_6eW}z)> zUWy&It3OktclC!&T++o zz1^qn`8q!5Xna++U|-xYRjXiv$fBE3Of1Kk7BHOT>Xmfj zT~cUvphID$=`Z{AjFb~@+_gU{A57#FX)-EPrC-)`GpsuEZ*Si-dEGlbSBx3CzQ(hfC96yiH9uyO)fQZQdtHRw z-QP^Co1@N8RBzhWkas0l?S<%?mF)7HLwXo^;`o?UOt(!8JhOB0s_yKV4y`M#jK34A zb}BWzV!pjy<-fx3+kv|u7AvLdmD?+YE|zd`xVr!KB!;)H2m017*!hn`FaB6&dgGt` z{eAr5@~ zEyZU)b|{Hd|Nl$*$A?XFXZMEdpTCy;K6r{HCJ+|ob-p_HXu~#}et=qBG!fi`N|CEjOZxT{g z{`>vIzAqrlhv5TrvPPKJ?uwj+GhQ%!H^e(ORP1bwZkyv?v=|2+E={dZiyL+1Yb=)RrnV+Vu% z=aqju8T*tUmEY}+UXh-^;nK99Z?+tr82EoqyNB6{8~oxqOb(_e7nU%J2rN+f6Xo{b zblG2lH$S7N?$64681**1w)e;PyuCG*CddB&;Iz4>y84o4sQ1b(3;)|Ndh~Ms+QOrb)5a@lTSGv-ah$|zqiJc(J|}< zXqsi7%X$qPqt)zYKaLH|9sjPn zfA}%;T9m-|>X`Z`hg9qT|9kv9hk1vr{^uDCv3H(0H0;XwG{Nwu>fh2w@yE9@{9kAN z(a7qwiJ!=?_jZMuj12sG8ykw^9D{ixLhk*SZ}(B&UbG~Dg~M=4)7zE3O#9qCvT|b* zgfwz@{i}tQj zo5}y}Zn~%&Y@f4Z`E}h7tMZ@4{k?Zy^T(d=4h{(~C3ugqeURL6v6RP((fQRDh8Mc# zkH7t$^IS#wue|)R^yjtj=hq6%_cPObx2FGoo%FjGGh{-iM*n`;{aIFOcScfShTZX6 zwV&6E%?=1wUt|hk%rs?Oa3<-(lk0(MtO4;K-?RMBiV3g!@M3BEbm1d8Dn9jp0&5mN zU*HhsV3c!5%;tsg`Wxx4w*r=!DW)!-xjp9mvhU(GUu4)9|KFbUU(dQ@!QQCyb65WE z6`I4$^4DDc@Z(C)qT=iEZOXT6|KB=%yS{$=@yF*M&Q$kHWZpWhX|v`g)hn((7bn%b z?LL`d-pqQEXUnXMY3py5bM8;DVpWrtoV@MeGq%J{3vbvk`vt~&E%!b`X%3#>bxbm%YJ+9V0kPy zYjQ^Tl+d+Wp|935BzSXORSz-AatfQ>wTJVGvFnaW6I>=rulATe-(M>&-Ri)_n-(gY zJ<}MsZRnYKH_G>vM94pLhD_ZT)5IoBTT@^DjCJ*n{D@NjBwn>C#oxjjtot5+*d%wp zzE)=b%L57NXCyH)8|5i0;j#B*#@AUII)rU)EefO`vS8lkU`3C7eEUt=An0<&WEMAG?OT>V!;z!>*|px3g=Q zZ24B3epTb<62YZnUOZ3PnAZPGX7)Piz~G)Hs@^d3-~ZM8%NS&2#67tk=Dx^v?>$sD zWrdXDRMx2}?+xQ`RbGF?`dI&4g4t*=*M7bx^)Q#o;m6&}4J!Xn{w-X$^P556-mhsfrjMs&DJ-a)+OoeaC%Il&u*$S{@5S9{@TnK zr}*4&^I|f6&$unPkZyj#?%$iDD*O0d`)ViDeEn6_^rF9BGJcijerdT2>F>0vf`led zWIfo)s<|wVLEs5f!_p-ARdV%*T#VHg=R`3)6=gO%{d{qbeRB_|RL-O%V>V{NCSwMZ z-zp5QU5|p<_xy^NkTQPIqpsY!pmOHpOK~m7Q{+Vt6vdQswC{ia^*3|5K9_;R>VsiV zbKk98{X}V@tAzeRq5sA&G+xYPx|Ft|rO3%6{ZG1-Y1U4mP1C}fs(ioy4!)&o#*m?! zH{;?>liZ{K;=XXzvvcyZGtLrNq~+NB_3-O9^`j@>dfw$KxP0+x!}L`L&eux$Ppgj4 z5ns3|_S`xN#>&f+ziY64Idt`3_ucn3ju)j0moZG<$ack%VUs5RGv=5%J%9G)hA^D{ zy?@7^lj7?w_RXm-Gdq6%{bJq+OWO;+n1(;jFnyY#ws85XhJgR>hv#du>a*UhV97R* zulx4pfBc^}H{;LWlu5t!_qIRNi}U*#*JtXv>Ndps@CdAusQT*uwddfQHA2o8nM4+J z*fKLP^r6zjB(DXJ{;(Cw71Tjf~*vjKC_s$^S3u z9p0?WbNarY-R*r145G>l-v3`-HzhIH*XG5f`t=j)f6x6b{{MHl{JnY4{@2%){5N@f z|96ov^P2e&mE5---T3m1@qUAo|I6O8g@pboKF^||aG=FOkzr}8<7p4al_tD5E+_ww ze#};As<*u9W9Rw@r#~Fw3zOX3aGp8Dbk}l|xSyPQcUYExU9C{Kp|FVeyZ$t-7s0C; z1T8rduEYjz`WUBCn`vb%9<%Vm{a;%9_Vm2olf$~2yHT2H?XjmE40WGa{2boaZF@8& zt<=GF%_6?mo-3=SYI!^nHIhGX{xpZNB*90}ptCDe#mDK0ijkSJfnvfX12gIWR$=jz z>t!Wpt3Kq9`^*1N%5^pm!`23ySsaN{CmH5U?3)_sz$<9$@$Y-_oGioGWAA_V?f&>` z`~9+qzh+K<#=m!#>{aa#Nn4V>T={Oa&&xyMk*=qs0^mS261& zV*BKdY!;qV^@qibOF}05c1+Ini=CTya3uS9uev^mOpa);8R}C&t~zt zIPXxr>fgKFj`#m@-Kp4iXwjkz_5V9Rul`q`l9vp1szVQFP;KH=t1=4$Gs%%>Pn?H^_Gmu$1 zQsbGo?Emn(?c145vKGG$J9_M{t9<448{dCT?>y2PXnQ?$^^^Gy=JH1$?vCHI-|nKn zZFhRzQ%;-ix1!DS{~hw2bL0H|%DbgIKHhSEH{tohTYR%7eB=BdbKFe*=km`cAM^VH zmmT7GF{ymrxwF@EIsSgX`jx>!q4$_l0PDn(w>m5hdhw2%*L?neW%Iu`Cz{U)%}6qP zaCy?2s!#V>ix_pc+ldKeoKNxoBk6JH)_2j2qsJUonKrR`*Tp`xo_;Sp#4-Q1HE4Kl zXKKuI3#Cs?+Vv%@6Q^wn*jFL6{B;>uX`*Ym(RsV5i^~)v&eu0_&Wb$UvWT}=^>w<@ zt+PGfcnoH8EDJOUJ-9{DC+W+*e+RX>mKi27{lCfKqt@~=^Pq{aTDr*PPW$C-v8C(e zPG4;{{=38I>%G*MLfe=H9<1pGnd*w@9!vgI>%YP5Lmsfods#|BAu;KQ)W9~ED-a0saX6?Gd zvNl5aU}=5hVfX^%Q11sI|y{i%E(EZ=QY(){0U&eUK3r=R=oajCyPwf@oNuOGkN zE_iNVV;xuRxh+^%x!RYZGkP!Av}aG=^R}x_yBWWI|As%{;kaXbdtd#&##J!aS$ul{{}+V7utPF_;AE?j^oNbPxt-b#!{oTEM`CFlbpq~jaXMdFtIzO$s+xINz0Oh zc5DlHm+>dqKj%~v4m#&{_3Syl7fgx#W!(Z2BEOEDe5&Hja$zE?bkC;;*W`@Q%v@_&Dc)Meq>r==}_l$t-fHvflh#cAuj-3E^p3-xZUxMl3O z{ZCG&r>^1W7e`-DR_5#T_VrlleP-(n3#Z=R_jMkv=jQ5M_^UmORcwQcEQ5otQo+*l zP$p+9FEgvF54bkGPT*h5(2`~MMx$Z>$JF1i`E4&JeOhm~$@=lX^L8D__kX@x@zr9^ z-r(BbYj^yuuhO?uyrufzYJ1R@6Bl@SVk*-PU#xlV_;htR!3H@1g!h*SnoFjYF*!?XOJ`N7}m%(s@gIyp#ldTzQn>!Q;9&J#&f zy=6Ol-)1hkCH~|=5SuS^1apUo;y?R0e=BFC-VBuOTHmqv{N9SSz3Sbx zD^k%}ij6EnOwAJ}2qYx4F>kBCQPJ`|efIqMXOD0G$S{Nb7@K`g^TBNnP19P|L}oZ7 z^G~Xp_k3a1ig&NpDYjRC>#L}Dc~kstZ{_A?drY6NdtWni>eQ)~=XX|jL~Kmbb$ZZZ zkkm3^fkedp`E7DCf^Kb1%F61i-xP+mMEqLsdnO>cMQzRX|JRN7ec_lFeJE_k?oPB-_vN&o0aCBvYj{$#Fq#{0XCchBp}+_-i1>g1Pk z4|grUe0$#2;8)h$L`603d0nPYsSe@QGs3diuZbrhxB{4X+RC zE^1h$cPf-~PUVJmIqUgT-u`4$C^*mb;esQ(&FmjrU#&IU)?}=I<+jL?hJ<8x%}r^^ z#f5Js*Kz7eX$M?eCqFaf;2W(clNhodsQ3nC2pTD>vzaf;zH(cx%G+b-`=vdC4ZYcW zum9X(mcC?b-|KgWgzf8fFB!<(ss4V>yYQa*{3E${Z>>D7_#Y;N$2ERFB=zEd zebx7eOaCuUXS6AOdoz3U_I1ZAmp|ANUn#xsul!y{>;2y>=2bkX-1Fg!oR04En+AU0 ze$V78d2Zy^|L(}6!{_}QWcKNoxT??nvHjC}rX;qfC-*FU!nIRhr}SfCFyr*%hAscT z`Z}n8-YeKr%=m__fIlPD`#>o}LaofJkJuUu#0-%pDx zp6H4{Sk(NtHvP=wC*?6cYvp%|-~JU+uFq-z{&&IWvWCOlJ{MU+9wyaWhRn`oNn(n6 z>DVQ}@Y2~Wsh%yyW5HB0hKoI{Cx2!2hpuH|YFjn?q{wew)=fr6hpQaCKiy|Z@m#mY zw}I;bSKq-Ef6j#6Q)26GE%MoGUj9NWiD_lpR;R|Jk{ruq{Z`-dlj+}e#L0DGP8HWA zo(B_dIL(-7{JZ#zSf`uL&-C=b4+5S>LTo1#9i<#J9D-i!baEU$aiQ9mO{uf#IkQJ} zL55->M`NGJ~MW2?w*%qmz}!i`Sgti*UN1FUOm3yZEg93iL%}qwj5VE5A2=v zWv0i!l|lR4)z62$-2S(7wcNjXv8>mRPE=Ts^P5}vtL=Bc^zcnbeqC^g68*d1B3+>T z7jqQD`Mm4;g^mCH6*u%JpYf zB{yz1-sqM-FuCH^2dZX zvoM^hxagKsb$qG2pTZ_NZ_c;O)*s%O&pR|(eupss-Ho!lZQWjP_qxox*l@1KC#A{H zIW3viDCq6(N#A{`sq^66^+3ko{pH;6BDonD80%{F5?!jorw zqgIe5hjFREl$Fb8zpA*(etgl{$f-`yev9`%ICy`D z-1fUWWq0rTQc>R$vRd#KpXQ64^%@SFmbW&ZO$uOL@ZiY53)*j{cUd=Sr!ucDJa=sm z`xQUQTW{Ie!aNwGv_s#$`)@V5XUk<_0da1&F9Lh+N$r2PR$y!PafhQjKGZs{ovXB2 zLA@+i+%Zq2a=ZCt)~1f_JKk>kpMB70+Y);t3vMTc?AI^8$%yL;|ELTxcI;dH@g)0) z69<_Xp64CP4ByM4et)0rZUyZd6L$G$SupF{aUS-*#vu0ZXJXj@^~Zm|KagDiUTVwd zk0-Vj$(%A2zZkQVHG2J>rnU7?we~!E_x!_?_qD>?W6SyF-Zuo-FK?C-xbT#VXG6oL z&_9uLb0>4G;p|~+Q50ZVb2IrY!y$$xr}-3I7>rt@x6EOSEbn*Q1r zj@XCG^5Zqc+U6D?-14vd{Zp-J6?2vB8m8wR)SN!QQGET=qjz`8`rrF>rIB-O%~wO4 z{};F`%5?XC7mY4_$2z4E>9uB??84sige-i&2Q(Y*a%-*%njp663zSthLBHa&UI@jJh}tPMBZI&8h5o^7qev>Q#&TwR}C z6RI`*RWGzP?wOimYs(Su6KC17C10!y{Qt9{V_V{qv<@?aNd`fW<)2@<#mUAvv-$77 zr44(IoajC&B&2z*UzjP)WTBIv`^n#nzy7jnJ62l$xN~{=dA~ESGUR!c>wdHDc;bJr zyZqm)+aFe)buB-VK3C4%+{hqeb8z`byPp$$T-JyD_YU&inItBo z70dnBgl*S#)u-j*Tz9`djlB~T!4!H#q04aF0iT%`!MTV3XodCY&B#z#=5UK=V#6Hc z7N$)s%qGswSLU+TnPxSu7nARHW_elG%IV$w!xBlUsQ@{T% znfTIkt;)Nbrtf#kJeSk?Yp?yq|GkaTmwJX9wod=PG-qF)XLW2&P{GfaH-4?(&z!&a z9s7s5>n((5Z>g%^+0{5Tmpzc_Z20}f4B`tjeIHCXptZnb!HF<|3n>l_EEoQ8e92;% z`|wNTzmF%g&fTf2xuLO+nc?;059iG0F)*sx#&An4;Nz&+bU!vo_1~>-iGNSD3pLkH zeEX+yH(y@z-v_bTV$4hSmzlK$({R6)ELsI&VRI+w_`>`uv(LcNtXs|O4kJ2u%%*LC*B-X zR9z7fzd|H5^HsC$%N2WXT}(9;oAFrU$u}S7kY6fMySTzx*-jh~`rM{daL;qX(v~fg zC(cV>awFvAf2JalCu#hj43tc(C$K9o(fIaN<3wS@8SfMN8#;f#4S5pF`Du^R`;*t2 z1iZJ}e>rt~E4%g8Mak8x4P>1rto8e5-Tou=TFk}^Ejzm1BCB`JfBns%H*7(DLG}H^ z)r)!@9=zYnqW|~4eb4*q$CZzD_MLFJlGT6Ji#v6z%hHO-%QYIve$1}vOLJ*=P0_b&;8=XSmh`AcAdxhzy06; z^3SQi$@P!tzWTlIZ&PzQ|ITddc~v#y)B2x${(bg!;q{_6#hqI%wzJIR=Hs%EUchLGBB2G`gB=YE=tImOKeNv7P&hb0jJy^t2aL5S;%qn zU->D4Ef-$g_rJJ0|E6$K8&|_JFDvgwYp%XOzVS`I-MYeMK`+*-?s%^&-n!X#_nfu& zzb(18KX31LDYg@dl0wrI&k9s3oOmnq$;kPZB#=| z`x(c%@3wBZyv+B&>kVuVqbBY9yY_{A!q@nhFBqBYzDrlUn3taZJhx7#S66!D%m07l zYToznml2<07q-3P?YBLf-e2Xjue6^uyXWKP1>T#o*E-JP%Q%~||Ki7g^>eS7oS1qx z{^!1cxj9R7J678YYHL4u`rdeb&uNPaMbYYod-lzF8_DUo>HSm7s?!tZGs@)3d#`0w z@L8vrbNq@RdQ{liC;WAl<2>I8^_P?)I>VIn$Rd{ev@K_Fa z=AZxnu1$!QqxL&2~=L`Eb?Z;=u z)IuM2jXAgExsKZ3bd#3R@CZJ@sw&JRs){=2xlwE8!il;bYHjcG6s%ci zwM_`QrTR><@$Igeispt-*xK%Fi-_f{I?V8kx9!fH1>23EysKp9ui#j?klFa4q&VN1 zDRxghF1&Hv|2^upi&2B~!BYzUe=50lO!a;`-yx$XF!g&H)SXI;4_ID6t1&kwr& z@6&zvKA-Y_;>|7wA>YCcoV7;r5sp(G5*VX?YwiECKKs#Exli9#M9fdK*~b1wm;2y- z#>?vs-+oNp-G9Yty2P#}7gQVd#HY1|f@6mEUj7 zKl?-{DV>A)m{6MkuY=!8!~FjUM<{zRMNH~6W@ml9sdm0jlWJ3$TYy}{y*ah(a#$Xw z2>7u+oM*@@zQ&m0+VNiz+#CisPp=C$VF+8@X8hu}e*EmO^EJNk=N)9u{wcY?-1uGe z>VO;kF^%2fYkg!KrpEKTZr$@L`02^6J?fuM+LvAV&!Dnx-~THS5l=bovzdxrqxI^y z-bZ&yFU~I42hSKqPX5n-E#Dr%voK?Cms!E;{VA*d%IN;c zGcHM9S6B9`Rc!Zt)2DwI9=_Y0UCi4UE57)aFn7SUz`w7*H6+}hu2tLj?*3uBsLs9% zJDzI3`L%WZgI~g-F%voe)W>w^|MwP8*3Nk3vsxlxZ4+}t<=gKe8yMXceIjCgFZ0e& z)6i<4_E&hR*Szip_e2^tcQASFJ=RozE?Dc~&kH*X-fZSQ6ysL1iB);p-o+}vSyjq5 z7;gRhJfWCF@b~cn3vEK9Lmi4ES6n?Ww#s8to#F~ zt~JeKwXtV<%J$_?+6;jQk;41`?6uGe4b1H%^akJGNc_@Kw}vNh_?L)tI^HnY~inWaRF2=F0f2Qi5xk44f zY7CP9txbjBAKl>fZ@JsF%k`ns;dS=1zIohtsz0sh`~K&(h4Ha{>N{6$47vJXm)4u? zesAyI|9#H8>}51Es<`ET?v4KNRk-w*yW5ZF_uID5-Oc&=1@m<&mIrz9>^l@ESS{VU zXMfb$$yYh#4{y5j;={|xBbg?DgRXv8iC@)K&(3t>N49Y6&#lUP(%1G&_WIwe|4{Oa z+y9{C^f`42)6S>o9C&cbm~Y;{|1(Xu=*o0`Jn%W<$8P!K&!=6wb<4m1=|@}f$5ZQ{ zKHah5&HOz1EZcum%Ou1F6q+p?p2u=8$eqhL;f`#E6Z?ZDp0W+<>gUeK+iY`e0FOTi zuHIoN`uh??K>TE$hLiQ*Q@>9RUw`%&Kf}KleK%LEiYtG9tBz0bL-P6quD>Ur@OR$M z&zaz?om=wiRnqm%uWJ9u=$=e}`!a0R-{S%`#oPXcmrZCeZJv1WX?fQA2e&TA@UsdS zyiE^cwGo*g`8M;2PnfvKgl?EG(hk!5=w zcq%kBp5dgd_kzv$CdV`~7#-|c^))Kvn@_{vr0Kp}9`baeN;hRj^tqR}mtv&c( zwQi`I_{62-!us9PxB0g5HgHsYU&^lb>^pP#gEyuZ{y+79XJ_@||BQl#zh289Ts}YE z%5Wxw%K`fe+j~Xd)XtRWGHjb}bU0$!sdbDC9tb#Uak#d7NiGqTk-p4yfnicv4Eux9 z175NXx|eS;^x4Wn;_{zlc<9gL4GkyRZvL8e$*bTM!&&L`M_aQiEpLl&{C)WQgOibW zZvDMQXJ9}Y=MA`i5>$CYoGh;kI z9!*L0?3XXSDSAG?Ni|5L+H;QXBhUIRI|JwGcQ$=y`telv#ZKAC4T_pk(qLvYKvzOJ9&vg42L{r_e2k3a7&UUXbm_w8Oaz3Md2A6M_c_cBj7BemMk$NTlQ#*Bq$PKl;O?H2a` z@N18+L&hDe4+U;3RCREY49?R;1$~3c~6N+@yAd1br&CoC4bn=bnno@ZQlaV zO}^7}+@-zj`N6=eY#(~n`SW#x`y0pJ{(IfOKlQBQ^p^ZIll}TK)eRdD zerS)c`oJ#A$LYOqz0Z$jUU@HFEWbIfU|avH{oJvQuQziso@rX3>*Sa;QFfy40k;(m zZF_>=dlt?ZXeH=gQD>643S(R9Asr6lZW*{^!aN`t^TRT&xlu54t zpf1P$=*sngPft{Qv`!SW=4H;exD;6S>;KESH@?lYKX!cIFAF(4!TzKB|7q>ncIGfc zz=|g?UYRkReX#6OlxEIKKWkTQgGmmDJ6C;5Y29f#rES4)ZLtW>zrCSOJ?hC%2o+mG2E|yskNx`x8){>y8qL*uKe!b z_rJ>c-DFcHuJ`+La=)9NR#9EJd|KblH6okU{Quq54tM=J|EREmW5AE&DPK}~*PS?Z zjwPTV&ER)i?=Id40XL_IGG323w94&i`i=XYwm;852;h`pTRy?ljf3l+!?!@ACJv_l zH~*YJRDXN0_sjcD>cKL*Wy$!@@#w_$@f^uVtTsODGtLXe{YV@ycNdM z91|oQ+aB0#IX_)EX?Kr7QcKy*|9%{cduB*1jCBw(b$B?X#bCaGpHj26_572M|k5p7tp#x!J$n~V7Ul1AmV8c)8)oh)st zm~w7wds))Q67007xQW`G``vUyzbedIe&{^l}K!<+IBNmVou^=)+NE) zSe%$5PM)`Ldhu=AgXgh{hkw>x&d5)`V`#tZsN-=2rul#cHzoDzdhxj_q^1A!~54qbF>A$m@7!$^%{O3&ehoViJ=PXG0v}5%RhM>D# z0gw2uGj`mRXg_t&%7XvIiEDAk87#OD{FJ!PxMT0_^y`cbUpLzENpMW)%=>@!%D>lK zlFa!#8J5rA&AEK-)9dm3dHZd)343qev$T5t(L-y`%h;^tWze&g)vr1mY4J3pa(2+% z1!Dc7`~jWSrJO;c}~aX`uPeI6a;pzD@_y(VQNYa{djo#hM<=w zXWyLM)g;a4&Tc%V*GnzsVVg z{Cb!7ojjfD*F$Xm8KVo+4|JfA#{3(J?$@{%4Q$NM;Yt!0zX@l44V>+gStFmfyU1oZ3-N%r6XaBv8 zr{m3U+Zau2o@F~}n%#+2U6E%uFP@b7={bMmZ~i)l_=@B54-eNDXy@%Kww~uz^yhSb z-G9*?cO;Ec9^gz7KGsRBHW(m9=`_?e%=%ldUFkKyX|6%B=@mSq%CGj!dpSTPEl6ZSS%3?rJP);i~I}CViTx z>C3*aKkPuBShAtxzK;?btf8?20m34lj@|N!x{B-`^S^lBd z{I8td5AFDa;p^&U`rpW%H9Kc3b$sjdN0%51zHHu;!8qlI(W<$vC(oZ%HC1z1%Um#P zJKrBz&X292GsL{4Qd zy%PWL@AA0D@H6HTGG?iFuhv}r`+5H-qnJ(C;;SEOT6}G`@7R9kSDD}Z!^gJ%>yfXO z)Z6!w^G?g+h3&V4>%T`X^-G+3V9MGme@UMnhDI$%WoDNJ5qlQcD)=TbG|YHlUmdC% z@aAuHAnSqHBENQ($b~L$+t&(*Y3}{K^Yb)buM<)SoE>>s?mm8)q!18hV0ytxQ-90m z$s&r}i&*%-#QbKkRb9Jnt)hm7?CP9`s1!f#^F<9EfBtUSw}(L?yZeA3W0QeDN2frD zfO`WUv&mM6gbNytPR%|H%LQB71vnZ4^Tk&rB{fLJx+kE#^SckVXmmo(&bhWObVh4s zf?Kz(w?5{YaN2JAL9^{AZb~fOHB-c8bCkbT;(W`+h4;(e{G1+t;H}q=#fcYEmnT1N zoxQQ}vkY6!jK=N*3v@rvu+A1X+xN+5&yO>cH)LMh|9EP2p=0>_`AM8Bt~9Rg;tAa> z&U*9SqAt#WlQ*K0bFG%Ruiyz;_0Q@YhsHUUHFNzsx*YD_j^4U0`K`U2*WO9%V%sik zeN+xhZG`9% zMwOtgFJ+#eW0v2~-yc_Pre`tZfAz=A8$}1x&e(tX^XiuO?Dh`*TEV!Q4eQ?<^UKNo z`oHG;g}L4t_1|Atd??@F$3E5JdCs9bQ_~+jIr~}SOnY;(^P1E-X6+BBO*SZhDtV*m z<;#k|86V~LWj0LS_tq)r=NaZ3$Cmb**&CK_EjEwtW_ZbPh3{7&ndcQxn0|F@T?O5oksV(YBuZRll~WNS$8jbd;O|Awoh|;SQ?uCy*mBz zQso+6h6_O#Z_Mw_({I1`Qre*6uO<8Y=TXP5O)fZfZF1i{uWH+UZ);*IP6?Rr-%#`9 z*UpknN=;gU8|z*MP36g2mUpUU!G<{B_y5C;0v0T`stPp{Q0`EAHc6(s!CRGav6kk% z!W-8OoUY#X{rX(BQ;Pf7A2~?@!IoY@Ey*h`DJcolo&3Xu>$5g*lx^{OufW{M%9wM2 zec|$DQ`4PV=1FibSeQIz?$z^Cneq&(wC62I>a?$XYR{3xkiw!NEFcmQmheD|EAn7B zmt7{yLXM?8iVoS*4qObA{@QUqILdKNu);Zef4fjp+L1XI4V3#>Ca%40zof-OjHgiV zz4KI$>s8_xy#8dny%FAY^w66s!8Z?P?H6OdE;K`86;~?bGe*^SCRezF+ad*Bs@WRC zg?G+a8}_-UZ>!zoum#&~o9}|K`{CC=uYE4KWnW{vZr`Jx=J(piyxbcmud6dOyT6C` zyrZJj97)k*s}7!N2&s}%V4d>Y@@a0Un%?v(-M*E3-?isROT2z+{yT)ZHFWu$|4tY9 zSRNE!aM+@?dgc2`n_0@6cAqzJc=URs%G1`?We;R{UbM|+oxA3>8>0!c(}I-O>kZ^H zlg=f3r``9iT$!@XL| zb3eDPe{}EazUOztAG}eMvHAPo|Gm|(m;Zko%iZo=8fhHIUpA-KsO|r8>4K}}aUI@# z*JdW(0?mwmH~#+kWZ%KV2_H+ZykrQGa&hEQ$e+{b{A`_bR;OK=+l98qOh%vHBlo!< z@U6*aSi>))cf0%Jsr?~dySMuGO@15l_Q2ddRz`BZf8re!{=N^0VvUGcoNy!R%A5LM zq7|NpkDFJR%3u6*;`BAXqBEzsE-dt0eIa`5eI~JwD%0n1s%70-7_l!SVU08Ex&6d5LwsEfC zd_SYzrM`E9!%tqXxY(nx{H#{AQ*@MTh~Uxr{KkunlGIF&NTe+AJS`z`!612q#GCAH z$>!FEgGqAxPhA&EKCt#Lj{%#ffrrCnV=1Ot(*!+vc``&NHaD_l9q z8MpO?mh!c0O}aTh_2#nQXZx`B`P;eo{>zHX+5G*#E&aojjV}M~!;U@Pz2VPy`^NSE zueg6W`CM$H!Rq>PPoYwBK)FZFXd)w4r?BqDT|HS;vk#m+_V5b8+9eG5OjwLCy>pos;TLhY#9K zVsALou~sQ-XFjvXBbnRhmkNEkDmwo{`0VXD2k!I;`K~#pbo1)Dca?b=0<){uWrRC! zkF#;=aFEbS;^6pT8Q3ssq52D<3l0Vzx@=b#G$!p(Oy1CGcw>n~9#h+F$>v~@!)I^W z&0F+stM7piQ*z|ZJ5>K4k$BRf#(b6|Lu0X^)8dN^vP?-LEsbXr`LaImy6`f)`qWId zb5A3`Ub*k5y)dIGJCQMfm5HHILNIM>rjOLBN&k->G+EvgymgsY<6%FZfa{G7roH!f zFn<60D*R*U+>4Qw|1>R1--N7x9=mqx<82dG%r(`@Z7__T__y;1-%^7G6~*1*zjpVh z)!vH!=b6u}vD5#D=4@ey>ue9=<8#uLi?&tX`+m!dV*^L-w6ZN84|qitd|rI7U|f`9 zGAXzI<+-Mu-+dtt>Y@ymihO@`0yfUAKRPd1W)eq9kn!x#3I5e3T!(9uc=H&h&+QYR zyE*gMXUocl51&jg{Qv*w;Ns}N|0Cq%D|xKz{y6{mvQ9EnWX}tg`}Ubm$DJKB<|K+Z zD(+%6Xx>s9=CJMSGRY;n9!x7N7!!(ZS2`T;7JXpF(2(qx8ofJYzuygY;VRQv8ip+y zk3OvzzmUKx5b)RMMa#JZifl~Z$`}uPkLfY)54-hm&GDbhAAHHT&Chje&-M#CrroyJ zX)CLv4@3WhknD&inZ3LCKRK-M`m;2|c@e9VkJ<|NSD~Wo53LS9E6GRB@?~5|n$WDi@yY*pSLYYqXWlyBoT=$G zH%q#8f$DON>z)iw?My8W!&9%2eQERo?7f8K(#wr}H;1C(j zx202EWl@J_#MDW-dhLx$JQujb%W~M5`O~WP)j#~uzklGc{BHj8WV6~e#T?1I6k7k} zp3{D(cIbufe>GvZshk_Sj=g#FVnZi`)c0-TKe;8IdreUP-+D8GlUeLQgI1Zc;Fhlp zs!UZIUVWUL{Mv0!(2Vec3z0|v9ImxxD-t^}hk56tbXi8xEK2z^jPj9nzMg}N(G;QQV?ncZLd zLLZ&8lG^uU zqIKQR>z-@pZ);%m(#S55VX^2s#=rlI#>NxYK9Akw%39t}KepoT-lARK|4oj%bj6Az zKx?;V)THNMzH7vB20ER2^(B>iQBP95#_3m$(?ece<4R)KJxy`#8MTI;Z%XFyv6`CQ zKHj{KDNm%eYU}kF2ZnSb6DE_j5l_|4_;@;M@2-3uyW3&gf9YK+m*-uzxXk$A82|ci zb7|2EHpcs_z8qc3BYF0O@sCBpg;@7<0Q`b4H+1o5q_$1`_o0H~(Qe7Q~*yf~| zzN%B(opi{6(V4YvZ33S&PpAQVLPWgR3~y#0A%iPDJ6U*yBfT92csvXk5)~MI#jM<}o z%N8+7$m@~*-&s<{#u4lPoG#~>~ zuFrFAEe>Qp#JMfsf#(iej=!WP+r{%u5*Lo#na`?nj4#;H}d3Q#NfjI4yX$NyYEY{B+s)^R==J7RT65o_ex>^^e2B)&5^H z<-^%II{x3kBzGa3QRZ41!<*eocHdvE{r&uR)t(;nInCVLa%?`-6->P;I8~a-RBy_o z+6&8AA8>L)Z=l+$V@u4ukW3N>L5 zePr{q=0wIEJ?j&XIIRqPZVLTAAR&-wz$KCPRXbruW1`TR)Emzw|L*UX;#nY}B(2TR z>9AYgyGOxm!G|EBeTq2GgFezlx5|;Fw~2F}>im!fcCz zX`G6`YtE>dNS>M%^vPxE2E84dIhU;46mhOL_Bq3uESudO--P+s=(&_JNZAL+R$f`T zCii9b(o=i}6$#NyF^0Qd2Qq)Vu;c3d0QIDmz2CxlnI_EoG5`KTj?gIgf6ph*twk zPMmMuXnUNcFmqqj;_LHlcW-%oZu!R78L`gt@yvJsZ89!+c2m9jtN7e={yTo1X|6Au zU&+wF?tM;s{-1BYJBrhOKX18^X?--TIOqHtx5;<^ZGRJfdckBd#~YhZ*;W1CSlzXb znc?`O4Nv@bJ(+LQxL&xv$*swy$z|@2Kd+BGc3@b!kzGM=^J!6z0+F8mWu|(wmL|Q} zXu!t!@OWI=12N0Z0>3R&I~HCuN!@7o_rcyvt~S2f0tqf1$_r*Tt5!;{%AP0i{LL%v zhp$fBH=8P$IB?9fFu z=B##`bi>1%?wz~`3g#J@%Nyuizp-DH=Xv6p#xoLIx)|p$G8|Q4@a^c}yku$hvOv-fBxV3Zc}#VmFfPkOmpHl-&MM8xjgInq;J_;Y(~p` z4|jC--efCOF!r(M<`S@3-cjrjy!LY3gUjFNv>oL9JMZ(Wwe0ankL`G|`p$Od>b`4V zKR?{PF?XZwj;?Bj3%m7X_4)RGHkxB<6Y*=m!@n=@N`Kk&ZI(N***ja`!{_4Gm9lFB z>gAe>IsQ8I@o1?p+ERT}e}2q$h6s80ESBZ3{8j$5c)wD1T(ptF^-qwK-t>w?@dq0C zI2OcNGaPL;mytbLuW+F}t|Y1Bzi;3B-6GQsPjy-B0@zuUF@Xx}3JOA6V|CdX!C~{+%NIurQti^b&(Qfs@Wr}P|-&f3YXq;=vnCyRGu6oVho3o1MGNqPJHTbwc z;+m+h%Ky}Pt)Ge+=anB=dFTAg&o?&IEjXvsP-}O7lJ;|xYa5r$Ki+)n659pM|L<2n zI^TZnxJ^>b%Kd&nRIP;*?ws=95Y(|FnNeeLn8}MXiQJ$`bJ)kK=bcd-P^p$1mw!dvy8znmQ|Gx!Q$I3pdshFxZ7`Po26ju+7l zUfg$X>mRzNE4?vO?E8-=d2e!_zWAye?=@leJewa&e&1HV@^61}@0b7S*Ixc_ueI5c z?6x3oFZ(GMZISN_G_}MUo(cE!Z|7rpAaRvp#-u;ZyO<-UC~64^aZGGve5$muKA4@| zN@0P_fuKf)d+W=W9Iw_p9=ZH{^5f1QW%;(8v+tEH3|{lS*k=EpZ--yMnOu;i>$dcs z`x+j#jSrTp$f{PpTJ_7VJ;!JL%KaP}P3nhN%@dgwWGAk@?NZW((Eqj*RT$!HdHm16 z+PUv%P0X%T<+jlNh~=AOZ?1FQH18GLo0EG&D;}{qUf#gk;Axm~;@>{U3kN3MN=xBf zuDa`y?Q`3;CL#O#7cSs7OMYyc{_s%b-N)kcH-CTLRd@T%yyyIJjoM!FyMO(5=gIq( zvW=awrf$k}%lt>3%`Orh;jgboGc>9GE)IDAKb_~GgxV{6c7x?f_nD8zeE84(>0bTE z`ENsap1D`YWOC%{YbW#dGf#vzo!%3W?d=(1z5Cgrvo`|L%C&5Ny@(N>aUwz~iZf*r zb9(RhzwDdN2j0w@W*IuevNiC+U4APr6=%oU=?n}qz1Nc5lTupW$;qtzg?$qEgNG#3~|Mmwl8%>H9DA;BRzlRKMpBXiSS&Sy?dic?Hq?cY78+P=_q-K?wC zg}042-+s-1^~aG~cK_Y`leo{>T)wUPT=|D+_JaTQzZRB${jY2|FM7_weRZ)Aw-WRPRk zX^`LX>d2aZ(?3pMe@K=6)8BIY-QVK&Z}vO(EPPI-uTsb`okl`101%SlXzH8n5jEe&T}wUXJc_m zxFHc*uhEtGO-1feQ@t=#Lvp+@kD!mk1mm`Y^YwUrwvQGD zi(lwUxf^fr|8wp0hOM*jXE`uW43)Jt)cAWmu<^m?^~~2bem}K6>9TFwl&Ut*o${;J zSX+0iFqxfSXqkWc)={-`UHQ471aanQ^I{qKaK%#Fm^b66eZR+mJ%E}NxTZewgc>l=Su=k)Wg*~wZB8+O&cG!bav*w}DLS5eK$_<8z4 zw)^M1{=PWD=x@*MT>g(yF(lh$;^un{`@ZGO`FCoqMSa-!?^}<5ymHa5+^&Xyo32ca zG{gSiM%m02&r{j&yG>{Lw7h)H<^TOhX2jpOzYdz*^6#GOr?Xt9EpY83|4W*`$~pIC zGd+1H#Icf@dES@R!5^&z80v0_9Xlu}Gks1Uum0_#Juma+ZtQrbGiUd#>u*CC60FP8 zBdyCH+lD`23YdDP^3bVgy)sh%X_6eiZawQJym4>{aa?>;xbD^ewTYX#dH(%Z7LXE; z@_N!aJ=8gH)&GDChd7Gf_uULNx>qR~SN2oVV&|82>aVKSTke>(OWSsOmTyV+{zC;B zL8S+e%$MSlO%O1>{a;;y?Z)3K*K6;tYVW^3ulMr)zf3V^tE+eam9*KZZ+N0r?B~4n zk7w4@J-4s2jk6aQ3}bM6@{6zKf{3-w_X}z-wNjcVCQXp!|Ls;-pwV`)p6x{4vyMGd;%~~ie(ih_`t<^XV%4TQjK+7qzf^86+n$|% zz2Ug;zDZ4sg@}J@j zjmOqVlsIg7U*D^=%Ogv0?pp>ct^@!7`|g;Qw&eB%(N8DY`|8*Z1hw7S^7g^x_uDPr z{(qajXjT8| z(DwC5x>J2+c;fUItMvt+_T`zg>8>~BJp6I2d97=gnC42TAukhb%Pk*nfd#?BMyH)Dn z%1Yn)bqR0F3{y>de&p|y%}*+OwWUG$&g`43H}bFq%-^yq?5fQAFN=#9G`@WMwD-uf zo9ABS2pw>r^0RlihU8lx|CI=L-DE0-Get5 zK20vMaX&c4;Y>iN!HrNuV~xe)cRI@)R?DeKYwg*_xq0EK_&T<@Ki>JrwCf)h?^qddU6^@e_&KxNx~n<3B_F7rc&rqD zcaFgCx-$LDtzCK(zaJM4b!cC``Twi!k4vBD&6<1v^E|QB@_U5+-}oQ+zwh>k7x$}l z<2Fy*8QH&mKTG((x7iyWZ(n!Z_DO!7ZMcs9hV&4oI)+t`8fyy*CN0!4o6c3bBR2PJ zkm|q3pLU?A?8jxpApQRKz3S89x6?pUTu!sF&@lTYDhb;fH$ufvfhvIct0q zKkv6$?!mBe^^(cV`D{|2Cmdeg-oJps^k~?I5dN#ewQQT4gm+bMS65n)Z!Mp`CyVXN zqvz8%ZYZvNt1Ec?@Q-)h{CC!}t`^y_qDJVCuO5SacT@l zxcYw6*;wy`P8?5v7&PvG@V5QiuiZ?|F0<$7cpbeWutfEKrtUq(K#vXQfBtmca=$|8 z`~H79F?PZZ77Bk(MSq-DzE@tpu8e=$)o)oYtEbI~ZhxQ1_oOQ#TGd0XEc)YK^%uK>&vzMw`8C44dLvp=N>||zYxOOfq<#zebo2TdP z6t%MEp9ETB`M^Pg;ql2DmXKcF3k#nuYFk$xRwulNf0CYIVE>88vql#dSq95r(_3Qa z^tgTfAe{-G{eG$7x<8$(KYn?C zx2Ijrk%7ZRP~%YQJdQ;yOL;D>^15Mb_A#Z^;mvvNz~x`gt9nN2{$y`R7oU(ciczM$I`af3h+MGfgLJsgYNHJD^I++!!`*Bb3GMzv*CKoGi=d2@5 zwmwXN;^QnBT z)SNAs;Aq@+q3!W{Aa-t!H6w}mf~ zRG(GVW3c~TA?LY0S*LGFGCVS?cy)fFjiuN&ma2eNFD_+%zH;i90`q6VsDNi$hSv{g z1>RXK@nj8iJ#TOLf6Lpp6CDKp$aN`9Z)jl@v(}el>RplTt>e(J;?{lp67_fUL>U+u z^lxYt9u{kYZ91ZYyQgQ>?%_)9$6!k+4nq-_hm6} zIeB4{>}P>p)eoxQZ7ICE@w7}tKH~wy6^sFss#i_<^|x3l{J;A0|H)l!3|h+^zV6Is z&$!~1-q4qG{NZlB<43cXuK4#o|It0YeV6oh7sV@+403 z?TgcEC6?OG^smg2D0JYM9L{{?SPO%5)8z+Oe@|U|f97(v|HAAy>;GB33z@6AnXUf0 z)Sidw@!h9oGG1ideDwD>^Q(|+yX^uDpFY14`{LzOgV<&Rh1WOz93)oj3ICYChf)9c z#_5lx&V=>G)V)@_b5pgu&X<#KSN-o^>B8M-3YN07{XKmBq3`}LVtWonYaCng?p~T8 z`#Mg6)1OsbG;>94r*2a`+OTJSe1n?^vtcd6?mJc85AJ)^-)a?RZm$3Pw&L?M##!fb56=(GbWOZ|RN8T96+m-Z$~y|N1}2ZmxNFRBZ7f9i>$| zA6D62d&60pvHZ)e)$`p-T7E?@7v(;*qcvXePjS0aVZ-WcI;U7t5<5Z)y*dkD?O$FLOA3`F}d+ zb@b8ivP}Cw9kuwne-HEh(oYPZH}9F=f2X_po6h_Db)nox(kpI}UYg-@59hu3H$)PEvA|%8dDY{d1N!SFp7wbQ5UGS|| zP*mZ8{rakZwhqiErpq5L=C|9wN%Wz^v8&OA-7(Wy= z{D{8adj0>At2^2W==hC^hx&o8MnaSZzdPsdSAnTPvrRT&&L~0q z@8o^J6_9(%_V3l{kN?Q;5?Vh)P}2AR_uAvp9wiBn=lIU}z4!g$?Q;$LC#QY!~w#yq1r1oxm^m?+7!PnU759a*;wfBej@^fd?sw=pSyNnfBClYI*(bo?_Wv; z9aK_$m^HsgjMhJR~cn9P#7c5)f>D^y@wxN{aK|~tU2KBA; zGP*39@)B2Ttg-i%xXF|AMyPc4fjDJ{B`Ry9G8sgU{N?DKU&pE!nO*cnz5L<2picF7 zb9i@U)gL}FaYD$%&^O8=D^*om*=1Tn)_vMKZNdqrNlTiJB-t--V3c5w+_I;Q|9ob;I>fcxM-~9CdPri31r+<-SWBYqWyYMV~y+D5bryrZTau{c}LcE&17Hq1Jp2Yzt{b~?xtAIw=McTjndODxI51G zF`L}EH0tPnHfyiapR+a>{Bo}oxc__G^arOR*U!|M0NT_p;^r^ja(UnS_@7++?6kcP zRK=88ZTq+W{qfy(&#OP^>fdfUo9y5)_lZ{0GCz||-0O@(r%fslYvVNRiw$}6LBvox zS|P}DLZyx8yL;15uG$~ArJi}&u}S}p?CR|0=3M<;`7~_K|Hki+*6QEtNZdQ`n{~c)=iIXWyEnVwrv4tr`)1myA9v3?c=@|+d+XktK62;XKUnJ@zV>}Td;b1{&$rf! zoUV9K`9|YvQRDoZm!e+>a^@8OuhDeq=so7O&}ws~=*9i>^7dCnkVeXFqow1vXjWr+N@wIqd`~H~q z|6|$(oUK>C>lSW1r(&VLqG2befCR&Q8;8%U?>A=u`x^amuJo*}Tdk6;xIesGUY4}; zd4x|)soH_x3s?0We$H0#Sa@@B#f|xeuY+%Vd$a4;|3vBO^Ba5LSM%@tDm(q$y4v*H zdN%fK9aX0VHK$B^Ka+FTS` zV58Zl?|+52e0{~R^83~em!Gh_+E%9)VRmu%p_D@=Szo44`tEW4d;Beim>Q3M2Zi!W z3=hwWKRC4YPmj1;#E-?+A0je)HMRz5J!sp^bSr3f!}l`bDO=xLuUmQXOzr!6r>nc) z`LAO%nH9C;SL6B+m$xE+es6!!xc{Tqo;16piVfete(Ns0xc?9Loqbcj*P7h_##0vm z-$HKZw>dX|a>f_3#r@fRzx(~pYU_EwJ|$N?5$1pJargYA?^*?e)FnJ0^9a3N{WID2 zwySSqkp9AN+VbB2R)5^t{^57(`0_TW$VT+yCbd36CR(B@19=!yLE@ng@ugq_C@;dcKpoT^WahWHPwT5yEA*V zd~9D|IHr3(;d%SJgSqc_%7n{viFa?G(|`J2jhtTf16G@_TaSNuFl&-zxtceJ+2nY(;k0g^2i#tJbZn54b;n+TCsk{aXzO_4i7yuRP7V zLu9k)>s|Hswh#MP)?L}#%WanUZ@2!j?)!CudOMO%zX`d;z4(!L|07LT)9>=XScy!}m9Xys~tewq|^o5_ZTrE%*-WJvYaY(i@FU(fsMjCKD_!G{$iR9Q~5q z(U`K)Z>bCy+w5LLAD8pLUbBADjz9Rk{;Tyzxmg=CKGrlJJRjSgUweG%jxXl<2YlyO zF!=l0axaUPzjVXuYpe#_2mgIC;(xb>KMGxbcE_vuV|}VRe?CnwxLwC+W3}=6$BCYQ zUVq%k{^7*-dG@nUefZboZ@XpPzy8A)UjLuQU2v>Cw&nA^5;?sdo7)yI&b_TzmBFRy zXkwjw>SiC4xWd<_bTtp*H;)};rkn}T0Ua9BVwrI+qMR{8-eT1)hDCwDuZET#%`I!W zvG3iq2JM#2BS#l$>{t-J%{fHkl-i?XPoK+u;yS4H*gd}Mv$@QYN!i;M-tXV-e)88R zZ~4S0b|2XlayL4cv^fa+Upb{eW%cvxsypgtXwN(lv2gu%18)5TM+*Z-@$^XKCJo?^2z8iLZdWh5W|);oH7 z|7)d~pG%Jy{4XtkxX<6NVL9vX4M&&Mtux{Ix2}8R*SpgnKDs`)mpfhaor&s$+n@FK zcjWhN{cy#B=a^9IyU6edJ^wDnKbV$pD=waMvm==A;=R^xv&UyZc>kwM_m(DWF0>8! z=J;1`gzb!nrnD3mO*!dW>3(FXv@jd-jTO>ZO_7AI0*`eL7cim&HXGT7$ykFz_ zPgL*A>1hg+cQQ`dv7ox$<9I3K3xP(4T*2y6_GZ6Bm*Uee#g&Bri4imVc*Cgw=8L~) z!YtiOME@Q;7qQycU`1#^iNbr~?@70-^^VWIUv%%y%ut4cmoIPpnti|Z{m%E6HedI@ zKf3q*Zf1Vl-5Jkizd9ucpJy(AabW54hfCxuHT9})dl#IPl-}?%;M~podl=348943U zZ>`+R-dVEb=Rx_s?EZf@n z%lW6Z-CqVd-QQfhv%MDYE#s*05%<+}xMZ}YbK376i>7~a^>g3jQn`t4otH0qZ0y<$tfd+`hpX8R{S zcYk*{_WhnO7g*d<&$#oX&&}|0oN(M>VeamYf27SHEUW*+w&!8M-*b)*HK%{>c%%FL z@g`f{Y47U9{O|2%K7a4OOqt#8j;G?&sxLggqR6)VeDb?R!G))eE`RvtwEyvIKTVk; zZ=8SiaQ{!PeLwyPMJ*S|-d^?FHaYcxd7y1v#>Ib!{U2`r|5di)_?jHC@1|{xXANX- z9Z1-^QBg_g?cQ7VzBU(`-&F7W9u%{$u#Hhmu;6Uujt~9!db#J{=#`DHmg&Fu%Vb_< zlDL?B6(jfV=p~t<$2=Zv_CI!~a`uLYO=oX>(S81C&T0SS-Ta}4e*M0GBv(`Z#>owp ze1{{yo|5l>e7gUk-THgd8nSZ^vY&8xTXRY1z$W$okJj34yXPtD#N$@TmEgTiy0wo( z>W@wFg5MK@_H;KlS|z;l+&_2y#nr_rx?LuTD_1cESvMa{StA$9%pld^c8o zB9>q2^AAt|_ocny-7`f$yO8&nk1l_BX8(7qIX}zS9h8= znN?*Os$7l*wcXjk+h2RTa&!6*-@e)XjKBW3F+TeBZ|ARy9QAqo-e&HZz3hOEhXqoDTPM0itu z`?Rp;!#s?8{>kLTWye1?pMOaCyM6!N*iZS4A@5iO_RbgBtC4W+UX;h?5)cr>-Xy3MDFA^25Vc-Qr7ERRC2h(&!8dSkvV@%Y~JiMN+#2Rz#Mi6^J_=S+*A z)0Q9ma3b{O_WfTf?|f_9T=;HUZ@Zp>l~A+Duj=~;oYz}R>sOurna$k0peO zHHGKSoy+=A=UDr@W4hPxwjSpY%~&q%)0%nZW8pK_iB0S3na=-vclO7r^Lx1Rk900v z<`k^?_3uBsqwP!;X@aN!zn%T#*!g{8>+MDK->jKj@alVfTk!t>n-;%ryt|+I{GWH` z-xoC=aNkz{BSI!p@&9A7J1ft5WXB4|@37h&v%^@DVZq!!fBD`Oo0xwe47Olf?6pp{ zS~a_AoxOJ2qATC(r+yWCVmQKSB?9`V>MlKNJ&o?ZIp`sNqA{PFN_pKGrg z{<3ejQh1;daaex0c)uJU!-LEG>n{KA|N8MI`>(&j+zDLiUsrYZ2ssut9a~oJkj-&P z?ZkmrkL0#XHX`~Adk*R_?AHvK!*#TbA$sABz`u{`bIif{(HYl-!%H<;VGE(tjb3ANxHwE-n7xz_?Q7dX=C<$h|}DJ6Rtbk8kt7_RIL+ zi?TbG)BaR%n%{7yg{3sn-qp!%b<2__F$ZbR#fr(6T5g6n+*xmZ`mT_{-V&(N!>~mw zW9r4G3zL*0r8obbm!i}Z$!Qt%{y@RqyE}^4PCX=Fy;HX5xAK9l)h%C&=O2pSB6&R3 z{POu)CUe_e!sq99R9^+99sA!pd0J_SN55@OEMD31tYD(ViLauZnoO2jOSWxmQTqQY zyzsgE`@?H30$IbFJL-3Uqo@PW*pVyd&r=5; z8r#<`+GoA`K?sBU*QFZF;#==N;pAi@U#-1|HPu6$p1I6`~BtJRY}3)k8f;~wyyhh{=>?!3)j|(_1wFf z^dvlJ$uB9*PaGaxzxNqNdpp#<3;uCxy4>5ZVtvOK1|LxkXJuX%v+Bb9zZUy8WVaj@ zPTcqRoO!|Hul`Byo_t}umo+iIPTtC*^7mGF!QNHs|6UyVxW;PTybs$aJUMABxM>6X z_E*!tIIfKfz1zj~#Lz4BqrO<=jEB*}7jE;G9csQ7fBnDTyQJ=O8#RnJ?f7KBlJU8W z?#cdti?;nTaZFG-M?8N*Ece6neY^)U z1Ku<|3evd8zvecBod4q|p<)p&Nh~I7Id3cod!pYSGqt6>eQgNm7ajA!cN1^FIxuzJ zul31Vd#3$J_W9$q#9pSyOCxk@!>{I;v@>xxuCslgy#Bz?;%pJFpD${6I3Jx{KX=|A z?FpMi9d0$MzFsTfrW<+bzJ}WwiHVL*i;Dy|oXPorZ&mI735G0e|30@E1ih^17Yy2z z;CdwfdXUq ze@fWx&Td&MoVfee#+F$7zk)SSX0qzIxbO15G+$&TYf;fOZcU-YN0;jIwxuj+s7lop z3S#(l|Br^<_ty48@xJd}|L;XIY})?%;oOKHNA(|YM^5a2e*DYN_QL!5HKN~3N)KuF zbU6KxV`llY$7)jNk&8BVHT-#dzGm9^H>hmQ3XN>3PSJn-NyB771r$g*m^> z3oqQb`rKbNhfO7CbsvkypZQR`vRnS^`DwOE#% zQ=hc(eWE?y(S zdX%FlmEkm#PWB;=14bO)4AVjlg%Tv21UZ!1ZoP3hpmc%FQA*_2B39+tf}mTBEN(mh z-OYc%7pc7J_qx*sCsQV^&sp&=>*~(){M`S4eofJF zp>HzhZ(lX8{_w}YhDE;WN93Edm(2I|Ubcsxyfb%I>-NCUH_Hs3bSKQi60F3h9+ zB}+sQGs`XRp0)iSKR)2I`B-1iXkPW(mua^iuQI<)gXpHs&R5U(zqwkJ{Kg~X_l+r9 zyDq=D`tsY97@i#=g0`CPSACI`N;Ap7Ewnfze_`~$eKL1;E(rObIJN%Uwzj)nd)y~Z zulav-vMfWvHaW?Td#jk4l-;;pr6$bZo^yUZYf+iNm*xL>=KuM$I6Hg$VZ(r*|KF~E z-2MMy)ty+!!dUO?TTVYcQ#E11`?=TFg)TTV*>qiORIjaRWu1S_)8BSw9Jd*w9DiN7 zTk*yr{&H7#?E?-&ms|CpBx{PMOxSGP;dMGU*&*TF#oH}6^unIpWt_=e#CO6+K+cL= z!AaB6(y@{w)<*Qx*4}&n>$OCp|L(0zyA${_;?4hfc7|_8V$m963_3IU9A5G(zH?-F zG_THH?L_-3)&;9BoPWZrZB{z(jrCrCFX^WIYqDqehp=kgynp)ossGOFOYUp)SUUuK z7dm%oU%}Sbx#ACdiv16+jEL5M%6z*#`QlL?-Ir{e(*ApREZFzze%Hl+&EY?czh3>v zxIyOBQVG_C$_L^r*g3fb1Vk41I;BO9Uwdd;Hh*vZ#~Xj^K6CE*aJasy-*#KE z&#I?8B>VR@q}P3E{_$Y%`3J1&Ga??nzY?L_xaqw2rfpMPdZs;?UVpAjF!DfdLqjuT z=Y*(jOPq}^+5gtr_iOg`$Ht3mO0-qiH!>Pb`?7e^`7+V1dt)2^Jj|{5FnxcEy?uc7 z;l;Hzk3DTZ3D>us*Hcd^t*X2p95YWc{O?hdd+qnu`mA9;^|d08)B69p;~zFIZ4rBQ zs9tQ3TBvv5)Co7Qo8F8%BQsIzYu2<~d=1~vyMI~3Q2%b0)?3Ms-?O~VZ2FdZM0Cf4 zLN1;P0j>!Ow%Q9r{(CBT=XBP;yqYWYX^{kT!bu5V-R?#P-tRsPYD*@D{^u>ZE~oLK z*u3B9()~Xic7Gr4E@bWt_U)_xoLcki`u;BKE-|O;T+1fB$c;@F51pm|?{8{vP-^#^ zUjatkjvXs=ovaGM32mBa(c^7fk9tPL{{7!6tGs3PF_G1KmWjUg+k9niy_wZo14Nk->zAQ`e+tLG1r} z$<}}Mq9rj50Czq>~nG#!F(N57eJc-YU=5syRHvSjaTul?Hl@9Fe)u}A;b zZhyFK<;ne_98m(d+y`iUECxE^0$RKK@@i8){JM3QJ&-!Zm^ z5(Vd14h#yN#*AG}pXc9cy?wpvy#2SJ7^7(q{MjBY^)fG9@aOgV2YvB>nCy1X;9Gm* z+?kjEP1iJPe)qo69(Ujn|36NyM2+8b5Id%P}#X z?ME^s=PIOfAM&_zEA+_o8l5k2K$=cir8*tZ&2b_jMxjRSE26mey<5?B$85w-ycxTk^#A zQ&dC4m5n!zHmjam+NF1nO(QAd>70oV3L7nNXI$izNbX-dw=J*GKh#EJ>XV?F7f1f< z1eiPOUDwz6`|pxM;p5tWOTnR@|wJE3?Nm?3+b0+n1h( zt!I0g82!w6-Y(v{>fgQo`}@VO3$F3B{I`&~Dt7XH?d*HU=_Zi`r{vYP33SRjCePhz}@Bfqc zvNEXqNIN`^muB=_kg@mI`U@-O-rqLq^6ewqav_XNAG-ePOv<>gvx|A(3fA(_>XLiA z<{r}X3Qd?(m%d0sZ`Q?-ZIV}lS-W-b<$RNyxo!z>W8U3{gQn?gegEX&*0cHjl6_;Y z&d>E)ObuUo^bWmfe&g7Zwz@@3%z+~!PM{~`&3mtiYdxu|-rqtEq6B{1oy}qtg`-AS&l8YZRGDxicw0Lz}Z|Dgrr>q^T)@Uv%Jb0w-?fui=>tD#({Bo{u zvad{FpJx*$e=6zFi|PA2{QvLue{edf;bk0ilD|aJ^>`arfe3cS9Gjie=l>lkt@wX^ z`@@?`ao4~8{VmDrDiz3of-z&|g@?8sv1JQwKJE*d z{c}nA%N}{T@G(^KsI>k&KlPRUuP6Hz-~SDNqTt~;!6N$6`IakOm*ypK{+Hlbz{k|V z_$OJRVf&+0W~a~jvHjNnez|}6r(Q1qJI~PEuJQN1zjb^5Yxh6MTxX&jGBJANk40OW zRo!OCyjGo=nv{2!bLAP!^!^g24YJAyu6@20&7j6DaD$&=0TaJ{QZ9q{=4Jom%k|}K zZ(I-Q($4OCUAkz+##2YP=q-)P`2YW#_m3C#JLJkIC~dzZ`eTwFaI}OTHtg1+vXniH#)mc#b&7=cYBdn_KKY$Yr&Qr zlU)mQ)0}Qb`sR2Wv%T#9^mvg8jiIqc3lB~B&d?RQ z!)le_=GCkm3W5uGl!~WK{ui&1a^8JPn((et_JdPCUS5C5TdADyD7&d$k$v99%#-`I z=ki}+TA5+;g2mMBfI^kLiSpaRl7fuA&W9wKUf#0uV7$b^#kpqzhtvdfA*NIho(b0? zMSKo;G%_|Y9yC&A6^z`^Iyp60;6=LpA=&r)WY$~X_$|j~Vqa#e_v3r~!Mm4_oL%uf z<(QUP(e8ck`#)UV|IaJt%jNZlOlP;>DEP8ztM&iad%JgpJ`UgCWo`GBLGJI-{KH$r za}Qb`zZF*c`*OvP|G#AH>aQDDFs9f&SaU*1;l=Mp*94P21hYN?^Ezv0lloC`6W-r|TNBqYz`TL!}>;6UUc@}-{plD)m_IHkD z|6hgHmHb`zr0$8Uk7(T8FSdK;CM*$X@K8__;7DM#-NCG1^Z)mUgY)Z5^?t7me{_qN z_gUXm_oCaEE1t;zx7=57zxamp)j6HPla7SE*UI|7-fVlvsaGoh=Y9PmA73d_{^zj& z!(x4%#K;xzU+W)y&EUb^C390K{%q){{9SUDnhMX~e5w4uQ(8iR)%doX1Cx`B*kqn( zEZhawv)5eWS}~#Frii*o?Z?H;Vk>^tE^Ae3{QKRgNX$cI@!Ph9-w|25+k)6!H|=m? zkW4wzEak-u-}_LN8B60F1CH`n&YE~@ z^?{6kkG5GVwb*=+b6EE3`!4ac2_IEF8G@LEZ^!Q`pDfkdvnK06iUZTms|sgVTv5Bs z$CUQqt^UEK*)FW^^&foi>{a?%-m+WcOTWzoro<&J)1p@!>1af?DYiCFNNDVQlC)e! zHok39L+Am+hRsTP9E%t>U0l_4>sQ%i&l%>jKl|g4+y4J-cIW^0xbD;Ui!I~oUdi~`Nii}k z;+rJ7|NP#b==sM!zyFt9^GAK%u~z%PUVExeyXDy3+P+l1Eb({!KdXIz{@wmyz;Wy1 zojse5H}N!0;o_UQDmTn2?hAwclxZrm@0H)YdCzf0%fkIajZ;UPZ=p8_6H5@wcGYCo zCaoC^zAXFRNACGxT;Fy+u0$&D$Ia^xr{-I*PoHaY;LpY_UpF_aZ)7=ptfz_T(zUOW zuhMU)Y+AyVwQ_bR4?}N*P-ES5wL2dt?*91Tey#1ge|_8^H|^{=cyX0d-SN#8Kep!| z@||DH;P1Hb;^g|m?UHkO>NrJyebY<&q<-|9imc26E`bZeJ74{u9e-GS-EQgrCv~#w zkNRfi<`{}EOLYrcy!!je{k6KC4l@)3s{*nN8yH^9xmC&K{C=hB-kA=JPLh$#6$%f` z6n03+q>j5Mm~utDdCl|W#EBE{j=t4tV0dyz$ynj=vMxaulb`|) zfy{*>4NrM^ZYFI~v*2LRPV;CpyVA_?q*bc4K%cWDPyt+sw7)yZ{Qn7W#bNF9hg?gh zT>msp$Zh@({_THeuYa&iT+D#USLN@EDD9q2@6%N#MxL2k>veLmW%#|yMM!lNf&8(sClqq-W;)J&c zg+dm8y)-q><3LmH`furlRWCXOZU`4%x+!B2^J40}JKnB0wL%P-HW?LZeW{-weX<}V z=3v}YqdTYUCpEFLsCYAJ%#L8Y*wc6HTvb|9&Htqqzg?v_X8KN@dc5HIH&z`#&wpp< z`A>AarhW5J?r$fz)4i(ynO%i8K8f#nr#*9m^6VeVM|%5sZ5E#3P-b**O=)#;i&1#N z*3`yx_Mgwp20?3%0}CcDmvm8J4dK{P&t7)-o5{QVzkFhfHhi!DcGBYQTJc9;&f9b> z-!(HV{N0LI3+LNxSh1d?U+B~Nde-fK?5{uGRvy>2`FHm%I50x(hI%*G|HT@-~IY>S$ELOj=3*{OdSrKNeBzz-k{~A(&BMKg=4Y<0|(x0zcNt-uTIH*Qd|JwC@jd_@h1VYdH1xCUKv$%q;j~!Q{4;S%inL@y{=y7e5aul3&X8>mRD{c_YDf)85Lh=-z%}`TE_?=>HGG?rfhE`-Fe)w3fAr){7@;1+A{lmTK7cFz$n&XeQ?bw+X2YYm)q9 zS&wjWu|x(X_%S9;;4tEhP!ih2Sgo|J?`qZFo6-CkV()o5uiW+UikYbw^Rb(@n}qg?;k}XYK_Xmzc5yoZ#TRaALxl38~uqUbHbWX@qQUd{=PnF~bGH zUs^k*W(T?+^ty0+{;@v!y?pZ3M}R-~a9C4wDZTuZr!ykn~ab`@?JhpR?Wh zbVg67%wfaL>0dqT{RPX<+OA0Pe!5$(j_Ke$!IjO$3LBz)4tXimo2_9mXJg@5FT3M) zD8sL=?~M(USs3JZviJYrY@S${yt!<`PSGXyd%5{{OCNIFuO`rO?ZqV1uq!v>kIxcL z$+_SB-2S(~yC3}K52odIM=0H}3E%OKb3*Y=d&%6{6D$^8?O}1^>Js62+oT}MJJDjp z-?Zh=y)SMFQJG;qJ)@x}HAOEkq9utzTj>3PLRZkx3PW(o9~+M4mUo#CtP?b`$L#x_^w2ug4LTB-c;T8PnTb1lGY~W_()$D z_2rW^Se)-~-Fmk9^h*JSSqD!x-I3_MHIGr?X6C_HAsf$}kYicD*ZsoVOtt<0n#0%1 zUj6%bcH!nL*Q#5;#ZRi|Uvq3yz3XlV30@xoT~}AGoI{Qbmzo?VReX^C#kYau!bwR* z1E+-SuL7+mlDjTAnE#j@#maKvgr%s9*a{BE|3Av^>=9#NV5s}@bjP31{Es>(adg`j z=X$d)une*>(_`^x& zbGuLXr5xH-ELoj*tL^vy7iD+8it|5Qx$e;8#<|UrGj)ERV=!6~zB^XM`;WAf(nbcE z4GA-kwC>nZuXnHO5Q7KD5r$@g83LQE1Q?F+aRj&+Di}|y&u3(kc;NED%(3o&n9YaZ z+aEQ$Ij*-APv7%0X3vj3!3D2w&MtV>{cL0Ag$0sqCWi~AFnI0D-!|{b^yj=~?NfsS zSKkRxV@}dFRebs4U0CF6%`I1${v65E`Llcdqiga1x$CB3cAhV`b z&MCSiP|WOBtZ_?on$WcA#kM(dpzfdUoA!mLrpa;D#j!=BOW@rTed-oblU@ z7Ip^p-*rK|ukkQ=)W=7%8X26vedp^6^%DsVZzhKIb@hl@GFRTP5Kdt6WIk~~`;TtJ zgo9Fw#T$;;FX5{RVqd@2P|8N(f)Cpoei5S$>sLoDZ&(%U$f(KijQ942zsv19{BtcO z>pwb&KRUEMuH&@69q;oCf?OXqE8O2I+h6;r?#|ct?T;Ftig|KQTA8gcqR6r7woR6N zb3<%J;NF5j`MwQGZe`EJ1tqxD9>0;`NT{ygzFhp+^{9916EbbqbXb2A4gTg?mLBo* zYi`8{-{%icZMAzGJ^$EY?$Z%j#~izu>wQ^V;%4s)(9C+{|NH&NUG-_LTO{9p_P+7= ziJVRS^)j2EKifaNNS8Zy*)?D;Z-j`){_`C>cr|uCsSlCqy}@1M>7(Als%hr2VVf4S zT=bIzY^$cd`v2!d`HHgvIqkDPO+Viq_4j}L0}BHOtA%af_IB*&I5bruhw-7Dz_diY zP0lUXQw`LQEbU-$+U%0y){+){EJ4J;;n)J!whoCr0iJ2hZ6_qs4lx|q5XjSFkg!0K zk(ZHIut{st^Jc|}e%)|suC)u4c0P#z#z5A;*^-W!k z`nLMVo;Kfhx_`KFUGAvy>074R85e)PxV9&BeV0;gR@KD~b7Dl+bYx{32s9+L&sq}q zA^TCOLC)JEt%LDf)jQ6g&M3APP~38kEkHr!E`ulwzee-4eL*jqyjIOGRl2ZY9-Gxg z=Y3%-qi%k`ckx^N`=e2@J7bm#R|?I_m^)$D)0RK+r}C3t|J7eB8l~0A#$-_K^yba# zjVzT#dVaCNzjv?q7i}+`TX)Un&Z*B@|BqEiMnCP0Y&-w&m!$3O zo{;JS7OpI>M_zyTUAvoHyS}DH*8SVZnx*^+zF+I6?p%87_quF{Z!R`dPwnr%x>f7i zylWhjc0IVOupm}QcwN%_$>(oIU+%H<(OMvo^Eb^kg!#>*ww^YoS%HTdm}aqMs4Q?i zq+qPn&?XYd$(WieY%zoH#Hk-CTW=h>^{OTD`@;h%+(zYdCMJkTD4X4#`!sdI(-t!` z30A{vv0+avUC-_F-zvFFz2RKKYM<_?({nkL%sDJ?-?UtD;{O{Kit@Vzg;~p;?Rnv_k6pqd{Ub~&fj^hH`VxllIf?d z8Ed`Y7-}}ZS-i?tI@O?iE_do$GvQ*V?~MQY-&(VK&-{MhT=!eVM%OFV$185Xo>=L- zxv!o7$@7na4{nq+JP#JV-FwvW=+rZ=nwt;Y4cf!N7ebVgk-fd{ndO?SsC+e7jR-31R$AN-Ag9|;0Y2JUD zlMb{na!PGzX2@#qH(+~(#aBneMF*S_<;iuP3NkR*o~ z7KJdmvdQe0m%XBjD`i(Rv23>Z5+-*f>i+W?34K0yp6{#duyr`vz_p`lTe54-QF3ENHF)p>PX=if)U zM2E&%pQ;pp(Rndt<@PV9&V4(%=)`Ha{L}a3@2LJ;<8iU}VE66glh|L)4d1TL%OJp1 zp%*EzB4_iWhr7JaOnaRJwujTEdjKd;%J;yN)qj zX^sxQ%p4iJv2KnSZ_zU6ck>++8Ls?yKNihU!jsg)a5a179q)JNI}908;=fxLt}kg> zqsW(0zlufW^yal|N>Yzn>FH**OgX#FxY;1cF-uD!f%AmSg`(~=y^RdLXM=s?9`;Hu z;gL#h5jc>v(M=}RL|XcYjzq_NTSr5V59c`xSxkP{vc1(cE0ng}`uF&+JY_daYmNnV z_spIxIP4&Ob5-dZh66UrOT{)>v|N}Jx*_{Qt+1lb#JM|FI!;b`;`a`&Qfpdo!Mr@CM#^liRr|wZY=?V!XE_?5 zyEfB?U+o-|j?Ut{mlwrm7*?e*c5lx={+a*nfq5ppHbqtYzv$>~4l)tzur%|W|3zCl zc-wpboxv-wdoIv3XE3Tb_x`$MjZKrdVphR+Cih*>gXhn!zCC+Yan;eCwF+0u?qwG~ zI?&f~`1`CE3xq17=BAdeQLU&l_!xV)%zbV}%JYJ-TXO7KuKcRS-ia?6kE<*Ws|1N*$Ftz{3%@{V^ zWBPEW#qQV23pbp~-?y~!UPx{Gke4x|ou&1IQi_Da+TYjN+@e?xwRJ3+CefZ`sL0US z^vs9B@1Ou%Q;~Yxajt;7&aY;7d)?o@?4I>}*{5d(7XQ+lajCv5OU&x%{@D^c3tbCL zUoGtG+}D}=Oy_}%tghd|I8U>_jd=_uDWQC&p#`chuO#if5VPCs{FJOuvnD&d)Ow#tO?i8P_Tm zEeUqesaO8}s(7cl_9`mJ*RYzhL-YyY!cwYFGviOiXMX$d^jdJ>`-t|$aIxry)e z4wYXhq_HF5u2SjMc?QQHJXkZ$Od*|rTC-A0@b$M1hm^!-ur}U!xrArg8_^FzU%ysv zUKC!ZTb!2Pcy7vqbr){jUBq+r@vbjtcg^n7Di2%1v?)D&PV9=R_orq{Ze8AN<$b(G zbgrW6t<6hk?A_`mEAT=xWNFqtM&;y$tBoPW%M28=UE6Cb=;W5B0gcs-+l3RE>|~iJ-2-CTmPqLzD+aSDwnG(c0l{u z#@`{cpYR2VEjb{;syu%i_p|7`MQ&W2o)VfjZpQ0~x)v=BI{9FsgS#%1`8OkJrnidK z|D2gJHN{W%!w(9`QBQ!YohHF?l?VXD!JJxe(nM*o7p#o9bFEZ zP8JT6l+>N(Y+ls1<%#v$`8&1G-MzH$@Pe=dYZdN)5t+C5rRH{lIYEr~{E8t=sb}rXgRyzR-GciSbx@64RmuN?u;)7(RRx+gj$Lse4{%Vv$j5y?)m=UY(!U zC;gG`&I(ETlls3d@CiruT{(fGsFpiZY;JQ1*f1RZ8SsW_19O-6!XIBgPX9M=2Mc#lQl`c&A%9Pp{oJ;Nfnj13`_JmtKd!Ei{iSt(OV%eT6XudbyL=9cGB3C%k+Aon zmxp(Y$YgHyo%&YWJr*8Zq4K}=n|y+UgNMiIo+*-@qLuig2PREq`I&c5!t7P%L;$M`GmefSs1`+aNqniZdZ*InANVzq^Va{>QnmfXGiHz!Zt_kWklA*Qa2 zTK822%d&C>Bc60A9=Ln!lHLZp7p7aAt{SKE`mUQ>^#5s(X6Tz^2m3#Lzq4K7<(2rg z&)61BzLNB@`b(D9^`4DYZ+%xT@ZIZg$28^Y-naf&_e2$@?PlGuJ~gw;!NAcZ;z1O{ zo=eZq8yHmiJKWx3%{%$)%1;cP!Yb}hVox$ib6qgrWV<|-wY_BmJBQIMr-|w&470>U zrF9pd?6LTAZU4miUk%nW%)29eeBM!)oJA?F?W)O2k)O_I9Bt$c+oF8T!#GN9^)c4f zM;8y8PYIN3HBL|J8)qobKF_eDt0|a8$E9OuY2wO+=yzUbbMl!vlo}f;tj8 z5+_wJ`)ld86;LQMX3c}`?LMqoC>(~lkRN1Wc zT9tjHB<1TXgQ;HIZ{JV(E-4q$q|y)?D&@{$EP2;{Mpu!w?$7HTW_=T;v1e>nXiu59 zps+(`n&KQE8;*fiy{W1%G;hT& z-J~&veYt_OcXCxxQ1IWFBZc{o+Jk=Gym9ZlUf)`wwxF9ID|NF%BctETy%TWf-(v9Kc6iidqq5|V(~6B@4LWY(eZKzEee+B*Shng)@N_Y73q%MkQqoC# zVp;8;bmqj74q0Udhs!S6Z`l+K1&&8hCak#AM2}t!8%p^taR1x-~b9DV2@i*kY0Jb)_4h z7bYD)H6vr`tZmD7B)yVhXx$R+T=)K~T;RL?7w_$l&C%^-*!8AQjbp0XfgPn&8Rc1) z@SGErNX!1;@!@yTkAUYor5U%J|og9kMJdwFo*T=g$N#WQ0zTc^wqX|^TeMkN`CHhW3#>h@(QXm(P!z8;cLP6&?L^=k}FJ4%3uV^pyGDls*$nSSWWSDloV7^qGw}j+B%e z?dEu&@=5+dJ%5I?)1;iR7{`f4PH}8SY5Ccwj_p{pBm4rxNiH!q@3wbpCxjNLbE_Dc zsAmXhudBQDIU~qvNtKvYVWgWKOJk@ow>|xts?Nc&|FRGc(8H&2OC-bDUp^ z?U}XLO)mMz(p#I|xGGpAzQ!-fW!qn&Rszxi|&j6 zIuG&-`uuj*$sIBYO!L1_s{3^Ci>d4*+Y)^% z|09o|U*59$=JP!r@8|9mu{+A*%ATU@-gx1iVDaA|SFMx#QJ>D0XAGmGcWl4+#m9P{VRvXkJIMN&}G;%{b+&Km*RAXxbF%TA48ZK zr-3d~-RM}C$70ud_L08!rvpuorm&p3cy#@Zn)}fkRTijR=3*)A))iZPw`%K=g6~-e zO^#l2-TkWPNr#WFiov8wTbWiUw0F3~vGUxTWpLueZlxtBC)h4rwW#W-*O8X}&vvd4 zV%dC6(Eh<=Ee3UE4k-%7hH4kTr%gC zFy}t|1pbo@?5{&ih6!xvI2st4EN2C+D`@sPeIabl zJH2DpW{0Z_m?xMi_;DyNK4K*3#NedXvW?CYJUL0#HDUSm%}twb9-ky+aVye8=fR0GGoxi|_TKYs z47;;jE@f->+BKW6ep}J``MvS(-o9N1*)vx%=cU_sBTKJ*q-`;M2B-eg|j+Z6g|Z^ooejjWY3 zCr-NfzqfMx!I|aK)z4z%7&ddxGTOQB;vVJ@ncP^R*%nDDo1e*YrkrzR*zV>xQ$SPM z`)G=m+474w*x4?uY*Y}nxXyeu_1EQy;Onc>9aUdNp4VH;utd(r$RJ4lpmJy0Yaa)- zGILIa=R6CK&65@lZ+Nr!^vQYNB~zKsDn#TY{qkqZQ(B?Qk}Ie&W4Cu%?J5n9o06Rq zk1?Lfi<@$!{?@!$ars}LH{{p9cKsRFV{kNYJ=-F+`tPfBc;;p>ez<@8%I}+XdzNkJ zVttUcUeljz)~C2hg;ocaRsD|)KF>5)F#18koB5?Z;m5yTz4;`)xNPpkH8;2J>-rby z#aYGCjqoY??ZX>~4K6cg`u^Pup7O{g0XDx#I>#Be%c*`;Slm zdf$qnA+&^Ho6|=27WIao44%pt68K$|I@-llj2!2Qr}%lO^E%{m{%BI=Jk2CED?mib ztp1jcfU5Yv&0NyyrBW+Gy^pFTZXzDL&0IAm=*UGz>`i^~K{{;F=H+Mvst8E*|Zo~&s-@gQeQT;GQ!_f1$m($`nq z>fHLIPB>g7QE&4f!&|cpH6}$InvzxC!n#;?nUa-A!;`81%Vz4{c%yB`B-Liii+Rws&LM(S*Mg}7bKf|6^;)Z%GFGIiHli%U>KXi@%mc{1P3^UeR z9sm69Ypkq>EN{gc^LGg%$)|qD3QgBq8UN;S&p$ivg%;uMf1dRFJ)U~%g_1{(?VUG& z{N}OolyuHm{4X|Ne(gFNo0EpCZX3qjVUv51c{N^-$zzj?Q-|(_&;Jqc=uA8m8ch1n@%)HHNJ>CCyZr`v# zW6ke<-aL|rCRv7GjawV%oTD2vWzHjhM$YeRIvm*EZ54H9xar6^KmDmrfWJvoNUrwp zoo&b7%|3RCPmpz*_on!$0Gl60f#$5%y%y5FJHBjPe~o{(F-Py^?vpGpJq{#svZjhV zguIsiR+C$kAI!>n_qoji<(2=#?eE_)|5c_m&*|K(0QRqQB8B#NMPD;KGAULq`Hk+3 zsiD%mTl^YzJ@X_p`_?|%y5@wD(=~>rJe;Yr>dfR){iAUQ=}%S312qw>(yP4{9KALd}0 zbT_;FL0J8=-MLc^sS2+%p8w#A>?tELrd7v}oy=_(ImG!`KcWOir#`8ipyP%FFCm- zxjkabl~B}-bY<$f_^!w1yoZrPwEBN$M?c2OnS1pnt26yOEAWOBdTh>bEc7-FrV_b$H0W zdB1-5OgkW!?Ob|lUsuAOxEtT6U)VsT-#PK-)O^mrbL6I7lM}8!#eMI)zhEZ|r*6>~7se)&j0y97Con3! zF;Q^F1og%S)y+JQdwD0RTsBFJ*uYT| zYW!MX^!D=zhQ#}?w`}UyDqeQo-jR=eWBG+sKShf4f3QTKj{W4Pb}UL~%P%`;?;phh z8B^jO?)ma$TkqFvWn1t4E5Ecyb?*`uEm5=Iq0g4{vOdoV-O{J1uz=6t0!@Q$UeMi)s|xiuZNsU zaPgkla$wf(pAH9S`Wo%rURm^X!Q+Ex8=WPUt`}!*ns3oR|9bE3+-d2vH=ke4R{7$M zjZlZ1@T%NNhCdY89<(P!*%>P83SY`IRnX^|;QV^g7HO@jKWqy6O_~lZ2Y0sWslJf$ z30Tm;uyNyei|vYHcElrV&2olhTf@oU_f2*Uvut?A9ns1ucGKndoyIw=vfk(C zKUMlwA$U?U@tD-vRJo0!+(nPSJ25?E^E661sJ(>Ok;(Dmt>RUVg{_+%rL_O)WW2vu z6yzYVaho2SQ_^+T6;Uil7zK5Uoz;^j$29&B-pFaVMf3T#^rLgdF1lQbxTQJ8_F2J) zL#>5X5zVXiPEz~U;~AhRdn0>QeE8jag$LHJeDmfu#R8x9U{ZVnrlm^=Y@ z?~F+bMJ_UZ7oKFueXv}(uwjO=RH94j=Ib**pV~j+``K+A9qUY1hyLB_)TiCGx$LS& zlZ$Dg0o$h_PK_819RZ=#<&POJnJi-rFx#KBdU4J{2^Nzg`)bwd+BtW%+6Bynn$qkg znxa{D)+Q|Ad-!CN=enr9H}Csqii&=Jw_t_lnuT1q?!Mcg;_1Ea?1hT>RE~_Lu6jud zk5-)up8UV(j=__vGOr6pBGRj`Z)I;;82WCVVQE4iW8x8x#cUg69nJ_Eay(L6XsM~N zX@_fq!9rK2S!PNFn|3N1-2Zy6d+xzGd&LX6uI4OCj$gfM=d`n1UK>Brd&zR8p(B%{ zyxcGB^Ty(`fb<lT#$LiHjufP937$aQQEMSi%<8E%=c;0J{*g4 zurr) zjhoJ=ZjE=0oHO-yw*RK9gIvnj%>H7B0vXqj_vWw>) zgjGaNEH}QiVNLGGQonuEHZwDH|7DvZUKJQ6*|5}=L$J_Oz+a6;@6nbw&-<0O{j(H4 zbRpLM)#vo1bLK=OYbOaa3vbG*|5vop;7r;@Qj~#+nQ4Ytoh}*EI2I;oum|1w%t%Qa1uK*nbnXxNlu~T#@}hxxuS*V&PT6& zyh&?Y;_cl&&sSLScNrw4FD;*t&dAUbqUx~D%z2+!?fywZ&lP7dL~Xa9nHL<@qsO!2 z#LXED+seEjrgyzv&81bYm*WmS%gpO4|9EBH!dpG6t1!sh)8n|w3o#;2!%2-)H>gk1ygL-|2B~R-+ul=6Q z=JrsB_2#v}Gc#;Iucdw%2;}DKA#|?sAsv3Ul%r?X-oN3&^-ZFWu z*N0QA#oZEyhqmlA?CG#nn53Y$z2i)WkbtUk#+pehE_}UNc&O#{p*V)g?<%J_=U!1i zDw@1#eT(1C388E33wjCzR{!JDaH*EfG_qXB=fYsIqB7v#`)%UwE46dA9e83ZEBxQu zAKnpg{zmN-7atqTwV7vxPkM%aT(U8Jo0fcylja^jH+7*lJ;Akn) zu(Px^+6?SJk2p7qaznX7ZZ`lvY^_;v5D?vY8gjnk@Q*Ek;G=*+F(pV?%0qf!12bJjX` zXZ1*<%#&`64u8(9xPI@(zl%Qm-EDiOt_^*DdEdPC?|qXUy0SfWs#wckS;@`#=Odw{ zt6RQkey4Pb+{|wQ1+q$63+D7l@rNwh@@Sn&&f2P@bC+4yvrJ@qq#>;^L2_dB5wj?v zJsa)nn;Oftuf0AJXCpbg=WY180G=nd{{?e7&y{byU9P&s>Zg3f0=?5bJ2U&2=eDwL zVA)kBY;fuU6N`yAhpzGy#T~H@BHR9bJ;71jG=uwxV%_~?Tew*{0@%(^4Bg?qP`Sc? z&-OK698RbN#N1AJcl?ivT)6FghMh~j>$qM-bV%!k3S$v|q zEG*S%nzz#ZWHz<7c`XymRvvY?7{Y2r!YPJ82P3a02_gDU(yz2k)!cX-~%nYI& z0SpWb!I2F84T6H}?lx}_(Y+Xbu0vOO3vbf7u(xxoE?i^p<6eE{H{)tmfhnBaD>gZb zmfQ%4JDVc?C&^RDY59)q?uK8d>ibR{PT9L(rS^aRc6QYZ^BAm}9p}~tT>UN0BiScp zb3nc2XGUX8<=ZaK*}J3!=Z5%M2CzA}2Fu-^@>;Cn)lQv-Uh||>;>xmOIM-!`Oqi&$ zwuwv3E8wPtU)v)_4YSbM*Zlvd)c#!k4% zFjK1_u5gD>*rF_{sh5>Evw!FgQvJ8n>PN++#Xa&dUbd`}M_-&>sB%j6!umDm)t)HK7>=$%P6 zQ|{fmbuUP|AWtyKNbEoj^E(N~lMM_T_kCNyHBHe%MfkkahFU+?C5$#0pb{O?vSw;PK}WAfu~ zU0h=8x2z@_xWYx4zt~6AL{rhHy`+C6*N%^bam7Xn5n3lZt?@F<6YtE^tEMV?cN-?}?b^h7& zJU*uekLkLS)9s63-6uzl4hnWAmGrj{^0Zn-X>pcYIvDgq!gmM1NGgS8BMq|*+RNol~x-W zAG*6oJnvcIokM5tDjO+jZ+`Xv_J)epMc+-#t)JgG`qi!KVfyj->sssnR@<0r*M+aB zdil$#MQ?A5=PW2+y=v#D?{DAVy;uGtCLv38vTw!uEAnYl z_adYB$cU~!q_y2h@bej!!|dAEPt^*(TTmgQR_zm5B=Y^Jwr%vzQ!DR={;gD7?|bI^ zrMjO>LpSRFohi80Yx`|~hR}b)8)ElQUH$3t{e7`2zgFFO^UX0QNnPG+v+&xpG9EEA zKPVoNF5U9_TrTi<`K15Ti+&wb`Tzc6`hN`uomkcn z@3p=j5@(G2s?E41=xg-yO?Ot@ySwt1ZSdA=YrijXBCDjXpZ^!$x+=H+Lj2v(zY9H_ zC&;jg-p`DgJ^kC-Z@-h?==gBxI0*>GHC_0*ed{e>#q!OQv(1W*uHBWSynOAJ&sQHz zwHJ)&<=P%}bE{UI$f`Nogit_K9ha73M&4vqf5#`1KD+sk#m@Clv-aQL$@$;+ z27ku4tLp4Ks^o9{R#?touqe{5{@8)Koi!0$Ic%pE-)h`B?HbQM#f|nm{O8ZOl4!Xq zeo|}-m-O7=i7~5kr~QdPT`zAb6EQ7{apI~!$G>O1Q?=YZK{M&D#eVkcwPjaNZQU9( zsdHj z8R}0}h>G);+nxNH-u1=t%c=LR??Z0gOpK10v?TlTvQ@=zQ@7N%zc=Um6>`74C1v}X z@2nF0^LKM^&P-Jdzw@{hjgv-|AR!uGFy+7WLfR+w<+6x7~aXQ z*#D-BV|kJW*No~~u`Z2mvNp#QuG!q5T~sf2#Bn!Q>y%qMr?)*WRSeJl{c+c-tNU_) z>HjwQ$88^4Uy$(MZhBGn@^5L&^FmKel`g$M`}8jxo!)Y-_2F9EY9HNJKVlvF&MUNX z^{w2HSo6OTB56n8O1zJnZ(%L^@kg?-g!SaPy}nZWWzUOf{lB;JEBn9R?|)`L`G4PQ z|NHBCdw0v8V|)>Q_v`unSKM50G)<4;y-;toVbz>h7xM3Zc`h~eX7&4Tx2Lb0+Z7-?5N$#{A)$Cm%U$oPprsw>AD?X zwHaO|o{`_j_RwnTYPX|bSM~pV=VZ7bIC869+E>#9D#|LNqc6m3etwLkhvWs6+ zrajH~f6=v0{_(y4%D>NF`4M9B`!K`ny#IPFCns(=l^XkXW9{V=vA;X_7O7wV_4Hq= z;`UE}Un~nN4nLJU=hngvKb+R){od#OTH`0b_y5ED{(rCdHlN8MhQVVk(~VUjoBco4 zeLuFIX-DYR)xR8%t5qfbjEr1SB<#4!OLX1+w1Nv0d1bH84Y_8g@tM>4_Nleq3$5;m z^~OJXE!;ADZp=gezgmB|7`DFZs;bZ0@ZOcH{Ca)gs@EZL?SJ>j+Fv&3;h4Lgul=9` zOQ~H)?_}-j6Mrtc`d8~dFdKXz`JdTZ0ma7Ug{$K~5zepBa_XOS!XByYv$ z`Aaj9;m3{p%X6hWl^W(woxtYWnb$q$TB@QV1693bsc#oO={44d?IU+B{9sQ#Q=u##*7Bjpn~@Ipu}A zU6f(F@G)U~)XCDNvldNU{p)?l?%AxJ|6?_`@A}Aj*l+GOX9ZiGl~YyLhSuy05`4ej zR)b|mF;mRKSL*`5^3OfBJ1TW==*uko;v4n;)gD#+x6Wq$m&um?u=H2ouMP(Fs%NFE zbgvfewA-q2b!}JSq5Er|Uh}(K|3h`_`RLN!RTe(^`urzSpN2nGdAHT_+w=Oy zgwl2Ola`ptt(IOH@%4S-=HT#aMcZOcegF5CU0+%ITKMm>U}>i7Rag6cLtoxc-Y>uJ z&Wh;$6Mw(+n!E1mOShvRu5ax+fA-Rk-r$|PKXVppuuS=@{@(3zfBaeDdpB2peE#64 z&k?oU|Dj6HqfDj$f8RcH=FY%R|6|r}voqk2exJkm&wc+VJrU-psqrOR+s_NHX598S ztV2=HnDxw?8`4}Kw^giM5pZWdQ-I7FCV4}#maB;mV|ov%|6W*e<=0gAosR-LzUEwt zU-oF9-OX2LcnSr(rpB&JdTK8ldH?Ij`*ZuhI~k^AhzGt&|NZgGn{OHyw{7f{-Ja_{ z?YBx{?yu_xWxEfTWgedt6Ja?^X2%oLOc`NKVez?_bJqOT>tf#d(L(Fbl4HvkNr*3U z-go)i>IE?~Uhs6*2d`cmmUa5JotRY8pWFYk;|@<-^gJu{bf)U^|BLfx{QSSw^2>YX z8{hpI74#Tx9AcBrY}(`}))x2m@XRlenBaCXZ5cDwz6 zMv2VVS0A2VxDk2VP3o-M&%XNL6Q+K#k>B+Xt^V-7yvX&gzU8`gzlEFKSsS!h-d;AL7c;)sk!G6M8F0V`Xuf7Z}_FWj;;^Yvbz?IJQW?g7l z@2j%Kn|9u2PgIoH8Ta+{a`*E${#&VVe!DO)<@kQ-@C)3_^FrUIzmbyXXi1k!Gkk1O zcVpJIcmDUUdKK9jN~Zmq(-mti_vKa6|L-dsi#(-I@a}UKO8%zM-zD*E-)ya4Sq>%J z9CP36e*Aay$&qypE6!v!96NtX&UAs>YSCL4`f7_Na4q=jn7dZ?OKj-F&w_2sMC#?e~$_^OCdttY=jum4Q)SouiaYU=GE*S(=Pb3=B_J(Z}xZl>*dyV*-y zuej7p?zjKASA6QOMH92NO0%!)E9TAF6+Y?BbLI9WN!ppRYwHY-_HQ_|tMtzIai=n^Kl0^& zYh{q0-81Q-cIaEbtMwnX8DD9*v z7%k&nS}H86cG#sYzO^{OChO^`(0H3&tFHcYSKOXgb1D9WX=}{RkA?B8*L~R--|^F@ z=h}B~#dn{-zp|RTP@>>&J>#+&-G8SYQ7e=2>%CpJ%q}iaRDFZlyA8?Jf1b&P(wpzM*-o=7-t>Qt)chR9U9XNmT_o|`+T8Abrq$MkclqZFlxBs`wtE@>^U=#) zQ+}Hr`KJAU<^F;P_a8l(&vf8-Ji`Q&UrG(O0tPxi*ZnPP@Vc7+KBVR5y}(7!wa-5c zd7FKIV%Tjvjy1df9j?85EIj$~gX?o{t)ClzPL?&jm0zum@xc4!wKWH##EPD-nxCq3 zyDipAw z@6s;w_b9&5=qh<`5G6Fl>!0&+fj^V2{^}RkZ*ve?&+;Oq?%y8%LyrTPFP|-)pLsK-%ISD%Vrj@ zd#jhl-1&6k%K7DUE*y95nd|8l_U}LE_f>~4XLR4HU;W4GPW#^W4}QOvo_Q!YNn625 zFd^}R;hGZ$Co}W}d)asI|M}TkYk67tiO_uv1}gugw_cqaQ>$J6eWL##@A(=nwp;#w zE=v3AWiFGpv)*p$nM+ZYQ|GS_$ypcruI`pN6W=*b4$k_Bi%))c1_%^HPrZHiM`gQ$ zYUGTIw)VMGta5K`S*m#RYwn3}vJWF~x7f2>+x4$;b?Z+L-!XaV`Dc!*eUz{fT=3sN zUFiRhJ9F!O)*Se~o9pl5cuoOvCZq3mA_mTi2gHrviu=2HKm6;`t9!1sEb6yt|Kh1B zD-WgaiB-~)YyG%j#qVk#-;9<;Pd4nS*Zscj4&tlKw}1MfCw0 zMrGd$J%X3oGyk}7y=1;!%=2GT2b5pis~=aFKRoSd-PgOH!|rZay7PY2Rk45E?pI6q zhi>|mzVyn{I|n0`R_5+qnfhNo;?w^|aR#+8MvwMr#=0-sjCDbVB8+oR-FFLpeoeiy zY}=abTK8A`x9`pR<8$#;iNLa=bnUd>;O)i-BW33BF*{TU&O15P{`=hen?3$K+uG}2 z-h9Y;=wpLHNV~S^$F0|wDZhyl-5t6tsA}1h`)ZXtpKmhOR(Nzy@y$}1Qoi$7`8b30 ze*RZ4{dLf0fBes~jT<;Nd^8qL%un0>k@L#geYGu3-X21a(&wytb1e5s=#)GBjWsve zUz~63(7w%{@51rqdi!s-w?^rnzn11QC+ctb^fuv@Tu}4-7b?k~^`HOWJLlyUR9J1H zJ^NSj?$xzI`+3>_Ox3E(WS;Zk=iw_{9MM1LuRiwML1SysLA6DSlHKgPi`2JsHzv$b zHF^IdAJ$p=&*Aj>%ih^<6QggwW!$#kz%yTiqs8UIIf3$}UGX#P zl>clfe*0{OWN%1y*X^L-mDO8sZ(q2xW&f^s)1Qm2-Bt9o`IYoZvCvqJSw9c#`s`S? z{q_7_mDWY^SyLNw`;YGn-Wj?2$3AC4gD--MYY+Xq*Y08;{$Yo$ht2O@7kl>~&-%gk zExi+~#`VUvL3!L)ujK6hyVqGh^Y_Vz`y-eB?o!<7b3}0ZKDFQf-<+Gf^GpwGf6><~ zE+2z#INBGLUOc7>-|(;vEOgui-|c|Vb%;^Kxerhe0RixtuC*Z11xKbZEWXK{;J zonES9_^bpKJD1GjwPi14-V`0aQ>WnR{5+xd?fbLqgg9LrvPEyb?PffD$z^%b<&`<> zzHx?dbUkMAn)Jqap3gktleK|ApVmj$f2#aZ?{g{0Z8gIoo-~dp?QfMr;~8F@Id7#W z)U)pWk+OmhpYP48k9%MLZ|CnVzh(K)?f&pv{%!4&G@Zvw&y*?LKQ^^~bE?wG`~G=L zn_`n{-z13pG4p%h`DZ%y$A34!``#yKny8-J%uyRX$7fAwi`_Xnvuiu{o!^jKdF8rm z=Yy2lar5n8N}qk^H98wp*~x_3Fi(=%t75zWE)z+v~vs z+jC!5?|g3j^WT$WlXCx`YpLGu-CoSvU-8sgqoL)I`;RqC*8Mu;rj%~0 zH>wS;`R4b>($6|fUgcle%6gAK`Bz^0K6AKrXZP$Gg7RDyX+84y?p9p&T2wF3`dN5m zXKbs)u?N4x-mSBrv-&x*wU^W(i?WQ3%3HUD<}Z?EL$Tp9B(XTP3FaryGQeP7~_&%zr&zxkbg z?<)K5WnWX(j>~=C^ndZA%Kycd5rxyFr#sJo^!vQH@BM%K`913`Tui>q->LfFe#K?; ze~au6fYRipwG6?o44c{4#;&xG$f*%j;A7$!TKj`tc-5pbv14o^GgswvsI8j2tA20L z^6Q6l&hAtE&3vG4d)UQa%m>;S53byz&Ae<~?T&X@_kS*Z_cV_83Xh87$<620Y{Yl} zS^Md{atP~T&QptBUd}kDSRznw^8MS?NZy2QhO(s{oJZ=mzni@L?$%SUbJZHVzP&iw zeD1@V+lLQonMQ}th@Y-4eBi!D^`18ZSKfb1cRljl?Z!51Es+@q-%Z(O@O87?zrT0) zH6*61c4tIx%lmFT<*@CKNbl-@t%rL4M{ymUeo<$w{Eb|t?Cm}tzjK8X-4a*)x}DAM z=_v8#%)Cz%dUze@$-h}u^#A&YzV>f(=6>A1Iir5s7jEOpV(eGUCTu%ck~RNrv}k;Z zR`licmTfY>c7yWnGv~O|z_x8HnpSb&YJ4Z?J>W)c&CyQ;1s&d-l0WoXt$q3>{_4y6**A9@ zIE#0OKWI(=kUn*>{};DbhU))kL+{6LmEQmNqgpJ_#@5q%J^bN6Hy-F?KfieGboR%M zwkz+82OqK!Jl3SR&(Qeq&L#D+Ctq$gkrDZ+Rh2j2V^^Tyqx~OzrX8ufS(l|$cGlO1 z=kb@fXHHj??l`LT@AKs3PQGmnW$*vYx+Z=quCHUGobUggDK%C~*MHj0DHT!wSNMPC z?f3dGoqhgkG8|!TSh<#AA;*XNDu4eUNk~pR^QLppN(BRzsJ!j|UVK0LH0HIQWL@rj zX@97>%pXmrteO8NB8~@G76LxI(%s5=k>6pDuAZS*f z;#7^NR_wPOBen^AC|$-ef%8b6`9ufR`GqDgdeR>HNN+s3_wIl3d`8Xh^P?o2e!X7n z|Lbr3*PK6dD|Xi#)HJK@+qZd3^X-$s&tua495>2sjbPI zwHC~MVf<+3hmhHPxoKDO^sbcOC|BFE_`@9M`x9RUt7eKsw7;LEEgMr`xY|j7$+xoq zId1c#|3ogG_g47nozExPZiavUKKqsaejyT7GzQZ}^kel()NcAK9LJ+j#zL$Nn$pb$V%aB?>sZa@=0P3J zZFZ`m7QE~o&whzcs^?=BrrhQiv1*?Yt2#+bXla_tqrX?TYZY;Z)c85iIdaRz{Hog@hxOvKYZdIxt7pzS zDZ}*3TC}Qe$DNyEyzegaEn~aeY^-DSUDMNirb_B7xg6a#=_>tyW|D)<^c!_GnYSg5s{Y%%;jPLm^UL$p!z5jWMZ6#N zr`Xt*`>uRlH81jK$s^Obw#_$f-^Jvu`fpPeQ>VE+{F3ARNl}KP`yNe=`t-k1{l5r9 zUnJ**s86zC%r~4IYF2IYT$P(3q8>H5xt;gipH2Uo86Ez$a;Vp~Gd`&PKl=vrfwvk9C&)7Ogt3Wp7|6iU1jGK=tnQl%fmjB}Otg3z2pcyPRQ#iLHwZF1$WH z^Lg6uk2_XWAKOuycSr8uzpOvso$pMv*q@+h=4JP9X2naz^)J8P{a|@IW#L2Dq)E@V z{@pwOb*6P<&8cm_V`AoawH66i%NKm|XG~bdFv07~vuX2=Hs`8w@H7UlO7<*uvX5W= z`>?k0?@8Q#!OaKe|NZ~H`^fW>Hw^;QelKHV`TujB>UFtEYl=Np%O{^#{pz!fNk=;7 zY#CqR?F%jwHAFj(w41tm2G0nMzGb)Rywdis%JX(^ar`^=XYoCiwC{h;*MI!FcgFs2 zPxkIPcmMJBLdM54cmCTwbAfu+;Ypr;YC=~HI*xaq{95+!-lLhV;%6-+ZM7=4z5jkE zVfw0)?& z?{oV-*cY)gzJC4E&S_HH)TD2FJHvZ!DizIe{geHO|DTER?itz*XYRh9dHX=*@kKNL zy?jzwmG#u`mB6hZHnWns7=x4l2>$*3sCwS3?Qc%)zVjwVW^I|U_Q#_02fjt_{CHh` zZ<+M@ZFko<_3-=ImFZYW@Glarx+!W=JlUqcE6G;+`AvO|Kl@)u<@#(|B45Ay(fq$E z2MssAcHF=C$PL@0YfQS1Pf-1Maq-?|;)hf26vnxH{PyGZkk!_Iu;ABbyC^55Byju;9q^l4Wcy zS0-(|Q<|@LXIc%++RF@@w~xo#(f{_WmZn(WMGx*Q|VS>)Y~e{##eC z3p)RN&iORYddctnE35er1-)JQ`^??fm&!I&Dt$Z=^55iZ&i#~IS2k*w|8u?Td35*A zCGTT4cE;BJNOGKDcQs#>`}n=!$D3!Kl(H6{rxhe?EcPU9EwA>Mpyy^sH_!FHeNm)3 z??{Y&-TZBZXS9@bZ@sU(HUGcFx=iPFrJsJ7nCqPv^-CB2&u{#5Im3n$Z3nHBv!yeh zH95>q@h!jhIC6%0pp!WJa&Pv=m5dLkWdDE3cx#gHhrUX^<0S&e_lZ8wkl&`TK=`)1 zP4M@mw4I-V?E9w87rp6paOdp<$Jy1d$=`cEyYs(VIls5i#*jjX_N(=o_RI-?6}P5a znSOYh7TfPB_UzWDjYrqSM4uC7e)g+Z_3f`qweG9#bG+4<`r+RcjRkRw{P#ZDJhQs~ zUdpfbD;1ku)-BlcC9b|IX`{!xpU;jh-kI`t##C$XuJYp67ZxwAQ~y|&^30n(*E)^I zuFSO|ceS^Ac3RjG-sHwD_ua}*MQkXUl>REgqxzY*PU*eVnuV`5H+tH9{E(Ko|I?j6 zmh*jkUZ4K`^o!|V(_3oRsy6*guYVD)yyklB>CW7s?q5^*jXy4DC^+TSklge*^Qe-a0zk`;=q_GarfxD4*~5!!F44-lXS6MeKs*La~JQ!f{df266ZPGE{F$?)-kiHP z?R_N2jMwj{eUs+DTfgVbIzCVJJ;ChjmrhH)%`DpgGI;sC^R@HOhS+a>vadL~%c%Xr z*}K{6{{Q&cc}($nMsV@_kCQysMsMg+FHLw5()dbfWY$$lOHU83_;+fnV-raSa6TlH+^x>T9!{ys1 z1~>m)Y4%~l+XpwQV~YNI3r(pIsDJza-(}YNYw^PD1$uK!ZPg-ogh#*YTHN$%z5yUuwGou<=L_3b}8;@B0)tdMZW*ztb+eG z&+q-^WM!B$>4h(^$;ty6nTt%P?`WO*H{Zzd~HA(hJXX_&G?fq9? z^(Qk|xoW%0d0Tsk8r03m{OmpNqaovmJ3I&OtT}W^<;%U3ha_)(GCay~yKG+2{lc93 ztoeW6tG~;B`MGjM`sCNj`(|ns%(1GonQHhXcKgcTDu2IMhRa)-?Tolz+Aoot=~$Pi zbKywEhLpC-ESrc)KV*vUopwI1S}e@(9lPj$MbGsw3wL|OJ3aDed~m6?A;jRvan|!c zRv)pCp0#EvABUBzR>$1k-`1y1T2pLkHgRsXTKak(J)b$}Zrb(vzBnH$5>-{Zd|PXX z(*LuqaW7*gKAtjFz^X7zQSQyTNjh_q7rgG8JgMjRmY3dN-+$(8-TnK^`{y0LdO|DoEBK1fKUmG(%lcpM z;4e*1GV>~KlglhUSf z{idGhjXqw#epwN}nRlx=g3mu&l3sL<@6m~GAAhr+|1jsRbGjCvZAf%P)DDYh3)U>X z{`V>?+r8xUn0~GgdZrgjkKX*??iu_vBvlqt$k8Hm|;wG_w4- zQf~6q*edl+<(lLVmyUcFYF)K+o5M+ zS(D+xoW_qa0zX9Rik|QKcBi3eoy*r1=|wO37HQu*X}mAx_Y1k3=l5AW{yINM!Q%UZ z@8`P1#WSu?&oY0mSG9Nb#0|e4-2T67to!%;Tf*Cyciw*t?)1I)(c;eD#~UBa`1nAl z^SpJRcVDjZUFEmj^2Jp@f4fh)ZW~eKe#oEkfu<;n-tXkM^PZL3FH8Sqx1E2^T8Dyl z2S0f7m%Xpvz3=?zx>M($`8;=jkSX@=-tNL5JtyDS9&dIx75nh6>EC1~hItnr{0`7R z5G*Y-FX{M)kXy%-bGX0TXuO(aJd2h2?&X)x_pTP#g`a<>`2M@IkHNfC)xW1CPj)Z* zf7jXV*89`dzx$58RWJ8d`MH3Bf#H4Oq(cep3_a{U> - + @@ -933,7 +933,7 @@ - + diff --git a/port/assets/menus/menus.json b/port/assets/menus/menus.json index 803f8cb..d470ca5 100644 --- a/port/assets/menus/menus.json +++ b/port/assets/menus/menus.json @@ -6,6 +6,11 @@ "ce/main_menu.campaign_select.xml", "ce/main_menu.difficulty_select.xml", "ce/main_menu.gametype_select.xml", + "ce/main_menu.halocustoms.xml", + "ce/main_menu.halocustoms_bitmaps.xml", + "ce/main_menu.halocustoms_friends.xml", + "ce/main_menu.halocustoms_panel.xml", + "ce/main_menu.halocustoms_welcome.xml", "ce/main_menu.mods.xml", "ce/main_menu.multiplayer_type_select.connected.4way_profile_select.xml", "ce/main_menu.multiplayer_type_select.connected.xml", @@ -70,6 +75,11 @@ "ce/shell.xml", "ce/strings.xml", "ce/port/blank__0.png", + "ce/port/main_menu/halocustoms/header_halocustoms.png", + "ce/port/main_menu/menu_friends__0.png", + "ce/port/main_menu/menu_friends__1.png", + "ce/port/main_menu/menu_halocustoms__0.png", + "ce/port/main_menu/menu_halocustoms__1.png", "ce/port/main_menu/menu_mods__0.png", "ce/port/main_menu/menu_mods__1.png", "ce/port/main_menu/mods/header_mods.png", diff --git a/port/assets/menus/port_svg/main_menu/halocustoms/header_halocustoms.svg b/port/assets/menus/port_svg/main_menu/halocustoms/header_halocustoms.svg new file mode 100644 index 0000000..282d7c3 --- /dev/null +++ b/port/assets/menus/port_svg/main_menu/halocustoms/header_halocustoms.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/port/assets/menus/port_svg/main_menu/menu_friends__0.svg b/port/assets/menus/port_svg/main_menu/menu_friends__0.svg new file mode 100644 index 0000000..15a4d82 --- /dev/null +++ b/port/assets/menus/port_svg/main_menu/menu_friends__0.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/port/assets/menus/port_svg/main_menu/menu_friends__1.svg b/port/assets/menus/port_svg/main_menu/menu_friends__1.svg new file mode 100644 index 0000000..896e037 --- /dev/null +++ b/port/assets/menus/port_svg/main_menu/menu_friends__1.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/port/assets/menus/port_svg/main_menu/menu_halocustoms__0.svg b/port/assets/menus/port_svg/main_menu/menu_halocustoms__0.svg new file mode 100644 index 0000000..d618b1c --- /dev/null +++ b/port/assets/menus/port_svg/main_menu/menu_halocustoms__0.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/port/assets/menus/port_svg/main_menu/menu_halocustoms__1.svg b/port/assets/menus/port_svg/main_menu/menu_halocustoms__1.svg new file mode 100644 index 0000000..c3075f3 --- /dev/null +++ b/port/assets/menus/port_svg/main_menu/menu_halocustoms__1.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/port/assets/network/brokers.txt b/port/assets/network/brokers.txt index e2966d2..d5730e9 100644 --- a/port/assets/network/brokers.txt +++ b/port/assets/network/brokers.txt @@ -1,12 +1,13 @@ -# The public MQTT brokers of internet play: the machines of an invite find -# each other through them, and the server browser's listings go through -# them. The game uses every one at once (up to 4), so any one working is -# enough. One host:port on each line; "#" starts a comment. +# The MQTT brokers of internet play: the machines of an invite find each +# other through them, and the server browser's listings go through them. +# The game uses every one at once (up to 4), so any one working is enough: +# HaloCustoms' first, then OpenCE's and two public ones, which OpenCE's +# players use too. One host:port on each line; "#" starts a comment. # (The game reads this file beside config.toml: network.brokers_file. An # update replaces it; keep a list of your own under another name. The web -# build reaches the last three over their secure WebSockets: -# port/linux/src/p2p_signal.c.) +# build reaches HaloCustoms' and the public ones over their secure +# WebSockets: port/linux/src/p2p_signal.c, halocustoms_services.h.) +halocustoms.org:1883 opence.milenko.org:1883 broker.emqx.io:1883 broker.hivemq.com:1883 -test.mosquitto.org:1883 diff --git a/port/assets/network/masters.txt b/port/assets/network/masters.txt index b428cf6..cba15f5 100644 --- a/port/assets/network/masters.txt +++ b/port/assets/network/masters.txt @@ -8,5 +8,8 @@ # of your own under another name.) # # A file that names no master, as this one, leaves the game the official -# master, built in: https://master.opencommunityedition.com. A file that -# names one or more is asked instead of it. +# masters, built in: HaloCustoms' first (https://halocustoms.org/master, in +# a release, which is given its key), then OpenCE's +# (https://master.opencommunityedition.com), whose players and servers +# HaloCustoms' play with. A file that names one or more is asked instead of +# them: name both, then your own, to keep them. diff --git a/port/assets/server_ui/app.js b/port/assets/server_ui/app.js index 5949e9b..c408c4f 100644 --- a/port/assets/server_ui/app.js +++ b/port/assets/server_ui/app.js @@ -729,7 +729,7 @@ function startPage(root) { const admin = roleAtLeast(page.role, 'admin'); const masters = s.masters || []; $('server-name').textContent = s.name; - doc.title = `${s.name}: OpenCE server`; + doc.title = `${s.name}: HaloCustoms server`; $('server-state').textContent = stateLabel(s.state); $('server-state').className = 'badge state-' + s.state; $('status-facts').replaceChildren(...[ diff --git a/port/assets/server_ui/index.html b/port/assets/server_ui/index.html index 97887e7..feaeeaa 100644 --- a/port/assets/server_ui/index.html +++ b/port/assets/server_ui/index.html @@ -5,7 +5,7 @@ -OpenCE server +HaloCustoms server @@ -13,7 +13,7 @@
- Join Discord + HaloCustoms.org
diff --git a/port/web/site/manifest.webmanifest b/port/web/site/manifest.webmanifest index 83c9dc6..ea01f4d 100644 --- a/port/web/site/manifest.webmanifest +++ b/port/web/site/manifest.webmanifest @@ -1,6 +1,6 @@ { - "name": "OpenCE", - "short_name": "OpenCE", + "name": "HaloCustoms", + "short_name": "HaloCustoms", "description": "Halo: Combat Evolved in your browser, from your own Xbox disc image.", "start_url": "./", "scope": "./", diff --git a/port/web/site/net.js b/port/web/site/net.js index aaba5d8..299034d 100644 --- a/port/web/site/net.js +++ b/port/web/site/net.js @@ -28,8 +28,10 @@ tests' own). 'use strict'; window.HaloNet = (() => { - const BROKERS = ['wss://broker.emqx.io:8084/mqtt', 'wss://broker.hivemq.com:8884/mqtt', - 'wss://test.mosquitto.org:8081/mqtt']; + // (as p2p_signal.c's DEFAULT_WEB_BROKERS: HaloCustoms' first, its address + // port/linux/src/halocustoms_services.h's, then the public ones) + const BROKERS = ['wss://halocustoms.org/mqtt', 'wss://broker.emqx.io:8084/mqtt', + 'wss://broker.hivemq.com:8884/mqtt']; // (port/linux/src/port_config.c's network.stun_servers) const ICE_SERVERS = [{ urls: ['stun:stun.l.google.com:19302', 'stun:stun.cloudflare.com:3478'] }]; const TOPIC_PREFIX = 'opence/web/1/room/'; diff --git a/port/web/site/servers.js b/port/web/site/servers.js index a3bf0e1..d594cf0 100644 --- a/port/web/site/servers.js +++ b/port/web/site/servers.js @@ -37,7 +37,8 @@ seconds while the page shows, and joined with Play here (the page's ?join=). const MAXIMUM_ENTRIES = 512; const MAXIMUM_MASTERS = 4; // the official master servers, asked when masters.json names none (as - // master_list.c's master_list_read_defaults) + // master_list.c's master_list_read_defaults without HaloCustoms' key: the + // site's installer writes masters.json, HaloCustoms' master first) const DEFAULT_MASTERS = [ { url: 'https://master.opencommunityedition.com', key: 'ed25519:89f4eda71229f11f3538679f49ef7fb0c2b407b380093f7f4fe02105b92225aa' }, ]; diff --git a/port/web/src/web_library.js b/port/web/src/web_library.js index f97e5a2..27c823b 100644 --- a/port/web/src/web_library.js +++ b/port/web/src/web_library.js @@ -22,7 +22,8 @@ addToLibrary({ // kind: 0 a status, 1 a notice, 2 an invite link of a game hosted for the // internet (to offer), 3 a fatal error (the game stops), 4 the game quits // as the player asked (the page starts again: port_mods.c), 5 a page to - // offer to open (a Discord link's: player_link.c) + // offer to open (a Discord link's: player_link.c; one of the site that + // serves the page: halocustoms_account.c) web_js_post__proxy: 'sync', web_js_post: (kind, text) => { var message = UTF8ToString(text); @@ -118,10 +119,16 @@ addToLibrary({ // at most, 10 seconds at most. (async: the game's thread does not wait for // the page) web_js_fetch__proxy: 'async', - web_js_fetch__deps: ['malloc', 'free'], + web_js_fetch__deps: ['$webFetchInto', 'free'], web_js_fetch: (url, slot, id) => { var address = UTF8ToString(url); _free(url); + webFetchInto(address, { cache: 'no-store' }, slot, id); + }, + // (web_js_fetch's and web_js_send's: the fetch made with init, its answer + // written into the slot as web_js_fetch says) + $webFetchInto__deps: ['malloc'], + $webFetchInto: (address, init, slot, id) => { var finish = (status, bytes, signature, key) => { if (Atomics.compareExchange(HEAP32, slot >> 2, id, -id) !== id) return; var body = 0; @@ -141,7 +148,7 @@ addToLibrary({ }; var controller = new AbortController(); var timer = setTimeout(() => controller.abort(), 10000); - fetch(address, { signal: controller.signal, cache: 'no-store' }) + fetch(address, { ...init, signal: controller.signal }) .then((response) => response.arrayBuffer().then((buffer) => { clearTimeout(timer); finish(response.status, new Uint8Array(buffer), response.headers.get('Signature'), @@ -150,6 +157,31 @@ addToLibrary({ .catch(() => finish(0, null, '', '')); }, + // the HaloCustoms account's requests of the page's own site + // (port/linux/src/halocustoms_account.c, by master_list.c's + // master_list_send): the url with a method, one header ("Name: value", or + // "") and a JSON body (or ""), each the C side's copy, freed here once read; + // the browser's session for the origin goes with it, as a same-origin + // fetch's does. Its answer as web_js_fetch's. (async, as web_js_fetch) + web_js_send__proxy: 'async', + web_js_send__deps: ['$webFetchInto', 'free'], + web_js_send: (url, method, header, body, slot, id) => { + var address = UTF8ToString(url); + var verb = UTF8ToString(method); + var line = UTF8ToString(header); + var text = UTF8ToString(body); + _free(url); + _free(method); + _free(header); + _free(body); + var headers = { Accept: 'application/json' }; + var colon = line.indexOf(':'); + if (colon > 0) headers[line.slice(0, colon).trim()] = line.slice(colon + 1).trim(); + if (text) headers['Content-Type'] = 'application/json'; + webFetchInto(address, { method: verb, headers, body: text || undefined, cache: 'no-store', + credentials: 'same-origin' }, slot, id); + }, + // internet play's WebRTC connections (port/web/src/web_p2p.c), which the // page makes (port/web/site/p2p.js, Module.haloP2P): a command, the // connection, and a copy of its text or packet, freed here diff --git a/port/web/stamp_version.py b/port/web/stamp_version.py index bc5d199..3fb637b 100644 --- a/port/web/stamp_version.py +++ b/port/web/stamp_version.py @@ -3,13 +3,17 @@ site is, for the page (its About box, the update prompt) and its service worker (port/web/site/sw.js), which keeps each build's files together under its version. - python port/web/stamp_version.py --flavor release OUTPUT FILE... + python port/web/stamp_version.py --flavor release [--embedded-menus] OUTPUT FILE... The version is a hash of the site's files, so that any change is a new one; the build number (HALO_BUILD_NUMBER, which tools/ci_build.py gives builds of main), the commit (GITHUB_SHA, else git's) and the network version (HALO_PORT_NETWORK_VERSION, port/linux/include/halo_port_limits.h: which -native builds a web build could play with) go with it. +native builds a web build could play with) go with it. --embedded-menus +(a build with configure.py --menus-overlay, whose folder gave the game its +files: tools/web_build.py) adds "embedded_menus": true: the site's own menus +are in the game, and it need not copy them into the browser's storage; a +build without leaves the key out. """ import argparse @@ -44,7 +48,7 @@ def site_name(output: Path, path: Path) -> str: return Path(os.path.relpath(path, output.parent)).as_posix() -def stamp(output: Path, inputs: list, flavor: str) -> dict: +def stamp(output: Path, inputs: list, flavor: str, embedded_menus: bool = False) -> dict: digest = hashlib.sha256() # (what the service worker caches: the site's files but its dot files, # which only tell GitHub Pages how to serve it) @@ -62,6 +66,8 @@ def stamp(output: Path, inputs: list, flavor: str) -> dict: "network_version": network_version(), "files": names, } + if embedded_menus: + version["embedded_menus"] = True output.write_text(json.dumps(version, indent=1) + "\n", encoding="utf-8") return version @@ -69,10 +75,12 @@ def stamp(output: Path, inputs: list, flavor: str) -> dict: def main() -> None: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--flavor", choices=["debug", "release"], default="debug") + parser.add_argument("--embedded-menus", action="store_true", + help="the build embeds a site's own menus (configure.py --menus-overlay)") parser.add_argument("output", type=Path) parser.add_argument("inputs", nargs="+") args = parser.parse_args() - stamp(args.output, args.inputs, args.flavor) + stamp(args.output, args.inputs, args.flavor, args.embedded_menus) if __name__ == "__main__": diff --git a/port/web/tests/internet.mjs b/port/web/tests/internet.mjs index df71c56..0d5d019 100644 --- a/port/web/tests/internet.mjs +++ b/port/web/tests/internet.mjs @@ -80,9 +80,6 @@ async function browser(name) { headless: true, viewport: { width: 800, height: 450 }, }); const page = context.pages()[0] || await context.newPage(); - // Local browser checks must not wait for the public analytics service. - await page.route('https://nexus.mrbruh.com/**', (route) => - route.fulfill({ contentType: 'text/javascript', body: '', headers: { 'Access-Control-Allow-Origin': '*' } })); await page.goto(pageUrl); await page.waitForFunction(() => document.querySelectorAll('#checks li').length >= 5, null, { timeout: 60000 }); await page.setInputFiles('#folder-input', maps); diff --git a/port/web/tests/page.test.js b/port/web/tests/page.test.js new file mode 100644 index 0000000..d76599b --- /dev/null +++ b/port/web/tests/page.test.js @@ -0,0 +1,25 @@ +// The launcher's page (port/web/site/index.html) as HaloCustoms serves it: +// its scripts are its own files, and it loads no analytics, which would +// count its visits for another project. node --test "port/web/tests/*.test.js" + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const page = fs.readFileSync(path.join(__dirname, '..', 'site', 'index.html'), 'utf8'); + +test('the page loads its own scripts alone', () => { + const sources = [...page.matchAll(/]*\bsrc="([^"]*)"/g)].map((match) => match[1]); + assert.deepStrictEqual(sources, ['touch.js', 'net.js', 'p2p.js', 'servers.js', 'app.js']); + for (const source of sources) + assert.ok(fs.existsSync(path.join(__dirname, '..', 'site', source)), `${source} is not one of the site's files`); +}); + +test('the page loads no analytics', () => { + assert.doesNotMatch(page, /umami|data-website-id|nexus\.mrbruh\.com/i); + // (and no script of another origin, written in any other way) + assert.doesNotMatch(page, /]*\bsrc="(?:[a-z][a-z0-9+.-]*:)?\/\//i); +}); diff --git a/port/web/tests/rooms.mjs b/port/web/tests/rooms.mjs index ac875e7..ee8abbd 100644 --- a/port/web/tests/rooms.mjs +++ b/port/web/tests/rooms.mjs @@ -48,9 +48,6 @@ try { const browser = await chromium.launch({ args: ['--disable-features=WebRtcHideLocalIpsWithMdns'] }); browsers.push(browser); const page = await browser.newPage(); - // Local browser checks must not wait for the public analytics service. - await page.route('https://nexus.mrbruh.com/**', (route) => - route.fulfill({ contentType: 'text/javascript', body: '', headers: { 'Access-Control-Allow-Origin': '*' } })); page.on('pageerror', (error) => console.log(`browser ${index}: ${error}`)); await page.goto(url); pages.push(page); diff --git a/port/web/tests/servers.test.js b/port/web/tests/servers.test.js index 446ddac..7e998a1 100644 --- a/port/web/tests/servers.test.js +++ b/port/web/tests/servers.test.js @@ -86,7 +86,7 @@ test("masters.json: https masters (with a path or none) with an Ed25519 key, fou assert.deepStrictEqual(require('../site/masters.json'), { masters: [] }); }); -test("the official master is asked when masters.json names none", () => { +test("the official master is asked when masters.json names none (the site's names HaloCustoms')", () => { const official = ['https://master.opencommunityedition.com']; const key = 'ed25519:' + 'ab'.repeat(32); assert.deepStrictEqual(servers.mastersOrDefault({ masters: [] }).map((item) => item.url), official); diff --git a/port/web/tests/smoke.mjs b/port/web/tests/smoke.mjs index c45551b..d7bb8b2 100644 --- a/port/web/tests/smoke.mjs +++ b/port/web/tests/smoke.mjs @@ -38,9 +38,6 @@ try { args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist'], }); const page = await browser.newPage(); - // Local browser checks must not wait for the public analytics service. - await page.route('https://nexus.mrbruh.com/**', (route) => - route.fulfill({ contentType: 'text/javascript', body: '', headers: { 'Access-Control-Allow-Origin': '*' } })); page.on('console', (message) => log.push(message.text())); page.on('pageerror', (error) => log.push('page error: ' + error)); diff --git a/port/windows/README.md b/port/windows/README.md index d247e2c..f2d45b4 100644 --- a/port/windows/README.md +++ b/port/windows/README.md @@ -112,6 +112,14 @@ These files use only the Windows SDK: `port.json` gives the Linux files that these files replace, and the Windows libraries of the link. +`halo.rc` holds the executable's icon. The build adds its version +information (`tools/windows_version_rc.py`), which the file's properties +show: HaloCustoms, based on OpenCE, the version of the build's label +(`build-343-hc`, from `HALO_BUILD_NUMBER` when it is configured; a local +build without it) and its licence. LLVM's resource compiler compiles the two +without the preprocessor (`tools/test_windows_resources.py` reads the result +back). + ### Inline functions MSVC makes a COMDAT copy of an external `__inline` function where it does diff --git a/port/windows/halo.rc b/port/windows/halo.rc index 0dff6f2..0acb2e7 100644 --- a/port/windows/halo.rc +++ b/port/windows/halo.rc @@ -1,4 +1,6 @@ /* halo.exe's resources (tools/windows_build.py): its icon (tools/app_icon.py), which Explorer and the taskbar show and SDL's windows take, as the first icon -of the executable */ +of the executable. The build adds its version information after it +(tools/windows_version_rc.py), and compiles the two without the +preprocessor */ 1 ICON "opence-icon.ico" diff --git a/port/windows/src/win32_server.c b/port/windows/src/win32_server.c index d5f8478..8c4e123 100644 --- a/port/windows/src/win32_server.c +++ b/port/windows/src/win32_server.c @@ -318,6 +318,54 @@ int posix_server_write_private_file(const char *path, const void *data, unsigned return CloseHandle(file) && succeeded; } +int posix_server_append_line(const char *path, const char *line) +{ + wchar_t wide[MAX_PATH * 2]; + size_t size = strlen(line), length = 0; + char *text; + HANDLE file; + LARGE_INTEGER end; + DWORD written = 0, got = 0; + char last = '\n'; + int whole; + + if (size > 0x7FFFFFF0 || !wide_path(path, wide, MAX_PATH * 2)) + return 0; + text = malloc(size + 2); + if (!text) + return 0; + /* (appended to the end by the system, whatever another writer added + meanwhile: FILE_APPEND_DATA without FILE_WRITE_DATA) */ + file = CreateFileW(wide, FILE_APPEND_DATA | FILE_READ_DATA | SYNCHRONIZE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, + OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + if (file == INVALID_HANDLE_VALUE) + { + free(text); + return 0; + } + /* (after a line that a write broke off, the next on a line of its own) */ + end.QuadPart = 0; + if (GetFileSizeEx(file, &end) && end.QuadPart > 0) + { + LARGE_INTEGER before; + + before.QuadPart = end.QuadPart - 1; + if (!SetFilePointerEx(file, before, NULL, FILE_BEGIN) || !ReadFile(file, &last, 1, &got, NULL) || got != 1) + last = '\n'; + } + if (last != '\n') + text[length++] = '\n'; + memcpy(text + length, line, size); + length += size; + text[length++] = '\n'; + /* (one write, the line and its newline together: a reader never sees + a line without its end, as a stream's buffer written in pieces would + show it) */ + whole = WriteFile(file, text, (DWORD)length, &written, NULL) && written == length; + free(text); + return CloseHandle(file) && whole; +} + int posix_server_remove(const char *path) { wchar_t wide[MAX_PATH * 2]; diff --git a/source/effects/player_effects.c b/source/effects/player_effects.c index 8f2dddc..88076e1 100644 --- a/source/effects/player_effects.c +++ b/source/effects/player_effects.c @@ -1293,6 +1293,12 @@ void player_effect_start( struct damage_effect_definition *definition = damage_effect_definition_get(damage->definition_index); struct player_effect_datum *effect = player_effect_get(local_player_index); + /* port: no recoil for a local player whose controller aim assist is + boosted: their own weapon's firing damage (the recoil's camera kick and + shake: damage they did to themselves) leaves the camera be */ + extern int halo_linux_controller_aim_assist(short gamepad_index); + boolean own_recoil = damage->owner_player_index == player_index && + halo_linux_controller_aim_assist(local_player_index) >= 1; player_effect_update_screen_flash( local_player_index, @@ -1300,19 +1306,22 @@ void player_effect_start( &definition->screen_flash, scale, 1.0f); - player_effect_update_camera_impulse( - local_player_index, - effect, - &definition->camera_impulse, - direction, - scale, - 1.0f); - player_effect_update_camera_shake( - local_player_index, - effect, - &definition->camera_shake, - scale, - 1.0f); + if (!own_recoil) + { + player_effect_update_camera_impulse( + local_player_index, + effect, + &definition->camera_impulse, + direction, + scale, + 1.0f); + player_effect_update_camera_shake( + local_player_index, + effect, + &definition->camera_shake, + scale, + 1.0f); + } rumble_player_impulse( local_player_index, (struct rumble_definition *)&definition->vibrate, diff --git a/source/game/aim_assist.c b/source/game/aim_assist.c index 7eea61e..ca3eb43 100644 --- a/source/game/aim_assist.c +++ b/source/game/aim_assist.c @@ -107,6 +107,7 @@ real compute_composite_attenuation( static boolean unit_get_aim_assist_parameters( long unit_index, short zoom_level, + int boost, struct aim_assist_parameters *parameters); static int compare_targets( void const *target0, @@ -170,9 +171,65 @@ real compute_composite_attenuation( return compute_attenuation(distance, maximum_distance) * compute_attenuation(angle, maximum_angle); } +/* port: how boosted a controller's aim assist is for the player on a gamepad: +0 as the game has it, 1 strong, 2 lock (input.controller_aim_assist: +port/linux/src/xinput_sdl.c) */ +extern int halo_linux_controller_aim_assist(short gamepad_index); +/* port: lock's field of view in degrees, the whole angle (input.controller_aim_assist_fov) */ +extern float halo_linux_controller_aim_assist_fov(void); + +/* port: the boost's reach: the angles and distances of the weapon's magnetism +and autoaim, widened; lock holds within its own cone, narrowed as the weapon +zooms (inverse_magnification), at least the weapon's own */ +static void aim_assist_parameters_boost( + struct aim_assist_parameters *parameters, + int boost, + real inverse_magnification) +{ + real distance_scale= boost>=2 ? 2.f : 1.5f; + + if (boost<=0) + return; + if (boost>=2) + { + real cone= DEGREES_TO_RADIANS(halo_linux_controller_aim_assist_fov()*0.5f)*inverse_magnification; + + parameters->magnetism_angle= MAX(parameters->magnetism_angle, cone); + parameters->autoaim_angle= MAX(parameters->autoaim_angle, cone); + } + else + { + parameters->magnetism_angle*= 1.6f; + parameters->autoaim_angle*= 1.25f; + } + parameters->magnetism_distance*= distance_scale; + parameters->autoaim_distance*= distance_scale; + parameters->deviation_angle= MAX(parameters->deviation_angle, parameters->autoaim_angle); +} + +/* port: lock aims at the head: a biped target's "head" marker takes the place +of the nearest point of its pill (its angle and levels, which chose it, stay) */ +static void aim_assist_target_head( + struct aim_assist_target *target, + real_point3d const *position) +{ + struct object_datum *object= object_get(target->object_index); + struct object_marker marker; + + if (!TEST_FLAG(_object_mask_biped, object->object.type)) + return; + if (object_get_marker_by_name(target->object_index, "head", &marker, 1)<=0) + return; + target->position= marker.matrix.position; + vector_from_points3d(position, &target->position, &target->vector); + target->direction= target->vector; + target->distance= normalize3d(&target->direction); +} + static boolean unit_get_aim_assist_parameters( long unit_index, short zoom_level, + int boost, struct aim_assist_parameters *parameters) { if (unit_index!=NONE) @@ -196,6 +253,7 @@ static boolean unit_get_aim_assist_parameters( parameters->magnetism_distance= weapon_definition->weapon.aim_assist_parameters.magnetism_distance*magnification; parameters->deviation_angle= MAX(weapon_definition->weapon.aim_assist_parameters.deviation_angle, weapon_definition->weapon.aim_assist_parameters.autoaim_angle)*inverse_magnification; + aim_assist_parameters_boost(parameters, boost, inverse_magnification); return TRUE; } @@ -568,7 +626,9 @@ long player_aim_projectile_preview( global_current_collision_user_depth < MAXIMUM_COLLISION_USER_STACK_DEPTH); global_current_collision_users[global_current_collision_user_depth++] = _collision_user_aim_assist; - if (unit_get_aim_assist_parameters(aiming_unit_index, unit_get_zoom_level(aiming_unit_index), ¶meters)) + int boost= player->local_player_index==NONE ? 0 : halo_linux_controller_aim_assist(player->local_player_index); + + if (unit_get_aim_assist_parameters(aiming_unit_index, unit_get_zoom_level(aiming_unit_index), boost, ¶meters)) { real_point3d camera_position; real_vector3d camera_direction; @@ -585,13 +645,19 @@ long player_aim_projectile_preview( if (aim_assist(¶meters, &camera_position, &camera_direction, player->unit_index, player->team_index, &target)) { + if (boost>=2) + { + aim_assist_target_head(&target, &camera_position); + autoaim_level= 1.f; + } vector_from_points3d(position, &target.position, &target_direction); if (normalize3d(&target_direction)==0.f) { target_direction= *direction; } - autoaim_level= target.autoaim_level; + if (boost<2) + autoaim_level= target.autoaim_level; target_object_index= target.object_index; } } @@ -673,8 +739,9 @@ long local_player_aim_assist( struct player_datum *player= player_get(local_player_get_player_index(local_player_index)); long aiming_unit_index= unit_get_aiming_unit_index(player->unit_index); struct aim_assist_parameters parameters; + int boost= halo_linux_controller_aim_assist(local_player_index); - if (unit_get_aim_assist_parameters(aiming_unit_index, player_control_get_zoom_level(local_player_index), ¶meters)) + if (unit_get_aim_assist_parameters(aiming_unit_index, player_control_get_zoom_level(local_player_index), boost, ¶meters)) { struct observer_result const *camera= observer_get_camera(local_player_index); struct aim_assist_target target; @@ -687,6 +754,14 @@ long local_player_aim_assist( { real_vector3d velocity, target_velocity, magnetism_velocity; + if (boost>=2) + { + /* (and the crosshair lights up, as over a target: hud_weapon.c + takes an autoaim level of 1 as the weapon's "active" state) */ + aim_assist_target_head(&target, &aim_position); + target.magnetism_level= 1.f; + target.autoaim_level= 1.f; + } *autoaim_level= target.autoaim_level; *magnetism_level= target.magnetism_level; euler_angles2d_from_vector3d(target_angular_position, &target.vector); @@ -710,6 +785,24 @@ long local_player_aim_assist( horizontal_distance_squared); } + /* port: lock: the reticle is put onto the target's head as well as + carried along with it: the whole angle left to it each tick (player_control.c + leaves it unpinned) */ + if (boost>=2) + { + real_euler_angles2d aim_angles; + real yaw_left, pitch_left; + + euler_angles2d_from_vector3d(&aim_angles, &aim_forward); + yaw_left= target_angular_position->yaw - aim_angles.yaw; + while (yaw_left>(real)M_PI) yaw_left-= (real)(2*M_PI); + while (yaw_left<-(real)M_PI) yaw_left+= (real)(2*M_PI); + pitch_left= target_angular_position->pitch - aim_angles.pitch; + /* (all of it: the reticle is on the head the next tick) */ + target_angular_velocity->yaw+= yaw_left; + target_angular_velocity->pitch+= pitch_left; + } + target_index= target.object_index; } } diff --git a/source/game/game_engine.c b/source/game/game_engine.c index 442c531..f186433 100644 --- a/source/game/game_engine.c +++ b/source/game/game_engine.c @@ -598,6 +598,7 @@ struct network_game *network_game_server_get_game(struct network_game_server *se #include "custom_edition_cache.h" /* port: port/linux/game/custom_edition_cache.c */ #include "latency_meter.h" /* port: port/linux/game/latency_meter.c */ #include "touch_postgame.h" /* port: port/linux/game/touch_postgame.c */ +#include "halocustoms_stats.h" /* port: port/linux/game/halocustoms_stats.c */ /* network_game_globals.c's */ boolean network_game_distributed_client(void); @@ -4353,6 +4354,7 @@ static void game_engine_build_lighting( void game_engine_dispose_from_old_map( void) { + halocustoms_stats_map_ended(); /* port: HaloCustoms: a level session's record, for the site */ if (game_engine && game_engine->dispose_from_old_map) game_engine->dispose_from_old_map(); @@ -5816,6 +5818,7 @@ void game_engine_override_game_variant( void game_engine_switch_to_postgame( void) { + halocustoms_stats_round_over(); /* port: HaloCustoms: the host ended a co-op client's level */ if (game_engine_globals.postgame_state==game_engine_mode_active) { if (global_network_game_server_get()) @@ -5854,6 +5857,7 @@ void game_engine_end_game( game_engine_globals.postgame_timer = 7.0f; game_engine_play_multiplayer_sound(_multiplayer_sound_game_over); ui_widgets_close_all(); + halocustoms_stats_game_over(); /* port: HaloCustoms: each local player's result, for the site */ } return; @@ -7573,6 +7577,7 @@ void game_engine_initialize( void game_engine_initialize_for_new_map( void) { + halocustoms_stats_game_started(); /* port: HaloCustoms: the game's or level's start, for its record */ if (game_engine) { game_engine_verify_current_map(); diff --git a/source/game/player_control.c b/source/game/player_control.c index 58d5211..7acd8ba 100644 --- a/source/game/player_control.c +++ b/source/game/player_control.c @@ -1307,18 +1307,40 @@ static void get_local_player_input_blob( control->magnetism_level = 0.f; } } + /* port: a controller's boosted aim assist (input.controller_aim_assist): + more friction and adhesion over a target; lock holds the reticle on + it whether or not the sticks move, and may turn faster to it */ + extern int halo_linux_controller_aim_assist(short gamepad_index); + int aim_boost = halo_linux_controller_aim_assist(gamepad_index); + if (player_magnetism_flag && control->magnetism_level > 0.f && - (touching || + (aim_boost >= 2 || + touching || fabs(clamped_yaw) > _real_epsilon || fabs(clamped_pitch) > _real_epsilon || fabs(input->throttle.i) > _real_epsilon || fabs(input->throttle.j) > _real_epsilon)) { real game_speed = game_time_get_speed(); - real input_scale = 1.f - control->magnetism_level * - PIN(constants->magnetism_friction, 0.f, 1.f); - real magnetism_scale = control->magnetism_level * - PIN(constants->magnetism_adhesion, 0.f, 1.f); + real friction = PIN(constants->magnetism_friction, 0.f, 1.f); + real adhesion = PIN(constants->magnetism_adhesion, 0.f, 1.f); + /* (lock: no limit to the turn, the reticle reaches the head in a tick) */ + real turn_limit = aim_boost >= 2 ? 60.f : 1.f; + real input_scale, magnetism_scale; + + if (aim_boost >= 2) + { + /* (the sticks do not move the view while a target is held) */ + friction = 1.f; + adhesion = 1.f; + } + else if (aim_boost == 1) + { + friction = MAX(friction, 0.7f); + adhesion = MAX(adhesion, 0.75f); + } + input_scale = 1.f - control->magnetism_level * friction; + magnetism_scale = control->magnetism_level * adhesion; if (game_players_are_double_speed()) { @@ -1328,12 +1350,12 @@ static void get_local_player_input_blob( target_angular_velocity.pitch *= game_speed; target_angular_velocity.yaw = PIN( target_angular_velocity.yaw, - -DEGREES_TO_RADIANS(6.f), - DEGREES_TO_RADIANS(6.f)); + -DEGREES_TO_RADIANS(6.f) * turn_limit, + DEGREES_TO_RADIANS(6.f) * turn_limit); target_angular_velocity.pitch = PIN( target_angular_velocity.pitch, - -DEGREES_TO_RADIANS(3.f), - DEGREES_TO_RADIANS(3.f)); + -DEGREES_TO_RADIANS(3.f) * turn_limit, + DEGREES_TO_RADIANS(3.f) * turn_limit); look_delta.yaw = target_angular_velocity.yaw * magnetism_scale + look_delta.yaw * input_scale; look_delta.pitch = target_angular_velocity.pitch * magnetism_scale + @@ -1440,6 +1462,17 @@ static void get_local_player_input_blob( input->unit_control_flags, _unit_control_weapon_primary_trigger_bit, effective_buttons[_button_fire]); + /* port: lock fires by itself while it holds a target + (input.controller_auto_fire), as a full pull of the trigger */ + { + extern int halo_linux_controller_auto_fire(short gamepad_index); + + if (control->target_object_index != NONE && halo_linux_controller_auto_fire(gamepad_index)) + { + input->primary_trigger = 1.f; + SET_FLAG(input->unit_control_flags, _unit_control_weapon_primary_trigger_bit, TRUE); + } + } SET_FLAG( input->unit_control_flags, _unit_control_throw_grenade_bit, diff --git a/source/game/players.c b/source/game/players.c index 16271cf..4ff6672 100644 --- a/source/game/players.c +++ b/source/game/players.c @@ -239,6 +239,7 @@ symbols in this file: #include "devices/devices.h" #include "network_coop.h" /* port: port/linux/game/network_coop.c */ #include "bots.h" /* port: port/linux/game/bots.c */ +#include "halocustoms_stats.h" /* port: port/linux/game/halocustoms_stats.c */ #include "cutscene/cinematics.h" /* port: network co-op's first spawns */ #include "editor/editor_stubs.h" #include "effects/effects.h" @@ -1470,12 +1471,18 @@ static void player_spawn( change_color = *game_engine_player_get_change_color( &change_color_storage, player_index); - placement_data_set_change_color( - &placement_data, - &change_color); - unit_index = object_new(&placement_data); - if (unit_index != NONE && network_coop_active()) - players_coop_color_unit(unit_index, &change_color); + { + /* port: HaloCustoms: a prestige tier's armour, by the rank in + the name (port/linux/game/halocustoms_stats.c) */ + boolean rank_skin = halocustoms_rank_color(player_get(player_index)->name, &change_color); + + placement_data_set_change_color( + &placement_data, + &change_color); + unit_index = object_new(&placement_data); + if (unit_index != NONE && (network_coop_active() || rank_skin)) + players_coop_color_unit(unit_index, &change_color); + } if (unit_index != NONE) { unit = unit_try_and_get(unit_index); diff --git a/source/interface/ui_widget.c b/source/interface/ui_widget.c index a29a290..c1c6e6d 100644 --- a/source/interface/ui_widget.c +++ b/source/interface/ui_widget.c @@ -681,8 +681,8 @@ struct widget_instance; #include "text/unicode.h" #include "ui_widget.h" /* port: port/linux/game/menu_functions.c: a text's colour in place of its -own (the server browser's pings) */ -boolean pc_menu_text_color(struct widget_instance *widget, real_argb_color *color); +own (the server browser's pings; a menu theme's, focused or not) */ +boolean pc_menu_text_color(struct widget_instance *widget, boolean focus, real_argb_color *color); /* ---------- constants */ @@ -1246,7 +1246,10 @@ struct widget_instance boolean delete_recursion_lock; boolean widget_is_error_dialog; boolean close_if_local_player_controller_present; - byte pad17; + /* port: where it is placed on a screen wider than the menus' 640 + columns (its definition's or its reference's : + pc_menu_anchor_shift); the original's padding */ + byte anchor; long creation_time; unsigned long milliseconds_to_auto_close; unsigned long auto_close_fade_time; @@ -1387,6 +1390,61 @@ boolean pc_menu_frame_placement( short *y, short *width, short *height); +/* port: the menus' anchors (port/assets/menus/README.md, "Anchors"): a +widget's own (port/linux/game/menu_tags.c), and the one a child reference +gives the child it loads, in place of anchor, the child's own */ +byte pc_menu_widget_anchor( + long tag_index); +byte pc_menu_child_anchor( + long parent_tag_index, + struct ui_widget_child_reference const *reference, + byte anchor); +/* port: an anchored widget, or one in one: how far right of where its +parent places it it is drawn, how much wider at either side its area is, +and the screen's margin left of the menus' columns, which its clip takes +in; all 0 for any other (port/linux/game/menu_functions.c) */ +short pc_menu_anchor_shift( + struct widget_instance *widget, + short *grow, + short *margin); +/* port: the units at the left and right of a frame of the menus' that are +drawn as they are, the middle stretched between them (: +port/linux/game/menu_tags.c) */ +boolean pc_menu_bitmap_caps( + struct bitmap_data const *bitmap, + short *left, + short *right); +/* port: the part of its width, in thousandths, a widget with a fill draws +its picture across (: port/linux/game/menu_functions.c) */ +boolean pc_menu_fill_fraction( + struct widget_instance *widget, + long *thousandths); +/* port: the flags a text widget's text is drawn with: a menus' widget's +text_flags="wrap" breaks it at spaces to stay in its box +(port/linux/game/menu_tags.c) */ +unsigned long pc_menu_text_draw_flags( + long tag_index); +/* port: a picture with caps, and a part of a picture's width drawn in a +rectangle (a capped picture's ends and middle) */ +static void draw_capped_bitmap_in_rect( + struct bitmap_data *bitmap, + rectangle2d const *bounds, + rectangle2d *texels, + short cap_left, + short cap_right, + rectangle2d *clip, + pixel32 color, + struct rasterizer_dynamic_screen_geometry_parameters *multitexture_params); +static void draw_bitmap_in_rect_part( + struct bitmap_data *bitmap, + rectangle2d *rect, + rectangle2d *bitmap_rect, + real texture_left, + real texture_right, + rectangle2d *clip_rect, + pixel32 argb, + struct rasterizer_dynamic_screen_geometry_parameters *multitexture_params, + boolean no_plasma); /* port: the placeholder bitmap the menus draw a sprite of a high-res texture from, or the sheet if it has none (port/linux/game/hud_hires_tags.c) */ struct bitmap_data const *hud_hires_sprite_bitmap( @@ -1692,6 +1750,71 @@ void draw_bitmap_in_rect( pixel32 argb, struct rasterizer_dynamic_screen_geometry_parameters *multitexture_params, boolean no_plasma) +{ + /* port: the game's own, drawn from the picture's left edge across as much + of it as bitmap_rect is wide (draw_bitmap_in_rect_part) */ + draw_bitmap_in_rect_part(bitmap, rect, bitmap_rect, 0.0f, -1.0f, clip_rect, argb, multitexture_params, + no_plasma); + + return; +} + +/* port: a picture with caps (: pc_menu_bitmap_caps) across +bounds, at least cap_left and cap_right wide: its ends, cap_left and +cap_right of its units, as they are, and its middle stretched or squeezed +across the rest; each as tall as a picture without them is drawn (texels' +height: cut if the widget is shorter than it, stretched if taller) */ +static void draw_capped_bitmap_in_rect( + struct bitmap_data *bitmap, + rectangle2d const *bounds, + rectangle2d *texels, + short cap_left, + short cap_right, + rectangle2d *clip, + pixel32 color, + struct rasterizer_dynamic_screen_geometry_parameters *multitexture_params) +{ + real width = (real)MAX(1, bitmap->width); + rectangle2d part = *bounds; + + part.x1 = (short)(bounds->x0 + cap_left); + if (part.x1 > part.x0) + { + draw_bitmap_in_rect_part(bitmap, &part, texels, 0.0f, (real)cap_left / width, clip, color, + multitexture_params, FALSE); + } + part.x0 = part.x1; + part.x1 = (short)(bounds->x1 - cap_right); + if (part.x1 > part.x0) + { + draw_bitmap_in_rect_part(bitmap, &part, texels, (real)cap_left / width, + (real)(bitmap->width - cap_right) / width, clip, color, multitexture_params, FALSE); + } + part.x0 = part.x1; + part.x1 = bounds->x1; + if (part.x1 > part.x0) + { + draw_bitmap_in_rect_part(bitmap, &part, texels, (real)(bitmap->width - cap_right) / width, 1.0f, clip, + color, multitexture_params, FALSE); + } + + return; +} + +/* port: draw_bitmap_in_rect's body, for the part of the picture's width +from texture_left to texture_right (0 to 1 of it; a texture_right below 0 +is as much of it as bitmap_rect is wide, the game's own): a capped +picture's ends and middle (draw_capped_bitmap_in_rect) */ +static void draw_bitmap_in_rect_part( + struct bitmap_data *bitmap, + rectangle2d *rect, + rectangle2d *bitmap_rect, + real texture_left, + real texture_right, + rectangle2d *clip_rect, + pixel32 argb, + struct rasterizer_dynamic_screen_geometry_parameters *multitexture_params, + boolean no_plasma) { if (bitmap && rect) { @@ -1764,6 +1887,9 @@ void draw_bitmap_in_rect( texture_width = MIN( (real)source_width / bitmap_width, 1.0f); + /* port: (the part asked for) */ + if (texture_right >= 0.0f) + texture_width = texture_right; bitmap_height = MAX(1.0f, (real)bitmap->height); texture_height = MIN( (real)source_height / bitmap_height, @@ -1774,8 +1900,9 @@ void draw_bitmap_in_rect( vertex_index++) { vertices[vertex_index].color = argb; + /* port: (from the part's left: the game's own from 0) */ vertices[vertex_index].texture_coordinates.x = - (vertex_index % 3) ? texture_width : 0.0f; + (vertex_index % 3) ? texture_width : texture_left; vertices[vertex_index].texture_coordinates.y = (vertex_index > 1) ? texture_height : 0.0f; vertices[vertex_index].position = points[vertex_index]; @@ -3482,6 +3609,10 @@ static void event_handler_dispatch( new_widget->vertical_offset += widget->vertical_offset; if (parent) { + /* port: in its place, anchored as it was (unless it has + an anchor of its own) */ + if (!new_widget->anchor) + new_widget->anchor = widget->anchor; new_widget->parent = parent; if (parent->child == widget) parent->child = new_widget; @@ -3709,6 +3840,8 @@ static boolean ui_widget_load_children_recursive( } child->horizontal_offset = reference->horizontal_offset + widget->horizontal_offset; child->vertical_offset = reference->vertical_offset + widget->vertical_offset; + /* port: the reference's anchor, if it gives one */ + child->anchor = pc_menu_child_anchor(widget->definition_tag_index, reference, child->anchor); ui_widget_add_child(widget, child); } } @@ -3794,6 +3927,8 @@ static void widget_instance_initialize( widget->type = _ui_widget_type_text_box; } widget->definition_tag_index = tag_index; + /* port: its widget's anchor (a child reference may give another) */ + widget->anchor = pc_menu_widget_anchor(tag_index); widget->name = definition->name; widget->local_player_index = local_player_index; widget->type = definition->type; @@ -5306,6 +5441,8 @@ static void widget_instance_render_text_box( real_argb_color color; rectangle2d bounds; rectangle2d clip; + /* port: (pc_menu_anchor_shift) */ + short grow; if (definition->text_label_string_list.index != NONE) { @@ -5393,6 +5530,10 @@ static void widget_instance_render_text_box( return; alpha_modifier = widget_instance_get_cumulative_alpha_modifier(widget); bounds = definition->bounds; + /* port: wider at either side, stretched (the menus' anchors) */ + pc_menu_anchor_shift(widget, &grow, NULL); + bounds.x0 -= grow; + bounds.x1 += grow; clip = clip_rect ? *clip_rect : definition->bounds; bounds.x1 += offset.x; bounds.y1 += offset.y; @@ -5419,7 +5560,7 @@ static void widget_instance_render_text_box( } } /* port: a colour of the port's for the text (its alpha the box's) */ - pc_menu_text_color(widget, &color); + pc_menu_text_color(widget, focus, &color); color.alpha = alpha_modifier * color_alpha; if (TEST_FLAG(definition->text_box_flags, _text_box_flashing_text_bit)) { @@ -5427,7 +5568,10 @@ static void widget_instance_render_text_box( widget_globals.current_system_milliseconds * SECONDS_PER_MILLISECOND * 3.0f) + 1.5f) * 0.4f) * color.alpha; } - draw_string_set_draw_mode(font_index, NONE, justification, 0, &color); + /* port: the flags of a text of the menus' (pc_menu_text_draw_flags: + wrapped); the game's own are none */ + draw_string_set_draw_mode(font_index, NONE, justification, pc_menu_text_draw_flags(widget->definition_tag_index), + &color); /* port: the port's own error text, wrapped to its dialog: the box its background draws (narrower than the text box; its picture is padded to a power of two, so a margin of the text's offset on each side and as much @@ -5484,9 +5628,13 @@ static void widget_instance_render_spinner_list( short last_list_tab_direction; struct bitmap_data *bitmap; rectangle2d bounds; + /* port: how much wider at either side it is, stretched (the menus' + anchors: its arrows at its sides, its text across it) */ + short grow; if (!widget->visible) return; + pc_menu_anchor_shift(widget, &grow, NULL); last_list_tab_direction = widget->parameters.list.last_list_tab_direction; if (last_list_tab_direction) { @@ -5523,6 +5671,8 @@ static void widget_instance_render_spinner_list( bounds.x0 -= SPINNER_EXTRA_WIDTH; bounds.x1 -= SPINNER_EXTRA_WIDTH; } + bounds.x0 -= grow; + bounds.x1 -= grow; bounds.x0 += offset.x; bounds.y0 += offset.y; bounds.x1 += offset.x; @@ -5547,6 +5697,8 @@ static void widget_instance_render_spinner_list( csmemset(¶meters, 0, sizeof(parameters)); bounds = definition->list_footer_bounds; + bounds.x0 += grow; + bounds.x1 += grow; bounds.x0 += offset.x; bounds.y0 += offset.y; bounds.x1 += offset.x; @@ -5638,6 +5790,9 @@ static void widget_instance_render_spinner_list( rectangle2d clip = clip_rect ? *clip_rect : definition->bounds; bounds = definition->bounds; + /* port: wider at either side, stretched */ + bounds.x0 -= grow; + bounds.x1 += grow; bounds.x1 += offset.x; bounds.y1 += offset.y; bounds.x0 += offset.x; @@ -5669,6 +5824,9 @@ static void widget_instance_render_spinner_list( color_alpha = color.alpha; } } + /* port: a colour of the port's for the text, as a text box's + (a menu theme's focused text; its alpha the spinner's) */ + pc_menu_text_color(widget, focus, &color); color.alpha = text_alpha_modifier * color_alpha; if (TEST_FLAG( definition->text_box_flags, @@ -6088,6 +6246,7 @@ static void ui_mouse_note_slot_band( rectangle2d slots; short back, forward; boolean found = FALSE; + short grow; ui_mouse_list_directions(widget, &back, &forward); if (back != _widget_event_dpad_left) @@ -6095,14 +6254,16 @@ static void ui_mouse_note_slot_band( for (child = widget->child; child; child = child->next) { rectangle2d slot; + short shift, child_grow; if (!child->visible) continue; /* where widget_instance_render_recursive draws the child: the - list's offset plus the child's own */ + list's offset plus the child's own, as the menus' anchors place it */ + shift = pc_menu_anchor_shift(child, &child_grow, NULL); slot = ui_widget_definition_get(child->definition_tag_index)->bounds; - slot.x0 += offset.x + child->horizontal_offset; - slot.x1 += offset.x + child->horizontal_offset; + slot.x0 += offset.x + child->horizontal_offset + shift - child_grow; + slot.x1 += offset.x + child->horizontal_offset + shift + child_grow; slot.y0 += offset.y + child->vertical_offset; slot.y1 += offset.y + child->vertical_offset; if (!found) @@ -6120,13 +6281,15 @@ static void ui_mouse_note_slot_band( } if (!found) return; + /* (the list's area as drawn: wider, stretched) */ + pc_menu_anchor_shift(widget, &grow, NULL); bounds.y0 = slots.y0; bounds.y1 = slots.y1; - bounds.x0 = definition->bounds.x0 + offset.x; + bounds.x0 = definition->bounds.x0 - grow + offset.x; bounds.x1 = slots.x0; ui_mouse_note_list_step(widget, &bounds, _ui_mouse_target_list_back); bounds.x0 = slots.x1; - bounds.x1 = definition->bounds.x1 + offset.x; + bounds.x1 = definition->bounds.x1 + grow + offset.x; ui_mouse_note_list_step(widget, &bounds, _ui_mouse_target_list_forward); return; @@ -6149,6 +6312,7 @@ static void ui_mouse_note_target( struct widget_instance *target_label = NULL; short button_index; short kind; + short shift, grow; if (!ui_mouse_noting_targets || ui_mouse_target_count >= UI_MOUSE_MAXIMUM_TARGETS || @@ -6156,8 +6320,11 @@ static void ui_mouse_note_target( { return; } - bounds.x0 += offset.x; - bounds.x1 += offset.x; + /* (its area as drawn: offset already holds where the menus' anchors + place it, and a stretched one is wider at either side) */ + shift = pc_menu_anchor_shift(widget, &grow, NULL); + bounds.x0 += offset.x - grow; + bounds.x1 += offset.x + grow; bounds.y0 += offset.y; bounds.y1 += offset.y; button_index = ui_mouse_key_button(widget); @@ -6175,13 +6342,19 @@ static void ui_mouse_note_target( struct ui_widget_definition *sibling_definition; rectangle2d sibling_bounds; short distance; + short sibling_shift, sibling_grow; if (sibling->type != _ui_widget_type_text_box || !sibling->visible) continue; sibling_definition = ui_widget_definition_get(sibling->definition_tag_index); sibling_bounds = sibling_definition->bounds; - sibling_bounds.x0 += offset.x - widget->horizontal_offset + sibling->horizontal_offset; - sibling_bounds.x1 += offset.x - widget->horizontal_offset + sibling->horizontal_offset; + /* (from the parent's offset, as its anchor and the sibling's + place them) */ + sibling_shift = pc_menu_anchor_shift(sibling, &sibling_grow, NULL); + sibling_bounds.x0 += offset.x - widget->horizontal_offset - shift + sibling->horizontal_offset + + sibling_shift - sibling_grow; + sibling_bounds.x1 += offset.x - widget->horizontal_offset - shift + sibling->horizontal_offset + + sibling_shift + sibling_grow; sibling_bounds.y0 += offset.y - widget->vertical_offset + sibling->vertical_offset; sibling_bounds.y1 += offset.y - widget->vertical_offset + sibling->vertical_offset; distance = (short)ABS(sibling_bounds.x0 - bounds.x1); @@ -6384,7 +6557,8 @@ static short ui_mouse_label_text_right( the menu, HUD, cinematic and terminal draws set their font before they draw; the debug texts that set only a colour or format find a menu font either way */ - draw_string_set_draw_mode(definition->text_font.index, NONE, definition->justification, 0, &color); + draw_string_set_draw_mode(definition->text_font.index, NONE, definition->justification, + pc_menu_text_draw_flags(label->definition_tag_index), &color); draw_unicode_string_compute_bounds(&bounds, text, &text_bounds, &cursor_bounds); return text_bounds.x1 == SHORT_MIN ? NONE : text_bounds.x1; @@ -7105,6 +7279,12 @@ static void widget_instance_render_recursive( /* port: (custom_edition_maps_picture) */ struct bitmap_data *custom_edition_picture; short frame_index; + /* port: the menus' anchors (pc_menu_anchor_shift): how much wider at + either side the widget is drawn, the screen's margin left of the + columns, and the clip that takes it in */ + short anchor_grow; + short anchor_margin; + rectangle2d anchor_clip; if (!use_nifty_plasma_fx && TEST_FLAG(definition->flags, _widget_always_render_with_nifty_fx_bit)) @@ -7113,6 +7293,16 @@ static void widget_instance_render_recursive( } offset.x += widget->horizontal_offset; offset.y += widget->vertical_offset; + /* port: where the menus' anchors place it on a screen wider than their + 640 columns; it, and what is in it, may be drawn in the screen's left + margin, which the clip (the screen's, from the columns' left) takes in */ + offset.x += pc_menu_anchor_shift(widget, &anchor_grow, &anchor_margin); + if (anchor_margin && clip_rect && clip_rect->x0 > -anchor_margin) + { + anchor_clip = *clip_rect; + anchor_clip.x0 = (short)-anchor_margin; + clip_rect = &anchor_clip; + } for (input_index = 0; input_index < definition->game_data_inputs.count; input_index++) @@ -7171,6 +7361,9 @@ static void widget_instance_render_recursive( statement: C89) */ long extra = halo_screen_ui_offset_current(); short design_right = bounds.x1; + /* port: the part of its width a fill draws (pc_menu_fill_fraction) */ + long fill; + boolean filled; if (use_nifty_plasma_fx) { @@ -7184,6 +7377,18 @@ static void widget_instance_render_recursive( bounds.x0 -= (short)extra; bounds.x1 = (short)(bounds.x1 + extra); } + else + { + /* port: wider at either side, stretched by the menus' anchors + (a flat fill over the whole screen is already as wide as it) */ + bounds.x0 -= anchor_grow; + bounds.x1 += anchor_grow; + } + /* port: as much of it as its fill gives (), from its + left: its picture cut there, as a narrower widget's is */ + filled = pc_menu_fill_fraction(widget, &fill); + if (filled) + bounds.x1 = (short)(bounds.x0 + (long)(bounds.x1 - bounds.x0) * PIN(fill, 0, 1000) / 1000); bounds.x0 += offset.x; bounds.x1 += offset.x; bounds.y0 += offset.y; @@ -7224,9 +7429,12 @@ static void widget_instance_render_recursive( laid out as the stock pictures are */ rectangle2d texels = bounds; short frame_x, frame_y, frame_width, frame_height; - boolean shown = TRUE; + /* port: (a fill that leaves none of it draws nothing) */ + boolean shown = !filled || bounds.x1 > bounds.x0; boolean stretched = custom_edition_picture && custom_edition_maps_campaign_level(widget->animation.current_frame_index) == NONE; + /* port: (: pc_menu_bitmap_caps) */ + short cap_left, cap_right; if (pc_menu_frame_placement(bitmap, &frame_x, &frame_y, &frame_width, &frame_height)) { @@ -7238,7 +7446,14 @@ static void widget_instance_render_recursive( texels.y1 = (short)((long)(bounds.y1 - bounds.y0) * bitmap->height / frame_height); shown = bounds.x1 > bounds.x0 && bounds.y1 > bounds.y0; } - if (shown) + if (shown && !stretched && pc_menu_bitmap_caps(bitmap, &cap_left, &cap_right) && + bounds.x1 - bounds.x0 >= cap_left + cap_right) + { + /* port: a picture with caps () */ + draw_capped_bitmap_in_rect(bitmap, &bounds, &texels, cap_left, cap_right, clip, color, + &multitexture_params); + } + else if (shown) { draw_bitmap_in_rect( bitmap, @@ -7274,6 +7489,8 @@ static void widget_instance_render_recursive( { rectangle2d text_bounds = definition->bounds; + text_bounds.x0 -= anchor_grow; + text_bounds.x1 += anchor_grow; offset_rectangle2d(&text_bounds, offset.x, offset.y); text_bounds.x0 += definition->horizontal_offset; text_bounds.y0 += definition->vertical_offset; diff --git a/source/items/weapons.c b/source/items/weapons.c index 870772e..9f05d70 100644 --- a/source/items/weapons.c +++ b/source/items/weapons.c @@ -1533,6 +1533,28 @@ void weapon_owner_update( return; } +/* port: whether the weapon is held by a local player whose controller aim +assist is boosted (input.controller_aim_assist strong or lock): their reloads +and chambering take a tick, and the weapon is ready again at once */ +static boolean weapon_port_boosted_holder( + struct weapon_datum const *weapon) +{ + extern int halo_linux_controller_aim_assist(short gamepad_index); + long unit_index = weapon->object.parent_object_index; + struct unit_datum *unit; + long player_index; + + if (unit_index == NONE) + return FALSE; + unit = unit_try_and_get(unit_index); + if (!unit) + return FALSE; + player_index = unit->unit.player_index; + if (player_index == NONE || player_get(player_index)->local_player_index == NONE) + return FALSE; + return halo_linux_controller_aim_assist(player_get(player_index)->local_player_index) >= 1; +} + static void weapon_magazine_finish_reload( long weapon_index, short magazine_index) @@ -1569,6 +1591,11 @@ static void weapon_magazine_finish_reload( { weapon_magazine_start_reload(weapon_index, magazine_index, FALSE); } + else if (weapon_port_boosted_holder(weapon)) + { + /* (port: instant: the reload's animation does not hold the weapon) */ + weapon_set_state(weapon_index, _weapon_state_idle, TRUE); + } return; } @@ -1601,6 +1628,8 @@ static void weapon_magazine_start_chamber( 0.0f); magazine->state = _magazine_chambering; magazine->state_timer = (short)(magazine_definition->chamber_time * TICKS_PER_SECOND); + if (weapon_port_boosted_holder(weapon)) + magazine->state_timer = 1; /* (port: instant) */ } return; @@ -1759,6 +1788,8 @@ static void weapon_magazine_start_reload( magazine->state = _magazine_reloading; magazine->original_time = magazine->state_timer = weapon_get_first_person_animation_time(weapon_index, 0, _first_person_weapon_animation_reload_while_empty, reload_type); + if (weapon_port_boosted_holder(weapon)) + magazine->original_time = magazine->state_timer = 1; /* (port: instant) */ } weapon->weapon.flags &= ~FLAG(3); @@ -1976,6 +2007,14 @@ static boolean weapon_trigger_can_fire_again( required_ticks = (weapon->weapon.age * weapon_definition->weapon.age_rate_of_fire_penalty + 1.0f) * required_ticks; } + /* port: a boosted controller player's weapons fire faster + (input.controller_fire_rate_scale times the weapon's rate) */ + if (weapon_port_boosted_holder(weapon)) + { + extern float halo_linux_controller_fire_rate_scale(void); + + required_ticks /= halo_linux_controller_fire_rate_scale(); + } ticks_since_fire = trigger->idle_ticks; if ((real)ticks_since_fire + 1.0f >= required_ticks) @@ -2469,6 +2508,7 @@ static void trigger_create_projectiles( long target_object_index= NONE; long projectile_definition_index; short projectile_count; + long firing_player_index= NONE; /* port: the player shooting, for the spread below */ if (!TEST_FLAG(trigger_definition->flags, _weapon_trigger_projectiles_cannot_be_aimed_bit) && unit && @@ -2488,6 +2528,7 @@ static void trigger_create_projectiles( actor_index= gunner->unit.actor_index; } + firing_player_index= player_index; adjust_origin= TEST_FLAG(unit_definition->unit.flags, _unit_fires_from_camera_bit); use_aiming_vector= TRUE; if (actor_index!=NONE && actor_firing_blindly(actor_index)) @@ -2585,19 +2626,29 @@ static void trigger_create_projectiles( trigger->sequential_non_tracer_rounds= 0; } - if (error==0.0f) + /* port: a local player whose controller aim assist is boosted + (input.controller_aim_assist strong or lock) shoots without spread: + every projectile leaves along the aim (port/linux/src/xinput_sdl.c) */ { - real fraction= TEST_FLAG(trigger_definition->flags, _weapon_trigger_analog_rate_of_fire_bit) ? - weapon->weapon.primary_trigger : - trigger->error; + extern int halo_linux_controller_aim_assist(short gamepad_index); + boolean no_spread= firing_player_index!=NONE && player_get(firing_player_index)->local_player_index!=NONE && + halo_linux_controller_aim_assist(player_get(firing_player_index)->local_player_index)>=1; - error= (1.0f-fraction)*trigger_definition->projectile_error_angle_lower_bound + fraction*trigger_definition->projectile_error_angle_upper_bound; - } + if (error==0.0f) + { + real fraction= TEST_FLAG(trigger_definition->flags, _weapon_trigger_analog_rate_of_fire_bit) ? + weapon->weapon.primary_trigger : + trigger->error; - if (!TEST_FLAG(trigger_definition->flags, _weapon_trigger_use_error_when_unzoomed_bit) || - !TEST_FLAG(weapon->weapon.control_flags, _weapon_control_zoomed_bit)) - { - random_vector_in_cone3d(&data.forward, trigger_definition->projectile_error_inner_cone_angle, error, &data.forward); + error= (1.0f-fraction)*trigger_definition->projectile_error_angle_lower_bound + fraction*trigger_definition->projectile_error_angle_upper_bound; + } + + if (!no_spread && + (!TEST_FLAG(trigger_definition->flags, _weapon_trigger_use_error_when_unzoomed_bit) || + !TEST_FLAG(weapon->weapon.control_flags, _weapon_control_zoomed_bit))) + { + random_vector_in_cone3d(&data.forward, trigger_definition->projectile_error_inner_cone_angle, error, &data.forward); + } } if (projectile_index==0) diff --git a/source/main/main.c b/source/main/main.c index eef0323..c8a3652 100644 --- a/source/main/main.c +++ b/source/main/main.c @@ -685,6 +685,8 @@ typedef char screenshot_and_framerate_globals_size_assert[ void network_test_update(boolean main_menu_loaded, real seconds); #include "server_director.h" /* port: port/linux/game/server_director.c */ +#include "halocustoms_stats.h" /* port: port/linux/game/halocustoms_stats.c */ +#include "halocustoms_build.h" /* port: port/linux/include/halocustoms_build.h */ /* ---------- prototypes */ @@ -1739,11 +1741,13 @@ static void main_native_build_label(char *label, size_t capacity) #else platform = "Linux"; #endif + /* port: HaloCustoms' build, and OpenCE, which it is based on (within the + 64 characters a client's label is told to servers in) */ if (HALO_BUILD_NUMBER > 0) - _snprintf(label, capacity - 1, "OpenCE %s | build %d (%s)", - platform, HALO_BUILD_NUMBER, HALO_BUILD_FLAVOR); + _snprintf(label, capacity - 1, "HaloCustoms %s | build-%d-hc%d (%s), OpenCE-based", + platform, HALOCUSTOMS_OPENCE_BUILD, HALO_BUILD_NUMBER, HALO_BUILD_FLAVOR); else - _snprintf(label, capacity - 1, "OpenCE %s | local build (%s)", + _snprintf(label, capacity - 1, "HaloCustoms %s | local build (%s), OpenCE-based", platform, HALO_BUILD_FLAVOR); label[capacity - 1] = 0; } @@ -2387,6 +2391,7 @@ static void main_won_map_private( short level; short local_player_index; + halocustoms_stats_level_won(); /* port: HaloCustoms: the level session's record */ /* port: when a network co-op level is won, the round ends for everyone as in multiplayer, back to the lobby, and the next round is the campaign's next level (The Maw's: The Pillar of Autumn). A level not in the campaign @@ -3790,10 +3795,12 @@ void main_build_identity( #ifdef HALO_NATIVE_BUILD_INFO main_native_build_label(version, version_size); #else + /* port: (HaloCustoms' build, as the native builds' label) */ if (HALO_BUILD_NUMBER > 0) - _snprintf(version, version_size - 1, "OpenCE Web | build %d (%s)", HALO_BUILD_NUMBER, HALO_BUILD_FLAVOR); + _snprintf(version, version_size - 1, "HaloCustoms Web | build-%d-hc%d (%s), OpenCE-based", + HALOCUSTOMS_OPENCE_BUILD, HALO_BUILD_NUMBER, HALO_BUILD_FLAVOR); else - _snprintf(version, version_size - 1, "OpenCE Web | local build (%s)", HALO_BUILD_FLAVOR); + _snprintf(version, version_size - 1, "HaloCustoms Web | local build (%s), OpenCE-based", HALO_BUILD_FLAVOR); version[version_size - 1] = 0; #endif } diff --git a/source/networking/network_client_manager.c b/source/networking/network_client_manager.c index 2fb82d8..61f9c85 100644 --- a/source/networking/network_client_manager.c +++ b/source/networking/network_client_manager.c @@ -393,6 +393,7 @@ symbols in this file: #include "text/unicode.h" #include "map_share.h" /* port: port/linux/game/map_share.c */ #include "custom_edition_cache.h" /* port: port/linux/game/custom_edition_cache.c */ +#include "halocustoms_stats.h" /* port: port/linux/game/halocustoms_stats.c */ /* ---------- constants */ @@ -1548,6 +1549,9 @@ boolean network_game_client_add_player( player.machine_index = (char)client->machine_index; ustrncpy(player.name, profile.player_name, NETWORK_PLAYER_NAME_LENGTH - 1); player.name[NETWORK_PLAYER_NAME_LENGTH - 1] = 0; + /* port: HaloCustoms: the rank the site gave the player's profile before + their name */ + halocustoms_rank_name(local_player_index, player.name, NETWORK_PLAYER_NAME_LENGTH); player.primary_color_index = profile.primary_color_index; player.icon_index = NONE; player.team_index = NONE; diff --git a/source/networking/network_game_manager.c b/source/networking/network_game_manager.c index 0bff82a..73fc764 100644 --- a/source/networking/network_game_manager.c +++ b/source/networking/network_game_manager.c @@ -93,6 +93,7 @@ symbols in this file: #include "objects/objects.h" #include "units/units.h" #include "text/unicode.h" +#include "halocustoms_stats.h" /* port: port/linux/game/halocustoms_stats.c */ #include @@ -281,6 +282,9 @@ void network_game_generate_local_machine_name( if (profile.player_name[0]) { ustrncpy(machine_name, profile.player_name, MAXIMUM_PLAYER_PROFILE_NAME_LENGTH); + /* port: HaloCustoms: the rank the site gave the profile before + the name */ + halocustoms_rank_name(joined_player_index, machine_name, MAXIMUM_PLAYER_PROFILE_NAME_LENGTH + 1); machine_name[MIN(MAXIMUM_PLAYER_PROFILE_NAME_LENGTH, 32) - 1] = 0; return; } diff --git a/source/objects/damage.c b/source/objects/damage.c index fcbfef0..fb0e47d 100644 --- a/source/objects/damage.c +++ b/source/objects/damage.c @@ -1530,6 +1530,25 @@ void object_cause_damage( damage_was_modified = TRUE; } + /* port: a local player whose controller aim assist is boosted + (input.controller_aim_assist strong or lock) takes a share of each hit + on their shields and health (input.controller_damage_taken_scale); + another's hit on them, not their own (falling, their own grenades) */ + { + extern int halo_linux_controller_aim_assist(short gamepad_index); + extern float halo_linux_controller_damage_taken_scale(void); + long hurt_player_index = get_player_index_from_object_or_parents(object_index); + + if (hurt_player_index != NONE && + player_get(hurt_player_index)->local_player_index != NONE && + damage->owner_player_index != hurt_player_index && + halo_linux_controller_aim_assist(player_get(hurt_player_index)->local_player_index) >= 1) + { + total_damage *= halo_linux_controller_damage_taken_scale(); + damage_was_modified = TRUE; + } + } + damaged_object_count = 0; material_effect_recorded = FALSE; if (TEST_FLAG(damage->flags, _damage_area_of_effect_bit) || diff --git a/source/rasterizer/rasterizer_text.c b/source/rasterizer/rasterizer_text.c index 6835ddd..96b069e 100644 --- a/source/rasterizer/rasterizer_text.c +++ b/source/rasterizer/rasterizer_text.c @@ -94,6 +94,9 @@ long text_hires_font(char const *tag_name, float cap_height, short const *letter int text_hires_covers(long font, unsigned long code); int text_hires_glyph(long font, unsigned long code, struct text_hires_glyph *glyph); void text_hires_register_atlas(unsigned long const *texture, unsigned long width, unsigned long height); +int text_hires_laid_out(long font); +float text_hires_advance(long font, unsigned long code); +float text_hires_kerning(long font, unsigned long left, unsigned long right); /* ---------- constants */ @@ -106,6 +109,20 @@ enum of the atlas's glyphs), and the fonts remembered */ HIRES_TEXT_ATLAS_BITMAP_SIZE = 256, MAXIMUM_HIRES_TEXT_FONTS = 8, + /* port: a menu theme's font's runs (hires_run_layout): the characters of + one at most, and a gap that starts another (a tab stop's, a column's), in + the font tag's spaces */ + MAXIMUM_HIRES_RUN = 256, + HIRES_RUN_GAP_SPACES = 3, +}; + +/* port: draw_string.c's justifications (_text_justification_*), which a theme +font's run is placed by */ +enum +{ + HIRES_TEXT_JUSTIFY_LEFT = 0, + HIRES_TEXT_JUSTIFY_RIGHT, + HIRES_TEXT_JUSTIFY_CENTER, }; enum @@ -138,6 +155,8 @@ struct font_character struct parse_string_state; +short draw_string_justification(struct parse_string_state const *state); /* port: source/text/draw_string.c */ + typedef void (*draw_character_proc)( struct parse_string_state *state, struct font_header *font, @@ -234,6 +253,8 @@ static void rasterizer_draw_hires_character_with_dropshadow( short y, short dx, short dy); +static void hires_run_flush( + void); /* ---------- globals */ @@ -250,6 +271,21 @@ static struct long hires_font; } hires_text_fonts[MAXIMUM_HIRES_TEXT_FONTS]; static short hires_text_next_font = 0; +/* port: a menu theme's font's run of characters (text_hires_laid_out): those +of a line, as draw_string placed them by the font tag's widths, kept until +the line is done (hires_run_flush) */ +static struct +{ + struct font_header *font; + struct font_character *font_character; + unsigned long color; + unsigned long shadow_color; + boolean shadow; + short x0, y0, x, y, dx, dy; +} hires_run[MAXIMUM_HIRES_RUN]; +static short hires_run_count = 0; +static short hires_run_justification = HIRES_TEXT_JUSTIFY_LEFT; +static long hires_run_font = NONE; /* port: the text's scale about a point (rasterizer_text_set_scale) */ static real text_scale = 1.0f; static real text_scale_origin_x = 0.0f; @@ -556,6 +592,8 @@ rasterizer_draw_string( &viewport_bounds, height_adjust, string); + /* port: a theme font's last run */ + hires_run_flush(); rasterizer_text_end(); } } @@ -687,6 +725,8 @@ rasterizer_draw_unicode_string( &viewport_bounds, height_adjust, string); + /* port: a theme font's last run */ + hires_run_flush(); rasterizer_text_end(); } } @@ -918,7 +958,9 @@ baseline, its advance centred on the font tag's character's, cut where draw_string cut a part of the font tag's character that shows (to the text's box). Where it cut only the character's bitmap's empty columns or rows, or its edge's faint pixels, the character looked whole, and so does -the glyph. */ +the glyph. A theme font's glyph is at its run's place instead (laid out: its +pen laid_pen, narrowed by laid_scale, cut at the run's laid_clip_left and +laid_clip_right: hires_run_flush). */ static void rasterizer_draw_hires_glyph( struct font_header *font, struct font_character *font_character, @@ -930,7 +972,12 @@ static void rasterizer_draw_hires_glyph( short x, short y, short dx, - short dy) + short dy, + boolean laid_out, + real laid_pen, + real laid_scale, + real laid_clip_left, + real laid_clip_right) { struct text_hires_glyph glyph; struct dynamic_screen_vertex vertices[NUMBER_OF_VERTICES_PER_QUADRILATERAL]; @@ -945,16 +992,26 @@ static void rasterizer_draw_hires_glyph( if (!text_hires_glyph(hires_text_font, font_character->character, &glyph)) return; - /* (the font's advance centred on the tag's, so that its letters keep - their own spacing) */ - left = pen_x + (font_character->character_width - glyph.advance) * 0.5f + glyph.left; - right = left + (glyph.right - glyph.left); - if (x > 0 && !font_character_cut(font, font_character, 0, x, 0, font_character->bitmap_height)) - clip_left = -32768.0f; - if (x + dx < font_character->bitmap_width && - !font_character_cut(font, font_character, x + dx, font_character->bitmap_width, 0, font_character->bitmap_height)) + if (laid_out) + { + left = laid_pen + glyph.left * laid_scale; + right = left + (glyph.right - glyph.left) * laid_scale; + clip_left = laid_clip_left; + clip_right = laid_clip_right; + } + else { - clip_right = 32767.0f; + /* (the font's advance centred on the tag's, so that its letters keep + their own spacing) */ + left = pen_x + (font_character->character_width - glyph.advance) * 0.5f + glyph.left; + right = left + (glyph.right - glyph.left); + if (x > 0 && !font_character_cut(font, font_character, 0, x, 0, font_character->bitmap_height)) + clip_left = -32768.0f; + if (x + dx < font_character->bitmap_width && + !font_character_cut(font, font_character, x + dx, font_character->bitmap_width, 0, font_character->bitmap_height)) + { + clip_right = 32767.0f; + } } if (y > 0 && !font_character_cut(font, font_character, 0, font_character->bitmap_width, 0, y)) clip_top = -32768.0f; @@ -1013,6 +1070,178 @@ static void rasterizer_draw_hires_glyph( return; } +/* port: a theme font's run laid out with its own advances (each character's, +and the theme's kerning before it, in units: text_hires_advance, +text_hires_kerning) where draw_string placed its characters by the font +tag's widths (their pens, and their widths): the gap between two +characters (the font tag's spaces, and others it draws nothing for) as +the theme's space is to the tag's (space_ratio), so that a space keeps its +share of the run. Each character's pen into pens_laid; the run narrowed as +a whole (the scale returned, at most 1) where it is wider than the tag's +(from the first pen to the last character's end), and placed in that +extent by the justification (draw_string's), so that columns, centred and +right-aligned text stay where the font tag puts them */ +static real hires_run_layout( + short count, + real const *pens, + real const *widths, + real const *advances, + real const *kernings, + real space_ratio, + short justification, + real *pens_laid) +{ + real map_left, map_right, width = 0.0f, scale = 1.0f, start; + short index; + + if (count <= 0) + return 1.0f; + map_left = pens[0]; + map_right = pens[count - 1] + widths[count - 1]; + for (index = 0; index < count; index++) + { + if (index > 0) + { + real gap = pens[index] - (pens[index - 1] + widths[index - 1]); + + width += gap > 0.0f ? gap * space_ratio : kernings[index]; + } + pens_laid[index] = width; + width += advances[index]; + } + if (width > map_right - map_left && width > 0.0f) + scale = (map_right - map_left) / width; + switch (justification) + { + case HIRES_TEXT_JUSTIFY_RIGHT: + start = map_right - width * scale; + break; + case HIRES_TEXT_JUSTIFY_CENTER: + start = map_left + (map_right - map_left - width * scale) * 0.5f; + break; + default: + start = map_left; + break; + } + for (index = 0; index < count; index++) + pens_laid[index] = start + pens_laid[index] * scale; + return scale; +} + +/* port: a theme font's run drawn (hires_run_layout), each glyph at its pen, +narrowed with the run, the run cut where draw_string cut a part of its first +character or its last that shows (to the text's box) */ +static void hires_run_flush( + void) +{ + real pens[MAXIMUM_HIRES_RUN], widths[MAXIMUM_HIRES_RUN], advances[MAXIMUM_HIRES_RUN]; + real kernings[MAXIMUM_HIRES_RUN], pens_laid[MAXIMUM_HIRES_RUN]; + real space_ratio = 1.0f, scale, clip_left = -32768.0f, clip_right = 32767.0f; + struct font_character *space; + short index, count = hires_run_count; + long font = hires_text_font; + + if (!count) + return; + hires_run_count = 0; + hires_text_font = hires_run_font; + space = font_get_character_by_ascii_code(hires_run[0].font, ' '); + if (space && space->character_width > 0) + space_ratio = text_hires_advance(hires_text_font, ' ') / (real)space->character_width; + for (index = 0; index < count; index++) + { + struct font_character *character = hires_run[index].font_character; + + pens[index] = (real)(hires_run[index].x0 - hires_run[index].x + character->bitmap_origin_x); + widths[index] = (real)character->character_width; + advances[index] = text_hires_advance(hires_text_font, character->character); + kernings[index] = index ? text_hires_kerning(hires_text_font, hires_run[index - 1].font_character->character, + character->character) : 0.0f; + } + scale = hires_run_layout(count, pens, widths, advances, kernings, space_ratio, hires_run_justification, pens_laid); + if (hires_run[0].x > 0 && font_character_cut(hires_run[0].font, hires_run[0].font_character, 0, hires_run[0].x, 0, + hires_run[0].font_character->bitmap_height)) + { + clip_left = (real)hires_run[0].x0; + } + if (hires_run[count - 1].x + hires_run[count - 1].dx < hires_run[count - 1].font_character->bitmap_width && + font_character_cut(hires_run[count - 1].font, hires_run[count - 1].font_character, + hires_run[count - 1].x + hires_run[count - 1].dx, hires_run[count - 1].font_character->bitmap_width, 0, + hires_run[count - 1].font_character->bitmap_height)) + { + clip_right = (real)(hires_run[count - 1].x0 + hires_run[count - 1].dx); + } + for (index = 0; index < count; index++) + { + rasterizer_draw_hires_glyph(hires_run[index].font, hires_run[index].font_character, hires_run[index].color, + hires_run[index].shadow_color, hires_run[index].shadow, hires_run[index].x0, hires_run[index].y0, + hires_run[index].x, hires_run[index].y, hires_run[index].dx, hires_run[index].dy, TRUE, pens_laid[index], + scale, clip_left, clip_right); + } + hires_text_font = font; + + return; +} + +/* port: a theme font's character kept in its run, which is drawn first where +the character is not its line's next: on another baseline, back from the +run's end, of another font or justification, or past a gap of a tab stop's +(more than HIRES_RUN_GAP_SPACES of the font tag's spaces) */ +static void hires_run_add( + struct parse_string_state *state, + struct font_header *font, + struct font_character *font_character, + unsigned long color, + unsigned long shadow_color, + boolean shadow, + short x0, + short y0, + short x, + short y, + short dx, + short dy) +{ + short justification = draw_string_justification(state); + long pen = x0 - x + font_character->bitmap_origin_x; + long baseline = y0 - y + font_character->bitmap_origin_y; + + if (hires_run_count) + { + short last = hires_run_count - 1; + struct font_character *previous = hires_run[last].font_character; + struct font_character *space = font_get_character_by_ascii_code(font, ' '); + long previous_end = hires_run[last].x0 - hires_run[last].x + previous->bitmap_origin_x + previous->character_width; + long previous_baseline = hires_run[last].y0 - hires_run[last].y + previous->bitmap_origin_y; + long gap_limit = HIRES_RUN_GAP_SPACES * (space && space->character_width > 0 ? space->character_width : 4); + + if (hires_run_count == MAXIMUM_HIRES_RUN || hires_run[last].font != font || hires_run_font != hires_text_font || + hires_run_justification != justification || baseline != previous_baseline || pen < previous_end || + pen - previous_end > gap_limit) + { + hires_run_flush(); + } + } + if (!hires_run_count) + { + hires_run_justification = justification; + hires_run_font = hires_text_font; + } + hires_run[hires_run_count].font = font; + hires_run[hires_run_count].font_character = font_character; + hires_run[hires_run_count].color = color; + hires_run[hires_run_count].shadow_color = shadow_color; + hires_run[hires_run_count].shadow = shadow; + hires_run[hires_run_count].x0 = x0; + hires_run[hires_run_count].y0 = y0; + hires_run[hires_run_count].x = x; + hires_run[hires_run_count].y = y; + hires_run[hires_run_count].dx = dx; + hires_run[hires_run_count].dy = dy; + hires_run_count++; + + return; +} + static void rasterizer_draw_hires_character( struct parse_string_state *state, struct font_header *font, @@ -1025,7 +1254,11 @@ static void rasterizer_draw_hires_character( short dx, short dy) { - rasterizer_draw_hires_glyph(font, font_character, color, 0, FALSE, x0, y0, x, y, dx, dy); + if (text_hires_laid_out(hires_text_font)) + hires_run_add(state, font, font_character, color, 0, FALSE, x0, y0, x, y, dx, dy); + else + rasterizer_draw_hires_glyph(font, font_character, color, 0, FALSE, x0, y0, x, y, dx, dy, FALSE, 0.0f, 1.0f, + 0.0f, 0.0f); return; } @@ -1046,7 +1279,11 @@ static void rasterizer_draw_hires_character_with_dropshadow( global_shadow_color : (color & 0xFF000000); - rasterizer_draw_hires_glyph(font, font_character, color, shadow_color, TRUE, x0, y0, x, y, dx, dy); + if (text_hires_laid_out(hires_text_font)) + hires_run_add(state, font, font_character, color, shadow_color, TRUE, x0, y0, x, y, dx, dy); + else + rasterizer_draw_hires_glyph(font, font_character, color, shadow_color, TRUE, x0, y0, x, y, dx, dy, FALSE, 0.0f, + 1.0f, 0.0f, 0.0f); return; } diff --git a/source/saved games/game_state.c b/source/saved games/game_state.c index 42c2ea5..47dcfb9 100644 --- a/source/saved games/game_state.c +++ b/source/saved games/game_state.c @@ -102,6 +102,7 @@ symbols in this file: #include "game_state.h" #include "coop_vehicles.h" /* port: preserve replacement-vehicle homes across reverts */ #include "network_coop.h" /* port: checkpoint membership for host team-wipe reverts */ +#include "halocustoms_stats.h" /* port: port/linux/game/halocustoms_stats.c */ #include "game.h" #include "tag_files.h" #include "cache_files.h" @@ -216,7 +217,10 @@ static game_state_after_load_proc after_load_procs[] = director_initialize_for_saved_game, scripted_hud_messages_clear, /* port: restore matching homes or forget an unrelated disk/core load. */ - coop_vehicles_after_load + coop_vehicles_after_load, + /* port: HaloCustoms: the units back alive at the indices they died at + (port/linux/game/halocustoms_stats.c) */ + halocustoms_stats_reverted }; /* ---------- public code */ diff --git a/source/saved games/saved_game_files.c b/source/saved games/saved_game_files.c index a920aea..7b1e54c 100644 --- a/source/saved games/saved_game_files.c +++ b/source/saved games/saved_game_files.c @@ -1142,6 +1142,10 @@ boolean synchronize_metadata_display_name_with_profile_name( if (success == TRUE) { + /* port: HaloCustoms: a profile's account and matches go with + it to its new folder, before the old one is deleted */ + if (file.type == _saved_game_file_type_player_profile) + halocustoms_profile_moved(file.path, new_path); if (XDeleteSaveGame(root_path, file.display_name)) { error(_error_silent, "XDeleteSaveGame() failed to delete old saved game metadata in rename_metadata_display_name()"); diff --git a/source/text/draw_string.c b/source/text/draw_string.c index 7dc41ec..de2c66a 100644 --- a/source/text/draw_string.c +++ b/source/text/draw_string.c @@ -477,6 +477,15 @@ void draw_string_set_format( return; } +/* port: the justification of a string's text where a character of it is +drawn (the high-res text places a theme font's runs by it: +source/rasterizer/rasterizer_text.c's hires_run_layout) */ +short draw_string_justification( + struct parse_string_state const *state) +{ + return state ? state->justification : _text_justification_left; +} + void draw_string_set_draw_mode( long font_index, short style, diff --git a/source/units/bipeds.c b/source/units/bipeds.c index 167e83e..790ff35 100644 --- a/source/units/bipeds.c +++ b/source/units/bipeds.c @@ -1835,6 +1835,20 @@ static void biped_vehicle_speech( return; } +/* port: whether the biped is a local player's whose controller aim assist is +boosted (input.controller_aim_assist strong or lock): they jump again the +tick they land, with no landing recovery */ +static boolean biped_port_boosted( + struct biped_datum const *biped) +{ + extern int halo_linux_controller_aim_assist(short gamepad_index); + long player_index = biped->unit.player_index; + + if (player_index == NONE || player_get(player_index)->local_player_index == NONE) + return FALSE; + return halo_linux_controller_aim_assist(player_get(player_index)->local_player_index) >= 1; +} + static boolean biped_jump( long biped_index) { @@ -1843,7 +1857,7 @@ static boolean biped_jump( boolean jumped = FALSE; if (!TEST_FLAG(biped->biped.flags, _biped_airborne_bit) && - biped->biped.landing!=1) + (biped->biped.landing!=1 || biped_port_boosted(biped))) { long actor_index; real_vector3d jump_velocity; @@ -1851,6 +1865,13 @@ static boolean biped_jump( jumped = TRUE; jump_magnitude = definition->biped.jump_velocity; + /* port: a boosted controller player jumps higher (input.controller_jump_scale) */ + if (biped_port_boosted(biped)) + { + extern float halo_linux_controller_jump_scale(void); + + jump_magnitude *= halo_linux_controller_jump_scale(); + } if (biped->unit.player_index!=NONE) { struct game_globals_player_information *player_information = @@ -2206,13 +2227,14 @@ static void biped_update_jumping( biped_definition_get(biped->definition_index); if (!TEST_FLAG(biped->biped.flags, _biped_airborne_bit) && - biped->biped.landing != 1) + (biped->biped.landing != 1 || biped_port_boosted(biped))) { if (biped->biped.jump_recovery_timer < SCHAR_MAX) ++biped->biped.jump_recovery_timer; + /* (port: a boosted controller player's jump waits for no recovery) */ if (TEST_FLAG(biped->unit.control_flags, _unit_control_jump_bit) && - biped->biped.jump_recovery_timer > 5) + (biped->biped.jump_recovery_timer > 5 || biped_port_boosted(biped))) { biped_jump(biped_index); } @@ -3697,6 +3719,14 @@ static void biped_update_moving( break; } + /* port: a boosted controller player moves faster on foot + (input.controller_move_speed_scale) */ + if (biped_port_boosted(biped)) + { + extern float halo_linux_controller_move_speed_scale(void); + + movement_scale *= halo_linux_controller_move_speed_scale(); + } physics.movement_desired.i *= movement_scale; physics.movement_desired.j = dy * movement_scale; physics.movement_desired.k *= movement_scale; @@ -4395,6 +4425,13 @@ boolean biped_update( melee_speedup_ticks = biped->biped.player_melee_ticks >> 2; biped->biped.player_melee_ticks -= melee_speedup_ticks; biped->biped.player_melee_attack_tick -= melee_speedup_ticks; + /* port: a boosted controller player's melee lands the next tick + and is over the tick after: no wind-up, no recovery */ + if (biped_port_boosted(biped)) + { + biped->biped.player_melee_ticks = 2; + biped->biped.player_melee_attack_tick = 2; + } } } } diff --git a/tools/android_build.py b/tools/android_build.py index b2ce28e..9155ece 100644 --- a/tools/android_build.py +++ b/tools/android_build.py @@ -29,7 +29,7 @@ from typing import Any, Dict, List, Optional from .linux_build import (LINUX_PROFILE, MINIUPNPC_DEFINES, MINIUPNPC_DIR, MUSL_MATH_DIR, STB_VORBIS_SOURCE, XDK_INCLUDE, compile_launcher, configuration_defines, game_defines_and_includes, game_sources, miniupnpc_sources, musl_math_sources, opus_cflags, opus_sources, pgo_mode, pgo_profile, - profile_use_flags, updater_defines, xdk_headers) + profile_use_flags, updater_defines, master_key_defines, pinned_key, xdk_headers) from .embed_assets import hud_assets_build, hud_configure_inputs from .ninja_syntax import Writer @@ -484,9 +484,11 @@ def generate_android_build(n: Writer, sln: Any) -> None: for source in sorted((LINUX_DIR / "src").glob("*.c")): if source.name.startswith("posix_") or source.name in guest_host_only: continue - objects.append(guest_object(source, platform_cflags)) + objects.append(guest_object(source, f"{platform_cflags} {master_key_defines()}" + if source.name == "master_list.c" else platform_cflags)) # the high-res HUD's textures (port/assets/hud; port/linux/src/hud_hires.c) - for source in hud_assets_build(n, "android", gen_dir / "hud_hires_assets.c"): + for source in hud_assets_build(n, "android", gen_dir / "hud_hires_assets.c", + overlay=getattr(sln, "port_menus_overlay", None)): objects.append(guest_object(source, platform_cflags)) # the settings file's parser (port/third_party/tomlc17) objects.append(guest_object(TOML_DIR / "tomlc17.c", platform_cflags)) @@ -650,10 +652,14 @@ def generate_android_build(n: Writer, sln: Any) -> None: apk = PORT_DIR / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk" sdl_android_mouse_listener = SDL_DIR / SDL_ANDROID_MOUSE_LISTENER + # (the release key the app pins, given when the build is configured: + # HALOCUSTOMS_RELEASE_KEY, as updater.c's; FORK.md, "Releases") + release_key = pinned_key("HALOCUSTOMS_RELEASE_KEY") n.rule( name="android_gradle", # Gradle leaves the APK alone when its contents would not change - command=(f"cd {PORT_DIR} && ./gradlew --console=plain -q assembleDebug && " + command=(f"cd {PORT_DIR} && ./gradlew --console=plain -q " + f"{'-PhalocustomsReleaseKey=' + release_key + ' ' if release_key else ''}assembleDebug && " "touch app/build/outputs/apk/debug/app-debug.apk"), description="ANDROID GRADLE $out", pool="console", diff --git a/tools/ce_menus.py b/tools/ce_menus.py index 4aa558d..727ff3a 100644 --- a/tools/ce_menus.py +++ b/tools/ce_menus.py @@ -74,10 +74,11 @@ LEFT_OUT = { "ui\\shell\\main_menu\\multiplayer_type_select\\join_game\\filters\\op_ping", "ui\\shell\\main_menu\\multiplayer_type_select\\join_game\\filters\\op_game_options", } -# children moved: (parent, child) to (x, y); the main menu's Quit a row below -# Mods, which takes Credits' place; Multiplayer's Edit Gametypes and divider -# below Co-op, keeping its 32-unit highlight inside the description panel's -# bottom curve (port_settings.WIDGET_PATCHES) +# children moved: (parent, child) to (x, y); the main menu's items closer +# together, Mods (in Credits' place), HaloCustoms and Friends before Quit; +# Multiplayer's Edit Gametypes and divider below Co-op, keeping its 32-unit +# highlight inside the description panel's bottom curve +# (port_settings.WIDGET_PATCHES) CHILD_OFFSETS = { # the server browser's source tab (ALL GAMES, DEDICATED, P2P GAMES), 20 # units wider than CE's for its words (port_settings.WIDGET_PATCHES): 9 @@ -91,8 +92,11 @@ CHILD_OFFSETS = { **{("ui\\shell\\main_menu\\settings_select\\multiplayer_setup\\player_options_edit\\player_options_menu", f"ui\\shell\\main_menu\\settings_select\\multiplayer_setup\\player_options_edit\\op_{row}"): (54, 73 + 27 * index) for index, row in enumerate(port_settings.PLAYER_OPTIONS_ROWS)}, - ("ui\\shell\\main_menu\\main_menu_select_list", "ui\\shell\\main_menu\\main_menu_item_quit_game"): - port_settings.QUIT_ITEM_PLACE, + # the main menu's eight items, 30 apart (port_settings.MAIN_MENU_ITEM_PLACES: + # the PC version's five here, the port's three by its WIDGET_PATCHES) + **{("ui\\shell\\main_menu\\main_menu_select_list", f"ui\\shell\\main_menu\\main_menu_item_{item}"): place + for item, place in port_settings.MAIN_MENU_ITEM_PLACES.items() + if item not in ("mods", "halocustoms", "friends")}, ("ui\\shell\\main_menu\\multiplayer_type_select\\multiplayer_type_select_list", "ui\\shell\\main_menu\\multiplayer_type_select\\multiplayer_type_gametypes_item"): (0, 358), ("ui\\shell\\main_menu\\multiplayer_type_select\\multiplayer_type_select_screen", @@ -613,8 +617,10 @@ def pause_art(art): def regenerate_settings(): - """Write port-owned settings and register any new screens for embedding.""" - screens = port_settings.settings_files() + """Write the port's own screens (the settings screens, and its + multiplayer, Mods and HaloCustoms screens: tools/port_settings.py) and + register any new screens for embedding.""" + screens = {**port_settings.settings_files(), **port_settings.multiplayer_files()} for name, lines in screens.items(): (CE / name).write_text("\n".join(lines)) manifest = MENUS / "menus.json" @@ -627,7 +633,8 @@ def regenerate_settings(): def main() -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("--settings-only", action="store_true", help="regenerate port-owned settings screens and their manifest") + parser.add_argument("--settings-only", action="store_true", + help="write the port's own screens (no PC tags needed) and register them in menus.json") parser.add_argument("--tags", type=Path, required="--settings-only" not in sys.argv) parser.add_argument("--definitions", type=Path, required="--settings-only" not in sys.argv) parser.add_argument("--redraws", type=Path, required="--settings-only" not in sys.argv) @@ -706,9 +713,12 @@ def main() -> None: ((ROOT / "port/assets/icons/lucide" / f"{icon}.svg").read_text().replace('stroke="currentColor"', 'stroke="#FFFFFF"'), 24, 24) for icon in ("volume-2", "volume-x")])) - # (the main menu's MODS, in the redraws' own letters, and the mods' - # status icons: Lucide's, port/assets/icons/lucide) + # (the main menu's MODS, HALOCUSTOMS and FRIENDS, in the redraws' own + # letters, and the mods' status icons: Lucide's, port/assets/icons/lucide) lines.append(art.port_picture("main_menu/menu_mods", port_settings.menu_item_frames("MODS", art.redraws))) + lines.append(art.port_picture("main_menu/menu_halocustoms", + port_settings.menu_item_frames("HALOCUSTOMS", art.redraws))) + lines.append(art.port_picture("main_menu/menu_friends", port_settings.menu_item_frames("FRIENDS", art.redraws))) lines.append(art.port_picture("mods/status", port_settings.mod_status_frames(ROOT / "port/assets/icons/lucide"))) lines.append(art.port_picture("mods/status_small", port_settings.mod_status_frames(ROOT / "port/assets/icons/lucide", 16))) diff --git a/tools/ci_build.py b/tools/ci_build.py index 1897fd1..188891f 100644 --- a/tools/ci_build.py +++ b/tools/ci_build.py @@ -15,7 +15,11 @@ and is skipped with an older one). A profile build is a debug build with configure.py --profile, so that the profiling build is built too. CI_COMPILER_LAUNCHER (ccache, say) is passed on as --compiler-launcher. A build of the main branch gets the run's number (HALO_BUILD_NUMBER), which its release is named after and the -self-updater compares (and the web site's version.json names). +self-updater compares (and the web site's version.json names). --menus-overlay DIR +is passed on to configure.py, as an absolute path (it is given where this +is run, and configure.py runs in the repository's root): a site's own menu +files embedded in place of and with port/assets/menus' +(port/assets/menus/README.md). The web build (port/web/README.md) needs Emscripten (emcc on the PATH, as emsdk_env.sh puts it); dist/halo-web-/ is the whole site, which a @@ -40,6 +44,8 @@ import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "tools")) +from embed_assets import menu_overlay_folder, menu_overlay_font_licences # noqa: E402 # what each port's build leaves, and what goes into dist/ OUTPUTS = { @@ -81,15 +87,34 @@ def write_sums(folder: Path) -> int: return 0 -def main() -> int: - if len(sys.argv) == 3 and sys.argv[1] == "sums": - return write_sums(Path(sys.argv[2])) +def menus_overlay(text: str) -> Path: + """--menus-overlay's folder, as the absolute path configure.py is given + (tools/embed_assets.py's menu_overlay_folder, which refuses what is not + a menu overlay)""" + try: + return menu_overlay_folder(text) + except ValueError as error: + raise argparse.ArgumentTypeError(str(error)) from error + + +def arguments(argv=None) -> argparse.Namespace: + """the command line's platform, configuration and options""" parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("platform", choices=sorted(OUTPUTS)) parser.add_argument("config", choices=["debug", "release", "profile"]) - args = parser.parse_args() + parser.add_argument("--menus-overlay", metavar="DIR", type=menus_overlay, + help="a folder of menu files to embed in place of and with port/assets/menus' (configure.py)") + return parser.parse_args(argv) + + +def main() -> int: + if len(sys.argv) == 3 and sys.argv[1] == "sums": + return write_sums(Path(sys.argv[2])) + args = arguments() configure = [sys.executable, "configure.py", "--portable"] + if args.menus_overlay is not None: + configure += ["--menus-overlay", args.menus_overlay] if args.config == "release": configure.append("--release") else: @@ -160,6 +185,10 @@ def main() -> int: # the text's fonts (port/assets/fonts), embedded in every build, whose # SIL Open Font License asks each copy to carry it shutil.copy2(ROOT / "port/assets/fonts/Overpass-OFL.txt", dist / "Overpass-OFL.txt") + # and a menu overlay's theme fonts (embedded with its menus), whose + # licences ask the same + for licence in menu_overlay_font_licences(args.menus_overlay) if args.menus_overlay else []: + shutil.copy2(licence, dist / licence.name) # the menus' XML parser (port/third_party/expat), in every build, whose # MIT license asks copies to carry its notice shutil.copy2(ROOT / "port/third_party/expat/COPYING", dist / "expat-COPYING.txt") diff --git a/tools/embed_assets.py b/tools/embed_assets.py index 61f3981..515d56d 100644 --- a/tools/embed_assets.py +++ b/tools/embed_assets.py @@ -7,7 +7,7 @@ made by tools/button_assets.py), the fonts the text is drawn with tools/ce_menus.py) and SMAA's shader and lookup textures (port/third_party/smaa) in the game as C data: - python tools/embed_assets.py OUTPUT.c + python tools/embed_assets.py [--menus-overlay DIR] [--depfile FILE] OUTPUT.c writes OUTPUT.c with each PNG and the bitmap it stands for (its tag, index and checksum of its pixels, from port/assets/hud/layout.json, @@ -16,20 +16,60 @@ fonts and their tags from port/assets/fonts/fonts.json, the menu files listed in port/assets/menus/menus.json, and SMAA's shader and lookup textures, as port/linux/src/hud_hires.h, text_hires.h, menu_files.h and xgpu_post.c declare them. The builds generate it -(hud_assets_build, called by tools/linux_build.py, windows_build.py and -android_build.py), so the assets are the committed source and Android needs -no files beside its guest image. +(hud_assets_build, called by tools/linux_build.py, windows_build.py, +android_build.py and web_build.py), so the assets are the committed source +and Android needs no files beside its guest image. OUTPUT.c is written only +when it would change, and ninja looks at it again after the step (restat), +so that a run that changes nothing compiles nothing. + +--menus-overlay DIR (configure.py's option of that name) embeds a site's own +look with the stock menus: a folder laid out as port/assets/menus is, its +files under ce/ (menu_overlay_folder refuses a folder that has none there). +Its .xml and .png files, a theme's .ttf fonts and their licences +(-OFL.txt; the suffix in any case) are embedded: one with the path +of a file menus.json lists in place of that file, the others after them, in +the order of their paths. Its other files are left out, and the +step says which (but for its dot files and folders, and a manifest.json at +its top, a site's list of the folder); a link to a folder is followed. The +merged set is then checked as the game checks it when it loads it +(tools/menu_check.py), for each platform, and the step fails with every +problem and its file where the game would log one and keep the Xbox's +menus. The stock pictures that no XML file of the merged set names any more +are left out (but MENU_FILES_THE_GAME_NAMES, which the game's code names), +so a menus folder beside config.toml, which still replaces any file +(port/linux/src/menu_files.c), brings its own copy of one of them it names. + +--depfile FILE writes what the step is to run again for, as a compiler's +depfile, which ninja reads (the rule's depfile and deps): every folder of +the overlay that was walked (a folder's time changes when a file is added +to it, removed from it or renamed), the folder that holds the overlay (for +an overlay put there again whole; but not the folder ninja runs in, which +ninja's own files change), every file of it that was read, and the game's +sources the check reads its names from. build.ninja cannot list them: it +would know the files there were when configure.py ran. A path ninja cannot +read from a depfile (depfile_name: one with a ; or a quote, say) makes the +step run on every build instead, and it says so. The data are 32-bit words, not bytes: the Android build passes the guest's assembly through tools/android_asm_convert.py, which rewrites identifiers in operands (it keeps .ascii strings as they are, but once garbled them). """ +import argparse import json +import os +import shlex import struct +import subprocess import sys from pathlib import Path -from typing import Any, List +from typing import Any, List, Optional, Tuple + +if __package__: + from . import menu_check +else: + # (run as a script, or imported from tools/ itself) + import menu_check ROOT = Path(__file__).resolve().parent.parent HUD_ASSETS = Path("port/assets/hud") @@ -48,6 +88,20 @@ FONT_ASSETS = Path("port/assets/fonts") FONT_LIST = FONT_ASSETS / "fonts.json" MENU_ASSETS = Path("port/assets/menus") MENU_LIST = MENU_ASSETS / "menus.json" +# the kinds of file a menu overlay gives the game: the menus' XML, their +# pictures, and a theme's fonts with the licences beside them (the suffix in +# any case: port/assets/menus/README.md, "Theme fonts") +MENU_OVERLAY_SUFFIXES = (".xml", ".png", ".ttf") +MENU_OVERLAY_LICENCE_SUFFIX = "-ofl.txt" +# the menus' files the game's code names by their paths, which no XML file +# need name: they are embedded with a menu overlay too (BLANK_PNG, +# port/linux/game/menu_tags.c; tools/test_web_build.py looks for others) +MENU_FILES_THE_GAME_NAMES = ("ce/port/blank__0.png",) +# what a menu overlay is, for the messages that refuse one +MENU_OVERLAY_LAYOUT = ("an overlay is a folder laid out as port/assets/menus is, with its .xml and .png files " + "(and a theme's .ttf fonts with their -OFL.txt licences) under ce/ (ce/bitmaps.xml, " + "ce/port/...): each is embedded in place of the stock file of its path, or with them " + "(port/assets/menus/README.md)") # SMAA's files and the names port/linux/src/xgpu_post.c declares them by SMAA_ASSETS = Path("port/third_party/smaa") SMAA_FILES = (("SMAA.hlsl", "xgpu_smaa_shader"), ("area_tex.zlib", "xgpu_smaa_area_texture"), @@ -87,13 +141,123 @@ def menu_files() -> List[str]: return json.loads((ROOT / MENU_LIST).read_text())["files"] +def menu_overlay_embedded_file(name: str) -> bool: + """whether a menu overlay's file is embedded: an .xml, .png or .ttf, or a + font's licence (-OFL.txt), the suffix in any case""" + lower = name.lower() + return lower.endswith(MENU_OVERLAY_SUFFIXES) or lower.endswith(MENU_OVERLAY_LICENCE_SUFFIX) + + +def menu_overlay_font_licences(overlay: Path) -> List[Path]: + """the licences of a menu overlay's theme fonts (its -OFL.txt + files, which it embeds), which a build ships beside the game (the web's + licenses.txt; a release's folder, tools/ci_build.py)""" + return [overlay / name for name in menu_overlay_files(overlay) + if name.lower().endswith(MENU_OVERLAY_LICENCE_SUFFIX)] + + +def menu_overlay_walk(overlay: Path) -> Tuple[List[str], List[Path], List[str]]: + """A menu overlay's files that are embedded (.xml, .png and .ttf, and the + fonts' -OFL.txt licences, the suffix in any case), relative to it with / separators, in the order of their + paths; the folders walked for them, the overlay first; and what is left + out, relative as well: files of another kind and whatever is neither a + file nor a folder. Dot files and folders are passed by without a word, + and so is a manifest.json at the top (a site's list of the folder). A + link to a folder is followed; ValueError for one that leads back to a + folder it is in, and for a folder that cannot be read.""" + files: List[str] = [] + folders: List[Path] = [] + others: List[str] = [] + + def walk(folder: Path, parts: Tuple[str, ...], above: Tuple[str, ...]) -> None: + real = os.path.realpath(folder) + if real in above: + raise ValueError(f"{folder}: a link back to a folder that holds it") + folders.append(folder) + try: + names = sorted(os.listdir(folder)) + except OSError as error: + raise ValueError(f"{folder}: {error.strerror}") from error + for name in names: + if name.startswith("."): + continue + path = folder / name + if path.is_dir(): + walk(path, (*parts, name), (*above, real)) + elif path.is_file() and menu_overlay_embedded_file(name): + files.append("/".join((*parts, name))) + elif parts or name != "manifest.json": + others.append("/".join((*parts, name))) + + walk(overlay, (), ()) + return sorted(files), folders, sorted(others) + + +def menu_overlay_files(overlay: Path) -> List[str]: + """The files of a menu overlay that are embedded (menu_overlay_walk).""" + return menu_overlay_walk(overlay)[0] + + +def menu_overlay_folder(text: str) -> Path: + """The folder a --menus-overlay option names, as an absolute path (the + option is given where the command is run, which is not always where the + files are read from). ValueError, with what an overlay is, for an empty + path, for one that is not a folder, and for a folder with no file under + the folders the stock files are in (ce/): a folder one level too high, + or an empty one, would embed nothing of the game's menus, or whatever + else it holds.""" + if not str(text).strip(): + raise ValueError(f"an empty path: {MENU_OVERLAY_LAYOUT}") + path = Path(os.path.abspath(text)) + # (a .. after a link is another folder to the system than to abspath: + # the system's then) + if os.path.exists(text) and not (path.exists() and os.path.samefile(path, text)): + path = Path(os.path.realpath(text)) + if not path.is_dir(): + raise ValueError(f"{path}: not a folder: {MENU_OVERLAY_LAYOUT}") + tops = sorted({name.split("/", 1)[0] for name in menu_files() if "/" in name}) + files = menu_overlay_files(path) + if tops and not any(name.split("/", 1)[0] in tops for name in files if "/" in name): + raise ValueError(f"{path}: no .xml or .png file under {', '.join(f'{top}/' for top in tops)}: " + f"{MENU_OVERLAY_LAYOUT}") + return path + + +def menu_overlay_embeds(overlay: Optional[Path]) -> bool: + """Whether a menu overlay gives the game any file, in place of a stock + one or with them: what a web build's version.json says of its menus + (port/web/stamp_version.py).""" + try: + return overlay is not None and bool(menu_overlay_files(overlay)) + except ValueError: + return False + + +def menu_sources(overlay: Optional[Path] = None, files: Optional[List[str]] = None) -> List[Tuple[str, Path]]: + """The menus' files, each one's path in the menus and the file it is + read from. Without an overlay, menus.json's files from port/assets/menus; + with one, the overlay's file of the same path in place of each it has, + and then the overlay's other files (files: menu_overlay_files, where + they have been listed already).""" + sources = [(name, MENU_ASSETS / name) for name in menu_files()] + if overlay is None: + return sources + if files is None: + files = menu_overlay_files(overlay) + replaced = set(files) + sources = [(name, overlay / name if name in replaced else path) for name, path in sources] + listed = {name for name, _ in sources} + return sources + [(name, overlay / name) for name in files if name not in listed] + + def smaa_files() -> List[tuple]: """SMAA's files that the checkout has, with their symbols.""" return [(name, symbol) for name, symbol in SMAA_FILES if (ROOT / SMAA_ASSETS / name).is_file()] def hud_asset_inputs() -> List[Path]: - """The files the generated source is made from.""" + """The files of the repository the generated source is made from (a + menu overlay's are in the step's depfile).""" inputs = [listing for listing in (LAYOUT, CE_LAYOUT, CE_STOCK, CUSTOM_EDITION_HUD, TITLE_LIST, BUTTON_LIST, CONTROLLER_LIST, FONT_LIST, MENU_LIST) if (ROOT / listing).is_file()] return [*inputs, *(folder / f"{asset['name']}.png" for folder, asset, _ in textures()), @@ -106,8 +270,10 @@ def hud_asset_inputs() -> List[Path]: def hud_configure_inputs() -> List[Path]: """What configure.py is run again for: the lists of assets (and the folders, for files added or removed), not each file, which a change of a - list may rename or remove.""" - inputs = [] + list may rename or remove. And this script, whose rule the builds' + graphs carry: a change to the rule must reach a build folder made + before it.""" + inputs = [Path("tools/embed_assets.py")] for folder, listing in ((HUD_ASSETS, LAYOUT), (HUD_ASSETS, CE_LAYOUT), (TITLE_ASSETS, TITLE_LIST), (BUTTON_ASSETS, BUTTON_LIST), (CONTROLLER_ASSETS, CONTROLLER_LIST), (FONT_ASSETS, FONT_LIST), (MENU_ASSETS, MENU_LIST)): @@ -126,17 +292,41 @@ def words(data: bytes) -> List[str]: for start in range(0, len(values), 8)] -def hud_assets_build(n: Any, prefix: str, output: Path) -> List[Path]: +def command_argument(text: str) -> str: + """text as one argument of a ninja rule's command: quoted for what runs + the command (a POSIX shell; on Windows the program itself, which reads + its command line as the C runtime does), then with ninja's $ doubled and + each space escaped, so that ninja_syntax does not break a long line + inside it. An argument with nothing special comes out as it is. + ValueError for one with a line break, which a build.ninja cannot hold.""" + if "\n" in text or "\r" in text: + raise ValueError(f"{text!r}: a line break cannot be written in build.ninja") + quoted = subprocess.list2cmdline([text]) if os.name == "nt" else shlex.quote(text) + return quoted.replace("$", "$$").replace(" ", "$ ") + + +def hud_assets_build(n: Any, prefix: str, output: Path, overlay: Optional[Path] = None) -> List[Path]: """Emits the rule that generates output; returns [output]. It is made whatever assets the checkout has (with none, its tables are empty), so - that the symbols the platform layer refers to are always defined.""" - inputs = hud_asset_inputs() + that the symbols the platform layer refers to are always defined. With + a menu overlay (configure.py --menus-overlay), its files are embedded + too; they are not inputs here but in the depfile the step writes, with + its folders: a file added to the overlay, removed from it or changed + makes the output again.""" + option = "" + if overlay is not None: + option = f"--menus-overlay {command_argument(str(overlay))} --depfile $out.d " n.rule( name=f"{prefix}_embed_assets", - command="$python tools/embed_assets.py $out", + command=f"$python tools/embed_assets.py {option}$out", description=f"{prefix.upper()} EMBED $out", + depfile="$out.d" if overlay is not None else None, + # (the output is written only when it changes) + restat=True, + deps="gcc" if overlay is not None else None, ) - n.build(outputs=output, rule=f"{prefix}_embed_assets", implicit=[Path("tools/embed_assets.py"), *inputs]) + n.build(outputs=output, rule=f"{prefix}_embed_assets", + implicit=[Path("tools/embed_assets.py"), Path("tools/menu_check.py"), *hud_asset_inputs()]) return [output] @@ -167,11 +357,156 @@ def png_size(data: bytes, name: str) -> tuple: return width, height +def c_string(text: str) -> str: + """text as the inside of a C string literal that holds its UTF-8 bytes: + a quote, a backslash and whatever is not a printable ASCII character as + escapes""" + return "".join(chr(byte) if 0x20 <= byte < 0x7F and byte not in b'"\\' else f"\\{byte:03o}" + for byte in os.fsencode(text)) + + +def depfile_name(path: str) -> Optional[str]: + """path as ninja reads a name from a depfile (its depfile_parser, which + takes a compiler's Makefile rule): a space, a # and a $ escaped, and a + backslash before one of them as it needs. None for a path it cannot be + given at all: one with a control character or one of ; * < > | ^ ` ? " ' + & not after a backslash (ninja 1.13 reads the last four, those before + it do not), or that ends in a backslash or a colon.""" + plain = "+,/_:.~()}{%=@[]!-" + # (a backslash at its end would escape what follows the name, and a + # colon there ends a rule's targets) + if not path or path[-1] in "\\:": + return None + result = [] + index = 0 + while index < len(path): + run = index + while path[run] == "\\": + run += 1 + backslashes, character = run - index, path[run] + if character in "\0\r\n": + return None + if character == " ": + # (2n + 1 backslashes and a space: n backslashes and a space) + result.append("\\" * (2 * backslashes + 1) + " ") + elif character == "#" or (character == ":" and backslashes): + # (n + 1 backslashes and it: n backslashes and it) + result.append("\\" * (backslashes + 1) + character) + elif backslashes: + # (backslashes and any other character are themselves) + result.append("\\" * backslashes + character) + elif character == "$": + result.append("$$") + elif character.isascii() and not character.isalnum() and character not in plain: + return None + else: + result.append(character) + index = run + 1 + return "".join(result) + + +def depfile_text(output: Path, paths: List[Path]) -> Tuple[bytes, List[str]]: + """A depfile's bytes: output's rule, with the paths it is made again for + (those under the working folder relative to it, as build.ninja names + files); and the paths ninja cannot read from one. With any of those, the + rule names a file beside the output that is never made, so that ninja + runs the step on every build: it could not tell when to.""" + here = Path.cwd() + names, untracked = [], [] + target = depfile_name(str(output)) + if target is None: + sys.exit(f"{output}: a depfile cannot name this path (--depfile)") + for path in paths: + try: + text = str(path.relative_to(here)) + except ValueError: + text = str(path) + name = depfile_name(text) + if name is None: + untracked.append(text) + elif name not in names: + names.append(name) + if untracked: + names.append(depfile_name(f"{output}.every-build")) + return os.fsencode(target + ":" + "".join(f" \\\n {name}" for name in names) + "\n"), untracked + + +def some(names: List[str], most: int = 5) -> str: + """a few of the names, and how many more there are""" + return ", ".join(names[:most]) + (f" and {len(names) - most} more" if len(names) > most else "") + + +def menu_overlay_sources(overlay: Path) -> Tuple[List[Tuple[str, Path]], List[Path]]: + """What a build with a menu overlay embeds of the menus: each file's path + in the menus and the file it is read from (menu_sources), the merged + set having been checked (tools/menu_check.py) and the stock pictures + nothing names left out; and what the step is run again for (its + depfile's paths). Stops with every problem of the set; ValueError for an + overlay that cannot be read.""" + files, folders, others = menu_overlay_walk(overlay) + if others: + kind = "file that is not an .xml, .png or .ttf file, or a font's -OFL.txt licence, and is" \ + if len(others) == 1 else "files that are not .xml, .png or .ttf files, or fonts' -OFL.txt licences, and are" + print(f"embed_assets.py: {overlay} has {len(others)} {kind} left out: {some(others)}") + sources = menu_sources(overlay, files) + try: + read = [menu_check.MenuFile(name, (ROOT / path).read_bytes(), str(path)) for name, path in sources] + except OSError as error: + raise ValueError(f"{error.filename}: {error.strerror}") from error + problems = menu_check.problems(read, ROOT) + if problems: + shown = 50 + sys.exit("\n".join([ + *problems[:shown], + *([f"... and {len(problems) - shown} more"] if len(problems) > shown else []), + f"embed_assets.py: {'1 problem' if len(problems) == 1 else f'{len(problems)} problems'} in the menus " + f"with {overlay} (--menus-overlay), which are not embedded: a game built with them would log the " + "first when it is run and keep the Xbox's menus, or draw them wrong"])) + # (the stock pictures nothing names any more: the overlay's bitmaps name its own) + named = menu_check.pictures_named(read) | set(MENU_FILES_THE_GAME_NAMES) + of_the_overlay = set(files) + sources = [(name, path) for name, path in sources + if name in of_the_overlay or not name.lower().endswith(".png") or name in named] + # (the folder that holds the overlay too: its time changes when the + # overlay is put there again whole, with files as old as they were. Not + # where it is the folder ninja runs in, whose time ninja's own files + # change on every build) + holder = [] if os.path.samefile(overlay.parent, Path.cwd()) else [overlay.parent] + tracked = [*holder, *folders, *(overlay / name for name in files), + ROOT / menu_check.TAGS_SOURCE, ROOT / menu_check.FUNCTIONS_SOURCE] + return sources, tracked + + def main() -> None: - if len(sys.argv) != 2: - sys.exit("usage: embed_assets.py OUTPUT.c") + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0], + usage="embed_assets.py [--menus-overlay DIR] [--depfile FILE] OUTPUT.c") + parser.add_argument("--menus-overlay", metavar="DIR", + help="a folder of menu files laid out as port/assets/menus, embedded in place of and with its files") + parser.add_argument("--depfile", metavar="FILE", type=Path, + help="write what the step is run again for (a menu overlay's folders and files), for ninja") + parser.add_argument("output", metavar="OUTPUT.c", type=Path) + args = parser.parse_args() + overlay = None + menus = menu_sources() + tracked: List[Path] = [] + if args.menus_overlay is not None: + try: + overlay = menu_overlay_folder(args.menus_overlay) + except ValueError as error: + sys.exit(f"--menus-overlay {error}") + try: + menus, tracked = menu_overlay_sources(overlay) + except ValueError as error: + sys.exit(str(error)) lines = [ "/* generated by tools/embed_assets.py from port/assets: do not edit */", + ] + if overlay is not None: + # (its path in plain characters, and never the end of the comment) + shown = "".join(character if " " <= character <= "~" else "?" for character in overlay.as_posix()) + lines.append(f"/* (the menus' files from {shown.replace('*/', '* /')}, --menus-overlay, in place of and with " + "port/assets/menus') */") + lines += [ "", '#include "hud_hires.h"', "", @@ -248,20 +583,21 @@ def main() -> None: # the menus' files (menu_files.h) lines.append('#include "menu_files.h"') lines.append("") - menus = menu_files() - for index, name in enumerate(menus): - data = (ROOT / MENU_ASSETS / name).read_bytes() - if name.endswith(".png"): + sizes = [] + for index, (name, path) in enumerate(menus): + data = (ROOT / path).read_bytes() + # (a menu overlay's set has been checked whole: menu_overlay_sources) + if overlay is None and name.endswith(".png"): png_size(data, name) + sizes.append(len(data)) lines.append(f"static const unsigned int menu{index}[] = {{") lines.extend(words(data)) lines.append("};") lines.append("") lines.append("const struct menu_file_embedded menu_files_embedded[] =") lines.append("{") - for index, name in enumerate(menus): - size = (ROOT / MENU_ASSETS / name).stat().st_size - lines.append(f'\t{{ "{name}", menu{index}, {size} }},') + for index, (name, path) in enumerate(menus): + lines.append(f'\t{{ "{c_string(name)}", menu{index}, {sizes[index]} }},') if not menus: lines.append("\t{ 0 },") lines.append("};") @@ -297,8 +633,15 @@ def main() -> None: lines.append(f"const unsigned long platform_menu_cursor_size = {len(data)};") lines.append("") lines.append("#endif") - output = Path(sys.argv[1]) + output = args.output output.parent.mkdir(parents=True, exist_ok=True) + if args.depfile is not None: + depfile, untracked = depfile_text(output, tracked) + if untracked: + print(f"embed_assets.py: ninja cannot follow {len(untracked)} of the menu overlay's paths (a depfile " + f"cannot name {untracked[0]}): the menus are embedded again on every build") + args.depfile.parent.mkdir(parents=True, exist_ok=True) + args.depfile.write_bytes(depfile) text = "\n".join(lines) + "\n" if not output.is_file() or output.read_text() != text: output.write_text(text) diff --git a/tools/halocustoms_release_sign.py b/tools/halocustoms_release_sign.py new file mode 100644 index 0000000..93fea0c --- /dev/null +++ b/tools/halocustoms_release_sign.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""HaloCustoms' releases signed offline (contract 1, FORK.md "Releases"), with the release key: an Ed25519 key that +whoever releases makes and keeps on a machine of their own, never on the site's server, so that a break-in at the +site cannot send players' games code they would run. A build pins its public half (HALOCUSTOMS_RELEASE_KEY) and takes +a release only when the release's manifest checks with it (port/linux/src/updater.c, ReleaseManifest.java). + + python tools/halocustoms_release_sign.py new-key + python tools/halocustoms_release_sign.py public-key + python tools/halocustoms_release_sign.py sign ... + python tools/halocustoms_release_sign.py verify + +new-key writes a new key's seed (64 hexadecimal digits and a line feed) to a new file that only its owner may read +(0400), and prints the key's public half, ed25519:<64 hex>, and its id (the first 8 bytes of the SHA-256 of its 32 +bytes, in hex). public-key prints them again. sign prints the release's manifest, its signature and the key's id as +one JSON object of the feed's three members (halocustoms_manifest, halocustoms_signature, halocustoms_key_id), which +the site's publishing takes; its zips are named as the feed's assets (halo--.zip). +verify checks such an object, a file, with a public key. + +Ed25519 is OpenSSL's (its command line: openssl pkeyutl -rawin, OpenSSL 3), as OpenCE's tools/release_sign.sh signs +with it: nothing to install on a machine with OpenSSL 3, and an implementation other than the game's own (Monocypher, +Ed25519.java), which the tests compare it with. The seed goes to OpenSSL in a file of a private temporary folder, which +is removed at once, and is never printed. +""" + +import argparse +import hashlib +import json +import os +import re +import secrets +import shutil +import stat +import subprocess +import sys +import tempfile +from contextlib import contextmanager +from pathlib import Path + +# the feed's assets' names (the site's GameReleases::ASSET_PATTERN) +ASSET_PATTERN = re.compile(r"halo-(linux|windows|android)-(release|debug)\.zip") +# the contract's limits, as the games read a manifest (updater.c, ReleaseManifest.java) +BUILD_MAXIMUM = 999999999 +NETWORK_MAXIMUM = 65535 +SIZE_MAXIMUM = 256 * 1024 * 1024 +MANIFEST_MAXIMUM = 4096 +LINE_MAXIMUM = 200 +ASSETS_MAXIMUM = 16 +# an Ed25519 key's DER forms: a seed's PKCS #8, a public key's SubjectPublicKeyInfo (RFC 8410) +PKCS8_PREFIX = bytes.fromhex("302e020100300506032b657004220420") +SPKI_PREFIX = bytes.fromhex("302a300506032b6570032100") + + +class Refused(Exception): + """what the tool will not do, and why""" + + +# ---------- the manifest + +def manifest_text(build, network, assets): + """the manifest of a release: assets are (name, size, sha256 hex), in any order""" + lines = ["halocustoms-release 1", f"tag build-{build}", f"network {network}"] + lines += [f"asset {name} {size} {digest}" for name, size, digest in sorted(assets, key=lambda a: a[0].encode())] + return "".join(line + "\n" for line in lines) + + +def decimal(text, maximum): + """a decimal number from 1 to maximum, with no sign or leading zero, else None""" + if not re.fullmatch(r"[1-9][0-9]{0,18}", text): + return None + value = int(text) + return value if value <= maximum else None + + +def read_manifest(text): + """a manifest's (build, network version, {name: (size, sha256)}), read as the games read one; Refused if it is + not one""" + data = text.encode("utf-8") + if not data or len(data) > MANIFEST_MAXIMUM or not data.endswith(b"\n"): + raise Refused("the manifest is empty, longer than 4096 bytes or not ended by a line feed") + lines = data[:-1].split(b"\n") + if len(lines) < 4: + raise Refused("the manifest lists no asset") + for number, line in enumerate(lines, 1): + if any(byte < 0x20 or byte > 0x7E for byte in line) or len(line) > LINE_MAXIMUM: + raise Refused(f"the manifest's line {number} holds a byte that is not printable ASCII, or is too long") + fields = [line.decode("ascii").split(" ") for line in lines] + if any("" in line for line in fields): + raise Refused("a line of the manifest has a field that is empty (a space at an end, or two together)") + if fields[0] != ["halocustoms-release", "1"]: + raise Refused("the manifest is not one of contract 1") + build = (decimal(fields[1][1][6:], BUILD_MAXIMUM) if len(fields[1]) == 2 and fields[1][0] == "tag" and + fields[1][1].startswith("build-") else None) + network = decimal(fields[2][1], NETWORK_MAXIMUM) if len(fields[2]) == 2 and fields[2][0] == "network" else None + if build is None or network is None: + raise Refused("the manifest's tag or network version is not one") + assets = {} + previous = None + for line in fields[3:]: + if (len(line) != 4 or line[0] != "asset" or not re.fullmatch(r"[a-z0-9][a-z0-9._-]{0,59}\.zip", line[1]) or + decimal(line[2], SIZE_MAXIMUM) is None or not re.fullmatch(r"[0-9a-f]{64}", line[3])): + raise Refused(f"the manifest's line {' '.join(line)!r} is not an asset") + if previous is not None and line[1].encode() <= previous.encode(): + raise Refused("the manifest's assets are not in increasing order of their names") + previous = line[1] + assets[line[1]] = (int(line[2]), line[3]) + if len(assets) > ASSETS_MAXIMUM: + raise Refused(f"the manifest lists more than {ASSETS_MAXIMUM} assets") + return build, network, assets + + +def key_id(public): + """a key's id: the first 8 bytes of the SHA-256 of its 32 bytes, in hex""" + return hashlib.sha256(public).hexdigest()[:16] + + +# ---------- OpenSSL's Ed25519 + +def openssl(*arguments): + """an openssl command's standard output; Refused when it fails""" + command = shutil.which("openssl") + if not command: + raise Refused("no openssl command here (OpenSSL 3 is needed)") + result = subprocess.run([command, *arguments], capture_output=True) + if result.returncode: + raise Refused(f"openssl {arguments[0]} failed (OpenSSL 3 is needed): " + f"{result.stderr.decode(errors='replace').strip()}") + return result.stdout + + +@contextmanager +def private_folder(): + """a temporary folder only its owner may enter, removed (its files overwritten first) at the end""" + folder = Path(tempfile.mkdtemp(prefix="halocustoms-release-")) + try: + yield folder + finally: + for path in folder.iterdir(): + try: + with open(path, "r+b") as file: + file.write(b"\0" * path.stat().st_size) + except OSError: + pass + shutil.rmtree(folder, ignore_errors=True) + + +def write_private(path, data): + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "wb") as file: + file.write(data) + + +def public_key(seed): + """the seed's public key, 32 bytes""" + with private_folder() as folder: + write_private(folder / "seed.der", PKCS8_PREFIX + seed) + der = openssl("pkey", "-inform", "DER", "-in", str(folder / "seed.der"), "-pubout", "-outform", "DER") + if len(der) != len(SPKI_PREFIX) + 32 or not der.startswith(SPKI_PREFIX): + raise Refused("openssl gave a public key that is not an Ed25519 key's") + return der[len(SPKI_PREFIX):] + + +def signature_of(seed, data): + """the Ed25519 signature of data's exact bytes, 64 bytes""" + with private_folder() as folder: + write_private(folder / "seed.der", PKCS8_PREFIX + seed) + write_private(folder / "message", data) + signature = openssl("pkeyutl", "-sign", "-rawin", "-inkey", str(folder / "seed.der"), "-keyform", "DER", + "-in", str(folder / "message")) + if len(signature) != 64: + raise Refused("openssl gave a signature that is not an Ed25519 signature") + return signature + + +def checks(public, data, signature): + """whether the signature is the public key's, of data""" + with private_folder() as folder: + write_private(folder / "key.der", SPKI_PREFIX + public) + write_private(folder / "message", data) + write_private(folder / "signature", signature) + command = shutil.which("openssl") + if not command: + raise Refused("no openssl command here (OpenSSL 3 is needed)") + result = subprocess.run([command, "pkeyutl", "-verify", "-rawin", "-pubin", "-inkey", str(folder / "key.der"), + "-keyform", "DER", "-in", str(folder / "message"), "-sigfile", + str(folder / "signature")], capture_output=True) + return result.returncode == 0 + + +# ---------- the seed file + +def read_seed(path): + """a seed file's 32 bytes; Refused if others may read it, or it is not a seed's""" + try: + mode = os.stat(path).st_mode + except OSError as error: + raise Refused(f"cannot read {path}: {error.strerror}") from None + if os.name == "posix" and mode & (stat.S_IRWXG | stat.S_IRWXO): + raise Refused(f"{path} may be read by others than its owner (chmod 400 it)") + text = Path(path).read_text(encoding="ascii", errors="replace") + if not re.fullmatch(r"[0-9a-f]{64}\n?", text): + raise Refused(f"{path} is not a seed: 64 lowercase hexadecimal digits") + return bytes.fromhex(text[:64]) + + +def parse_public(text): + if not re.fullmatch(r"ed25519:[0-9a-f]{64}", text): + raise Refused("a public key is ed25519: and 64 lowercase hexadecimal digits") + return bytes.fromhex(text[8:]) + + +# ---------- the commands + +def new_key(path): + seed = secrets.token_bytes(32) + public = public_key(seed) + try: + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o400) + except FileExistsError: + raise Refused(f"{path} is there already: a key is never written over") from None + with os.fdopen(descriptor, "w", encoding="ascii") as file: + file.write(seed.hex() + "\n") + return public + + +def release(seed, build, network, zips): + """the feed's three members of a signed release of the zips""" + if decimal(str(build), BUILD_MAXIMUM) is None or decimal(str(network), NETWORK_MAXIMUM) is None: + raise Refused(f"a build number is from 1 to {BUILD_MAXIMUM}, a network version from 1 to {NETWORK_MAXIMUM}") + assets = [] + names = set() + for path in map(Path, zips): + if not ASSET_PATTERN.fullmatch(path.name): + raise Refused(f"{path.name} is not a name of the feed's assets: halo--" + ".zip") + if path.name in names: + raise Refused(f"{path.name} is named twice") + if not path.is_file(): + raise Refused(f"{path} is not a file") + names.add(path.name) + size = path.stat().st_size + if not 0 < size <= SIZE_MAXIMUM: + raise Refused(f"{path.name} is {size} bytes: a release's file is from 1 to {SIZE_MAXIMUM}") + digest = hashlib.sha256() + with open(path, "rb") as file: + for block in iter(lambda: file.read(1 << 20), b""): + digest.update(block) + assets.append((path.name, size, digest.hexdigest())) + if not assets: + raise Refused("name a zip at least") + text = manifest_text(build, network, assets) + read_manifest(text) + public = public_key(seed) + signature = signature_of(seed, text.encode()) + # (checked again, with the public key alone, as the games will) + if not checks(public, text.encode(), signature): + raise Refused("the signature does not check: OpenSSL is not signing as it should") + return {"halocustoms_manifest": text, "halocustoms_signature": "ed25519:" + signature.hex(), + "halocustoms_key_id": key_id(public)} + + +def verify(public, fields): + """a release's three members checked with a public key: its manifest's (build, network, assets)""" + text, signature, identifier = (fields.get(name) for name in ("halocustoms_manifest", "halocustoms_signature", + "halocustoms_key_id")) + if not all(isinstance(value, str) for value in (text, signature, identifier)): + raise Refused("the release has no halocustoms_manifest, halocustoms_signature or halocustoms_key_id") + if identifier.lower() != key_id(public): + raise Refused(f"it is signed with another key ({identifier}), not this one ({key_id(public)})") + if not re.fullmatch(r"ed25519:[0-9a-fA-F]{128}", signature): + raise Refused("its signature is not ed25519:<128 hexadecimal digits>") + if not checks(public, text.encode("utf-8"), bytes.fromhex(signature[8:])): + raise Refused("its signature does not check with this key") + return read_manifest(text) + + +def main(arguments): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + commands = parser.add_subparsers(dest="command", required=True) + command = commands.add_parser("new-key", help="make a new release key, its seed in a new file (0400)") + command.add_argument("seed") + command = commands.add_parser("public-key", help="print a seed's public key and key id") + command.add_argument("seed") + command = commands.add_parser("sign", help="print a release's signed manifest, as the feed's members") + command.add_argument("seed") + command.add_argument("build", type=int) + command.add_argument("network", type=int) + command.add_argument("zips", nargs="+") + command = commands.add_parser("verify", help="check a release's signed manifest with a public key") + command.add_argument("public_key") + command.add_argument("release") + args = parser.parse_args(arguments) + try: + if args.command == "new-key": + public = new_key(args.seed) + print(f"ed25519:{public.hex()}") + print(f"key id {key_id(public)}", file=sys.stderr) + elif args.command == "public-key": + public = public_key(read_seed(args.seed)) + print(f"ed25519:{public.hex()}") + print(f"key id {key_id(public)}", file=sys.stderr) + elif args.command == "sign": + print(json.dumps(release(read_seed(args.seed), args.build, args.network, args.zips), indent=2)) + else: + try: + fields = json.loads(Path(args.release).read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise Refused(f"cannot read {args.release}: {error}") from None + build, network, assets = verify(parse_public(args.public_key), fields if isinstance(fields, dict) else {}) + print(f"checks: build-{build}, network version {network}, " + + ", ".join(f"{name} ({size} bytes)" for name, (size, _) in assets.items())) + except Refused as error: + print(f"{Path(sys.argv[0]).name}: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/harness/tests/halocustoms_friends.c b/tools/harness/tests/halocustoms_friends.c new file mode 100644 index 0000000..798ec7d --- /dev/null +++ b/tools/harness/tests/halocustoms_friends.c @@ -0,0 +1,518 @@ +/* +HALOCUSTOMS_FRIENDS.C (test) + +Player 1's profile's friends at the HaloCustoms site and the presence the +game tells it (port/linux/src/halocustoms_friends.c, compiled whole as +friends.inc over the real JSON reader and writer, server_json.c): the +site's friends' list read, as untrusted input (sizes, counts, characters, a +game's listing, entries that are not ones); a heartbeat's answer read; a +heartbeat's body made; when one is due; a friend's state in words; and what +the game says it does. And the FRIENDS screen's own (menu_functions.c's, +under_test.inc): its rows' texts, which help it shows, and why a friend's +game is not joined, over a stub of p2p_lobby.c's listing check. From +test_halocustoms_friends.py. +*/ + +#include "harness.h" +#include "../../../port/linux/src/server_json.c" + +#include +#include + +#define NUMBEROF(array) ((long)(sizeof(array) / sizeof((array)[0]))) +#define PIN(value, low, high) ((value) < (low) ? (low) : (value) > (high) ? (high) : (value)) + +#include "friends.inc" + +/* ---------- the FRIENDS screen's world */ + +#include "version.inc" + +/* a widget, as the screen's functions read one: its name alone */ +struct widget_instance +{ + char name[32]; +}; + +/* a public game as the server browser shows one (p2p.h's, what is read of it) */ +struct p2p_listing +{ + char name[33]; + unsigned short network_version; + unsigned char locked, open; +}; + +static int online = 1; + +static int config_boolean(char const *name) +{ + return !strcmp(name, "network.online") ? online : 0; +} + +/* p2p_lobby.c's check of a listing, stubbed: its first byte says what it is +(G: a good one, V: of another network version, P: with a password, else +none that checks) */ +static int listing_checks; + +static int p2p_listing_from_bytes(const unsigned char *bytes, int size, struct p2p_listing *game) +{ + listing_checks++; + memset(game, 0, sizeof(*game)); + if (size < HALOCUSTOMS_LISTING_MINIMUM || (bytes[0] != 'G' && bytes[0] != 'V' && bytes[0] != 'P')) + return 0; + snprintf(game->name, sizeof(game->name), "Friendly CTF"); + game->network_version = bytes[0] == 'V' ? HALO_PORT_NETWORK_VERSION + 1 : HALO_PORT_NETWORK_VERSION; + game->locked = bytes[0] == 'P'; + game->open = 1; + return 1; +} + +#include "under_test.inc" + +/* ---------- helpers */ + +static struct halocustoms_friends_list list; + +static int read_text(const char *answer) +{ + return halocustoms_friends_read(answer, (long)strlen(answer), &list); +} + +/* base64 of size bytes, each byte the one given, into text */ +static void listing_text(unsigned char byte, int size, char *text) +{ + unsigned char bytes[512]; + + memset(bytes, byte, sizeof(bytes)); + server_base64_encode(bytes, size, text); +} + +static int wide_is(wchar_t const *text, wchar_t const *expected) +{ + return !wcscmp(text, expected); +} + +/* ---------- the cases */ + +static void case_read(void) +{ + char answer[4096], listing[512]; + + listing_text('H', 168, listing); + snprintf(answer, sizeof(answer), + "{\"friends\":[{\"name\":\"Chief\",\"gamertag\":\"Chief\",\"tag\":\"P3L42\",\"status\":\"playing\"," + "\"game\":{\"name\":\"HaloCustoms CTF\",\"map\":\"bloodgulch\",\"gametype\":\"CTF\",\"players\":7," + "\"max_players\":16,\"password\":false,\"dedicated\":true,\"network_version\":41,\"server_id\":\"4c5f15237c866f6f\"," + "\"listing\":\"%s\"}}," + "{\"name\":\"Cortana\",\"tag\":\"L7\",\"status\":\"menus\",\"game\":null}," + "{\"name\":\"Keyes\",\"tag\":\"L1\",\"status\":\"offline\",\"game\":null}]," + "\"friends_total\":12,\"online\":2," + "\"requests\":[{\"id\":5,\"name\":\"Arbiter\",\"gamertag\":\"Arbiter\",\"tag\":\"L2\",\"sent\":1762800000}]," + "\"pending\":3,\"presence\":\"friends\",\"interval\":45,\"ttl\":120,\"later\":{\"a\":[1,2]}}", listing); + CHECK(read_text(answer), "the site's answer not read"); + CHECK(list.count == 3 && list.total == 12 && list.online == 2, "counts %ld %ld %ld", list.count, list.total, + list.online); + CHECK(!strcmp(list.friends[0].name, "Chief") && !strcmp(list.friends[0].tag, "P3L42") && + list.friends[0].status == _halocustoms_friend_playing, "the first friend"); + CHECK(list.friends[0].has_game && !strcmp(list.friends[0].game_name, "HaloCustoms CTF") && + !strcmp(list.friends[0].map, "bloodgulch") && !strcmp(list.friends[0].gametype, "CTF") && + list.friends[0].players == 7 && list.friends[0].maximum_players == 16 && !list.friends[0].password && + list.friends[0].dedicated && list.friends[0].network_version == 41, "the first friend's game"); + CHECK(list.friends[0].listing_size == 168 && (unsigned char)list.friends[0].listing[0] == 'H' && + (unsigned char)list.friends[0].listing[167] == 'H', "the game's listing: %ld bytes", list.friends[0].listing_size); + CHECK(list.friends[1].status == _halocustoms_friend_menus && !list.friends[1].has_game, "in the menus"); + CHECK(list.friends[2].status == _halocustoms_friend_offline, "offline"); + CHECK(list.request_count == 1 && list.requests[0].id == 5 && !strcmp(list.requests[0].name, "Arbiter") && + !strcmp(list.requests[0].tag, "L2") && list.pending == 3, "the request"); + CHECK(!list.hidden && list.interval == 45, "presence %ld, interval %ld", list.hidden, list.interval); + + CHECK(read_text("{\"friends\":[],\"requests\":[],\"presence\":\"nobody\"}") && list.count == 0 && + list.hidden && list.interval == HALOCUSTOMS_PRESENCE_INTERVAL, "an empty list, appearing offline"); +} + +static void case_read_hostile(void) +{ + static char answer[200000]; + char listing[512], *at; + long index; + + /* (not the site's) */ + CHECK(!read_text(""), "nothing read"); + CHECK(!read_text("Bad gateway"), "a proxy's page read"); + CHECK(!read_text("[{\"name\":\"Chief\"}]"), "an array read"); + CHECK(!read_text("{\"friends\":{\"name\":\"Chief\"}}"), "friends not an array read"); + CHECK(!read_text("{\"requests\":[]}"), "no friends read"); + CHECK(!read_text("{\"friends\":[{\"name\":\"Chief\""), "a cut answer read"); + CHECK(!halocustoms_friends_read(NULL, 10, &list) && list.count == 0, "none read"); + + /* (entries that are not ones passed over; names made printable ASCII and cut) */ + CHECK(read_text("{\"friends\":[1,\"x\",null,{\"tag\":\"L1\"},{\"name\":\"\"},{\"name\":7}," + "{\"name\":\"N\\u00e9lly\\u0007\",\"status\":\"PLAYING\"}," + "{\"name\":\"0123456789012345678901234567890123456789\",\"tag\":\"P10L50P10L50P10L50\",\"status\":\"playing\"," + "\"game\":{\"name\":\"no listing\"}}]}"), "entries that are not ones refused the list"); + CHECK(list.count == 2, "%ld friends kept of entries that are not ones", list.count); + CHECK(!strcmp(list.friends[0].name, "N?lly?") && list.friends[0].status == _halocustoms_friend_offline, + "a name of other characters: %s, status %ld", list.friends[0].name, list.friends[0].status); + CHECK(strlen(list.friends[1].name) == HALOCUSTOMS_FRIEND_NAME_SIZE - 1 && + strlen(list.friends[1].tag) == HALOCUSTOMS_FRIEND_TAG_SIZE - 1, "long texts not cut"); + CHECK(list.friends[1].status == _halocustoms_friend_playing && !list.friends[1].has_game, + "a game with no listing kept"); + CHECK(!read_text("{\"friends\":[{\"name\":\"Ch\\u0000ief\"}]}") || list.count == 0, "a name with a NUL kept"); + + /* (a game's listing: base64 of a listing's bounds, else no game) */ + listing_text('H', HALOCUSTOMS_LISTING_MINIMUM - 1, listing); + snprintf(answer, sizeof(answer), "{\"friends\":[{\"name\":\"A\",\"status\":\"playing\",\"game\":{\"listing\":\"%s\"}},", + listing); + listing_text('H', HALOCUSTOMS_LISTING_MAXIMUM + 1, listing); + snprintf(answer + strlen(answer), sizeof(answer) - strlen(answer), + "{\"name\":\"B\",\"status\":\"playing\",\"game\":{\"listing\":\"%s\"}}," + "{\"name\":\"C\",\"status\":\"playing\",\"game\":{\"listing\":\"!!!not base64!!!\"}}," + "{\"name\":\"D\",\"status\":\"playing\",\"game\":{\"listing\":12}},", listing); + listing_text('H', HALOCUSTOMS_LISTING_MAXIMUM, listing); + snprintf(answer + strlen(answer), sizeof(answer) - strlen(answer), + "{\"name\":\"E\",\"status\":\"menus\",\"game\":{\"listing\":\"%s\"}}," + "{\"name\":\"F\",\"status\":\"playing\",\"game\":{\"listing\":\"%s\",\"players\":-5,\"max_players\":100000," + "\"network_version\":99999999,\"map\":\"%s\"}}]}", listing, listing, + "a map name far longer than any map name a listing can carry"); + CHECK(read_text(answer) && list.count == 6, "the games' friends: %ld", list.count); + for (index = 0; index < 4; index++) + CHECK(!list.friends[index].has_game && !list.friends[index].listing_size, "a listing out of bounds kept (%ld)", + index); + CHECK(!list.friends[4].has_game, "a game kept for a friend in the menus"); + CHECK(list.friends[5].has_game && list.friends[5].listing_size == HALOCUSTOMS_LISTING_MAXIMUM && + list.friends[5].players == 0 && list.friends[5].maximum_players == 255 && + list.friends[5].network_version == 65535 && strlen(list.friends[5].map) == HALOCUSTOMS_GAME_MAP_SIZE - 1, + "a listing at its longest, and counts within bounds"); + + /* (counts: never below what was kept, nor negative; the interval bounded) */ + CHECK(read_text("{\"friends\":[{\"name\":\"A\"},{\"name\":\"B\"}],\"friends_total\":-4,\"online\":99," + "\"pending\":-1,\"requests\":[{\"id\":0,\"name\":\"zero\"},{\"id\":-3,\"name\":\"less\"},{\"id\":\"7\",\"name\":\"text\"}," + "{\"id\":9,\"name\":\"\"},{\"id\":10},{\"id\":11,\"name\":\"Fine\"}],\"interval\":5}"), "the counts' list"); + CHECK(list.total == 2 && list.online == 2 && list.pending == 1, "counts %ld %ld %ld", list.total, list.online, + list.pending); + CHECK(list.request_count == 1 && list.requests[0].id == 11, "requests that are not ones kept: %ld", + list.request_count); + CHECK(list.interval == HALOCUSTOMS_PRESENCE_INTERVAL, "an interval of 5 kept as %ld", list.interval); + CHECK(read_text("{\"friends\":[],\"interval\":1e9}") && list.interval == HALOCUSTOMS_PRESENCE_LONGEST_INTERVAL, + "an interval of 1e9 kept as %ld", list.interval); + + /* (a list longer than the site sends: its first fifty, its first twenty requests) */ + at = answer + sprintf(answer, "{\"friends\":["); + for (index = 0; index < 80; index++) + at += sprintf(at, "%s{\"name\":\"Friend%ld\",\"status\":\"menus\"}", index ? "," : "", index); + at += sprintf(at, "],\"requests\":["); + for (index = 0; index < 30; index++) + at += sprintf(at, "%s{\"id\":%ld,\"name\":\"Asker%ld\"}", index ? "," : "", index + 1, index); + sprintf(at, "],\"friends_total\":80,\"pending\":30}"); + CHECK(read_text(answer), "a long list"); + CHECK(list.count == HALOCUSTOMS_FRIENDS_MAXIMUM && !strcmp(list.friends[49].name, "Friend49") && + list.request_count == HALOCUSTOMS_REQUESTS_MAXIMUM && list.total == 80 && list.pending == 30, + "a long list kept as %ld friends and %ld requests", list.count, list.request_count); + + /* (a list of more tokens than an answer may hold is no answer) */ + at = answer + sprintf(answer, "{\"friends\":["); + for (index = 0; index < 3000; index++) + at += sprintf(at, "%s{\"name\":\"F\",\"status\":\"menus\"}", index ? "," : ""); + sprintf(at, "]}"); + CHECK(!read_text(answer), "an answer of too many tokens read"); +} + +static long shown, online_count, pending, interval; + +/* a heartbeat's answer, of its text's length */ +static int beat_read(const char *answer) +{ + return halocustoms_presence_answer_read(answer, answer ? (long)strlen(answer) : 0, &shown, &online_count, &pending, + &interval); +} + +static void case_unverified(void) +{ + const char *texts[] = + { + "{\"message\":\"Verify your email first.\",\"code\":\"email_unverified\"}", + "{\"code\":\"email_unverified\"}", + }; + const char *others[] = + { + "{\"message\":\"This account has been suspended.\"}", + "{\"message\":\"email_unverified\"}", + "{\"code\":\"email_unverified_later\"}", + "{\"code\":7}", + "[\"email_unverified\"]", + "email_unverified", + "", + }; + long index; + + for (index = 0; index < NUMBEROF(texts); index++) + CHECK(halocustoms_answer_unverified(texts[index], (long)strlen(texts[index])), "not read as unverified: %s", + texts[index]); + for (index = 0; index < NUMBEROF(others); index++) + CHECK(!halocustoms_answer_unverified(others[index], (long)strlen(others[index])), "read as unverified: %s", + others[index]); + CHECK(!halocustoms_answer_unverified(NULL, 5), "nothing read as unverified"); +} + +static void case_beat_answer(void) +{ + CHECK(beat_read("{\"shown\":true,\"online\":3,\"pending\":1,\"interval\":30,\"ttl\":120}") && shown && + online_count == 3 && pending == 1 && interval == 30, "the site's answer"); + CHECK(beat_read("{\"shown\":false,\"online\":-2,\"pending\":1e12,\"interval\":3}") && !shown && online_count == 0 && + pending == 100000 && interval == HALOCUSTOMS_PRESENCE_INTERVAL, "counts out of bounds: %ld %ld %ld", + online_count, pending, interval); + CHECK(!beat_read("{\"message\":\"Too many requests\"}") && !shown && !online_count, "an error read as an answer"); + CHECK(!beat_read("[]"), "an array read"); + CHECK(!beat_read("garbage"), "garbage read"); + CHECK(!beat_read(NULL), "nothing read"); +} + +static void case_body(void) +{ + char body[HALOCUSTOMS_PRESENCE_BODY_SIZE], expected[1024], text[512]; + unsigned char listing[HALOCUSTOMS_LISTING_MAXIMUM]; + long length; + + memset(listing, 'H', sizeof(listing)); + CHECK(halocustoms_presence_body(body, sizeof(body), _halocustoms_presence_menus, NULL, 0, "", "", -1, -1) && + !strcmp(body, "{\"state\":\"menus\"}"), "the menus: %s", body); + CHECK(halocustoms_presence_body(body, sizeof(body), _halocustoms_presence_playing, NULL, 0, "bloodgulch", "CTF", + 5, 16) && !strcmp(body, "{\"state\":\"playing\"}"), "a private game told: %s", body); + /* (a public game's listing in base64, with the game's own word on what it plays now) */ + length = halocustoms_presence_body(body, sizeof(body), _halocustoms_presence_playing, listing, 168, + "blood\"gulch\n", "C\\T\x01" "F", 7, 16); + server_base64_encode(listing, 168, text); + snprintf(expected, sizeof(expected), "{\"state\":\"playing\",\"game\":{\"listing\":\"%s\",\"map\":\"blood\\\"gulch\"," + "\"gametype\":\"C\\\\TF\",\"players\":7,\"max_players\":16}}", text); + CHECK(length == (long)strlen(expected) && !strcmp(body, expected), "a public game: %s", body); + /* (counts out of bounds left out; a menus' listing not sent) */ + CHECK(halocustoms_presence_body(body, sizeof(body), _halocustoms_presence_playing, listing, 140, "", "", 256, -1) && + !strstr(body, "players") && !strstr(body, "map"), "counts out of bounds sent: %s", body); + CHECK(halocustoms_presence_body(body, sizeof(body), _halocustoms_presence_menus, listing, 168, "a", "b", 1, 2) && + !strcmp(body, "{\"state\":\"menus\"}"), "a game told in the menus: %s", body); + /* (a listing out of its bounds is none) */ + CHECK(halocustoms_presence_body(body, sizeof(body), _halocustoms_presence_playing, listing, 139, "a", "b", 1, 2) && + !strcmp(body, "{\"state\":\"playing\"}"), "a short listing sent: %s", body); + CHECK(halocustoms_presence_body(body, sizeof(body), _halocustoms_presence_playing, listing, + HALOCUSTOMS_LISTING_MAXIMUM + 1, "a", "b", 1, 2) && !strcmp(body, "{\"state\":\"playing\"}"), + "a long listing sent: %s", body); + /* (no state that is not one, and nothing that does not fit) */ + CHECK(!halocustoms_presence_body(body, sizeof(body), 7, NULL, 0, "", "", -1, -1), "a state that is not one"); + CHECK(!halocustoms_presence_body(body, 10, _halocustoms_presence_menus, NULL, 0, "", "", -1, -1), + "a body longer than its room"); + CHECK(halocustoms_presence_body(body, sizeof(body), _halocustoms_presence_playing, listing, + HALOCUSTOMS_LISTING_MAXIMUM, "0123456789012345678901234567890123456789", "012345678901234567890123456789", 16, + 16) < HALOCUSTOMS_PRESENCE_BODY_SIZE && strstr(body, "\"map\":\"01234567890123456789012345678901\"") && + strstr(body, "\"gametype\":\"012345678901234567890123\""), "names not cut to the site's: %s", body); +} + +static void case_due(void) +{ + CHECK(halocustoms_presence_due(1000, 0, 30, 0), "the first not due"); + CHECK(!halocustoms_presence_due(1000, 1000, 30, 0) && !halocustoms_presence_due(1029, 1000, 30, 0), + "one due within the interval"); + CHECK(halocustoms_presence_due(1030, 1000, 30, 0), "one not due at the interval"); + CHECK(!halocustoms_presence_due(1014, 1000, 30, 1), "a change sent within 15 seconds"); + CHECK(halocustoms_presence_due(1015, 1000, 30, 1), "a change not sent after 15 seconds"); + CHECK(!halocustoms_presence_due(1029, 1000, 5, 0) && halocustoms_presence_due(1030, 1000, 5, 0), + "an interval below 30 seconds taken"); + CHECK(!halocustoms_presence_due(1059, 1000, 60, 0) && halocustoms_presence_due(1060, 1000, 60, 0), + "the site's interval not taken"); + /* (a clock set back: as long as the longest interval) */ + CHECK(halocustoms_presence_due(900, 1000, 30, 0), "a clock set back keeps the heartbeat back"); +} + +static void case_status(void) +{ + struct halocustoms_friend member; + char text[64]; + + memset(&member, 0, sizeof(member)); + halocustoms_friend_status_text(&member, text, sizeof(text)); + CHECK(!strcmp(text, "OFFLINE"), "offline: %s", text); + member.status = _halocustoms_friend_menus; + halocustoms_friend_status_text(&member, text, sizeof(text)); + CHECK(!strcmp(text, "IN THE MENUS"), "in the menus: %s", text); + member.status = _halocustoms_friend_playing; + halocustoms_friend_status_text(&member, text, sizeof(text)); + CHECK(!strcmp(text, "PLAYING"), "a private game: %s", text); + member.has_game = 1; + snprintf(member.game_name, sizeof(member.game_name), "Friendly ctf"); + snprintf(member.map, sizeof(member.map), "bloodgulch"); + snprintf(member.gametype, sizeof(member.gametype), "ctf"); + halocustoms_friend_status_text(&member, text, sizeof(text)); + CHECK(!strcmp(text, "FRIENDLY CTF - BLOODGULCH (CTF)"), "a public game: %s", text); + snprintf(member.game_name, sizeof(member.game_name), "A server named at length, 32 ch"); + halocustoms_friend_status_text(&member, text, sizeof(text)); + CHECK(!strcmp(text, "A SERVER NAMED AT LENGTH, 32 CH - BLOODGULCH"), "a long name with its gametype: %s", text); + member.game_name[0] = member.map[0] = 0; + halocustoms_friend_status_text(&member, text, sizeof(text)); + CHECK(!strcmp(text, "UNNAMED GAME - ? (CTF)"), "a game of no names: %s", text); + halocustoms_friend_status_text(&member, text, 8); + CHECK(strlen(text) == 7, "a text past its room: %s", text); +} + +static void case_doing(void) +{ + long state, players, maximum_players; + unsigned long changes, before; + char map[HALOCUSTOMS_GAME_MAP_SIZE], gametype[HALOCUSTOMS_GAME_GAMETYPE_SIZE]; + + halocustoms_presence_now(&state, map, sizeof(map), gametype, sizeof(gametype), &players, &maximum_players, &before); + CHECK(state == _halocustoms_presence_menus && !map[0], "the game starts playing"); + halocustoms_presence_game(1, "bloodgulch\x01", "CTF"); + halocustoms_presence_players(5, 16); + halocustoms_presence_now(&state, map, sizeof(map), gametype, sizeof(gametype), &players, &maximum_players, + &changes); + CHECK(state == _halocustoms_presence_playing && !strcmp(map, "bloodgulch") && !strcmp(gametype, "CTF") && + players == 5 && maximum_players == 16 && changes == before + 1, "a game started: %s %s %lu", map, gametype, + changes); + halocustoms_presence_game(1, "bloodgulch", "CTF"); + halocustoms_presence_now(&state, map, sizeof(map), gametype, sizeof(gametype), &players, &maximum_players, + &changes); + CHECK(changes == before + 1 && players == -1, "the same game again counted a change: %lu", changes); + halocustoms_presence_game(0, "ui", "anything"); + halocustoms_presence_now(&state, map, sizeof(map), gametype, sizeof(gametype), &players, &maximum_players, + &changes); + CHECK(state == _halocustoms_presence_menus && !map[0] && !gametype[0] && changes == before + 2, + "the menus keep a game's names: %s %s", map, gametype); +} + +static void case_screen(void) +{ + struct halocustoms_friends_list screen_list; + struct widget_instance row; + wchar_t name[64], status[64]; + + /* (the rows' names: friend_row_1 ... are rows, their texts are not) */ + snprintf(row.name, sizeof(row.name), "friend_row_3"); + CHECK(friends_row_index(&row) == 2, "friend_row_3's index"); + snprintf(row.name, sizeof(row.name), "friend_row_10"); + CHECK(friends_row_index(&row) == 9, "friend_row_10's index"); + snprintf(row.name, sizeof(row.name), "friend_row_3_name"); + CHECK(friends_row_index(&row) == NONE, "a row's text taken for a row"); + snprintf(row.name, sizeof(row.name), "button_action"); + CHECK(friends_row_index(&row) == NONE && friends_row_index(NULL) == NONE, "a button taken for a row"); + + memset(&screen_list, 0, sizeof(screen_list)); + screen_list.count = 2; + snprintf(screen_list.friends[0].name, sizeof(screen_list.friends[0].name), "Chief"); + snprintf(screen_list.friends[0].tag, sizeof(screen_list.friends[0].tag), "P3L42"); + screen_list.friends[0].status = _halocustoms_friend_menus; + snprintf(screen_list.friends[1].name, sizeof(screen_list.friends[1].name), "Keyes"); + screen_list.request_count = 1; + screen_list.requests[0].id = 9; + snprintf(screen_list.requests[0].name, sizeof(screen_list.requests[0].name), "Arbiter"); + friends_row_texts(&screen_list, FALSE, 0, name, status, 64); + CHECK(wide_is(name, L"P3L42 Chief") && wide_is(status, L"IN THE MENUS"), "a friend's row"); + friends_row_texts(&screen_list, FALSE, 1, name, status, 64); + CHECK(wide_is(name, L"Keyes") && wide_is(status, L"OFFLINE"), "an untagged friend's row"); + friends_row_texts(&screen_list, FALSE, 2, name, status, 64); + CHECK(!name[0] && !status[0], "a row past the friends"); + friends_row_texts(&screen_list, TRUE, 0, name, status, 64); + CHECK(wide_is(name, L"Arbiter") && wide_is(status, L"WANTS TO BE YOUR FRIEND"), "a request's row"); + friends_row_texts(&screen_list, TRUE, 1, name, status, 64); + CHECK(!name[0] && !status[0], "a row past the requests"); + friends_row_texts(&screen_list, FALSE, -1, name, status, 64); + CHECK(!name[0], "a row before the friends"); + screen_list.count = 9999; + CHECK(friends_entries(&screen_list, FALSE) == HALOCUSTOMS_FRIENDS_MAXIMUM, "entries past the list's room"); + screen_list.count = 2; + + /* (the help: a refusal first, then a request's answer, the list's state, the view's) */ + CHECK(friends_help(_halocustoms_friends_listed, &screen_list, FALSE, TRUE, _friends_help_password) == + _friends_help_password, "a refusal not said"); + /* (an email not verified: an answer refused, or no list given) */ + screen_list.said = _halocustoms_friends_said_unverified; + CHECK(friends_help(_halocustoms_friends_listed, &screen_list, TRUE, TRUE, NONE) == _friends_help_unverified, + "an answer refused for the email not said"); + screen_list.said = _halocustoms_friends_said_nothing; + CHECK(friends_help(_halocustoms_friends_unverified, &screen_list, FALSE, TRUE, NONE) == + _friends_help_unverified_list, "a list refused for the email not said"); + screen_list.said = _halocustoms_friends_said_full; + CHECK(friends_help(_halocustoms_friends_listed, &screen_list, TRUE, TRUE, NONE) == _friends_help_full, + "a full list's answer not said"); + screen_list.said = _halocustoms_friends_said_nothing; + CHECK(friends_help(_halocustoms_friends_signed_out, &screen_list, FALSE, TRUE, NONE) == _friends_help_signed_out, + "signed out"); + CHECK(friends_help(_halocustoms_friends_none, &screen_list, FALSE, TRUE, NONE) == _friends_help_signed_out, + "nothing asked"); + CHECK(friends_help(_halocustoms_friends_loading, &screen_list, FALSE, TRUE, NONE) == _friends_help_loading, + "loading"); + CHECK(friends_help(_halocustoms_friends_offline, &screen_list, FALSE, TRUE, NONE) == _friends_help_offline, + "offline"); + CHECK(friends_help(_halocustoms_friends_listed, &screen_list, FALSE, TRUE, NONE) == _friends_help_friends, + "the friends"); + CHECK(friends_help(_halocustoms_friends_listed, &screen_list, FALSE, FALSE, NONE) == _friends_help_hidden, + "the setting off"); + screen_list.hidden = 1; + CHECK(friends_help(_halocustoms_friends_listed, &screen_list, FALSE, TRUE, NONE) == _friends_help_hidden, + "appearing offline at the site"); + CHECK(friends_help(_halocustoms_friends_listed, &screen_list, TRUE, TRUE, NONE) == _friends_help_requests, + "the requests"); + screen_list.request_count = 0; + CHECK(friends_help(_halocustoms_friends_listed, &screen_list, TRUE, TRUE, NONE) == _friends_help_no_requests, + "no requests"); + screen_list.hidden = 0; + screen_list.count = 0; + CHECK(friends_help(_halocustoms_friends_listed, &screen_list, FALSE, TRUE, NONE) == _friends_help_none_yet, + "no friends"); +} + +static void case_join(void) +{ + struct halocustoms_friend member; + struct p2p_listing game; + + memset(&member, 0, sizeof(member)); + member.status = _halocustoms_friend_playing; + CHECK(friends_join_refusal(&member, &game) == _friends_help_private && !listing_checks, + "a private game joined (its listing checked)"); + member.has_game = 1; + member.listing_size = HALOCUSTOMS_LISTING_MINIMUM; + memset(member.listing, 'X', sizeof(member.listing)); + CHECK(friends_join_refusal(&member, &game) == _friends_help_private, "a listing that does not check joined"); + member.listing[0] = 'V'; + CHECK(friends_join_refusal(&member, &game) == _friends_help_version, "another version's game joined"); + member.listing[0] = 'P'; + CHECK(friends_join_refusal(&member, &game) == _friends_help_password, "a password's game joined here"); + member.listing[0] = 'G'; + online = 0; + CHECK(friends_join_refusal(&member, &game) == _friends_help_online_off, "joined with internet play off"); + online = 1; + CHECK(friends_join_refusal(&member, &game) == NONE && !strcmp(game.name, "Friendly CTF"), "a public game refused"); + member.status = _halocustoms_friend_menus; + CHECK(friends_join_refusal(&member, &game) == _friends_help_private, "a friend in the menus joined"); +} + +int main(int argc, char **argv) +{ + const char *name = argc > 1 ? argv[1] : ""; + + if (!strcmp(name, "read")) + case_read(); + else if (!strcmp(name, "read-hostile")) + case_read_hostile(); + else if (!strcmp(name, "beat-answer")) + case_beat_answer(); + else if (!strcmp(name, "unverified")) + case_unverified(); + else if (!strcmp(name, "body")) + case_body(); + else if (!strcmp(name, "due")) + case_due(); + else if (!strcmp(name, "status")) + case_status(); + else if (!strcmp(name, "doing")) + case_doing(); + else if (!strcmp(name, "screen")) + case_screen(); + else if (!strcmp(name, "join")) + case_join(); + else + { + fprintf(stderr, "no case %s\n", name); + return 2; + } + return 0; +} diff --git a/tools/harness/tests/halocustoms_kills.c b/tools/harness/tests/halocustoms_kills.c new file mode 100644 index 0000000..6b16801 --- /dev/null +++ b/tools/harness/tests/halocustoms_kills.c @@ -0,0 +1,1732 @@ +/* +HALOCUSTOMS_KILLS.C (test) + +What halocustoms_stats.c records of each local player's game: in +multiplayer, their kills by a headshot and by a melee blow (what +network_damage.c makes of a killing blow's event, distributed_count_kill, +which the host has as it makes it and a client as it comes, twice), their +longest run of kills, and the game's scoreboard; in a campaign or co-op +level session, their kills by the dead unit's class (as the blow is dealt, +alone and on a co-op host; by the host's word of it on a co-op client), the +allies they killed, their deaths, and whether the level was won; each line +written into the player's own profile's account (its slot), and the rank +put before each local player's name. From test_halocustoms_kills.py's +config.inc (the sources' enums and structures) and under_test.inc (their +functions), over the real JSON reader (server_json.c), which reads each line +back. + +The world: players (the first ones a machine's local players); a game of a +kind (multiplayer, campaign, co-op host or client); damage effects (a +pistol's shot, which kills at the head; a plasma bolt; a melee blow; a +rifle's shot, which a multiplayer game doubles at the head; a melee blow +that would kill at the head; a grenade's explosion); bipeds of a collision +model with a body and a head; the campaign's units, of tag paths and teams +(the player's and the marines' friends, the others enemies), and a vehicle; +and player profiles, each in a folder whose account has a slot. +*/ + +#include "harness.h" +#include "../../../port/linux/src/server_json.c" + +#include + +#include "halocustoms_account.h" +#include "halocustoms_friends.h" + +typedef unsigned short word; +#define MAX(a, b) ((a) > (b) ? (a) : (b)) +#define MIN(a, b) ((a) < (b) ? (a) : (b)) +#define PIN(value, low, high) MIN(MAX((value), (low)), (high)) +#define NUMBEROF(array) ((long)(sizeof(array) / sizeof((array)[0]))) +#define TICKS_PER_SECOND 30 +#define csmemcpy memcpy +#define csmemset memset + +enum +{ + MAXIMUM_LOCAL_PLAYERS = 4, + MAXIMUM_PLAYER_PROFILE_NAME_LENGTH = 12, + MAXIMUM_FILENAME_LENGTH = 255, +}; + +#include "config.inc" + +/* ---------- the game, as halocustoms_stats.c sees it */ + +struct game_statistics +{ + short kills_in_a_row; + short kills[4]; + short assists[4]; + short deaths; +}; + +struct player_datum +{ + wchar_t name[12]; + long team_index; + long unit_index; + struct game_statistics statistics; +}; + +struct game_variant +{ + wchar_t human_readable_game_description[12]; + struct + { + boolean teams; + } universal_variant; +}; + +enum +{ + MAXIMUM_TEST_PLAYERS = 20, +}; + +/* a player's datum index: an identifier over its absolute index */ +/* (the players' identifiers: another salt, other players at the same indices, +as when one leaves and another joins into their index) */ +static unsigned long player_salt; +#define PLAYER(index) ((long)(((0x7000UL + player_salt + (unsigned long)(index)) << 16) | (unsigned long)(index))) + +static struct player_datum players[MAXIMUM_TEST_PLAYERS]; +static long player_count; +static long player_scores[MAXIMUM_TEST_PLAYERS]; +/* the machine's local players: the first ones */ +static short local_count; +static struct game_variant variant = { L"Slayer" }; + +/* the multiplayer game's engine (NULL: none, a level session) */ +struct game_engine +{ + long (*get_player_score)(long player_index, int score_type); +}; + +static long player_score_get(long player_index, int score_type); +static struct game_engine engine = { player_score_get }; +static struct game_engine *game_engine = &engine; + +static struct fake_scenario +{ + short type; +} scenario = { 0 }; +static struct fake_scenario *global_scenario = &scenario; + +static short connection; +static short difficulty; +static long game_time; +static char const *map_name = "levels\\test\\bloodgulch\\bloodgulch"; + +static short game_connection(void) +{ + return connection; +} + +static short game_difficulty_level_get(void) +{ + return difficulty; +} + +static char const *main_get_map_name(void) +{ + return map_name; +} + +static char const *main_get_multiplayer_map_name(void) +{ + return map_name; +} + +static long game_time_get(void) +{ + return game_time; +} + +static struct player_datum *player_get(long player_index) +{ + long index = DATUM_INDEX_TO_ABSOLUTE_INDEX(player_index); + + CHECK(player_index != NONE && index < player_count && player_index == PLAYER(index), "a player of the game: %lx", + (unsigned long)player_index); + return &players[index]; +} + +static long player_score_get(long player_index, int score_type) +{ + (void)score_type; + return player_scores[DATUM_INDEX_TO_ABSOLUTE_INDEX(player_index)]; +} + +static short local_player_get_next(short local_player_index) +{ + short next = (short)(local_player_index == NONE ? 0 : local_player_index + 1); + + return next < local_count ? next : NONE; +} + +static long local_player_get_player_index(short local_player_index) +{ + return local_player_index >= 0 && local_player_index < local_count ? PLAYER(local_player_index) : NONE; +} + +static long players_in_game(void) +{ + return player_count; +} + +struct data_iterator +{ + long datum_index; + long next; +}; + +static void *player_data; + +static void data_iterator_new(struct data_iterator *iterator, void *data) +{ + (void)data; + iterator->datum_index = NONE; + iterator->next = 0; +} + +static void *data_iterator_next(struct data_iterator *iterator) +{ + if (iterator->next >= player_count) + return NULL; + iterator->datum_index = PLAYER(iterator->next); + return &players[iterator->next++]; +} + +static struct game_variant *game_engine_get_variant(void) +{ + return &variant; +} + +static boolean local_player_won(long player_index) +{ + return player_index == PLAYER(0); +} + +/* the game's teams (game_allegiance.c's): the player's and the humans' are +friends, the others their enemies and each other's */ +enum +{ + TEAM_PLAYER = 1, + TEAM_HUMAN, + TEAM_COVENANT, + TEAM_FLOOD, + TEAM_SENTINEL, +}; + +static boolean game_team_is_enemy(short team, short other) +{ + boolean friends = team == other || ((team == TEAM_PLAYER || team == TEAM_HUMAN) && + (other == TEAM_PLAYER || other == TEAM_HUMAN)); + + return !friends; +} + +/* the dedicated server's run */ +static int server_mode; + +static int halo_server_mode(void) +{ + return server_mode; +} + +/* atexit: the session's end at the process's (counted) */ +static int exit_hooks; +#define atexit(hook) ((void)(hook), exit_hooks++, 0) + +/* ---------- the profiles, and their accounts as halocustoms_account.c has them */ + +struct player_profile +{ + wchar_t player_name[MAXIMUM_PLAYER_PROFILE_NAME_LENGTH]; +}; + +enum +{ + PROFILE_COUNT = 3, +}; + +static char const *const profile_folders[PROFILE_COUNT] = { "u:\\UDATA\\AAA\\", "u:\\UDATA\\BBB\\", "u:\\UDATA\\CCC\\" }; +static wchar_t const *const profile_names[PROFILE_COUNT] = { L"Chief", L"Ar\x00e9na", L"Third" }; +/* each local player's active profile (by index, NONE: none), and player 1's +last used */ +static long active_profiles[MAXIMUM_LOCAL_PLAYERS]; +static long last_used_profile; + +static long player_ui_get_active_player_profile_index(short local_player_index) +{ + return local_player_index >= 0 && local_player_index < MAXIMUM_LOCAL_PLAYERS ? active_profiles[local_player_index] : + NONE; +} + +static long player_ui_get_player1_last_used_profile_index(void) +{ + return last_used_profile; +} + +static boolean player_profile_get(long profile_index, struct player_profile *profile) +{ + if (profile_index < 0 || profile_index >= PROFILE_COUNT) + return FALSE; + wcsncpy(profile->player_name, profile_names[profile_index], MAXIMUM_PLAYER_PROFILE_NAME_LENGTH - 1); + profile->player_name[MAXIMUM_PLAYER_PROFILE_NAME_LENGTH - 1] = 0; + return TRUE; +} + +static boolean player_profile_get_enclosing_directory_path(long profile_index, char *path) +{ + path[0] = 0; + if (profile_index < 0 || profile_index >= PROFILE_COUNT) + return FALSE; + snprintf(path, MAXIMUM_FILENAME_LENGTH + 1, "%s", profile_folders[profile_index]); + return TRUE; +} + +static void player_profiles_enumerate_available_to_local_player_index(short local_player_index, word *count, + long *profile_indices, boolean include_default_profiles) +{ + long index; + + (void)local_player_index; + (void)include_default_profiles; + for (index = 0; index < PROFILE_COUNT && index < *count; index++) + profile_indices[index] = index; + *count = (word)index; +} + +/* the account's side: a slot for each folder (its index, + 10), the names +and flags each was named with, the ranks the slots have, and the lines given */ +static struct +{ + int namings; + char folder[64]; + char name[32]; + int flags; +} named[32]; +static int naming_count; +static char slot_ranks[PROFILE_COUNT][16] = { "P3L42", "L7", "" }; + +int halocustoms_profile_slot(const char *folder, const char *name, int flags) +{ + int index; + + for (index = 0; index < PROFILE_COUNT; index++) + { + if (!strcmp(folder, profile_folders[index])) + { + if (naming_count < NUMBEROF(named)) + { + snprintf(named[naming_count].folder, sizeof(named[naming_count].folder), "%s", folder); + snprintf(named[naming_count].name, sizeof(named[naming_count].name), "%s", name); + named[naming_count].flags = flags; + naming_count++; + } + return 10 + index; + } + } + return -1; +} + +int halocustoms_rank_tag(int slot, int local_player_index, char *tag, long size) +{ + (void)local_player_index; + tag[0] = 0; + if (slot >= 10 && slot < 10 + PROFILE_COUNT) + snprintf(tag, (size_t)size, "%s", slot_ranks[slot - 10]); + return tag[0] != 0; +} + +enum +{ + MAXIMUM_RECORDS = 8, +}; + +static struct +{ + int slot; + int local_player; + char line[HALOCUSTOMS_MAXIMUM_MATCH_LINE + 1]; +} records[MAXIMUM_RECORDS]; +static int record_count; + +/* what the game tells the HaloCustoms presence it is doing +(halocustoms_friends.c's): the last told, and how many times; the players */ +static struct +{ + long playing; + char map[64], gametype[64]; + int told; + long players, maximum_players; +} presence; + +void halocustoms_presence_game(long playing, const char *map, const char *gametype) +{ + presence.playing = playing; + snprintf(presence.map, sizeof(presence.map), "%s", map ? map : "(null)"); + snprintf(presence.gametype, sizeof(presence.gametype), "%s", gametype ? gametype : "(null)"); + presence.told++; +} + +void halocustoms_presence_players(long players, long maximum_players) +{ + presence.players = players; + presence.maximum_players = maximum_players; +} + +void halocustoms_match_record(int slot, int local_player_index, const char *line) +{ + CHECK(record_count < MAXIMUM_RECORDS, "no more lines than the test has room for"); + CHECK(strlen(line) <= HALOCUSTOMS_MAXIMUM_MATCH_LINE, "a line of %d bytes at most: %lu", + (int)HALOCUSTOMS_MAXIMUM_MATCH_LINE, (unsigned long)strlen(line)); + records[record_count].slot = slot; + records[record_count].local_player = local_player_index; + snprintf(records[record_count].line, sizeof(records[record_count].line), "%s", line); + record_count++; +} + +/* ---------- the map, as network_damage.c and halocustoms_stats.c see it */ + +#define DAMAGE_EFFECT_DEFINITION_TAG 0x6A707421L + +struct damage_definition +{ + short category; + unsigned long flags; +}; + +struct damage_effect_definition +{ + struct damage_definition damage; +}; + +enum +{ + EFFECT_PISTOL, + EFFECT_PLASMA, + EFFECT_MELEE, + EFFECT_RIFLE, + EFFECT_MELEE_AT_HEADS, + EFFECT_GRENADE, + EFFECT_COUNT, + /* damage.c's category of a grenade's explosion, which network_damage.c + does not name */ + CATEGORY_GRENADE = 3, +}; + +static struct damage_effect_definition effects[EFFECT_COUNT] = +{ + { { _damage_category_bullet, FLAG(_damage_can_cause_headshots_bit) } }, + { { _damage_category_plasma, 0 } }, + { { _damage_category_melee, 0 } }, + { { _damage_category_bullet, FLAG(_damage_can_cause_multiplayer_headshots_bit) } }, + { { _damage_category_melee, FLAG(_damage_can_cause_headshots_bit) } }, + { { CATEGORY_GRENADE, 0 } }, +}; + +static boolean tag_index_is_group(long tag_index, long group_tag) +{ + return group_tag == DAMAGE_EFFECT_DEFINITION_TAG && tag_index >= 0 && tag_index < EFFECT_COUNT; +} + +static struct damage_effect_definition *damage_effect_definition_get(long definition_index) +{ + CHECK(definition_index >= 0 && definition_index < EFFECT_COUNT, "a damage effect of the map's: %ld", + definition_index); + return &effects[definition_index]; +} + +struct tag_block +{ + long count; + void *address; +}; + +/* (an element the block has: one past it is a fault of the code's) */ +static void *tag_block_element(struct tag_block const *block, long index, size_t size) +{ + CHECK(index >= 0 && index < block->count, "an element of the block's %ld: %ld", block->count, index); + return (char *)block->address + (size_t)index * size; +} + +#define TAG_BLOCK_GET_ELEMENT(block, index, type) ((type *)tag_block_element((block), (index), sizeof(type))) + +struct damage_resistance_material +{ + unsigned long flags; +}; + +struct collision_model +{ + struct + { + struct tag_block materials; + short indirect_damage_material_index; + } resistance; +}; + +enum +{ + MATERIAL_BODY, + MATERIAL_HEAD, + MATERIAL_COUNT, +}; + +static struct damage_resistance_material materials[MATERIAL_COUNT] = { { 0 }, { FLAG(_damage_material_head_bit) } }; + +enum +{ + MODEL_COUNT = 4, +}; + +/* the collision models: a player's biped's (what strikes no material of it +takes its body's); one whose indirect material is its head; one with none; +and one whose indirect material is none it has (a map's) */ +static struct collision_model models[MODEL_COUNT] = +{ + { { { MATERIAL_COUNT, materials }, MATERIAL_BODY } }, + { { { MATERIAL_COUNT, materials }, MATERIAL_HEAD } }, + { { { MATERIAL_COUNT, materials }, NONE } }, + { { { MATERIAL_COUNT, materials }, 9 } }, +}; + +static struct collision_model *collision_model_definition_get(long collision_model_index) +{ + CHECK(collision_model_index >= 0 && collision_model_index < MODEL_COUNT, "a collision model of the map's: %ld", + collision_model_index); + return &models[collision_model_index]; +} + +struct object_definition +{ + struct + { + struct + { + long index; + } collision_model; + } object; +}; + +/* an object and a unit as the game's datums have them (a unit an object +first) */ +struct fake_object +{ + short owner_team_index; + unsigned long damage_flags; +}; + +struct object_datum +{ + long definition_index; + struct fake_object object; +}; + +struct unit_datum +{ + long definition_index; + struct fake_object object; + struct + { + long player_index; + } unit; +}; + +enum +{ + _object_mask_biped = 1, + _object_mask_vehicle = 2, + /* the multiplayer objects: bipeds of the first collision model, four + each of the others, one with no collision model, and a vehicle */ + UNIT_COUNT = 48, + UNITS_OF_A_MODEL = 4, + UNIT_INDIRECT_HEAD = UNIT_COUNT, + UNIT_NO_INDIRECT = UNIT_INDIRECT_HEAD + UNITS_OF_A_MODEL, + UNIT_BAD_INDIRECT = UNIT_NO_INDIRECT + UNITS_OF_A_MODEL, + UNIT_NO_MODEL = UNIT_BAD_INDIRECT + UNITS_OF_A_MODEL, + VEHICLE, + /* the campaign's: units of each tag path, and a vehicle */ + CAMPAIGN_UNIT = 100, + CAMPAIGN_UNIT_COUNT = 100, + CAMPAIGN_VEHICLE = CAMPAIGN_UNIT + CAMPAIGN_UNIT_COUNT, + OBJECT_COUNT, + /* the objects' definitions: a biped of each collision model, one of + none, and the vehicle (of the first); and the campaign's, from + CAMPAIGN_DEFINITION on, by their tag paths (campaign_paths) */ + DEFINITION_NO_MODEL = MODEL_COUNT, + DEFINITION_VEHICLE, + DEFINITION_COUNT, + CAMPAIGN_DEFINITION = 50, +}; + +static struct object_definition definitions[DEFINITION_COUNT] = { { { { 0 } } }, { { { 1 } } }, { { { 2 } } }, + { { { 3 } } }, { { { NONE } } }, { { { 0 } } } }; +static struct unit_datum objects[OBJECT_COUNT]; +/* whether this machine has its copy of each object (a co-op client may not) */ +static boolean objects_here[OBJECT_COUNT]; + +/* the campaign's units' tag paths, CAMPAIGN_DEFINITION's first */ +enum +{ + GRUNT, + JACKAL, + ELITE, + HUNTER, + FLOOD_INFECTION, + FLOOD_COMBAT, + FLOOD_CARRIER, + SENTINEL, + MARINE, + CYBORG, + CAMPAIGN_PATH_COUNT, +}; + +static char const *const campaign_paths[CAMPAIGN_PATH_COUNT] = +{ + "characters\\grunt\\grunt", + "characters\\jackal\\jackal", + "characters\\elite\\elite specops", + "characters\\hunter\\hunter", + "characters\\floodcombat elite\\floodinfection", + "characters\\floodcombat elite\\floodcombat elite", + "characters\\FloodCarrier\\floodcarrier", + "characters\\sentinel\\sentinel", + "characters\\marine\\marine", + "characters\\cyborg\\cyborg", +}; + +static char *tag_get_name(long tag_index) +{ + CHECK(tag_index >= CAMPAIGN_DEFINITION && tag_index < CAMPAIGN_DEFINITION + CAMPAIGN_PATH_COUNT, + "a campaign unit's definition: %ld", tag_index); + return (char *)campaign_paths[tag_index - CAMPAIGN_DEFINITION]; +} + +static void *object_try_and_get_and_verify_type(long object_index, long mask) +{ + long type; + + if (object_index < 0 || object_index >= OBJECT_COUNT || !objects_here[object_index]) + return NULL; + type = object_index == VEHICLE || object_index == CAMPAIGN_VEHICLE ? _object_mask_vehicle : _object_mask_biped; + return (type & mask) ? &objects[object_index] : NULL; +} + +static struct object_definition *object_definition_get(long definition_index) +{ + CHECK(definition_index >= 0 && definition_index < DEFINITION_COUNT, "an object's definition: %ld", + definition_index); + return &definitions[definition_index]; +} + +/* the game's mode (game_modes.c): none, or SWAT */ +static short game_mode; + +static short game_modes_mode(void) +{ + return game_mode; +} + +static long distributed_player_from_byte(byte player_index) +{ + return player_index < player_count ? PLAYER(player_index) : NONE; +} + +static void halocustoms_stats_at_exit(void) +{ +} + +/* the colour of a unit's armour, and the settings the rank's colour reads +(halocustoms.rank_skins, read again when the settings change) */ +typedef struct +{ + float red, green, blue; +} real_rgb_color; + +static int rank_skins = 1; +static unsigned long settings_changes; + +static unsigned long config_changes(void) +{ + return settings_changes; +} + +int config_boolean(char const *name) +{ + CHECK(!strcmp(name, "halocustoms.rank_skins"), "another setting read: %s", name); + return rank_skins; +} + +#include "under_test.inc" + +/* ---------- the cases' tools */ + +static void world_reset(void) +{ + long index; + + for (index = 0; index < OBJECT_COUNT; index++) + { + objects[index].definition_index = index < UNIT_COUNT ? 0 : index == VEHICLE ? DEFINITION_VEHICLE : + index == UNIT_NO_MODEL ? DEFINITION_NO_MODEL : index < CAMPAIGN_UNIT ? + 1 + (index - UNIT_COUNT) / UNITS_OF_A_MODEL : CAMPAIGN_DEFINITION + (index - CAMPAIGN_UNIT) % CAMPAIGN_PATH_COUNT; + objects[index].object.owner_team_index = index < CAMPAIGN_UNIT ? 0 : + (index - CAMPAIGN_UNIT) % CAMPAIGN_PATH_COUNT == MARINE ? TEAM_HUMAN : + (index - CAMPAIGN_UNIT) % CAMPAIGN_PATH_COUNT == CYBORG ? TEAM_PLAYER : + (index - CAMPAIGN_UNIT) % CAMPAIGN_PATH_COUNT == SENTINEL ? TEAM_SENTINEL : + (index - CAMPAIGN_UNIT) % CAMPAIGN_PATH_COUNT >= FLOOD_INFECTION && + (index - CAMPAIGN_UNIT) % CAMPAIGN_PATH_COUNT <= FLOOD_CARRIER ? TEAM_FLOOD : TEAM_COVENANT; + objects[index].object.damage_flags = 0; + objects[index].unit.player_index = NONE; + objects_here[index] = TRUE; + } + memset(players, 0, sizeof(players)); + memset(player_scores, 0, sizeof(player_scores)); + wcscpy(players[0].name, L"Tester"); + wcscpy(players[1].name, L"Other"); + wcscpy(players[2].name, L"Third"); + for (index = 0; index < MAXIMUM_TEST_PLAYERS; index++) + { + players[index].unit_index = NONE; + players[index].team_index = index % 2; + } + player_count = 3; + local_count = 1; + variant.universal_variant.teams = FALSE; + game_engine = &engine; + scenario.type = _scenario_type_multiplayer; + connection = _game_connection_local; + difficulty = 2; + map_name = "levels\\test\\bloodgulch\\bloodgulch"; + game_mode = _game_mode_none; + game_time = 0; + server_mode = 0; + for (index = 0; index < MAXIMUM_LOCAL_PLAYERS; index++) + active_profiles[index] = index < PROFILE_COUNT ? index : NONE; + last_used_profile = NONE; + naming_count = 0; + record_count = 0; + memset(records, 0, sizeof(records)); +} + +/* a multiplayer game begins */ +static void multiplayer_start(void) +{ + game_engine = &engine; + scenario.type = _scenario_type_multiplayer; + halocustoms_stats_game_started(); +} + +/* a level session begins, alone (local) or as a co-op host or client */ +static void session_start(short session_connection) +{ + game_engine = NULL; + scenario.type = _scenario_type_solo; + connection = session_connection; + map_name = session_connection == _game_connection_local ? "levels\\a30\\a30" : "levels\\b30\\b30"; + halocustoms_stats_game_started(); +} + +/* the first local player's run of kills as the game has it at a tick */ +static void tick(short kills_in_a_row) +{ + players[0].statistics.kills_in_a_row = kills_in_a_row; + game_time++; + halocustoms_stats_tick(); +} + +/* ticks of a level session, the local players' units as they are */ +static void ticks(long count) +{ + for (; count > 0; count--) + { + game_time++; + halocustoms_stats_tick(); + } +} + +/* a killing blow's event as the host makes it: whom the host scored the kill +for (NO_PLAYER: no one), whose blow it was, its damage, the unit, the +material struck (NONE: none, as an explosion or a melee blow strikes), what +of it reached the body, and whether the host has it as friendly fire */ +static struct distributed_damage_event const *blow(byte killer, byte owner, long effect, long unit, short material, + real body_damage, boolean friendly_fire) +{ + static struct distributed_damage_event event; + + memset(&event, 0, sizeof(event)); + event.player_index = killer; + event.damage.owner_player_index = owner; + event.damage.definition_index = effect; + event.object_index = unit; + event.material_index = material; + event.body_damage = body_damage; + if (friendly_fire) + event.kill_flags |= FLAG(_damage_event_friendly_fire_bit); + return &event; +} + +/* the first local player's own killing blow at an enemy, on a machine that +has the unit */ +static void own_blow(long effect, long unit, short material) +{ + distributed_count_kill(blow(0, 0, effect, unit, material, 40.0f, FALSE), TRUE); +} + +#define COUNTED(by_headshot, by_melee) (record.locals[0].headshots == (by_headshot) && \ + record.locals[0].melee_kills == (by_melee)) +#define HEADSHOTS record.locals[0].headshots +#define MELEE_KILLS record.locals[0].melee_kills + +/* a campaign unit of a path, and the nth of them */ +#define CAMPAIGN(path, n) (CAMPAIGN_UNIT + (path) + (n) * CAMPAIGN_PATH_COUNT) + +/* a blow's aftermath in a level session (halocustoms_stats_blow): whose blow +(a player's absolute index, NONE: no player's), what it did */ +static void campaign_blow(long unit, long owner, unsigned long flags) +{ + halocustoms_stats_blow(unit, owner == NONE ? NONE : PLAYER(owner), flags, + objects[unit].unit.player_index); + if (TEST_FLAG(flags, _object_being_damaged_body_depleted_bit)) + objects[unit].object.damage_flags |= FLAG(_object_dead_bit); +} + +#define KILLED FLAG(_object_being_damaged_body_depleted_bit) +#define DESTROYED (FLAG(_object_being_damaged_body_depleted_bit) | FLAG(_object_being_damaged_body_destroyed_bit)) +#define BY_FRIEND FLAG(_object_being_damaged_by_friendly_bit) + +/* the local player's count of a class */ +#define ENEMIES(local, class) record.locals[local].enemies[class] + +/* the line's member read back: its JSON number (or -1000000 for none) */ +static long line_number_of(char const *line, char const *path) +{ + struct server_json_document document; + char key[64]; + char const *at = path, *dot; + int token = 0; + double value = -1000000; + + CHECK(server_json_parse_tokens(&document, line, (int)strlen(line), 8, 2048), "the line is JSON: %s", line); + while ((dot = strchr(at, '.')) != NULL && token >= 0) + { + snprintf(key, sizeof(key), "%.*s", (int)(dot - at), at); + token = key[0] >= '0' && key[0] <= '9' ? server_json_element(&document, token, atoi(key)) : + server_json_member(&document, token, key); + at = dot + 1; + } + if (token >= 0) + { + token = at[0] >= '0' && at[0] <= '9' ? server_json_element(&document, token, atoi(at)) : + server_json_member(&document, token, at); + if (token >= 0 && !server_json_get_number(&document, token, &value)) + { + int flag = 0; + + if (server_json_get_boolean(&document, token, &flag)) + value = flag; + } + } + server_json_document_free(&document); + return (long)value; +} + +/* ... a string member's (cut to size) */ +static char const *line_string_of(char const *line, char const *path) +{ + static char text[256]; + struct server_json_document document; + char key[64]; + char const *at = path, *dot; + int token = 0; + + text[0] = 0; + CHECK(server_json_parse_tokens(&document, line, (int)strlen(line), 8, 2048), "the line is JSON: %s", line); + while ((dot = strchr(at, '.')) != NULL && token >= 0) + { + snprintf(key, sizeof(key), "%.*s", (int)(dot - at), at); + token = key[0] >= '0' && key[0] <= '9' ? server_json_element(&document, token, atoi(key)) : + server_json_member(&document, token, key); + at = dot + 1; + } + if (token >= 0) + { + token = at[0] >= '0' && at[0] <= '9' ? server_json_element(&document, token, atoi(at)) : + server_json_member(&document, token, at); + if (token < 0 || !server_json_get_string(&document, token, text, sizeof(text))) + snprintf(text, sizeof(text), "(none)"); + } + server_json_document_free(&document); + return text; +} + +/* how many times text has the word */ +static int count_of(const char *text, const char *word) +{ + int count = 0; + + for (text = strstr(text, word); text; text = strstr(text + 1, word)) + count++; + return count; +} + +int main(int argc, char **argv) +{ + const char *case_name = argc > 1 ? argv[1] : ""; + + world_reset(); + CASE("streak") + { + /* three kills, a death, one more: the run the death ended is the + game's longest */ + multiplayer_start(); + tick(0); + tick(1); + tick(2); + tick(3); + tick(0); + tick(1); + players[0].statistics.kills[0] = 4; + players[0].statistics.deaths = 1; + game_time = 300 * TICKS_PER_SECOND; + halocustoms_stats_game_over(); + CHECK(record_count == 1 && strstr(records[0].line, "\"kills\":4,\"deaths\":1,") && + strstr(records[0].line, "\"assists\":0,\"best_streak\":3,"), + "a run a death ended before the game's end is the one recorded: %s", records[0].line); + CHECK(!strncmp(records[0].line, "{\"match_key\":\"bloodgulch-", 25) && + strstr(records[0].line, "\"mode\":\"multiplayer\",\"map\":\"bloodgulch\",\"player\":\"Tester\",\"local_player\":0,") && + strstr(records[0].line, "\"gametype\":\"Slayer\"") && + strstr(records[0].line, "\"won\":true,\"team_game\":false,\"scoreboard\":[") && + strstr(records[0].line, "],\"duration_seconds\":300,\"players\":3,\"played_at\":"), + "... in the game's line: %s", records[0].line); + CHECK(records[0].slot == 10 && records[0].local_player == 0, "the first local player's profile's account: %d, %d", + records[0].slot, records[0].local_player); + /* (the game over: nothing more of it, however long it is shown) */ + tick(9); + halocustoms_stats_game_over(); + CHECK(record_count == 1 && record.locals[0].best_streak == 3, + "the game's end is recorded once, and its ticks after are not watched: %d, %ld", record_count, + record.locals[0].best_streak); + + /* the next game starts from none; the run the player is on at the + end, a kill past the last tick's, is the longest when it is */ + multiplayer_start(); + tick(1); + tick(2); + tick(0); + tick(1); + tick(4); + players[0].statistics.kills_in_a_row = 5; + halocustoms_stats_game_over(); + CHECK(record_count == 2 && strstr(records[1].line, "\"best_streak\":5,"), + "the run at the game's end, when it is the longest: %s", records[1].line); + multiplayer_start(); + tick(1); + tick(0); + halocustoms_stats_game_over(); + CHECK(record_count == 3 && strstr(records[2].line, "\"best_streak\":1,"), + "a game's longest run is its own, not the game's before: %s", records[2].line); + + /* a machine with no player of its own, and no multiplayer game */ + multiplayer_start(); + local_count = 0; + tick(7); + halocustoms_stats_game_over(); + CHECK(record_count == 3 && record.locals[0].best_streak == 0, "a machine with no player of its own records " + "nothing: %d", record_count); + world_reset(); + multiplayer_start(); + game_engine = NULL; + tick(7); + halocustoms_stats_game_over(); + CHECK(record_count == 0 && record.locals[0].best_streak == 0, "no multiplayer game: nothing watched or " + "recorded: %d", record_count); + } + else CASE("counted") + { + long unit; + + /* the first local player's kills by their own blow: a melee blow's, + a blow to the head's, both (a melee kill), neither */ + multiplayer_start(); + halocustoms_stats_kill(PLAYER(0), 1, TRUE, FALSE); + CHECK(COUNTED(0, 1), "a melee kill: %ld, %ld", HEADSHOTS, MELEE_KILLS); + halocustoms_stats_kill(PLAYER(0), 2, FALSE, TRUE); + CHECK(COUNTED(1, 1), "a headshot: %ld, %ld", HEADSHOTS, MELEE_KILLS); + halocustoms_stats_kill(PLAYER(0), 3, TRUE, TRUE); + CHECK(COUNTED(1, 2), "a melee blow to the head is a melee kill, and no headshot: %ld, %ld", HEADSHOTS, + MELEE_KILLS); + halocustoms_stats_kill(PLAYER(0), 4, FALSE, FALSE); + CHECK(COUNTED(1, 2), "a kill that is neither: %ld, %ld", HEADSHOTS, MELEE_KILLS); + /* the host's second word of a kill, at once or some kills later */ + halocustoms_stats_kill(PLAYER(0), 3, TRUE, TRUE); + halocustoms_stats_kill(PLAYER(0), 1, TRUE, FALSE); + halocustoms_stats_kill(PLAYER(0), 2, FALSE, TRUE); + CHECK(COUNTED(1, 2), "a unit's kill is counted once, however often it is told: %ld, %ld", HEADSHOTS, + MELEE_KILLS); + /* another machine's player's, and no one's */ + halocustoms_stats_kill(PLAYER(1), 5, FALSE, TRUE); + halocustoms_stats_kill(PLAYER(2), 6, TRUE, FALSE); + halocustoms_stats_kill(NONE, 7, TRUE, FALSE); + CHECK(COUNTED(1, 2) && !record.locals[1].headshots && !record.locals[1].melee_kills, + "only the local players': %ld, %ld", HEADSHOTS, MELEE_KILLS); + /* the line: no more of them than the kills the game has for the + player */ + players[0].statistics.kills[0] = 9; + halocustoms_stats_game_over(); + CHECK(record_count == 1 && strstr(records[0].line, "\"kills\":9,\"deaths\":0,") && + strstr(records[0].line, "\"assists\":0,\"best_streak\":0,\"headshots\":1,\"melee_kills\":2,\"won\":true,"), + "the game's line has both: %s", records[0].line); + /* (the game over: a kill after it is no part of the game's record) */ + halocustoms_stats_kill(PLAYER(0), 8, FALSE, TRUE); + CHECK(COUNTED(1, 2), "a kill after the game's end is not counted: %ld, %ld", HEADSHOTS, MELEE_KILLS); + + /* the next game starts from none, a unit of the game before's index + too */ + multiplayer_start(); + CHECK(COUNTED(0, 0), "a game starts with none: %ld, %ld", HEADSHOTS, MELEE_KILLS); + halocustoms_stats_kill(PLAYER(0), 2, FALSE, TRUE); + CHECK(COUNTED(1, 0), "a unit of an index the game before counted is this game's: %ld, %ld", HEADSHOTS, + MELEE_KILLS); + /* more kills than units are kept: the last of them are still known */ + for (unit = 100; unit < 100 + MAXIMUM_COUNTED_UNITS + 8; unit++) + halocustoms_stats_kill(PLAYER(0), unit, (unit & 1) != 0, TRUE); + CHECK(COUNTED(1 + MAXIMUM_COUNTED_UNITS / 2 + 4, MAXIMUM_COUNTED_UNITS / 2 + 4), "each unit's kill counted: %ld, " + "%ld", HEADSHOTS, MELEE_KILLS); + for (unit = 100 + 8; unit < 100 + MAXIMUM_COUNTED_UNITS + 8; unit++) + halocustoms_stats_kill(PLAYER(0), unit, TRUE, TRUE); + CHECK(COUNTED(1 + MAXIMUM_COUNTED_UNITS / 2 + 4, MAXIMUM_COUNTED_UNITS / 2 + 4), + "the last %d units counted are known when told again: %ld, %ld", (int)MAXIMUM_COUNTED_UNITS, HEADSHOTS, + MELEE_KILLS); + /* fewer kills in the game's statistics than were counted (a client's + last word of them not come): the line has no more than those */ + players[0].statistics.kills[0] = 30; + halocustoms_stats_game_over(); + CHECK(record_count == 2 && strstr(records[1].line, "\"kills\":30,") && + strstr(records[1].line, "\"headshots\":10,\"melee_kills\":20,"), + "no more of the two together than the kills, the melee kills first: %s", records[1].line); + multiplayer_start(); + halocustoms_stats_kill(PLAYER(0), 1, FALSE, TRUE); + halocustoms_stats_kill(PLAYER(0), 2, TRUE, FALSE); + players[0].statistics.kills[0] = -3; + halocustoms_stats_game_over(); + CHECK(record_count == 3 && strstr(records[2].line, "\"headshots\":0,\"melee_kills\":0,"), + "none of either with no kills, or fewer: %s", records[2].line); + + /* a machine with no player of its own, and no multiplayer game */ + multiplayer_start(); + local_count = 0; + halocustoms_stats_kill(PLAYER(0), 1, FALSE, TRUE); + halocustoms_stats_kill(NONE, 2, TRUE, FALSE); + CHECK(COUNTED(0, 0), "a machine with no player of its own counts none: %ld, %ld", HEADSHOTS, MELEE_KILLS); + local_count = 1; + game_engine = NULL; + halocustoms_stats_kill(PLAYER(0), 1, FALSE, TRUE); + CHECK(COUNTED(0, 0), "no multiplayer game: none counted: %ld, %ld", HEADSHOTS, MELEE_KILLS); + } + else CASE("server-tallies") + { + long headshots, melee_kills, best_streak; + + /* a dedicated server, with no player of its own: every player's kills + by a melee blow and to the head, once each, and their longest runs */ + server_mode = 1; + local_count = 0; + player_data = players; + multiplayer_start(); + halocustoms_stats_kill(PLAYER(1), 1, TRUE, FALSE); + halocustoms_stats_kill(PLAYER(2), 2, FALSE, TRUE); + halocustoms_stats_kill(PLAYER(2), 3, TRUE, TRUE); + halocustoms_stats_kill(PLAYER(2), 2, FALSE, TRUE); + halocustoms_stats_kill(PLAYER(1), 4, FALSE, FALSE); + halocustoms_stats_kill(NONE, 5, TRUE, FALSE); + players[1].statistics.kills_in_a_row = 2; + ticks(1); + players[1].statistics.kills_in_a_row = 0; + players[2].statistics.kills_in_a_row = 1; + ticks(1); + CHECK(halocustoms_stats_player_tallies(1, &headshots, &melee_kills, &best_streak) && headshots == 0 && + melee_kills == 1 && best_streak == 2, "the second player's: %ld %ld %ld", headshots, melee_kills, + best_streak); + CHECK(halocustoms_stats_player_tallies(2, &headshots, &melee_kills, &best_streak) && headshots == 1 && + melee_kills == 1 && best_streak == 1, "the third player's (a melee blow to the head a melee kill, a kill " + "told twice once): %ld %ld %ld", headshots, melee_kills, best_streak); + CHECK(halocustoms_stats_player_tallies(0, &headshots, &melee_kills, &best_streak) && !headshots && + !melee_kills && !best_streak, "the first player's: none"); + CHECK(!halocustoms_stats_player_tallies(MAXIMUM_TALLIED_PLAYERS, &headshots, &melee_kills, &best_streak) && + !halocustoms_stats_player_tallies(-1, &headshots, &melee_kills, &best_streak), "a player past the table"); + /* (the players gone, and others joined into their indices: tallies + of their own, none of the players' before) */ + player_salt = 0x100; + players[1].statistics.kills_in_a_row = 1; + ticks(1); + CHECK(halocustoms_stats_player_tallies(1, &headshots, &melee_kills, &best_streak) && !headshots && + !melee_kills && best_streak == 1, "the tally of the player who left the index: %ld %ld %ld", headshots, + melee_kills, best_streak); + halocustoms_stats_kill(PLAYER(2), 8, FALSE, TRUE); + CHECK(halocustoms_stats_player_tallies(2, &headshots, &melee_kills, &best_streak) && headshots == 1 && + !melee_kills, "a kill of the player who joined, with the tally before's: %ld %ld", headshots, melee_kills); + player_salt = 0; + /* the next game starts from none */ + multiplayer_start(); + CHECK(halocustoms_stats_player_tallies(1, &headshots, &melee_kills, &best_streak) && !headshots && + !melee_kills && !best_streak, "the game before's tallies: %ld %ld %ld", headshots, melee_kills, + best_streak); + /* none out of a multiplayer game */ + session_start(_game_connection_network_server); + CHECK(!halocustoms_stats_player_tallies(1, &headshots, &melee_kills, &best_streak), "a level session's"); + /* not a dedicated server (a client, or a player's host): its own + players' alone, none tallied */ + server_mode = 0; + local_count = 1; + multiplayer_start(); + halocustoms_stats_kill(PLAYER(1), 6, TRUE, FALSE); + halocustoms_stats_kill(PLAYER(0), 7, TRUE, FALSE); + players[1].statistics.kills_in_a_row = 3; + ticks(1); + CHECK(halocustoms_stats_player_tallies(1, &headshots, &melee_kills, &best_streak) && !melee_kills && + !best_streak && COUNTED(0, 1), "a player's machine tallies none: %ld %ld", melee_kills, best_streak); + player_data = NULL; + } + else CASE("blow") + { + multiplayer_start(); + /* a shot that kills at the head: at the head, at the body */ + own_blow(EFFECT_PISTOL, 1, MATERIAL_HEAD); + CHECK(COUNTED(1, 0), "a pistol's shot to the head: a headshot: %ld, %ld", HEADSHOTS, MELEE_KILLS); + own_blow(EFFECT_PISTOL, 2, MATERIAL_BODY); + CHECK(COUNTED(1, 0), "one to the body is none: %ld, %ld", HEADSHOTS, MELEE_KILLS); + /* ... the event's second coming, of the same unit */ + own_blow(EFFECT_PISTOL, 1, MATERIAL_HEAD); + CHECK(COUNTED(1, 0), "the event's second coming is not another kill: %ld, %ld", HEADSHOTS, MELEE_KILLS); + /* ... none of it reaching the body (object_damage_body's rule) */ + distributed_count_kill(blow(0, 0, EFFECT_PISTOL, 3, MATERIAL_HEAD, 0.0f, FALSE), TRUE); + CHECK(COUNTED(1, 0), "a shot none of which reached the body is no headshot: %ld, %ld", HEADSHOTS, MELEE_KILLS); + /* a shot a multiplayer game doubles at the head; one that is nothing + more there */ + own_blow(EFFECT_RIFLE, 4, MATERIAL_HEAD); + CHECK(COUNTED(2, 0), "a shot doubled at the head: a headshot: %ld, %ld", HEADSHOTS, MELEE_KILLS); + own_blow(EFFECT_PLASMA, 5, MATERIAL_HEAD); + own_blow(EFFECT_GRENADE, 6, MATERIAL_HEAD); + CHECK(COUNTED(2, 0), "damage that is no more at the head is no headshot: %ld, %ld", HEADSHOTS, MELEE_KILLS); + /* a melee blow, which strikes no material; and one that would kill at + the head, at it */ + own_blow(EFFECT_MELEE, 7, NONE); + CHECK(COUNTED(2, 1), "a melee blow: a melee kill: %ld, %ld", HEADSHOTS, MELEE_KILLS); + own_blow(EFFECT_MELEE_AT_HEADS, 8, MATERIAL_HEAD); + CHECK(COUNTED(2, 2), "a melee blow to the head is a melee kill, no headshot: %ld, %ld", HEADSHOTS, MELEE_KILLS); + + /* what strikes no material of the unit, or none it has, takes its + indirect material (object_cause_damage's): its body's, another + model's head's, or none */ + own_blow(EFFECT_PISTOL, 9, NONE); + own_blow(EFFECT_PISTOL, 10, MATERIAL_COUNT); + own_blow(EFFECT_PISTOL, 11, 3000); + own_blow(EFFECT_PISTOL, 12, -7); + CHECK(COUNTED(2, 2), "a material the unit does not have is its indirect one, the body's: %ld, %ld", HEADSHOTS, + MELEE_KILLS); + own_blow(EFFECT_PISTOL, UNIT_INDIRECT_HEAD, NONE); + CHECK(COUNTED(3, 2), "... its head's, where that is the model's indirect material: %ld, %ld", HEADSHOTS, + MELEE_KILLS); + own_blow(EFFECT_PISTOL, UNIT_INDIRECT_HEAD + 1, 3000); + CHECK(COUNTED(4, 2), "... a material past the model's too: %ld, %ld", HEADSHOTS, MELEE_KILLS); + own_blow(EFFECT_PISTOL, UNIT_INDIRECT_HEAD + 2, MATERIAL_BODY); + CHECK(COUNTED(4, 2), "(the body struck of that model is the body: %ld, %ld)", HEADSHOTS, MELEE_KILLS); + own_blow(EFFECT_PISTOL, UNIT_NO_INDIRECT, NONE); + own_blow(EFFECT_PISTOL, UNIT_NO_INDIRECT + 1, 3000); + own_blow(EFFECT_PISTOL, UNIT_BAD_INDIRECT, NONE); + own_blow(EFFECT_PISTOL, UNIT_BAD_INDIRECT + 1, MATERIAL_COUNT); + CHECK(COUNTED(4, 2), "... none, where the model has no indirect material, or one it has not: %ld, %ld", + HEADSHOTS, MELEE_KILLS); + own_blow(EFFECT_PISTOL, UNIT_NO_INDIRECT + 2, MATERIAL_HEAD); + own_blow(EFFECT_PISTOL, UNIT_BAD_INDIRECT + 2, MATERIAL_HEAD); + CHECK(COUNTED(6, 2), "(the head struck of those models: headshots: %ld, %ld)", HEADSHOTS, MELEE_KILLS); + /* a unit with no collision model, a vehicle, no unit at all */ + own_blow(EFFECT_PISTOL, UNIT_NO_MODEL, MATERIAL_HEAD); + own_blow(EFFECT_PISTOL, VEHICLE, MATERIAL_HEAD); + own_blow(EFFECT_PISTOL, NONE, MATERIAL_HEAD); + own_blow(EFFECT_PISTOL, 0x7FFFFFFF, MATERIAL_HEAD); + CHECK(COUNTED(6, 2), "what has no head is not shot in it: %ld, %ld", HEADSHOTS, MELEE_KILLS); + + /* a kill the host did not score for the player who dealt the blow: a + teammate's or their own death, no one's kill, one credited for + another's blow or for a fall */ + distributed_count_kill(blow(0, 0, EFFECT_PISTOL, 13, MATERIAL_HEAD, 40.0f, TRUE), TRUE); + distributed_count_kill(blow(0, 0, EFFECT_MELEE, 14, NONE, 40.0f, TRUE), TRUE); + CHECK(COUNTED(6, 2), "a teammate's death, or the player's own, is no kill: %ld, %ld", HEADSHOTS, MELEE_KILLS); + distributed_count_kill(blow(NO_PLAYER, 0, EFFECT_PISTOL, 15, MATERIAL_HEAD, 40.0f, FALSE), TRUE); + distributed_count_kill(blow(NO_PLAYER, NO_PLAYER, EFFECT_MELEE, 16, NONE, 40.0f, FALSE), TRUE); + CHECK(COUNTED(6, 2), "a death the host scored for no one: %ld, %ld", HEADSHOTS, MELEE_KILLS); + distributed_count_kill(blow(0, 1, EFFECT_PISTOL, 17, MATERIAL_HEAD, 40.0f, FALSE), TRUE); + distributed_count_kill(blow(0, NO_PLAYER, EFFECT_MELEE, 18, NONE, 40.0f, FALSE), TRUE); + CHECK(COUNTED(6, 2), "a kill credited for another's blow, or for a fall, is neither: %ld, %ld", HEADSHOTS, + MELEE_KILLS); + /* another player's kills by their own blows */ + distributed_count_kill(blow(1, 1, EFFECT_PISTOL, 19, MATERIAL_HEAD, 40.0f, FALSE), TRUE); + distributed_count_kill(blow(2, 2, EFFECT_MELEE, 20, NONE, 40.0f, FALSE), TRUE); + distributed_count_kill(blow(1, 0, EFFECT_PISTOL, 21, MATERIAL_HEAD, 40.0f, FALSE), TRUE); + CHECK(COUNTED(6, 2), "another player's kills are not the first local player's: %ld, %ld", HEADSHOTS, + MELEE_KILLS); + /* a player this machine does not have, and damage that is no damage + effect of the map's (another machine's word: nothing read by it) */ + distributed_count_kill(blow(200, 200, EFFECT_PISTOL, 22, MATERIAL_HEAD, 40.0f, FALSE), TRUE); + distributed_count_kill(blow(0, 0, EFFECT_COUNT, 23, MATERIAL_HEAD, 40.0f, FALSE), TRUE); + distributed_count_kill(blow(0, 0, NONE, 24, MATERIAL_HEAD, 40.0f, FALSE), TRUE); + distributed_count_kill(blow(0, 0, 0x7FFFFFFF, 25, NONE, 40.0f, FALSE), TRUE); + CHECK(COUNTED(6, 2), "a player or a damage effect the game does not have: nothing: %ld, %ld", HEADSHOTS, + MELEE_KILLS); + + /* a client without the unit: the material is not told (no headshot), + the blow's kind is */ + distributed_count_kill(blow(0, 0, EFFECT_PISTOL, 26, MATERIAL_HEAD, 40.0f, FALSE), FALSE); + CHECK(COUNTED(6, 2), "a headshot at a unit this machine does not have is not counted: %ld, %ld", HEADSHOTS, + MELEE_KILLS); + distributed_count_kill(blow(0, 0, EFFECT_MELEE, 27, NONE, 40.0f, FALSE), FALSE); + CHECK(COUNTED(6, 3), "a melee kill of one is: %ld, %ld", HEADSHOTS, MELEE_KILLS); + + /* SWAT: a shot of any weapon's to the head kills, not an explosion */ + own_blow(EFFECT_PLASMA, 28, MATERIAL_HEAD); + CHECK(COUNTED(6, 3), "(a plasma bolt to the head, no SWAT: none)"); + game_mode = _game_mode_swat; + own_blow(EFFECT_PLASMA, 29, MATERIAL_HEAD); + CHECK(COUNTED(7, 3), "SWAT: a plasma bolt to the head is a headshot: %ld, %ld", HEADSHOTS, MELEE_KILLS); + own_blow(EFFECT_GRENADE, 30, MATERIAL_HEAD); + own_blow(EFFECT_PLASMA, 31, MATERIAL_BODY); + CHECK(COUNTED(7, 3), "... an explosion's at it, or a bolt to the body, is not: %ld, %ld", HEADSHOTS, + MELEE_KILLS); + own_blow(EFFECT_MELEE, 32, MATERIAL_HEAD); + CHECK(COUNTED(7, 4), "... a melee blow is a melee kill there too: %ld, %ld", HEADSHOTS, MELEE_KILLS); + + /* the game's line of them */ + players[0].statistics.kills[0] = 25; + halocustoms_stats_game_over(); + CHECK(record_count == 1 && strstr(records[0].line, "\"kills\":25,") && + strstr(records[0].line, "\"best_streak\":0,\"headshots\":7,\"melee_kills\":4,\"won\":true,"), + "the game's line has the kills by a headshot and by a melee blow: %s", records[0].line); + } + else CASE("classes") + { + /* by the tag path, lower-cased, in the order: flood with infection, + with carrier, the other flood; sentinel, grunt, jackal, hunter, + elite; the rest */ + CHECK(enemy_class("characters\\grunt\\grunt") == _enemy_grunt, "a grunt"); + CHECK(enemy_class("characters\\jackal\\jackal") == _enemy_jackal, "a jackal"); + CHECK(enemy_class("characters\\elite\\elite specops") == _enemy_elite, "an elite"); + CHECK(enemy_class("characters\\hunter\\hunter") == _enemy_hunter, "a hunter"); + CHECK(enemy_class("characters\\sentinel\\sentinel") == _enemy_sentinel, "a sentinel"); + CHECK(enemy_class("characters\\flood infection\\flood infection") == _enemy_flood_infection, + "an infection form"); + CHECK(enemy_class("characters\\floodcarrier\\floodcarrier") == _enemy_flood_carrier, "a carrier form"); + CHECK(enemy_class("characters\\floodcombat_human\\floodcombat_human") == _enemy_flood_combat, "a combat form"); + CHECK(enemy_class("characters\\floodcombat elite\\floodcombat elite") == _enemy_flood_combat, + "a combat elite is a combat form, not an elite"); + CHECK(enemy_class("CHARACTERS\\Flood Infection\\Flood Infection") == _enemy_flood_infection && + enemy_class("Characters\\GRUNT\\Grunt") == _enemy_grunt && enemy_class("cmt\\Elite_Ranger\\x") == _enemy_elite, + "whatever its letters' case"); + /* (the order the tests go in) */ + CHECK(enemy_class("flood\\infection carrier") == _enemy_flood_infection, "infection before carrier"); + CHECK(enemy_class("flood sentinel") == _enemy_flood_combat, "the Flood before sentinels"); + CHECK(enemy_class("sentinel grunt") == _enemy_sentinel, "sentinels before grunts"); + CHECK(enemy_class("grunt jackal") == _enemy_grunt, "grunts before jackals"); + CHECK(enemy_class("jackal hunter") == _enemy_jackal, "jackals before hunters"); + CHECK(enemy_class("elite hunter") == _enemy_hunter, "hunters before elites"); + CHECK(enemy_class("characters\\infection\\infection") == _enemy_other && + enemy_class("characters\\carrier\\carrier") == _enemy_other, "infection or carrier without flood: another"); + CHECK(enemy_class("characters\\marine\\marine") == _enemy_other && enemy_class("") == _enemy_other && + enemy_class(NULL) == _enemy_other, "the rest: another"); + } + else CASE("solo") + { + long unit; + char key[64]; + + /* a campaign level alone: the player's kills as the blows are dealt */ + session_start(_game_connection_local); + CHECK(record.kind == _record_campaign && !strcmp(record.map, "levels\\a30\\a30"), "a campaign level's session: " + "%d %s", record.kind, record.map); + players[0].unit_index = 5000; + ticks(30); + campaign_blow(CAMPAIGN(GRUNT, 0), 0, KILLED); + campaign_blow(CAMPAIGN(GRUNT, 1), 0, KILLED); + campaign_blow(CAMPAIGN(ELITE, 0), 0, KILLED); + campaign_blow(CAMPAIGN(FLOOD_CARRIER, 0), 0, KILLED); + CHECK(ENEMIES(0, _enemy_grunt) == 2 && ENEMIES(0, _enemy_elite) == 1 && ENEMIES(0, _enemy_flood_carrier) == 1, + "each kill by its class: %ld %ld %ld", ENEMIES(0, _enemy_grunt), ENEMIES(0, _enemy_elite), + ENEMIES(0, _enemy_flood_carrier)); + /* a body the blow destroys outright is a kill; a body already dead + destroyed is none */ + campaign_blow(CAMPAIGN(FLOOD_INFECTION, 0), 0, DESTROYED); + CHECK(ENEMIES(0, _enemy_flood_infection) == 1, "a unit destroyed outright: a kill: %ld", + ENEMIES(0, _enemy_flood_infection)); + campaign_blow(CAMPAIGN(GRUNT, 0), 0, DESTROYED); + campaign_blow(CAMPAIGN(FLOOD_INFECTION, 0), 0, DESTROYED); + CHECK(ENEMIES(0, _enemy_grunt) == 2 && ENEMIES(0, _enemy_flood_infection) == 1, + "a dead body destroyed is no other kill: %ld %ld", ENEMIES(0, _enemy_grunt), + ENEMIES(0, _enemy_flood_infection)); + /* a unit another killed, its body destroyed by the player after */ + campaign_blow(CAMPAIGN(JACKAL, 0), NONE, KILLED); + campaign_blow(CAMPAIGN(JACKAL, 0), 0, DESTROYED); + CHECK(ENEMIES(0, _enemy_jackal) == 0, "a unit another killed is not the player's for its body: %ld", + ENEMIES(0, _enemy_jackal)); + /* an ally, the player's own unit, a vehicle, a blow that does not kill */ + campaign_blow(CAMPAIGN(MARINE, 0), 0, KILLED | BY_FRIEND); + CHECK(record.locals[0].allies_killed == 1 && ENEMIES(0, _enemy_other) == 0, "a marine: an ally killed: %ld", + record.locals[0].allies_killed); + objects[CAMPAIGN(CYBORG, 0)].unit.player_index = PLAYER(0); + campaign_blow(CAMPAIGN(CYBORG, 0), 0, KILLED | BY_FRIEND); + campaign_blow(CAMPAIGN_VEHICLE, 0, KILLED); + campaign_blow(CAMPAIGN(HUNTER, 0), 0, 0); + campaign_blow(CAMPAIGN(HUNTER, 1), 0, FLAG(_object_being_damaged_body_destroyed_bit)); + CHECK(record.locals[0].allies_killed == 1 && ENEMIES(0, _enemy_other) == 0 && ENEMIES(0, _enemy_hunter) == 0, + "their own death, a vehicle, a blow that does not kill: none"); + /* another's blow, no player's, a network client's own aftermath of a + blow it replays (the host's word counts there) */ + campaign_blow(CAMPAIGN(SENTINEL, 0), 1, KILLED); + campaign_blow(CAMPAIGN(SENTINEL, 1), NONE, KILLED); + connection = _game_connection_network_client; + campaign_blow(CAMPAIGN(SENTINEL, 2), 0, KILLED); + connection = _game_connection_local; + CHECK(ENEMIES(0, _enemy_sentinel) == 0, "not the local player's: none: %ld", ENEMIES(0, _enemy_sentinel)); + campaign_blow(CAMPAIGN(SENTINEL, 3), 0, KILLED); + CHECK(ENEMIES(0, _enemy_sentinel) == 1, "(a sentinel the player kills: %ld)", ENEMIES(0, _enemy_sentinel)); + + /* the player dies; a checkpoint is reverted to: the units killed since + live again, at their indices, and their kills stay counted */ + players[0].unit_index = NONE; + ticks(60); + objects[CAMPAIGN(GRUNT, 0)].object.damage_flags = 0; + objects[CAMPAIGN(GRUNT, 1)].object.damage_flags = 0; + halocustoms_stats_reverted(); + campaign_blow(CAMPAIGN(GRUNT, 1), 0, DESTROYED); + CHECK(ENEMIES(0, _enemy_grunt) == 3, "a unit alive again after the revert, destroyed outright: a kill: %ld", + ENEMIES(0, _enemy_grunt)); + players[0].unit_index = 5000; + ticks(30); + campaign_blow(CAMPAIGN(GRUNT, 0), 0, KILLED); + campaign_blow(CAMPAIGN(GRUNT, 0), 0, DESTROYED); + CHECK(ENEMIES(0, _enemy_grunt) == 4 && record.locals[0].deaths == 1, + "a unit killed again after a revert is another kill, and its body after that none; a death: %ld, %ld", + ENEMIES(0, _enemy_grunt), record.locals[0].deaths); + campaign_blow(CAMPAIGN(FLOOD_INFECTION, 0), 0, DESTROYED); + CHECK(ENEMIES(0, _enemy_flood_infection) == 1, "a body the revert kept is still dead: %ld", + ENEMIES(0, _enemy_flood_infection)); + /* more deaths than units are kept: a body long dead destroyed is + known still */ + for (unit = 0; unit < 50; unit++) + campaign_blow(CAMPAIGN(GRUNT, 2) + unit, NONE, KILLED); + campaign_blow(CAMPAIGN(ELITE, 0), 0, DESTROYED); + CHECK(ENEMIES(0, _enemy_elite) == 1, "a body dead fifty deaths ago is known: %ld", ENEMIES(0, _enemy_elite)); + /* the player dies twice more (a respawn between) */ + players[0].unit_index = NONE; + ticks(1); + players[0].unit_index = 5001; + ticks(1); + players[0].unit_index = NONE; + ticks(1); + ticks(5); + CHECK(record.locals[0].deaths == 3, "each death once, however long dead: %ld", record.locals[0].deaths); + players[0].unit_index = 5002; + ticks(30 * 600 - 128); + CHECK(record_count == 0, "nothing written while the level is played"); + + /* won, then unloaded: the line */ + halocustoms_stats_level_won(); + halocustoms_stats_map_ended(); + snprintf(key, sizeof(key), "a30-%ld", record.started_at); + CHECK(record_count == 1 && records[0].slot == 10 && records[0].local_player == 0, + "the session's line, into the player's profile's account: %d, %d", record_count, records[0].slot); + CHECK(!strcmp(line_string_of(records[0].line, "match_key"), key) && + !strcmp(line_string_of(records[0].line, "mode"), "campaign") && + !strcmp(line_string_of(records[0].line, "map"), "a30") && + !strcmp(line_string_of(records[0].line, "player"), "Tester") && + line_number_of(records[0].line, "local_player") == 0 && + !strcmp(line_string_of(records[0].line, "difficulty"), "heroic"), + "its key, mode, map, player and difficulty: %s", records[0].line); + CHECK(strstr(records[0].line, ",\"kills\":8,\"deaths\":3,\"difficulty\":\"heroic\",\"enemies\":{\"grunt\":4," + "\"jackal\":0,\"elite\":1,\"hunter\":0,\"flood_infection\":1,\"flood_combat\":0,\"flood_carrier\":1," + "\"sentinel\":1,\"other\":0},\"allies_killed\":1,\"completed\":true,\"duration_seconds\":600,\"players\":3," + "\"played_at\":"), "the kills their classes' sum, each class, the allies, won, the time played: %s", + records[0].line); + CHECK(!strstr(records[0].line, "gametype") && !strstr(records[0].line, "scoreboard") && + !strstr(records[0].line, "best_streak") && !strstr(records[0].line, "\"won\""), + "no multiplayer field: %s", records[0].line); + CHECK(line_number_of(records[0].line, "played_at") >= record.started_at, "when it ended"); + /* (once: the process's end after it adds nothing) */ + halocustoms_stats_map_ended(); + campaign_blow(CAMPAIGN(GRUNT, 9), 0, KILLED); + halocustoms_stats_level_won(); + CHECK(record_count == 1 && ENEMIES(0, _enemy_grunt) == 4, "the session's end is written once, and nothing " + "counted after it"); + + /* a level left at once, a session not won, the easiest difficulty */ + session_start(_game_connection_local); + halocustoms_stats_map_ended(); + CHECK(record_count == 1, "a level left before a tick of it: no session"); + difficulty = 0; + session_start(_game_connection_local); + ticks(45); + halocustoms_stats_map_ended(); + CHECK(record_count == 2 && strstr(records[1].line, "\"kills\":0,\"deaths\":0,\"difficulty\":\"easy\",") && + strstr(records[1].line, "\"completed\":false,\"duration_seconds\":1,"), + "a short session lost on easy: %s", records[1].line); + /* a player of no name: unknown */ + players[0].name[0] = 0; + session_start(_game_connection_local); + ticks(30); + halocustoms_stats_map_ended(); + CHECK(record_count == 3 && !strcmp(line_string_of(records[2].line, "player"), "unknown"), + "a player of no name: unknown: %s", records[2].line); + wcscpy(players[0].name, L"Tester"); + /* the main menu and a multiplayer game are no sessions */ + scenario.type = _scenario_type_main_menu; + game_engine = NULL; + halocustoms_stats_game_started(); + ticks(40); + campaign_blow(CAMPAIGN(GRUNT, 7), 0, KILLED); + halocustoms_stats_map_ended(); + multiplayer_start(); + ticks(40); + campaign_blow(CAMPAIGN(GRUNT, 8), 0, KILLED); + halocustoms_stats_level_won(); + halocustoms_stats_map_ended(); + CHECK(record_count == 3 && ENEMIES(0, _enemy_grunt) == 0, "the main menu, a multiplayer game: no session"); + } + else CASE("coop") + { + /* a co-op client: the host's word of each kill, which comes twice */ + session_start(_game_connection_network_client); + CHECK(record.kind == _record_coop, "a co-op level's session: %d", record.kind); + local_count = 1; + players[0].unit_index = 6000; + ticks(30); + halocustoms_stats_death_told(CAMPAIGN(ELITE, 0), TRUE, PLAYER(0), TEAM_PLAYER, FALSE); + objects[CAMPAIGN(ELITE, 0)].object.damage_flags |= FLAG(_object_dead_bit); + halocustoms_stats_death_told(CAMPAIGN(ELITE, 0), TRUE, PLAYER(0), TEAM_PLAYER, FALSE); + CHECK(ENEMIES(0, _enemy_elite) == 1, "a kill told twice is one: %ld", ENEMIES(0, _enemy_elite)); + /* a unit this machine lacks: another; an ally; another machine's + player's; no one's */ + objects_here[CAMPAIGN(HUNTER, 0)] = FALSE; + halocustoms_stats_death_told(CAMPAIGN(HUNTER, 0), FALSE, PLAYER(0), TEAM_PLAYER, FALSE); + halocustoms_stats_death_told(CAMPAIGN(HUNTER, 0), FALSE, PLAYER(0), TEAM_PLAYER, FALSE); + CHECK(ENEMIES(0, _enemy_other) == 1 && ENEMIES(0, _enemy_hunter) == 0, + "a unit this machine lacks: another, once: %ld", ENEMIES(0, _enemy_other)); + halocustoms_stats_death_told(CAMPAIGN(MARINE, 0), TRUE, PLAYER(0), TEAM_PLAYER, FALSE); + CHECK(record.locals[0].allies_killed == 1, "a marine, the player's team's friend: an ally: %ld", + record.locals[0].allies_killed); + halocustoms_stats_death_told(CAMPAIGN(GRUNT, 0), TRUE, PLAYER(1), TEAM_PLAYER, FALSE); + halocustoms_stats_death_told(CAMPAIGN(GRUNT, 1), TRUE, NONE, NONE, FALSE); + CHECK(ENEMIES(0, _enemy_grunt) == 0, "another machine's player's kill, no one's: none"); + /* another player of the game, killed by the local player (a + teammate); the local player's own death */ + objects[CAMPAIGN(CYBORG, 0)].unit.player_index = PLAYER(1); + halocustoms_stats_death_told(CAMPAIGN(CYBORG, 0), TRUE, PLAYER(0), TEAM_PLAYER, FALSE); + objects[CAMPAIGN(CYBORG, 1)].unit.player_index = PLAYER(0); + halocustoms_stats_death_told(CAMPAIGN(CYBORG, 1), TRUE, PLAYER(0), TEAM_PLAYER, FALSE); + CHECK(record.locals[0].allies_killed == 2 && ENEMIES(0, _enemy_other) == 1, + "a teammate killed: an ally; their own death: none: %ld", record.locals[0].allies_killed); + /* a body the blow destroyed outright: a kill of a unit this machine + has alive; none of one dead already, or one it lacks (a vehicle, for + all it can tell) */ + halocustoms_stats_death_told(CAMPAIGN(FLOOD_INFECTION, 0), TRUE, PLAYER(0), TEAM_PLAYER, TRUE); + CHECK(ENEMIES(0, _enemy_flood_infection) == 1, "an infection form destroyed outright: %ld", + ENEMIES(0, _enemy_flood_infection)); + halocustoms_stats_death_told(CAMPAIGN(ELITE, 0), TRUE, PLAYER(0), TEAM_PLAYER, TRUE); + objects[CAMPAIGN(FLOOD_COMBAT, 0)].object.damage_flags |= FLAG(_object_dead_bit); + halocustoms_stats_death_told(CAMPAIGN(FLOOD_COMBAT, 0), TRUE, PLAYER(0), TEAM_PLAYER, TRUE); + halocustoms_stats_death_told(CAMPAIGN(FLOOD_COMBAT, 0), TRUE, PLAYER(0), TEAM_PLAYER, FALSE); + objects_here[CAMPAIGN(FLOOD_COMBAT, 1)] = FALSE; + halocustoms_stats_death_told(CAMPAIGN(FLOOD_COMBAT, 1), FALSE, PLAYER(0), TEAM_PLAYER, TRUE); + halocustoms_stats_death_told(CAMPAIGN_VEHICLE, TRUE, PLAYER(0), TEAM_PLAYER, TRUE); + CHECK(ENEMIES(0, _enemy_elite) == 1 && ENEMIES(0, _enemy_flood_combat) == 0 && ENEMIES(0, _enemy_other) == 1, + "a dead body destroyed, its kill's word after, one lacked, a vehicle: none: %ld %ld %ld", + ENEMIES(0, _enemy_elite), ENEMIES(0, _enemy_flood_combat), ENEMIES(0, _enemy_other)); + /* (on a host or alone the blows count: not the host's word) */ + connection = _game_connection_network_server; + halocustoms_stats_death_told(CAMPAIGN(SENTINEL, 0), TRUE, PLAYER(0), TEAM_PLAYER, FALSE); + connection = _game_connection_network_client; + CHECK(ENEMIES(0, _enemy_sentinel) == 0, "the host's own word is not counted: %ld", ENEMIES(0, _enemy_sentinel)); + /* the host reverted: a unit killed again is another kill, though this + machine's copy of it be dead still (the host's word of its new state + not come yet) */ + halocustoms_stats_death_told(CAMPAIGN(JACKAL, 3), TRUE, PLAYER(0), TEAM_PLAYER, FALSE); + objects[CAMPAIGN(JACKAL, 3)].object.damage_flags |= FLAG(_object_dead_bit); + objects[CAMPAIGN(ELITE, 0)].object.damage_flags = 0; + halocustoms_stats_reverted(); + halocustoms_stats_death_told(CAMPAIGN(ELITE, 0), TRUE, PLAYER(0), TEAM_PLAYER, FALSE); + halocustoms_stats_death_told(CAMPAIGN(JACKAL, 3), TRUE, PLAYER(0), TEAM_PLAYER, FALSE); + CHECK(ENEMIES(0, _enemy_elite) == 2 && ENEMIES(0, _enemy_jackal) == 2, + "after the host's revert, a unit killed again, whatever its copy: %ld %ld", ENEMIES(0, _enemy_elite), + ENEMIES(0, _enemy_jackal)); + /* the host ends the round: the level won, for a client */ + player_count = 4; + ticks(30); + halocustoms_stats_round_over(); + halocustoms_stats_map_ended(); + CHECK(record_count == 1 && strstr(records[0].line, "\"mode\":\"coop\",\"map\":\"b30\",") && + strstr(records[0].line, "\"kills\":6,\"deaths\":0,") && strstr(records[0].line, "\"jackal\":2,") && + strstr(records[0].line, "\"elite\":2,") && strstr(records[0].line, "\"other\":1}") && + strstr(records[0].line, "\"allies_killed\":2,\"completed\":true,\"duration_seconds\":2,\"players\":4,"), + "the client's line: every player of the game, the level won: %s", records[0].line); + /* ... not for its host, which knows it as it wins */ + session_start(_game_connection_network_server); + ticks(30); + campaign_blow(CAMPAIGN(GRUNT, 0), 0, KILLED); + halocustoms_stats_death_told(CAMPAIGN(GRUNT, 1), TRUE, PLAYER(0), TEAM_PLAYER, FALSE); + halocustoms_stats_round_over(); + halocustoms_stats_map_ended(); + CHECK(record_count == 2 && strstr(records[1].line, "\"mode\":\"coop\",") && + strstr(records[1].line, "\"kills\":1,") && strstr(records[1].line, "\"completed\":false,"), + "the host's: its blows, the round's end no win: %s", records[1].line); + } + else CASE("split") + { + /* two local players: each one's kills, deaths and line, into their + own profile's account */ + local_count = 2; + session_start(_game_connection_local); + players[0].unit_index = 5000; + players[1].unit_index = 5001; + ticks(30); + campaign_blow(CAMPAIGN(GRUNT, 0), 0, KILLED); + campaign_blow(CAMPAIGN(JACKAL, 0), 1, KILLED); + campaign_blow(CAMPAIGN(JACKAL, 1), 1, KILLED); + players[1].unit_index = NONE; + ticks(30); + halocustoms_stats_map_ended(); + CHECK(record_count == 2 && records[0].slot == 10 && records[0].local_player == 0 && records[1].slot == 11 && + records[1].local_player == 1, "a line for each, each its profile's: %d: %d %d, %d %d", record_count, + records[0].slot, records[0].local_player, records[1].slot, records[1].local_player); + CHECK(strstr(records[0].line, "\"player\":\"Tester\",\"local_player\":0,\"kills\":1,\"deaths\":0,") && + strstr(records[1].line, "\"player\":\"Other\",\"local_player\":1,\"kills\":2,\"deaths\":1,") && + strstr(records[1].line, "\"jackal\":2,") && !strcmp(line_string_of(records[0].line, "match_key"), + line_string_of(records[1].line, "match_key")), "each one's own, the session's key the same: %s\n%s", + records[0].line, records[1].line); + /* a local player with no profile of their own: their line goes to no + account */ + active_profiles[1] = NONE; + session_start(_game_connection_local); + ticks(30); + halocustoms_stats_map_ended(); + CHECK(record_count == 4 && records[3].slot == -1 && records[3].local_player == 1, + "a player with no profile: no slot (%d)", records[3].slot); + + /* split screen in multiplayer: each local player's line, the + scoreboard the same but for whose row is "me" */ + active_profiles[1] = 1; + multiplayer_start(); + players[0].statistics.kills[0] = 3; + players[1].statistics.kills[0] = 5; + player_scores[0] = 3; + player_scores[1] = 5; + halocustoms_stats_kill(PLAYER(1), 1, TRUE, FALSE); + halocustoms_stats_kill(PLAYER(0), 2, FALSE, TRUE); + halocustoms_stats_game_over(); + CHECK(record_count == 6 && records[4].slot == 10 && records[5].slot == 11 && records[5].local_player == 1, + "a line for each local player, into their own account: %d", record_count); + CHECK(strstr(records[4].line, "\"headshots\":1,\"melee_kills\":0,") && + strstr(records[5].line, "\"headshots\":0,\"melee_kills\":1,"), "each one's kills: %s\n%s", + records[4].line, records[5].line); + CHECK(!strcmp(line_string_of(records[4].line, "scoreboard.1.name"), "Tester") && + line_number_of(records[4].line, "scoreboard.1.me") == 1 && + line_number_of(records[4].line, "scoreboard.0.me") == -1000000 && + !strcmp(line_string_of(records[5].line, "scoreboard.0.name"), "Other") && + line_number_of(records[5].line, "scoreboard.0.me") == 1 && + line_number_of(records[5].line, "scoreboard.1.me") == -1000000 && count_of(records[5].line, "\"me\"") == 1, + "\"me\" on each one's own row alone: %s\n%s", records[4].line, records[5].line); + } + else CASE("scoreboard") + { + long index; + + /* eighteen players, a team game; the local player the least scored */ + player_count = 18; + for (index = 0; index < player_count; index++) + { + swprintf(players[index].name, 12, L"P%ld", index); + player_scores[index] = 100 - index; + players[index].statistics.kills[0] = (short)(20 - index); + players[index].statistics.deaths = (short)index; + players[index].statistics.assists[0] = (short)(index % 3); + } + player_scores[0] = -5; + /* (names with a quote, a backslash, what is not ASCII, a control + character; and two of a score, by kills) */ + wcscpy(players[1].name, L"a\"b\\c"); + wcscpy(players[2].name, L"J\x00f6rg\x2605\x0007!"); + player_scores[4] = player_scores[3]; + players[4].statistics.kills[0] = 40; + variant.universal_variant.teams = TRUE; + multiplayer_start(); + halocustoms_stats_game_over(); + CHECK(record_count == 1 && strlen(records[0].line) <= HALOCUSTOMS_MAXIMUM_MATCH_LINE, + "a line of a crowded game fits: %lu bytes", (unsigned long)strlen(records[0].line)); + CHECK(strstr(records[0].line, "\"team_game\":true,\"scoreboard\":[{"), "a team game's: %s", records[0].line); + CHECK(count_of(records[0].line, "{\"name\":") == 16 && line_number_of(records[0].line, "scoreboard.15.score") == -5 && + !strcmp(line_string_of(records[0].line, "scoreboard.15.name"), "P0") && + line_number_of(records[0].line, "scoreboard.15.me") == 1 && count_of(records[0].line, "\"me\"") == 1, + "sixteen rows, the line's player's last, theirs below the sixteenth: %s", records[0].line); + CHECK(!strcmp(line_string_of(records[0].line, "scoreboard.0.name"), "a\"b\\c") && + strstr(records[0].line, "{\"name\":\"a\\\"b\\\\c\",\"team\":1,\"kills\":19,\"deaths\":1,\"assists\":1," + "\"score\":99}"), "the first, by score, its name escaped: %s", records[0].line); + CHECK(!strcmp(line_string_of(records[0].line, "scoreboard.1.name"), "J?rg?\?!") && + line_number_of(records[0].line, "scoreboard.1.team") == 0, + "what is not printable ASCII a question mark: %s", line_string_of(records[0].line, "scoreboard.1.name")); + CHECK(!strcmp(line_string_of(records[0].line, "scoreboard.2.name"), "P4") && + !strcmp(line_string_of(records[0].line, "scoreboard.3.name"), "P3"), + "of two of a score, the more kills first"); + for (index = 0; records[0].line[index]; index++) + CHECK(records[0].line[index] >= 32 && records[0].line[index] < 127, "the line is printable ASCII at %ld", + index); + /* a game without teams: no team; a player of no name: unknown */ + players[1].name[0] = 0; + variant.universal_variant.teams = FALSE; + player_count = 2; + player_scores[0] = 9; + player_scores[1] = 1; + multiplayer_start(); + halocustoms_stats_game_over(); + CHECK(record_count == 2 && strstr(records[1].line, "\"team_game\":false,\"scoreboard\":[{\"name\":\"P0\",\"team\":-1," + "\"kills\":20,\"deaths\":0,\"assists\":0,\"score\":9,\"me\":true},{\"name\":\"unknown\",\"team\":-1,"), + "in a game without teams, a team of -1: %s", records[1].line); + } + else CASE("profiles") + { + /* a map loads: player 1's profile named first as such, then every + other; each local player's in use, its slot theirs */ + active_profiles[0] = 1; + active_profiles[1] = 2; + active_profiles[2] = NONE; + local_count = 2; + multiplayer_start(); + CHECK(exit_hooks == 1, "the process's end noted, once: %d", exit_hooks); + CHECK(naming_count >= 3 && !strcmp(named[0].folder, profile_folders[1]) && + named[0].flags == (HALOCUSTOMS_PROFILE_PLAYER_ONE | HALOCUSTOMS_PROFILE_USED) && + !strcmp(named[0].name, "Ar?na"), "player 1's first, in use, its name in ASCII: %s %d %s", named[0].folder, + named[0].flags, named[0].name); + CHECK(!strcmp(named[1].folder, profile_folders[0]) && named[1].flags == 0 && + !strcmp(named[2].folder, profile_folders[2]) && named[2].flags == 0, "then the others: %s %s", + named[1].folder, named[2].folder); + CHECK(record.locals[0].slot == 11 && record.locals[1].slot == 12 && record.locals[2].slot == -1, + "each local player's slot: %ld %ld %ld", record.locals[0].slot, record.locals[1].slot, record.locals[2].slot); + /* with none active: the one last used, else the first; the first + local player's lines its, another's none */ + active_profiles[0] = NONE; + last_used_profile = 2; + CHECK(halocustoms_player_one_profile() == 2, "the one used last"); + multiplayer_start(); + CHECK(record.locals[0].slot == 12 && record.locals[1].slot == 12, "the first local player's: player 1's " + "profile, the one used last: %ld", record.locals[0].slot); + last_used_profile = 7; + CHECK(halocustoms_player_one_profile() == 0, "a last one gone: the first"); + active_profiles[1] = NONE; + multiplayer_start(); + CHECK(record.locals[0].slot == 10 && record.locals[1].slot == -1, "... the first; another local player with " + "none in use: none: %ld %ld", record.locals[0].slot, record.locals[1].slot); + active_profiles[1] = 2; + /* the rank before each local player's name, their own profile's */ + { + wchar_t name[12]; + + active_profiles[0] = 0; + active_profiles[1] = 1; + wcscpy(name, L"Chief"); + halocustoms_rank_name(0, name, 12); + CHECK(!wcscmp(name, L"P3L42 Chief"), "player 1's tag"); + halocustoms_rank_name(0, name, 12); + CHECK(!wcscmp(name, L"P3L42 Chief"), "a name tagged already: as it is"); + wcscpy(name, L"Arena Long"); + halocustoms_rank_name(1, name, 12); + CHECK(!wcscmp(name, L"L7 Arena Lo"), "player 2's own, within the eleven characters"); + active_profiles[2] = 2; + wcscpy(name, L"Third"); + halocustoms_rank_name(2, name, 12); + CHECK(!wcscmp(name, L"Third"), "a profile with no rank: the name alone"); + } + /* the rank put before the first local player's name (a join's) names + player 1's profile as player 1's; another's as in use alone */ + { + wchar_t name[12]; + + naming_count = 0; + wcscpy(name, L"Chief"); + halocustoms_rank_name(0, name, 12); + wcscpy(name, L"Arena"); + halocustoms_rank_name(1, name, 12); + CHECK(naming_count == 2 && named[0].flags == (HALOCUSTOMS_PROFILE_PLAYER_ONE | HALOCUSTOMS_PROFILE_USED) && + !strcmp(named[0].folder, profile_folders[0]) && named[1].flags == HALOCUSTOMS_PROFILE_USED, + "the rank's naming: %d, %d %d", naming_count, named[0].flags, named[1].flags); + } + /* a dedicated server names none */ + naming_count = 0; + server_mode = 1; + multiplayer_start(); + CHECK(naming_count == 0 && record.locals[0].slot == -1, "a dedicated server: no profile named: %d", naming_count); + } + else CASE("presence") + { + /* a multiplayer game: playing, its map and gametype; its players each + tick */ + memset(&presence, 0, sizeof(presence)); + multiplayer_start(); + CHECK(presence.told == 1 && presence.playing && !strcmp(presence.map, "bloodgulch") && + !strcmp(presence.gametype, "Slayer"), "a multiplayer game told as %ld, %s, %s", presence.playing, + presence.map, presence.gametype); + tick(0); + CHECK(presence.players == 3 && presence.maximum_players == -1, "the game's players told as %ld", presence.players); + /* a level session: playing, its level, no gametype */ + session_start(_game_connection_local); + CHECK(presence.told == 2 && presence.playing && !strcmp(presence.map, "a30") && + !presence.gametype[0], "a level session told as %ld, %s, %s", presence.playing, presence.map, + presence.gametype); + /* the main menu's map: the menus */ + game_engine = NULL; + scenario.type = _scenario_type_main_menu; + halocustoms_stats_game_started(); + CHECK(presence.told == 3 && !presence.playing, "the menus told as playing"); + /* (a gametype's name of other characters: a question mark each) */ + wcscpy(variant.human_readable_game_description, L"CTF\x00e9"); + multiplayer_start(); + CHECK(!strcmp(presence.gametype, "CTF?"), "a gametype's name of other characters: %s", presence.gametype); + wcscpy(variant.human_readable_game_description, L"Slayer"); + } + else CASE("rank-colour") + { + real_rgb_color color = { 0.5f, 0.5f, 0.5f }; + + /* a prestige's tier colour, in a game without teams and in co-op */ + game_engine = &engine; + variant.universal_variant.teams = FALSE; + CHECK(halocustoms_rank_color(L"P4L12 Nelly", &color) && color.red == 0.95f && color.blue == 0.12f, + "Gold's colour: %f %f", color.red, color.blue); + game_engine = NULL; + CHECK(halocustoms_rank_color(L"P10L1 Arena", &color) && color.red == 1.00f && color.green == 0.92f, + "Eternal's colour in co-op: %f", color.green); + /* none without a prestige, past the tiers, or a tag in a name's + middle */ + color.red = 0.5f; + CHECK(!halocustoms_rank_color(L"L42 Nelly", &color) && !halocustoms_rank_color(L"P11L1 Nelly", &color) && + !halocustoms_rank_color(L"P0L1 Nelly", &color) && !halocustoms_rank_color(L"Nelly P4L12", &color) && + !halocustoms_rank_color(L"P4L12", &color) && color.red == 0.5f, "a colour of no prestige's"); + /* a team game's colours are its teams' */ + game_engine = &engine; + variant.universal_variant.teams = TRUE; + CHECK(!halocustoms_rank_color(L"P4L12 Nelly", &color) && color.red == 0.5f, + "a tier's colour over a team's"); + variant.universal_variant.teams = FALSE; + /* the setting off, read again as the settings change */ + rank_skins = 0; + settings_changes++; + CHECK(!halocustoms_rank_color(L"P4L12 Nelly", &color), "a tier's colour with the setting off"); + rank_skins = 1; + settings_changes++; + CHECK(halocustoms_rank_color(L"P4L12 Nelly", &color), "the setting's change unread"); + } + else + { + return 2; + } + return 0; +} diff --git a/tools/harness/tests/halocustoms_matches.c b/tools/harness/tests/halocustoms_matches.c new file mode 100644 index 0000000..d2ac193 --- /dev/null +++ b/tools/harness/tests/halocustoms_matches.c @@ -0,0 +1,1527 @@ +/* +HALOCUSTOMS_MATCHES.C (test) + +What the HaloCustoms accounts make of the game's records +(port/linux/src/halocustoms_account.c): each profile's account in a slot of +its own, its files in its profile's folder (halocustoms.key, halocustoms.rank, +matches.jsonl and its marker), player 1's taking an older build's account +(the install's halocustoms.key, the data root's matches.jsonl and marker, and +halocustoms.rank_tag) and halocustoms.token, a profile renamed keeping its +files; the line of a match or a level session as a request has it, each +field within the site's bounds; the marker of what was reported, believed +only while the file is the one it was written for; the lines not yet +reported counted; a request's batch of them (fifty at most, a line not +finished left for later, one that is not a match passed over and said to +be, once); what a report's answer does (only the site's own passes +anything; lines refused together go one to a request); an account's state; +the device's name the link page shows; the member's page opened; and, in the +web build, what the site's answer says of who is signed in. From +test_halocustoms_matches.py's under_test.inc, over the real JSON reader and +writer (server_json.c), in a folder of the case's own. +*/ + +#include "harness.h" +#include "../../../port/linux/src/server_json.c" + +#include +#include +#include +#include +#include +#include +#include + +#include "halocustoms_account.h" +#include "halocustoms_friends.h" + +/* ---------- the platform layer, as the code under test sees it */ + +static const char *root; +static char data_root[1024], config_root[1024], save_root[1024]; + +static const char *platform_data_root(void) +{ + return data_root; +} + +static void config_folder(char *path, size_t size) +{ + snprintf(path, size, "%s/", config_root); +} + +/* an Xbox path (u:\UDATA\\) as a host path below the saves, as +xbox_files.c's has it (no separator at its end) */ +static void platform_translate_path(const char *xbox_path, char *host_path, unsigned long size) +{ + char path[1024]; + long index, length; + + snprintf(path, sizeof(path), "%s/%c/%s", save_root, xbox_path[0] | 0x20, xbox_path + 3); + for (index = 0; path[index]; index++) + { + if (path[index] == '\\') + path[index] = '/'; + } + length = (long)strlen(path); + while (length > 0 && path[length - 1] == '/') + path[--length] = 0; + snprintf(host_path, size, "%s", path); +} + +/* the log: how many lines, how many of them of a line passed over, and the +last */ +static int logged, logged_passed_over; +static char last_log[1024]; + +static void platform_log(const char *format, ...) +{ + va_list arguments; + + va_start(arguments, format); + vsnprintf(last_log, sizeof(last_log), format, arguments); + va_end(arguments); + logged++; + logged_passed_over += strstr(last_log, "passed over") != NULL; + fprintf(stderr, "%s\n", last_log); +} + +/* the browser: the page last opened */ +static char opened[512]; + +static void platform_open_url(const char *url) +{ + snprintf(opened, sizeof(opened), "%s", url); +} + +/* config.toml: halocustoms.rank_tag and halocustoms.token, and how often +they were written */ +static char rank_tag[32], token_setting[160]; +static int rank_writes, token_writes; + +static const char *config_string(const char *name) +{ + return !strcmp(name, "halocustoms.rank_tag") ? rank_tag : !strcmp(name, "halocustoms.token") ? token_setting : ""; +} + +static int config_write(const char *name, const char *value) +{ + CHECK(!strcmp(name, "halocustoms.rank_tag") || !strcmp(name, "halocustoms.token"), + "only the rank and the token are written: %s", name); + if (!strcmp(name, "halocustoms.rank_tag")) + { + snprintf(rank_tag, sizeof(rank_tag), "%s", value); + rank_writes++; + } + else + { + snprintf(token_setting, sizeof(token_setting), "%s", value); + token_writes++; + } + return 1; +} + +/* a file's bytes (malloc'd, NUL ended) and their count; NULL with none */ +static char *config_file_read(const char *path, size_t *size) +{ + FILE *file = fopen(path, "rb"); + char *bytes; + long length; + + *size = 0; + if (!file) + return NULL; + fseek(file, 0, SEEK_END); + length = ftell(file); + fseek(file, 0, SEEK_SET); + bytes = malloc((size_t)length + 1); + if (fread(bytes, 1, (size_t)length, file) != (size_t)length) + length = 0; + fclose(file); + bytes[length] = 0; + *size = (size_t)length; + return bytes; +} + +/* whether the sign-in is the site's own session (the web build) */ +static int session; + +int halocustoms_account_session(void) +{ + return session; +} + +/* the page's fetch (master_list.c's master_list_get): the answer the case +gives it, and what was asked */ +static struct +{ + int got; + int status; + const char *body; + int asked; + char url[64]; +} fetch; + +static int master_list_get(const char *url, int *status, char **body, unsigned long *size) +{ + fetch.asked++; + snprintf(fetch.url, sizeof(fetch.url), "%s", url); + *status = 0; + *body = NULL; + *size = 0; + if (!fetch.got) + return 0; + *status = fetch.status; + *size = (unsigned long)strlen(fetch.body); + *body = malloc(*size + 1); + memcpy(*body, fetch.body, *size + 1); + return 1; +} + +/* a request to the site (halocustoms_account.c's site_request): the answer +the case gives it, what was asked, and what happens meanwhile */ +static struct +{ + int got; + int status; + const char *answer; + void (*meanwhile)(void); + int asked; + char method[8], path[64], name[32], value[128]; + char body[65536]; +} request; + +static int site_request(const char *method, const char *path, const char *name, const char *value, const char *body, + int *status, char **answer, long *answer_size) +{ + request.asked++; + snprintf(request.method, sizeof(request.method), "%s", method); + snprintf(request.path, sizeof(request.path), "%s", path); + snprintf(request.name, sizeof(request.name), "%s", name ? name : ""); + snprintf(request.value, sizeof(request.value), "%s", name ? value : ""); + snprintf(request.body, sizeof(request.body), "%s", body ? body : ""); + if (request.meanwhile) + request.meanwhile(); + *status = 0; + *answer = NULL; + *answer_size = 0; + if (!request.got) + return 0; + *status = request.status; + *answer_size = (long)strlen(request.answer); + *answer = malloc((size_t)*answer_size + 1); + memcpy(*answer, request.answer, (size_t)*answer_size + 1); + return 1; +} + +/* the site the cases' accounts are of */ +static char site_address[256] = "https://halocustoms.test"; + +/* the identity master's pass as player_link.c holds it: the one shown +(the last taken, and not forgotten since), how many were taken and how often +it was forgotten; refusing, it takes none (a pass of a key no longer the +site's, or not this machine's) */ +#include "player_link.h" + +static unsigned char identity_shown[PLAYER_LINK_PASS_SIZE]; +static int identity_held, identity_takes, identity_forgets, identity_refusing; + +int player_link_identity_take(const unsigned char *pass, int size, unsigned long long *issued, + unsigned long long *expires) +{ + identity_takes++; + *issued = 0; + *expires = 0; + if (identity_refusing || size != PLAYER_LINK_PASS_SIZE || memcmp(pass, "OPCEPASS", 8)) + return 0; + memcpy(identity_shown, pass, PLAYER_LINK_PASS_SIZE); + identity_held = 1; + *issued = 1; + *expires = 2; + return 1; +} + +void player_link_identity_forget(void) +{ + identity_forgets++; + identity_held = 0; + memset(identity_shown, 0, sizeof(identity_shown)); +} + +/* the site told the presence is offline (halocustoms_account.c's friends' +section: none here, counted) */ +struct account_slot; +static int offline_told; +static void friends_offline_with(struct account_slot const *slot); + +#include "under_test.inc" + +static void friends_offline_with(struct account_slot const *slot) +{ + if (slot) + offline_told++; +} + +/* the account's thread, started: none here, the cases do its work */ +void halocustoms_account_initialize(void) +{ + account.started = 1; +} + +/* ---------- the cases' tools */ + +/* a file's path below the case's folder */ +static void path_of(const char *name, char *path, size_t size) +{ + snprintf(path, size, "%s/%s", root, name); +} + +static void file_write_bytes(const char *name, const void *bytes, size_t size) +{ + char path[1024]; + FILE *file; + + path_of(name, path, sizeof(path)); + file = fopen(path, "wb"); + CHECK(file != NULL, "%s opens for writing", path); + CHECK(fwrite(bytes, 1, size, file) == size, "%s is written", path); + fclose(file); +} + +static void file_write(const char *name, const char *text) +{ + file_write_bytes(name, text, strlen(text)); +} + +static void file_remove(const char *name) +{ + char path[1024]; + + path_of(name, path, sizeof(path)); + remove(path); +} + +/* a file's text (cut to fit) and its size, -1 without one */ +static long file_read(const char *name, char *text, size_t size) +{ + char path[1024]; + FILE *file; + size_t length; + + path_of(name, path, sizeof(path)); + text[0] = 0; + file = fopen(path, "rb"); + if (!file) + return -1; + length = fread(text, 1, size - 1, file); + fclose(file); + text[length] = 0; + return (long)length; +} + +/* a file's permission bits */ +static int file_mode(const char *name) +{ + char path[1024]; + struct stat information; + + path_of(name, path, sizeof(path)); + return stat(path, &information) == 0 ? (int)(information.st_mode & 0777) : -1; +} + +static void folder_make(const char *name) +{ + char path[1024]; + + path_of(name, path, sizeof(path)); + mkdir(path, 0700); +} + +/* the marker a file reported up to offset has, by the test's own reckoning: +the offset, and 32-bit FNV-1a of its last 256 bytes before it */ +static void marker_expected(const char *content, long offset, char *text, size_t size) +{ + long from = offset > 256 ? offset - 256 : 0, index; + uint32_t hash = 2166136261u; + + for (index = from; index < offset; index++) + { + hash ^= (unsigned char)content[index]; + hash *= 16777619u; + } + snprintf(text, size, "%ld %08lx\n", offset, (unsigned long)hash); +} + +/* how many times text has the word */ +static int count_of(const char *text, const char *word) +{ + int count = 0; + + for (text = strstr(text, word); text; text = strstr(text + 1, word)) + count++; + return count; +} + +static void account_reset(void) +{ + pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; + pthread_cond_t wake = PTHREAD_COND_INITIALIZER; + + memset(&account, 0, sizeof(account)); + account.lock = lock; + account.wake = wake; + memset(slot_files, 0, sizeof(slot_files)); +} + +/* the slot the cases' own lines are in (the case's folder's matches.jsonl), +signed in with a token (or not, "") */ +static struct match_files *lines; + +static struct account_slot *slot_ready(const char *token) +{ + struct account_slot *slot = &account.slots[0]; + + if (account.slot_count < 1) + account.slot_count = 1; + snprintf(slot->folder, sizeof(slot->folder), "%s/", root); + snprintf(slot->key, sizeof(slot->key), "%s/halocustoms.key", root); + snprintf(slot->rank, sizeof(slot->rank), "%s/halocustoms.rank", root); + snprintf(slot->token, sizeof(slot->token), "%s", token); + slot->token_from_key = token[0] != 0; + lines = &slot_files[0]; + snprintf(lines->matches, sizeof(lines->matches), "%s/matches.jsonl", root); + snprintf(lines->marker, sizeof(lines->marker), "%s/matches.jsonl.sent", root); + return slot; +} + +static void fetch_answer(int got, int status, const char *body) +{ + fetch.got = got; + fetch.status = status; + fetch.body = body; +} + +static void request_answer(int got, int status, const char *answer) +{ + request.got = got; + request.status = status; + request.answer = answer; + request.meanwhile = NULL; +} + +/* the first slot's state, with what comes with it */ +static struct halocustoms_profile shown; +static char shown_name[32], shown_code[16]; + +static int state(void) +{ + return halocustoms_account_state(0, shown_name, sizeof(shown_name), shown_code, sizeof(shown_code), &shown); +} + +static void sign_out_meanwhile(void) +{ + account.slots[0].token[0] = 0; +} + +static void rename_meanwhile(void) +{ + lines->serial++; +} + +/* a report, as the account's thread makes one */ +static void report(void) +{ + account.slots[0].upload_wanted = 0; + matches_report(0); +} + +/* the request's body, the matches' array of it read back: a member of the +nth match (its path: "kills", "enemies.grunt", "scoreboard.2.name"); -1000000 +for none (a string's into text) */ +static long body_number(int match, const char *path, char *text, size_t size) +{ + struct server_json_document document; + char key[64]; + const char *at = path, *dot; + int token; + double value = -1000000; + + if (text) + text[0] = 0; + CHECK(server_json_parse_tokens(&document, request.body, (int)strlen(request.body), 8, 8192), + "the request's body is JSON: %s", request.body); + token = server_json_element(&document, server_json_member(&document, 0, "matches"), match); + while (token >= 0 && (dot = strchr(at, '.')) != NULL) + { + snprintf(key, sizeof(key), "%.*s", (int)(dot - at), at); + token = key[0] >= '0' && key[0] <= '9' ? server_json_element(&document, token, atoi(key)) : + server_json_member(&document, token, key); + at = dot + 1; + } + if (token >= 0) + { + token = at[0] >= '0' && at[0] <= '9' ? server_json_element(&document, token, atoi(at)) : + server_json_member(&document, token, at); + if (token >= 0 && !server_json_get_number(&document, token, &value)) + { + int flag = 0; + + if (text && server_json_get_string(&document, token, text, (int)size)) + value = 0; + else if (server_json_get_boolean(&document, token, &flag)) + value = flag; + } + } + server_json_document_free(&document); + return (long)value; +} + +/* the request made of a file of lines (the first slot's), one batch */ +static long batch_of(const char *content) +{ + char *body = NULL, key[KEY_SIZE]; + long end, count; + + file_write("matches.jsonl", content); + lines_reset(lines); + lines->marker_read = 1; + count = batch_read(lines, MATCHES_PER_REQUEST, &body, &end, key); + snprintf(request.body, sizeof(request.body), "%s", body ? body : ""); + free(body); + return count; +} + +#define LINE1 "{\"match_key\":\"beavercreek-1762713000\",\"map\":\"beavercreek\",\"gametype\":\"Slayer\",\"player\":\"Tester\",\"local_player\":0,\"kills\":12,\"deaths\":3,\"assists\":2,\"best_streak\":5,\"won\":true,\"duration_seconds\":420,\"players\":4,\"played_at\":1762713000}\n" +#define LINE2 "{\"match_key\":\"hangemhigh-1762713500\",\"map\":\"hangemhigh\",\"gametype\":\"CTF\",\"kills\":2000,\"deaths\":-4,\"won\":false,\"duration_seconds\":100000,\"played_at\":951782400}\n" +#define LINE3 "{\"match_key\":\"prisoner-1762714000\",\"map\":\"prisoner\",\"gametype\":\"Oddball\",\"kills\":1,\"deaths\":1,\"won\":false}\n" +/* (LINE2 and LINE1 with other keys of the same lengths: another file of the +same size, and another match) */ +#define LINE2_OTHER "{\"match_key\":\"hangemhigh-1762713599\",\"map\":\"hangemhigh\",\"gametype\":\"CTF\",\"kills\":2000,\"deaths\":-4,\"won\":false,\"duration_seconds\":100000,\"played_at\":951782400}\n" +#define LINE1_OTHER "{\"match_key\":\"beavercreek-1762713999\",\"map\":\"beavercreek\",\"gametype\":\"Slayer\",\"player\":\"Tester\",\"local_player\":0,\"kills\":12,\"deaths\":3,\"assists\":2,\"best_streak\":5,\"won\":true,\"duration_seconds\":420,\"players\":4,\"played_at\":1762713000}\n" +#define NOT_A_MATCH "not json\n" + +/* the lines as this build's recorder writes them (halocustoms_stats.c, as +test_halocustoms_kills.py checks them): a multiplayer game's, and a +campaign level session's */ +#define MULTIPLAYER_LINE "{\"match_key\":\"bloodgulch-1762713000\",\"mode\":\"multiplayer\",\"map\":\"bloodgulch\",\"player\":\"P3L42 Chief\",\"local_player\":1,\"kills\":9,\"deaths\":2,\"gametype\":\"Team Slayer\",\"assists\":1,\"best_streak\":4,\"headshots\":3,\"melee_kills\":1,\"won\":true,\"team_game\":true,\"scoreboard\":[{\"name\":\"a\\\"b\\\\c\",\"team\":1,\"kills\":19,\"deaths\":1,\"assists\":1,\"score\":99},{\"name\":\"P3L42 Chief\",\"team\":0,\"kills\":9,\"deaths\":2,\"assists\":1,\"score\":9,\"me\":true}],\"duration_seconds\":600,\"players\":2,\"played_at\":1762713600}\n" +#define CAMPAIGN_LINE "{\"match_key\":\"a30-1762713000\",\"mode\":\"campaign\",\"map\":\"a30\",\"player\":\"Tester\",\"local_player\":0,\"kills\":8,\"deaths\":3,\"difficulty\":\"heroic\",\"enemies\":{\"grunt\":4,\"jackal\":0,\"elite\":1,\"hunter\":0,\"flood_infection\":1,\"flood_combat\":0,\"flood_carrier\":1,\"sentinel\":1,\"other\":0},\"allies_killed\":1,\"completed\":true,\"duration_seconds\":600,\"players\":1,\"played_at\":1762713600}\n" + +#define PROFILE "{\"gamertag\":null,\"name\":\"Stand In\",\"tag\":\"\\u2605L7\",\"game_tag\":\"P2L7\",\"level\":7,\"prestige\":2," \ + "\"tier\":\"Bronze\",\"xp\":12340,\"level_xp\":12000,\"next_level_xp\":null,\"kills\":1234,\"deaths\":567,\"kd\":2.18," \ + "\"games\":120,\"wins\":64,\"assists\":33,\"headshots\":9,\"melee_kills\":4,\"best_streak\":-9," \ + "\"campaign_kills\":4321,\"campaign_deaths\":12,\"coop_kills\":55,\"coop_deaths\":-6,\"missions_completed\":3," \ + "\"url\":\"https://halocustoms.test/players/stand-in\"}" +#define PAGE "This domain is parked." + +static int iso_is(unsigned long seconds, const char *expected) +{ + char text[32]; + + iso_time(seconds, text, sizeof(text)); + return !strcmp(text, expected); +} + +int main(int argc, char **argv) +{ + const char *case_name = argc > 1 ? argv[1] : ""; + + root = argc > 2 ? argv[2] : "."; + snprintf(data_root, sizeof(data_root), "%s/data", root); + snprintf(config_root, sizeof(config_root), "%s/config", root); + snprintf(save_root, sizeof(save_root), "%s/saves", root); + folder_make("data"); + folder_make("config"); + folder_make("saves"); + folder_make("saves/u"); + folder_make("saves/u/UDATA"); + account_reset(); + files_make(); + slot_ready(""); + file_remove("matches.jsonl"); + file_remove("matches.jsonl.sent"); + CASE("iso") + { + CHECK(iso_is(0, "1970-01-01T00:00:00Z"), "the epoch"); + CHECK(iso_is(951782400, "2000-02-29T00:00:00Z"), "a leap day of a century year"); + CHECK(iso_is(1735689599, "2024-12-31T23:59:59Z"), "the last second of a year"); + CHECK(iso_is(1762713000, "2025-11-09T18:30:00Z"), "a date in November"); + CHECK(iso_is(4102444800UL, "2100-01-01T00:00:00Z"), "past 2038, as an unsigned count"); + } + else CASE("recorder") + { + char text[256]; + + /* the multiplayer line this build writes: each field through, the + scoreboard whole */ + CHECK(batch_of(MULTIPLAYER_LINE) == 1, "this build's multiplayer line is a match"); + CHECK(strstr(request.body, "{\"matches\":[{\"match_key\":\"bloodgulch-1762713000\",\"mode\":\"multiplayer\"," + "\"map\":\"bloodgulch\",\"player\":\"P3L42 Chief\",\"local_player\":1,\"gametype\":\"Team Slayer\"," + "\"kills\":9,\"deaths\":2,\"assists\":1,\"best_streak\":4,\"headshots\":3,\"melee_kills\":1,\"won\":true," + "\"team_game\":true,\"scoreboard\":[{\"name\":\"a\\\"b\\\\c\",\"team\":1,\"kills\":19,\"deaths\":1," + "\"assists\":1,\"score\":99},{\"name\":\"P3L42 Chief\",\"team\":0,\"kills\":9,\"deaths\":2,\"assists\":1," + "\"score\":9,\"me\":true}],\"duration_seconds\":600,\"players\":2,\"played_at\":\"2025-11-09T18:40:00Z\"}"), + "the multiplayer match: %s", request.body); + /* the campaign line: its enemies, the kills their sum */ + CHECK(batch_of(CAMPAIGN_LINE) == 1, "this build's campaign line is a match"); + CHECK(strstr(request.body, "{\"match_key\":\"a30-1762713000\",\"mode\":\"campaign\",\"map\":\"a30\"," + "\"player\":\"Tester\",\"local_player\":0,\"enemies\":{\"grunt\":4,\"jackal\":0,\"elite\":1,\"hunter\":0," + "\"flood_infection\":1,\"flood_combat\":0,\"flood_carrier\":1,\"sentinel\":1,\"other\":0},\"kills\":8," + "\"deaths\":3,\"allies_killed\":1,\"difficulty\":\"heroic\",\"completed\":true,\"duration_seconds\":600," + "\"players\":1,\"played_at\":\"2025-11-09T18:40:00Z\"}"), "the campaign session: %s", request.body); + CHECK(!strstr(request.body, "gametype") && !strstr(request.body, "scoreboard") && !strstr(request.body, "won"), + "no multiplayer field in a level session's: %s", request.body); + /* a co-op one, every class over its bound or missing, the kills the + sum of the bounded classes, whatever the line says */ + CHECK(batch_of("{\"match_key\":\"b30-1\",\"mode\":\"coop\",\"map\":\"b30\",\"kills\":1,\"deaths\":25000," + "\"difficulty\":\"legendary\",\"enemies\":{\"grunt\":7000,\"elite\":-3,\"hunter\":2.5,\"other\":\"x\"}," + "\"allies_killed\":4000,\"completed\":1,\"players\":40}\n") == 1, "a co-op line is a match"); + CHECK(strstr(request.body, "\"mode\":\"coop\",\"map\":\"b30\",\"enemies\":{\"grunt\":5000,\"jackal\":0," + "\"elite\":0,\"hunter\":2,\"flood_infection\":0,\"flood_combat\":0,\"flood_carrier\":0,\"sentinel\":0," + "\"other\":0},\"kills\":5002,\"deaths\":20000,\"allies_killed\":1000,\"difficulty\":\"legendary\"," + "\"completed\":false,\"duration_seconds\":0,\"players\":16}"), + "every class within its bounds, the kills their sum, the rest within theirs: %s", request.body); + /* a session of more kills than the site takes: none; one of no + difficulty, or one this build does not know; a mode it does not */ + CHECK(batch_of("{\"match_key\":\"d40-1\",\"mode\":\"campaign\",\"difficulty\":\"easy\",\"enemies\":{\"grunt\":" + "5000,\"jackal\":5000,\"elite\":5000,\"hunter\":5000,\"other\":1}}\n") == 0 && + batch_of("{\"match_key\":\"d40-2\",\"mode\":\"campaign\",\"difficulty\":\"easy\",\"enemies\":{\"grunt\":" + "5000,\"jackal\":5000,\"elite\":5000,\"hunter\":5000}}\n") == 1 && strstr(request.body, "\"kills\":20000,"), + "a session of more than 20000 kills is no match; of 20000 is: %s", request.body); + CHECK(batch_of("{\"match_key\":\"d40-3\",\"mode\":\"campaign\",\"enemies\":{}}\n") == 0 && + batch_of("{\"match_key\":\"d40-4\",\"mode\":\"coop\",\"difficulty\":\"impossible\"}\n") == 0 && + batch_of("{\"match_key\":\"d40-5\",\"mode\":\"firefight\",\"difficulty\":\"easy\"}\n") == 0 && + batch_of("{\"match_key\":\"d40-6\",\"mode\":3}\n") == 0, "a session of no difficulty, a mode unknown: none"); + /* an older build's line, without a mode: a multiplayer match's, + without what it did not count */ + CHECK(batch_of(LINE3) == 1 && strstr(request.body, "{\"match_key\":\"prisoner-1762714000\",\"mode\":" + "\"multiplayer\",\"map\":\"prisoner\",\"gametype\":\"Oddball\",\"kills\":1,\"deaths\":1,\"assists\":0," + "\"best_streak\":0,\"won\":false,\"duration_seconds\":0}"), "an older line: %s", request.body); + /* a scoreboard within the site's bounds: sixteen rows of objects, + every member each, a name of printable ASCII and 32 bytes, a team of + -1 to 15, counts and a score within theirs, "me" once */ + { + static char line[HALOCUSTOMS_MAXIMUM_MATCH_LINE + 1]; + long index; + + snprintf(line, sizeof(line), "{\"match_key\":\"crowd-1\",\"players\":30,\"local_player\":9," + "\"player\":\"J\\u00f6rg\\u2605\\u0007!\",\"scoreboard\":[7,{\"name\":\"0123456789012345678901234567890123" + "456789\",\"team\":99,\"kills\":5000,\"score\":-20000000,\"me\":true},{\"team\":-5,\"me\":true," + "\"score\":20000000,\"deaths\":-1,\"name\":\"\"}"); + for (index = 0; index < 16; index++) + snprintf(line + strlen(line), sizeof(line) - strlen(line), ",{\"name\":\"r%ld\",\"score\":%ld}", index, + index * 1000); + snprintf(line + strlen(line), sizeof(line) - strlen(line), "]}\n"); + CHECK(batch_of(line) == 1, "a crowded scoreboard's line is a match: %s", line); + CHECK(body_number(0, "players", NULL, 0) == 16 && body_number(0, "local_player", NULL, 0) == 3, + "its players and local player within the site's bounds: %s", request.body); + body_number(0, "player", text, sizeof(text)); + CHECK(!strcmp(text, "J?rg?\?!"), "the player's name printable ASCII: %s", text); + CHECK(count_of(request.body, "{\"name\":") == 16 && body_number(0, "scoreboard.15.score", NULL, 0) == 13000, + "no more than sixteen rows, a row that is not an object left out: %s", request.body); + body_number(0, "scoreboard.0.name", text, sizeof(text)); + CHECK(!strcmp(text, "01234567890123456789012345678901") && body_number(0, "scoreboard.0.team", NULL, 0) == 15 && + body_number(0, "scoreboard.0.kills", NULL, 0) == 1000 && + body_number(0, "scoreboard.0.score", NULL, 0) == -10000000 && body_number(0, "scoreboard.0.me", NULL, 0) == 1, + "a row's name 32 bytes, its team 15 at most, its counts and score within the site's: %s", request.body); + body_number(0, "scoreboard.1.name", text, sizeof(text)); + CHECK(!strcmp(text, "unknown") && body_number(0, "scoreboard.1.team", NULL, 0) == -1 && + body_number(0, "scoreboard.1.deaths", NULL, 0) == 0 && body_number(0, "scoreboard.1.assists", NULL, 0) == 0 && + body_number(0, "scoreboard.1.score", NULL, 0) == 10000000 && + body_number(0, "scoreboard.1.me", NULL, 0) == -1000000 && count_of(request.body, "\"me\"") == 1, + "every member of a row, a team of -1 at least, \"me\" once: %s", request.body); + } + } + else CASE("marker") + { + static char content[2048], text[2048]; + char expected[64]; + long two = (long)(strlen(LINE1) + strlen(LINE2)); + + /* written, and read back: the offset and the hash */ + file_write("matches.jsonl", LINE1 LINE2 LINE3); + lines_reset(lines); + lines->offset = two; + marker_write(lines); + marker_expected(LINE1 LINE2 LINE3, two, expected, sizeof(expected)); + CHECK(file_read("matches.jsonl.sent", text, sizeof(text)) > 0 && !strcmp(text, expected), + "the marker: the offset and the hash of the last 256 bytes before it: '%s', not '%s'", text, expected); + lines_reset(lines); + marker_read(lines); + CHECK(lines->offset == two && pending_count(lines) == 1 && lines->offset == two, + "the marker written is believed while the file is the one: %ld", lines->offset); + + /* ... when the last line before the offset is one passed over */ + file_write("matches.jsonl", LINE1 NOT_A_MATCH LINE3); + lines_reset(lines); + lines->offset = (long)(strlen(LINE1) + strlen(NOT_A_MATCH)); + marker_write(lines); + lines_reset(lines); + marker_read(lines); + CHECK(lines->offset == (long)(strlen(LINE1) + strlen(NOT_A_MATCH)) && pending_count(lines) == 1, + "a marker after a line that is not a match is believed too: %ld", lines->offset); + + /* ... and of a file longer than the hash covers: its last 256 bytes */ + snprintf(content, sizeof(content), "%s%s%s%s", LINE1, LINE2, LINE3, LINE1_OTHER); + file_write("matches.jsonl", content); + lines_reset(lines); + lines->offset = (long)strlen(content); + marker_write(lines); + marker_expected(content, (long)strlen(content), expected, sizeof(expected)); + CHECK(file_read("matches.jsonl.sent", text, sizeof(text)) > 0 && !strcmp(text, expected) && + strlen(content) > 256, + "of a longer file, the hash of its last 256 bytes: '%s', not '%s'", text, expected); + lines_reset(lines); + marker_read(lines); + CHECK(lines->offset == (long)strlen(content), "... believed: %ld", lines->offset); + + /* a file cut short, or another of the same size: from the start */ + file_write("matches.jsonl", LINE1 LINE2 LINE3); + lines_reset(lines); + lines->offset = two; + marker_write(lines); + file_write("matches.jsonl", LINE1); + marker_read(lines); + CHECK(lines->offset == 0, "a file cut short: from the start (%ld)", lines->offset); + file_write("matches.jsonl", LINE1 LINE2_OTHER LINE3); + CHECK(strlen(LINE2_OTHER) == strlen(LINE2), "(the other file is as long)"); + marker_read(lines); + CHECK(lines->offset == 0, "another file of the same size, a line ending at the offset: from the start (%ld)", + lines->offset); + file_write("matches.jsonl", LINE1 LINE2 LINE3); + marker_read(lines); + CHECK(lines->offset == two, "(the file it was written for again: believed, %ld)", lines->offset); + + /* an offset that is not a line's end, whatever the hash */ + marker_expected(LINE1 LINE2 LINE3, two - 1, expected, sizeof(expected)); + file_write("matches.jsonl.sent", expected); + marker_read(lines); + CHECK(lines->offset == 0 && pending_count(lines) == 3, "an offset that is not a line's end: from the start"); + /* a marker of before (the last match's key), one with no hash, one + with too much after it: from the start */ + snprintf(text, sizeof(text), "%ld hangemhigh-1762713500-0\n", two); + file_write("matches.jsonl.sent", text); + marker_read(lines); + CHECK(lines->offset == 0, "a marker of before, with a key: from the start (%ld)", lines->offset); + snprintf(text, sizeof(text), "%ld\n", two); + file_write("matches.jsonl.sent", text); + marker_read(lines); + CHECK(lines->offset == 0, "a marker with no hash: from the start (%ld)", lines->offset); + marker_expected(LINE1 LINE2 LINE3, two, expected, sizeof(expected)); + snprintf(text, sizeof(text), "%.*s0\n", (int)strlen(expected) - 1, expected); + file_write("matches.jsonl.sent", text); + marker_read(lines); + CHECK(lines->offset == 0, "a marker with a ninth digit: from the start (%ld)", lines->offset); + file_write("matches.jsonl.sent", "-5 811c9dc5\n"); + marker_read(lines); + CHECK(lines->offset == 0, "a marker before the file's start: from the start (%ld)", lines->offset); + file_remove("matches.jsonl.sent"); + marker_read(lines); + CHECK(lines->offset == 0 && lines->marker_read, "no marker: from the start"); + /* (the paths are the slot's whatever is forgotten) */ + CHECK(strstr(lines->matches, "/matches.jsonl") && strstr(lines->marker, "/matches.jsonl.sent"), + "forgetting what was reported keeps the files' paths: %s", lines->matches); + } + else CASE("pending") + { + long two = (long)(strlen(LINE1) + strlen(LINE2)); + + file_write("matches.jsonl", LINE1 LINE2 LINE3 NOT_A_MATCH "{\"match_key\":\"unfinished-1\""); + lines_reset(lines); + lines->offset = two; + marker_write(lines); + CHECK(pending_count(lines) == 1 && lines->offset == two, + "after the marker, the lines that are matches: not the one that is none, nor the one with no end: %ld", + pending_count(lines)); + /* the file cut short under the marker held: all of what it has now */ + file_write("matches.jsonl", LINE1); + CHECK(pending_count(lines) == 1 && lines->offset == 0, "a file cut short: all of it, from its start (%ld from " + "%ld)", pending_count(lines), lines->offset); + /* ... and another put in its place, as long or longer */ + file_write("matches.jsonl", LINE1 LINE2 LINE3); + lines_reset(lines); + lines->offset = two; + marker_write(lines); + file_write("matches.jsonl", LINE1 LINE2_OTHER LINE3 LINE1_OTHER); + CHECK(pending_count(lines) == 4 && lines->offset == 0, "another file in its place: all of it (%ld from %ld)", + pending_count(lines), lines->offset); + file_remove("matches.jsonl"); + lines_reset(lines); + lines->offset = two; + CHECK(pending_count(lines) == 0 && lines->offset == 0, "no file: nothing, and from the start of the next"); + } + else CASE("batch") + { + char *body = NULL, key[KEY_SIZE]; + long end, count, index, long_size, first_size = 0; + char *many; + + file_write("matches.jsonl", LINE1 LINE2 NOT_A_MATCH LINE3 "{\"match_key\":\"unfinished-1\""); + lines_reset(lines); + lines->offset = (long)strlen(LINE1); + marker_write(lines); + logged_passed_over = 0; + CHECK(pending_count(lines) == 2 && logged_passed_over == 1 && strstr(last_log, "line at byte") && + strstr(last_log, "matches.jsonl is not a match: passed over"), + "the line that is not a match is said to be passed over: %d, '%s'", logged_passed_over, last_log); + count = batch_read(lines, MATCHES_PER_REQUEST, &body, &end, key); + CHECK(count == 2 && body != NULL, "the lines after the marker that are matches: %ld", count); + CHECK(end == (long)(strlen(LINE1) + strlen(LINE2) + strlen(NOT_A_MATCH) + strlen(LINE3)), + "the offset after the last complete line, the unfinished one left: %ld", end); + CHECK(!strcmp(key, "prisoner-1762714000"), "the last match's key: %s", key); + CHECK(strstr(body, "\"match_key\":\"hangemhigh-1762713500\"") && strstr(body, "\"kills\":1000") && + strstr(body, "\"deaths\":0") && strstr(body, "\"duration_seconds\":86400") && + strstr(body, "\"played_at\":\"2000-02-29T00:00:00Z\"") && strstr(body, "\"won\":false") && + !strstr(body, "beavercreek"), "the body: the site's fields, counts within its bounds: %s", body); + CHECK(pending_count(lines) == 2 && logged_passed_over == 1, "... said once, however often the line is read: %d", + logged_passed_over); + free(body); + /* a line added after one a write left unfinished is a line of its own */ + CHECK(matches_append(lines->matches, "{\"match_key\":\"after-1\",\"map\":\"m\"}"), "a line is added"); + count = batch_read(lines, MATCHES_PER_REQUEST, &body, &end, key); + CHECK(count == 3 && !strcmp(key, "after-1") && !strstr(body, "unfinished"), + "the line added after an unfinished one is a match, the unfinished one none: %ld %s", count, key); + free(body); + file_write("matches.jsonl", LINE1 LINE2 NOT_A_MATCH LINE3 "{\"match_key\":\"unfinished-1\""); + lines_reset(lines); + count = batch_read(lines, MATCHES_PER_REQUEST, &body, &end, key); + CHECK(count == 3 && strstr(body, "\"played_at\":\"2025-11-09T18:30:00Z\"") && strstr(body, "\"won\":true"), + "a time past a thousand million seconds (every one since 2001) kept whole, a game won: %s", body); + CHECK(strstr(body, "\"match_key\":\"prisoner-1762714000\"") && !strstr(body, "unfinished"), + "the last line sent, the unfinished one not: %s", body); + free(body); + /* (a line too long, passed over; sixty matches: fifty to a request) */ + many = malloc(70 * 128 + MAXIMUM_LINE + 64); + many[0] = 0; + strcat(many, "{\"match_key\":\"long-0\",\"map\":\""); + for (index = 0; index < MAXIMUM_LINE; index++) + strcat(many, "m"); + strcat(many, "\"}\n"); + long_size = (long)strlen(many); + for (index = 0; index < 60; index++) + { + char line[128]; + + snprintf(line, sizeof(line), + "{\"match_key\":\"many-%ld\",\"map\":\"m\",\"gametype\":\"g\",\"kills\":1,\"deaths\":1}\n", + index); + strcat(many, line); + if (index == 0) + first_size = (long)strlen(line); + } + file_write("matches.jsonl", many); + lines_reset(lines); + logged_passed_over = 0; + count = batch_read(lines, MATCHES_PER_REQUEST, &body, &end, key); + CHECK(count == 50 && !strcmp(key, "many-49") && logged_passed_over == 1 && count_of(body, + "\"match_key\"") == 50, + "fifty to a request, the long line passed over: %ld %s", count, key); + free(body); + lines->offset = end; + count = batch_read(lines, MATCHES_PER_REQUEST, &body, &end, key); + CHECK(count == 10 && !strcmp(key, "many-59") && end == (long)strlen(many), "the rest next: %ld %s", count, key); + free(body); + lines->offset = 0; + count = batch_read(lines, 1, &body, &end, key); + CHECK(count == 1 && !strcmp(key, "many-0") && count_of(body, "\"match_key\"") == 1 && + end == long_size + first_size, + "one alone, when asked for one: %ld %s, to %ld", count, key, end); + free(body); + free(many); + } + else CASE("nul") + { + /* a line with a NUL in it, one that is a match up to its NUL, and + good lines after each */ + static const char with_nul[] = "{\"match_key\":\"nul-1\",\0\"map\":\"m\"}\n"; + static const char match_then_nul[] = "{\"match_key\":\"sneaky-1\",\"map\":\"m\"}\0 and more\n"; + static char content[2048]; + char *body = NULL, key[KEY_SIZE]; + size_t size = 0; + long end, count; + + memcpy(content + size, LINE1, strlen(LINE1)); + size += strlen(LINE1); + memcpy(content + size, with_nul, sizeof(with_nul) - 1); + size += sizeof(with_nul) - 1; + memcpy(content + size, LINE2, strlen(LINE2)); + size += strlen(LINE2); + memcpy(content + size, match_then_nul, sizeof(match_then_nul) - 1); + size += sizeof(match_then_nul) - 1; + memcpy(content + size, LINE3, strlen(LINE3)); + size += strlen(LINE3); + file_write_bytes("matches.jsonl", content, size); + lines_reset(lines); + logged_passed_over = 0; + count = batch_read(lines, MATCHES_PER_REQUEST, &body, &end, key); + CHECK(count == 3 && strstr(body, "beavercreek-1762713000") && strstr(body, "hangemhigh-1762713500") && + strstr(body, "prisoner-1762714000"), "the line after one with a NUL is read: %ld: %s", count, + body ? body : ""); + CHECK(!strstr(body, "nul-1") && !strstr(body, "sneaky-1") && logged_passed_over == 2, + "a line with a NUL is no match, though it be one up to it: %d passed over: %s", logged_passed_over, body); + CHECK(end == (long)size && !strcmp(key, "prisoner-1762714000"), + "the lines stepped over by their bytes: to %ld of %ld", + end, (long)size); + free(body); + lines->offset = end; + marker_write(lines); + CHECK(lines->offset == (long)size && pending_count(lines) == 0, "... all of them reported: none pending from %ld", + lines->offset); + } + else CASE("line") + { + struct server_json json; + char key[KEY_SIZE], text[MAXIMUM_LINE + 64], *body = NULL; + const char *bad = "{\"match_key\":\"has space-1\",\"map\":\"m\"}"; + const char *array = "[1,2]"; + const char *no_key = "{\"map\":\"m\"}"; + const char *control = "{\"match_key\":\"tab\\tbed-1\"}"; + const char *long_map = "{\"match_key\":\"cut-1\",\"map\":\"0123456789012345678901234567890123456789012345678901234567890123456789\"," + "\"gametype\":\"a\\u0007b\",\"kills\":-3,\"duration_seconds\":-50,\"won\":true,\"played_at\":-7}"; + long end, count, length; + + server_json_begin(&json); + server_json_array_begin(&json, NULL); + CHECK(!match_line_take(&json, bad, (long)strlen(bad), key), "a key with a space is no match"); + CHECK(!match_line_take(&json, array, (long)strlen(array), key), "an array is no match"); + CHECK(!match_line_take(&json, no_key, (long)strlen(no_key), key), "an object with no key is no match"); + CHECK(!match_line_take(&json, control, (long)strlen(control), key), + "a key with a control character is no match"); + /* the key: 64 characters at most */ + snprintf(text, sizeof(text), "{\"match_key\":\"%.64s\"}", + "k234567890123456789012345678901234567890123456789012345678901234567890"); + CHECK(match_line_take(NULL, text, (long)strlen(text), key) && strlen(key) == 64, "a key of 64 characters: %s", + key); + snprintf(text, sizeof(text), "{\"match_key\":\"%.65s\"}", + "k234567890123456789012345678901234567890123456789012345678901234567890"); + CHECK(!match_line_take(NULL, text, (long)strlen(text), key) && !key[0], "a key of 65 is no match: %s", key); + CHECK(!strcmp(server_json_text(&json), "["), "none of them is in the request: %s", server_json_text(&json)); + CHECK(match_line_take(&json, LINE3, (long)strlen(LINE3) - 1, key) && !strcmp(key, "prisoner-1762714000"), + "a match"); + CHECK(match_line_take(&json, long_map, (long)strlen(long_map), key), "a match with a long map"); + server_json_array_end(&json); + CHECK(strstr(server_json_text(&json), "\"gametype\":\"Oddball\"") && !strstr(server_json_text(&json), + "played_at"), + "its fields, no time when the line has none (or one before 1970): %s", server_json_text(&json)); + CHECK(strstr(server_json_text(&json), + "\"map\":\"0123456789012345678901234567890123456789012345678901234567890123\"," + "\"gametype\":\"a b\",\"kills\":0,\"deaths\":0,\"assists\":0,\"best_streak\":0,\"won\":true," + "\"duration_seconds\":0}"), + "the map 64 characters of it, a control character a space, nothing below 0, and no counts of headshots or " + "melee kills when the line has none (an earlier build's): %s", server_json_text(&json)); + server_json_free(&json); + + /* a line of the most bytes is a match; of one more, none */ + length = snprintf(text, sizeof(text), "{\"match_key\":\"edge-1\",\"gametype\":\""); + memset(text + length, 'g', sizeof(text) - (size_t)length); + memcpy(text + MAXIMUM_LINE - 2, "\"}\n", 4); + CHECK((long)strlen(text) == MAXIMUM_LINE + 1, "(a line of %d bytes and its newline)", (int)MAXIMUM_LINE); + file_write("matches.jsonl", text); + lines_reset(lines); + count = batch_read(lines, MATCHES_PER_REQUEST, &body, &end, key); + CHECK(count == 1 && !strcmp(key, "edge-1") && end == MAXIMUM_LINE + 1, + "a line of the most bytes is a match: %ld", count); + free(body); + memcpy(text + MAXIMUM_LINE - 2, "g\"}\n", 5); + file_write("matches.jsonl", text); + logged_passed_over = 0; + count = batch_read(lines, MATCHES_PER_REQUEST, &body, &end, key); + CHECK(count == 0 && !body && end == MAXIMUM_LINE + 2 && logged_passed_over == 1, + "a line of one byte more is passed over: %ld, to %ld", count, end); + } + else CASE("state") + { + struct account_slot *slot = slot_ready(""); + + /* where the game signs in with a code */ + session = 0; + CHECK(state() == _halocustoms_state_none, "nothing: not signed in"); + CHECK(halocustoms_account_state(5, shown_name, sizeof(shown_name), NULL, 0, &shown) == _halocustoms_state_none && + halocustoms_account_state(-1, NULL, 0, NULL, 0, NULL) == _halocustoms_state_none, + "a slot of no profile's: none"); + slot->failed = 1; + slot->expired = 1; + CHECK(state() == _halocustoms_state_failed, "a sign-in that could not start, before one that ran out"); + slot->failed = 0; + CHECK(state() == _halocustoms_state_expired, "a sign-in that ran out"); + snprintf(slot->token, sizeof(slot->token), "token-1"); + CHECK(state() == _halocustoms_state_linking, "a token, no profile yet: signing in"); + slot->offline = 1; + CHECK(state() == _halocustoms_state_offline, "... and no answer: offline"); + slot->has_profile = 1; + snprintf(slot->profile.name, sizeof(slot->profile.name), "Member"); + slot->profile.kills = 7; + CHECK(state() == _halocustoms_state_linked && !strcmp(shown_name, "Member") && shown.kills == 7 && + !shown_code[0], + "a profile: linked, whatever the last request did, with the name and the profile"); + slot->refused = 1; + CHECK(state() == _halocustoms_state_refused && !shown_name[0] && !shown.kills, + "refused by the site: that first, and no profile shown"); + /* a sign-in in progress comes before all of it */ + slot->link_requested = 1; + CHECK(state() == _halocustoms_state_waiting && !shown_code[0], "a sign-in asked for: waiting, no code yet"); + slot->link_requested = 0; + slot->link_starting = 1; + CHECK(state() == _halocustoms_state_waiting && !shown_code[0], + "... while the site is asked for the code: still waiting (a second press starts no second one)"); + slot->link_starting = 0; + slot->waiting = 1; + snprintf(slot->code, sizeof(slot->code), "ABCD-2345"); + CHECK(state() == _halocustoms_state_waiting && !strcmp(shown_code, "ABCD-2345"), + "the code, once the site gave it"); + CHECK(account.shown_at != 0, "(asking for the state is noted, for the web build's thread)"); + /* another slot's is its own */ + account.slot_count = 2; + snprintf(account.slots[1].folder, sizeof(account.slots[1].folder), "%s/other/", root); + CHECK(halocustoms_account_state(1, NULL, 0, NULL, 0, NULL) == _halocustoms_state_none, + "another profile's account is not this one's"); + /* a slot given up (its folder another's): none */ + slot->folder[0] = 0; + CHECK(state() == _halocustoms_state_none, "a slot given up: none"); + } + else CASE("session") + { + int lines_said; + + /* the web build: who is signed in at the site, by its own session */ + session = 1; + account_reset(); + CHECK(session_state() == _halocustoms_state_none, "no thread: nothing shown as coming"); + account.started = 1; + CHECK(session_state() == _halocustoms_state_waiting, "before the site's first answer: waiting"); + + fetch_answer(1, 200, "{\"signed_in\":true,\"member\":42,\"profile\":" PROFILE ",\"token\":\"t\"}"); + session_ask(); + CHECK(fetch.asked == 1 && !strcmp(fetch.url, "../api/play/me"), "the site is asked from the page: %s", + fetch.url); + shown = account.session.profile; + CHECK(session_state() == _halocustoms_state_linked && !strcmp(shown.name, "Stand In"), + "signed in, with a profile: linked as %s", shown.name); + /* (the link token its answer gives the page: the presence's and the + answers', halocustoms_friends.h) */ + CHECK(!strcmp(account.session.token, "t"), "the session's link token not kept: %s", account.session.token); + CHECK(!strcmp(shown.game_tag, "P2L7") && !strcmp(shown.tier, "Bronze") && shown.level == 7 && + shown.prestige == 2 && shown.xp == 12340 && shown.level_xp == 12000 && shown.next_level_xp == -1 && + shown.kills == 1234 && shown.deaths == 567 && shown.games == 120 && shown.wins == 64 && + shown.assists == 33 && shown.headshots == 9 && shown.melee_kills == 4 && shown.best_streak == 0, + "the profile's numbers: no next level at the top, no count below 0 (%ld, %ld)", shown.next_level_xp, + shown.best_streak); + CHECK(shown.campaign_kills == 4321 && shown.campaign_deaths == 12 && shown.coop_kills == 55 && + shown.coop_deaths == 0 && shown.missions_completed == 3 && + !strcmp(shown.url, "https://halocustoms.test/players/stand-in"), + "the campaign's and co-op's, none below 0, and the player page: %ld %ld %s", shown.coop_kills, + shown.coop_deaths, shown.url); + CHECK(!strcmp(rank_tag, "P2L7") && rank_writes == 1, "the rank is written: %s (%d)", rank_tag, rank_writes); + CHECK(!strcmp(last_log, "HaloCustoms: the site says Stand In is signed in (P2L7)"), "... and said: %s", + last_log); + lines_said = logged; + session_ask(); + CHECK(session_state() == _halocustoms_state_linked && rank_writes == 1 && logged == lines_said, + "the same answer again: nothing written or said again (%d, %d)", rank_writes, logged); + + /* an older site's profile: none of the campaign's, no page */ + fetch_answer(1, 200, "{\"signed_in\":true,\"profile\":{\"name\":\"Old\",\"url\":\"javascript:alert(1)\"}}"); + session_ask(); + shown = account.session.profile; + CHECK(shown.campaign_kills == -1 && shown.campaign_deaths == -1 && shown.coop_kills == -1 && + shown.coop_deaths == -1 && shown.missions_completed == -1 && !shown.url[0], + "an older site's profile: -1 for each it does not give, no page that is not the web's: %s", shown.url); + + fetch_answer(1, 200, "{\"signed_in\":false}"); + session_ask(); + CHECK(session_state() == _halocustoms_state_none && !account.session.has_profile, "signed out: none"); + CHECK(!strcmp(last_log, "HaloCustoms: nobody is signed in at the site"), "said: %s", last_log); + CHECK(rank_writes == 2, "(the rank is left as it was last given)"); + CHECK(!account.session.token[0], "signed out: the token kept (%s)", account.session.token); + /* (a token that may not go into a header is none; a member signing + out has the presence told offline) */ + fetch_answer(1, 200, "{\"signed_in\":true,\"profile\":" PROFILE ",\"token\":\"t\\r\\nX-Other: 1\"}"); + session_ask(); + CHECK(session_state() == _halocustoms_state_linked && !account.session.token[0], + "a token of a line break kept: %s", account.session.token); + fetch_answer(1, 200, "{\"signed_in\":true,\"profile\":" PROFILE ",\"token\":\"t\"}"); + session_ask(); + offline_told = 0; + fetch_answer(1, 200, "{\"signed_in\":false}"); + session_ask(); + CHECK(!account.session.token[0] && offline_told == 1, + "signed out: the token kept (%s), or the presence not told offline (%d)", account.session.token, + offline_told); + fetch_answer(1, 404, PAGE); + lines_said = logged; + session_ask(); + CHECK(session_state() == _halocustoms_state_none && logged == lines_said, + "a page served without the site's API (404): none, not said again"); + + fetch_answer(0, 0, NULL); + session_ask(); + CHECK(session_state() == _halocustoms_state_offline && !strcmp(last_log, "HaloCustoms: the site did not answer"), + "no answer: offline: %s", last_log); + lines_said = logged; + fetch_answer(1, 200, PAGE); + session_ask(); + CHECK(session_state() == _halocustoms_state_offline, "a page that is not JSON, with a good status: no answer"); + CHECK(logged == lines_said + 1 && strstr(last_log, "HaloCustoms: the site gave an unexpected answer (status 200"), + "said: %s", last_log); + fetch_answer(1, 200, "{\"signed_in\":true}"); + session_ask(); + CHECK(session_state() == _halocustoms_state_offline, "signed in, it says, with no profile: no answer"); + fetch_answer(1, 200, "[{\"signed_in\":true,\"profile\":" PROFILE "}]"); + session_ask(); + CHECK(session_state() == _halocustoms_state_offline, "an array: no answer"); + fetch_answer(1, 200, "{\"profile\":" PROFILE "}"); + session_ask(); + CHECK(session_state() == _halocustoms_state_offline, "a profile, and no word of who is signed in: no answer"); + fetch_answer(1, 500, "{\"signed_in\":true,\"profile\":" PROFILE "}"); + session_ask(); + CHECK(session_state() == _halocustoms_state_offline, "the site's error: no answer"); + + fetch_answer(1, 200, "{\"signed_in\":true,\"profile\":{\"name\":\"\",\"game_tag\":\"has space\"}}"); + session_ask(); + CHECK(session_state() == _halocustoms_state_linked && !account.session.profile.name[0] && + !account.session.profile.game_tag[0] && account.session.profile.next_level_xp == -1, + "a profile of little: linked, no name, no rank that is not one"); + CHECK(!strcmp(last_log, "HaloCustoms: the site says a member is signed in (no rank)") && !rank_tag[0], + "said, and the rank cleared: %s", last_log); + } + else CASE("report") + { + static char text[2048], expected[64]; + struct account_slot *slot; + unsigned long now; + long one = (long)strlen(LINE1), two = one + (long)strlen(LINE2), three = two + (long)strlen(LINE3); + long four = three + (long)strlen(LINE1_OTHER); + + session = 0; + account_reset(); + slot = slot_ready("token-1"); + file_write("matches.jsonl", LINE1 LINE2 LINE3); + lines_reset(lines); + + /* a page that is not the site's, with a good status: nothing passes, + and it is tried once more, later */ + request_answer(1, 200, PAGE); + now = now_seconds(); + report(); + CHECK(request.asked == 1 && !strcmp(request.method, "POST") && !strcmp(request.path, "/api/play/matches") && + !strcmp(request.name, "X-HaloCustoms-Token") && !strcmp(request.value, "token-1") && + count_of(request.body, "\"match_key\"") == 3 && !strncmp(request.body, "{\"matches\":[{", 13), + "the report: the three matches, with the profile's token in its header: %s", request.body); + CHECK(lines->offset == 0 && file_read("matches.jsonl.sent", text, sizeof(text)) < 0 && slot->pending == 3, + "a page with status 200 passes nothing: %ld, marker '%s'", lines->offset, text); + CHECK(slot->upload_wanted && slot->upload_retried && slot->upload_after >= now + RETRY_SECONDS && + slot->upload_after <= now + RETRY_SECONDS + 5 && slot->offline && !slot->refused, + "... tried again in a minute, and offline meanwhile"); + report(); + CHECK(!slot->upload_wanted && lines->offset == 0, "... once: after that, at the next match or start"); + /* the site's own 200, with no count of what it recorded: nothing + passes either */ + slot->upload_retried = 0; + request_answer(1, 200, "{\"profile\":" PROFILE "}"); + report(); + CHECK(lines->offset == 0 && !slot->has_profile && slot->upload_wanted, + "a 200 that does not say what was recorded passes nothing"); + + /* a proxy's 401 is no word of the site's; the site's is */ + slot->upload_retried = 0; + slot->offline = 0; + request_answer(1, 401, PAGE); + report(); + CHECK(!slot->refused && slot->offline && lines->offset == 0, "a page with status 401: not refused, no answer"); + request_answer(1, 403, "{\"message\":\"banned\"}"); + report(); + CHECK(slot->refused && lines->offset == 0 && slot->pending == 3, "the site's 403: refused, the matches kept"); + slot->refused = 0; + request_answer(1, 401, "{\"message\":\"unknown token\"}"); + report(); + CHECK(slot->refused && lines->offset == 0, "the site's 401: refused"); + slot->refused = 0; + + /* 429, and no answer at all: later */ + slot->upload_retried = 0; + slot->offline = 0; + request_answer(1, 429, "{\"message\":\"slow down\"}"); + report(); + CHECK(lines->offset == 0 && slot->upload_wanted && slot->upload_retried && !slot->offline && !slot->refused, + "429: tried again later (the site answered: not offline)"); + slot->upload_retried = 0; + request_answer(0, 0, NULL); + report(); + CHECK(lines->offset == 0 && slot->offline && slot->upload_wanted, "no answer: later, offline"); + + /* the lines refused together: nothing passes, and they go one to a + request, no faster than one a second */ + slot->upload_retried = 1; + request_answer(1, 422, "{\"message\":\"The matches.1.map field is invalid.\"}"); + now = now_seconds(); + report(); + CHECK(lines->offset == 0 && lines->single_until == three && file_read("matches.jsonl.sent", text, sizeof(text)) < 0, + "422 for three lines: none passed (%ld), sent one at a time up to %ld", lines->offset, lines->single_until); + CHECK(slot->upload_wanted && !slot->upload_retried && slot->upload_after > now && !slot->offline, + "... the first of them next, a second later at least"); + request_answer(1, 200, "{\"recorded\":1,\"profile\":" PROFILE "}"); + now = now_seconds(); + report(); + marker_expected(LINE1 LINE2 LINE3, one, expected, sizeof(expected)); + CHECK(count_of(request.body, "\"match_key\"") == 1 && strstr(request.body, "beavercreek-1762713000") && + lines->offset == one && file_read("matches.jsonl.sent", text, sizeof(text)) > 0 && !strcmp(text, expected), + "the first alone, taken: the marker after it: '%s', not '%s'", text, expected); + CHECK(slot->has_profile && !strcmp(slot->profile.name, "Stand In") && !strcmp(slot->rank_tag, "P2L7") && + file_read("halocustoms.rank", text, sizeof(text)) > 0 && !strcmp(text, "P2L7\n") && !rank_writes && + slot->pending == 2 && slot->upload_wanted && slot->upload_after > now, + "... its answer's profile kept, the rank the profile's own (halocustoms.rank, not config.toml's), the next " + "a second later at least: '%s'", text); + request_answer(1, 422, "{\"message\":\"The matches.0.map field is invalid.\"}"); + report(); + CHECK(count_of(request.body, "\"match_key\"") == 1 && strstr(request.body, "hangemhigh-1762713500") && + lines->offset == two && strstr(last_log, "hangemhigh-1762713500") && strstr(last_log, "passed over"), + "the second alone, refused: passed over, and said with its key: %s", last_log); + request_answer(1, 200, "{\"recorded\":0}"); + report(); + CHECK(count_of(request.body, "\"match_key\"") == 1 && strstr(request.body, "prisoner-1762714000") && + lines->offset == three && lines->single_until == 0 && slot->pending == 0 && !slot->upload_wanted, + "the third alone (the site had it already): past the lines refused, a request's worth again"); + + /* ... then the next ones together again; and signed out meanwhile: + what the site took is done with, the answer's profile is not kept */ + file_write("matches.jsonl", LINE1 LINE2 LINE3 LINE1_OTHER NOT_A_MATCH LINE3); + slot->has_profile = 0; + request_answer(1, 200, "{\"recorded\":2,\"profile\":" PROFILE "}"); + request.meanwhile = sign_out_meanwhile; + report(); + CHECK(count_of(request.body, "\"match_key\"") == 2 && strstr(request.body, "beavercreek-1762713999") && + lines->offset == four + (long)strlen(NOT_A_MATCH) + (long)strlen(LINE3) && slot->pending == 0, + "the two new matches in one request, the line between them passed over: to %ld", lines->offset); + CHECK(!slot->has_profile && !slot->token[0], "signed out meanwhile: the answer's profile is nobody's now"); + /* ... nor its rank written: a sign-out clears the rank, and an answer to + the token it forgot comes after it */ + slot->rank_tag[0] = 0; + file_remove("halocustoms.rank"); + CHECK(rank_tag_write(0, "token-1", "P9L9") && !slot->rank_tag[0] && file_read("halocustoms.rank", text, + sizeof(text)) < 0, "the rank of an answer to a token no longer held is not written: '%s'", slot->rank_tag); + snprintf(slot->token, sizeof(slot->token), "token-1"); + CHECK(rank_tag_write(0, "token-1", "P9L9") && !strcmp(slot->rank_tag, "P9L9") && + file_read("halocustoms.rank", text, sizeof(text)) > 0 && !strcmp(text, "P9L9\n"), + "... of the token held, it is: '%s'", text); + CHECK(rank_tag_write(0, "token-1", "") && !slot->rank_tag[0] && file_read("halocustoms.rank", text, + sizeof(text)) < 0, "... and none deletes the file"); + request.asked = 0; + report(); + CHECK(request.asked == 0 && slot->pending == 0, "nothing left: the site is not asked"); + /* a file put in the slot's place while a report was out (a profile + renamed): its end is not marked in the new one */ + file_write("matches.jsonl", LINE1 LINE2); + lines_reset(lines); + lines->marker_read = 1; + file_remove("matches.jsonl.sent"); + request_answer(1, 200, "{\"recorded\":2}"); + request.meanwhile = rename_meanwhile; + report(); + CHECK(request.asked == 1 && lines->offset == 0 && file_read("matches.jsonl.sent", text, sizeof(text)) < 0, + "the lines of a file no longer the slot's are not marked reported: %ld", lines->offset); + /* signed out since the thread looked: nothing is sent with no token */ + lines_reset(lines); + slot->token[0] = 0; + request.asked = 0; + report(); + CHECK(request.asked == 0 && lines->offset == 0, "no token: nothing is reported, whatever there is to report"); + } + else CASE("identity") + { + int a, b, takes; + + /* player 1's profile's pass is the one shown: asked for as the + profile becomes player 1's, shown at once when it has one, and + forgotten at its sign-out */ + account_reset(); + snprintf(account.site, sizeof(account.site), "%s", site_address); + folder_make("saves/u/UDATA/AAA"); + folder_make("saves/u/UDATA/BBB"); + file_write("saves/u/UDATA/AAA/halocustoms.key", "https://halocustoms.test\ntoken-a\n"); + file_write("saves/u/UDATA/BBB/halocustoms.key", "https://halocustoms.test\ntoken-b\n"); + a = halocustoms_profile_slot("u:\\UDATA\\AAA\\", "Chief", HALOCUSTOMS_PROFILE_USED | + HALOCUSTOMS_PROFILE_PLAYER_ONE); + b = halocustoms_profile_slot("u:\\UDATA\\BBB\\", "Arena", HALOCUSTOMS_PROFILE_USED); + CHECK(a == 0 && b == 1 && account.identity_named && account.identity_slot == a && + account.slots[a].pass_wanted && !account.slots[b].pass_wanted, + "player 1's profile's pass wanted, another's not: %d %d", account.slots[a].pass_wanted, + account.slots[b].pass_wanted); + CHECK(!identity_held && identity_forgets == 1, "a pass shown before there was one"); + /* (A's pass, as the site gave it) */ + memcpy(account.slots[a].pass, "OPCEPASS", 8); + account.slots[a].pass[8] = 'A'; + account.slots[a].has_pass = 1; + account.slots[a].pass_wanted = 0; + /* (B player 1's: none shown, B's asked for; A's again: shown at once) */ + halocustoms_profile_slot("u:\\UDATA\\BBB\\", "Arena", HALOCUSTOMS_PROFILE_USED | + HALOCUSTOMS_PROFILE_PLAYER_ONE); + CHECK(account.identity_slot == b && !identity_held && account.slots[b].pass_wanted && account.slots[a].has_pass, + "another profile player 1's: its pass (none) shown"); + halocustoms_profile_slot("u:\\UDATA\\AAA\\", "Chief", HALOCUSTOMS_PROFILE_USED | + HALOCUSTOMS_PROFILE_PLAYER_ONE); + CHECK(account.identity_slot == a && identity_held && identity_shown[8] == 'A' && !account.slots[a].pass_wanted, + "player 1's again: its pass shown at once"); + /* (named again, the same: nothing taken again; another named as in + use alone: player 1's stays) */ + takes = identity_takes; + halocustoms_profile_slot("u:\\UDATA\\AAA\\", "Chief", HALOCUSTOMS_PROFILE_USED | + HALOCUSTOMS_PROFILE_PLAYER_ONE); + halocustoms_profile_slot("u:\\UDATA\\BBB\\", "Arena", HALOCUSTOMS_PROFILE_USED); + CHECK(identity_takes == takes && account.identity_slot == a && identity_held, "the same player 1 shown again"); + /* (a pass player_link.c takes no more: not shown, forgotten, asked for + again) */ + halocustoms_profile_slot("u:\\UDATA\\BBB\\", "Arena", HALOCUSTOMS_PROFILE_PLAYER_ONE); + identity_refusing = 1; + halocustoms_profile_slot("u:\\UDATA\\AAA\\", "Chief", HALOCUSTOMS_PROFILE_PLAYER_ONE); + identity_refusing = 0; + CHECK(!identity_held && !account.slots[a].has_pass && account.slots[a].pass_wanted, + "a pass player_link.c refuses kept"); + /* (signed out: its pass forgotten, and not shown) */ + memcpy(account.slots[a].pass, "OPCEPASS", 8); + account.slots[a].has_pass = 1; + halocustoms_profile_slot("u:\\UDATA\\BBB\\", "Arena", HALOCUSTOMS_PROFILE_PLAYER_ONE); + halocustoms_profile_slot("u:\\UDATA\\AAA\\", "Chief", HALOCUSTOMS_PROFILE_PLAYER_ONE); + CHECK(identity_held, "the pass not shown again"); + halocustoms_sign_out(a); + CHECK(!account.slots[a].has_pass && !identity_held, "a pass kept, or shown, after its sign-out"); + /* (another's sign-out: player 1's shown still) */ + memcpy(account.slots[a].pass, "OPCEPASS", 8); + account.slots[a].has_pass = 1; + halocustoms_profile_slot("u:\\UDATA\\BBB\\", "Arena", HALOCUSTOMS_PROFILE_PLAYER_ONE); + halocustoms_profile_slot("u:\\UDATA\\AAA\\", "Chief", HALOCUSTOMS_PROFILE_PLAYER_ONE); + halocustoms_sign_out(b); + CHECK(identity_held && account.slots[a].has_pass, "another's sign-out forgot player 1's pass"); + } + else CASE("profiles") + { + static char text[2048]; + char folder[64]; + int slot, other, index; + + /* a profile named: its slot, its folder's files; its token, if the + site that gave it is halocustoms.url's; its rank */ + account_reset(); + snprintf(account.site, sizeof(account.site), "%s", site_address); + folder_make("saves/u/UDATA/AAA"); + folder_make("saves/u/UDATA/BBB"); + file_write("saves/u/UDATA/AAA/halocustoms.key", "https://halocustoms.test\ntoken-a\n"); + file_write("saves/u/UDATA/AAA/halocustoms.rank", "P1L3\n"); + file_write("saves/u/UDATA/BBB/halocustoms.key", "https://elsewhere.test\ntoken-b\n"); + slot = halocustoms_profile_slot("u:\\UDATA\\AAA\\", "Chief", HALOCUSTOMS_PROFILE_USED); + other = halocustoms_profile_slot("u:\\UDATA\\BBB\\", "Arena", 0); + CHECK(slot == 0 && other == 1 && account.slot_count == 2, "a slot each: %d %d", slot, other); + CHECK(strstr(account.slots[0].folder, "/saves/u/UDATA/AAA/") && strstr(account.slots[0].key, + "/saves/u/UDATA/AAA/halocustoms.key") && strstr(slot_files[0].matches, "/saves/u/UDATA/AAA/matches.jsonl") && + strstr(slot_files[0].marker, "/saves/u/UDATA/AAA/matches.jsonl.sent") && + strstr(account.slots[0].rank, "/saves/u/UDATA/AAA/halocustoms.rank") && !strcmp(account.slots[0].name, "Chief"), + "its files in its profile's folder: %s", slot_files[0].matches); + CHECK(!strcmp(account.slots[0].token, "token-a") && account.slots[0].token_from_key && + !strcmp(account.slots[0].rank_tag, "P1L3") && account.slots[0].used && account.slots[0].refresh_wanted && + account.slots[0].upload_wanted && account.slots[0].recount_wanted, + "its token and rank read, its profile asked for (in use), its lines counted and sent"); + CHECK(!account.slots[1].token[0] && !account.slots[1].used && !account.slots[1].refresh_wanted && + account.slots[1].upload_wanted, "another site's token not used; a profile not in use not asked for"); + CHECK(halocustoms_rank_tag(slot, 3, text, 16) && !strcmp(text, "P1L3") && !halocustoms_rank_tag(other, 0, text, 16) + && !halocustoms_rank_tag(7, 0, text, 16), "each slot's rank its own"); + /* the same folder again: the same slot, in use from now on */ + CHECK(halocustoms_profile_slot("u:\\UDATA\\BBB", "Arena", HALOCUSTOMS_PROFILE_USED) == 1 && + account.slot_count == 2 && account.slots[1].used && !account.slots[1].refresh_wanted, + "the same profile again: its slot, in use (no profile asked for without a token)"); + /* its key deleted (the profile deleted, and made again with its + name): its token forgotten */ + file_remove("saves/u/UDATA/AAA/halocustoms.key"); + file_remove("saves/u/UDATA/AAA/halocustoms.rank"); + CHECK(halocustoms_profile_slot("u:\\UDATA\\AAA\\", "Chief", 0) == 0 && !account.slots[0].token[0] && + !account.slots[0].token_from_key && !account.slots[0].rank_tag[0], "a key gone: its token forgotten"); + /* lines recorded for a slot go to its own file; for none, nowhere */ + halocustoms_match_record(1, 1, "{\"match_key\":\"m-1\"}"); + CHECK(file_read("saves/u/UDATA/BBB/matches.jsonl", text, sizeof(text)) > 0 && !strcmp(text, + "{\"match_key\":\"m-1\"}\n") && account.slots[1].upload_wanted && account.slots[1].recount_wanted, + "a slot's line in its profile's matches.jsonl: '%s'", text); + halocustoms_match_record(-1, 2, "{\"match_key\":\"m-2\"}"); + CHECK(strstr(last_log, "player 3 has no profile") && file_read("data/matches.jsonl", text, sizeof(text)) < 0, + "no profile: not kept, not the data root's: %s", last_log); + halocustoms_match_record(1, 0, "two\nlines"); + CHECK(strstr(last_log, "not one line") && file_read("saves/u/UDATA/BBB/matches.jsonl", text, sizeof(text)) == + (long)strlen("{\"match_key\":\"m-1\"}\n"), "a record of two lines: not kept"); + /* a slot of each folder, as many as a run serves */ + for (index = 2; index < HALOCUSTOMS_MAXIMUM_PROFILES; index++) + { + snprintf(folder, sizeof(folder), "u:\\UDATA\\P%02d\\", index); + CHECK(halocustoms_profile_slot(folder, "", 0) == index, "slot %d", index); + } + CHECK(halocustoms_profile_slot("u:\\UDATA\\ONE_MORE\\", "", 0) == -1 && strstr(last_log, "more than 32 profiles"), + "one more profile than a run serves: none: %s", last_log); + CHECK(halocustoms_profile_slot("", "", 0) == -1 && halocustoms_profile_slot(NULL, "", 0) == -1, + "no folder: none"); + } + else CASE("migration") + { + static char text[4096]; + char expected[64]; + long one = (long)strlen(LINE1); + int slot; + + /* an older build's account: halocustoms.key beside config.toml, + matches.jsonl and its marker in the data root, its rank in + config.toml: player 1's profile, the first named so, takes them */ + account_reset(); + snprintf(account.site, sizeof(account.site), "%s", site_address); + folder_make("saves/u/UDATA/AAA"); + folder_make("saves/u/UDATA/BBB"); + file_write("config/halocustoms.key", "https://halocustoms.test\ntoken-old\n"); + file_write("data/matches.jsonl", LINE1 LINE2); + marker_expected(LINE1 LINE2, one, expected, sizeof(expected)); + file_write("data/matches.jsonl.sent", expected); + snprintf(rank_tag, sizeof(rank_tag), "P4L1"); + snprintf(token_setting, sizeof(token_setting), "token-by-hand"); + CHECK(halocustoms_profile_slot("u:\\UDATA\\BBB\\", "Other", 0) == 0 && !account.slots[0].token[0] && + file_read("config/halocustoms.key", text, sizeof(text)) > 0, "(another profile named first: nothing moved)"); + slot = halocustoms_profile_slot("u:\\UDATA\\AAA\\", "Chief", + HALOCUSTOMS_PROFILE_PLAYER_ONE | HALOCUSTOMS_PROFILE_USED); + CHECK(slot == 1 && file_read("config/halocustoms.key", text, sizeof(text)) < 0 && + file_read("saves/u/UDATA/AAA/halocustoms.key", text, sizeof(text)) > 0 && + !strcmp(text, "https://halocustoms.test\ntoken-old\n") && file_mode("saves/u/UDATA/AAA/halocustoms.key") == 0600, + "the key moved to player 1's profile, its owner's alone: %o", file_mode("saves/u/UDATA/AAA/halocustoms.key")); + CHECK(!strcmp(account.slots[1].token, "token-old") && account.slots[1].token_from_key && + !account.slots[1].token_from_setting, "its token player 1's, the key's first (not halocustoms.token)"); + CHECK(file_read("data/matches.jsonl", text, sizeof(text)) < 0 && file_read("data/matches.jsonl.sent", text, + sizeof(text)) < 0 && file_read("saves/u/UDATA/AAA/matches.jsonl", text, sizeof(text)) > 0 && + !strcmp(text, LINE1 LINE2) && file_read("saves/u/UDATA/AAA/matches.jsonl.sent", text, sizeof(text)) > 0 && + !strcmp(text, expected), "the matches and their marker moved with it"); + lines_ready(&slot_files[1]); + CHECK(slot_files[1].offset == one && pending_count(&slot_files[1]) == 1, + "what was reported of them still known: from %ld", slot_files[1].offset); + CHECK(!rank_tag[0] && !strcmp(account.slots[1].rank_tag, "P4L1") && file_read("saves/u/UDATA/AAA/halocustoms.rank", + text, sizeof(text)) > 0 && !strcmp(text, "P4L1\n"), "the rank the profile's, config.toml's cleared"); + /* once a run: player 1's named again, or another named so, takes + nothing more */ + file_write("config/halocustoms.key", "https://halocustoms.test\ntoken-older\n"); + CHECK(halocustoms_profile_slot("u:\\UDATA\\BBB\\", "Other", HALOCUSTOMS_PROFILE_PLAYER_ONE) == 0 && + !account.slots[0].token[0] && file_read("config/halocustoms.key", text, sizeof(text)) > 0, + "once a run: nothing more moved, no setting's token"); + + /* the next run: a profile with its own already (its key kept, the + older left as it is; the older lines not reported added after its + own); and halocustoms.token for player 1's with no key */ + account_reset(); + snprintf(account.site, sizeof(account.site), "%s", site_address); + memset(&files, 0, sizeof(files)); + files_make(); + file_write("data/matches.jsonl", LINE1 LINE2 "{\"match_key\":\"unfinished"); + marker_expected(LINE1 LINE2, one, expected, sizeof(expected)); + file_write("data/matches.jsonl.sent", expected); + file_write("saves/u/UDATA/AAA/matches.jsonl", LINE3); + slot = halocustoms_profile_slot("u:\\UDATA\\AAA\\", "Chief", HALOCUSTOMS_PROFILE_PLAYER_ONE); + CHECK(slot == 0 && !strcmp(account.slots[0].token, "token-old") && file_read("config/halocustoms.key", text, + sizeof(text)) > 0 && !strcmp(text, "https://halocustoms.test\ntoken-older\n"), + "the profile's own key kept, the older left as it is"); + CHECK(file_read("saves/u/UDATA/AAA/matches.jsonl", text, sizeof(text)) > 0 && !strcmp(text, LINE3 LINE2) && + file_read("data/matches.jsonl", text, sizeof(text)) < 0 && file_read("data/matches.jsonl.sent", text, + sizeof(text)) < 0, "the older lines not reported after the profile's own, the unfinished left out: %s", text); + account_reset(); + snprintf(account.site, sizeof(account.site), "%s", site_address); + remove(files.key); + file_remove("saves/u/UDATA/BBB/halocustoms.key"); + slot = halocustoms_profile_slot("u:\\UDATA\\BBB\\", "Other", HALOCUSTOMS_PROFILE_PLAYER_ONE); + CHECK(slot == 0 && !strcmp(account.slots[0].token, "token-by-hand") && account.slots[0].token_from_setting && + !account.slots[0].token_from_key, "halocustoms.token, player 1's when it has no key"); + /* signed out: that token cleared from config.toml, the key and rank + deleted, the matches kept */ + file_write("saves/u/UDATA/BBB/halocustoms.rank", "L2\n"); + account.slots[0].rank_tag[0] = 'L'; + halocustoms_sign_out(0); + CHECK(!account.slots[0].token[0] && !token_setting[0] && token_writes == 1 && + file_read("saves/u/UDATA/BBB/halocustoms.rank", text, sizeof(text)) < 0, + "signed out: the setting's token cleared, the rank deleted"); + } + else CASE("rename") + { + static char text[4096]; + long one = (long)strlen(LINE1); + char expected[64]; + int slot, stale; + + /* a profile renamed: its files copied to its new folder, its slot + its new folder's, signed in still */ + account_reset(); + snprintf(account.site, sizeof(account.site), "%s", site_address); + folder_make("saves/u/UDATA/OLD"); + folder_make("saves/u/UDATA/NEW"); + file_write("saves/u/UDATA/OLD/halocustoms.key", "https://halocustoms.test\ntoken-r\n"); + file_write("saves/u/UDATA/OLD/halocustoms.rank", "L9\n"); + file_write("saves/u/UDATA/OLD/matches.jsonl", LINE1 LINE2); + marker_expected(LINE1 LINE2, one, expected, sizeof(expected)); + file_write("saves/u/UDATA/OLD/matches.jsonl.sent", expected); + /* (a slot of a profile of the new name named before: deleted since) */ + stale = halocustoms_profile_slot("u:\\UDATA\\NEW\\", "New", 0); + snprintf(account.slots[stale].token, sizeof(account.slots[stale].token), "stale"); + slot = halocustoms_profile_slot("u:\\UDATA\\OLD\\", "Old", HALOCUSTOMS_PROFILE_USED); + lines_ready(&slot_files[slot]); + CHECK(stale == 0 && slot == 1 && !strcmp(account.slots[slot].token, "token-r") && slot_files[slot].offset == one, + "(the profile signed in, a match reported)"); + halocustoms_profile_moved("u:\\UDATA\\OLD\\blam.sav", "u:\\UDATA\\NEW\\blam.sav"); + CHECK(file_read("saves/u/UDATA/NEW/halocustoms.key", text, sizeof(text)) > 0 && + !strcmp(text, "https://halocustoms.test\ntoken-r\n") && file_mode("saves/u/UDATA/NEW/halocustoms.key") == 0600 && + file_read("saves/u/UDATA/NEW/halocustoms.rank", text, sizeof(text)) > 0 && !strcmp(text, "L9\n") && + file_read("saves/u/UDATA/NEW/matches.jsonl", text, sizeof(text)) > 0 && !strcmp(text, LINE1 LINE2) && + file_read("saves/u/UDATA/NEW/matches.jsonl.sent", text, sizeof(text)) > 0 && !strcmp(text, expected), + "its files copied to the new folder, the key its owner's alone"); + CHECK(strstr(account.slots[slot].folder, "/UDATA/NEW/") && strstr(account.slots[slot].key, "/UDATA/NEW/halocustoms.key") && + strstr(slot_files[slot].matches, "/UDATA/NEW/matches.jsonl") && + strstr(slot_files[slot].marker, "/UDATA/NEW/matches.jsonl.sent") && !strcmp(account.slots[slot].token, "token-r") && + slot_files[slot].offset == one, "its slot the new folder's, signed in, what was reported kept: %s", + account.slots[slot].folder); + CHECK(!account.slots[stale].folder[0] && !account.slots[stale].token[0] && !slot_files[stale].matches[0] && + halocustoms_account_state(stale, NULL, 0, NULL, 0, NULL) == _halocustoms_state_none, + "the older slot of that folder given up, its token forgotten"); + CHECK(halocustoms_profile_slot("u:\\UDATA\\NEW\\", "Renamed", 0) == slot, "the new folder's profile: its slot"); + /* a rename with no files: nothing; to the same folder: nothing */ + halocustoms_profile_moved("u:\\UDATA\\NONE\\blam.sav", "u:\\UDATA\\NONE2\\blam.sav"); + halocustoms_profile_moved("u:\\UDATA\\NEW\\blam.sav", "u:\\UDATA\\NEW\\blam.sav"); + CHECK(halocustoms_profile_slot("u:\\UDATA\\NEW\\", "Renamed", 0) == slot, "(nothing changed)"); + } + else CASE("device") + { + char name[256]; + size_t index; + + /* the profile's name in the device's, of the characters the site + takes, 64 at most */ + device_name("Chief", name, sizeof(name)); + CHECK(!strncmp(name, "HaloCustoms on ", 15) && strstr(name, ", profile Chief") && strlen(name) <= 64, + "the computer's and the profile's names: %s", name); + device_name("Ch!e?f #1 (x),._-", name, sizeof(name)); + CHECK(strstr(name, ", profile Chef 1 (x),._-") && strlen(name) <= 64, "only the characters the site takes: %s", + name); + device_name("!?#", name, sizeof(name)); + CHECK(strstr(name, ", profile unnamed"), "a name of none of them: unnamed: %s", name); + device_name("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz", name, sizeof(name)); + CHECK(strlen(name) <= 64 && strstr(name, ", profile abcdef"), "64 at most: %lu: %s", (unsigned long)strlen(name), + name); + for (index = 0; name[index]; index++) + CHECK(device_character(name[index]), "a character the site takes at %lu: %s", (unsigned long)index, name); + } + else CASE("open") + { + struct account_slot *slot = slot_ready("token-1"); + + /* the member's own player page, when the site gave it and it is the + site's; else the site's */ + snprintf(account.site, sizeof(account.site), "%s", site_address); + halocustoms_open_site(0); + CHECK(!strcmp(opened, "https://halocustoms.test/my/player"), "signed out: the site's page: %s", opened); + slot->has_profile = 1; + snprintf(slot->profile.url, sizeof(slot->profile.url), "https://halocustoms.test/players/chief"); + halocustoms_open_site(0); + CHECK(!strcmp(opened, "https://halocustoms.test/players/chief"), "signed in: the member's: %s", opened); + snprintf(slot->profile.url, sizeof(slot->profile.url), "https://elsewhere.test/players/chief"); + halocustoms_open_site(0); + CHECK(!strcmp(opened, "https://halocustoms.test/my/player"), "a page of another site's: the site's: %s", opened); + snprintf(slot->profile.url, sizeof(slot->profile.url), "https://halocustoms.test.evil/x"); + halocustoms_open_site(0); + CHECK(!strcmp(opened, "https://halocustoms.test/my/player"), "... one only beginning so: %s", opened); + halocustoms_open_site(9); + CHECK(!strcmp(opened, "https://halocustoms.test/my/player"), "no profile: the site's: %s", opened); + } + else return 2; + return 0; +} diff --git a/tools/harness/tests/halocustoms_vectors.json b/tools/harness/tests/halocustoms_vectors.json new file mode 100644 index 0000000..b757841 --- /dev/null +++ b/tools/harness/tests/halocustoms_vectors.json @@ -0,0 +1,22 @@ +{ + "about": "A HaloCustoms pass (docs/master_protocol.md \"The pass\"), made by the site's App\\Services\\Identity\\PassIssuer from fixed seeds: the site's identity key from RFC 8032 test 2, the player key from RFC 8032 test 3. The account id is the site's (snowflake-shaped: the profile's creation in milliseconds since 2015-01-01 above 22 bits of its id).", + "site_seed_hex": "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb", + "site_public_key": "ed25519:3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", + "site_key_id": "39f713d0a644253f", + "player_seed_hex": "c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7", + "player_public_key_hex": "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", + "profile_id": 42, + "profile_created": 1794312000, + "account_id": "1569683039846400042", + "account_id_created_ms": 1794312000000, + "name": "Chief 117", + "flags": 0, + "issued": 1794313800, + "expires": 1794400200, + "pass_hex": "4f50434550415353010039f713d0a644253ffc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb91154890802515c8a2688000002a4368696566203131370000000000000000000000000000000000000000000000000000006af30e48000000006af45fc8f09d45dacc26ede90c109a89f3e7df1455962203848a32948b6fed663c57e7d2e4a678bbfbf9c38fcf7df2358fc2c43007948a4d75436e3578031d077ac61a0f", + "pass_request": { + "url": "/api/play/pass?key=fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025&ts=1794313800&sig=fe37389bff6e17d09b092535eb4f9d478850a4961a3cd773de0e23ad48e2cd3a3021bce8bdeff7a3f7bab69dbc768095a89e619a1f71e33ff1f31ccab2a89a05", + "signed_text": "opence-pass-get-1\n39f713d0a644253f\nfc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025\n1794313800", + "sig_hex": "fe37389bff6e17d09b092535eb4f9d478850a4961a3cd773de0e23ad48e2cd3a3021bce8bdeff7a3f7bab69dbc768095a89e619a1f71e33ff1f31ccab2a89a05" + } +} diff --git a/tools/harness/tests/halocustoms_welcome.c b/tools/harness/tests/halocustoms_welcome.c new file mode 100644 index 0000000..b3a76f8 --- /dev/null +++ b/tools/harness/tests/halocustoms_welcome.c @@ -0,0 +1,296 @@ +/* +HALOCUSTOMS_WELCOME.C (test) + +The main menu's HaloCustoms account panel and its welcome: when the welcome +is shown (halocustoms_account.c's halocustoms_welcome_due), the link page's +address for CREATE ACCOUNT (link_page_address: register=1 in its query), and +the panel's lines for each state of player 1's profile's account +(menu_functions.c's halocustoms_panel_read), in the words of the menus' +string list (tools/port_settings.py's HALOCUSTOMS_PANEL_WORDS, words.inc), +from test_halocustoms_welcome.py's account.inc, panel.inc, words.inc and +under_test.inc. +*/ + +#include "harness.h" + +#include +#include + +#define NUMBEROF(array) ((long)(sizeof(array) / sizeof((array)[0]))) +#define PIN(value, low, high) ((value) < (low) ? (low) : (value) > (high) ? (high) : (value)) + +#include "account.inc" +#include "words.inc" + +/* the game's usnprintf: %s of a wide string, as MSVC's wide formats have it */ +static int usnprintf(wchar_t *text, long size, const wchar_t *format, ...) +{ + wchar_t wide_format[128]; + long in, out = 0; + va_list arguments; + int written; + + for (in = 0; format[in] && out < NUMBEROF(wide_format) - 2; in++) + { + wide_format[out++] = format[in]; + if (format[in] == L'%' && format[in + 1] == L's') + wide_format[out++] = L'l'; + } + wide_format[out] = 0; + va_start(arguments, format); + written = vswprintf(text, (size_t)size, wide_format, arguments); + va_end(arguments); + return written; +} + +#define HALOCUSTOMS_PANEL_WORDS "the panel's words" + +/* the menus' words (menu_functions.c's words_get of the panel's list) */ +static void words_get(char const *list, short index, wchar_t *text, short size) +{ + (void)list; + swprintf(text, (size_t)size, L"%ls", index >= 0 && index < NUMBEROF(panel_words) ? panel_words[index] : L"?"); +} + +static int site_known = 1, server_mode; + +static int halocustoms_site_known(void) +{ + return site_known; +} + +static int halo_server_mode(void) +{ + return server_mode; +} + +#include "panel.inc" +#include "under_test.inc" + +/* ---------- the cases */ + +static int line_is(int line, const wchar_t *expected) +{ + if (wcscmp(halocustoms_panel.lines[line], expected)) + { + fprintf(stderr, "line %d: %ls, not %ls\n", line, halocustoms_panel.lines[line], expected); + return 0; + } + return 1; +} + +static void member(struct halocustoms_profile *profile) +{ + memset(profile, 0, sizeof(*profile)); + strcpy(profile->name, "Nelly"); + strcpy(profile->game_tag, "P3L42"); + strcpy(profile->tier, "Gold"); + profile->xp = 12340; + profile->next_level_xp = 15000; + profile->kills = 12345; + profile->deaths = 9876; + profile->games = 1234; + profile->wins = 678; +} + +static void case_due(void) +{ + enum + { + NONE_STATE = _halocustoms_state_none, + }; + + CHECK(halocustoms_welcome_due(1, 1, 0, 0, 0, NONE_STATE, 0), "a signed-out profile not welcomed"); + CHECK(halocustoms_welcome_due(1, 1, 0, 0, 3, _halocustoms_state_expired, 0) && + halocustoms_welcome_due(1, 1, 0, 0, 3, _halocustoms_state_failed, 0), "a sign-in that ended, not welcomed"); + /* (signed in, or a sign-in waiting, or the site not heard from yet) */ + CHECK(!halocustoms_welcome_due(1, 1, 0, 0, 0, _halocustoms_state_waiting, 0), "welcomed while signing in"); + CHECK(!halocustoms_welcome_due(1, 1, 0, 0, 0, _halocustoms_state_linked, 0) && + !halocustoms_welcome_due(1, 1, 0, 0, 0, _halocustoms_state_linking, 0) && + !halocustoms_welcome_due(1, 1, 0, 0, 0, _halocustoms_state_offline, 0) && + !halocustoms_welcome_due(1, 1, 0, 0, 0, _halocustoms_state_refused, 0), "a profile with a token welcomed"); + /* (once a run; a guest; no profile; the setting; no site; a run for no + player) */ + CHECK(!halocustoms_welcome_due(1, 1, 0, 1, 0, NONE_STATE, 0), "welcomed twice in a run"); + CHECK(!halocustoms_welcome_due(1, 1, 0, 0, 0, NONE_STATE, 1), "a guest welcomed again"); + CHECK(!halocustoms_welcome_due(1, 1, 0, 0, -1, NONE_STATE, 0), "welcomed with no profile"); + CHECK(!halocustoms_welcome_due(0, 1, 0, 0, 0, NONE_STATE, 0), "welcomed with halocustoms.welcome never"); + CHECK(!halocustoms_welcome_due(1, 0, 0, 0, 0, NONE_STATE, 0), "welcomed with no site"); + CHECK(!halocustoms_welcome_due(1, 1, 1, 0, 0, NONE_STATE, 0), "welcomed in a quiet run"); +} + +static void case_link_page(void) +{ + char opened[64]; + + link_page_address("https://h.org/link/game?code=AB-CD", 0, opened, sizeof(opened)); + CHECK(!strcmp(opened, "https://h.org/link/game?code=AB-CD"), "the plain page changed: %s", opened); + link_page_address("https://h.org/link/game?code=AB-CD", 1, opened, sizeof(opened)); + CHECK(!strcmp(opened, "https://h.org/link/game?code=AB-CD®ister=1"), "a query's: %s", opened); + link_page_address("https://h.org/link/AB-CD", 1, opened, sizeof(opened)); + CHECK(!strcmp(opened, "https://h.org/link/AB-CD?register=1"), "no query's: %s", opened); + link_page_address("https://h.org/link?c=1#code", 1, opened, sizeof(opened)); + CHECK(!strcmp(opened, "https://h.org/link?c=1®ister=1#code"), "before the fragment: %s", opened); + link_page_address("https://h.org/link#a?b", 1, opened, sizeof(opened)); + CHECK(!strcmp(opened, "https://h.org/link?register=1#a?b"), "a fragment's ? taken for a query: %s", opened); + /* (one that would not fit: none, rather than cut) */ + link_page_address("https://h.org/link/game?code=ABCDEFGHIJKLMNOPQRSTUVWX", 1, opened, 64); + CHECK(opened[0] == 0, "a page cut short: %s", opened); + link_page_address("https://h.org/link/game?code=ABCDEFGHIJKLMNOPQRSTUVW", 1, opened, 64); + CHECK(!strcmp(opened, "https://h.org/link/game?code=ABCDEFGHIJKLMNOPQRSTUVW®ister=1"), + "the longest that fits refused: %s", opened); +} + +static void case_panel_member(void) +{ + struct halocustoms_profile profile; + + member(&profile); + site_known = 1; + server_mode = 0; + halocustoms_panel_read(_halocustoms_state_linked, "Nelly", "", &profile, FALSE, -1); + CHECK(halocustoms_panel.visible, "the panel hidden"); + CHECK(line_is(0, L"Nelly") && line_is(1, L"GOLD P3L42") && line_is(2, L"12,340 / 15,000 XP") && + line_is(3, L"KILLS 12,345") && line_is(4, L"DEATHS 9,876") && line_is(5, L"MATCHES 1,234") && + line_is(6, L"WINS 678") && line_is(7, L"K/D 1.25"), "a member's lines"); + /* (the kills for each death, to the nearest hundredth: 1/3, 2/3, an + even 2; no deaths, the kills) */ + profile.kills = 1; + profile.deaths = 3; + halocustoms_panel_read(_halocustoms_state_linked, "Nelly", "", &profile, FALSE, -1); + CHECK(line_is(7, L"K/D 0.33"), "a third"); + profile.kills = 2; + halocustoms_panel_read(_halocustoms_state_linked, "Nelly", "", &profile, FALSE, -1); + CHECK(line_is(7, L"K/D 0.67"), "two thirds"); + profile.kills = 1999; + profile.deaths = 1000; + halocustoms_panel_read(_halocustoms_state_linked, "Nelly", "", &profile, FALSE, -1); + CHECK(line_is(7, L"K/D 2.00"), "1,999 for 1,000 not 2.00"); + profile.kills = 12; + profile.deaths = 0; + halocustoms_panel_read(_halocustoms_state_linked, "Nelly", "", &profile, FALSE, -1); + CHECK(line_is(7, L"K/D 12.00") && line_is(4, L"DEATHS 0"), "no deaths: the kills"); + profile.kills = 0; + halocustoms_panel_read(_halocustoms_state_linked, "Nelly", "", &profile, FALSE, -1); + CHECK(line_is(7, L"K/D 0.00"), "no kills and no deaths"); + /* (the friends online, as the site counted them: none while it has not) */ + CHECK(line_is(8, L""), "friends online shown before the site counted them"); + halocustoms_panel_read(_halocustoms_state_linked, "Nelly", "", &profile, FALSE, 0); + CHECK(line_is(8, L"0 FRIENDS ONLINE"), "no friends online"); + halocustoms_panel_read(_halocustoms_state_linked, "Nelly", "", &profile, FALSE, 1); + CHECK(line_is(8, L"1 FRIEND ONLINE"), "one friend online"); + halocustoms_panel_read(_halocustoms_state_linked, "Nelly", "", &profile, FALSE, 1234567); + CHECK(line_is(8, L"99,999 FRIENDS ONLINE"), "a count kept to five digits"); + halocustoms_panel_read(_halocustoms_state_none, "", "", &profile, FALSE, 3); + CHECK(line_is(8, L""), "friends online shown signed out"); + member(&profile); + halocustoms_panel_read(_halocustoms_state_linked, "Nelly", "", &profile, FALSE, -1); + /* (the experience toward the next level a fill draws: from the level's + start, in thousandths, to the nearest below) */ + CHECK(halocustoms_panel.xp_thousandths == 822, "12,340 of 15,000 is %ld thousandths", halocustoms_panel.xp_thousandths); + profile.level_xp = 10000; + halocustoms_panel_read(_halocustoms_state_linked, "Nelly", "", &profile, FALSE, -1); + CHECK(halocustoms_panel.xp_thousandths == 468, "2,340 of a level's 5,000 is %ld thousandths", + halocustoms_panel.xp_thousandths); + profile.xp = 9000; + halocustoms_panel_read(_halocustoms_state_linked, "Nelly", "", &profile, FALSE, -1); + CHECK(halocustoms_panel.xp_thousandths == 0, "less than the level's start is %ld", halocustoms_panel.xp_thousandths); + profile.xp = 999999999; + profile.level_xp = 0; + profile.next_level_xp = 999999998; + halocustoms_panel_read(_halocustoms_state_linked, "Nelly", "", &profile, FALSE, -1); + CHECK(halocustoms_panel.xp_thousandths == 1000, "more than the next level's is %ld", halocustoms_panel.xp_thousandths); + profile.next_level_xp = 0; + halocustoms_panel_read(_halocustoms_state_linked, "Nelly", "", &profile, FALSE, -1); + CHECK(halocustoms_panel.xp_thousandths == 0, "a next level at the level's start is %ld", + halocustoms_panel.xp_thousandths); + member(&profile); + /* (no rank yet; the top level; counts the site says are huge or below + zero) */ + profile.game_tag[0] = 0; + profile.next_level_xp = -1; + profile.kills = 2147483647; + profile.deaths = -5; + halocustoms_panel_read(_halocustoms_state_linked, "Nelly", "", &profile, FALSE, -1); + CHECK(line_is(1, L"UNRANKED") && line_is(2, L"MAX RANK"), "unranked, or at the top"); + CHECK(line_is(3, L"KILLS 99,999,999") && line_is(4, L"DEATHS 0"), "counts kept to eight digits"); + CHECK(line_is(7, L"K/D 99999999.00"), "the kills for each death of counts kept to eight digits"); + CHECK(halocustoms_panel.xp_thousandths == 1000, "the top rank's fill is %ld", halocustoms_panel.xp_thousandths); + halocustoms_panel_read(_halocustoms_state_none, "", "", &profile, FALSE, -1); + CHECK(halocustoms_panel.xp_thousandths == 0, "a signed-out profile's fill is %ld", halocustoms_panel.xp_thousandths); + /* (a name of other characters: one question mark each) */ + halocustoms_panel_read(_halocustoms_state_linked, "N\xc3\xa9lly", "", &profile, FALSE, -1); + CHECK(line_is(0, L"N?lly"), "a UTF-8 name"); +} + +static void case_panel_signed_out(void) +{ + struct halocustoms_profile profile; + + memset(&profile, 0, sizeof(profile)); + site_known = 1; + server_mode = 0; + halocustoms_panel_read(_halocustoms_state_none, "", "", &profile, FALSE, -1); + CHECK(line_is(0, L"NOT SIGNED IN") && line_is(1, L"SIGN IN: HALOCUSTOMS") && line_is(2, L"") && + line_is(7, L""), "signed out: the invitation"); + halocustoms_panel_read(_halocustoms_state_none, "", "", &profile, TRUE, -1); + CHECK(line_is(0, L"PLAYING AS A GUEST") && line_is(1, L"SIGN IN: HALOCUSTOMS") && line_is(7, L""), + "a guest: the invitation"); + halocustoms_panel_read(_halocustoms_state_expired, "", "", &profile, FALSE, -1); + CHECK(line_is(0, L"NOT SIGNED IN") && line_is(1, L"SIGN IN: HALOCUSTOMS"), "a sign-in that ran out"); + /* (a sign-in waiting: connecting, then its code) */ + halocustoms_panel_read(_halocustoms_state_waiting, "", "", &profile, FALSE, -1); + CHECK(line_is(0, L"CONNECTING...") && line_is(1, L""), "a sign-in asking for its code"); + halocustoms_panel_read(_halocustoms_state_waiting, "", "ABC-DEF", &profile, FALSE, -1); + CHECK(line_is(0, L"SIGNING IN...") && line_is(1, L"CODE ABC-DEF"), "a sign-in's code"); + halocustoms_panel_read(_halocustoms_state_linking, "", "", &profile, FALSE, -1); + CHECK(line_is(0, L"SIGNING IN...") && line_is(1, L""), "a token, its profile asked for"); + halocustoms_panel_read(_halocustoms_state_offline, "", "", &profile, FALSE, -1); + CHECK(line_is(0, L"OFFLINE"), "no answer"); + halocustoms_panel_read(_halocustoms_state_refused, "", "", &profile, FALSE, -1); + CHECK(line_is(0, L"SIGNED OUT BY THE SITE") && line_is(1, L"SIGN IN: HALOCUSTOMS"), "the token refused"); + /* (a member's lines gone with the sign-out) */ + member(&profile); + halocustoms_panel_read(_halocustoms_state_linked, "Nelly", "", &profile, FALSE, -1); + halocustoms_panel_read(_halocustoms_state_none, "", "", &profile, FALSE, -1); + CHECK(line_is(6, L"") && line_is(2, L"") && line_is(7, L""), "a member's lines left after the sign-out"); + halocustoms_panel_read(_halocustoms_state_linked, "Nelly", "", &profile, FALSE, 4); + halocustoms_panel_read(_halocustoms_state_none, "", "", &profile, TRUE, -1); + CHECK(line_is(7, L"") && line_is(8, L""), "a member's K/D or friends left for a guest"); +} + +static void case_panel_hidden(void) +{ + struct halocustoms_profile profile; + + memset(&profile, 0, sizeof(profile)); + site_known = 0; + server_mode = 0; + halocustoms_panel_read(_halocustoms_state_none, "", "", &profile, FALSE, -1); + CHECK(!halocustoms_panel.visible, "shown with no site"); + site_known = 1; + server_mode = 1; + halocustoms_panel_read(_halocustoms_state_none, "", "", &profile, FALSE, -1); + CHECK(!halocustoms_panel.visible, "shown on a dedicated server"); + server_mode = 0; + halocustoms_panel_read(_halocustoms_state_none, "", "", &profile, FALSE, -1); + CHECK(halocustoms_panel.visible, "hidden with a site"); +} + +int main(int argc, char **argv) +{ + const char *case_name = argc > 1 ? argv[1] : ""; + + CASE("due") + case_due(); + else CASE("link-page") + case_link_page(); + else CASE("panel-member") + case_panel_member(); + else CASE("panel-signed-out") + case_panel_signed_out(); + else CASE("panel-hidden") + case_panel_hidden(); + else return 2; + return 0; +} diff --git a/tools/harness/tests/hires_run_layout.c b/tools/harness/tests/hires_run_layout.c new file mode 100644 index 0000000..b808cbf --- /dev/null +++ b/tools/harness/tests/hires_run_layout.c @@ -0,0 +1,158 @@ +/* +HIRES_RUN_LAYOUT.C (test) + +A menu theme's font's runs of characters laid out with the font's own +advances (source/rasterizer/rasterizer_text.c's hires_run_layout): never +wider than the font tag's run, spaces keeping the theme's share of it, +kerning between letters, and placed by the justification in the tag's +extent: test_hires_run_layout.py takes the code. +*/ + +#include "harness.h" + +#include + +#include "layout.inc" + +enum +{ + MAXIMUM = 64, +}; + +static real pens[MAXIMUM], widths[MAXIMUM], advances[MAXIMUM], kernings[MAXIMUM], laid[MAXIMUM]; +static short count; + +/* a line as draw_string places it by the font tag's widths (letter and space +widths), its characters drawn (the spaces are not: their widths are the +gaps), and the theme font's advances (letter, space) */ +static real line(const char *text, real map_letter, real map_space, real theme_letter, real theme_space, + short justification, real *space_ratio) +{ + real pen = 100.0f; + const char *at; + + count = 0; + for (at = text; *at; at++) + { + if (*at == ' ') + { + pen += map_space; + continue; + } + pens[count] = pen; + widths[count] = map_letter; + advances[count] = theme_letter; + kernings[count] = 0.0f; + count++; + pen += map_letter; + } + *space_ratio = theme_space / map_space; + return hires_run_layout(count, pens, widths, advances, kernings, *space_ratio, justification, laid); +} + +static real run_left(void) +{ + return laid[0]; +} + +static real run_right(real scale) +{ + return laid[count - 1] + advances[count - 1] * scale; +} + +static real map_right(void) +{ + return pens[count - 1] + widths[count - 1]; +} + +int main(int argc, char **argv) +{ + const char *case_name = argc > 1 ? argv[1] : ""; + real ratio; + + /* a theme font wider than the tag's: narrowed as a whole into the tag's + run, its space keeping the theme's share (SYSTEM DEFAULT's) */ + CASE("fits") + { + real scale = line("SYSTEM DEFAULT", 9.0f, 4.0f, 11.0f, 6.0f, HIRES_TEXT_JUSTIFY_LEFT, &ratio); + real gap = laid[6] - (laid[5] + advances[5] * scale); + real width = run_right(scale) - run_left(); + + CHECK(scale < 1.0f && scale > 0.0f, "the run's scale is %f\n", scale); + CHECK(run_left() == pens[0], "the run starts at %f, not the tag's %f\n", run_left(), pens[0]); + CHECK(run_right(scale) <= map_right() + 0.001f, "the run ends at %f, past the tag's %f\n", run_right(scale), + map_right()); + CHECK(fabsf(run_right(scale) - map_right()) < 0.001f, "the run narrowed to %f, not to the tag's end %f\n", + run_right(scale), map_right()); + /* (the space: the theme's, narrowed with the run, its share of the + run the theme's own) */ + CHECK(gap >= 6.0f * scale - 0.001f, "the space between M and D is %f, not the theme's %f\n", gap, + 6.0f * scale); + CHECK(fabsf(gap / width - 6.0f / (13.0f * 11.0f + 6.0f)) < 0.001f, "the space's share is %f\n", gap / width); + /* (letters evenly apart) */ + CHECK(fabsf((laid[1] - laid[0]) - 11.0f * scale) < 0.001f && fabsf((laid[5] - laid[4]) - 11.0f * scale) < 0.001f, + "letters not evenly apart\n"); + return 0; + } + /* a theme font narrower than the tag's: its own advances, not widened, + placed in the tag's extent by the justification */ + CASE("justified") + { + real scale = line("HANG EM HIGH", 9.0f, 4.0f, 7.0f, 3.0f, HIRES_TEXT_JUSTIFY_LEFT, &ratio); + real theme_width = 10.0f * 7.0f + 2.0f * 3.0f; + + CHECK(scale == 1.0f, "a narrower run scaled by %f\n", scale); + CHECK(run_left() == pens[0] && fabsf(run_right(scale) - run_left() - theme_width) < 0.001f, + "left: from %f to %f\n", run_left(), run_right(scale)); + CHECK(fabsf((laid[4] - (laid[3] + 7.0f)) - 3.0f) < 0.001f, "the space after HANG is %f\n", + laid[4] - (laid[3] + 7.0f)); + scale = line("HANG EM HIGH", 9.0f, 4.0f, 7.0f, 3.0f, HIRES_TEXT_JUSTIFY_RIGHT, &ratio); + CHECK(fabsf(run_right(scale) - map_right()) < 0.001f, "right: ends at %f, not the tag's %f\n", + run_right(scale), map_right()); + scale = line("HANG EM HIGH", 9.0f, 4.0f, 7.0f, 3.0f, HIRES_TEXT_JUSTIFY_CENTER, &ratio); + CHECK(fabsf((run_left() - pens[0]) - (map_right() - run_right(scale))) < 0.001f, + "centred: %f before, %f after\n", run_left() - pens[0], map_right() - run_right(scale)); + return 0; + } + /* the theme's kerning between two letters drawn together */ + CASE("kerning") + { + real scale; + + line("AV", 9.0f, 4.0f, 8.0f, 3.0f, HIRES_TEXT_JUSTIFY_LEFT, &ratio); + kernings[1] = -2.0f; + scale = hires_run_layout(count, pens, widths, advances, kernings, ratio, HIRES_TEXT_JUSTIFY_LEFT, laid); + CHECK(scale == 1.0f && fabsf(laid[1] - laid[0] - 6.0f) < 0.001f, "V is %f after A\n", laid[1] - laid[0]); + return 0; + } + /* never wider than the tag's run, whatever the widths */ + CASE("bounded") + { + static const char *const texts[] = { "SYSTEM DEFAULT", "JOIN", "A", "W W W", "MULTIPLAYER GAME" }; + int text, letter, space; + + for (text = 0; text < 5; text++) + { + for (letter = 4; letter < 30; letter += 5) + { + for (space = 1; space < 12; space += 3) + { + short justification; + + for (justification = HIRES_TEXT_JUSTIFY_LEFT; justification <= HIRES_TEXT_JUSTIFY_CENTER; + justification++) + { + real scale = line(texts[text], 9.0f, 4.0f, (real)letter, (real)space, justification, &ratio); + + CHECK(run_left() >= pens[0] - 0.001f && run_right(scale) <= map_right() + 0.001f, + "%s (letters %d, spaces %d): from %f to %f, the tag's %f to %f\n", texts[text], letter, + space, run_left(), run_right(scale), pens[0], map_right()); + } + } + } + } + return 0; + } + fprintf(stderr, "unknown case: %s\n", case_name); + return 2; +} diff --git a/tools/harness/tests/master_protocol.c b/tools/harness/tests/master_protocol.c index 4db4c0f..a6f0ef9 100644 --- a/tools/harness/tests/master_protocol.c +++ b/tools/harness/tests/master_protocol.c @@ -52,6 +52,9 @@ void posix_random_bytes(void *buffer, posix_ulong size) #include "master_list.h" #include "server_master.h" +#include "halocustoms_services.h" +/* (a release's key for HaloCustoms' master, or none: test_master_protocol.py) */ +#include "release.inc" /* ---------- the fake world */ @@ -880,27 +883,46 @@ int main(int argc, char **argv) "masters read from no file"); return 0; } - /* the official master, built in: asked when masters.txt names none */ + /* the official masters, built in: asked when masters.txt names none, + HaloCustoms' first in a build given its key, then OpenCE's */ CASE("masters-default") { char urls[MASTER_LIST_MAXIMUM_MASTERS][MASTER_LIST_URL_SIZE]; unsigned char keys[MASTER_LIST_MAXIMUM_MASTERS][MASTER_LIST_KEY_SIZE]; unsigned char official[32]; +#ifdef HALOCUSTOMS_MASTER_KEY + unsigned char halocustoms[32]; + const int first = 1; +#else + const int first = 0; +#endif static const char *const none[] = { NULL, "", "# comments alone\n\n#https://master1.example.org\n", "https://master1.example.org ed25519:abc\n" }; static char named[256]; int index; key_of("89f4eda71229f11f3538679f49ef7fb0c2b407b380093f7f4fe02105b92225aa", official); +#ifdef HALOCUSTOMS_MASTER_KEY + key_of(&HALOCUSTOMS_MASTER_KEY[8], halocustoms); +#endif for (index = 0; index < (int)(sizeof(none) / sizeof(none[0])); index++) { file_masters = none[index]; memset(urls, 0, sizeof(urls)); - CHECK(master_list_read("masters.txt", urls, keys, MASTER_LIST_MAXIMUM_MASTERS) == 1 && - !strcmp(urls[0], "https://master.opencommunityedition.com"), - "masters.txt %d naming none: %s", index, urls[0]); - CHECK(!memcmp(keys[0], official, 32), "the official master's key is %s", hex_of(keys[0], 32)); + CHECK(master_list_read("masters.txt", urls, keys, MASTER_LIST_MAXIMUM_MASTERS) == first + 1 && + !strcmp(urls[first], "https://master.opencommunityedition.com"), + "masters.txt %d naming none: %s, %s", index, urls[0], urls[1]); + CHECK(!memcmp(keys[first], official, 32), "OpenCE's master's key is %s", hex_of(keys[first], 32)); +#ifdef HALOCUSTOMS_MASTER_KEY + CHECK(!strcmp(urls[0], HALOCUSTOMS_MASTER_URL) && !memcmp(keys[0], halocustoms, 32), + "HaloCustoms' master first: %s, key %s", urls[0], hex_of(keys[0], 32)); +#endif } + /* (the most kept: the first alone) */ + file_masters = NULL; + CHECK(master_list_read_defaults(urls, keys, 1) == 1 && + !strcmp(urls[0], first ? HALOCUSTOMS_MASTER_URL : "https://master.opencommunityedition.com"), + "one master asked for: %s", urls[0]); snprintf(named, sizeof(named), "https://master1.example.org %s\n", VECTOR_MASTER_PUBLIC_KEY_TEXT); file_masters = named; CHECK(master_list_read("masters.txt", urls, keys, MASTER_LIST_MAXIMUM_MASTERS) == 1 && diff --git a/tools/harness/tests/menu_anchor.c b/tools/harness/tests/menu_anchor.c new file mode 100644 index 0000000..5c8d19d --- /dev/null +++ b/tools/harness/tests/menu_anchor.c @@ -0,0 +1,862 @@ +/* +MENU_ANCHOR.C (test) + +The menus' anchors on a screen wider than their 640 columns +(port/assets/menus/README.md, "Anchors"): where a widget is placed from its +parent's edges (port/linux/game/menu_functions.c's anchor_place and +pc_menu_anchor_shift: left, right, center, stretch and fluid, nested, a +screen's own, a quarter's 320 columns) at 4:3, 16:9 and 21:9; the mouse's +targets where the widgets are drawn (source/interface/ui_widget.c's +ui_mouse_note_target, ui_mouse_note_slot_band and ui_mouse_target_at); and a +bitmap's caps as the game reads them (port/linux/game/menu_tags.c's +caps_add and pc_menu_bitmap_caps). test_menu_anchor.py takes the code +(widget.inc, anchor.inc, mouse.inc, caps.inc). +*/ + +#include "harness.h" + +#include +#include + +#include "halo_menus.h" + +/* ---------- the game's types */ + +typedef unsigned short word; +typedef union +{ + short n[2]; + struct { short x, y; }; +} point2d; +typedef union +{ + short v[4]; + struct { short y0, x0, y1, x1; }; +} rectangle2d; +#define NUMBEROF(array) ((long)(sizeof(array) / sizeof((array)[0]))) +#define MIN(a, b) ((a) < (b) ? (a) : (b)) +#define MAX(a, b) ((a) > (b) ? (a) : (b)) +#define ABS(a) ((a) < 0 ? -(a) : (a)) + +#include "widget.inc" + +/* ---------- the fake screen: a widget's tag index is its index in +instances and definitions */ + +#define WIDGETS 64 + +struct ui_widget_definition +{ + rectangle2d bounds; +}; + +static struct ui_widget_definition definitions[WIDGETS]; +static struct widget_instance instances[WIDGETS]; +static long instance_count; +/* the screen's margin at either side of its columns, and its columns */ +static long screen_margin, screen_columns = 640; +/* what the mouse's predicates say of each widget */ +static short key_buttons[WIDGETS]; +static boolean items[WIDGETS], several[WIDGETS]; + +static long halo_screen_ui_offset_current(void) +{ + return screen_margin; +} + +static long halo_screen_ui_columns_current(void) +{ + return screen_columns; +} + +static void pc_menu_widget_area(long tag_index, short *left, short *right) +{ + *left = definitions[tag_index].bounds.x0; + *right = definitions[tag_index].bounds.x1; +} + +static struct ui_widget_definition *ui_widget_definition_get(long tag_index) +{ + return &definitions[tag_index]; +} + +#include "anchor.inc" + +/* ---------- the mouse's world (ui_widget.c) */ + +enum +{ + _ui_widget_type_container, + _ui_widget_type_text_box, + _ui_widget_type_spinner_list, + _ui_widget_type_column_list +}; + +enum +{ + _widget_event_dpad_up, + _widget_event_dpad_down, + _widget_event_dpad_left, + _widget_event_dpad_right +}; + +static short ui_mouse_key_button(struct widget_instance *widget) +{ + return key_buttons[widget->definition_tag_index]; +} + +static boolean ui_mouse_list_shows_several(struct widget_instance *widget) +{ + return several[widget->definition_tag_index]; +} + +static boolean ui_mouse_widget_is_item(struct widget_instance *widget) +{ + return items[widget->definition_tag_index]; +} + +static short ui_mouse_child_index(struct widget_instance *widget) +{ + (void)widget; + return 0; +} + +static boolean widget_instance_can_receive_events(struct widget_instance *widget) +{ + (void)widget; + return TRUE; +} + +static void ui_mouse_list_directions(struct widget_instance *widget, short *back, short *forward) +{ + (void)widget; + *back = _widget_event_dpad_left; + *forward = _widget_event_dpad_right; +} + +#include "mouse.inc" + +/* ---------- the caps' world (menu_tags.c) */ + +struct bitmap_data +{ + short width, height; +}; +struct cache_file_tag_instance; +struct pc_menu_setting; +struct menu_frame_placement; + +static int problems; +static char last_problem[256]; + +static void problem(char const *file, long line, char const *message, char const *detail) +{ + (void)file; + (void)line; + problems++; + snprintf(last_problem, sizeof(last_problem), "%s %s", message, detail ? detail : ""); +} + +#include "caps.inc" + +/* ---------- the drawing's world (ui_widget.c's draw_bitmap_in_rect: the +quads it gives the rasterizer) */ + +typedef unsigned long pixel32; +typedef struct +{ + real x, y; +} real_point2d; +typedef struct +{ + real i, j; +} real_vector2d; +typedef struct +{ + real red, green, blue; +} real_rgb_color; +typedef struct +{ + real alpha, red, green, blue; +} real_argb_color; +#define NUMBER_OF_POINTS_PER_RECTANGLE 4 +#define csmemset memset + +struct dynamic_screen_vertex +{ + real_point2d position; + real_point2d texture_coordinates; + pixel32 color; +}; + +#include "draw_types.inc" + +struct tag_block +{ + long count; + void *address; +}; + +struct bitmap_group +{ + struct tag_block bitmaps; +}; + +#define TAG_BLOCK_GET_ELEMENT(block, index, type) ((type *)(block)->address + (index)) +#define _interface_bitmap_iface_map3 0 + +static struct bitmap_data plasma = { 311, 311 }; +static struct bitmap_group plasma_group = { { 1, &plasma } }; +static real_argb_color ui_plasma_effect_color; +/* the quads drawn, and whether each was the plasma's */ +static struct dynamic_screen_vertex quads[8][NUMBER_OF_POINTS_PER_RECTANGLE]; +static boolean quad_plasma[8]; +static long quad_count; + +static struct bitmap_group *bitmap_group_get(long index) +{ + (void)index; + return &plasma_group; +} + +static long interface_get_tag_index(long index) +{ + return index; +} + +static long system_milliseconds(void) +{ + return 1000; +} + +static void rasterizer_psuedo_dynamic_screen_quad_draw(struct rasterizer_dynamic_screen_geometry_parameters *parameters, + struct dynamic_screen_vertex *vertices) +{ + CHECK(quad_count < 8, "too many quads"); + memcpy(quads[quad_count], vertices, sizeof(quads[quad_count])); + quad_plasma[quad_count] = parameters->doing_plasma_effect; + quad_count++; +} + +#include "draw.inc" + +/* ---------- a screen of widgets */ + +/* a widget in parent (NULL: a screen) at x, y in it, with its area and its +anchor (menu_tags.c's: anchor_names' index and 1, 0 for none), as +ui_widget.c loads it: its offset from its parent's */ +static struct widget_instance *add(struct widget_instance *parent, short x, short y, short left, short width, + byte anchor) +{ + struct widget_instance *widget = &instances[instance_count]; + long tag = instance_count++; + + CHECK(instance_count <= WIDGETS, "too many widgets"); + memset(widget, 0, sizeof(*widget)); + definitions[tag].bounds.x0 = left; + definitions[tag].bounds.x1 = (short)(left + width); + definitions[tag].bounds.y0 = 0; + definitions[tag].bounds.y1 = 40; + key_buttons[tag] = NONE; + items[tag] = several[tag] = FALSE; + widget->definition_tag_index = tag; + widget->horizontal_offset = x; + widget->vertical_offset = y; + widget->anchor = anchor; + widget->visible = TRUE; + widget->parent = parent; + if (parent) + { + struct widget_instance **last = &parent->child; + + while (*last) + last = &(*last)->next; + *last = widget; + } + return widget; +} + +static void screen_begin(long width) +{ + instance_count = 0; + screen_margin = (width - 640) / 2; + screen_columns = 640; +} + +/* where a widget is drawn as widget_instance_render_recursive places it: the +offsets of it and the widgets it is in, and each one's anchor's shift */ +static short drawn_x(struct widget_instance *widget) +{ + short x = 0; + + for (; widget; widget = widget->parent) + x = (short)(x + widget->horizontal_offset + pc_menu_anchor_shift(widget, NULL, NULL)); + return x; +} + +/* its area's left and right as drawn */ +static void drawn_area(struct widget_instance *widget, short *left, short *right) +{ + short grow; + + pc_menu_anchor_shift(widget, &grow, NULL); + *left = (short)(drawn_x(widget) + definitions[widget->definition_tag_index].bounds.x0 - grow); + *right = (short)(drawn_x(widget) + definitions[widget->definition_tag_index].bounds.x1 + grow); +} + +/* the targets of a screen noted as widget_instance_render_recursive notes +them: each widget at its parent's offset, its own and its anchor's shift */ +static void note_tree(struct widget_instance *widget, point2d offset) +{ + struct widget_instance *child; + + offset.x = (short)(offset.x + widget->horizontal_offset); + offset.y = (short)(offset.y + widget->vertical_offset); + offset.x = (short)(offset.x + pc_menu_anchor_shift(widget, NULL, NULL)); + ui_mouse_note_target(widget, &definitions[widget->definition_tag_index], offset); + for (child = widget->child; child; child = child->next) + note_tree(child, offset); +} + +static void note_screen(struct widget_instance *screen) +{ + point2d offset = { { 0, 0 } }; + + ui_mouse_target_count = 0; + ui_mouse_noting_targets = TRUE; + note_tree(screen, offset); + ui_mouse_noting_targets = FALSE; +} + +/* the widths of the screens tried: 4:3, 16:9 (852 columns), 21:9 (1120) */ +static long const widths[] = { 640, 852, 1120 }; + +/* ---------- the cases */ + +/* the placement maths, level by level: the screen's children, a stretched +bar's, a moved panel's, an anchored screen's, a quarter's */ +static void case_place(void) +{ + long index; + + for (index = 0; index < (long)(sizeof(widths) / sizeof(*widths)); index++) + { + long margin = (widths[index] - 640) / 2; + long shift, grow; + struct anchor_level levels[4]; + + /* a screen's children: from the display's edges */ + levels[0].anchor = _anchor_none; + levels[0].offset = 0; + levels[0].left = 0; + levels[0].right = 640; + levels[1].offset = 12; + levels[1].left = 0; + levels[1].right = 176; + levels[1].anchor = _anchor_left; + anchor_place(levels, 2, margin, 640, &shift, &grow); + CHECK(shift == -margin && grow == 0, "left at %ld: %ld %ld", widths[index], shift, grow); + levels[1].anchor = _anchor_right; + anchor_place(levels, 2, margin, 640, &shift, &grow); + CHECK(shift == margin && grow == 0, "right at %ld: %ld %ld", widths[index], shift, grow); + levels[1].anchor = _anchor_center; + anchor_place(levels, 2, margin, 640, &shift, &grow); + CHECK(shift == 0 && grow == 0, "center at %ld: %ld %ld", widths[index], shift, grow); + levels[1].anchor = _anchor_none; + anchor_place(levels, 2, margin, 640, &shift, &grow); + CHECK(shift == 0 && grow == 0, "none at %ld: %ld %ld", widths[index], shift, grow); + levels[1].anchor = _anchor_stretch; + anchor_place(levels, 2, margin, 640, &shift, &grow); + CHECK(shift == 0 && grow == margin, "stretch at %ld: %ld %ld", widths[index], shift, grow); + /* (its middle at 100 of 640: 0.15625 of the way, to the nearest unit) */ + levels[1].anchor = _anchor_fluid; + anchor_place(levels, 2, margin, 640, &shift, &grow); + CHECK(shift == (margin == 106 ? -73 : margin == 240 ? -165 : 0) && grow == 0, "fluid at %ld: %ld %ld", + widths[index], shift, grow); + /* (its middle at 102: 33.79 and 76.5 units of the way, which round up) */ + levels[1].offset = 14; + anchor_place(levels, 2, margin, 640, &shift, &grow); + CHECK(shift == (margin == 106 ? -72 : margin == 240 ? -163 : 0), "fluid rounded at %ld: %ld", widths[index], + shift); + + /* a stretched bar (100 to 540): its right child at its right end, its + fluid children by their place along it */ + levels[1].anchor = _anchor_stretch; + levels[1].offset = 0; + levels[1].left = 100; + levels[1].right = 540; + levels[2].offset = 440; + levels[2].left = 0; + levels[2].right = 100; + levels[2].anchor = _anchor_right; + anchor_place(levels, 3, margin, 640, &shift, &grow); + CHECK(shift == margin && grow == 0, "a stretched bar's right child at %ld: %ld", widths[index], shift); + levels[2].anchor = _anchor_left; + anchor_place(levels, 3, margin, 640, &shift, &grow); + CHECK(shift == -margin, "a stretched bar's left child at %ld: %ld", widths[index], shift); + levels[2].anchor = _anchor_stretch; + anchor_place(levels, 3, margin, 640, &shift, &grow); + CHECK(shift == 0 && grow == margin, "a stretched bar's stretched child at %ld: %ld %ld", widths[index], shift, + grow); + /* (its middle in the bar's: 320) */ + levels[2].offset = 270; + levels[2].anchor = _anchor_fluid; + anchor_place(levels, 3, margin, 640, &shift, &grow); + CHECK(shift == 0, "a stretched bar's middle fluid child at %ld: %ld", widths[index], shift); + /* (its middle at the bar's right end, 540: all of the way) */ + levels[2].offset = 490; + anchor_place(levels, 3, margin, 640, &shift, &grow); + CHECK(shift == margin, "a stretched bar's last fluid child at %ld: %ld", widths[index], shift); + + /* a panel moved to the left: what is in it moves with it, and its + edges did not move apart, so an anchor there moves nothing more */ + levels[1].anchor = _anchor_left; + levels[1].offset = 12; + levels[1].left = 0; + levels[1].right = 176; + levels[2].offset = 100; + levels[2].anchor = _anchor_right; + anchor_place(levels, 3, margin, 640, &shift, &grow); + CHECK(shift == 0 && grow == 0, "a left panel's right child at %ld: %ld %ld", widths[index], shift, grow); + levels[2].anchor = _anchor_none; + levels[3].anchor = _anchor_stretch; + levels[3].offset = 0; + levels[3].left = 0; + levels[3].right = 50; + anchor_place(levels, 4, margin, 640, &shift, &grow); + CHECK(shift == 0 && grow == 0, "a left panel's stretched grandchild at %ld: %ld %ld", widths[index], shift, + grow); + + /* a screen anchored left moves as a whole: its children from its edges */ + levels[0].anchor = _anchor_left; + levels[1].anchor = _anchor_right; + anchor_place(levels, 1, margin, 640, &shift, &grow); + CHECK(shift == -margin, "a left screen at %ld: %ld", widths[index], shift); + anchor_place(levels, 2, margin, 640, &shift, &grow); + CHECK(shift == 0, "a left screen's right child at %ld: %ld", widths[index], shift); + /* a stretched screen spans the display, its children from its sides */ + levels[0].anchor = _anchor_stretch; + anchor_place(levels, 1, margin, 640, &shift, &grow); + CHECK(shift == 0 && grow == margin, "a stretched screen at %ld: %ld %ld", widths[index], shift, grow); + anchor_place(levels, 2, margin, 640, &shift, &grow); + CHECK(shift == margin, "a stretched screen's right child at %ld: %ld", widths[index], shift); + levels[0].anchor = _anchor_center; + anchor_place(levels, 2, margin, 640, &shift, &grow); + CHECK(shift == margin, "a centred screen's right child at %ld: %ld", widths[index], shift); + } + { + /* a quarter's 320 columns, in a view 426 wide */ + long shift, grow; + struct anchor_level levels[2]; + + levels[0].anchor = _anchor_none; + levels[0].offset = 0; + levels[0].left = 0; + levels[0].right = 320; + levels[1].anchor = _anchor_fluid; + levels[1].offset = 110; + levels[1].left = 0; + levels[1].right = 100; + anchor_place(levels, 2, 53, 320, &shift, &grow); + CHECK(shift == 0, "a quarter's middle fluid child: %ld", shift); + levels[1].anchor = _anchor_right; + anchor_place(levels, 2, 53, 320, &shift, &grow); + CHECK(shift == 53, "a quarter's right child: %ld", shift); + } +} + +/* the shift of widgets of a screen: none without anchors, or on a screen 640 +wide; the margin for an anchored widget and what is in it; nothing deeper +than the levels kept */ +static void case_shift(void) +{ + long index; + + for (index = 0; index < (long)(sizeof(widths) / sizeof(*widths)); index++) + { + long margin = (widths[index] - 640) / 2; + struct widget_instance *screen, *container, *item, *panel, *inner; + short grow, edge, left, right; + + screen_begin(widths[index]); + screen = add(NULL, 0, 0, 0, 640, 0); + container = add(screen, 20, 40, 0, 600, 0); + item = add(container, 10, 10, 0, 200, 0); + CHECK(pc_menu_anchor_shift(item, &grow, &edge) == 0 && grow == 0 && edge == 0, + "a stock item moved at %ld", widths[index]); + CHECK(drawn_x(item) == 30, "a stock item drawn at %d", drawn_x(item)); + + panel = add(screen, 12, 290, 0, 176, 3 /* right */); + inner = add(panel, 8, 8, 0, 160, 0); + CHECK(pc_menu_anchor_shift(panel, &grow, &edge) == margin && grow == 0 && edge == margin, + "a right panel at %ld", widths[index]); + /* (what is in it moves with it: its own shift is none, but its clip + takes in the margin too) */ + CHECK(pc_menu_anchor_shift(inner, &grow, &edge) == 0 && edge == margin, "a right panel's text at %ld", + widths[index]); + CHECK(drawn_x(inner) == 20 + margin, "a right panel's text drawn at %d", drawn_x(inner)); + + panel->anchor = 4 /* stretch */; + drawn_area(panel, &left, &right); + CHECK(left == 12 - margin && right == 188 + margin, "a stretched panel drawn from %d to %d", left, right); + drawn_area(inner, &left, &right); + CHECK(left == 20 && right == 180, "a stretched panel's text drawn from %d to %d", left, right); + inner->anchor = 3 /* right */; + drawn_area(inner, &left, &right); + CHECK(left == 20 + margin && right == 180 + margin, "a stretched panel's right text drawn from %d to %d", + left, right); + } + for (index = 0; index < (long)(sizeof(widths) / sizeof(*widths)); index++) + { + /* a place the game's code gives a widget as it runs (the server + browser's counts, menu_functions.c: the players at 312, the page at + 381, in the 640 columns) is anchored as one the file gives: the + players at the left, the servers at the right, the page in the middle + of a stretched panel */ + long margin = (widths[index] - 640) / 2; + struct widget_instance *screen, *stats, *players, *page, *servers; + + screen_begin(widths[index]); + screen = add(NULL, 0, 0, 0, 640, 0); + stats = add(screen, 0, 60, 0, 640, 4 /* stretch */); + players = add(stats, 301, 0, 0, 170, 2 /* left */); + page = add(stats, 466, 0, 0, 170, 0); + servers = add(stats, 460, 0, 0, 170, 3 /* right */); + players->horizontal_offset = 312; + page->horizontal_offset = 466 - 170 / 2; + CHECK(drawn_x(players) == 312 - margin, "the players' count drawn at %d", drawn_x(players)); + CHECK(drawn_x(page) == 381, "the page's count drawn at %d", drawn_x(page)); + CHECK(drawn_x(servers) == 460 + margin, "the servers' count drawn at %d", drawn_x(servers)); + /* (and fluid: by the fraction of the place given, its middle at 466) */ + page->anchor = 5 /* fluid */; + CHECK(drawn_x(page) == 381 + (margin == 106 ? 48 : margin == 240 ? 110 : 0), "a fluid page's count drawn at %d", + drawn_x(page)); + } + { + /* deeper than the levels kept (in stretched widgets, each as wide as + the display): it moves with its parent */ + struct widget_instance *widget; + long depth; + + screen_begin(852); + widget = add(NULL, 0, 0, 0, 640, 0); + for (depth = 1; depth < 40; depth++) + widget = add(widget, 1, 0, 0, 10, 4 /* stretch */); + widget->anchor = 3 /* right */; + CHECK(pc_menu_anchor_shift(widget, NULL, NULL) == 0, "a widget 40 deep moved"); + /* (as deep as the levels kept: placed) */ + instances[ANCHOR_MAXIMUM_DEPTH - 1].anchor = 3 /* right */; + CHECK(pc_menu_anchor_shift(&instances[ANCHOR_MAXIMUM_DEPTH - 1], NULL, NULL) == 106, + "a widget %d deep not placed", ANCHOR_MAXIMUM_DEPTH); + } +} + +/* the mouse's targets where the widgets are drawn: an anchored item, a +stretched one, a legend button and its label, a list's slots and the band +beside them */ +static void case_hit(void) +{ + long index; + + for (index = 0; index < (long)(sizeof(widths) / sizeof(*widths)); index++) + { + long margin = (widths[index] - 640) / 2; + struct widget_instance *screen, *panel, *bar, *icon, *label, *list, *first, *last; + struct ui_mouse_target *target; + + screen_begin(widths[index]); + screen = add(NULL, 0, 0, 0, 640, 0); + panel = add(screen, 12, 290, 0, 176, 2 /* left */); + items[panel->definition_tag_index] = TRUE; + bar = add(screen, 0, 100, 100, 440, 4 /* stretch */); + items[bar->definition_tag_index] = TRUE; + note_screen(screen); + target = ui_mouse_target_at((short)(14 - margin), 300); + CHECK(target && target->widget == panel, "the left panel's left edge at %ld", widths[index]); + target = ui_mouse_target_at((short)(186 - margin), 300); + CHECK(target && target->widget == panel, "the left panel's right edge at %ld", widths[index]); + if (margin) + { + target = ui_mouse_target_at(187, 300); + CHECK(!target, "the left panel where it is laid out at %ld", widths[index]); + } + target = ui_mouse_target_at((short)(100 - margin), 110); + CHECK(target && target->widget == bar, "the stretched bar's left end at %ld", widths[index]); + target = ui_mouse_target_at((short)(539 + margin), 110); + CHECK(target && target->widget == bar, "the stretched bar's right end at %ld", widths[index]); + target = ui_mouse_target_at((short)(540 + margin), 110); + CHECK(!target, "past the stretched bar at %ld", widths[index]); + + /* a legend's button and its label, both at the display's right: the + label is found beside the icon where both are drawn */ + screen_begin(widths[index]); + screen = add(NULL, 0, 0, 0, 640, 0); + icon = add(screen, 500, 440, 0, 16, 3 /* right */); + key_buttons[icon->definition_tag_index] = 0; + label = add(screen, 520, 440, 0, 100, 3 /* right */); + label->type = _ui_widget_type_text_box; + note_screen(screen); + target = ui_mouse_target_at((short)(504 + margin), 450); + CHECK(target && target->widget == icon && target->kind == _ui_mouse_target_button, + "the legend's button at %ld", widths[index]); + CHECK(target->label == label && target->label_bounds.x0 == 520 + margin, + "the legend's label beside it at %ld: %d", widths[index], target->label ? target->label_bounds.x0 : -1); + /* (and a label anchored otherwise, stretched, laid out so that it + begins at the icon where both are drawn) */ + screen_begin(widths[index]); + screen = add(NULL, 0, 0, 0, 640, 0); + icon = add(screen, 400, 440, 0, 16, 3 /* right */); + key_buttons[icon->definition_tag_index] = 0; + label = add(screen, 0, 440, (short)(416 + 2 * margin), 100, 4 /* stretch */); + label->type = _ui_widget_type_text_box; + note_screen(screen); + target = ui_mouse_target_at((short)(404 + margin), 450); + CHECK(target && target->widget == icon, "the button with a stretched label at %ld", widths[index]); + CHECK(target->label == label && target->label_bounds.x0 == 416 + margin, + "the stretched label beside it at %ld: %d", widths[index], target->label ? target->label_bounds.x0 : -1); + + /* a list showing several items side by side, stretched, its first and + last slots at its sides: the band beside them steps it */ + screen_begin(widths[index]); + screen = add(NULL, 0, 0, 0, 640, 0); + list = add(screen, 0, 200, 40, 560, 4 /* stretch */); + list->type = _ui_widget_type_spinner_list; + several[list->definition_tag_index] = TRUE; + first = add(list, 100, 0, 0, 80, 2 /* left */); + last = add(list, 420, 0, 0, 80, 3 /* right */); + several[first->definition_tag_index] = several[last->definition_tag_index] = FALSE; + /* (its slots are its children, which ui_mouse_list_shows_several's + list takes; the fake says the list is one) */ + note_screen(screen); + target = ui_mouse_target_at((short)(41 - margin), 210); + CHECK(target && target->kind == _ui_mouse_target_list_back, "the band before the first slot at %ld", + widths[index]); + CHECK(target->bounds.x0 == 40 - margin && target->bounds.x1 == 100 - margin, + "the band before the first slot from %d to %d", target->bounds.x0, target->bounds.x1); + target = ui_mouse_target_at((short)(510 + margin), 210); + CHECK(target && target->kind == _ui_mouse_target_list_forward, "the band after the last slot at %ld", + widths[index]); + CHECK(target->bounds.x0 == 500 + margin && target->bounds.x1 == 600 + margin, + "the band after the last slot from %d to %d", target->bounds.x0, target->bounds.x1); + } +} + +/* an anchor as the game reads it (anchor_names' index and 1, as +menu_functions.c's enum has them), and a bitmap's caps: two whole numbers, +"left,right", that leave a middle in every frame, of PNGs; noted for each +frame */ +static void case_caps(void) +{ + static struct halo_menu_frame frames[3]; + static struct bitmap_data bitmaps[2], other; + static char const *const refused[] = { "4", "4,", ",4", "a,4", "4,a", " 4,4", "+4,4", "4,+4", "4,4 ", "4,-1", + "-4,4", "4;4", "4,4,4", "3000,1", "1,3000", "40,24" }; + struct halo_menus menus; + struct halo_menu_bitmap source; + short left, right; + long index; + + problems = 0; + CHECK(anchor_parse(NULL, "a.xml", 1) == _anchor_none && anchor_parse("center", "a.xml", 1) == _anchor_center && + anchor_parse("left", "a.xml", 1) == _anchor_left && anchor_parse("right", "a.xml", 1) == _anchor_right && + anchor_parse("stretch", "a.xml", 1) == _anchor_stretch && anchor_parse("fluid", "a.xml", 1) == _anchor_fluid, + "the anchors read"); + CHECK(!problems, "an anchor refused: %s", last_problem); + CHECK(anchor_parse("middle", "a.xml", 1) == _anchor_none && problems == 1 && strstr(last_problem, "middle"), + "an anchor that is none taken"); + CHECK(anchor_parse("Left", "a.xml", 1) == _anchor_none && problems == 2, "an anchor of another case taken"); + memset(&menus, 0, sizeof(menus)); + memset(frames, 0, sizeof(frames)); + menus.frames = frames; + menus.frame_count = 3; + frames[0].png = "a.png"; + frames[0].width = 64; + frames[1].png = "b.png"; + frames[1].width = 48; + frames[2].map = "ui\\shell\\bitmaps\\white"; + build.menus = &menus; + memset(&source, 0, sizeof(source)); + source.name = "bar"; + source.first_frame = 0; + source.frame_count = 2; + + source.caps = "12,20"; + problems = 0; + caps_add(&source, bitmaps); + CHECK(!problems, "12,20 refused: %s", last_problem); + menu_tags.loaded = TRUE; + CHECK(pc_menu_bitmap_caps(&bitmaps[0], &left, &right) && left == 12 && right == 20, "the first frame's caps"); + CHECK(pc_menu_bitmap_caps(&bitmaps[1], &left, &right) && left == 12 && right == 20, "the second frame's caps"); + CHECK(!pc_menu_bitmap_caps(&other, &left, &right), "caps for a bitmap without them"); + menu_tags.loaded = FALSE; + CHECK(!pc_menu_bitmap_caps(&bitmaps[0], &left, &right), "caps with the game's own menus"); + + /* (none: 0,0 leaves the whole picture as the middle; 47 is the most the + narrower frame leaves a middle of) */ + source.caps = "0,0"; + problems = 0; + caps_add(&source, bitmaps); + CHECK(!problems, "0,0 refused: %s", last_problem); + source.caps = "47,0"; + caps_add(&source, bitmaps); + CHECK(!problems, "47,0 refused: %s", last_problem); + source.caps = "24,24"; + caps_add(&source, bitmaps); + CHECK(problems == 1 && strstr(last_problem, "no middle"), "24,24 in a frame 48 wide: %s", last_problem); + for (index = 0; index < (long)(sizeof(refused) / sizeof(*refused)); index++) + { + problems = 0; + source.caps = refused[index]; + caps_add(&source, bitmaps); + CHECK(problems == 1, "caps=\"%s\" taken", refused[index]); + } + /* a frame of the map's */ + source.caps = "2,2"; + source.frame_count = 3; + problems = 0; + caps_add(&source, bitmaps); + CHECK(problems == 1 && strstr(last_problem, "PNG"), "caps of a map's frame: %s", last_problem); +} + +/* checks a quad drawn: its left and right, top and bottom, and the part of +the picture across it */ +static void check_quad(long index, real x0, real x1, real y0, real y1, real u0, real u1, real v1) +{ + struct dynamic_screen_vertex const *vertex = quads[index]; + + CHECK(index < quad_count, "quad %ld not drawn (%ld)", index, quad_count); + CHECK(vertex[0].position.x == x0 && vertex[3].position.x == x0 && vertex[1].position.x == x1 && + vertex[2].position.x == x1, "quad %ld across %g to %g, not %g to %g", index, vertex[0].position.x, + vertex[1].position.x, x0, x1); + CHECK(vertex[0].position.y == y0 && vertex[1].position.y == y0 && vertex[2].position.y == y1 && + vertex[3].position.y == y1, "quad %ld down %g to %g", index, vertex[0].position.y, vertex[2].position.y); + CHECK(vertex[0].texture_coordinates.x == u0 && vertex[3].texture_coordinates.x == u0 && + vertex[1].texture_coordinates.x == u1 && vertex[2].texture_coordinates.x == u1, + "quad %ld of the picture from %g to %g, not %g to %g", index, vertex[0].texture_coordinates.x, + vertex[1].texture_coordinates.x, u0, u1); + CHECK(vertex[0].texture_coordinates.y == 0.0f && vertex[1].texture_coordinates.y == 0.0f && + vertex[2].texture_coordinates.y == v1 && vertex[3].texture_coordinates.y == v1, + "quad %ld of the picture down to %g, not %g", index, vertex[2].texture_coordinates.y, v1); +} + +/* a picture drawn as the game always drew it (from its left, as much of it as +the rectangle of texels is wide: cut, or stretched), and a capped picture in +three: its ends as they are, its middle across the rest */ +static void case_draw(void) +{ + struct bitmap_data bitmap = { 64, 32 }; + struct rasterizer_dynamic_screen_geometry_parameters parameters; + rectangle2d bounds, texels; + + memset(¶meters, 0, sizeof(parameters)); + bounds.x0 = 10; + bounds.x1 = 138; + bounds.y0 = 20; + bounds.y1 = 52; + texels = bounds; + quad_count = 0; + draw_bitmap_in_rect(&bitmap, &bounds, &texels, NULL, 0xFFFFFFFF, ¶meters, TRUE); + CHECK(quad_count == 1, "a picture drawn in %ld quads", quad_count); + check_quad(0, 10.0f, 138.0f, 20.0f, 52.0f, 0.0f, 1.0f, 1.0f); + /* (narrower than the picture, and shorter: cut) */ + bounds.x1 = 42; + bounds.y1 = 36; + texels = bounds; + quad_count = 0; + draw_bitmap_in_rect(&bitmap, &bounds, &texels, NULL, 0xFFFFFFFF, ¶meters, TRUE); + check_quad(0, 10.0f, 42.0f, 20.0f, 36.0f, 0.0f, 0.5f, 0.5f); + /* (the whole picture: no rectangle of texels) */ + quad_count = 0; + draw_bitmap_in_rect(&bitmap, &bounds, NULL, NULL, 0xFFFFFFFF, ¶meters, TRUE); + check_quad(0, 10.0f, 42.0f, 20.0f, 36.0f, 0.0f, 1.0f, 1.0f); + + /* caps of 8 and 12 units of 64, across 200 */ + bounds.x0 = 10; + bounds.x1 = 210; + bounds.y0 = 20; + bounds.y1 = 52; + texels = bounds; + quad_count = 0; + draw_capped_bitmap_in_rect(&bitmap, &bounds, &texels, 8, 12, NULL, 0xFFFFFFFF, ¶meters); + CHECK(quad_count == 3, "a capped picture drawn in %ld quads", quad_count); + check_quad(0, 10.0f, 18.0f, 20.0f, 52.0f, 0.0f, 0.125f, 1.0f); + check_quad(1, 18.0f, 198.0f, 20.0f, 52.0f, 0.125f, 0.8125f, 1.0f); + check_quad(2, 198.0f, 210.0f, 20.0f, 52.0f, 0.8125f, 1.0f, 1.0f); + CHECK(quad_plasma[0] && quad_plasma[1] && quad_plasma[2], "a capped picture drawn without the plasma's maps"); + /* (squeezed: 30 units across, and no left cap) */ + bounds.x1 = 40; + quad_count = 0; + draw_capped_bitmap_in_rect(&bitmap, &bounds, &texels, 0, 12, NULL, 0xFFFFFFFF, ¶meters); + CHECK(quad_count == 2, "a picture with no left cap drawn in %ld quads", quad_count); + check_quad(0, 10.0f, 28.0f, 20.0f, 52.0f, 0.0f, 0.8125f, 1.0f); + check_quad(1, 28.0f, 40.0f, 20.0f, 52.0f, 0.8125f, 1.0f, 1.0f); +} + +/* the placement of a widget in a widget in a screen, each anchored every way +(none, then anchor_names'), at several offsets and margins, a line each +(shift and grow): tools/menu_check.py's anchor_place must give the same */ +static void case_table(void) +{ + static long const margins[] = { 0, 106, 240, 249 }; + static short const offsets[] = { -30, 12, 333 }; + long margin, screen, parent, child, offset; + + for (margin = 0; margin < (long)(sizeof(margins) / sizeof(*margins)); margin++) + { + for (screen = _anchor_none; screen <= _anchor_fluid; screen++) + { + for (parent = _anchor_none; parent <= _anchor_fluid; parent++) + { + for (child = _anchor_none; child <= _anchor_fluid; child++) + { + for (offset = 0; offset < (long)(sizeof(offsets) / sizeof(*offsets)); offset++) + { + struct anchor_level levels[3]; + long shift, grow; + + levels[0].anchor = (byte)screen; + levels[0].offset = 0; + levels[0].left = 0; + levels[0].right = 640; + levels[1].anchor = (byte)parent; + levels[1].offset = 100; + levels[1].left = 20; + levels[1].right = 460; + levels[2].anchor = (byte)child; + levels[2].offset = offsets[offset]; + levels[2].left = 0; + levels[2].right = 90; + anchor_place(levels, 2, margins[margin], 640, &shift, &grow); + printf("%ld %ld ", shift, grow); + anchor_place(levels, 3, margins[margin], 640, &shift, &grow); + printf("%ld %ld\n", shift, grow); + } + } + } + } + } +} + +int main(int argc, char **argv) +{ + char const *name = argc > 1 ? argv[1] : ""; + + if (!strcmp(name, "place")) + case_place(); + else if (!strcmp(name, "draw")) + case_draw(); + else if (!strcmp(name, "table")) + case_table(); + else if (!strcmp(name, "shift")) + case_shift(); + else if (!strcmp(name, "hit")) + case_hit(); + else if (!strcmp(name, "caps")) + case_caps(); + else + { + fprintf(stderr, "no case %s\n", name); + return 2; + } + return 0; +} diff --git a/tools/harness/tests/menu_fonts.c b/tools/harness/tests/menu_fonts.c new file mode 100644 index 0000000..5fccc7b --- /dev/null +++ b/tools/harness/tests/menu_fonts.c @@ -0,0 +1,380 @@ +/* +MENU_FONTS.C (test) + +A menu set's theme fonts: a read from a menu file +(port/linux/src/menu_files.c's read_font), each font checked as the game +checks it (halo_menus_font_check: a tag the high-res text draws, a static +TrueType font of the menus' files within its size whose tables lie in it, +the SIL Open Font License beside it), the theme's fonts kept by tag, a +later one winning (port/linux/game/menu_tags.c's theme_fonts_build), and +the high-res text drawing a themed tag with the theme's font and the others, +and every tag without a theme, with fonts.json's as before +(port/linux/src/text_hires.c): test_menu_fonts.py takes the code. +*/ + +#include "harness.h" + +#include +#include +#include +#include +#include + +/* ---------- the platform's world */ + +static int logged; +static char last_log[512]; + +static void platform_log(const char *format, ...) +{ + va_list arguments; + + logged++; + va_start(arguments, format); + vsnprintf(last_log, sizeof(last_log), format, arguments); + va_end(arguments); + fprintf(stderr, "log: %s\n", last_log); +} + +/* the config folder: the test's (MENU_FONTS_FOLDER, which holds menus/) */ +static void config_folder(char *path, size_t size) +{ + const char *folder = getenv("MENU_FONTS_FOLDER"); + + snprintf(path, size, "%s/", folder ? folder : "."); +} + +/* SDL's, as SDL 3 has it: a file's bytes, which SDL_free frees */ +static void *SDL_LoadFile(const char *path, size_t *size) +{ + FILE *file = fopen(path, "rb"); + char *bytes = NULL; + long length; + + if (!file) + return NULL; + if (!fseek(file, 0, SEEK_END) && (length = ftell(file)) >= 0 && !fseek(file, 0, SEEK_SET)) + { + bytes = malloc((size_t)length + 1); + if (bytes && fread(bytes, 1, (size_t)length, file) != (size_t)length) + { + free(bytes); + bytes = NULL; + } + if (bytes) + *size = (size_t)length; + } + fclose(file); + return bytes; +} +#define SDL_free free + +static int config_boolean(const char *name) +{ + (void)name; + return 1; +} + +static unsigned long config_changes(void) +{ + return 1; +} + +/* ---------- the reader's world (menu_files.c), as menu_theme.c has it */ + +typedef char XML_Char; +typedef void *XML_Parser; +#define XML_FALSE 0 +static long parser_line = 7; + +static unsigned long XML_GetCurrentLineNumber(XML_Parser parser) +{ + (void)parser; + return (unsigned long)parser_line; +} + +static void XML_StopParser(XML_Parser parser, int resumable) +{ + (void)parser; + (void)resumable; +} + +/* ---------- the high-res text (text_hires.c), with stb_truetype */ + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Weverything" +#define STBTT_STATIC +#define STB_TRUETYPE_IMPLEMENTATION +#include "stb_truetype.h" +#pragma clang diagnostic pop + +#include "text_hires.inc" + +/* the fonts.json fonts: the stock ones, read from port/assets/fonts */ +static struct text_hires_embedded text_hires_embedded[2]; +static const unsigned int text_hires_embedded_count = 2; + +#include "text_hires_code.inc" + +/* ---------- the menu files and their fonts (menu_files.c) */ + +#include "halo_menus.h" +#include "files.inc" + +/* the theme given to the high-res text (halo_menus_fonts_use) */ +#include "fonts_use.inc" + +/* ---------- the game's (menu_tags.c) */ + +#define csmemset memset +#define csstrcasecmp strcasecmp + +static struct +{ + boolean failed; +} build; + +static int problems; +static char last_problem[512]; + +static void problem(char const *file, long line, char const *message, char const *detail) +{ + problems++; + build.failed = TRUE; + snprintf(last_problem, sizeof(last_problem), "%s:%ld: %s %s", file ? file : "?", line, message, + detail ? detail : ""); + fprintf(stderr, "problem: %s\n", last_problem); +} + +#include "theme.inc" + +/* ---------- the test's own */ + +static unsigned int *file_words(const char *path, unsigned int *size) +{ + unsigned long length = 0; + unsigned char *data = file_read(path, &length); + unsigned int *words; + + if (!data) + return NULL; + words = calloc(length / 4 + 2, 4); + if (words) + memcpy(words, data, length); + free(data); + *size = (unsigned int)length; + return words; +} + +static void stock_fonts(void) +{ + char path[1024]; + const char *repository = getenv("MENU_FONTS_REPOSITORY"); + + snprintf(path, sizeof(path), "%s/port/assets/fonts/Overpass-750.ttf", repository ? repository : "."); + text_hires_embedded[0].tag = "ui\\small_ui"; + text_hires_embedded[0].file = "Overpass-750.ttf"; + text_hires_embedded[0].data = file_words(path, &text_hires_embedded[0].size); + snprintf(path, sizeof(path), "%s/port/assets/fonts/Overpass-900.ttf", repository ? repository : "."); + text_hires_embedded[1].tag = "ui\\large_ui"; + text_hires_embedded[1].file = "Overpass-900.ttf"; + text_hires_embedded[1].data = file_words(path, &text_hires_embedded[1].size); + if (!text_hires_embedded[0].data || !text_hires_embedded[1].data) + { + fprintf(stderr, "the stock fonts are not there\n"); + exit(2); + } +} + +/* the font drawn for a tag (text_hires_font), and the file the log names */ +static long font_of(const char *tag, char *file, size_t size) +{ + static short widths[TEXT_HIRES_FIT_LETTERS]; + long font, letter; + + for (letter = 0; letter < TEXT_HIRES_FIT_LETTERS; letter++) + widths[letter] = 12; + last_log[0] = 0; + font = text_hires_font(tag, 20.0f, widths, 1.0f); + file[0] = 0; + { + const char *with = strstr(last_log, " drawn with "); + + if (with) + snprintf(file, size, "%.*s", (int)strcspn(with + 12, ","), with + 12); + } + return font; +} + +static int resets; + +static void reset_counted(void) +{ + resets++; +} + +/* the batch's lines (test_menu_fonts.py): " ", each judged by +halo_menus_font_check: "ok" or "refused " */ +static int batch(const char *path) +{ + static char line[2048]; + FILE *file = fopen(path, "r"); + + if (!file) + return 2; + while (fgets(line, sizeof(line), file)) + { + char *tag = strtok(line, " \n"); + char *font = strtok(NULL, " \n"); + char why[256] = ""; + + if (!tag || !font) + continue; + if (halo_menus_font_check(tag, font, why, sizeof(why))) + printf("ok\n"); + else + printf("refused %s\n", why); + } + fclose(file); + return 0; +} + +int main(int argc, char **argv) +{ + const char *case_name = argc > 1 ? argv[1] : ""; + struct reader reader; + + memset(&reader, 0, sizeof(reader)); + reader.file = "ce/theme.xml"; + stock_fonts(); + /* (the menus folder beside config.toml, as files_gather finds it) */ + config_folder(folder, sizeof(folder)); + snprintf(folder + strlen(folder), sizeof(folder) - strlen(folder), "menus"); + + /* a 's tag and file, as written; one without either refused */ + CASE("read") + { + static const XML_Char *good[] = { "tag", "UI\\small_ui", "file", "ce/fonts/Exo2-SemiBold.ttf", NULL }; + static const XML_Char *no_file[] = { "tag", "ui\\small_ui", NULL }; + static const XML_Char *no_tag[] = { "file", "ce/fonts/Exo2-SemiBold.ttf", NULL }; + static const XML_Char *other[] = { "tag", "ui\\small_ui", "file", "a.ttf", "size", "12", NULL }; + + read_font(&reader, good); + CHECK(!reader.failed && reader.menus.font_count == 1, "a was not read\n"); + CHECK(!strcmp(reader.menus.fonts[0].tag, "UI\\small_ui") && + !strcmp(reader.menus.fonts[0].path, "ce/fonts/Exo2-SemiBold.ttf") && + !strcmp(reader.menus.fonts[0].file, "ce/theme.xml") && reader.menus.fonts[0].line == 7, + "the read is %s, %s\n", reader.menus.fonts[0].tag, reader.menus.fonts[0].path); + read_font(&reader, no_file); + CHECK(reader.failed, "a without a file was read\n"); + reader.failed = 0; + read_font(&reader, no_tag); + CHECK(reader.failed, "a without a tag was read\n"); + reader.failed = 0; + read_font(&reader, other); + CHECK(reader.failed, "a with another attribute was read\n"); + return 0; + } + /* each font of the batch checked */ + CASE("checks") + { + return argc > 2 ? batch(argv[2]) : 2; + } + /* the theme's fonts, by tag: a later one of a tag (in any case) winning, + a font the check refuses a problem */ + CASE("theme") + { + struct halo_menu_font fonts[] = + { + { "ui\\small_ui", "ce/fonts/Good-Regular.ttf", "ce/theme.xml", 1 }, + { "ui\\large_ui", "ce/fonts/Good-Bold.ttf", "ce/theme.xml", 2 }, + { "UI\\SMALL_UI", "ce/fonts/Good-Medium.ttf", "ce/theme.xml", 3 }, + }; + struct halo_menus menus; + + memset(&menus, 0, sizeof(menus)); + menus.fonts = fonts; + menus.font_count = 3; + checked_good = 1; + theme_fonts_build(&menus); + CHECK(!problems && menu_theme.font_count == 2, "%ld fonts kept, %d problems\n", menu_theme.font_count, + problems); + CHECK(!strcmp(menu_theme.font_paths[0], "ce/fonts/Good-Medium.ttf") && + !strcmp(menu_theme.font_paths[1], "ce/fonts/Good-Bold.ttf"), "the fonts kept are %s and %s\n", + menu_theme.font_paths[0], menu_theme.font_paths[1]); + /* (one the check refuses) */ + checked_good = 0; + theme_fonts_build(&menus); + CHECK(problems && build.failed && strstr(last_problem, "ce/theme.xml:1:"), "a refused font was no problem\n"); + CHECK(menu_theme.font_count == 0, "a refused font was kept (%ld)\n", menu_theme.font_count); + return 0; + } + /* the high-res text: a themed tag drawn with the theme's font, the + others with the stock fonts; without a theme, the stock fonts, and no + glyph made again for nothing */ + CASE("draw") + { + char file[128]; + const char *tags[] = { "UI\\SMALL_UI" }; + const char *files[] = { "ce/fonts/Theme-Bold.ttf" }; + const void *datas[1]; + unsigned long sizes[1]; + long font; + + text_hires_set_reset_hook(reset_counted); + font = font_of("ui\\small_ui", file, sizeof(file)); + CHECK(font >= 0 && !strcmp(file, "Overpass-750.ttf"), "without a theme, ui\\small_ui drawn with %s\n", file); + CHECK(text_hires_draws("UI\\Small_UI") && text_hires_draws("ui\\large_ui") && !text_hires_draws("ui\\hud"), + "the tags drawn\n"); + atlas_reset(1.0f); + resets = 0; + /* (no theme for none: nothing made again) */ + text_hires_theme(NULL, NULL, NULL, NULL, 0); + CHECK(!resets && font_of("ui\\small_ui", file, sizeof(file)) == font, "no theme made the fonts again\n"); + /* (a theme: the atlas and the fonts made again) */ + datas[0] = text_hires_embedded[1].data; + sizes[0] = text_hires_embedded[1].size; + text_hires_theme(tags, files, datas, sizes, 1); + CHECK(resets == 1, "a theme did not empty the atlas (%d)\n", resets); + font_of("ui\\small_ui", file, sizeof(file)); + CHECK(!strcmp(file, "ce/fonts/Theme-Bold.ttf"), "with a theme, ui\\small_ui drawn with %s\n", file); + font_of("ui\\large_ui", file, sizeof(file)); + CHECK(!strcmp(file, "Overpass-900.ttf"), "with a theme of ui\\small_ui, ui\\large_ui drawn with %s\n", file); + /* (a theme's font the font reader cannot read: the stock one) */ + { + static const unsigned int garbage[64] = { 0x00000100, 0x01000000 }; + + datas[0] = garbage; + sizes[0] = sizeof(garbage); + text_hires_theme(tags, files, datas, sizes, 1); + font = font_of("ui\\small_ui", file, sizeof(file)); + CHECK(font >= 0 && !strcmp(file, "Overpass-750.ttf"), "a theme's unreadable font drew ui\\small_ui (%s)\n", + file); + } + /* (the theme taken away: the stock fonts) */ + resets = 0; + text_hires_theme(NULL, NULL, NULL, NULL, 0); + CHECK(resets == 1, "the theme taken away did not empty the atlas\n"); + font_of("ui\\small_ui", file, sizeof(file)); + CHECK(!strcmp(file, "Overpass-750.ttf"), "with the theme taken away, ui\\small_ui drawn with %s\n", file); + return 0; + } + /* the theme's fonts given to the high-res text, their data the menu + files' */ + CASE("use") + { + const char *tags[] = { "ui\\small_ui" }; + const char *paths[] = { "ce/fonts/Good-Regular.ttf" }; + char file[128]; + + halo_menus_fonts_use(tags, paths, 1); + font_of("ui\\small_ui", file, sizeof(file)); + CHECK(!strcmp(file, "ce/fonts/Good-Regular.ttf"), "the theme's font used is %s\n", file); + halo_menus_fonts_use(NULL, NULL, 0); + font_of("ui\\small_ui", file, sizeof(file)); + CHECK(!strcmp(file, "Overpass-750.ttf"), "with none, %s\n", file); + return 0; + } + fprintf(stderr, "unknown case: %s\n", case_name); + return 2; +} diff --git a/tools/harness/tests/menu_theme.c b/tools/harness/tests/menu_theme.c new file mode 100644 index 0000000..7fd8115 --- /dev/null +++ b/tools/harness/tests/menu_theme.c @@ -0,0 +1,357 @@ +/* +MENU_THEME.C (test) + +A menu theme's colours: its elements read from a menu file +(port/linux/src/menu_files.c's text_element and read_color: and the fixed colours' <..._text color=>), the theme built from +them and its colours looked up (port/linux/game/menu_tags.c's theme_build, +theme_mapped, pc_menu_theme_text: a text colour mapped, a later map of a +colour winning, 32 at most, a colour's syntax), and the colour a text is +drawn in (port/linux/game/menu_functions.c's pc_menu_text_color: focused +text, the server browser's rows): test_menu_theme.py takes the code +(reader.inc, theme.inc, hook.inc). +*/ + +#include "harness.h" + +#include +#include + +#include "halo_menus.h" + +/* ---------- the reader's world (menu_files.c) */ + +typedef char XML_Char; +typedef void *XML_Parser; +#define XML_FALSE 0 +static long parser_line = 7; +static int errors; +static char last_error[256]; + +static unsigned long XML_GetCurrentLineNumber(XML_Parser parser) +{ + (void)parser; + return (unsigned long)parser_line; +} + +static void XML_StopParser(XML_Parser parser, int resumable) +{ + (void)parser; + (void)resumable; +} + +static void platform_log(const char *format, ...) +{ + va_list arguments; + + errors++; + va_start(arguments, format); + vsnprintf(last_error, sizeof(last_error), format, arguments); + va_end(arguments); +} + +#include "reader.inc" + +/* ---------- the game's world (menu_tags.c, menu_functions.c) */ + +typedef struct +{ + real alpha, red, green, blue; +} real_argb_color; + +#define PIN(value, low, high) ((value) < (low) ? (low) : (value) > (high) ? (high) : (value)) +#define csmemset memset +#define csmemcpy memcpy + +static struct +{ + boolean loaded; +} menu_tags; + +static struct +{ + boolean failed; +} build; + +static int problems; +static char last_problem[256]; + +static void problem(char const *file, long line, char const *message, char const *detail) +{ + problems++; + snprintf(last_problem, sizeof(last_problem), "%s:%ld: %s %s", file, line, message, detail ? detail : ""); + build.failed = TRUE; +} + +#include "theme.inc" + +/* the server browser's rows (menu_functions.c's) */ +struct widget_instance +{ + char name[32]; + struct widget_instance *parent; +}; + +enum +{ + BROWSER_ROWS = 4, +}; + +#include "hook.inc" + +/* ---------- the cases */ + +static struct halo_menu_color read_one(const char *element, const char *const *attributes) +{ + struct reader reader; + struct halo_menu_color color; + + memset(&reader, 0, sizeof(reader)); + reader.file = "ce/theme.xml"; + read_color(&reader, element, (const XML_Char **)attributes, text_element(element)); + CHECK(reader.menus.color_count == 1, "the colour not kept: %ld", reader.menus.color_count); + color = reader.menus.colors[0]; + color.line = reader.failed ? -1 : color.line; + free(reader.menus.colors); + return color; +} + +static void case_read(void) +{ + static const char *const map[] = { "from", "#FF2896FF", "to", "#FFFFB000", NULL }; + static const char *const focus[] = { "color", "#FFFFE0A0", "platform", "web", NULL }; + static const char *const map_without_to[] = { "from", "#FF2896FF", NULL }; + static const char *const focus_misspelt[] = { "colour", "#FFFFE0A0", NULL }; + static const char *const browser[] = { "color", "#123456", NULL }; + struct halo_menu_color color; + + /* (a map: its from and to as written, no fixed colour's) */ + color = read_one("color_map", map); + CHECK(color.text == HALO_MENU_NONE && !strcmp(color.from, "#FF2896FF") && !strcmp(color.to, "#FFFFB000") && + !color.color && color.line == 7 && !strcmp(color.file, "ce/theme.xml"), "a map read: %ld", color.text); + /* (a fixed colour: which, by its element's name, and its colour; the + platform attribute every element takes) */ + color = read_one("focus_text", focus); + CHECK(color.text == HALO_MENU_TEXT_FOCUS && !strcmp(color.color, "#FFFFE0A0") && color.line == 7, + "the focused text's colour read: %ld", color.text); + CHECK(text_element("ping_good_text") == HALO_MENU_TEXT_PING_GOOD && + text_element("ping_fair_text") == HALO_MENU_TEXT_PING_FAIR && + text_element("ping_poor_text") == HALO_MENU_TEXT_PING_POOR && + text_element("status_text") == HALO_MENU_TEXT_STATUS && text_element("greyed_text") == HALO_MENU_TEXT_GREYED, + "the browser's colours' elements"); + CHECK(text_element("color_map") == HALO_MENU_NONE && text_element("widget") == HALO_MENU_NONE && + text_element("focus") == HALO_MENU_NONE && text_element("focus_text_") == HALO_MENU_NONE, + "another element taken for a fixed colour"); + color = read_one("greyed_text", browser); + CHECK(color.text == HALO_MENU_TEXT_GREYED && !strcmp(color.color, "#123456"), "a greyed row's colour read"); + /* (one that lacks what it needs, or has another attribute: the file's + error) */ + errors = 0; + color = read_one("color_map", map_without_to); + CHECK(color.line == -1 && errors == 1 && strstr(last_error, "needs a from and a to"), "a map without a to: %s", + last_error); + errors = 0; + color = read_one("focus_text", focus_misspelt); + CHECK(color.line == -1 && errors == 1 && strstr(last_error, "has no attribute colour"), "a misspelt colour: %s", + last_error); +} + +static struct halo_menus theme_of(struct halo_menu_color const *colors, long count) +{ + struct halo_menus menus; + + memset(&menus, 0, sizeof(menus)); + menus.colors = (struct halo_menu_color *)colors; + menus.color_count = count; + problems = 0; + build.failed = FALSE; + theme_build(&menus); + menu_tags.loaded = !build.failed; + return menus; +} + +static int color_is(real_argb_color const *color, unsigned long argb) +{ + return color_packed(color) == argb; +} + +static real_argb_color color_of(unsigned long argb) +{ + real_argb_color color; + + color.alpha = (real)((argb >> 24) & 0xFF) / 255.0f; + color.red = (real)((argb >> 16) & 0xFF) / 255.0f; + color.green = (real)((argb >> 8) & 0xFF) / 255.0f; + color.blue = (real)(argb & 0xFF) / 255.0f; + return color; +} + +static void case_map(void) +{ + static struct halo_menu_color const colors[] = + { + { HALO_MENU_NONE, "#FF2896FF", "#FFFFB000", NULL, "ce/theme.xml", 3 }, + { HALO_MENU_NONE, "#FFFFFF", "#C0101820", NULL, "ce/theme.xml", 4 }, + /* (the same from again: this one wins) */ + { HALO_MENU_NONE, "#ff2896ff", "#FF00FF00", NULL, "ce/theme.xml", 5 }, + }; + real_argb_color color; + + theme_of(colors, 3); + CHECK(!problems && menu_theme.map_count == 2, "the maps kept: %ld (%s)", menu_theme.map_count, last_problem); + /* (the stock blue, written in either case: the later map's) */ + color = color_of(0xFF2896FF); + theme_mapped(&color); + CHECK(color_is(&color, 0xFF00FF00), "the later map of a colour not the one taken: %08lx", color_packed(&color)); + /* (#RRGGBB is opaque: white text, the menus' own default, mapped with + its alpha) */ + color = color_of(0xFFFFFFFF); + theme_mapped(&color); + CHECK(color_is(&color, 0xC0101820), "white mapped: %08lx", color_packed(&color)); + /* (another colour, or the same colour of another alpha: its own) */ + color = color_of(0x802896FF); + theme_mapped(&color); + CHECK(color_is(&color, 0x802896FF), "a colour of another alpha mapped: %08lx", color_packed(&color)); + color = color_of(0xFF123456); + theme_mapped(&color); + CHECK(color_is(&color, 0xFF123456), "a colour not mapped changed"); + /* (no theme: every colour its own) */ + theme_of(colors, 0); + color = color_of(0xFF2896FF); + theme_mapped(&color); + CHECK(color_is(&color, 0xFF2896FF) && !menu_theme.map_count, "a map with no theme"); +} + +static void case_limits(void) +{ + static struct halo_menu_color colors[HALO_MENU_MAXIMUM_COLOR_MAPS + 2]; + static char froms[HALO_MENU_MAXIMUM_COLOR_MAPS + 2][16]; + static struct halo_menu_color const bad[] = + { + { HALO_MENU_NONE, "#FF2896F", "#FFFFB000", NULL, "ce/theme.xml", 3 }, + }; + static struct halo_menu_color const bad_to[] = + { + { HALO_MENU_NONE, "#FF2896FF", "FFFFB000", NULL, "ce/theme.xml", 4 }, + }; + static struct halo_menu_color const bad_text[] = + { + { HALO_MENU_TEXT_FOCUS, NULL, NULL, "#GG0000", "ce/theme.xml", 5 }, + }; + long index; + + /* (32 colours mapped, and one of them again: taken) */ + for (index = 0; index < HALO_MENU_MAXIMUM_COLOR_MAPS; index++) + { + snprintf(froms[index], sizeof(froms[index]), "#FF0000%02lX", index); + colors[index].text = HALO_MENU_NONE; + colors[index].from = froms[index]; + colors[index].to = "#FF000000"; + colors[index].file = "ce/theme.xml"; + colors[index].line = index + 1; + } + colors[HALO_MENU_MAXIMUM_COLOR_MAPS] = colors[0]; + theme_of(colors, HALO_MENU_MAXIMUM_COLOR_MAPS + 1); + CHECK(!problems && menu_theme.map_count == HALO_MENU_MAXIMUM_COLOR_MAPS, "32 maps refused: %s", last_problem); + /* (a 33rd colour: the set's problem) */ + snprintf(froms[HALO_MENU_MAXIMUM_COLOR_MAPS + 1], sizeof(froms[0]), "#FF00FF00"); + colors[HALO_MENU_MAXIMUM_COLOR_MAPS + 1] = colors[0]; + colors[HALO_MENU_MAXIMUM_COLOR_MAPS + 1].from = froms[HALO_MENU_MAXIMUM_COLOR_MAPS + 1]; + theme_of(colors, HALO_MENU_MAXIMUM_COLOR_MAPS + 2); + CHECK(problems == 1 && strstr(last_problem, "32 text colors at most") && !menu_tags.loaded, + "a 33rd map taken: %s", last_problem); + /* (a colour that is not one: the set's problem) */ + theme_of(bad, 1); + CHECK(problems == 1 && strstr(last_problem, "#FF2896F"), "a bad from taken: %s", last_problem); + theme_of(bad_to, 1); + CHECK(problems == 1 && strstr(last_problem, "FFFFB000"), "a bad to taken: %s", last_problem); + theme_of(bad_text, 1); + CHECK(problems == 1 && strstr(last_problem, "#GG0000"), "a bad focus colour taken: %s", last_problem); +} + +static void case_texts(void) +{ + static struct halo_menu_color const colors[] = + { + { HALO_MENU_TEXT_FOCUS, NULL, NULL, "#FF102030", "ce/theme.xml", 3 }, + { HALO_MENU_TEXT_GREYED, NULL, NULL, "#FF405060", "ce/theme.xml", 4 }, + /* (the last of each wins) */ + { HALO_MENU_TEXT_FOCUS, NULL, NULL, "#FFA0B0C0", "ce/theme.xml", 5 }, + }; + real_argb_color color; + + theme_of(colors, 3); + CHECK(pc_menu_theme_text(HALO_MENU_TEXT_FOCUS, &color) && color_is(&color, 0xFFA0B0C0), + "the last focus colour not taken"); + CHECK(pc_menu_theme_text(HALO_MENU_TEXT_GREYED, &color) && color_is(&color, 0xFF405060), "the greyed rows'"); + CHECK(!pc_menu_theme_text(HALO_MENU_TEXT_PING_GOOD, &color) && !pc_menu_theme_text(-1, &color) && + !pc_menu_theme_text(NUMBER_OF_HALO_MENU_TEXTS, &color), "a colour the theme does not give"); + /* (the game's own menus: none) */ + menu_tags.loaded = FALSE; + CHECK(!pc_menu_theme_text(HALO_MENU_TEXT_FOCUS, &color), "a theme's colour with the game's own menus"); +} + +static void case_hook(void) +{ + static struct halo_menu_color const colors[] = + { + { HALO_MENU_TEXT_FOCUS, NULL, NULL, "#FF102030", "ce/theme.xml", 3 }, + { HALO_MENU_TEXT_PING_GOOD, NULL, NULL, "#FF00FF00", "ce/theme.xml", 4 }, + { HALO_MENU_TEXT_STATUS, NULL, NULL, "#FFFF00FF", "ce/theme.xml", 5 }, + }; + struct widget_instance row = { "server_item_2", NULL }; + struct widget_instance ping = { "server_item_ping", &row }; + struct widget_instance name = { "server_item_name", &row }; + struct widget_instance label = { "op_label", NULL }; + real_argb_color color; + + /* (no theme: a focused text its own; the browser's own colours) */ + theme_of(colors, 0); + color = color_of(0x80CCCCCC); + CHECK(!pc_menu_text_color(&label, TRUE, &color) && color_is(&color, 0x80CCCCCC), "focused text changed"); + browser_row_pings[1] = 40; + CHECK(pc_menu_text_color(&ping, FALSE, &color) && color_is(&color, 0x805ED38C), "the browser's good ping"); + browser_row_pings[1] = 200; + CHECK(pc_menu_text_color(&ping, FALSE, &color) && color_is(&color, 0x80E86A5A), "the browser's poor ping"); + /* (a theme: focused text in its colour, its alpha its own; not focused, + its own) */ + theme_of(colors, 3); + color = color_of(0x80CCCCCC); + CHECK(pc_menu_text_color(&label, TRUE, &color) && color_is(&color, 0x80102030), "the focus colour: %08lx", + color_packed(&color)); + color = color_of(0x80CCCCCC); + CHECK(!pc_menu_text_color(&label, FALSE, &color) && color_is(&color, 0x80CCCCCC), "text not focused changed"); + /* (the browser's colours: the theme's where it has one, the browser's + where not; a row's name of a game not greyed, focused: the focus + colour) */ + browser_row_pings[1] = 40; + CHECK(pc_menu_text_color(&ping, TRUE, &color) && color_is(&color, 0x8000FF00), "the theme's good ping"); + browser_row_pings[1] = 120; + CHECK(pc_menu_text_color(&ping, FALSE, &color) && color_is(&color, 0x80E8C547), "the browser's fair ping"); + browser_row_marked[1] = TRUE; + CHECK(pc_menu_text_color(&ping, FALSE, &color) && color_is(&color, 0x80FF00FF), "the theme's status word"); + browser_row_marked[1] = FALSE; + color = color_of(0x80CCCCCC); + CHECK(pc_menu_text_color(&name, TRUE, &color) && color_is(&color, 0x80102030), "a focused row's name"); + browser_row_greyed[1] = TRUE; + CHECK(pc_menu_text_color(&name, TRUE, &color) && color_is(&color, 0x808A8F99), "a greyed row's name"); + browser_row_greyed[1] = FALSE; +} + +int main(int argc, char **argv) +{ + const char *case_name = argc > 1 ? argv[1] : ""; + + CASE("read") + case_read(); + else CASE("map") + case_map(); + else CASE("limits") + case_limits(); + else CASE("texts") + case_texts(); + else CASE("hook") + case_hook(); + else return 2; + return 0; +} diff --git a/tools/harness/tests/player_link.c b/tools/harness/tests/player_link.c index 322bd5f..06a37a6 100644 --- a/tools/harness/tests/player_link.c +++ b/tools/harness/tests/player_link.c @@ -10,7 +10,11 @@ confirmation code and the texts a link and a pass request sign (port/linux/src/player_link.c); a netlist read, the addresses in its ranges, and the ranges of vpn_allow.txt (port/linux/src/server_netlist.c); on the real SHA-256 and Ed25519 (port/linux/src/p2p_crypto.c, Monocypher): -test_player_link.py takes the code (under_test.inc). +test_player_link.py takes the code (under_test.inc). And the HaloCustoms +site's accounts: the account hint, the site's pass (its vector, +halocustoms_vectors.json) at a server with link masters, its request's query, +a refused join's next pass, and the door's word for a server's link +masters. */ #include "harness.h" @@ -51,6 +55,24 @@ struct vector_bytes #include "vectors.inc" +/* the server's link masters' key ids (server_master.c's, which +player_link_server_hinted asks): none on a server with no +server.link_masters_file */ +static unsigned char link_master_ids[2][8]; +static int link_master_count; + +int server_master_link_master(const unsigned char *key_id) +{ + int index; + + for (index = 0; index < link_master_count; index++) + { + if (!memcmp(link_master_ids[index], key_id, 8)) + return 1; + } + return 0; +} + #include "under_test.inc" /* ---------- the cases */ @@ -349,6 +371,324 @@ static void case_next_pass(void) CHECK(player_link_next_pass(keys, only_b, 3, NULL, 0, -1, &needed) == 1, "the held one for an invite"); } +/* ---------- the HaloCustoms site's accounts */ + +static void case_hint(void) +{ + static const unsigned char ids[4][8] = { "siteAAAA", "siteBBBB", "masterCC", "masterDD" }; + unsigned char pass[PLAYER_LINK_PASS_SIZE], proof[PLAYER_LINK_PROOF_SIZE]; + /* (more than a hint holds: a fault that reads too many is seen, not a crash) */ + unsigned char read_ids[8][8]; + int index; + + memset(pass, 0, sizeof(pass)); + /* (one key id: its bytes as docs/master_protocol.md has them, zeros after) */ + CHECK(player_link_hint_make(ids, 1, proof), "a hint of one is not made"); + CHECK(!memcmp(proof, "OPCEHINT", 8) && proof[8] == 1 && proof[9] == 1 && !memcmp(proof + 10, ids[0], 8), + "a hint's bytes"); + for (index = 18; index < PLAYER_LINK_PROOF_SIZE; index++) + CHECK(!proof[index], "byte %d of a hint of one is not zero", index); + CHECK(player_link_hint_read(pass, proof, read_ids) == 1 && !memcmp(read_ids[0], ids[0], 8), "a hint read back"); + CHECK(player_link_hint_make(ids, 4, proof) && player_link_hint_read(pass, proof, read_ids) == 4 && + !memcmp(read_ids[1], ids[1], 8) && !memcmp(read_ids[3], ids[3], 8), "a hint of four"); + CHECK(!player_link_hint_make(ids, 0, proof) && !player_link_hint_make(ids, 5, proof), "a hint of none, or five"); + /* (malformed: none) */ + player_link_hint_make(ids, 2, proof); + proof[8] = 2; + CHECK(!player_link_hint_read(pass, proof, read_ids), "a hint of another format read"); + player_link_hint_make(ids, 2, proof); + proof[9] = 5; + CHECK(!player_link_hint_read(pass, proof, read_ids), "a hint of five read"); + player_link_hint_make(ids, 1, proof); + proof[9] = 0; + memset(proof + 10, 0, 8); + CHECK(!player_link_hint_read(pass, proof, read_ids), "a hint of none read"); + player_link_hint_make(ids, 2, proof); + proof[26] = 1; + CHECK(!player_link_hint_read(pass, proof, read_ids), "a hint with a byte just after its key ids read"); + player_link_hint_make(ids, 2, proof); + proof[63] = 1; + CHECK(!player_link_hint_read(pass, proof, read_ids), "a hint with a last byte read"); + player_link_hint_make(ids, 1, proof); + proof[0] = 'o'; + CHECK(!player_link_hint_read(pass, proof, read_ids), "a hint of another magic read"); + memset(proof, 0, sizeof(proof)); + CHECK(!player_link_hint_read(pass, proof, read_ids), "a stock client's zeros read as a hint"); +} + +static void case_hint_unread(void) +{ + static const unsigned char ids[1][8] = { "siteAAAA" }; + unsigned char pass[400], proof[PLAYER_LINK_PROOF_SIZE], binding[32], keys[1][PLAYER_LINK_KEY_SIZE]; + unsigned char read_ids[8][8]; + struct player_link_pass fields; + + /* (beside a pass, the bytes are its proof: no hint) */ + vector_pass(pass); + player_link_hint_make(ids, 1, proof); + CHECK(!player_link_hint_read(pass, proof, read_ids), "a hint read beside a pass"); + /* (no pass: shown as none, so a stock server checks nothing of it; and + its check, were it made, takes no pass of zeros) */ + memset(pass, 0, sizeof(pass)); + CHECK(!player_link_pass_shown(pass), "a pass of zeros shown"); + player_link_binding(NULL, binding); + key_of(VECTOR_MASTER_PUBLIC_KEY, keys[0]); + CHECK(!player_link_check(pass, proof, binding, (const unsigned char (*)[32])keys, 1, VECTOR_PASS_ISSUED, &fields), + "a pass of zeros with a hint checks"); + /* (a server with no link master is hinted at by none; one whose link + master the hint names is, another's not) */ + link_master_count = 0; + CHECK(!player_link_server_hinted(pass, proof), "a server with no link master hinted at"); + link_master_count = 1; + memcpy(link_master_ids[0], ids[0], 8); + CHECK(player_link_server_hinted(pass, proof), "a link master's hint not seen"); + memcpy(link_master_ids[0], "otherXXX", 8); + CHECK(!player_link_server_hinted(pass, proof), "another master's hint seen"); + link_master_count = 0; + /* (a machine that shows none and hints at nothing the server takes: in, + as a guest, by the stock settings) */ + CHECK(player_link_door_word(0, 0, 0, 1, 1, 0, 0) == _player_link_door_in, "a guest refused"); +} + +static void case_site_pass(void) +{ + /* (the site's key is RFC 8032's second test key, as the master's + vectors' master key is: the publisher's is another) */ + unsigned char pass[400], other[400], keys[2][PLAYER_LINK_KEY_SIZE], seed[32], public_key[32], binding[32]; + unsigned char secret[32], proof[64], message[PLAYER_LINK_PROOF_MESSAGE_SIZE]; + char hex[129]; + int link[2] = { 0, 1 }, unlinked[2] = { 0, 0 }; + struct player_link_account account; + struct player_link_pass fields; + + CHECK(from_hex(VECTOR_SITE_PASS, pass, sizeof(pass)) == PLAYER_LINK_PASS_SIZE, "the site's pass is 170 bytes"); + key_of(VECTOR_PUBLISHER_PUBLIC_KEY, keys[0]); + key_of(VECTOR_SITE_PUBLIC_KEY, keys[1]); + /* (its fields: the site's key id, the player's key, the account, its + times; signed by the site's key alone) */ + CHECK(player_link_pass_read(pass, PLAYER_LINK_PASS_SIZE, &fields), "the site's pass does not read"); + CHECK(fields.flags == VECTOR_SITE_FLAGS && fields.discord_id == strtoull(VECTOR_SITE_ACCOUNT_ID, NULL, 10) && + !strcmp(fields.discord_name, VECTOR_SITE_NAME) && fields.issued == VECTOR_SITE_ISSUED && + fields.expires == VECTOR_SITE_EXPIRES, "the site's pass's fields"); + p2p_hex(fields.master_key_id, 8, hex); + CHECK(!strcmp(hex, VECTOR_SITE_KEY_ID), "its master's key id %s", hex); + p2p_hex(fields.player_key, 32, hex); + CHECK(!strcmp(hex, VECTOR_SITE_PLAYER_PUBLIC_KEY), "its player key %s", hex); + CHECK(player_link_pass_signed(pass, keys[1]), "the site's signature does not check"); + CHECK(!player_link_pass_signed(pass, keys[0]), "another key's checks"); + /* (a LAN's proof, by the player's key) */ + from_hex(VECTOR_SITE_PLAYER_SEED, seed, 32); + p2p_ed25519_public(seed, public_key, NULL); + player_link_binding(NULL, binding); + player_link_proof_message(pass, binding, message); + p2p_ed25519_sign(seed, public_key, message, (int)sizeof(message), proof); + /* (a server that publishes to a master and takes the site's passes, its + link master's) */ + CHECK(player_link_check_account(pass, proof, binding, (const unsigned char (*)[32])keys, link, 2, + VECTOR_SITE_ISSUED + 10, &account), "the site's pass refused by its link master's server"); + CHECK(!strcmp(account.id, VECTOR_SITE_ACCOUNT_ID) && !strcmp(account.name, VECTOR_SITE_NAME) && + !strcmp(account.master, VECTOR_SITE_KEY_ID), "its account %s '%s' of %s", account.id, account.name, + account.master); + CHECK(account.link_master && !account.bound, "a link master's, unbound: %d %d", account.link_master, + account.bound); + CHECK(account.created == VECTOR_SITE_CREATED && account.created == VECTOR_SITE_PROFILE_CREATED, + "its account's creation, the profile's: %lu", account.created); + /* (the site a master published to: no link master's) */ + link[1] = 0; + CHECK(player_link_check_account(pass, proof, binding, (const unsigned char (*)[32])keys, link, 2, + VECTOR_SITE_ISSUED + 10, &account) && !account.link_master, "a published master's pass a link master's"); + link[1] = 1; + /* (a server that takes the publisher's master's alone; the pass out of + date; a proof of another binding: refused) */ + CHECK(!player_link_check_account(pass, proof, binding, (const unsigned char (*)[32])keys, link, 1, + VECTOR_SITE_ISSUED + 10, &account), "the site's pass taken by a server that does not take its passes"); + CHECK(!player_link_check_account(pass, proof, binding, (const unsigned char (*)[32])keys, link, 2, + VECTOR_SITE_EXPIRES + 301, &account), "the site's pass taken out of date"); + from_hex(VECTOR_PROOF_SESSION_SECRET, secret, 32); + player_link_binding(secret, binding); + CHECK(!player_link_check_account(pass, proof, binding, (const unsigned char (*)[32])keys, link, 2, + VECTOR_SITE_ISSUED + 10, &account), "a LAN's proof taken for a tunnel"); + /* (bound to the tunnel) */ + player_link_proof_message(pass, binding, message); + p2p_ed25519_sign(seed, public_key, message, (int)sizeof(message), proof); + CHECK(player_link_check_account(pass, proof, binding, (const unsigned char (*)[32])keys, link, 2, + VECTOR_SITE_ISSUED + 10, &account) && account.bound && account.link_master, "the tunnel's proof: bound"); + /* (a Discord master's pass at a server that publishes to its master: its + master's, no link master's) */ + vector_pass(other); + from_hex(VECTOR_PROOF_PROOF, proof, 64); + CHECK(player_link_check_account(other, proof, binding, (const unsigned char (*)[32])keys, unlinked, 2, + VECTOR_PASS_ISSUED + 10, &account) && !account.link_master && account.bound && + !strcmp(account.master, VECTOR_MASTER_KEY_ID_BYTES), "a Discord master's pass: %s %d", account.master, + account.link_master); +} + +static void case_pass_query(void) +{ + unsigned char seed[32], public_key[32], key_id[8]; + char query[400], text[300], hex[65]; + + from_hex(VECTOR_SITE_PLAYER_SEED, seed, 32); + p2p_ed25519_public(seed, public_key, NULL); + p2p_hex(public_key, 32, hex); + CHECK(!strcmp(hex, VECTOR_SITE_PLAYER_PUBLIC_KEY), "the player seed's key %s", hex); + from_hex(VECTOR_SITE_KEY_ID, key_id, 8); + player_link_request_text("opence-pass-get-1", key_id, public_key, VECTOR_SITE_REQUEST_TS, text, sizeof(text)); + CHECK(!strcmp(text, VECTOR_SITE_REQUEST_TEXT), "the pass request's text '%s'", text); + player_link_pass_query(seed, public_key, key_id, VECTOR_SITE_REQUEST_TS, query, sizeof(query)); + CHECK(!strcmp(query, VECTOR_SITE_QUERY), "the pass request's query '%s'", query); + CHECK(strstr(query, VECTOR_SITE_REQUEST_SIG) != NULL, "the query's signature"); +} + +static void case_join_choice(void) +{ + static const unsigned char keys[3][8] = { "masterAA", "masterBB", "masterCC" }; + static const unsigned char by_b[1][8] = { "masterBB" }; + int all[3] = { 1, 1, 1 }, none[3] = { 0, 0, 0 }, only_b[3] = { 0, 1, 0 }; + int needed; + enum + { + ASKED = PLAYER_LINK_REFUSED_REQUIRED, + INVALID = PLAYER_LINK_REFUSED_INVALID, + IDENTITY = PLAYER_LINK_IDENTITY, + }; + + /* (asked after a hint: the identity's first; refused, the masters' from + the first, in turn, to the last) */ + CHECK(player_link_join_choice(ASKED, -1, -1, 1, 1, keys, all, 3, NULL, 0, &needed) == IDENTITY, + "the identity's first after a hint"); + CHECK(player_link_join_choice(INVALID, IDENTITY, -1, 1, 0, keys, all, 3, NULL, 0, &needed) == 0, + "the first master's after the identity's"); + CHECK(player_link_join_choice(INVALID, 0, 0, 1, 0, keys, all, 3, NULL, 0, &needed) == 1 && + player_link_join_choice(INVALID, 2, 2, 1, 0, keys, all, 3, NULL, 0, &needed) == -1, + "the next master's, to the last"); + /* (asked with no hint: the masters' first, the identity's last; none + after the identity's shown last) */ + CHECK(player_link_join_choice(ASKED, -1, -1, 0, 1, keys, all, 3, NULL, 0, &needed) == 0, + "a master's first with no hint"); + CHECK(player_link_join_choice(INVALID, 2, 2, 0, 1, keys, all, 3, NULL, 0, &needed) == IDENTITY, + "the identity's after the last master's"); + CHECK(player_link_join_choice(INVALID, IDENTITY, 2, 0, 0, keys, all, 3, NULL, 0, &needed) == -1, + "a master's again after the identity's shown last"); + CHECK(player_link_join_choice(ASKED, -1, -1, 0, 1, keys, none, 3, NULL, 0, &needed) == IDENTITY, + "the identity's when no master's is held"); + /* (a hint, the identity's no longer good: the masters') */ + CHECK(player_link_join_choice(ASKED, -1, -1, 1, 0, keys, only_b, 3, NULL, 0, &needed) == 1, + "the master's held with the identity's gone"); + /* (a server listed by B: B's; none held, the identity's; neither, none, + and B the one to link with) */ + CHECK(player_link_join_choice(ASKED, -1, -1, 0, 1, keys, only_b, 3, by_b, 1, &needed) == 1, "B's for B's server"); + CHECK(player_link_join_choice(ASKED, -1, -1, 0, 1, keys, none, 3, by_b, 1, &needed) == IDENTITY, + "the identity's for B's server, none of B's held"); + CHECK(player_link_join_choice(ASKED, -1, -1, 0, 0, keys, none, 3, by_b, 1, &needed) == -1 && needed == 1, + "none, and B to link with"); + /* (asked while showing one; a pass refused with none shown; another + refusal: none) */ + CHECK(player_link_join_choice(ASKED, 0, 0, 0, 1, keys, all, 3, NULL, 0, &needed) == -1 && + player_link_join_choice(ASKED, IDENTITY, -1, 1, 1, keys, all, 3, NULL, 0, &needed) == -1, + "a pass asked for while one was shown"); + CHECK(player_link_join_choice(INVALID, -1, -1, 1, 1, keys, all, 3, NULL, 0, &needed) == -1, + "a pass refused with none shown"); + CHECK(player_link_join_choice(PLAYER_LINK_REFUSED_IN_USE, IDENTITY, -1, 1, 1, keys, all, 3, NULL, 0, &needed) == + -1 && player_link_join_choice(9, -1, -1, 1, 1, keys, all, 3, NULL, 0, &needed) == -1, "another refusal"); +} + +static void case_first_choice(void) +{ + enum + { + IDENTITY = PLAYER_LINK_IDENTITY, + }; + + /* (nothing set to always: none, the hint if the identity's is held) */ + CHECK(player_link_first_choice(1, "asked", "when_needed", -1) == -1 && + player_link_first_choice(0, "asked", "when_needed", -1) == -1, "a pass shown unasked"); + /* (the identity's shared always: it, whatever the masters') */ + CHECK(player_link_first_choice(1, "always", "always", 2) == IDENTITY && + player_link_first_choice(1, "always", "when_needed", -1) == IDENTITY, "the identity's always not shown"); + CHECK(player_link_first_choice(0, "always", "always", 2) == 2, "a master's not shown with no identity's held"); + /* (a master's always: it, unless the identity's is held and shared when + asked, whose hint goes first) */ + CHECK(player_link_first_choice(1, "asked", "always", 1) == -1, + "a Discord pass shown over the hint of an identity's shared when asked"); + CHECK(player_link_first_choice(0, "asked", "always", 1) == 1 && player_link_first_choice(1, "never", "always", 1) == + 1, "a master's always not shown with no identity's to hint at"); + CHECK(player_link_first_choice(0, "asked", "always", -1) == -1, "a master's shown with none held"); + CHECK(player_link_first_choice(0, "asked", "when_needed", 1) == -1, "a master's shown unasked when needed"); +} + +static void case_refusal_choice(void) +{ + enum + { + ASKED = PLAYER_LINK_REFUSED_REQUIRED, + INVALID = PLAYER_LINK_REFUSED_INVALID, + TOO_NEW = PLAYER_LINK_REFUSED_TOO_NEW, + IN_USE = PLAYER_LINK_REFUSED_IN_USE, + IDENTITY = PLAYER_LINK_IDENTITY, + NONE_WORD = _player_link_word_none, + }; + + /* (a pass asked for, none shown and none hinted at: a server that blocks + VPNs, a stock one too, which a ranked one cannot be told from: the VPN's + words, never the account's) */ + CHECK(player_link_refusal_choice(ASKED, -1, 0, 0, 0) == NONE_WORD, "a plain VPN block said as the account's"); + /* (asked after a hint, or after the identity's pass was refused: the + account's) */ + CHECK(player_link_refusal_choice(ASKED, -1, 1, 0, 0) == _player_link_word_account && + player_link_refusal_choice(ASKED, -1, 0, 1, 0) == _player_link_word_account, "an ask after a hint"); + /* (the identity's pass refused, too new, in use) */ + CHECK(player_link_refusal_choice(INVALID, IDENTITY, 1, 1, 0) == _player_link_word_account_invalid, + "the identity's refused"); + CHECK(player_link_refusal_choice(TOO_NEW, IDENTITY, 1, 0, 0) == _player_link_word_account_too_new, + "the identity's too new said as Discord's"); + CHECK(player_link_refusal_choice(IN_USE, IDENTITY, 1, 0, 0) == _player_link_word_account_in_use, + "the identity's in use"); + /* (a Discord pass of a master that lists the server refused: a server + that takes only accounts; after the identity's was refused too, the + identity's; another master's, an invite's, or a Discord pass too new or in + use: the Discord link's) */ + CHECK(player_link_refusal_choice(INVALID, 1, 0, 0, 1) == _player_link_word_account_only, + "a listed master's pass refused: not a server of accounts"); + CHECK(player_link_refusal_choice(INVALID, 1, 1, 1, 1) == _player_link_word_account_invalid, + "both refused: not the identity's"); + CHECK(player_link_refusal_choice(INVALID, 1, 0, 0, 0) == NONE_WORD && + player_link_refusal_choice(INVALID, 1, 1, 1, 0) == NONE_WORD, "an invite's Discord pass said as the account's"); + CHECK(player_link_refusal_choice(TOO_NEW, 0, 0, 0, 1) == NONE_WORD && + player_link_refusal_choice(IN_USE, 0, 0, 0, 1) == NONE_WORD, "a Discord account too new or in use"); + /* (other refusals: their own) */ + CHECK(player_link_refusal_choice(9, -1, 1, 0, 0) == NONE_WORD && + player_link_refusal_choice(4, IDENTITY, 1, 0, 0) == NONE_WORD, "another refusal"); +} + +static void case_door(void) +{ + /* (an account that counts: a link master's pass that checked, bound to + its tunnel, or from an address the internet does not route) */ + CHECK(player_link_account_counts(1, 1, 1, 1) && player_link_account_counts(1, 1, 0, 0) && + player_link_account_counts(1, 1, 1, 0), "an account that counts"); + CHECK(!player_link_account_counts(1, 1, 0, 1), "an unbound pass from the internet counts"); + CHECK(!player_link_account_counts(1, 0, 1, 1) && !player_link_account_counts(0, 1, 1, 1), + "a Discord master's account, or a pass that did not check, counts"); + /* (the quiet ask: a machine that hints, not asked lately, with the + setting on; else in, as a guest) */ + CHECK(player_link_door_word(0, 0, 1, 1, 1, 0, 0) == _player_link_door_ask, "a hint not asked for its pass"); + CHECK(player_link_door_word(0, 0, 1, 0, 1, 0, 0) == _player_link_door_in, "asked again within the seconds"); + CHECK(player_link_door_word(0, 0, 1, 1, 0, 0, 0) == _player_link_door_in, "asked with server.link_ask off"); + CHECK(player_link_door_word(0, 0, 0, 1, 1, 0, 0) == _player_link_door_in, "a machine with no hint asked"); + CHECK(player_link_door_word(1, 1, 0, 1, 1, 0, 0) == _player_link_door_in && + player_link_door_word(1, 0, 0, 1, 1, 0, 0) == _player_link_door_in, "a pass shown, counted or not, refused"); + /* (server.link_required: none shown; a hint, asked first; one that does + not count; one whose account plays) */ + CHECK(player_link_door_word(0, 0, 0, 1, 1, 1, 0) == _player_link_door_required, "none shown let in"); + CHECK(player_link_door_word(0, 0, 1, 1, 1, 1, 0) == _player_link_door_ask, "a hint not asked first"); + CHECK(player_link_door_word(0, 0, 1, 0, 1, 1, 0) == _player_link_door_required, "a hint asked already let in"); + CHECK(player_link_door_word(1, 0, 0, 1, 1, 1, 0) == _player_link_door_invalid, "a pass that does not count let in"); + CHECK(player_link_door_word(1, 1, 0, 1, 1, 1, 1) == _player_link_door_in_use, "a second seat let in"); + CHECK(player_link_door_word(1, 1, 0, 1, 1, 1, 0) == _player_link_door_in, "an account that counts refused"); + CHECK(player_link_door_word(1, 1, 0, 1, 1, 0, 1) == _player_link_door_in, "a second seat refused, not required"); +} + int main(int argc, char **argv) { static const struct @@ -367,6 +707,14 @@ int main(int argc, char **argv) { "netlists-refused", case_netlists_refused }, { "ranges-text", case_ranges_text }, { "next-pass", case_next_pass }, + { "hint", case_hint }, + { "hint-unread", case_hint_unread }, + { "site-pass", case_site_pass }, + { "pass-query", case_pass_query }, + { "join-choice", case_join_choice }, + { "first-choice", case_first_choice }, + { "refusal-choice", case_refusal_choice }, + { "door", case_door }, }; int index; diff --git a/tools/harness/tests/release_vectors.json b/tools/harness/tests/release_vectors.json new file mode 100644 index 0000000..c5b2ea8 --- /dev/null +++ b/tools/harness/tests/release_vectors.json @@ -0,0 +1,25 @@ +{ + "contract": "halocustoms-release 1", + "note": "A test key (RFC 8032, section 7.1, TEST 1): never a release's. The manifest's two assets are the contents given, whose sizes and SHA-256 it names; the signature is Ed25519 over the manifest's exact bytes.", + "seed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "public_key": "ed25519:d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + "key_id": "21fe31dfa154a261", + "tag": "build-16", + "network": 41, + "assets": [ + { + "name": "halo-android-release.zip", + "contents": "HaloCustoms release vector: halo-android-release.zip\n", + "size": 53, + "sha256": "8ec92189bf2d3d76c6d458b0586806c3fa9802120cda4a6da30133f6d9e88be4" + }, + { + "name": "halo-linux-release.zip", + "contents": "HaloCustoms release vector: halo-linux-release.zip\n", + "size": 51, + "sha256": "3021024fced94a436bf432c91da2b14c5b517aef3710e51174cf12c56ece6a02" + } + ], + "manifest": "halocustoms-release 1\ntag build-16\nnetwork 41\nasset halo-android-release.zip 53 8ec92189bf2d3d76c6d458b0586806c3fa9802120cda4a6da30133f6d9e88be4\nasset halo-linux-release.zip 51 3021024fced94a436bf432c91da2b14c5b517aef3710e51174cf12c56ece6a02\n", + "signature": "ed25519:acf87e3b9c73d596c40ed255247d731ea0bf0dd2e2149d6820607673a1c63737b63001ec019c3febfb3de4534d3f1b45cbf69e644faaba45a1a8f05026ae6803" +} diff --git a/tools/harness/tests/server_results.c b/tools/harness/tests/server_results.c new file mode 100644 index 0000000..4fa8f8d --- /dev/null +++ b/tools/harness/tests/server_results.c @@ -0,0 +1,189 @@ +/* +SERVER_RESULTS.C (test) + +A dedicated server's results line (server.results_file: +docs/dedicated_server.md, "Results"), as port/linux/game/server_director.c +writes one for each finished multiplayer game: test_server_results.py takes +its builder (under_test.inc) and the real server_json.c (server_json.inc). +*/ + +#include "harness.h" + +#include +#include +#include + +#include "server_json.inc" + +#define csmemset memset + +#include "under_test.inc" + +/* a row of the results: its slot, name, team, counts and account (NULL: +none) */ +static struct server_result_row row_of(long slot, const char *name, long team, long score, long kills, long deaths, + long assists, long headshots, long melee_kills, long best_streak, const char *account_id, const char *account_name) +{ + struct server_result_row row; + + memset(&row, 0, sizeof(row)); + row.seen = TRUE; + row.slot = slot; + snprintf(row.tally.name, sizeof(row.tally.name), "%s", name); + row.tally.team = team; + row.tally.score = score; + row.tally.kills = kills; + row.tally.deaths = deaths; + row.tally.assists = assists; + row.tally.headshots = headshots; + row.tally.melee_kills = melee_kills; + row.tally.best_streak = best_streak; + if (account_id) + { + row.has_account = TRUE; + snprintf(row.account_master, sizeof(row.account_master), "%s", "39f713d0a644253f"); + snprintf(row.account_id, sizeof(row.account_id), "%s", account_id); + snprintf(row.account_name, sizeof(row.account_name), "%s", account_name); + } + return row; +} + +static struct server_results_game game_of(boolean teams) +{ + struct server_results_game game; + + memset(&game, 0, sizeof(game)); + game.game = "halocustoms-eu-1-1794313800-3"; + game.build = "build-300"; + game.map = "bloodgulch"; + game.level = "levels\\test\\bloodgulch\\bloodgulch"; + game.gametype = "slayer"; + game.variant = teams ? "Team Slayer" : "Slayer"; + game.reason = "score"; + game.teams = teams; + game.team_scored = teams; + game.red = 50; + game.blue = 47; + game.started = 1794313800UL; + game.ended = 1794314400UL; + game.duration = 600; + return game; +} + +/* the line written, and read back as one JSON object */ +static const char *line_of(struct server_results_game const *game, struct server_result_row const *rows, long count, + struct server_result_row const *leavers, long leaver_count) +{ + static char text[8192]; + struct server_json json; + struct server_json_document document; + + server_json_begin(&json); + server_results_line(&json, game, rows, count, leavers, leaver_count); + snprintf(text, sizeof(text), "%s", server_json_text(&json)); + server_json_free(&json); + CHECK(!strchr(text, '\n'), "a line of more than one line"); + CHECK(server_json_parse(&document, text, (int)strlen(text), 8), "the line is not JSON: %s", text); + CHECK(document.tokens[0].type == _server_json_object, "the line is no object"); + server_json_document_free(&document); + return text; +} + +int main(int argc, char **argv) +{ + const char *line; + + if (argc < 2) + return 2; + if (!strcmp(argv[1], "line")) + { + struct server_result_row rows[2], leavers[1]; + struct server_results_game game = game_of(TRUE); + + /* (the design's line: the players in the game, the first credited + with its account, then one who left) */ + rows[0] = row_of(0, "P3L42 Nelly", 0, 2, 14, 6, 3, 4, 1, 5, "1569683039846400042", "Nelly"); + rows[1] = row_of(4, "Guest", 1, 1, 7, 9, 0, 0, 2, 2, NULL, NULL); + leavers[0] = row_of(2, "Gone", 1, 0, 3, 4, 1, 1, 0, 1, NULL, NULL); + line = line_of(&game, rows, 2, leavers, 1); + CHECK(!strcmp(line, "{\"v\":1,\"game\":\"halocustoms-eu-1-1794313800-3\",\"build\":\"build-300\"," + "\"map\":\"bloodgulch\",\"level\":\"levels\\\\test\\\\bloodgulch\\\\bloodgulch\",\"gametype\":\"slayer\"," + "\"variant\":\"Team Slayer\",\"teams\":true,\"reason\":\"score\",\"started\":1794313800," + "\"ended\":1794314400,\"duration\":600,\"team_scores\":[50,47],\"players\":[" + "{\"slot\":0,\"name\":\"P3L42 Nelly\",\"team\":0,\"score\":2,\"kills\":14,\"deaths\":6,\"assists\":3," + "\"headshots\":4,\"melee_kills\":1,\"best_streak\":5,\"left\":false," + "\"account\":{\"master\":\"39f713d0a644253f\",\"id\":\"1569683039846400042\",\"name\":\"Nelly\"}}," + "{\"slot\":4,\"name\":\"Guest\",\"team\":1,\"score\":1,\"kills\":7,\"deaths\":9,\"assists\":0," + "\"headshots\":0,\"melee_kills\":2,\"best_streak\":2,\"left\":false,\"account\":null}," + "{\"slot\":2,\"name\":\"Gone\",\"team\":1,\"score\":0,\"kills\":3,\"deaths\":4,\"assists\":1," + "\"headshots\":1,\"melee_kills\":0,\"best_streak\":1,\"left\":true,\"account\":null}]}"), + "the line: %s", line); + return 0; + } + if (!strcmp(argv[1], "no-teams")) + { + struct server_result_row rows[1]; + struct server_results_game game = game_of(FALSE); + + /* (a game without teams: team -1, no team scores) */ + rows[0] = row_of(0, "Solo", -1, 25, 25, 3, 0, 2, 0, 9, NULL, NULL); + line = line_of(&game, rows, 1, NULL, 0); + CHECK(strstr(line, "\"teams\":false,") && strstr(line, "\"team_scores\":[],\"players\":[{\"slot\":0," + "\"name\":\"Solo\",\"team\":-1,"), "a game without teams: %s", line); + return 0; + } + if (!strcmp(argv[1], "nobody-left")) + { + struct server_result_row leavers[2]; + struct server_results_game game = game_of(TRUE); + + /* (every player left: their rows alone, as last seen) */ + leavers[0] = row_of(1, "A", 0, 3, 3, 0, 0, 0, 0, 3, "1569683039846400042", "Nelly"); + leavers[1] = row_of(3, "B", 1, 0, 0, 3, 0, 0, 0, 0, NULL, NULL); + line = line_of(&game, NULL, 0, leavers, 2); + CHECK(strstr(line, "\"players\":[{\"slot\":1,\"name\":\"A\",") && strstr(line, "\"left\":true,\"account\":{") && + strstr(line, "{\"slot\":3,\"name\":\"B\",") && !strstr(line, "\"left\":false"), "only leavers: %s", line); + return 0; + } + if (!strcmp(argv[1], "left")) + { + long machine; + + /* (machine 3 plays in slots 5, credited with its account, and 6; machine + 4 in slot 7) */ + server.state = _server_state_playing; + for (machine = 0; machine < MAXIMUM_MACHINES; machine++) + server.result_account_slots[machine] = NONE; + server.result_rows[5] = row_of(5, "Nelly", 0, 0, 0, 0, 0, 0, 0, 0, "1569683039846400042", "Nelly"); + server.result_rows[5].tally.machine = 3; + server.result_rows[6] = row_of(6, "Nelly(2)", 0, 0, 0, 0, 0, 0, 0, 0, NULL, NULL); + server.result_rows[6].tally.machine = 3; + server.result_rows[7] = row_of(7, "Other", 1, 0, 0, 0, 0, 0, 0, 0, NULL, NULL); + server.result_rows[7].tally.machine = 4; + server.result_account_slots[3] = 5; + /* (the account's player leaves: its row kept, and the machine's + account no other player of it's) */ + server_results_left(5); + CHECK(server.result_leaver_count == 1 && server.result_leavers[0].slot == 5 && + server.result_leavers[0].has_account && !server.result_rows[5].seen, "the leaver's row: %ld", + server.result_leaver_count); + CHECK(server.result_account_slots[3] == RESULT_ACCOUNT_LEFT, "the machine's account credited again: %ld", + server.result_account_slots[3]); + /* (its last player leaves: the machine gone, the index free for + another machine's account; another machine's untouched) */ + server.result_account_slots[4] = NONE; + server_results_left(6); + CHECK(server.result_account_slots[3] == NONE, "the index of a machine gone kept from the next: %ld", + server.result_account_slots[3]); + CHECK(server.result_leaver_count == 2 && server.result_rows[7].seen, "another machine's player"); + /* (a machine's only player, credited, leaving: the index free at once) */ + server.result_account_slots[4] = 7; + server.result_rows[7].has_account = TRUE; + server_results_left(7); + CHECK(server.result_account_slots[4] == NONE, "a machine's only player's index kept: %ld", + server.result_account_slots[4]); + return 0; + } + fprintf(stderr, "no such case\n"); + return 2; +} diff --git a/tools/harness/tests/test_halocustoms_friends.py b/tools/harness/tests/test_halocustoms_friends.py new file mode 100644 index 0000000..8c98517 --- /dev/null +++ b/tools/harness/tests/test_halocustoms_friends.py @@ -0,0 +1,206 @@ +"""Player 1's profile's friends at the HaloCustoms site and the presence the game tells it +(port/linux/src/halocustoms_friends.c, compiled whole): the site's friends' list read as untrusted input (entries +that are not ones, names made printable ASCII and cut, counts within bounds, a game's listing within a listing's +bounds, a list longer than the site sends, an answer of too many tokens), a heartbeat's answer, a heartbeat's body +(a public game's listing in base64, the game's own names escaped and cut, a private game told as playing alone), when +a heartbeat is due, a friend's state in words, and what the game says it does. And the FRIENDS screen's +(port/linux/game/menu_functions.c): its rows' texts, its help, and why a friend's game is not joined from it; and +the main menu's FRIENDS, which opens it.""" +import json +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, ROOT, build, enum_with, function, mutated, read, run # noqa: E402 + +FRIENDS = "port/linux/src/halocustoms_friends.c" +MENUS = "port/linux/game/menu_functions.c" + +CASES = ["read", "read-hostile", "beat-answer", "unverified", "body", "due", "status", "doing", "screen", "join"] + +MENU_FUNCTIONS = ["halocustoms_widen", "friends_row_index", "friends_entries", "friends_row_texts", + "friends_join_refusal", "friends_help"] + +# a fault in the code (in halocustoms_friends.c, or a function of the menus'), and the case that must catch it +NEGATIVE_CONTROLS = { + "names-raw": (("text[out++] = character >= 32 && character < 127 ? (char)character : '?';", + "text[out++] = (char)character;"), None, "read-hostile"), + "nameless-kept": (("|| !member->name[0])\n\t\t{", ")\n\t\t{"), None, "read-hostile"), + "any-status-playing": (("!strcmp(status, \"playing\") ? _halocustoms_friend_playing :", + "strcmp(status, \"menus\") ? _halocustoms_friend_playing :"), None, "read-hostile"), + "game-in-the-menus": (("if (member->status == _halocustoms_friend_playing)\n\t\t\tfriend_game_read", + "if (1)\n\t\t\tfriend_game_read"), None, "read-hostile"), + "listing-any-size": (("if (size < HALOCUSTOMS_LISTING_MINIMUM || size > HALOCUSTOMS_LISTING_MAXIMUM)\n\t\treturn 0;", + "if (size <= 0)\n\t\treturn 0;"), None, "read-hostile"), + "players-unbounded": (("\"players\", 0, 255, -1)", "\"players\", -1000000, 1000000, -1)"), None, + "read-hostile"), + "friends-unbounded": (("list->count < HALOCUSTOMS_FRIENDS_MAXIMUM && server_json_element", + "list->count < 60 && server_json_element"), None, "read-hostile"), + "requests-of-no-id": (("if (request->id <= 0 ||", "if ("), None, "read-hostile"), + "pending-below-kept": (("if (list->pending < list->request_count)", "if (0)"), None, "read-hostile"), + "interval-unbounded": (("\"interval\", HALOCUSTOMS_PRESENCE_INTERVAL,\n\t\tHALOCUSTOMS_PRESENCE_LONGEST_INTERVAL, " + "HALOCUSTOMS_PRESENCE_INTERVAL);\n\tserver_json_document_free", + "\"interval\", 0, 1000000000, HALOCUSTOMS_PRESENCE_INTERVAL);\n\tserver_json_document_free"), + None, "read-hostile"), + "tokens-unbounded": (("MAXIMUM_LIST_TOKENS = 4096,", "MAXIMUM_LIST_TOKENS = 32768,"), None, "read-hostile"), + "error-taken-for-a-beat": (("&& server_json_member(&document, 0, \"shown\") >= 0;", ";"), None, "beat-answer"), + "unverified-by-its-words": (("server_json_member(&document, 0, \"code\")", "server_json_member(&document, 0, \"message\")"), + None, "unverified"), + "unverified-any-code": (("&&\n\t\t!strcmp(code, \"email_unverified\");", ";"), None, "unverified"), + "unverified-unsaid": (("\tcase _halocustoms_friends_said_unverified:\n\t\treturn _friends_help_unverified;\n", ""), + "friends_help", "screen"), + "beat-interval-unbounded": (("*interval = friends_number_get(&document, 0, \"interval\", HALOCUSTOMS_PRESENCE_INTERVAL,", + "*interval = friends_number_get(&document, 0, \"interval\", 0,"), None, "beat-answer"), + "private-game-told": (("if (state == _halocustoms_presence_playing && listing && listing_size >= " + "HALOCUSTOMS_LISTING_MINIMUM &&", "if (listing && listing_size >= HALOCUSTOMS_LISTING_MINIMUM &&"), + None, "body"), + "names-not-cut": (("presence_text(name, HALOCUSTOMS_GAME_GAMETYPE_SIZE, gametype);", + "presence_text(name, sizeof(name), gametype);"), None, "body"), + "controls-sent": (("if ((unsigned char)text[index] >= 32 && (unsigned char)text[index] < 127)", "if (1)"), None, + "body"), + "counts-unbounded": (("if (players >= 0 && players <= 255)", "if (players >= 0)"), None, "body"), + "due-at-once": (("if (since >= (unsigned long)interval)\n\t\treturn 1;", "return 1;"), None, "due"), + "change-at-once": (("return changed && since >= HALOCUSTOMS_PRESENCE_CHANGE_SECONDS;", "return changed;"), None, + "due"), + "interval-of-the-site": (("if (interval < HALOCUSTOMS_PRESENCE_INTERVAL)\n\t\tinterval = HALOCUSTOMS_PRESENCE_INTERVAL;", + ""), None, "due"), + "status-lower": (("\tfriends_upper(text);\n}", "}"), None, "status"), + "menus-keep-names": (("presence_text(doing.map, sizeof(doing.map), playing ? map : \"\");", + "presence_text(doing.map, sizeof(doing.map), map);"), None, "doing"), + "no-change-counted": (("\t\tdoing.changes++;", ""), None, "doing"), + # the screen's + "texts-taken-for-rows": (("&& !strchr(row->name + 11, '_')", ""), "friends_row_index", "screen"), + "rows-past-the-list": (("if (index < 0 || index >= friends_entries(list, requests))", "if (index < 0)"), + "friends_row_texts", "screen"), + "entries-unbounded": (("requests ? HALOCUSTOMS_REQUESTS_MAXIMUM : HALOCUSTOMS_FRIENDS_MAXIMUM)", + "requests ? 100000 : 100000)"), "friends_entries", "screen"), + "refusal-unsaid": (("if (refusal != NONE)\n\t\treturn refusal;", ""), "friends_help", "screen"), + "hidden-unsaid": (("if (!presence_on || list->hidden)", "if (0)"), "friends_help", "screen"), + "private-joined": (("if (!member->has_game || member->status != _halocustoms_friend_playing ||", + "if ("), "friends_join_refusal", "join"), + "other-version-joined": (("if (game->network_version != HALO_PORT_NETWORK_VERSION)\n\t\treturn _friends_help_version;", + ""), "friends_join_refusal", "join"), + "password-joined": (("if (game->locked)\n\t\treturn _friends_help_password;", ""), "friends_join_refusal", "join"), + "offline-joined": (("if (!config_boolean(\"network.online\"))\n\t\treturn _friends_help_online_off;", ""), + "friends_join_refusal", "join"), +} + + +def generated(fault=None, faulty_function=None): + friends = read(FRIENDS) + if fault and faulty_function is None: + friends = mutated(friends, *fault) + menus = read(MENUS) + code = enum_with(menus, "_friends_caption_join") + "\n" + enum_with(menus, "_friends_help_none") + "\n" + for name in MENU_FUNCTIONS: + text = function(menus, name) + if fault and name == faulty_function: + text = mutated(text, *fault) + code += text + "\n" + version = re.search(r"#define HALO_PORT_NETWORK_VERSION (\d+)", read("port/linux/include/halo_port_limits.h")) + return (("halocustoms_friends.h", read("port/linux/src/halocustoms_friends.h")), + ("server_json.h", read("port/linux/src/server_json.h")), + ("friends.inc", friends), + ("version.inc", f"#define HALO_PORT_NETWORK_VERSION {version.group(1)}\n"), + ("under_test.inc", code)) + + +def test_the_screen_has_the_rows_and_buttons_the_game_fills(): + """the generator's FRIENDS screen, and the file written from it, have the rows (friend_row_1 ...) with their + texts and the buttons the game names; its captions and helps are as many as the C's names, in their order""" + sys.path.insert(0, str(ROOT / "tools")) + import port_settings + menus = read(MENUS) + rows = int(re.search(r"#define FRIENDS_ROWS (\d+)", menus).group(1)) + assert rows == port_settings.HALOCUSTOMS_FRIENDS_ROWS + screen = (ROOT / "port/assets/menus/ce/main_menu.halocustoms_friends.xml").read_text() + base = port_settings.HALOCUSTOMS_FRIENDS + for row in range(1, rows + 1): + assert f'' in screen + for leaf in ("name", "status"): + assert f'' in account + + +def test_the_main_menu_has_friends_before_quit(): + """the main menu's FRIENDS, between HALOCUSTOMS and Quit, opens the FRIENDS screen. main_menu.xml's items (written + there by hand: tools/ce_menus.py's full run needs the PC version's tags) are the generator's, in its order and + places: 30 apart, none above where the first was with seven, Quit's box above the screen's foot. The item's + picture has its two frames, registered, drawn at 4x and embedded""" + sys.path.insert(0, str(ROOT / "tools")) + import port_settings + menus = ROOT / "port/assets/menus" + main = (menus / "ce/main_menu.xml").read_text() + block = re.search(r'', main, re.S).group(0) + children = re.findall(r'', block) + names = [name for name, _, _ in children] + assert names == port_settings.MAIN_MENU_ITEMS and names[-3:] == ["halocustoms", "friends", "quit_game"] + places = [(int(x), int(y)) for _, x, y in children] + assert places == [port_settings.MAIN_MENU_ITEM_PLACES[name] for name in names] + tops = [y for _, y in places] + assert {second - first for first, second in zip(tops, tops[1:])} == {30} + assert tops[0] >= 229 and tops[-1] + 33 <= 480 + screen = (menus / "ce/main_menu.halocustoms_friends.xml").read_text() + item = re.search(r']*)>(.*?)', screen, re.S) + assert item and 'bitmap="main_menu/menu_friends"' in item.group(1) + for event in ("a", "start"): + assert f'' in item.group(2) + bitmaps = (menus / "ce/bitmaps.xml").read_text() + frames = re.search(r'(.*?)', bitmaps, re.S) + assert frames + pngs = re.findall(r'', frames.group(1)) + assert pngs == [f"ce/port/main_menu/menu_friends__{index}.png" for index in range(2)] + embedded = json.loads((menus / "menus.json").read_text())["files"] + for png in pngs: + data = (menus / png).read_bytes() + assert png in embedded + # (a PNG's IHDR: its width and height, after the signature and the chunk's length and type) + assert data[12:16] == b"IHDR" + assert (int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big")) == (1024, 256) + + +def test_the_main_menu_friends_picture_is_the_redraws_letters(): + """the main menu's FRIENDS is drawn as its MODS and HALOCUSTOMS are: the redraws' own letters, spaced as they + space them (port_settings.menu_item_frames), as tools/ce_menus.py's full run writes it""" + pytest.importorskip("fontTools") + sys.path.insert(0, str(ROOT / "tools")) + import port_settings + menus = ROOT / "port/assets/menus" + frames = port_settings.menu_item_frames("FRIENDS", menus / "svg") + assert len(frames) == 2 + for index, (svg, width, height) in enumerate(frames): + assert (width, height) == (256, 64) + assert (menus / f"port_svg/main_menu/menu_friends__{index}.svg").read_text() == svg + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("halocustoms_friends", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, faulty_function, case = NEGATIVE_CONTROLS[control] + status, output = run(build("halocustoms_friends", generated(fault, faulty_function)), case) + assert status == CHECK_FAILED, f"the code with a fault ({control}) passed '{case}': the test cannot see it" diff --git a/tools/harness/tests/test_halocustoms_kills.py b/tools/harness/tests/test_halocustoms_kills.py new file mode 100644 index 0000000..c956b41 --- /dev/null +++ b/tools/harness/tests/test_halocustoms_kills.py @@ -0,0 +1,427 @@ +"""What halocustoms_stats.c records of each local player's game (port/linux/game/halocustoms_stats.c, and +port/linux/game/network_damage.c's distributed_count_kill): in multiplayer, the kills by a headshot and by a melee +blow as each machine has them of a killing blow's event (the host as it makes it, a client as it comes, twice), the +longest run of kills, and the scoreboard; in a campaign or co-op level session, the kills of units by the dead unit's +class (alone and on a co-op host as the blow is dealt; on a co-op client by the host's word, twice, or once for a body +destroyed outright), allies apart, each once, through a checkpoint's revert; the deaths; whether the level was won; +each local player's line into their own profile's account; the profiles named to their accounts as a map loads; and +the rank before each local player's name, their own profile's.""" +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, constant, enum_with, function, mutated, read, run, structure # noqa: E402 + +STATS = "port/linux/game/halocustoms_stats.c" +DAMAGE = "port/linux/game/network_damage.c" + +CASES = ["streak", "counted", "server-tallies", "blow", "classes", "solo", "coop", "split", "scoreboard", "profiles", + "presence", + "rank-colour"] + +# the recorder's code, in its file's order: its enums and arrays (by their first member or line), its state, and its +# functions (local_player_won is the test's own: who won is not what is tested) +STATS_PIECES = [ + ("enum", "MAXIMUM_COUNTED_UNITS = 32"), ("enum", "_record_none,"), + ("statement", "static char const *const record_mode_names"), ("enum", "_enemy_grunt,"), + ("statement", "static char const *const enemy_class_names"), ("statement", "static char const *const difficulty_names"), + ("structure", "halocustoms_local"), ("structure", "halocustoms_record"), + ("statement", "static struct halocustoms_record record;"), ("structure", "halocustoms_tally"), + ("statement", "static struct halocustoms_tally server_tallies[MAXIMUM_TALLIED_PLAYERS];"), + ("function", "server_tally"), + *[("function", name) for name in [ + "profile_name_ascii", "halocustoms_profile_slot_of", "halocustoms_player_one_profile", "local_player_profile", + "profiles_name", + "halocustoms_rank_name"]], + ("statement", "static real_rgb_color const tier_colors[10] ="), + *[("function", name) for name in [ + "name_prestige", "halocustoms_rank_color", "record_kind", "record_session", "local_record_of", "presence_told", + "halocustoms_stats_game_started", + "halocustoms_stats_tick", "halocustoms_stats_kill", "halocustoms_stats_player_tallies", "enemy_class", + "dead_unit_note", "unit_killed", + "halocustoms_stats_blow", "halocustoms_stats_death_told", "halocustoms_stats_reverted", + "halocustoms_stats_level_won", "halocustoms_stats_round_over"]], + ("enum", "MATCH_KEY_MAP_LENGTH = 40"), ("structure", "match_line"), + *[("function", name) for name in [ + "line_character", "line_text", "line_string_character", "line_string", "line_wide_string", "line_number", + "line_member_number", "match_map_name", "match_key_build", "line_reset", "line_begin", "line_end", "player_score", + "line_scoreboard", "multiplayer_line_build", "session_line_build", "halocustoms_stats_game_over", + "halocustoms_stats_map_ended"]], +] + +# a fault in a piece (a function, by its name), and the case that must catch it +NEGATIVE_CONTROLS = { + # the run of kills + "streak-is-the-run-at-the-tick": (("local->best_streak = MAX(local->best_streak, (long)player->statistics." + "kills_in_a_row);", + "local->best_streak = (long)player->statistics.kills_in_a_row;"), + "halocustoms_stats_tick", "streak"), + "streak-is-the-run-at-the-end": (("MAX(local->best_streak, (long)player->statistics.kills_in_a_row), MIN(", + "(long)player->statistics.kills_in_a_row, MIN("), + "halocustoms_stats_game_over", "streak"), + "streak-without-the-run-at-the-end": (("MAX(local->best_streak, (long)player->statistics.kills_in_a_row), MIN(", + "local->best_streak, MIN("), "halocustoms_stats_game_over", "streak"), + "streak-watched-after-the-end": (("if (record.kind == _record_none || record.recorded ||", + "if (record.kind == _record_none ||"), "halocustoms_stats_tick", "streak"), + "presence-untold": (("\tprofiles_name();\n\tpresence_told();", "\tprofiles_name();"), + "halocustoms_stats_game_started", "presence"), + "presence-menus-playing": (("halocustoms_presence_game(record.kind != _record_none, map, gametype);", + "halocustoms_presence_game(1, map, gametype);"), "presence_told", "presence"), + "presence-level-path": (("\t\t\tmap = character + 1;", "\t\t\t(void)character;"), "presence_told", "presence"), + "presence-gametype-raw": (("character >= 32 && character < 127 ? (char)character : '?'", "(char)character"), + "presence_told", "presence"), + "record-of-the-game-before": (("memset(&record, 0, sizeof(record));", ""), "halocustoms_stats_game_started", + "counted"), + # the kills counted + "kill-told-twice-counted-twice": (("if (record.counted_units[index] == dead_unit_index)\n\t\t\treturn;", ";"), + "halocustoms_stats_kill", "counted"), + "only-the-last-unit-known": (("record.counted_units[record.counted % MAXIMUM_COUNTED_UNITS] = dead_unit_index;", + "record.counted_units[0] = dead_unit_index;"), "halocustoms_stats_kill", "counted"), + "fewer-units-known": (("record.counted_units[record.counted % MAXIMUM_COUNTED_UNITS] = dead_unit_index;", + "record.counted_units[record.counted % (MAXIMUM_COUNTED_UNITS - 1)] = dead_unit_index;"), + "halocustoms_stats_kill", "counted"), + "melee-headshot-is-a-headshot": (("if (melee)\n\t\tlocal->melee_kills++;\n\telse\n\t\tlocal->headshots++;", + "if (!headshot)\n\t\tlocal->melee_kills++;\n\telse\n\t\tlocal->headshots++;"), + "halocustoms_stats_kill", "counted"), + "any-players-kills": (("(local = local_record_of(killing_player_index)) == NULL", + "(local = &record.locals[0]) == NULL"), "halocustoms_stats_kill", "counted"), + "kills-after-the-end": (("if (record.kind != _record_multiplayer || record.recorded || !game_engine ||", + "if (record.kind != _record_multiplayer || !game_engine ||"), "halocustoms_stats_kill", + "counted"), + "kills-with-no-game": (("if (record.kind != _record_multiplayer || record.recorded || !game_engine ||", + "if (record.kind != _record_multiplayer || record.recorded ||"), "halocustoms_stats_kill", + "counted"), + # a dedicated server's tallies of every player + "server-tallies-none": (("tally = server_tally(killing_player_index);", ";"), + "halocustoms_stats_kill", "server-tallies"), + "server-melee-headshot-is-a-headshot": (("if (tally && melee)", "if (tally && !headshot)"), + "halocustoms_stats_kill", "server-tallies"), + "server-kill-told-twice": (("if (record.counted_units[index] == dead_unit_index)\n\t\t\treturn;", ";"), + "halocustoms_stats_kill", "server-tallies"), + "tallies-on-a-client": (("if (halo_server_mode())\n\t\ttally = server_tally(", "if (1)\n\t\ttally = server_tally("), + "halocustoms_stats_kill", "server-tallies"), + "server-streak-not-watched": (("if (record.kind == _record_multiplayer && halo_server_mode() && player_data)", + "if (FALSE)"), "halocustoms_stats_tick", "server-tallies"), + "server-streak-is-the-run-now": (("tally->best_streak = MAX(tally->best_streak,", "tally->best_streak = MAX(0,"), + "halocustoms_stats_tick", "server-tallies"), + "tally-of-the-player-before": (("if (tally->player_index != player_index)", "if (!tally->player_index)"), + "server_tally", "server-tallies"), + "server-tallies-of-the-game-before": (("memset(server_tallies, 0, sizeof(server_tallies));", ""), + "halocustoms_stats_game_started", "server-tallies"), + # the rank's colour + "rank-colour-over-a-team": (("if (variant && variant->universal_variant.teams)\n\t\t\treturn FALSE;", ""), + "halocustoms_rank_color", "rank-colour"), + "rank-colour-any-prestige": (("if (prestige < 1 || prestige > 10)", "if (prestige < 1)"), + "halocustoms_rank_color", "rank-colour"), + "tallies-out-of-a-game": (("if (record.kind != _record_multiplayer || player_index < 0 ||", + "if (player_index < 0 ||"), "halocustoms_stats_player_tallies", "server-tallies"), + "more-headshots-than-kills": (("MIN(local->headshots, kills - by_melee),", "local->headshots,"), + "halocustoms_stats_game_over", "counted"), + "more-melee-kills-than-kills": (("by_melee = MIN(local->melee_kills, kills);", "by_melee = local->melee_kills;"), + "halocustoms_stats_game_over", "counted"), + "kills-below-none": (("kills = MAX((long)player->statistics.kills[0], 0);", + "kills = (long)player->statistics.kills[0];"), "halocustoms_stats_game_over", "counted"), + "line-without-them": (('line_member_number(line, "headshots", headshots);', ""), "multiplayer_line_build", + "counted"), + # the blow + "friendly-fire-counted": (("TEST_FLAG(event->kill_flags, _damage_event_friendly_fire_bit) ||", ""), + "distributed_count_kill", "blow"), + "anothers-blow-counted": (("event->damage.owner_player_index != event->player_index ||", ""), + "distributed_count_kill", "blow"), + "any-damage-read": (("||\n\t\t!tag_index_is_group(event->damage.definition_index, DAMAGE_EFFECT_DEFINITION_TAG))", + ")"), "distributed_count_kill", "blow"), + "headshot-none-reaching-the-body": (("if (unit && event->body_damage > 0.0f)", "if (unit)"), + "distributed_count_kill", "blow"), + "headshot-at-any-material": ((" &&\n\t\t\tTEST_FLAG(TAG_BLOCK_GET_ELEMENT(&collision_model->resistance.materials, " + "material_index,\n\t\t\t\tstruct damage_resistance_material)->flags, " + "_damage_material_head_bit))", ")"), "distributed_count_kill", "blow"), + "headshot-of-any-damage": (("headshot = TEST_FLAG(definition->flags, _damage_can_cause_headshots_bit) ||", + "headshot = TRUE ||"), "distributed_count_kill", "blow"), + "no-headshot-that-kills": (("headshot = TEST_FLAG(definition->flags, _damage_can_cause_headshots_bit) ||", + "headshot ="), "distributed_count_kill", "blow"), + "no-headshot-doubled": (("TEST_FLAG(definition->flags, _damage_can_cause_multiplayer_headshots_bit) ||", ""), + "distributed_count_kill", "blow"), + "no-swat": (("(game_modes_mode() == _game_mode_swat &&", "(FALSE &&"), "distributed_count_kill", "blow"), + "swat-in-every-game": (("(game_modes_mode() == _game_mode_swat &&", "(TRUE &&"), "distributed_count_kill", + "blow"), + "swat-of-any-damage": (("category == _damage_category_shotgun));", "category == _damage_category_shotgun || TRUE));"), + "distributed_count_kill", "blow"), + "no-indirect-material": (("event->material_index < collision_model->resistance.materials.count ? " + "event->material_index :\n\t\t\t\tcollision_model->resistance." + "indirect_damage_material_index;", + "event->material_index < collision_model->resistance.materials.count ? " + "event->material_index :\n\t\t\t\tNONE;"), "distributed_count_kill", "blow"), + "material-of-any-index": (("material_index = event->material_index >= 0 &&\n\t\t\t\tevent->material_index < " + "collision_model->resistance.materials.count ? event->material_index :", + "material_index = event->material_index >= 0 ? event->material_index :"), + "distributed_count_kill", "blow"), + "indirect-material-of-any-index": (("if (collision_model && material_index >= 0 && material_index < " + "collision_model->resistance.materials.count &&", + "if (collision_model && material_index >= 0 &&"), "distributed_count_kill", + "blow"), + "unit-not-had-read": (("unit = unit_here ?", "unit = TRUE ?"), "distributed_count_kill", "blow"), + "no-melee": (("definition->category == _damage_category_melee, headshot);", "FALSE, headshot);"), + "distributed_count_kill", "blow"), + "melee-of-any-damage": (("definition->category == _damage_category_melee, headshot);", + "definition->category != _damage_category_melee, headshot);"), "distributed_count_kill", + "blow"), + # a unit's class + "carrier-before-infection": (("if (strstr(path, \"infection\"))\n\t\t\treturn _enemy_flood_infection;\n\t\t" + "if (strstr(path, \"carrier\"))\n\t\t\treturn _enemy_flood_carrier;", + "if (strstr(path, \"carrier\"))\n\t\t\treturn _enemy_flood_carrier;\n\t\t" + "if (strstr(path, \"infection\"))\n\t\t\treturn _enemy_flood_infection;"), + "enemy_class", "classes"), + "case-kept": (("character >= 'A' && character <= 'Z' ? (char)(character - 'A' + 'a') : character", + "character"), "enemy_class", "classes"), + "elite-before-hunter": (("if (strstr(path, \"hunter\"))\n\t\treturn _enemy_hunter;\n\tif (strstr(path, " + "\"elite\"))\n\t\treturn _enemy_elite;", + "if (strstr(path, \"elite\"))\n\t\treturn _enemy_elite;\n\tif (strstr(path, " + "\"hunter\"))\n\t\treturn _enemy_hunter;"), "enemy_class", "classes"), + "combat-elite-an-elite": (('if (strstr(path, "flood"))', 'if (strstr(path, "flood") && !strstr(path, "elite"))'), + "enemy_class", "classes"), + "infection-without-flood": (('if (strstr(path, "flood"))\n\t{', 'if (strstr(path, "flood") || ' + 'strstr(path, "infection") || strstr(path, "carrier"))\n\t{'), "enemy_class", + "classes"), + # a level session's kills where the blow is dealt + "dead-body-destroyed-a-kill": (("if (!dead_unit_note(object_index) && destroyed)", + "if (!dead_unit_note(object_index) && destroyed && FALSE)"), + "halocustoms_stats_blow", "solo"), + "destroyed-outright-no-kill": (("if (!dead_unit_note(object_index) && destroyed)", + "if ((dead_unit_note(object_index), destroyed))"), "halocustoms_stats_blow", "solo"), + "ally-an-enemy": (("if (unit && friendly)\n\t\tlocal->allies_killed++;", "if (0)\n\t\tlocal->allies_killed++;"), + "unit_killed", "solo"), + "own-death-a-kill": (("killing_player_index == NONE || killing_player_index == victim_player_index ||", + "killing_player_index == NONE ||"), "unit_killed", "solo"), + "any-players-blow": (("(local = local_record_of(killing_player_index)) == NULL", + "(local = &record.locals[0]) == NULL"), "unit_killed", "solo"), + "vehicles-killed": (("object_try_and_get_and_verify_type(object_index, _object_mask_biped)) == NULL", + "object_try_and_get_and_verify_type(object_index, _object_mask_biped | _object_mask_vehicle)) " + "== NULL"), "halocustoms_stats_blow", "solo"), + "client-counts-its-replays": (("game_connection() == _game_connection_network_client ||", ""), + "halocustoms_stats_blow", "solo"), + "any-blow-a-kill": (("!TEST_FLAG(being_damaged_flags, _object_being_damaged_body_depleted_bit) ||", ""), + "halocustoms_stats_blow", "solo"), + "no-dead-unit-ring": (("record.dead_units[record.dead % MAXIMUM_DEAD_UNITS] = unit_index;", + "record.dead_units[0] = unit_index;"), "dead_unit_note", "solo"), + "revert-forgets-the-bodies": (("if (unit && TEST_FLAG(unit->object.damage_flags, _object_dead_bit))", "if (0)"), + "halocustoms_stats_reverted", "solo"), + "revert-forgets-nothing": (("if (unit && TEST_FLAG(unit->object.damage_flags, _object_dead_bit))", "if (unit)"), + "halocustoms_stats_reverted", "solo"), + "won-not-noted": (("if (record_session())\n\t\trecord.completed = TRUE;", "if (record_session())\n\t\t;"), + "halocustoms_stats_level_won", "solo"), + "deaths-each-tick": (("if (local->alive && !alive)", "if (!alive)"), "halocustoms_stats_tick", "solo"), + "deaths-never": (("if (local->alive && !alive)", "if (0)"), "halocustoms_stats_tick", "solo"), + "session-of-unseen-players": (("if (local->player_index == NONE)\n\t\t\tcontinue;", ""), + "halocustoms_stats_map_ended", "solo"), + "session-written-twice": (("record.recorded = TRUE;\n\tfor (local_player_index = 0;", + "for (local_player_index = 0;"), "halocustoms_stats_map_ended", "solo"), + "session-in-a-multiplayer-game": (("return (record.kind == _record_campaign || record.kind == _record_coop) && " + "!record.recorded;", "return record.kind != _record_none && !record.recorded;"), + "record_session", "solo"), + "kills-not-their-sum": (("kills += local->enemies[index];", "kills += index > 0;"), "session_line_build", "solo"), + "a-class-left-out": (("for (index = 0; index < NUMBER_OF_ENEMY_CLASSES; index++)\n\t{\n\t\tif (index > 0)", + "for (index = 0; index < NUMBER_OF_ENEMY_CLASSES - 1; index++)\n\t{\n\t\tif (index > 0)"), + "session_line_build", "solo"), + "difficulty-of-none": (("difficulty_names[PIN(difficulty, 0, (short)NUMBEROF(difficulty_names) - 1)]", + "difficulty_names[1]"), "session_line_build", "solo"), + "duration-in-ticks": (("record.ticks / TICKS_PER_SECOND", "record.ticks"), "halocustoms_stats_map_ended", "solo"), + "nameless-player-named-so": (("if (player_name && player_name[0])", "if (player_name)"), "line_begin", "solo"), + # a co-op client's, by the host's word + "word-twice-two-kills": (("if (!dead_unit_note(object_index))\n\t\treturn;", "dead_unit_note(object_index);"), + "halocustoms_stats_death_told", "coop"), + "lacked-unit-of-a-class": (("local->enemies[unit ? enemy_class(tag_get_name(unit->definition_index)) : " + "_enemy_other]++;", "local->enemies[unit ? enemy_class(tag_get_name(" + "unit->definition_index)) : _enemy_grunt]++;"), "unit_killed", "coop"), + "dead-copy-destroyed-a-kill": (("if (destroyed && (!unit || TEST_FLAG(unit->object.damage_flags, " + "_object_dead_bit)))", "if (destroyed && !unit)"), "halocustoms_stats_death_told", + "coop"), + "destroyed-never-told": (("if (destroyed && (!unit || TEST_FLAG(unit->object.damage_flags, _object_dead_bit)))", + "if (destroyed)"), "halocustoms_stats_death_told", "coop"), + "lacked-destroyed-a-kill": (("if (destroyed && (!unit || TEST_FLAG(unit->object.damage_flags, _object_dead_bit)))", + "if (destroyed && unit && TEST_FLAG(unit->object.damage_flags, _object_dead_bit))"), + "halocustoms_stats_death_told", "coop"), + "no-allies-told": (("unit && owner_team_index != NONE && !game_team_is_enemy(unit->object.owner_team_index, " + "owner_team_index));", "FALSE);"), "halocustoms_stats_death_told", "coop"), + "host-counts-the-word": (("if (!record_session() || game_connection() != _game_connection_network_client)", + "if (!record_session() || game_connection() == _game_connection_local)"), + "halocustoms_stats_death_told", "coop"), + "client-revert-keeps-bodies": (("if (game_connection() != _game_connection_network_client)", "if (1)"), + "halocustoms_stats_reverted", "coop"), + "round-over-a-hosts-win": (("record.kind == _record_coop && game_connection() == _game_connection_network_client", + "record.kind == _record_coop"), "halocustoms_stats_round_over", "coop"), + "round-over-never-won": (("if (record_session() && record.kind == _record_coop && game_connection() == " + "_game_connection_network_client)\n\t\trecord.completed = TRUE;", + "if (record_session() && record.kind == _record_coop && game_connection() == " + "_game_connection_network_client)\n\t\t;"), "halocustoms_stats_round_over", "coop"), + # each local player's own + "sessions-first-players-account": (("halocustoms_match_record(local->slot, local_player_index, line.text);", + "halocustoms_match_record(record.locals[0].slot, local_player_index, " + "line.text);"), "halocustoms_stats_map_ended", "split"), + "sessions-first-player-alone": (("local_player_index < MAXIMUM_LOCAL_PLAYERS; local_player_index++)", + "local_player_index < 1; local_player_index++)"), "halocustoms_stats_map_ended", + "split"), + "games-first-players-account": (("halocustoms_match_record(local->slot, local_player_index, line.text);", + "halocustoms_match_record(record.locals[0].slot, local_player_index, " + "line.text);"), "halocustoms_stats_game_over", "split"), + "games-first-player-alone": (("local_player_index = local_player_get_next(local_player_index))\n\t{\n\t\tlong " + "player_index = local_player_get_player_index(local_player_index);\n\t\tstruct " + "halocustoms_local *local;\n\t\tstruct player_datum *player;\n\t\tlong kills,", + "local_player_index = NONE)\n\t{\n\t\tlong player_index = " + "local_player_get_player_index(local_player_index);\n\t\tstruct halocustoms_local " + "*local;\n\t\tstruct player_datum *player;\n\t\tlong kills,"), + "halocustoms_stats_game_over", "split"), + # the scoreboard + "me-on-every-row": (("if (indices[row] == own_player_index)\n\t\t\tline_text(line, \",\\\"me\\\":true\");", + "line_text(line, \",\\\"me\\\":true\");"), "line_scoreboard", "split"), + "every-row": (("rows = MIN(count, MAXIMUM_SCOREBOARD_ROWS);", "rows = count;"), "line_scoreboard", "scoreboard"), + "own-row-left-out": (("if (own_row >= rows && rows > 0)", "if (0)"), "line_scoreboard", "scoreboard"), + "rows-unsorted": (("scores[index - 1] < score ||", "FALSE ||"), "line_scoreboard", "scoreboard"), + "ties-as-they-come": (("(scores[index - 1] == score && kills[index - 1] < player_kills)", "FALSE"), + "line_scoreboard", "scoreboard"), + "teams-without-teams": (("team_game ? player->team_index : -1", "player->team_index"), "line_scoreboard", + "scoreboard"), + "names-unescaped": (("if (character == '\"' || character == '\\\\')", "if (0)"), "line_string_character", + "scoreboard"), + "unprintable-dropped": (("else\n\t{\n\t\tline_character(line, '?');\n\t}", "else\n\t{\n\t}"), + "line_string_character", "scoreboard"), + "unprintable-kept": (("else if (character >= 32 && character < 127)", "else if (character >= 7)"), + "line_string_character", "scoreboard"), + "nameless-row-empty": (("if (player->name[0])\n\t\t\tline_wide_string(line, player->name, " + "NUMBEROF(player->name));\n\t\telse\n\t\t\tline_string(line, \"unknown\");", + "line_wide_string(line, player->name, NUMBEROF(player->name));"), "line_scoreboard", + "scoreboard"), + # the profiles + "player-one-not-named-so": (("halocustoms_profile_slot_of(player_one, HALOCUSTOMS_PROFILE_PLAYER_ONE | " + "HALOCUSTOMS_PROFILE_USED);", "halocustoms_profile_slot_of(player_one, " + "HALOCUSTOMS_PROFILE_USED);"), "profiles_name", "profiles"), + "profiles-on-a-server": (("if (halo_server_mode())\n\t\treturn;", ""), "profiles_name", "profiles"), + "local-slots-not-kept": (("record.locals[index].slot = halocustoms_profile_slot_of(", + "(void)halocustoms_profile_slot_of("), "profiles_name", "profiles"), + "first-player-of-no-profile": (("return local_player_index == 0 ? halocustoms_player_one_profile() :", + "return local_player_index == NONE ? halocustoms_player_one_profile() :"), + "local_player_profile", "profiles"), + "player-one-not-the-last-used": (("if (profile_index != NONE && player_profile_get(profile_index, &profile))\n\t\t" + "return profile_index;", "if (0)\n\t\treturn profile_index;"), + "halocustoms_player_one_profile", "profiles"), + "rank-of-player-one-for-all": (("local_player_profile(local_player_index)", "local_player_profile(0)"), + "halocustoms_rank_name", "profiles"), + "rank-names-anyone-player-one": (("(local_player_index == 0 ? HALOCUSTOMS_PROFILE_PLAYER_ONE : 0)", + "HALOCUSTOMS_PROFILE_PLAYER_ONE"), "halocustoms_rank_name", "profiles"), + "rank-names-player-one-as-anyone": (("(local_player_index == 0 ? HALOCUSTOMS_PROFILE_PLAYER_ONE : 0)", "0"), + "halocustoms_rank_name", "profiles"), + "name-not-ascii": (("name[index] >= 32 && name[index] < 127 ? (char)name[index] : '?'", "(char)name[index]"), + "profile_name_ascii", "profiles"), +} + + +def statement(source, start): + """The statement that begins a line with start, to its semicolon.""" + at = re.search(r"^" + re.escape(start), source, re.M).start() + return source[at:source.index(";", at) + 1] + + +def piece(source, kind, name): + if kind == "enum": + return enum_with(source, name) + if kind == "statement": + return statement(source, name) + if kind == "structure": + return structure(source, name) + return function(source, name) + + +def generated(fault=None, faulty_function=None): + stats, damage = read(STATS), read(DAMAGE) + config = "\n\n".join([ + "enum { NO_PLAYER = %d };" % constant(read("port/linux/game/network_distributed.h"), "NO_PLAYER"), + enum_with(read("source/game/game_engine.h"), "_game_mode_swat"), + enum_with(read("source/game/game.h"), "_game_connection_local = 0"), + "enum { _scenario_type_solo = %d, _scenario_type_multiplayer, _scenario_type_main_menu };" % + constant(read("source/scenario/scenario_definitions.h"), "_scenario_type_solo"), + enum_with(read("source/objects/damage.h"), "_object_being_damaged_body_depleted_bit = 0"), + enum_with(read("source/objects/objects.h"), "_object_passed_body_damage_threshold_bit = 0"), + enum_with(damage, "_damage_event_friendly_fire_bit = 0"), + enum_with(damage, "_damage_can_cause_headshots_bit = 1"), + enum_with(damage, "_damage_category_bullet = 2"), + enum_with(damage, "_damage_material_head_bit = 0"), + structure(damage, "distributed_damage"), + structure(damage, "distributed_damage_event"), + ]) + code = [] + for kind, name in STATS_PIECES + [("function", "distributed_count_kill")]: + text = piece(damage if name == "distributed_count_kill" else stats, kind, name) + if fault and name == faulty_function: + text = mutated(text, *fault) + code.append(text) + return (("halocustoms_account.h", read("port/linux/src/halocustoms_account.h")), + ("halocustoms_friends.h", read("port/linux/src/halocustoms_friends.h")), ("config.inc", config + "\n"), + ("under_test.inc", "\n\n".join(code) + "\n")) + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("halocustoms_kills", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, faulty_function, case = NEGATIVE_CONTROLS[control] + status, output = run(build("halocustoms_kills", generated(fault, faulty_function)), case) + assert status == CHECK_FAILED, (f"the code with a fault ({control}) passed '{case}': the test cannot see it\n" + f"{output}") + + +def test_both_machines_count_from_the_event(): + """The host counts a kill from the event it has just made for its clients, and a client from the event as it + comes, before anything that depends on its own copy of the unit: the same word on every machine. A level + session's kill is counted where the blow is dealt (a local game's too, before the netcode's early return), and on + a client by the host's word of a killing blow, or of a blow that destroyed a body, before its copy dies of it.""" + damage = read(DAMAGE) + host = function(damage, "network_damage_aftermath") + assert host.index("event->kind = _damage_event_kill;") < host.index("distributed_get_death(") < \ + host.index("SET_FLAG(event->kill_flags, _damage_event_friendly_fire_bit, friendly_fire);") < \ + host.index("distributed_count_kill(event, TRUE);") + assert host.index("halocustoms_stats_blow(object_index, damage->owner_player_index, being_damaged_flags, " + "victim_player_index);") < host.index("if (game_connection() != _game_connection_network_server)") + client = function(damage, "network_damage_handle_events") + kill = client.index("case _damage_event_kill:") + counted = client.index("distributed_count_kill(event, network_objects_client_has(event->object_index));") + told = client.index("halocustoms_stats_death_told(event->object_index, network_objects_client_has(event->object_index),", + kill) + assert client.index("!distributed_damage_to_data(&event->damage, &damage)") < kill < counted < told < \ + client.index("_object_dead_bit", kill) < client.index("damage_replay_kill(") + aftermath = client.index("case _damage_event_aftermath:") + gibbed = client.index("halocustoms_stats_death_told(", aftermath) + assert aftermath < gibbed < client.index("damage_replay_aftermath(", aftermath) < kill + assert "TEST_FLAG(event->being_damaged_flags, _object_being_damaged_body_depleted_bit)" in \ + client[aftermath:client.index("damage_replay_aftermath(", aftermath)] + assert damage.count("distributed_count_kill(") == 3 and damage.count("halocustoms_stats_death_told(") == 2 + + +def test_the_game_tells_the_session_its_moments(): + """A level session's end at the map's unloading and the process's; its win (alone or as the host, and on a co-op + client the host's end of the round); a checkpoint's revert, alone or a host's (the game state loaded again) and a + client's (the host's word of it); each local player's rank before their name in a network game; and a profile + renamed keeps its account.""" + engine = read("source/game/game_engine.c") + assert "halocustoms_stats_map_ended(); /* port:" in function(engine, "game_engine_dispose_from_old_map") + assert "halocustoms_stats_round_over(); /* port:" in function(engine, "game_engine_switch_to_postgame") + assert "halocustoms_stats_level_won(); /* port:" in function(read("source/main/main.c"), "main_won_map_private") + state = read("source/saved games/game_state.c") + procs = state[state.index("static game_state_after_load_proc after_load_procs[] ="):] + assert "halocustoms_stats_reverted\n};" in procs[:procs.index("};") + 2] + coop = read("port/linux/game/network_coop.c") + reverted = coop[coop.index("case _coop_event_reverted:"):] + assert reverted.index("halocustoms_stats_reverted();") < reverted.index("break;") + client = function(read("source/networking/network_client_manager.c"), "network_game_client_add_player") + assert client.index("ustrncpy(player.name, profile.player_name") < \ + client.index("halocustoms_rank_name(local_player_index, player.name, NETWORK_PLAYER_NAME_LENGTH);") + renamed = read("source/saved games/saved_game_files.c") + assert renamed.index("halocustoms_profile_moved(file.path, new_path);") < \ + renamed.index("if (XDeleteSaveGame(root_path, file.display_name))") + assert "halocustoms_stats_game_started(); /* port:" in function(engine, "game_engine_initialize_for_new_map") + assert "atexit(halocustoms_stats_at_exit);" in function(read(STATS), "halocustoms_stats_game_started") diff --git a/tools/harness/tests/test_halocustoms_matches.py b/tools/harness/tests/test_halocustoms_matches.py new file mode 100644 index 0000000..1c85031 --- /dev/null +++ b/tools/harness/tests/test_halocustoms_matches.py @@ -0,0 +1,347 @@ +"""What the HaloCustoms accounts make of the game's records (port/linux/src/halocustoms_account.c): an account for +each player profile, its files in the profile's folder, player 1's taking an older build's account and +halocustoms.token, a renamed profile keeping its files; a match's or a level session's line as a request has it, +within the site's bounds; the marker of what was reported, the lines not yet reported, a request's batch of them, what +a report's answer does, an account's state, the device's name, the member's page; and what the web build makes of the +site's word on who is signed in.""" +import re +import subprocess +import sys +import tempfile +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, structure # noqa: E402 + +ACCOUNT = "port/linux/src/halocustoms_account.c" + +CASES = ["iso", "recorder", "marker", "pending", "batch", "nul", "line", "state", "session", "report", "profiles", + "migration", "rename", "device", "open", "identity"] + +# the account's functions, in its file's order (halocustoms_account_session and halocustoms_account_initialize are +# the test's own: the web build's, and a thread's start, are not what is tested) +ACCOUNT_FUNCTIONS = [ + "now_seconds", "files_make", "file_exists", "file_folder", "folder_path", "word_valid", "text_get", + "ascii_text_get", "number_get", "count_get", "optional_count_get", "answer_object", "profile_read", "slot_at", + "profile_take", "profile_forget", "link_forget", "link_state", "session_state", "rank_file_read", + "rank_file_write", "rank_tag_write", "matches_append", "identity_holder", "identity_show", + "pass_forget", "session_answer", "session_token_read", "session_rank_write", "session_ask", "write_file", "key_file_read", "key_file_write", "key_file_delete", "file_copy", "lines_reset", "hash_bytes", + "marker_hash", "marker_check", "marker_read", "lines_ready", "marker_write", "iso_time", "clamp_between", + "clamp_count", "count_pass", "@modes", "match_mode_get", "scoreboard_pass", "enemies_get", "match_line_take", + "line_read", "lines_take", "pending_count", "batch_read", "older_matches_move", "device_character", + "device_name", "report_retry_later", "matches_report", "older_account_move", "halocustoms_profile_slot", + "halocustoms_profile_moved", "account_state", "halocustoms_account_state", "halocustoms_sign_out", + "halocustoms_matches_pending", + "halocustoms_open_site", "halocustoms_rank_tag", "halocustoms_match_record"] + +# a fault in the code, the piece it is in (a function, or what pieces() names otherwise), and the case that must +# catch it +NEGATIVE_CONTROLS = { + # player 1's profile's pass for servers + "identity-never-switched": (("(!account.identity_named || account.identity_slot != found)", + "!account.identity_named"), "halocustoms_profile_slot", "identity"), + "identity-of-anyone": (("if (flags & HALOCUSTOMS_PROFILE_PLAYER_ONE && (!account.identity_named ||", + "if ((!account.identity_named ||"), "halocustoms_profile_slot", "identity"), + "identity-pass-not-asked": (("slot->pass_wanted = !slot->has_pass;", ";"), "halocustoms_profile_slot", + "identity"), + "identity-old-shown": (("\tplayer_link_identity_forget();\n\t/* (one player_link.c takes no more", + "\t/* (one player_link.c takes no more"), "identity_show", "identity"), + "identity-refused-kept": (("\t\tholder->has_pass = 0;\n", ""), "identity_show", "identity"), + "pass-kept-at-sign-out": (("\tpass_forget(signed_out);", ""), "halocustoms_sign_out", "identity"), + "pass-forgotten-still-shown": (("\tif (shown)\n\t\tplayer_link_identity_forget();", ""), "pass_forget", + "identity"), + "march-of-a-leap-year": (("if (month <= 2)\n\t\tyear++;", "if (0)\n\t\tyear++;"), "iso_time", "iso"), + # a line as the request has it + "mode-not-sent": (('server_json_string(json, "mode", match_mode_names[mode]);', ""), "match_line_take", "recorder"), + "unknown-mode-multiplayer": (("return index;\n\t}\n\treturn _match_mode_none;", + "return index;\n\t}\n\treturn _match_mode_multiplayer;"), "match_mode_get", + "recorder"), + "older-line-a-session": (("if (token < 0)\n\t\treturn _match_mode_multiplayer;", + "if (token < 0)\n\t\treturn _match_mode_campaign;"), "match_mode_get", "recorder"), + "session-of-any-difficulty": (("(difficulty_index < 0 || kills > MAXIMUM_SESSION_KILLS)", + "(kills > MAXIMUM_SESSION_KILLS)"), "match_line_take", "recorder"), + "session-of-any-kills": (("(difficulty_index < 0 || kills > MAXIMUM_SESSION_KILLS)", "(difficulty_index < 0)"), + "match_line_take", "recorder"), + "classes-unbounded": (("clamp_between(number_get(document, token, enemy_names[index], 0), 0, MAXIMUM_CLASS_KILLS)", + "number_get(document, token, enemy_names[index], 0)"), "enemies_get", "recorder"), + "kills-the-lines-own": (('server_json_integer(json, "kills", kills);', + 'server_json_integer(json, "kills", number_get(&document, 0, "kills", 0));'), + "match_line_take", "recorder"), + "session-deaths-of-a-match": (('clamp_between(number_get(&document, 0, "deaths", 0), 0,\n\t\t\t\t' + 'MAXIMUM_SESSION_DEATHS)', 'clamp_count(number_get(&document, 0, "deaths", 0))'), + "match_line_take", "recorder"), + "players-of-a-crowd": (('clamp_between(number_get(&document, 0, "players", 0), 0, MAXIMUM_PLAYERS)', + 'clamp_count(number_get(&document, 0, "players", 0))'), "match_line_take", "recorder"), + "local-player-unbounded": (('clamp_between(number_get(&document, 0, "local_player", 0), 0, 3)', + 'number_get(&document, 0, "local_player", 0)'), "match_line_take", "recorder"), + "player-not-ascii": (("text[out++] = character >= 32 && character < 127 ? (char)character : '?';", + "text[out++] = (char)character;"), "ascii_text_get", "recorder"), + "scoreboard-dropped": (("if (scoreboard >= 0 && document.tokens[scoreboard].type == _server_json_array)", + "if (0)"), "match_line_take", "recorder"), + "every-row": (("rows < MAXIMUM_SCOREBOARD_ROWS &&", "rows < MAXIMUM_SCOREBOARD_ROWS + 4 &&"), "scoreboard_pass", + "recorder"), + "rows-of-elements": (("if (document->tokens[element].type != _server_json_object)\n\t\t\tcontinue;\n\t\trows++;", + "rows++;\n\t\tif (document->tokens[element].type != _server_json_object)\n\t\t\tcontinue;"), + "scoreboard_pass", "recorder"), + "teams-unbounded": (("-1, MAXIMUM_TEAM)", "-1, 255)"), "scoreboard_pass", "recorder"), + "scores-of-a-count": (("clamp_between(number_get(document, element, \"score\", 0), -MAXIMUM_SCORE,\n\t\t\t" + "MAXIMUM_SCORE)", "clamp_count(number_get(document, element, \"score\", 0))"), + "scoreboard_pass", "recorder"), + "me-on-every-row-that-says-so": (("&& me && !mine)", "&& me && mine >= 0)"), "scoreboard_pass", "recorder"), + "nameless-row": (('if (!ascii_text_get(document, element, "name", name, sizeof(name)) || !name[0])\n\t\t\t' + 'snprintf(name, sizeof(name), "unknown");\n\t\tserver_json_string(json, "name", name);', + 'if (ascii_text_get(document, element, "name", name, sizeof(name)))\n\t\t\t' + 'server_json_string(json, "name", name);'), "scoreboard_pass", "recorder"), + "empty-name-kept": (('|| !name[0])\n\t\t\tsnprintf(name, sizeof(name), "unknown");', + ')\n\t\t\tsnprintf(name, sizeof(name), "unknown");'), "scoreboard_pass", "recorder"), + "line-unfinished-joined": (("(last == '\\n' || last == EOF || fputc('\\n', file) != EOF)", "last != 0"), + "matches_append", "batch"), + # the marker + "marker-offset-anywhere": (("&&\n\t\ttail[length - 1] == '\\n';", ";"), "marker_hash", "marker"), + "marker-hash-unchecked": (("(marker_hash(lines, lines->offset, &hash) && hash == lines->hash)", + "marker_hash(lines, lines->offset, &hash)"), "marker_check", "marker"), + "marker-hash-of-everything": (("long from = offset > MARKER_HASHED ? offset - MARKER_HASHED : 0", + "long from = offset > MARKER_HASHED ? offset - MARKER_HASHED + 1 : 0"), + "marker_hash", "marker"), + "marker-any-tail": (("if (digits == 8 && (!end[9] || end[9] == '\\n' || end[9] == '\\r'))", "if (digits == 8)"), + "marker_read", "marker"), + "marker-written-wrong": (('"%ld %08lx\\n", lines->offset,', '"%ld %08lx\\n", lines->offset + 1,'), + "marker_write", "marker"), + "pending-of-a-cut-file": (("marker_check(lines);\n\treturn lines_take", "return lines_take"), "pending_count", + "pending"), + # the lines + "unfinished-line-taken": (("return character == '\\n';", "return count > 0 || character == '\\n';"), "line_read", + "batch"), + "no-limit": (("while (count < maximum && line_read", "while ((count < maximum || count < 60) && line_read"), + "lines_take", "batch"), + "passed-over-said-each-time": (("else if (offset >= lines->logged_until)", "else"), "lines_take", "batch"), + "passed-over-silently": (("else if (offset >= lines->logged_until)", "else if (0)"), "lines_take", "batch"), + "counts-unbounded": (("return clamp_between(value, 0, 1000);", "return clamp_between(value, 0, 100000);"), + "clamp_count", "batch"), + "kill-counts-always-sent": (("if (server_json_get_number(document, server_json_member(document, 0, key), &value))", + "if (1)"), "count_pass", "line"), + "duration-unbounded": (("duration < 0 ? 0 : duration > 86400 ? 86400 : duration", "duration"), "match_line_take", + "batch"), + "lines-stepped-by-their-text": (("offset += length + 1;", "offset += (long)strnlen(line, (size_t)length) + 1;"), + "lines_take", "nul"), + "line-ends-at-a-nul": (("while ((character = fgetc(file)) != EOF && character != '\\n')", + "while ((character = fgetc(file)) != EOF && character != '\\n' && character != 0)"), + "line_read", "nul"), + "key-of-any-length": (("word_valid(key, MAXIMUM_KEY_LENGTH)", "word_valid(key, KEY_SIZE - 1)"), "match_line_take", + "line"), + "map-not-cut": (("char map[MATCH_TEXT_SIZE], gametype[MATCH_TEXT_SIZE], player[PLAYER_NAME_SIZE]", + "char map[200], gametype[200], player[PLAYER_NAME_SIZE]"), "match_line_take", "line"), + "won-always-false": (('server_json_boolean(json, "won", won);', 'server_json_boolean(json, "won", 0);'), + "match_line_take", "line"), + "line-of-the-most-bytes-refused": (("if (length <= MAXIMUM_LINE && match_line_take", + "if (length < MAXIMUM_LINE && match_line_take"), "lines_take", "line"), + # an account's state + "starting-not-waiting": (("slot->link_requested || slot->link_starting || slot->waiting", + "slot->link_requested || slot->waiting"), "link_state", "state"), + "offline-before-linked": (("if (slot->has_profile)\n\t\t\treturn _halocustoms_state_linked;\n\t\treturn " + "slot->offline ? _halocustoms_state_offline : _halocustoms_state_linking;", + "if (slot->offline)\n\t\t\treturn _halocustoms_state_offline;\n\t\treturn " + "slot->has_profile ? _halocustoms_state_linked : _halocustoms_state_linking;"), + "link_state", "state"), + "given-up-slot-a-profiles": (("if (slot < 0 || slot >= account.slot_count || !account.slots[slot].folder[0])", + "if (slot < 0 || slot >= account.slot_count)"), "slot_at", "state"), + # the web build's session + "session-404-is-no-answer": (("if (status == 404)\n\t\treturn _session_signed_out;", + "if (0)\n\t\treturn _session_signed_out;"), "session_answer", "session"), + "session-any-page-signs-out": (("if (status != 200 || !answer_object(&document, body, size))\n\t\treturn " + "_session_no_answer;", + "if (status != 200 || !answer_object(&document, body, size))\n\t\treturn " + "_session_signed_out;"), "session_answer", "session"), + "session-profile-kept": (("profile_forget(&account.session);\n\t\taccount.session.offline =", + "account.session.offline ="), "session_ask", "session"), + "session-said-each-time": (("if (strcmp(line, said))", "if (1)"), "session_ask", "session"), + "session-unasked-is-none": (("if (!account.session_asked)\n\t\treturn _halocustoms_state_waiting;", ""), + "session_state", "session"), + "count-below-zero": (("return value < 0 ? 0 : value;", "return value;"), "count_get", "session"), + "older-site-gives-none": (("return -1;\n\treturn count_get(document, token, key);", + "return 0;\n\treturn count_get(document, token, key);"), "optional_count_get", + "session"), + "page-of-any-scheme": (('(strncmp(profile->url, "https://", 8) && strncmp(profile->url, "http://", 7))', "0"), + "profile_read", "session"), + # a report's answer + "page-200-passes-the-lines": (("if (outcome == _report_taken || outcome == _report_passed_over)\n\t\t{\n\t\t\t" + "lines->offset = end;", + "if (outcome == _report_taken || outcome == _report_passed_over || status == 200)" + "\n\t\t{\n\t\t\tlines->offset = end;"), "matches_report", "report"), + "recorded-not-needed": (('if (status == 200 && server_json_get_number(&document, server_json_member(&document, 0, ' + '"recorded"), &value))', "if (status == 200)"), "matches_report", "report"), + "any-page-is-the-sites": (("object = got && answer_object(&document, answer, answer_size);", + 'object = got && (answer_object(&document, answer, answer_size) || ' + 'server_json_parse(&document, "{}", 2, 1));'), "matches_report", "report"), + "422-passes-a-batch": (("outcome = count == 1 ? _report_passed_over : _report_split;", + "outcome = _report_passed_over;"), "matches_report", "report"), + "singles-at-once": (("slot->upload_after = single ? now_seconds() + SINGLE_SECONDS : 0;", + "slot->upload_after = single ? 0 : 0;"), "matches_report", "report"), + "retried-for-ever": (("if (!slot->upload_retried)", "if (1)"), "report_retry_later", "report"), + "answer's-profile-dropped": (("profile_take(slot, &profile);\n\t\t\t\t\tkept = 1;", "kept = 1;"), "matches_report", + "report"), + "rank-of-a-member-signed-out": (("if (slot && !strcmp(token, slot->token) && strcmp(slot->rank_tag, game_tag))", + "if (slot && strcmp(slot->rank_tag, game_tag))"), "rank_tag_write", "report"), + "rank-kept-with-none": (("if (!slot->rank_tag[0])\n\t{\n\t\tremove(slot->rank);", + "if (!slot->rank_tag[0] && 0)\n\t{\n\t\tremove(slot->rank);"), "rank_file_write", + "report"), + "stale-answer-kept": (("current = slot && !strcmp(token, slot->token);", "current = slot != NULL;"), + "matches_report", "report"), + "reported-with-no-token": (("if (!token[0])\n\t\treturn;\n\tpthread_mutex_lock(&matches_lock);", + "pthread_mutex_lock(&matches_lock);"), "matches_report", "report"), + "renamed-file-marked": (("if (lines->serial == serial)\n\t{", "if (lines->serial == serial || 1)\n\t{"), + "matches_report", "report"), + # the profiles + "another-sites-token": (("if (length == (long)strlen(site) && !strncmp(text, site, (size_t)length))", "if (1)"), + "key_file_read", "profiles"), + "key-gone-token-kept": (("else if (slot->token_from_key && !file_exists(slot->key))", "else if (0)"), + "halocustoms_profile_slot", "profiles"), + "every-profile-asked-for": (("slot->refresh_wanted = slot->token[0] != 0;", "slot->refresh_wanted = 1;"), + "halocustoms_profile_slot", "profiles"), + "unsent-not-sent": (("slot->upload_wanted = 1;\n\t\tslot->upload_after = 0;\n\t\tslot->recount_wanted = 1;", + "slot->recount_wanted = 1;"), "halocustoms_profile_slot", "profiles"), + "slots-unbounded": (("if (found < 0 && account.slot_count < HALOCUSTOMS_MAXIMUM_PROFILES)", + "if (found < 0 && account.slot_count < HALOCUSTOMS_MAXIMUM_PROFILES + 1)"), + "halocustoms_profile_slot", "profiles"), + "one-profiles-files-for-all": (('snprintf(slot_files[found].matches, sizeof(slot_files[found].matches), ' + '"%smatches.jsonl", path);', 'snprintf(slot_files[found].matches, ' + 'sizeof(slot_files[found].matches), "%s/matches.jsonl", platform_data_root());'), + "halocustoms_profile_slot", "profiles"), + "record-without-a-profile-kept": (("if (slot >= 0 && slot < HALOCUSTOMS_MAXIMUM_PROFILES && " + "slot_files[slot].matches[0])\n\t\tlines = &slot_files[slot];", + "lines = slot >= 0 && slot_files[slot].matches[0] ? &slot_files[slot] : " + "&files.root;"), "halocustoms_match_record", "profiles"), + # the older build's account + "no-migration": (("if (player_one)\n\t\tolder_account_move(found);", "if (0)\n\t\tolder_account_move(found);"), + "halocustoms_profile_slot", "migration"), + "migrated-each-time": (("if (flags & HALOCUSTOMS_PROFILE_PLAYER_ONE && !account.player_one_named)", + "if (flags & HALOCUSTOMS_PROFILE_PLAYER_ONE)"), "halocustoms_profile_slot", "migration"), + "own-key-overwritten": (("if (file_exists(key))\n\t\t{", "if (0)\n\t\t{"), "older_account_move", "migration"), + "key-copied-as-it-is": (("moved = file_copy(files.key, key, 1) && remove(files.key) == 0;", + "moved = rename(files.key, key) == 0;"), "older_account_move", "migration"), + "reported-lines-sent-again": (("start = from->offset > 0 && from->offset <= (long)size ? from->offset : 0;", + "start = 0;"), "older_matches_move", "migration"), + "unfinished-line-moved": (("for (stop = (long)size; stop > start && bytes[stop - 1] != '\\n'; stop--)\n\t\t\t;", + "stop = (long)size;"), "older_matches_move", "migration"), + "marker-left-behind": (("if (moved && file_exists(from->marker) && rename(from->marker, to->marker) != 0 &&", + "if (0 && file_exists(from->marker) && rename(from->marker, to->marker) != 0 &&"), + "older_matches_move", "migration"), + "rank-not-moved": (('if (word_valid(setting, MAXIMUM_RANK_LENGTH))\n\t\tsnprintf(tag, sizeof(tag), "%s", setting);', + ""), "older_account_move", "migration"), + "setting-token-for-anyone": (("if (player_one && !slot->token[0])", "if (!slot->token[0])"), + "halocustoms_profile_slot", "migration"), + "setting-token-kept-at-sign-out": (('if (config_string("halocustoms.token")[0])\n\t\t\twritten = ' + 'config_write("halocustoms.token", "");', ""), "halocustoms_sign_out", + "migration"), + # a profile renamed + "rename-copies-nothing": (("copied = file_copy(from_name, to_name, 0) && copied;", "copied = 1;"), + "halocustoms_profile_moved", "rename"), + "key-copied-for-all": (("copied = file_copy(from_name, to_name, index == 0) && copied;", + "copied = file_copy(from_name, to_name, 0) && copied;"), "halocustoms_profile_moved", + "rename"), + "slot-left-behind": (("else if (!strcmp(slot->folder, from) && moved < 0)", "else if (0)"), + "halocustoms_profile_moved", "rename"), + "stale-slot-kept": (("if (!strcmp(slot->folder, to) && given_up < 0)", "if (0)"), "halocustoms_profile_moved", + "rename"), + # the device's name, the member's page + "any-character-sent": (("if (device_character(profile_name[index]))", "if (1)"), "device_name", "device"), + "device-name-long": (("size < MAXIMUM_DEVICE_LENGTH + 1 ? size : MAXIMUM_DEVICE_LENGTH + 1", "size"), + "device_name", "device"), + "any-page-opened": (("!strncmp(shown->profile.url, page, strlen(page))", "shown->profile.url[0]"), + "halocustoms_open_site", "open"), + "no-member-page": (("if (shown && shown->has_profile && shown->token[0] &&", "if (0 &&"), + "halocustoms_open_site", "open"), +} + + +def static_structure(source, name): + """An unnamed static structure and its variable (static struct { ... } name ...;).""" + end = source.index("} " + name) + return source[source.rindex("static struct\n{", 0, end):source.index(";", end) + 1] + + +def line_of(source, start): + """The whole line that begins with start.""" + return re.search(r"^" + re.escape(start) + r"[^\n]*$", source, re.M).group(0) + + +def statement(source, start): + """The statement that begins a line with start, to its semicolon.""" + at = re.search(r"^" + re.escape(start), source, re.M).start() + return source[at:source.index(";", at) + 1] + + +def pieces(): + """What the test takes from the file, in the order it is compiled: (name, code).""" + account = read(ACCOUNT) + taken = [("account's sizes", enum_with(account, "MATCHES_PER_REQUEST = 50")), + ("account_slot", structure(account, "account_slot")), + ("account", static_structure(account, "account")), + ("match_files", structure(account, "match_files")), + ("matches_lock", line_of(account, "static pthread_mutex_t matches_lock")), + ("settings_lock", line_of(account, "static pthread_mutex_t settings_lock")), + ("slot_files", line_of(account, "static struct match_files slot_files")), + ("files", static_structure(account, "files")), + ("session's address", line_of(account, "#define SESSION_ADDRESS")), + ("session's answers", enum_with(account, "_session_no_answer,"))] + for name in ACCOUNT_FUNCTIONS: + if name == "@modes": + taken += [("modes", enum_with(account, "_match_mode_multiplayer,")), + ("mode names", statement(account, "static char const *const match_mode_names")), + ("difficulty names", statement(account, "static char const *const difficulty_names")), + ("enemy names", statement(account, "static char const *const enemy_names"))] + else: + taken.append((name, function(account, name))) + return taken + + +def generated(fault=None, faulty_piece=None): + text = "" + for name, code in pieces(): + if fault and name == faulty_piece: + code = mutated(code, *fault) + text += code + "\n\n" + return (("halocustoms_account.h", read("port/linux/src/halocustoms_account.h")), + ("halocustoms_friends.h", read("port/linux/src/halocustoms_friends.h")), + ("player_link.h", read("port/linux/src/player_link.h")), ("under_test.inc", text)) + + +def played(generated_text, case): + """the case run in a folder of its own, where it writes the files: (exit status, output)""" + with tempfile.TemporaryDirectory(prefix="halocustoms-") as root: + result = subprocess.run([str(build("halocustoms_matches", generated_text)), case, root], capture_output=True, + text=True, errors="replace", timeout=60) + return result.returncode, (result.stdout + result.stderr).strip() + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = played(generated(), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, faulty_piece, case = NEGATIVE_CONTROLS[control] + status, output = played(generated(fault, faulty_piece), case) + assert status == CHECK_FAILED, (f"the code with a fault ({control}) passed '{case}': the test cannot see it\n" + f"{output}") + + +def test_each_profile_is_served(): + """The account's thread serves every profile named: each in turn, its sign-in, its profile when in use and it has + none, its reports, its count; and the game names its profiles as each map loads (halocustoms_stats.c).""" + account = read(ACCOUNT) + work = function(account, "slot_work") + assert work.index("if (slot->link_requested)") < work.index("link_poll(slot_index);") < \ + work.index("profile_fetch(slot_index);") < work.index("matches_report(slot_index);") < \ + work.index("pending_count(&slot_files[slot_index]);") + assert "(slot->refresh_wanted || (slot->used && !slot->has_profile && !slot->offline))" in work + thread = account[account.index("static void *account_thread_main", account.index("static int slot_work")):] + thread = thread[:thread.index("\n}\n")] + assert "int index = (account.next_slot + step) % count;" in thread and \ + "account.next_slot = (index + 1) % count;" in thread + stats = read("port/linux/game/halocustoms_stats.c") + assert "profiles_name();" in function(stats, "halocustoms_stats_game_started") diff --git a/tools/harness/tests/test_halocustoms_welcome.py b/tools/harness/tests/test_halocustoms_welcome.py new file mode 100644 index 0000000..5de7944 --- /dev/null +++ b/tools/harness/tests/test_halocustoms_welcome.py @@ -0,0 +1,143 @@ +"""The main menu's HaloCustoms account panel and welcome: when the welcome shows (halocustoms_account.c's +halocustoms_welcome_due), the link page's address for CREATE ACCOUNT (link_page_address), and the panel's lines for +each state of player 1's profile's account (menu_functions.c's halocustoms_panel_read), in the words of the menus' +string list as tools/port_settings.py writes it (HALOCUSTOMS_PANEL_WORDS, which must be in the order of the C's +_halocustoms_panel_word_*).""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, ROOT, build, enum_with, function, mutated, read, run, structure # noqa: E402 + +CASES = ["due", "link-page", "panel-member", "panel-signed-out", "panel-hidden"] + +ACCOUNT_FUNCTIONS = ["halocustoms_welcome_due", "link_page_address"] +MENU_FUNCTIONS = ["halocustoms_widen", "halocustoms_upper", "halocustoms_thousands", "halocustoms_ratio", + "halocustoms_panel_word", "halocustoms_panel_count", "halocustoms_panel_read"] + +# a fault in the code, the function it is in, and the case that must catch it +NEGATIVE_CONTROLS = { + "due-while-signing-in": (("return state == _halocustoms_state_none ||", + "return state == _halocustoms_state_waiting || state == _halocustoms_state_none ||"), + "halocustoms_welcome_due", "due"), + "due-for-a-guest": (("|| slot < 0 || guest)", "|| slot < 0)"), "halocustoms_welcome_due", "due"), + "due-twice": (("|| quiet || shown ||", "|| quiet ||"), "halocustoms_welcome_due", "due"), + "due-in-a-quiet-run": (("|| !site || quiet ||", "|| !site ||"), "halocustoms_welcome_due", "due"), + "page-second-query": (("memchr(url, '?', (size_t)length) ? '&' : '?'", "'?'"), "link_page_address", "link-page"), + "page-after-fragment": (("long length = (long)strcspn(url, \"#\")", "long length = (long)strlen(url)"), + "link_page_address", "link-page"), + "page-cut-short": (("if (whole + 11 >= size)\n\t\treturn;", ""), "link_page_address", "link-page"), + "panel-guest-unsaid": (("guest ? _halocustoms_panel_word_guest : _halocustoms_panel_word_signed_out", + "_halocustoms_panel_word_signed_out"), "halocustoms_panel_read", "panel-signed-out"), + "panel-no-invitation": (("\t\thalocustoms_panel_word(_halocustoms_panel_word_invite, lines[1], " + "HALOCUSTOMS_PANEL_LINE_LENGTH);\n\t\tbreak;\n\t}", + "\t\tbreak;\n\t}"), "halocustoms_panel_read", "panel-signed-out"), + "panel-lines-kept": (("for (line = 0; line < HALOCUSTOMS_PANEL_LINES; line++)\n\t\tlines[line][0] = 0;", ""), + "halocustoms_panel_read", "panel-signed-out"), + "panel-code-unsaid": (("if (code[0])", "if (0)"), "halocustoms_panel_read", "panel-signed-out"), + "panel-deaths-as-kills": (("_halocustoms_panel_word_deaths, profile->deaths", "_halocustoms_panel_word_deaths, " + "profile->kills"), + "halocustoms_panel_read", "panel-member"), + "panel-on-a-server": (("halocustoms_site_known() && !halo_server_mode()", "halocustoms_site_known()"), + "halocustoms_panel_read", "panel-hidden"), + "panel-xp-from-zero": (("(double)(profile->xp - profile->level_xp) * 1000.0", "(double)profile->xp * 1000.0"), + "halocustoms_panel_read", "panel-member"), + "panel-xp-unbounded": (("(double)(profile->next_level_xp - profile->level_xp), 0.0, 1000.0);", + "(double)(profile->next_level_xp - profile->level_xp), -1e9, 1e9);"), + "halocustoms_panel_read", "panel-member"), + "panel-xp-kept": (("halocustoms_panel.xp_thousandths = 0;\n\tswitch (state)", "switch (state)"), + "halocustoms_panel_read", "panel-member"), + "panel-kd-of-games": (("halocustoms_ratio(PIN(profile->kills, 0, 99999999), PIN(profile->deaths, 0, 99999999),", + "halocustoms_ratio(PIN(profile->kills, 0, 99999999), PIN(profile->games, 0, 99999999),"), + "halocustoms_panel_read", "panel-member"), + "panel-kd-unbounded": (("halocustoms_ratio(PIN(profile->kills, 0, 99999999),", + "halocustoms_ratio(profile->kills,"), "halocustoms_panel_read", "panel-member"), + "ratio-truncated": (("*hundredths = (long)((ratio - (double)*whole) * 100.0 + 0.5);", + "*hundredths = (long)((ratio - (double)*whole) * 100.0);"), "halocustoms_ratio", + "panel-member"), + "ratio-no-carry": (("if (*hundredths >= 100)", "if (0)"), "halocustoms_ratio", "panel-member"), + "panel-friends-uncounted": (("if (friends_online >= 0)", "if (1)"), "halocustoms_panel_read", "panel-member"), + "panel-friends-unbounded": (("PIN(friends_online, 0, 99999)", "friends_online"), "halocustoms_panel_read", + "panel-member"), + "panel-friend-plural": (("friends_online == 1 ? _halocustoms_panel_word_friend_online :", + "friends_online == 0 ? _halocustoms_panel_word_friend_online :"), + "halocustoms_panel_read", "panel-member"), + "ratio-nothing-without-deaths": (("(double)kills / (double)deaths : (double)kills;", + "(double)kills / (double)deaths : 0.0;"), "halocustoms_ratio", "panel-member"), +} + + +def panel_words(): + """the generator's words, in its order, as C""" + sys.path.insert(0, str(ROOT / "tools")) + import port_settings + words = "".join(f'\tL"{word}",\n' for word in port_settings.HALOCUSTOMS_PANEL_WORDS) + return f"static const wchar_t *const panel_words[] =\n{{\n{words}}};\n" + + +def panel_state(menus): + """the panel's state (its anonymous static structure) and its constants""" + end = menus.index("} halocustoms_panel;") + len("} halocustoms_panel;") + return enum_with(menus, "_halocustoms_panel_word_title") + "\n" + menus[menus.rindex("static struct", 0, end):end] + + +def generated(fault=None, faulty_function=None): + header = read("port/linux/src/halocustoms_account.h") + account = read("port/linux/src/halocustoms_account.c") + menus = read("port/linux/game/menu_functions.c") + code = "" + for source, names in ((account, ACCOUNT_FUNCTIONS), (menus, MENU_FUNCTIONS)): + for name in names: + text = function(source, name) + if fault and name == faulty_function: + text = mutated(text, *fault) + code += text + "\n" + account_inc = (enum_with(header, "HALOCUSTOMS_MAXIMUM_MATCH_LINE") + "\n" + + enum_with(header, "_halocustoms_state_none") + "\n" + structure(header, "halocustoms_profile")) + return (("account.inc", account_inc), ("words.inc", panel_words()), ("panel.inc", panel_state(menus)), + ("under_test.inc", code)) + + +def test_words_follow_the_enum(): + """as many words in the generator's list as the C names (the cases check each word the C shows at its name)""" + sys.path.insert(0, str(ROOT / "tools")) + import port_settings + enum = enum_with(read("port/linux/game/menu_functions.c"), "_halocustoms_panel_word_title") + names = [line.strip().rstrip(",") for line in enum.splitlines() if line.strip().startswith("_halocustoms_panel_word_")] + assert len(names) == len(port_settings.HALOCUSTOMS_PANEL_WORDS) + + +def test_the_panel_has_the_lines_the_game_fills(): + """the generator's panel and the file written from it have the lines (line_0 ... line_8, the K/D and the friends + online last) the game fills in by their names, each 14 below the one before and inside the box""" + import re + sys.path.insert(0, str(ROOT / "tools")) + import port_settings + menus = read("port/linux/game/menu_functions.c") + count = int(re.search(r"HALOCUSTOMS_PANEL_LINES = (\d+),", menus).group(1)) + assert count == port_settings.HALOCUSTOMS_PANEL_LINES == 9 + panel = (ROOT / "port/assets/menus/ce/main_menu.halocustoms_panel.xml").read_text() + width, height = port_settings.HALOCUSTOMS_PANEL_SIZE + assert f'name="main_menu/halocustoms_panel/panel" controller="1" width="{width}" height="{height}"' in panel + for line in range(count): + y = int(re.search(rf'', panel).group(1)) + assert y == 22 + 14 * line and y + 15 <= height, line + assert f'' in panel + assert '' in panel and '' in panel + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("halocustoms_welcome", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, faulty_function, case = NEGATIVE_CONTROLS[control] + status, output = run(build("halocustoms_welcome", generated(fault, faulty_function)), case) + assert status == CHECK_FAILED, f"the code with a fault ({control}) passed '{case}': the test cannot see it" diff --git a/tools/harness/tests/test_hires_run_layout.py b/tools/harness/tests/test_hires_run_layout.py new file mode 100644 index 0000000..0c82916 --- /dev/null +++ b/tools/harness/tests/test_hires_run_layout.py @@ -0,0 +1,64 @@ +"""A menu theme's font's runs of characters (port/assets/menus/README.md, "Theme fonts"), laid out with the font's own +advances where draw_string placed the characters by the font tag's widths (source/rasterizer/rasterizer_text.c's +hires_run_layout): never wider than the tag's run (so columns, help lines and the generator's width checks hold), a +space keeping the theme's share of the run (no word run into the next), letters evenly apart with the theme's +kerning, and placed by the justification in the tag's extent (left, right, centred); the stock fonts drawn as before, +each glyph on its tag's character.""" + +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 # noqa: E402 + +CASES = ["fits", "justified", "kerning", "bounded"] + +NEGATIVE_CONTROLS = { + "unbounded": (("if (width > map_right - map_left && width > 0.0f)", "if (0)"), "fits"), + "tag-spaces": (("width += gap > 0.0f ? gap * space_ratio : kernings[index];", + "width += gap > 0.0f ? gap : kernings[index];"), "fits"), + "always-left": (("\tcase HIRES_TEXT_JUSTIFY_RIGHT:\n\t\tstart = map_right - width * scale;", + "\tcase HIRES_TEXT_JUSTIFY_RIGHT:\n\t\tstart = map_left;"), "justified"), + "unkerned": (("gap * space_ratio : kernings[index];", "gap * space_ratio : 0.0f;"), "kerning"), + "widened": (("if (width > map_right - map_left && width > 0.0f)", "if (width > 0.0f)"), "justified"), +} + + +def generated(fault=None): + source = read("source/rasterizer/rasterizer_text.c") + code = function(source, "hires_run_layout") + if fault: + code = mutated(code, *fault) + return (("layout.inc", enum_with(source, "HIRES_TEXT_JUSTIFY_LEFT = 0,") + "\n\n" + code + "\n"),) + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("hires_run_layout", 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("hires_run_layout", generated(fault)), case) + assert status == CHECK_FAILED, f"the code with a fault ({control}) passed '{case}': the test cannot see it" + + +def test_only_a_theme_font_is_laid_out(): + """the glyph callbacks keep a laid-out font's characters in their run (the stock fonts drawn at once, as + before), each string's last run is drawn before its text ends, and the run takes draw_string's justification""" + source = read("source/rasterizer/rasterizer_text.c") + for name in ("rasterizer_draw_hires_character", "rasterizer_draw_hires_character_with_dropshadow"): + body = function(source, name) + assert body.index("if (text_hires_laid_out(hires_text_font))") < body.index("hires_run_add(") < \ + body.index("rasterizer_draw_hires_glyph(") + for name, call in (("rasterizer_draw_string", "\t\t\tdraw_string("), + ("rasterizer_draw_unicode_string", "\t\t\tdraw_unicode_string(")): + # (the game's own functions, their type on the line before their name) + start = source.index(f"\n{name}(\n") + body = source[start:source.index("\n}\n", start)] + assert body.index(call) < body.index("hires_run_flush();") < body.index("rasterizer_text_end();") + assert "short draw_string_justification(" in read("source/text/draw_string.c") diff --git a/tools/harness/tests/test_master_protocol.py b/tools/harness/tests/test_master_protocol.py index f5203b9..4d68f6a 100644 --- a/tools/harness/tests/test_master_protocol.py +++ b/tools/harness/tests/test_master_protocol.py @@ -36,7 +36,8 @@ SOURCES = { "port/linux/src/server_master.c": ["hex_value", "seed_from_text", "key_file_path", "publisher_seed", "key_id_of", "server_master_publisher", "publisher_sign", "instance_name", "hex_text", "sign_request", "publish_body", "header_number", "logged_each_minute", - "publish_answer", "publish_to", "master_start", "server_master_start", + "publish_answer", "publish_to", "master_start", "read_link_masters", + "server_master_start", "probe_source_allowed", "server_master_probe"], } @@ -83,6 +84,7 @@ NEGATIVE_CONTROLS = { "masters-default-key": (('"ed25519:89f4eda71229f11f3538679f49ef7fb0c2b407b380093f7f4fe02105b92225aa"', '"ed25519:89f4eda71229f11f3538679f49ef7fb0c2b407b380093f7f4fe02105b92225ab"'), "master_list_read_defaults", "masters-default"), + "masters-json-unbounded": (("for (index = 0; count < maximum && server_json_element", "for (index = 0; server_json_element"), "master_list_read_json", "masters-json"), "list-taken-unchecked": (("p2p_sha256(key, MASTER_LIST_KEY_SIZE, digest);\n\tp2p_hex(digest, 8, key_id);", @@ -248,10 +250,27 @@ def crypto_includes(): ("server_json.inc", server_json), ("master_list.h", read("port/linux/src/master_list.h")), ("server_master.h", read("port/linux/src/server_master.h")), + ("halocustoms_services.h", read("port/linux/src/halocustoms_services.h")), ) -def generated(fault=None, faulty_function=None): +# a release's key for HaloCustoms' master (any key in the form: the release's is the site's) +RELEASE_KEY = "ed25519:" + "5a" * 32 +# faults of HaloCustoms' master in the built-in list, which a build given its key must catch +RELEASE_CONTROLS = { + "masters-default-opence-first": (("#ifdef HALOCUSTOMS_MASTER_KEY\n\t\t{ HALOCUSTOMS_MASTER_URL, HALOCUSTOMS_MASTER_KEY },\n#endif\n" + "\t\t{ \"https://master.opencommunityedition.com\",\n" + "\t\t\t\"ed25519:89f4eda71229f11f3538679f49ef7fb0c2b407b380093f7f4fe02105b92225aa\" },", + "{ \"https://master.opencommunityedition.com\",\n" + "\t\t\t\"ed25519:89f4eda71229f11f3538679f49ef7fb0c2b407b380093f7f4fe02105b92225aa\" },\n" + "#ifdef HALOCUSTOMS_MASTER_KEY\n\t\t{ HALOCUSTOMS_MASTER_URL, HALOCUSTOMS_MASTER_KEY },\n#endif"), + "master_list_read_defaults", "masters-default"), + "masters-default-halocustoms-gone": (("#ifdef HALOCUSTOMS_MASTER_KEY\n\t\t{ HALOCUSTOMS_MASTER_URL, HALOCUSTOMS_MASTER_KEY },\n#endif\n", ""), + "master_list_read_defaults", "masters-default"), +} + + +def generated(fault=None, faulty_function=None, release_key=None): lists = read("port/linux/src/master_list.c") publisher = read("port/linux/src/server_master.c") lobby = read("port/linux/src/p2p_lobby.c") @@ -278,8 +297,9 @@ def generated(fault=None, faulty_function=None): if fault and name == faulty_function: code = mutated(code, *fault) text += code + "\n" + release = f'#define HALOCUSTOMS_MASTER_KEY "{release_key}"\n' if release_key else "" return crypto_includes() + (("vectors.inc", vectors_include(vectors())), ("config.inc", config), - ("under_test.inc", text)) + ("under_test.inc", text), ("release.inc", release)) def test_vectors_copy(): @@ -302,3 +322,17 @@ def test_negative_control(control): fault, faulty_function, case = NEGATIVE_CONTROLS[control] status, output = run(build("master_protocol", generated(fault, faulty_function)), case) assert status == CHECK_FAILED, f"the code with a fault ({control}) passed '{case}': the test cannot see it" + + +def test_masters_default_with_the_release_key(): + """a build given HaloCustoms' master's key (a release: tools/linux_build.py's master_key_defines) asks + HaloCustoms' master first, then OpenCE's""" + status, output = run(build("master_protocol", generated(release_key=RELEASE_KEY)), "masters-default") + assert status == 0, output + + +@pytest.mark.parametrize("control", RELEASE_CONTROLS) +def test_release_negative_control(control): + fault, faulty_function, case = RELEASE_CONTROLS[control] + status, output = run(build("master_protocol", generated(fault, faulty_function, RELEASE_KEY)), case) + assert status == CHECK_FAILED, f"the code with a fault ({control}) passed '{case}': the test cannot see it" diff --git a/tools/harness/tests/test_menu_anchor.py b/tools/harness/tests/test_menu_anchor.py new file mode 100644 index 0000000..7004544 --- /dev/null +++ b/tools/harness/tests/test_menu_anchor.py @@ -0,0 +1,203 @@ +"""The menus' anchors on a screen wider than their 640 columns (port/assets/menus/README.md, "Anchors"): where a +widget is placed from its parent's edges (port/linux/game/menu_functions.c's anchor_place and pc_menu_anchor_shift) +at 4:3, 16:9 and 21:9, nested, a screen's own and a quarter's; the mouse's targets where the widgets are drawn +(source/interface/ui_widget.c's ui_mouse_note_target, ui_mouse_note_slot_band and ui_mouse_target_at: an anchored +item, a stretched one, a legend's button and its label, a list's slots and the band beside them); a bitmap's caps +as the game reads them (port/linux/game/menu_tags.c's caps_add, pc_menu_bitmap_caps); and how the engine applies +them as it draws (the offset, the clip, a stretched widget's area, its text's, its spinner's arrows, a capped +picture's pieces).""" + +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, enum_with, function, mutated, read, run, structure # noqa: E402 + +CASES = ["place", "draw", "shift", "hit", "caps"] + +ANCHOR = ["anchor_place", "pc_menu_anchor_shift"] +MOUSE = ["ui_mouse_note_list_step", "ui_mouse_note_slot_band", "ui_mouse_note_target", "ui_mouse_target_at"] +CAPS = ["name_index", "anchor_parse", "caps_add", "pc_menu_bitmap_caps"] +DRAW = ["draw_bitmap_in_rect_part", "draw_capped_bitmap_in_rect", "draw_bitmap_in_rect"] + +# a fault in a function and the case that must catch it +NEGATIVE_CONTROLS = { + "right-as-left": (("\t\tcase _anchor_right:\n\t\t\tmoved = moved_right;", + "\t\tcase _anchor_right:\n\t\t\tmoved = moved_left;"), "anchor_place", "place"), + "stretch-not-wider": (("wider = (moved_right - moved_left) / 2;", "wider = 0;"), "anchor_place", "place"), + "fluid-from-the-screen": (("(left + right - 2 * frame_left)", "(left + right)"), "anchor_place", "place"), + "fluid-truncated": (("(2 * across + (across < 0 ? -width : width)) / (2 * width)", "across / width"), + "anchor_place", "place"), + "absolute-shift": (("*shift = moved - origin;", "*shift = moved + 0 * origin;"), "anchor_place", "place"), + "children-of-the-screen-rigid": (("if (level || at->anchor == _anchor_left", "if (1 || at->anchor == _anchor_left"), + "anchor_place", "place"), + "anchored-screen-spans": (("if (level || at->anchor == _anchor_left || at->anchor == _anchor_right || " + "at->anchor == _anchor_fluid)", "if (level)"), "anchor_place", "place"), + "no-margin-inside": (("anchored = anchored || at->anchor != _anchor_none;", + "anchored = anchored || (at == widget && at->anchor != _anchor_none);"), + "pc_menu_anchor_shift", "shift"), + "deep-unbounded": (("if (!anchored || extra <= 0 || at)", "if (!anchored || extra <= 0)"), + "pc_menu_anchor_shift", "shift"), + "target-not-widened": (("bounds.x0 += offset.x - grow;", "bounds.x0 += offset.x;"), "ui_mouse_note_target", + "hit"), + "label-unshifted": (("offset.x - widget->horizontal_offset - shift + sibling->horizontal_offset +\n\t\t\t\t" + "sibling_shift - sibling_grow;", + "offset.x - widget->horizontal_offset + sibling->horizontal_offset;"), + "ui_mouse_note_target", "hit"), + "slot-unshifted": (("slot.x0 += offset.x + child->horizontal_offset + shift - child_grow;", + "slot.x0 += offset.x + child->horizontal_offset;"), "ui_mouse_note_slot_band", "hit"), + "band-not-widened": (("bounds.x0 = definition->bounds.x0 - grow + offset.x;", + "bounds.x0 = definition->bounds.x0 + offset.x;"), "ui_mouse_note_slot_band", "hit"), + "stock-whole-picture": (("draw_bitmap_in_rect_part(bitmap, rect, bitmap_rect, 0.0f, -1.0f,", + "draw_bitmap_in_rect_part(bitmap, rect, bitmap_rect, 0.0f, 1.0f,"), "draw_bitmap_in_rect", + "draw"), + "part-from-the-left": (("(vertex_index % 3) ? texture_width : texture_left;", "(vertex_index % 3) ? texture_width : 0.0f;"), + "draw_bitmap_in_rect_part", "draw"), + "part-as-asked-ignored": (("if (texture_right >= 0.0f)\n\t\t\ttexture_width = texture_right;", "(void)texture_right;"), + "draw_bitmap_in_rect_part", "draw"), + "middle-from-the-left": (("draw_bitmap_in_rect_part(bitmap, &part, texels, (real)cap_left / width,\n", + "draw_bitmap_in_rect_part(bitmap, &part, texels, 0.0f,\n"), "draw_capped_bitmap_in_rect", + "draw"), + "no-right-cap": (("part.x1 = (short)(bounds->x1 - cap_right);", "part.x1 = bounds->x1;"), + "draw_capped_bitmap_in_rect", "draw"), + "caps-with-spaces": (("if (source->caps[0] >= '0' && source->caps[0] <= '9' && end[0] == ','", + "if (end[0] == ','"), "caps_add", "caps"), + "caps-without-a-middle": (("if (left + right >= data->width)", "if (left + right > data->width)"), "caps_add", + "caps"), + "caps-of-a-map": (("if (!data->png)", "if (0)"), "caps_add", "caps"), + "anchor-shifted": (("return (byte)(anchor + 1);", "return (byte)anchor;"), "anchor_parse", "caps"), + "anchor-unknown-taken": (("if (anchor == NONE)\n\t{", "if (0)\n\t{"), "anchor_parse", "caps"), + "caps-with-the-games-menus": (("menu_tags.loaded && index < menu_tags.caps_count", "index < menu_tags.caps_count"), + "pc_menu_bitmap_caps", "caps"), +} + + +def static_structure(source, name): + """an unnamed static structure and its variable (static struct { ... } name;)""" + end = source.index("} " + name + ";") + return source[source.rindex("static struct\n{", 0, end):end + len(name) + 3] + + +def line(source, start): + """the line that begins with start""" + return re.search(r"^" + re.escape(start) + r"[^\n]*$", source, re.M).group(0) + + +def generated(fault=None, faulty_function=None): + widget = read("source/interface/ui_widget.c") + menus = read("port/linux/game/menu_functions.c") + tags = read("port/linux/game/menu_tags.c") + + def functions(source, names): + text = "" + for name in names: + code = function(source, name) + if fault and name == faulty_function: + code = mutated(code, *fault) + text += code + "\n\n" + return text + + instance = structure(widget, "widget_animation_data") + "\n\n" + structure(widget, "widget_instance") + anchor = "\n\n".join([line(menus, "#define ANCHOR_MAXIMUM_DEPTH"), enum_with(menus, "_anchor_none,"), + structure(menus, "anchor_level")]) + mouse = "\n".join([line(widget, "#define UI_MOUSE_MAXIMUM_TARGETS"), enum_with(widget, "_ui_mouse_target_item,"), + structure(widget, "ui_mouse_target"), + line(widget, "static struct ui_mouse_target ui_mouse_targets[UI_MOUSE_MAXIMUM_TARGETS];"), + line(widget, "static long ui_mouse_target_count = 0;"), + line(widget, "static boolean ui_mouse_noting_targets = FALSE;")]) + caps = "\n\n".join([line(tags, "static char const *const anchor_names[] ="), structure(tags, "menu_bitmap_caps"), + static_structure(tags, "menu_tags"), static_structure(tags, "build")]) + draw_types = structure(read("source/rasterizer/rasterizer.h"), "rasterizer_dynamic_screen_geometry_parameters") + return (("halo_menus.h", read("port/linux/include/halo_menus.h")), + ("widget.inc", instance), + ("anchor.inc", anchor + "\n\n" + functions(menus, ANCHOR)), + ("mouse.inc", mouse + "\n\n" + functions(widget, MOUSE)), + ("caps.inc", caps + "\n\n" + functions(tags, CAPS)), + ("draw_types.inc", draw_types), + ("draw.inc", function(widget, "compute_offset_coordinate").replace("static __inline", "static", 1) + "\n\n" + + functions(widget, DRAW))) + + +def test_the_engine_places_and_draws_anchored_widgets(): + """ui_widget.c places a widget where its anchor says before it notes its target and draws it, takes the + screen's margin into its clip, draws a stretched one wider (its picture, its text, a spinner's arrows and text), + a capped picture in three, and loads each widget with its anchor (its own, or its reference's)""" + widget = read("source/interface/ui_widget.c") + render = function(widget, "widget_instance_render_recursive") + placed = render.index("offset.x += pc_menu_anchor_shift(widget, &anchor_grow, &anchor_margin);") + assert render.index("offset.x += widget->horizontal_offset;") < placed < \ + render.index("ui_mouse_note_target(widget, definition, offset);") + assert placed < render.index("anchor_clip.x0 = (short)-anchor_margin;") < render.index("frame_index =") + assert "bounds.x0 -= anchor_grow;" in render and "bounds.x1 += anchor_grow;" in render + assert "draw_capped_bitmap_in_rect(bitmap, &bounds, &texels, cap_left, cap_right, clip, color," in render + assert "text_bounds.x0 -= anchor_grow;" in render + text_box = function(widget, "widget_instance_render_text_box") + assert text_box.index("pc_menu_anchor_shift(widget, &grow, NULL);") < text_box.index("bounds.x1 += offset.x;") + assert "pc_menu_text_draw_flags(widget->definition_tag_index)" in text_box + spinner = function(widget, "widget_instance_render_spinner_list") + assert spinner.count("bounds.x0 -= grow;") == 2 and "bounds.x0 += grow;" in spinner + initialize = function(widget, "widget_instance_initialize") + assert initialize.index("memset(widget, 0, sizeof(struct widget_instance));") < \ + initialize.index("widget->anchor = pc_menu_widget_anchor(tag_index);") + children = function(widget, "ui_widget_load_children_recursive") + assert "child->anchor = pc_menu_child_anchor(widget->definition_tag_index, reference, child->anchor);" in children + # (the game's own pictures are drawn as they always were: from the picture's left, as wide as asked) + assert "draw_bitmap_in_rect_part(bitmap, rect, bitmap_rect, 0.0f, -1.0f, clip_rect, argb, multitexture_params," \ + in function(widget, "draw_bitmap_in_rect") + + +def test_the_game_and_its_check_know_the_same_anchors_and_fills(): + """menu_tags.c's names (the game's) are what menu_functions.c places and fills by (its enum, from 1), and the + instance's anchor is where ui_widget.c and menu_functions.c's copy of its structure have it""" + tags = read("port/linux/game/menu_tags.c") + menus = read("port/linux/game/menu_functions.c") + names = re.search(r"anchor_names\[\] = \{([^}]*)\}", tags).group(1) + enum = enum_with(menus, "_anchor_none,") + assert [name.strip().strip('"') for name in names.split(",")] == \ + [member.strip().rstrip(",")[len("_anchor_"):] for member in enum.splitlines() + if member.strip().startswith("_anchor_") and member.strip() != "_anchor_none,"] + assert re.search(r"fill_names\[\] = \{ \"halocustoms_xp\" \}", tags) + assert "case 1: /* halocustoms_xp */" in function(menus, "pc_menu_fill_fraction") + engine = structure(read("source/interface/ui_widget.c"), "widget_instance") + copy = structure(menus, "widget_instance") + assert engine.index("boolean close_if_local_player_controller_present;") < engine.index("byte anchor;") < \ + engine.index("long creation_time;") + assert copy.index("boolean close_if_local_player_controller_present;") < copy.index("byte anchor;") < \ + copy.index("long creation_time;") + + +def test_menu_check_places_widgets_as_the_game_does(): + """tools/menu_check.py's anchor_place (which checks that a set's pressable widgets are on the display, and + that a site's preview can take) gives what the game's gives, each anchor in each, at each margin""" + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + import menu_check + + status, output = run(build("menu_anchor", generated()), "table") + assert status == 0, output + names = (None, *menu_check.ANCHORS) + expected = [] + for margin in (0, 106, 240, 249): + for screen in names: + for parent in names: + for child in names: + for offset in (-30, 12, 333): + levels = [(screen, 0, 0, 640), (parent, 100, 20, 460), (child, offset, 0, 90)] + expected.append("%d %d %d %d" % (*menu_check.anchor_place(levels[:2], margin), + *menu_check.anchor_place(levels, margin))) + assert output.splitlines() == expected + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("menu_anchor", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, faulty_function, case = NEGATIVE_CONTROLS[control] + status, output = run(build("menu_anchor", generated(fault, faulty_function)), case) + assert status == CHECK_FAILED, f"the code with a fault ({control}) passed '{case}': the test cannot see it" diff --git a/tools/harness/tests/test_menu_fonts.py b/tools/harness/tests/test_menu_fonts.py new file mode 100644 index 0000000..ed5842b --- /dev/null +++ b/tools/harness/tests/test_menu_fonts.py @@ -0,0 +1,304 @@ +"""A menu set's theme fonts (port/assets/menus/README.md, "Theme fonts"): a read from a menu file +(port/linux/src/menu_files.c's read_font); each font checked as the game checks it (halo_menus_font_check: a tag the +high-res text draws, a .ttf of the menus' files inside them, a static TrueType font within its size whose tables lie in +it, its SIL Open Font License beside it) and as tools/menu_check.py checks it for a build, both on the same fonts with +the same judgement; the theme's fonts kept by tag, a later one winning (port/linux/game/menu_tags.c's +theme_fonts_build); and the high-res text drawing a themed tag with the theme's font, the other tags and every tag +without a theme with fonts.json's as before, a theme's font that does not read with the stock one, and the atlas +made again only when the theme changes (port/linux/src/text_hires.c).""" + +import re +import shutil +import struct +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "tools")) +from harness import CHECK_FAILED, ROOT, build, constant, enum_with, function, mutated, read, run, structure # noqa: E402 +import menu_check # noqa: E402 + +CASES = ["read", "theme", "draw", "use"] + +FILES = ["file_find", "file_add", "file_read", "path_inside_folder", "file_data"] +READER = ["reader_error", "copy_length", "copy", "parse_long", "read_attributes", "current_line", "grow", "read_font"] +CHECKS = ["font_ushort", "font_ulong", "font_tables_check", "font_text_holds", "font_licence_check", + "halo_menus_font_check"] +TEXT = ["names_differ", "text_hires_enabled", "text_hires_set_reset_hook", "atlas_reset", "text_hires_draws", + "text_hires_theme", "font_fit", "text_hires_font"] + +# a fault in a function and the case that must catch it (None: the checks' batch must judge a font otherwise) +NEGATIVE_CONTROLS = { + "read-needs-nothing": (("if (!reader->failed && (!font.tag || !font.path))", "if (0)"), "read_font", "read"), + "first-of-a-tag-wins": (("menu_theme.font_paths[kept] = font->path;", + "if (!menu_theme.font_paths[kept]) menu_theme.font_paths[kept] = font->path;"), + "theme_fonts_build", "theme"), + "refused-font-kept": (("problem(font->file, font->line, \"a theme's font cannot draw its tag:\", why);\n\t\t\tcontinue;", + "problem(font->file, font->line, \"a theme's font cannot draw its tag:\", why);"), + "theme_fonts_build", "theme"), + "theme-unused": (("if (!names_differ(theme_fonts[theme].tag, tag_name))", "if (0)"), "text_hires_font", "draw"), + "atlas-kept": (("if (atlas)\n\t\tatlas_reset(atlas_scale);", ""), "text_hires_theme", "draw"), + "no-stock-after-a-bad-font": (("\t\t\tbreak;\n\t\t}\n\t}\n\tfor (index = 0;", "\t\t\treturn -1;\n\t\t}\n\t}\n\tfor (index = 0;"), + "text_hires_font", "draw"), + "variable-taken": (('if (!memcmp(entry, "fvar", 4) || !memcmp(entry, "gvar", 4))', "if (0)"), "font_tables_check", + None), + "big-taken": (("size > HALO_MENU_MAXIMUM_FONT_SIZE", "size > 2 * HALO_MENU_MAXIMUM_FONT_SIZE"), "font_tables_check", + None), + "past-the-end-taken": (("if (offset > size || length > size - offset)", "if (offset > size)"), + "font_tables_check", None), + "unlicensed-taken": (("\"it has no licence beside it: %s\", licence);\n\t\treturn 0;", + "\"it has no licence beside it: %s\", licence);\n\t\treturn 1;"), + "font_licence_check", None), + "any-licence-taken": (('if (!font_text_holds(data, size, "SIL OPEN FONT LICENSE") || ' + '!font_text_holds(data, size, "Version 1.1"))', "if (0)"), "font_licence_check", None), + "any-tag-taken": (("if (!tag || !text_hires_draws(tag))", "if (!tag)"), "halo_menus_font_check", None), +} + +STOCK_FONT = ROOT / "port/assets/fonts/Overpass-750.ttf" +STOCK_LICENCE = ROOT / "port/assets/fonts/Overpass-OFL.txt" + + +def static_structure(source, name): + """an unnamed static structure and its variable (static struct { ... } name...;)""" + end = re.search(r"\} " + re.escape(name) + r"(\[\w+\])?;", source) + return source[source.rindex("static struct\n{", 0, end.start()):end.end()] + + +def line(source, start): + """the line that begins with start""" + return re.search(r"^" + re.escape(start) + r"[^\n]*$", source, re.M).group(0) + + +def macro(source, name): + """a #define of several lines, to its last""" + start = source.index("#define " + name) + end = source.index("} while (0)", start) + len("} while (0)") + return source[start:end] + + +def generated(fault=None, faulty_function=None): + files = read("port/linux/src/menu_files.c") + tags = read("port/linux/game/menu_tags.c") + text = read("port/linux/src/text_hires.c") + header = read("port/linux/src/text_hires.h") + + def functions(source, names): + code = "" + for name in names: + body = function(source, name) + if fault and name == faulty_function: + body = mutated(body, *fault) + code += body + "\n\n" + return code + + text_state = "\n".join([ + structure(header, "text_hires_embedded"), line(header, "#define TEXT_HIRES_FIT_LETTERS"), + *(line(text, f"#define {name}") for name in ("MAXIMUM_FONTS", "ATLAS_SIZE", "ATLAS_PADDING", "GLYPH_CACHE_SIZE", + "MAXIMUM_THEME_FONTS")), + static_structure(text, "fonts"), line(text, "static long font_count;"), static_structure(text, "glyphs"), + line(text, "static unsigned char *atlas;"), line(text, "static float atlas_scale;"), + line(text, "static int pack_x, pack_y, pack_row_height;"), line(text, "static int dirty_top"), + line(text, "static void (*reset_hook)(void);"), static_structure(text, "theme_fonts"), + line(text, "static long theme_font_count;")]) + file_state = "\n".join([ + structure(files, "menu_file"), line(files, "static struct menu_file *files;"), + line(files, "static long file_count;"), line(files, "static char folder[1024];")]) + reader = "\n\n".join([f"#define MAXIMUM_DEPTH {constant(files, 'MAXIMUM_DEPTH')}", enum_with(files, "_element_none,"), + structure(files, "attribute"), structure(files, "reader"), macro(files, "GROW(")]) + theme = static_structure(tags, "menu_theme") + return (("stb_truetype.h", read("port/third_party/stb/stb_truetype.h")), + ("halo_menus.h", read("port/linux/include/halo_menus.h")), + ("text_hires.inc", text_state), + ("text_hires_code.inc", functions(text, TEXT)), + ("files.inc", file_state + "\n\n" + functions(files, FILES) + reader + "\n\n" + functions(files, READER) + + functions(files, CHECKS)), + ("fonts_use.inc", functions(files, ["halo_menus_fonts_use"])), + ("theme.inc", "typedef struct { real alpha, red, green, blue; } real_argb_color;\n" + theme + + "\n\nstatic int checked_good = 1;\n" + "static int theme_font_check(char const *tag, char const *path, char *why, int why_size)\n" + "{\n\t(void)tag;\n\t(void)path;\n\tif (!checked_good)\n\t\tsnprintf(why, (size_t)why_size, \"not a font\");\n" + "\treturn checked_good;\n}\n#define halo_menus_font_check theme_font_check\n" + + functions(tags, ["theme_fonts_build"]) + "#undef halo_menus_font_check\n")) + + +# ---------- the fonts both checks judge + +def tables(data): + """a font's table directory: (tag, offset, length, where in the file its entry is)""" + count = struct.unpack(">H", data[4:6])[0] + return [(data[12 + 16 * index:16 + 16 * index], *struct.unpack(">II", data[20 + 16 * index:28 + 16 * index]), + 12 + 16 * index) for index in range(count)] + + +def renamed(data, tag, to): + """the font with a table's tag changed""" + entry = next(where for name, _, _, where in tables(data) if name == tag) + return data[:entry] + to + data[entry + 4:] + + +def fonts(): + """the fonts of the check: (name, its bytes, a licence of its family beside it, whether it is to be taken)""" + stock = STOCK_FONT.read_bytes() + longest = max(tables(stock), key=lambda table: table[1]) + past = bytearray(stock) + struct.pack_into(">I", past, longest[3] + 12, len(stock) - longest[1] + 1) + return [ + ("Good-Regular.ttf", stock, True, True), + ("Good-Bold.ttf", stock, True, True), + ("Good-Medium.ttf", stock, True, True), + ("Upper-Regular.TTF", stock, True, True), + ("Truncated-Regular.ttf", stock[:1000], True, False), + ("Tiny-Regular.ttf", stock[:8], True, False), + ("Otto-Regular.ttf", b"OTTO" + stock[4:], True, False), + ("Collection-Regular.ttf", b"ttcf" + stock[4:], True, False), + ("Variable-Regular.ttf", renamed(stock, b"post", b"fvar"), True, False), + ("Gvar-Regular.ttf", renamed(stock, b"post", b"gvar"), True, False), + ("Noglyf-Regular.ttf", renamed(stock, b"glyf", b"glyx"), True, False), + ("Nocmap-Regular.ttf", renamed(stock, b"cmap", b"cmax"), True, False), + ("Big-Regular.ttf", stock + bytes(4194304 + 1 - len(stock)), True, False), + ("Largest-Regular.ttf", stock + bytes(4194304 - len(stock)), True, True), + ("Pastend-Regular.ttf", bytes(past), True, False), + ("Unlicensed-Regular.ttf", stock, False, False), + ("Otherlicence-Regular.ttf", stock, "MIT License\n\nPermission is hereby granted...\n", False), + ("Notattf-Regular.otf", stock, True, False), + ] + + +def menus_folder(root): + """the test's config folder: menus/ce/fonts/ with the fonts, and their licences beside them""" + folder = root / "menus" / "ce" / "fonts" + folder.mkdir(parents=True) + licence = STOCK_LICENCE.read_text() + for name, data, licensed, _ in fonts(): + (folder / name).write_bytes(data) + family = re.split(r"[-.]", name, maxsplit=1)[0] + if licensed is True: + (folder / f"{family}-OFL.txt").write_text(licence) + elif licensed: + (folder / f"{family}-OFL.txt").write_text(licensed) + (root / "menus" / "outside.ttf").write_bytes(STOCK_FONT.read_bytes()) + return root + + +CHECKED = [ + *(("ui\\small_ui", f"ce/fonts/{name}", good) for name, _, _, good in fonts()), + ("UI\\LARGE_UI", "ce/fonts/Good-Regular.ttf", True), + ("ui\\interstate", "ce/fonts/Good-Regular.ttf", False), # (not one of the test's stock tags) + ("ui\\hud_number", "ce/fonts/Good-Regular.ttf", False), + ("ui\\small_ui", "ce/fonts/Missing-Regular.ttf", False), + ("ui\\small_ui", "ce/fonts/../fonts/Good-Regular.ttf", False), + ("ui\\small_ui", "/ce/fonts/Good-Regular.ttf", False), + ("ui\\small_ui", "c:ce/fonts/Good-Regular.ttf", False), + ("ui\\small_ui", "ce/fonts/Good-OFL.txt", False), +] + + +def judged_by_the_game(executable, root, tmp_path, monkeypatch): + path = tmp_path / "checks.txt" + path.write_text("".join(f"{tag} {font}\n" for tag, font, _ in CHECKED)) + monkeypatch.setenv("MENU_FONTS_FOLDER", str(root)) + monkeypatch.setenv("MENU_FONTS_REPOSITORY", str(ROOT)) + result = subprocess.run([str(executable), "checks", str(path)], capture_output=True, text=True, timeout=120) + assert result.returncode == 0, result.stderr + return result.stdout.splitlines() + + +def set_files(root): + """the test folder's menus as a set's files (menu_check.MenuFile)""" + menus = root / "menus" + return {path.relative_to(menus).as_posix(): menu_check.MenuFile(path.relative_to(menus).as_posix(), + path.read_bytes(), str(path)) + for path in menus.rglob("*") if path.is_file()} + + +# ---------- the tests + +@pytest.mark.parametrize("case", CASES) +def test_case(case, tmp_path, monkeypatch): + root = menus_folder(tmp_path) + monkeypatch.setenv("MENU_FONTS_FOLDER", str(root)) + monkeypatch.setenv("MENU_FONTS_REPOSITORY", str(ROOT)) + status, output = run(build("menu_fonts", generated()), case) + assert status == 0, output + + +def test_the_game_checks_each_font(tmp_path, monkeypatch): + root = menus_folder(tmp_path) + results = judged_by_the_game(build("menu_fonts", generated()), root, tmp_path, monkeypatch) + assert len(results) == len(CHECKED) + wrong = [f"{tag} {font}: {result}" for (tag, font, good), result in zip(CHECKED, results) + if (result == "ok") != good] + assert not wrong + + +def test_the_build_checks_each_font_alike(tmp_path, monkeypatch): + """tools/menu_check.py's judgement of each font (with the test's stock tags, the game's harness's) is the + game's""" + root = menus_folder(tmp_path) + files = set_files(root) + tags = frozenset({"ui\\small_ui", "ui\\large_ui"}) + for tag, font, good in CHECKED: + problem = menu_check.font_problem(tag, font, files, tags) + assert (problem is None) == good, f"{tag} {font}: {problem}" + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control, tmp_path, monkeypatch): + fault, faulty_function, case = NEGATIVE_CONTROLS[control] + root = menus_folder(tmp_path) + executable = build("menu_fonts", generated(fault, faulty_function)) + if case is None: + results = judged_by_the_game(executable, root, tmp_path, monkeypatch) + assert any((result == "ok") != good for (_, _, good), result in zip(CHECKED, results)), \ + f"the code with a fault ({control}) judged every font as it should" + return + monkeypatch.setenv("MENU_FONTS_FOLDER", str(root)) + monkeypatch.setenv("MENU_FONTS_REPOSITORY", str(ROOT)) + status, output = run(executable, case) + assert status == CHECK_FAILED, f"the code with a fault ({control}) passed '{case}': the test cannot see it" + + +def test_the_menus_give_the_text_their_fonts(): + """menu_tags.c builds the theme's fonts with its colours, gives them to the high-res text once its menus are + built, and puts the stock fonts back when it lets them go (the game's own menus have no theme)""" + tags = read("port/linux/game/menu_tags.c") + loaded = function(tags, "menu_tags_loaded") + assert loaded.index("theme_build(menus);") < loaded.index("theme_fonts_build(menus);") < \ + loaded.index("menu_tags.loaded = TRUE;") < \ + loaded.index("halo_menus_fonts_use(menu_theme.font_tags, menu_theme.font_paths, menu_theme.font_count);") + release = function(tags, "menu_tags_release") + assert release.index("halo_menus_fonts_use(NULL, NULL, 0);") < release.index("csmemset(&menu_theme, 0") + + +def test_a_set_with_fonts_is_checked(tmp_path): + """menu_check.problems on a set whose theme.xml gives fonts: none for good fonts with their licences, each bad + one named (the stock set, which gives none, is checked by tools/test_linux_port.py)""" + stock = [file for file in (menu_check.MenuFile(name, (ROOT / "port/assets/menus" / name).read_bytes(), name) + for name in stock_names())] + licence = STOCK_LICENCE.read_bytes() + font = STOCK_FONT.read_bytes() + + def problems(theme, extra): + files = stock + [menu_check.MenuFile("ce/theme.xml", theme.encode(), "ce/theme.xml"), + *(menu_check.MenuFile(name, data, name) for name, data in extra.items())] + return menu_check.problems(files, ROOT) + + good = ('\n\n' + '\n\n') + assert problems(good, {"ce/fonts/Theme-SemiBold.ttf": font, "ce/fonts/Theme-Bold.ttf": font, + "ce/fonts/Theme-OFL.txt": licence}) == [] + found = problems(good, {"ce/fonts/Theme-SemiBold.ttf": font, "ce/fonts/Theme-Bold.ttf": font}) + assert found and all("no licence beside it" in problem for problem in found), found + found = problems('\n\n\n', {}) + assert found and "a needs a tag and a file" in found[0], found + found = problems('\n\n\n', + {"ce/fonts/Theme-SemiBold.ttf": font, "ce/fonts/Theme-OFL.txt": licence}) + assert found and "size" in found[0], found + + +def stock_names(): + import json + return json.loads((ROOT / "port/assets/menus/menus.json").read_text())["files"] diff --git a/tools/harness/tests/test_menu_theme.py b/tools/harness/tests/test_menu_theme.py new file mode 100644 index 0000000..0708f6d --- /dev/null +++ b/tools/harness/tests/test_menu_theme.py @@ -0,0 +1,124 @@ +"""A menu theme's colours (port/assets/menus/README.md, "Theme colours"): its elements read from a menu file +(port/linux/src/menu_files.c: , and the fixed colours' <..._text color=>), the theme the game +builds from them and its colours (port/linux/game/menu_tags.c: a text colour mapped, a later map of a colour +winning, 32 colours at most, a colour's syntax; the game's own menus have none), and the colour a text is drawn in +(port/linux/game/menu_functions.c's pc_menu_text_color: focused text, which source/interface/ui_widget.c draws in its +light grey; the server browser's rows).""" + +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, constant, enum_with, function, mutated, read, run, structure # noqa: E402 + +CASES = ["read", "map", "limits", "texts", "hook"] + +READER = ["reader_error", "copy_length", "copy", "parse_long", "read_attributes", "current_line", "grow", + "text_element", "read_color"] +THEME = ["hex_digit", "parse_color", "color_packed", "theme_build", "theme_mapped", "pc_menu_theme_text"] +HOOK = ["browser_row_index", "browser_row_text", "pc_menu_text_color"] + +# a fault in a function and the case that must catch it +NEGATIVE_CONTROLS = { + "map-unread": (('struct attribute map_table[] = { { "from", &color.from }, { "to", &color.to } };', + 'struct attribute map_table[] = { { "from", &color.from }, { "to", &color.from } };'), + "read_color", "read"), + "map-needs-nothing": (("if (!reader->failed && (!color.from || !color.to))", "if (0)"), "read_color", "read"), + "texts-shifted": (("return index;", "return index + 1;"), "text_element", "read"), + "first-map-wins": (("menu_theme.map_to[map] = to;", + "if (map == menu_theme.map_count - 1 || !menu_theme.map_to[map].alpha)\n" + "\t\t\tmenu_theme.map_to[map] = to;"), "theme_build", "map"), + "maps-unbounded": (("if (map == HALO_MENU_MAXIMUM_COLOR_MAPS)", "if (0)"), "theme_build", "limits"), + "bad-colour-taken": (("if (!parse_color(color->from, &from) || !parse_color(color->to, &to))", + "if (!parse_color(color->from, &from) && !parse_color(color->to, &to))"), "theme_build", + "limits"), + "alpha-unmatched": (("packed = packed << 8 | (unsigned long)(PIN(components[index], 0.0f, 1.0f) * 255.0f + 0.5f);", + "packed = index ? packed << 8 | (unsigned long)(PIN(components[index], 0.0f, 1.0f) * 255.0f " + "+ 0.5f) : 0;"), "color_packed", "map"), + "theme-with-the-games-menus": (("if (!menu_tags.loaded || text < 0", "if (text < 0"), "pc_menu_theme_text", + "texts"), + "focus-unthemed": (("if (focus && pc_menu_theme_text(HALO_MENU_TEXT_FOCUS, &theme))", "if (0)"), + "pc_menu_text_color", "hook"), + "focus-any-text": (("if (focus && pc_menu_theme_text(HALO_MENU_TEXT_FOCUS, &theme))", + "if (pc_menu_theme_text(HALO_MENU_TEXT_FOCUS, &theme))"), "pc_menu_text_color", "hook"), + "browser-unthemed": (("if (pc_menu_theme_text(text, &theme))", "if (0)"), "pc_menu_text_color", "hook"), + "status-as-fair-ping": (("text = HALO_MENU_TEXT_STATUS;", "text = HALO_MENU_TEXT_PING_FAIR;"), "browser_row_text", + "hook"), +} + + +def static_structure(source, name): + """an unnamed static structure and its variable (static struct { ... } name;)""" + end = source.index("} " + name + ";") + return source[source.rindex("static struct\n{", 0, end):end + len(name) + 3] + + +def macro(source, name): + """a #define of several lines, to its last""" + start = source.index("#define " + name) + end = source.index("} while (0)", start) + len("} while (0)") + return source[start:end] + + +def line(source, start): + """the line that begins with start""" + return re.search(r"^" + re.escape(start) + r"[^\n]*$", source, re.M).group(0) + + +def generated(fault=None, faulty_function=None): + files = read("port/linux/src/menu_files.c") + tags = read("port/linux/game/menu_tags.c") + menus = read("port/linux/game/menu_functions.c") + + def functions(source, names): + text = "" + for name in names: + code = function(source, name) + if fault and name == faulty_function: + code = mutated(code, *fault) + text += code + "\n\n" + return text + + reader = "\n\n".join([f"#define MAXIMUM_DEPTH {constant(files, 'MAXIMUM_DEPTH')}", enum_with(files, "_element_none,"), + structure(files, "attribute"), structure(files, "reader"), macro(files, "GROW(")]) + theme = static_structure(tags, "menu_theme") + hook = "\n".join([enum_with(menus, "PING_GOOD = "), *(line(menus, f"#define {name}") for name in ( + "PING_COLOR_GOOD", "PING_COLOR_FAIR", "PING_COLOR_POOR", "ROW_COLOR_GREYED")), + line(menus, "static short browser_row_pings[BROWSER_ROWS];"), + line(menus, "static boolean browser_row_marked[BROWSER_ROWS];"), + line(menus, "static boolean browser_row_greyed[BROWSER_ROWS];")]) + return (("halo_menus.h", read("port/linux/include/halo_menus.h")), + ("reader.inc", reader + "\n\n" + functions(files, READER)), + ("theme.inc", theme + "\n\n" + functions(tags, THEME)), + ("hook.inc", hook + "\n\n" + functions(menus, HOOK))) + + +def test_the_engine_asks_the_hook_for_focused_text(): + """ui_widget.c gives the hook a text box's and a spinner's colour with its focus, before their alpha is set, + and menu_tags.c maps every widget's text colour as it is built, its theme built first""" + widget = read("source/interface/ui_widget.c") + text_box = function(widget, "widget_instance_render_text_box") + spinner = function(widget, "widget_instance_render_spinner_list") + for body in (text_box, spinner): + assert body.index("pc_menu_text_color(widget, focus, &color);") < body.index("modifier * color_alpha;") + tags = read("port/linux/game/menu_tags.c") + assert "theme_mapped(&definition->text_color);" in function(tags, "widget_build") or \ + "theme_mapped(&definition->text_color);" in tags + loaded = function(tags, "menu_tags_loaded") + assert loaded.index("theme_build(menus);") < loaded.index("widget_build(index)") + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("menu_theme", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, faulty_function, case = NEGATIVE_CONTROLS[control] + status, output = run(build("menu_theme", generated(fault, faulty_function)), case) + assert status == CHECK_FAILED, f"the code with a fault ({control}) passed '{case}': the test cannot see it" diff --git a/tools/harness/tests/test_player_link.py b/tools/harness/tests/test_player_link.py index 0c7e453..f54bc06 100644 --- a/tools/harness/tests/test_player_link.py +++ b/tools/harness/tests/test_player_link.py @@ -5,8 +5,17 @@ and a pass request sign, and which master's pass a join shows (port/linux/src/pl addresses in it, and vpn_allow.txt's ranges (port/linux/src/server_netlist.c); on the real SHA-256 and Ed25519 (p2p_crypto.c, Monocypher). +And the HaloCustoms site's accounts ("The account hint", docs/dedicated_server.md "Accounts"): the hint a join +writes when it shows no pass, read back and refused when malformed, and never read beside a pass or by a server +with no link master; a site's pass (the vector the site's PassIssuer makes, halocustoms_vectors.json) checked by a +server over the masters it publishes to and its link masters, its account and master, and its request's query and +signature byte for byte; which pass a refused join shows next with the identity master's; and the door's word for +the link masters' accounts (the quiet ask, server.link_required's refusals). + The vectors are test_master_protocol.py's (the master's testdata/vectors.json, else the copy here).""" +import json + import sys from pathlib import Path @@ -17,14 +26,23 @@ from harness import CHECK_FAILED, build, constant, function, mutated, read, run from harness.tests.test_master_protocol import c_string, crypto_includes, key_hex, vectors # noqa: E402 CASES = ["pass-read", "pass-signed", "proof", "check", "passes-refused", "link-texts", "netlist", "netlists-refused", - "ranges-text", "next-pass"] + "ranges-text", "next-pass", "hint", "hint-unread", "site-pass", "pass-query", "join-choice", "first-choice", + "refusal-choice", "door"] + +# the HaloCustoms site's pass, as its PassIssuer makes it (the site's tests have the same) +SITE_VECTORS = Path(__file__).with_name("halocustoms_vectors.json") SOURCES = { "port/linux/src/p2p.c": ["p2p_hex"], "port/linux/src/player_link.c": ["get_u64", "player_link_pass_read", "player_link_pass_signed", "player_link_binding", "player_link_proof_message", "player_link_proof_check", "player_link_code", "player_link_request_text", "player_link_check", - "player_link_discord_created", "listed_by", "player_link_next_pass"], + "player_link_discord_created", "listed_by", "player_link_next_pass", + "player_link_join_choice", "player_link_first_choice", "player_link_refusal_choice", + "player_link_hint_make", "player_link_hint_read", + "player_link_pass_query", "player_link_pass_shown", "player_link_check_account", + "player_link_account_counts", "player_link_door_word", + "player_link_server_hinted"], "port/linux/src/server_netlist.c": ["get_u32", "get_u64", "server_netlist_read", "server_netlist_ranges_contain", "read_address", "compare_pairs", "server_netlist_parse_ranges"], } @@ -62,9 +80,101 @@ NEGATIVE_CONTROLS = { "next-pass-names-none": (("*needed = index;", "*needed = -1;"), "player_link_next_pass", "next-pass"), "ranges-never-merged": (("if (first <= *previous_last || first - 1 == *previous_last)", "if (first <= *previous_last)"), "server_netlist_parse_ranges", "ranges-text"), + # the account hint + "hint-mislabelled": (('memcpy(proof, "OPCEHINT", 8);', 'memcpy(proof, "OPCEHUNT", 8);'), "player_link_hint_make", + "hint"), + "hint-any-format": (("|| proof[8] != PLAYER_LINK_HINT_FORMAT)", ")"), "player_link_hint_read", "hint"), + "hint-any-count": (("count = proof[9];\n\tif (count < 1 || count > PLAYER_LINK_HINT_MAXIMUM)", + "count = proof[9];\n\tif (0)"), "player_link_hint_read", "hint"), + "hint-any-tail": (("for (index = 10 + PLAYER_LINK_KEY_ID_SIZE * count;", "for (index = PLAYER_LINK_PROOF_SIZE;"), + "player_link_hint_read", "hint"), + "hint-beside-a-pass": (("if (player_link_pass_shown(pass) || memcmp(", "if (memcmp("), "player_link_hint_read", + "hint-unread"), + "hint-of-any-master": (("if (server_master_link_master(key_ids[index]))", "if (1)"), "player_link_server_hinted", + "hint-unread"), + # a site's pass at a server + "account-any-link": (("account->link_master = link[index] != 0;", "account->link_master = 1;"), + "player_link_check_account", "site-pass"), + "account-always-bound": (("account->bound = zeros < PLAYER_LINK_BINDING_SIZE;", "account->bound = 1;"), + "player_link_check_account", "site-pass"), + "account-snowflake-raw": (("account->created = (unsigned long)player_link_discord_created(fields.discord_id);", + "account->created = (unsigned long)fields.discord_id;"), "player_link_check_account", + "site-pass"), + "account-unchecked": (("if (!player_link_check(pass, proof, binding, master_keys, master_count, now, &fields))", + "if (!player_link_pass_read(pass, PLAYER_LINK_PASS_SIZE, &fields))"), + "player_link_check_account", "site-pass"), + "query-mislabelled": (('player_link_request_text("opence-pass-get-1"', 'player_link_request_text("opence-pass-get"'), + "player_link_pass_query", "pass-query"), + "query-reordered": (('"key=%s&ts=%llu&sig=%s", key, ts, signature_hex', '"ts=%llu&key=%s&sig=%s", ts, key, ' + 'signature_hex'), "player_link_pass_query", "pass-query"), + # the retries + "choice-identity-unhinted": (("if (hinted && identity)", "if (identity)"), "player_link_join_choice", + "join-choice"), + "choice-identity-never-last": (("return index >= 0 ? index : identity ? PLAYER_LINK_IDENTITY : -1;\n\t}\n\tif " + "(rejection_code == PLAYER_LINK_REFUSED_INVALID)", + "return index;\n\t}\n\tif (rejection_code == PLAYER_LINK_REFUSED_INVALID)"), + "player_link_join_choice", "join-choice"), + "choice-masters-again": (("server_count, last, needed);\n\t\tif (shown < 0)", + "server_count, -1, needed);\n\t\tif (shown < 0)"), "player_link_join_choice", + "join-choice"), + "choice-no-identity-after-masters": (("server_count, shown, needed);\n\t\treturn index >= 0 ? index : identity ? " + "PLAYER_LINK_IDENTITY : -1;", "server_count, shown, needed);\n\t\treturn " + "index;"), "player_link_join_choice", "join-choice"), + "choice-asked-while-shown": (("if (shown != -1)\n\t\t\treturn -1;", ";"), "player_link_join_choice", + "join-choice"), + # the first join's pass (network.link_share "always" with the identity's shared when asked) + "first-discord-over-the-hint": (('if (discord >= 0 && !strcmp(link_share, "always") && !(identity && ' + '!strcmp(share_account, "asked")))', 'if (discord >= 0 && !strcmp(link_share, ' + '"always"))'), "player_link_first_choice", "first-choice"), + "first-identity-unasked": (('if (identity && !strcmp(share_account, "always"))', 'if (identity)'), + "player_link_first_choice", "first-choice"), + # the final refusal's words + "word-vpn-said-as-account": (("&& shown == -1 && (hinted || identity_refused))", "&& shown == -1)"), + "player_link_refusal_choice", "refusal-choice"), + "word-ask-after-hint-unsaid": (("&& shown == -1 && (hinted || identity_refused))", "&& shown == -1 && 0)"), + "player_link_refusal_choice", "refusal-choice"), + "word-too-new-as-discord": (("case PLAYER_LINK_REFUSED_TOO_NEW:\n\t\t\treturn _player_link_word_account_too_new;", + "case PLAYER_LINK_REFUSED_TOO_NEW:\n\t\t\treturn _player_link_word_none;"), + "player_link_refusal_choice", "refusal-choice"), + "word-any-discord-pass-ranked": (("shown >= 0 && listed)", "shown >= 0)"), "player_link_refusal_choice", + "refusal-choice"), + # the door + "counts-unbound-anywhere": (("return checked && link_master && (bound || !global);", + "return checked && link_master;"), "player_link_account_counts", "door"), + "counts-any-master": (("return checked && link_master && (bound || !global);", + "return checked && (bound || !global);"), "player_link_account_counts", "door"), + "ask-unhinted": (("if (!shown && hinted && link_ask && ask_allowed)", "if (!shown && link_ask && ask_allowed)"), + "player_link_door_word", "door"), + "ask-every-time": (("if (!shown && hinted && link_ask && ask_allowed)", "if (!shown && hinted && link_ask)"), + "player_link_door_word", "door"), + "ask-despite-the-setting": (("if (!shown && hinted && link_ask && ask_allowed)", + "if (!shown && hinted && ask_allowed)"), "player_link_door_word", "door"), + "required-never": (("if (!link_required)\n\t\treturn _player_link_door_in;", + "if (1)\n\t\treturn _player_link_door_in;"), "player_link_door_word", "door"), + "required-takes-any-pass": (("if (!counts)", "if (0)"), "player_link_door_word", "door"), + "required-takes-a-second-seat": (("if (in_use)", "if (0)"), "player_link_door_word", "door"), } +def site_vectors_include(): + """the site's pass vector as C""" + v = json.loads(SITE_VECTORS.read_text(encoding="utf-8")) + values = { + "SITE_SEED": v["site_seed_hex"], "SITE_PUBLIC_KEY": key_hex(v["site_public_key"]), + "SITE_KEY_ID": v["site_key_id"], "SITE_PLAYER_SEED": v["player_seed_hex"], + "SITE_PLAYER_PUBLIC_KEY": v["player_public_key_hex"], "SITE_ACCOUNT_ID": v["account_id"], + "SITE_NAME": v["name"], "SITE_PASS": v["pass_hex"], + "SITE_QUERY": v["pass_request"]["url"].split("?", 1)[1], + "SITE_REQUEST_TEXT": v["pass_request"]["signed_text"], "SITE_REQUEST_SIG": v["pass_request"]["sig_hex"], + } + text = "".join(f"#define VECTOR_{name} {c_string(value)}\n" for name, value in values.items()) + ts = v["pass_request"]["url"].split("ts=", 1)[1].split("&", 1)[0] + return text + (f"#define VECTOR_SITE_ISSUED {v['issued']}ULL\n#define VECTOR_SITE_EXPIRES {v['expires']}ULL\n" + f"#define VECTOR_SITE_CREATED {v['account_id_created_ms'] // 1000}UL\n" + f"#define VECTOR_SITE_PROFILE_CREATED {v['profile_created']}UL\n" + f"#define VECTOR_SITE_FLAGS {v['flags']}\n#define VECTOR_SITE_REQUEST_TS {ts}ULL\n") + + def vectors_include(v): """the vectors this test reads, as C""" discord_id = int(v["pass"]["discord_id"]) @@ -116,7 +226,8 @@ def generated(fault=None, faulty_function=None): text += code + "\n" return crypto_includes() + (("player_link.h", read("port/linux/src/player_link.h")), ("server_netlist.h", read("port/linux/src/server_netlist.h")), - ("vectors.inc", vectors_include(vectors())), ("under_test.inc", text)) + ("vectors.inc", vectors_include(vectors()) + site_vectors_include()), + ("under_test.inc", text)) @pytest.mark.parametrize("case", CASES) diff --git a/tools/harness/tests/test_server_results.py b/tools/harness/tests/test_server_results.py new file mode 100644 index 0000000..ab4baad --- /dev/null +++ b/tools/harness/tests/test_server_results.py @@ -0,0 +1,109 @@ +"""A dedicated server's results line (server.results_file: docs/dedicated_server.md, "Results"), as +port/linux/game/server_director.c writes one for each finished multiplayer game for a site to read: the game, the +rows of the players in it and of those who left, each with its tallies and the link master's account it is credited +with (or null), in exactly the form the site's collector reads; written by the real server_json.c.""" + +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, constant, function, mutated, read, run, structure # noqa: E402 + +CASES = ["line", "no-teams", "nobody-left", "left"] + +DIRECTOR = "port/linux/game/server_director.c" +JSMN = '#include "../../third_party/jsmn/jsmn.h"' + +# a fault in a function of the director's and the case that must catch it +NEGATIVE_CONTROLS = { + "left-never-said": (('server_json_boolean(json, "left", left);', 'server_json_boolean(json, "left", FALSE);'), + "server_results_row", "line"), + "account-without-null": (('\telse\n\t{\n\t\tserver_json_null(json, "account");\n\t}\n', ""), "server_results_row", + "line"), + "account-without-its-master": (('server_json_string(json, "master", row->account_master);', ""), + "server_results_row", "line"), + "leavers-dropped": (("\tfor (index = 0; index < leaver_count; index++)\n\t\tserver_results_row(json, " + "&leavers[index], TRUE);\n", ""), "server_results_line", "line"), + "leavers-first": (("\tfor (index = 0; index < count; index++)\n\t\tserver_results_row(json, &rows[index], FALSE);" + "\n\tfor (index = 0; index < leaver_count; index++)\n\t\tserver_results_row(json, " + "&leavers[index], TRUE);", + "\tfor (index = 0; index < leaver_count; index++)\n\t\tserver_results_row(json, " + "&leavers[index], TRUE);\n\tfor (index = 0; index < count; index++)\n\t\tserver_results_row(" + "json, &rows[index], FALSE);"), "server_results_line", "line"), + "team-scores-always": (("if (game->team_scored)", "if (1)"), "server_results_line", "no-teams"), + "no-version": (('server_json_integer(json, "v", 1);', ""), "server_results_line", "line"), + "map-for-level": (('server_json_string(json, "level", game->level);', + 'server_json_string(json, "level", game->map);'), "server_results_line", "line"), + # a player who leaves: the machine's account not credited to another of it, and freed with its last player + "left-credited-again": (("server.result_account_slots[machine] = RESULT_ACCOUNT_LEFT;", ";"), + "server_results_left", "left"), + "left-index-kept": (("if (other == MAXIMUM_SLOTS)\n\t\t\tserver.result_account_slots[machine] = NONE;", ""), + "server_results_left", "left"), + "left-row-not-kept": (("server.result_leavers[server.result_leaver_count++] = *row;", ";"), "server_results_left", + "left"), +} + + +def server_state(director): + """the director's limits, and the fields of its state that a player's leaving reads""" + known = director + read("port/linux/include/halo_port_limits.h") + limits = "\n".join(f"enum {{ {name} = {constant(known, name)} }};" for name in + ("MAXIMUM_SLOTS", "MAXIMUM_MACHINES", "MAXIMUM_RESULT_LEAVERS")) + limits += "\nenum { RESULT_ACCOUNT_LEFT = %s };" % re.search(r"RESULT_ACCOUNT_LEFT = (-?\d+)", director).group(1) + return limits + """ +enum { _server_state_playing = 3 }; +static struct +{ + short state; + struct server_result_row result_rows[MAXIMUM_SLOTS]; + struct server_result_row result_leavers[MAXIMUM_RESULT_LEAVERS]; + long result_leaver_count; + long result_account_slots[MAXIMUM_MACHINES]; +} server; +""" + + +def generated(fault=None, faulty_function=None): + director = read(DIRECTOR) + code = "\n\n".join([structure(read("port/linux/game/server_director.h"), "server_status_tally"), + structure(director, "server_result_row"), structure(director, "server_results_game")]) + code += "\n\n" + server_state(director) + for name in ("server_results_row", "server_results_line", "server_results_left"): + text = function(director, name) + if fault and name == faulty_function: + text = mutated(text, *fault) + code += "\n\n" + text + json = mutated(read("port/linux/src/server_json.c"), JSMN, '#include "jsmn.h"') + return (("server_json.h", read("port/linux/src/server_json.h")), ("jsmn.h", read("port/third_party/jsmn/jsmn.h")), + ("server_json.inc", json), ("under_test.inc", code + "\n")) + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("server_results", generated()), case) + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, faulty_function, case = NEGATIVE_CONTROLS[control] + status, output = run(build("server_results", generated(fault, faulty_function)), case) + assert status == CHECK_FAILED, f"the code with a fault ({control}) passed '{case}': the test cannot see it" + + +def test_the_results_are_written_at_the_games_end(): + """server_game_over adds the line before it says the game ended; a game starts the rows afresh; a player who + leaves keeps a row; and the rows are seen each second while the game is played (not co-op's).""" + director = read(DIRECTOR) + over = function(director, "server_game_over") + assert over.index("server_results_write();") < over.index('server_game_event("game_ended"') + assert "server_results_left(slot);" in function(director, "server_note_players") + playing = function(director, "server_playing") + assert "server_results_snapshot();" in playing and "!server_cooperative()" in playing + update = function(director, "server_update") + assert update.index("server.games_hosted++;") < update.index("server_results_begin();") + write = function(director, "server_results_write") + assert "server_cooperative()" in write and "posix_server_append_line(path" in write diff --git a/tools/harness/tests/test_updater_feed.py b/tools/harness/tests/test_updater_feed.py new file mode 100644 index 0000000..ef95e4f --- /dev/null +++ b/tools/harness/tests/test_updater_feed.py @@ -0,0 +1,411 @@ +"""The desktop self-updater's check of a release (port/linux/src/updater.c) by contract 1 (FORK.md, "Releases"): the +release's manifest read with no leniency (hostile manifests: sizes, order, names listed twice, CRLF, long lines, bytes +that are not printable ASCII, numbers out of their range), its Ed25519 signature checked with the build's pinned key +and its key id, every way a release fails to check refused (no key pinned, another key, the signature or manifest +changed, the feed's tag not the manifest's, no file for this build), the release offered only when newer, the zip +downloaded from the site's address for the tag and checked against the manifest's size and SHA-256; the release +vector (release_vectors.json, which the site keeps a copy of); and the signing tool's releases +(tools/halocustoms_release_sign.py) taken. The same manifests and signatures are judged by the Android updater's code +(ReleaseManifest.java, Ed25519.java) when a JDK is here. On the real SHA-256, Ed25519 (p2p_crypto.c, Monocypher) and +JSON reader (server_json.c).""" + +import hashlib +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, ROOT, build, enum_with, function, mutated, read, run, structure # noqa: E402 +from test_master_protocol import crypto_includes # noqa: E402 + +HERE = Path(__file__).resolve().parent +VECTOR = json.loads((HERE / "release_vectors.json").read_text()) +LINUX = next(asset for asset in VECTOR["assets"] if asset["name"] == "halo-linux-release.zip") +ANDROID = next(asset for asset in VECTOR["assets"] if asset["name"] == "halo-android-release.zip") +TOOL = ROOT / "tools/halocustoms_release_sign.py" +JAVA_SOURCES = [ROOT / "port/android/app/src/main/java/com/halo/decomp/Ed25519.java", + ROOT / "port/android/app/src/main/java/com/halo/decomp/ReleaseManifest.java", + ROOT / "tools/tests/ReleaseManifestBatch.java"] + +CASES = ["vector", "fail-closed", "newer", "zip-url", "zip-checks"] + +# the updater's functions taken, each defined before what calls it +FUNCTIONS = ["updater_path", "updater_hex_digit", "updater_hex_bytes", "updater_decimal", "updater_asset_name", + "updater_fields", "updater_manifest_read", "updater_release_checks", "updater_release_key", + "updater_latest_release", "updater_check_thread", "updater_zip_url", "updater_zip_checks"] + +# a fault in the code, the function it is in, and the case that must catch it +NEGATIVE_CONTROLS = { + "unsigned-taken": (("if (!p2p_ed25519_verify(key, text, (int)strlen(text), signature))", + "if (0 && !p2p_ed25519_verify(key, text, (int)strlen(text), signature))"), + "updater_release_checks", "fail-closed"), + "any-key-id": (("memcmp(id, digest, UPDATER_KEY_ID_SIZE))", "0)"), "updater_release_checks", "fail-closed"), + "any-tag": (("if (strcmp(tag, expected))", "if (0)"), "updater_release_checks", "fail-closed"), + "no-file-taken": (("if (!manifest->has_asset)", "if (0)"), "updater_release_checks", "fail-closed"), + "not-newer-offered": (("if (latest > HALO_BUILD_NUMBER)", "if (latest >= HALO_BUILD_NUMBER)"), + "updater_check_thread", "newer"), + "size-unchecked": (("if ((unsigned long long)size != updater_latest_size)", "if (0)"), "updater_zip_checks", + "zip-checks"), + "digest-unchecked": (("if (memcmp(digest, updater_latest_digest, sizeof(digest)))", "if (0)"), + "updater_zip_checks", "zip-checks"), +} + +# faults in the manifest's reading, which the batch's judgement must see (test_batch_sees_a_fault) +BATCH_CONTROLS = { + "names-twice": (("strcmp(name, previous) <= 0", "strcmp(name, previous) < 0"), "updater_manifest_read"), + "any-order": (("strcmp(name, previous) <= 0", "0"), "updater_manifest_read"), + "leading-zeros": (("length > 19 || text[0] == '0'", "length > 19"), "updater_decimal"), + "no-final-line-feed": (("|| text[length - 1] != '\\n')", ")"), "updater_manifest_read"), + "upper-case-sha256": (("sha256, P2P_SHA256_SIZE, 0))", "sha256, P2P_SHA256_SIZE, 1))"), "updater_manifest_read"), +} +# (none of the line limit's: a line of more than 200 bytes is no line of the contract's either, at most 145 for an +# asset, so its fields refuse it too) + + +def c_string(text): + """a C string literal of ASCII text""" + assert text.isascii() + return json.dumps(text) + + +def vector_include(): + return "".join(f"#define VECTOR_{name} {value}\n" for name, value in [ + ("SEED", c_string(VECTOR["seed"])), ("PUBLIC_KEY", c_string(VECTOR["public_key"][8:])), + ("KEY_ID", c_string(VECTOR["key_id"])), ("TAG", c_string(VECTOR["tag"])), ("NETWORK", VECTOR["network"]), + ("MANIFEST", c_string(VECTOR["manifest"])), ("SIGNATURE", c_string(VECTOR["signature"])), + ("LINUX_CONTENTS", c_string(LINUX["contents"])), ("LINUX_SIZE", LINUX["size"]), + ("LINUX_SHA256", c_string(LINUX["sha256"]))]) + + +def defines(source, *names): + """the #define lines of these names, as the source has them""" + lines = [] + for name in names: + found = re.search(rf"^#define {name} .*$", source, re.M) + if not found: + raise LookupError(f"#define {name} not found") + lines.append(found.group(0)) + return "\n".join(lines) + "\n" + + +def state(source, *names): + """the file's static variables of these names, as it declares them""" + lines = [] + for name in names: + found = re.search(rf"^static [\w ]+\b{name}(\[\w+\])?;$", source, re.M) + if not found: + raise LookupError(f"updater.c's {name} not found") + lines.append(found.group(0)) + return "\n".join(lines) + "\n" + + +def generated(fault=None, faulty_function=None, release_key=True): + updater = read("port/linux/src/updater.c") + pinned = re.search(r"^#ifdef HALOCUSTOMS_RELEASE_KEY\n#define UPDATER_RELEASE_KEY .*?^#endif$", updater, re.M | re.S) + if not pinned: + raise LookupError("updater.c's UPDATER_RELEASE_KEY (from HALOCUSTOMS_RELEASE_KEY) not found") + config = ('#define UPDATE_PLATFORM "linux"\n#define PATH_SEPARATOR "/"\n#define HALO_BUILD_FLAVOR "release"\n' + "#define HALO_BUILD_NUMBER 15\n" + + (f'#define HALOCUSTOMS_RELEASE_KEY "{VECTOR["public_key"]}"\n' if release_key else "") + + defines(updater, "UPDATE_ASSET", "MAXIMUM_CHECK_SIZE", "MAXIMUM_UPDATE_SIZE") + + pinned.group(0) + "\n" + enum_with(updater, "UPDATER_MANIFEST_MAXIMUM = ") + "\n" + + enum_with(updater, "_updater_available,") + "\n" + structure(updater, "updater_manifest") + "\n" + + state(updater, "updater_state", "updater_latest_build", "updater_latest_size", "updater_release_size", + "updater_latest_digest", "updater_release_digest", "updater_latest_network_version", + "updater_release_network_version", "updater_directory")) + text = "" + for name in FUNCTIONS: + code = function(updater, name) + if fault and name == faulty_function: + code = mutated(code, *fault) + text += code + "\n" + return crypto_includes() + (("p2p_hex.inc", function(read("port/linux/src/p2p.c"), "p2p_hex") + "\n"), + ("vector.inc", vector_include()), ("config.inc", config), ("under_test.inc", text)) + + +# ---------- the manifests and signatures both updaters judge + +def manifest(*lines): + return "".join(line + "\n" for line in lines) + + +HEAD = ("halocustoms-release 1", "tag build-16", "network 41") +LINUX_LINE = f"asset halo-linux-release.zip {LINUX['size']} {LINUX['sha256']}" +ANDROID_LINE = f"asset halo-android-release.zip {ANDROID['size']} {ANDROID['sha256']}" +TAKEN = ("ok", 16, 41, 1, LINUX["size"], LINUX["sha256"]) +WITHOUT = ("ok", 16, 41, 0, 0, "-") +SHA = LINUX["sha256"] + + +def assets(count): + return tuple(f"asset a{index:02d}.zip 1 {SHA}" for index in range(count)) + + +MANIFESTS = { + "the vector": (VECTOR["manifest"], TAKEN), + "one asset, this build's": (manifest("halocustoms-release 1", "tag build-1", "network 1", f"asset " + f"halo-linux-release.zip 1 {SHA}"), ("ok", 1, 1, 1, 1, SHA)), + "without this build's file": (manifest(*HEAD, ANDROID_LINE), WITHOUT), + "a later platform's file beside": (manifest(*HEAD, LINUX_LINE, f"asset halo-macos-release.zip 9 {SHA}"), TAKEN), + "a name sorted before by its prefix": (manifest(*HEAD, f"asset halo-linux-debug.zip 9 {SHA}", LINUX_LINE), TAKEN), + "16 assets": (manifest(*HEAD, *assets(15), LINUX_LINE), TAKEN), + "the largest size": (manifest(*HEAD, f"asset halo-linux-release.zip 268435456 {SHA}"), + ("ok", 16, 41, 1, 268435456, SHA)), + "the largest network version": (manifest(HEAD[0], HEAD[1], "network 65535", LINUX_LINE), + ("ok", 16, 65535, 1, LINUX["size"], SHA)), + "the largest build": (manifest(HEAD[0], "tag build-999999999", HEAD[2], LINUX_LINE), + ("ok", 999999999, 41, 1, LINUX["size"], SHA)), + # (refused) + "empty": ("", None), + "a line feed alone": ("\n", None), + "no asset": (manifest(*HEAD), None), + "CRLF lines": (manifest(*HEAD, LINUX_LINE).replace("\n", "\r\n"), None), + "a carriage return in a line": (manifest(*HEAD, LINUX_LINE.replace(" ", "\r", 1)), None), + "no final line feed": (manifest(*HEAD, LINUX_LINE)[:-1], None), + "an empty line at the end": (manifest(*HEAD, LINUX_LINE) + "\n", None), + "an empty line between": (manifest(*HEAD, "", LINUX_LINE), None), + "a space at a line's end": (manifest(*HEAD, LINUX_LINE + " "), None), + "a space at a line's start": (manifest(*HEAD, " " + LINUX_LINE), None), + "two spaces together": (manifest(*HEAD, LINUX_LINE.replace(" ", " ", 1)), None), + "a tab for a space": (manifest(*HEAD, LINUX_LINE.replace(" ", "\t", 1)), None), + "assets out of order": (manifest(*HEAD, LINUX_LINE, ANDROID_LINE), None), + "an asset twice": (manifest(*HEAD, LINUX_LINE, LINUX_LINE), None), + "this build's file twice, other sizes": (manifest(*HEAD, LINUX_LINE, f"asset halo-linux-release.zip 9 {SHA}"), + None), + "a name in capitals": (manifest(*HEAD, LINUX_LINE.replace("halo-linux", "Halo-linux")), None), + "a name with a path": (manifest(*HEAD, LINUX_LINE.replace("halo-linux", "../halo-linux")), None), + "a name not of a zip": (manifest(*HEAD, LINUX_LINE.replace(".zip", ".exe")), None), + "a name of 65 bytes": (manifest(*HEAD, f"asset {'a' * 61}.zip 1 {SHA}"), None), + "size 0": (manifest(*HEAD, f"asset halo-linux-release.zip 0 {SHA}"), None), + "a size with a leading zero": (manifest(*HEAD, f"asset halo-linux-release.zip 051 {SHA}"), None), + "a size with a sign": (manifest(*HEAD, f"asset halo-linux-release.zip +51 {SHA}"), None), + "a size past the largest download": (manifest(*HEAD, f"asset halo-linux-release.zip 268435457 {SHA}"), None), + "a size of 20 digits": (manifest(*HEAD, f"asset halo-linux-release.zip {'9' * 20} {SHA}"), None), + "a size that wraps around": (manifest(*HEAD, f"asset halo-linux-release.zip 18446744073709551667 {SHA}"), None), + "a size that is not a number": (manifest(*HEAD, f"asset halo-linux-release.zip 5l {SHA}"), None), + "a SHA-256 in capitals": (manifest(*HEAD, f"asset halo-linux-release.zip 51 {SHA.upper()}"), None), + "a SHA-256 of 63 digits": (manifest(*HEAD, f"asset halo-linux-release.zip 51 {SHA[:-1]}"), None), + "a SHA-256 of 65 digits": (manifest(*HEAD, f"asset halo-linux-release.zip 51 {SHA}0"), None), + "a SHA-256 that is not hexadecimal": (manifest(*HEAD, f"asset halo-linux-release.zip 51 {'g' + SHA[1:]}"), None), + "a fifth field": (manifest(*HEAD, LINUX_LINE + " more"), None), + "a line of 201 bytes": (manifest(*HEAD, LINUX_LINE, "a" * 201), None), + "a long line": (manifest(*HEAD, LINUX_LINE + " " + "a" * 5000), None), + "17 assets": (manifest(*HEAD, *assets(16), LINUX_LINE), None), + "past 4096 bytes": (manifest(*HEAD, *(f"asset a{index:03d}.zip 1 {SHA}" for index in range(60))), None), + "contract 2": (manifest("halocustoms-release 2", *HEAD[1:], LINUX_LINE), None), + "a header in capitals": (manifest("HaloCustoms-release 1", *HEAD[1:], LINUX_LINE), None), + "build-0": (manifest(HEAD[0], "tag build-0", HEAD[2], LINUX_LINE), None), + "build-016": (manifest(HEAD[0], "tag build-016", HEAD[2], LINUX_LINE), None), + "a tag v16": (manifest(HEAD[0], "tag v16", HEAD[2], LINUX_LINE), None), + "a build past the largest": (manifest(HEAD[0], "tag build-1000000000", HEAD[2], LINUX_LINE), None), + "network 0": (manifest(*HEAD[:2], "network 0", LINUX_LINE), None), + "network 65536": (manifest(*HEAD[:2], "network 65536", LINUX_LINE), None), + "the network before the tag": (manifest(HEAD[0], HEAD[2], HEAD[1], LINUX_LINE), None), + "the tag twice": (manifest(*HEAD[:2], HEAD[1], HEAD[2], LINUX_LINE), None), + "an asset before the network": (manifest(*HEAD[:2], LINUX_LINE, HEAD[2]), None), + "a NUL byte": (manifest(*HEAD, LINUX_LINE).replace("network 41", "network 4\0" + "1"), None), + "a UTF-8 letter": (manifest(*HEAD, LINUX_LINE.replace("halo-linux", "halo-lïnux")), None), +} + +RFC8032 = [ # section 7.1: TEST 1, 2 and 3 (key, message, signature) + ("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", "", + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"), + ("3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", "72", + "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"), + ("fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", "af82", + "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a"), +] +ORDER = 2 ** 252 + 27742317777372353535851937790883648493 +# the base point's encoding, and the identity's: a key of small order +BASE = "5866666666666666666666666666666666666666666666666666666666666666" +IDENTITY = "01" + "00" * 31 + + +def flipped(text, at): + """hexadecimal text with the bit at at (counted from the first byte's lowest) flipped""" + data = bytearray.fromhex(text) + data[at // 8] ^= 1 << (at % 8) + return data.hex() + + +def unreduced(signature): + """a signature with S + the group's order for its S""" + s = int.from_bytes(bytes.fromhex(signature[64:]), "little") + ORDER + return signature[:64] + s.to_bytes(32, "little").hex() + + +SIGNATURES = { + **{f"RFC 8032 test {index + 1}": (key, message, signature, True) + for index, (key, message, signature) in enumerate(RFC8032)}, + "the release vector": (VECTOR["public_key"][8:], VECTOR["manifest"].encode().hex(), VECTOR["signature"][8:], True), + "a bit of R changed": (RFC8032[0][0], RFC8032[0][1], flipped(RFC8032[0][2], 3), False), + "a bit of S changed": (RFC8032[0][0], RFC8032[0][1], flipped(RFC8032[0][2], 260), False), + "the message changed": (RFC8032[1][0], "73", RFC8032[1][2], False), + "the vector's manifest changed": (VECTOR["public_key"][8:], flipped(VECTOR["manifest"].encode().hex(), 9), + VECTOR["signature"][8:], False), + "S past the group's order": (RFC8032[0][0], RFC8032[0][1], unreduced(RFC8032[0][2]), False), + "another key": (RFC8032[1][0], RFC8032[0][1], RFC8032[0][2], False), + "a key that is not a point": ("02" + "00" * 31, RFC8032[0][1], RFC8032[0][2], False), + # (the identity's key, which [S]B = R + [k]A signs for with R = B, S = 1, whatever the message) + "a key of small order": (IDENTITY, "6d657373616765", BASE + (1).to_bytes(32, "little").hex(), False), +} + + +def batch_lines(): + lines = [f"manifest {text.encode('utf-8').hex() or '-'}" for text, _ in MANIFESTS.values()] + lines += [f"ed25519 {key} {message or '-'} {signature}" for key, message, signature, _ in SIGNATURES.values()] + return "\n".join(lines) + "\n" + + +def expectations(): + results = [" ".join(str(part) for part in expected) if expected else "refused" for _, expected in MANIFESTS.values()] + results += ["ok" if good else "refused" for *_, good in SIGNATURES.values()] + return results + + +def judged(results): + """the cases whose result is not the one expected""" + names = list(MANIFESTS) + list(SIGNATURES) + return [f"{name}: {result!r}, not {wanted!r}" for name, result, wanted in zip(names, results, expectations()) + if result != wanted] + + +def run_batch(executable, folder): + path = folder / "batch.txt" + path.write_text(batch_lines()) + result = subprocess.run([str(executable), "batch", str(path)], capture_output=True, text=True, timeout=120) + assert result.returncode == 0, result.stderr + return result.stdout.splitlines() + + +# ---------- the tests + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("updater_feed", generated()), case) + assert status == 0, output + + +def test_a_build_without_a_key_takes_no_release(): + status, output = run(build("updater_feed", generated(release_key=False)), "no-key") + assert status == 0, output + + +@pytest.mark.parametrize("control", NEGATIVE_CONTROLS) +def test_negative_control(control): + fault, faulty_function, case = NEGATIVE_CONTROLS[control] + status, output = run(build("updater_feed", generated(fault, faulty_function)), case) + assert status == CHECK_FAILED, f"the code with a fault ({control}) passed '{case}': the test cannot see it" + + +def test_the_manifests_and_signatures(tmp_path): + """the desktop updater's judgement of each manifest and signature""" + results = run_batch(build("updater_feed", generated()), tmp_path) + assert len(results) == len(expectations()) + assert not judged(results) + + +@pytest.mark.parametrize("control", BATCH_CONTROLS) +def test_batch_sees_a_fault(control, tmp_path): + fault, faulty_function = BATCH_CONTROLS[control] + results = run_batch(build("updater_feed", generated(fault, faulty_function)), tmp_path) + assert judged(results), f"the code with a fault ({control}) judged every manifest as it should" + + +def java_tools(): + javac, java = shutil.which("javac"), shutil.which("java") + if not javac or not java: + pytest.skip("no JDK here (javac, java)") + return javac, java + + +def test_the_android_updater_judges_them_alike(tmp_path): + """ReleaseManifest.java and Ed25519.java, the Android updater's, on the same manifests and signatures (for + this build's file, halo-linux-release.zip): the same judgement""" + javac, java = java_tools() + classes = tmp_path / "classes" + subprocess.run([javac, "--release", "17", "-d", str(classes), *map(str, JAVA_SOURCES)], check=True) + path = tmp_path / "batch.txt" + path.write_text(batch_lines()) + result = subprocess.run([java, "-cp", str(classes), "com.halo.decomp.ReleaseManifestBatch", str(path), + "halo-linux-release.zip"], capture_output=True, text=True, timeout=300) + assert result.returncode == 0, result.stderr + results = result.stdout.splitlines() + assert len(results) == len(expectations()) + assert not judged(results) + + +def test_the_android_updater_checks_a_release(tmp_path): + """ReleaseManifest.java's whole check of a feed's release (as Updater.java gives it the feed's strings): the + vector's taken, and each of the ways one fails refused""" + javac, java = java_tools() + classes = tmp_path / "classes" + subprocess.run([javac, "--release", "17", "-d", str(classes), *map(str, JAVA_SOURCES)], check=True) + vector = [VECTOR["tag"], VECTOR["manifest"], VECTOR["signature"], VECTOR["key_id"]] + key = VECTOR["public_key"] + cases = { + "the vector": (vector, key, "halo-android-release.zip", f"taken 16 {ANDROID['size']} {ANDROID['sha256']}"), + "no key pinned": (vector, "", "halo-android-release.zip", "refused"), + "another key id": (vector[:3] + ["8f85843f7e24248d"], key, "halo-android-release.zip", "refused"), + "the signature changed": (vector[:2] + ["ed25519:" + flipped(VECTOR["signature"][8:], 5)] + vector[3:], key, + "halo-android-release.zip", "refused"), + "the manifest changed": ([vector[0], VECTOR["manifest"].replace(" 53 ", " 54 ")] + vector[2:], key, + "halo-android-release.zip", "refused"), + "another tag": (["build-17"] + vector[1:], key, "halo-android-release.zip", "refused"), + "no file for this build": (vector, key, "halo-android-debug.zip", "refused"), + } + lines = "".join(f"release {' '.join(field.encode().hex() or '-' for field in fields + [key, asset])}\n" + for fields, key, asset, _ in cases.values()) + path = tmp_path / "releases.txt" + path.write_text(lines) + result = subprocess.run([java, "-cp", str(classes), "com.halo.decomp.ReleaseManifestBatch", str(path), "-"], + capture_output=True, text=True, timeout=300) + assert result.returncode == 0, result.stderr + got = [line.split(" ", 1)[0] if line.startswith("refused") else line for line in result.stdout.splitlines()] + assert got == [wanted for *_, wanted in cases.values()], result.stdout + + +def openssl_signs(): + if not shutil.which("openssl"): + return False + result = subprocess.run(["openssl", "list", "-public-key-algorithms"], capture_output=True, text=True) + return result.returncode == 0 and "ED25519" in result.stdout.upper() + + +def test_the_signing_tools_release_is_taken(tmp_path): + """a release signed with tools/halocustoms_release_sign.py: its feed's release taken by the game's code with the + tool's public key, and refused with another or once its manifest is changed""" + if not openssl_signs(): + pytest.skip("no OpenSSL with Ed25519 here") + seed = tmp_path / "release.seed" + made = subprocess.run([sys.executable, str(TOOL), "new-key", str(seed)], capture_output=True, text=True, + check=True) + public = re.search(r"ed25519:[0-9a-f]{64}", made.stdout).group(0) + zips = [] + for name in ("halo-linux-release.zip", "halo-windows-release.zip"): + path = tmp_path / name + path.write_bytes(f"a release's {name}\n".encode() * 100) + zips.append(str(path)) + signed = subprocess.run([sys.executable, str(TOOL), "sign", str(seed), "23", "41", *zips], capture_output=True, + text=True, check=True) + release = json.loads(signed.stdout) + executable = build("updater_feed", generated()) + + def judged_feed(fields, key): + feed = tmp_path / "feed.json" + feed.write_text(json.dumps({"tag_name": "build-23", "assets": [], **fields})) + result = subprocess.run([str(executable), "feed-file", str(feed), key[8:]], capture_output=True, text=True, + timeout=60) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + linux = Path(zips[0]).read_bytes() + assert judged_feed(release, public) == f"taken 23 {len(linux)} {hashlib.sha256(linux).hexdigest()}" + assert judged_feed(release, VECTOR["public_key"]).startswith("refused") + changed = dict(release, halocustoms_manifest=release["halocustoms_manifest"].replace("network 41", "network 42")) + assert judged_feed(changed, public).startswith("refused") diff --git a/tools/harness/tests/updater_feed.c b/tools/harness/tests/updater_feed.c new file mode 100644 index 0000000..d72b556 --- /dev/null +++ b/tools/harness/tests/updater_feed.c @@ -0,0 +1,573 @@ +/* +The desktop self-updater's check of a release (port/linux/src/updater.c): +its signed manifest (contract 1, FORK.md "Releases") read and checked with +the build's pinned release key, the release taken only then, and the zip +it downloads checked against the manifest: see test_updater_feed.py. +*/ + +#include "harness.h" + +#include +#include +#include +#include +#include +#include +#include + +/* ---------- the crypto and JSON, as whole files */ + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Weverything" +/* (compiled quickly: each negative control builds it again) */ +#pragma clang optimize off +#include "monocypher.inc" +/* (both files have a hash_reduce of their own) */ +#define hash_reduce ed25519_hash_reduce +#include "monocypher_ed25519.inc" +#undef hash_reduce +#pragma clang optimize on +#pragma clang diagnostic pop +#undef FOR +#undef COPY +#undef ZERO +#undef WIPE_CTX +#undef WIPE_BUFFER +#undef MIN + +#include "p2p_crypto.inc" +#include "server_json.inc" +#include "p2p_hex.inc" + +void posix_random_bytes(void *buffer, posix_ulong size) +{ + memset(buffer, 0x5A, size); +} + +#include "halocustoms_services.h" +/* (the release vector: release_vectors.json) */ +#include "vector.inc" + +/* ---------- the fake world */ + +/* the feed's answer (none: it does not answer), and where it was asked */ +static const char *feed_text; +static char feed_url[512]; +/* the last line the updater logged */ +static char logged[512]; + +void platform_log(const char *format, ...) +{ + va_list arguments; + + va_start(arguments, format); + vsnprintf(logged, sizeof(logged), format, arguments); + va_end(arguments); + fprintf(stderr, "log: %s\n", logged); +} + +/* update.h's download: the feed's answer, written to the file at path */ +int update_download(const char *url, const char *path, unsigned long long maximum, void *progress, void *context, + char *error, int error_size) +{ + FILE *file; + + (void)progress; + (void)context; + snprintf(feed_url, sizeof(feed_url), "%s", url); + if (!feed_text || strlen(feed_text) > maximum) + { + snprintf(error, (size_t)error_size, "no answer"); + return 0; + } + file = fopen(path, "wb"); + if (!file) + { + snprintf(error, (size_t)error_size, "cannot write %s", path); + return 0; + } + fwrite(feed_text, 1, strlen(feed_text), file); + fclose(file); + return 1; +} + +void update_delete_file(const char *path) +{ + remove(path); +} + +/* SDL's, as SDL 3 has them: a file's bytes, NUL terminated, which SDL_free +frees; an atomic int */ +static void *SDL_LoadFile(const char *path, size_t *size) +{ + FILE *file = fopen(path, "rb"); + char *bytes = NULL; + long length; + + if (!file) + return NULL; + if (!fseek(file, 0, SEEK_END) && (length = ftell(file)) >= 0 && !fseek(file, 0, SEEK_SET)) + { + bytes = malloc((size_t)length + 1); + if (bytes && fread(bytes, 1, (size_t)length, file) != (size_t)length) + { + free(bytes); + bytes = NULL; + } + if (bytes) + { + bytes[length] = 0; + *size = (size_t)length; + } + } + fclose(file); + return bytes; +} +#define SDL_free free +typedef struct { int value; } SDL_AtomicInt; +#define SDL_SetAtomicInt(atomic, to) ((atomic)->value = (to)) +#define SDL_GetAtomicInt(atomic) ((atomic)->value) +#define SDLCALL + +#include "config.inc" +#include "under_test.inc" + +/* ---------- the test's own */ + +static char zip_path[1200]; + +static void clean_up(void) +{ + if (zip_path[0]) + remove(zip_path); + if (updater_directory[0]) + rmdir(updater_directory); +} + +/* the updater's folder: one of the test's own */ +static void make_directory(void) +{ + const char *base = getenv("TMPDIR"); + + snprintf(updater_directory, sizeof(updater_directory), "%s/updater-feed-XXXXXX", base && base[0] ? base : "/tmp"); + if (!mkdtemp(updater_directory)) + { + fprintf(stderr, "cannot make %s\n", updater_directory); + exit(2); + } + atexit(clean_up); +} + +/* the bytes of hexadecimal text (2 * size digits), into bytes */ +static int bytes_of(const char *text, unsigned char *bytes, int size) +{ + return updater_hex_bytes(text, strlen(text), bytes, size, 1); +} + +/* a text, its quotes, backslashes and control bytes escaped, as a JSON +string's (into json, which holds 6 * its length + 3) */ +static void json_string(const char *text, size_t length, char *json) +{ + size_t index; + char *out = json; + + *out++ = '"'; + for (index = 0; index < length; index++) + { + unsigned char character = (unsigned char)text[index]; + + if (character == '"' || character == '\\') + { + *out++ = '\\'; + *out++ = (char)character; + } + else if (character == '\n') + { + *out++ = '\\'; + *out++ = 'n'; + } + else if (character < 0x20 || character >= 0x7F) + { + out += sprintf(out, "\\u%04x", character); + } + else + { + *out++ = (char)character; + } + } + *out++ = '"'; + *out = 0; +} + +/* the feed's latest release as the site gives it: its tag, notes and assets +(whose digest and notes are not the manifest's, and not believed), and +the manifest, its signature and its key's id; each left out when NULL */ +static const char *feed_of(const char *tag, const char *manifest, size_t manifest_length, const char *signature, + const char *key_id) +{ + static char feed[64 * 1024]; + static char escaped[6 * 8192 + 3]; + int length; + + length = snprintf(feed, sizeof(feed), "{\"tag_name\":\"%s\",\"name\":\"A build\",\"body\":\"Network version: 99\"," + "\"assets\":[{\"name\":\"halo-linux-release.zip\",\"size\":7,\"digest\":\"sha256:%064d\"," + "\"browser_download_url\":\"https://elsewhere.example/halo-linux-release.zip\"}]", tag ? tag : "", 0); + if (manifest) + { + json_string(manifest, manifest_length, escaped); + length += snprintf(feed + length, sizeof(feed) - (size_t)length, ",\"halocustoms_manifest\":%s", escaped); + } + if (signature) + length += snprintf(feed + length, sizeof(feed) - (size_t)length, ",\"halocustoms_signature\":\"%s\"", signature); + if (key_id) + length += snprintf(feed + length, sizeof(feed) - (size_t)length, ",\"halocustoms_key_id\":\"%s\"", key_id); + snprintf(feed + length, sizeof(feed) - (size_t)length, "}"); + return feed; +} + +/* a manifest signed with a seed (the vector's, unless another), as +"ed25519:" */ +static const char *signature_of(const char *manifest, const unsigned char *seed) +{ + static char text[8 + 128 + 1]; + unsigned char vector_seed[32], public_key[32], signature[64]; + + if (!seed) + { + bytes_of(VECTOR_SEED, vector_seed, 32); + seed = vector_seed; + } + p2p_ed25519_public(seed, public_key, NULL); + p2p_ed25519_sign(seed, public_key, manifest, (int)strlen(manifest), signature); + memcpy(text, "ed25519:", 8); + p2p_hex(signature, 64, text + 8); + return text; +} + +/* whether the release of a feed is taken with the key (none: NULL) */ +static int taken(const char *feed, const unsigned char *key, struct updater_manifest *manifest) +{ + char why[256] = ""; + int ok = updater_release_checks(feed, strlen(feed), key, manifest, why, sizeof(why)); + + fprintf(stderr, "%s%s\n", ok ? "taken" : "refused: ", ok ? "" : why); + return ok; +} + +static int write_file(const char *path, const char *text, size_t size) +{ + FILE *file = fopen(path, "wb"); + + if (!file) + return 0; + fwrite(text, 1, size, file); + fclose(file); + return 1; +} + +/* the batch's lines (test_updater_feed.py), each " ...": a +manifest read ("manifest ": "ok " or "refused"), and an Ed25519 signature +checked ("ed25519 ": "ok" or "refused"), "-" +for an empty field */ +static int batch(const char *path) +{ + static char line[64 * 1024]; + FILE *file = fopen(path, "r"); + + if (!file) + return 2; + while (fgets(line, sizeof(line), file)) + { + static unsigned char fields[4][16 * 1024]; + size_t sizes[4] = { 0, 0, 0, 0 }; + char *kind = strtok(line, " \n"), *field; + int count = 0; + + if (!kind) + continue; + while (count < 4 && (field = strtok(NULL, " \n")) != NULL) + { + if (strcmp(field, "-")) + { + sizes[count] = strlen(field) / 2; + if (sizes[count] > sizeof(fields[count]) || + !updater_hex_bytes(field, strlen(field), fields[count], (int)sizes[count], 1)) + { + return 2; + } + } + count++; + } + if (!strcmp(kind, "manifest") && count == 1) + { + struct updater_manifest manifest; + char why[256], sha256[65]; + + if (updater_manifest_read((const char *)fields[0], sizes[0], &manifest, why, sizeof(why))) + { + p2p_hex(manifest.asset_sha256, 32, sha256); + printf("ok %ld %ld %d %llu %s\n", manifest.build, manifest.network_version, manifest.has_asset, + manifest.asset_size, manifest.has_asset ? sha256 : "-"); + } + else + { + printf("refused\n"); + fprintf(stderr, "refused: %s\n", why); + } + } + else if (!strcmp(kind, "ed25519") && count == 3) + { + printf("%s\n", sizes[0] == 32 && sizes[2] == 64 && + p2p_ed25519_verify(fields[0], fields[1], (int)sizes[1], fields[2]) ? "ok" : "refused"); + } + else + { + return 2; + } + } + fclose(file); + return 0; +} + +int main(int argc, char **argv) +{ + const char *case_name = argc > 1 ? argv[1] : ""; + unsigned char key[32], expected[32]; + struct updater_manifest manifest; + + make_directory(); + bytes_of(VECTOR_PUBLIC_KEY, key, 32); + bytes_of(VECTOR_LINUX_SHA256, expected, 32); + + /* the release vector: its feed's release taken with its key, as its + manifest says it; and the game's own signature of its manifest is the + vector's (Ed25519 signs a message one way) */ + CASE("vector") + { + unsigned char pinned[32]; + const char *feed = feed_of(VECTOR_TAG, VECTOR_MANIFEST, strlen(VECTOR_MANIFEST), VECTOR_SIGNATURE, + VECTOR_KEY_ID); + + CHECK(updater_release_key(pinned) && !memcmp(pinned, key, 32), "the pinned key is not the vector's\n"); + CHECK(!strcmp(signature_of(VECTOR_MANIFEST, NULL), VECTOR_SIGNATURE), "the game signs the manifest %s\n", + signature_of(VECTOR_MANIFEST, NULL)); + CHECK(taken(feed, key, &manifest), "the vector's release was refused\n"); + CHECK(manifest.build == 16 && manifest.network_version == VECTOR_NETWORK && manifest.has_asset && + manifest.asset_size == VECTOR_LINUX_SIZE && !memcmp(manifest.asset_sha256, expected, 32), + "the manifest read is build %ld, network %ld, size %llu\n", manifest.build, manifest.network_version, + manifest.asset_size); + return 0; + } + /* every way a release fails to check, each refused */ + CASE("fail-closed") + { + static char manifest_text[8192]; + static char signature[160]; + unsigned char other_seed[32]; + int index; + + memset(other_seed, 0x42, sizeof(other_seed)); + /* (the vector as it is, first) */ + CHECK(taken(feed_of(VECTOR_TAG, VECTOR_MANIFEST, strlen(VECTOR_MANIFEST), VECTOR_SIGNATURE, VECTOR_KEY_ID), + key, &manifest), "the vector's release was refused\n"); + /* (no key pinned) */ + CHECK(!taken(feed_of(VECTOR_TAG, VECTOR_MANIFEST, strlen(VECTOR_MANIFEST), VECTOR_SIGNATURE, VECTOR_KEY_ID), + NULL, &manifest), "a release was taken with no key pinned\n"); + /* (another key's id, a short one, none) */ + CHECK(!taken(feed_of(VECTOR_TAG, VECTOR_MANIFEST, strlen(VECTOR_MANIFEST), VECTOR_SIGNATURE, + "8f85843f7e24248d"), key, &manifest), "a release signed by another key's id was taken\n"); + CHECK(!taken(feed_of(VECTOR_TAG, VECTOR_MANIFEST, strlen(VECTOR_MANIFEST), VECTOR_SIGNATURE, + "21fe31dfa154a26"), key, &manifest), "a key id of 15 digits was taken\n"); + CHECK(!taken(feed_of(VECTOR_TAG, VECTOR_MANIFEST, strlen(VECTOR_MANIFEST), VECTOR_SIGNATURE, NULL), key, + &manifest), "a release without a key id was taken\n"); + /* (the signature changed, malformed, none) */ + snprintf(signature, sizeof(signature), "%s", VECTOR_SIGNATURE); + signature[20] = signature[20] == '0' ? '1' : '0'; + CHECK(!taken(feed_of(VECTOR_TAG, VECTOR_MANIFEST, strlen(VECTOR_MANIFEST), signature, VECTOR_KEY_ID), key, + &manifest), "a release with a signature changed was taken\n"); + CHECK(!taken(feed_of(VECTOR_TAG, VECTOR_MANIFEST, strlen(VECTOR_MANIFEST), &VECTOR_SIGNATURE[8], + VECTOR_KEY_ID), key, &manifest), "a signature without ed25519: was taken\n"); + CHECK(!taken(feed_of(VECTOR_TAG, VECTOR_MANIFEST, strlen(VECTOR_MANIFEST), "ed25519:abcd", VECTOR_KEY_ID), + key, &manifest), "a short signature was taken\n"); + CHECK(!taken(feed_of(VECTOR_TAG, VECTOR_MANIFEST, strlen(VECTOR_MANIFEST), NULL, VECTOR_KEY_ID), key, + &manifest), "a release without a signature was taken\n"); + /* (the manifest changed by a bit, under the vector's signature: each + byte in turn) */ + for (index = 0; index < (int)strlen(VECTOR_MANIFEST); index++) + { + snprintf(manifest_text, sizeof(manifest_text), "%s", VECTOR_MANIFEST); + manifest_text[index] ^= 0x01; + CHECK(!taken(feed_of(VECTOR_TAG, manifest_text, strlen(manifest_text), VECTOR_SIGNATURE, VECTOR_KEY_ID), + key, &manifest), "the manifest with byte %d changed was taken\n", index); + } + /* (signed by another key, under the vector's key id) */ + snprintf(signature, sizeof(signature), "%s", signature_of(VECTOR_MANIFEST, other_seed)); + CHECK(!taken(feed_of(VECTOR_TAG, VECTOR_MANIFEST, strlen(VECTOR_MANIFEST), signature, VECTOR_KEY_ID), key, + &manifest), "a manifest signed by another key was taken\n"); + /* (no manifest) */ + CHECK(!taken(feed_of(VECTOR_TAG, NULL, 0, VECTOR_SIGNATURE, VECTOR_KEY_ID), key, &manifest), + "a release without a manifest was taken\n"); + /* (the feed's tag another build's, another form of it, or none) */ + CHECK(!taken(feed_of("build-17", VECTOR_MANIFEST, strlen(VECTOR_MANIFEST), VECTOR_SIGNATURE, VECTOR_KEY_ID), + key, &manifest), "a release whose tag is not its manifest's was taken\n"); + CHECK(!taken(feed_of("build-016", VECTOR_MANIFEST, strlen(VECTOR_MANIFEST), VECTOR_SIGNATURE, VECTOR_KEY_ID), + key, &manifest), "a release tagged build-016 was taken\n"); + CHECK(!taken(feed_of(NULL, VECTOR_MANIFEST, strlen(VECTOR_MANIFEST), VECTOR_SIGNATURE, VECTOR_KEY_ID), key, + &manifest), "a release with an empty tag was taken\n"); + /* (a signed manifest without this build's file) */ + snprintf(manifest_text, sizeof(manifest_text), "halocustoms-release 1\ntag build-16\nnetwork 41\n" + "asset halo-windows-release.zip 51 %s\n", VECTOR_LINUX_SHA256); + CHECK(!taken(feed_of(VECTOR_TAG, manifest_text, strlen(manifest_text), signature_of(manifest_text, NULL), + VECTOR_KEY_ID), key, &manifest), "a release without this build's file was taken\n"); + /* (a signed manifest of CRLF lines, and one past the limit) */ + snprintf(manifest_text, sizeof(manifest_text), "halocustoms-release 1\r\ntag build-16\r\nnetwork 41\r\n" + "asset halo-linux-release.zip 51 %s\r\n", VECTOR_LINUX_SHA256); + CHECK(!taken(feed_of(VECTOR_TAG, manifest_text, strlen(manifest_text), signature_of(manifest_text, NULL), + VECTOR_KEY_ID), key, &manifest), "a manifest of CRLF lines was taken\n"); + index = snprintf(manifest_text, sizeof(manifest_text), "halocustoms-release 1\ntag build-16\nnetwork 41\n" + "asset halo-linux-release.zip 51 %s\n", VECTOR_LINUX_SHA256); + while (index < UPDATER_MANIFEST_MAXIMUM + 10) + { + index += snprintf(manifest_text + index, sizeof(manifest_text) - (size_t)index, + "asset z%04d.zip 1 %064d\n", index, 0); + } + CHECK(!taken(feed_of(VECTOR_TAG, manifest_text, strlen(manifest_text), signature_of(manifest_text, NULL), + VECTOR_KEY_ID), key, &manifest), "a manifest past %d bytes was taken\n", UPDATER_MANIFEST_MAXIMUM); + /* (not JSON, not an object) */ + CHECK(!taken("halocustoms-release 1\n", key, &manifest), "a feed that is not JSON was taken\n"); + CHECK(!taken("[1, 2]", key, &manifest), "a feed that is a list was taken\n"); + /* (and a signed manifest that lists a file a later release adds, beside + this build's: taken) */ + snprintf(manifest_text, sizeof(manifest_text), "halocustoms-release 1\ntag build-16\nnetwork 41\n" + "asset halo-linux-release.zip 51 %s\nasset halo-macos-release.zip 9 %064d\n", VECTOR_LINUX_SHA256, 0); + CHECK(taken(feed_of(VECTOR_TAG, manifest_text, strlen(manifest_text), signature_of(manifest_text, NULL), + VECTOR_KEY_ID), key, &manifest), "a release that lists a later platform's file was refused\n"); + return 0; + } + /* the release is offered only when it is newer than this build (15) */ + CASE("newer") + { + static char manifest_text[512]; + static const long builds[] = { 14, 15, 16 }; + int index; + + for (index = 0; index < 3; index++) + { + char tag[32]; + + snprintf(tag, sizeof(tag), "build-%ld", builds[index]); + snprintf(manifest_text, sizeof(manifest_text), "halocustoms-release 1\ntag %s\nnetwork 42\n" + "asset halo-linux-release.zip %d %s\n", tag, VECTOR_LINUX_SIZE, VECTOR_LINUX_SHA256); + feed_text = feed_of(tag, manifest_text, strlen(manifest_text), signature_of(manifest_text, NULL), + VECTOR_KEY_ID); + updater_latest_build = 0; + updater_latest_size = 0; + updater_latest_network_version = 0; + memset(updater_latest_digest, 0, sizeof(updater_latest_digest)); + SDL_SetAtomicInt(&updater_state, _updater_checking); + updater_check_thread(NULL); + CHECK(!strcmp(feed_url, HALOCUSTOMS_RELEASES_URL), "the feed was asked at %s\n", feed_url); + if (builds[index] > HALO_BUILD_NUMBER) + { + CHECK(SDL_GetAtomicInt(&updater_state) == _updater_available && updater_latest_build == builds[index] && + updater_latest_size == VECTOR_LINUX_SIZE && updater_latest_network_version == 42 && + !memcmp(updater_latest_digest, expected, 32), "build %ld, newer, was not offered as its manifest " + "says it\n", builds[index]); + } + else + { + CHECK(SDL_GetAtomicInt(&updater_state) == _updater_handled && !updater_latest_build, + "build %ld, not newer than this one, was offered\n", builds[index]); + } + } + /* (no answer: nothing) */ + feed_text = NULL; + SDL_SetAtomicInt(&updater_state, _updater_checking); + updater_check_thread(NULL); + CHECK(SDL_GetAtomicInt(&updater_state) == _updater_handled, "no answer was a release\n"); + return 0; + } + /* a build that pins no key takes no release, the vector's neither */ + CASE("no-key") + { + unsigned char pinned[32]; + + CHECK(!updater_release_key(pinned), "a build without HALOCUSTOMS_RELEASE_KEY has a key\n"); + feed_text = feed_of(VECTOR_TAG, VECTOR_MANIFEST, strlen(VECTOR_MANIFEST), VECTOR_SIGNATURE, VECTOR_KEY_ID); + CHECK(updater_latest_release() == 0, "a build without a key took a release\n"); + CHECK(strstr(logged, "pins no release key") != NULL, "it logged: %s\n", logged); + return 0; + } + /* the zip is downloaded from the site, at the address of the release's + tag (the site's GameReleases::downloadUrl), not from the feed's text */ + CASE("zip-url") + { + char url[600]; + + updater_latest_build = 16; + updater_zip_url(url, sizeof(url)); + CHECK(!strcmp(url, "https://halocustoms.org/updates/releases/download/build-16/halo-linux-release.zip"), + "the zip is downloaded from %s\n", url); + return 0; + } + /* the zip downloaded must have its manifest's size and SHA-256 */ + CASE("zip-checks") + { + char error[256] = ""; + char changed[256]; + + snprintf(zip_path, sizeof(zip_path), "%s/halo-linux-release.zip", updater_directory); + updater_latest_size = VECTOR_LINUX_SIZE; + memcpy(updater_latest_digest, expected, 32); + CHECK(write_file(zip_path, VECTOR_LINUX_CONTENTS, VECTOR_LINUX_SIZE), "cannot write %s\n", zip_path); + CHECK(updater_zip_checks(zip_path, error, sizeof(error)), "the release's zip was refused: %s\n", error); + /* (a byte changed) */ + snprintf(changed, sizeof(changed), "%s", VECTOR_LINUX_CONTENTS); + changed[3] ^= 0x20; + CHECK(write_file(zip_path, changed, VECTOR_LINUX_SIZE), "cannot write %s\n", zip_path); + CHECK(!updater_zip_checks(zip_path, error, sizeof(error)) && strstr(error, "SHA-256"), + "a zip with a byte changed was taken (%s)\n", error); + /* (a byte more, and a byte less) */ + snprintf(changed, sizeof(changed), "%s!", VECTOR_LINUX_CONTENTS); + CHECK(write_file(zip_path, changed, VECTOR_LINUX_SIZE + 1), "cannot write %s\n", zip_path); + CHECK(!updater_zip_checks(zip_path, error, sizeof(error)) && strstr(error, "bytes"), + "a zip with a byte more was taken (%s)\n", error); + CHECK(write_file(zip_path, VECTOR_LINUX_CONTENTS, VECTOR_LINUX_SIZE - 1), "cannot write %s\n", zip_path); + CHECK(!updater_zip_checks(zip_path, error, sizeof(error)), "a zip with a byte less was taken\n"); + /* (none there) */ + remove(zip_path); + CHECK(!updater_zip_checks(zip_path, error, sizeof(error)), "a zip that is not there was taken\n"); + return 0; + } + /* the release in a feed file (made from the signing tool's answer: + test_updater_feed.py), checked with the key given in hex: "taken + " or "refused " */ + CASE("feed-file") + { + size_t length = 0; + char *feed = argc > 3 ? SDL_LoadFile(argv[2], &length) : NULL; + unsigned char given[32]; + char why[256] = "", sha256[65]; + + if (!feed || !bytes_of(argv[3], given, 32)) + return 2; + if (updater_release_checks(feed, length, given, &manifest, why, sizeof(why))) + { + p2p_hex(manifest.asset_sha256, 32, sha256); + printf("taken %ld %llu %s\n", manifest.build, manifest.asset_size, sha256); + } + else + { + printf("refused %s\n", why); + } + free(feed); + return 0; + } + CASE("batch") + { + return argc > 2 ? batch(argv[2]) : 2; + } + fprintf(stderr, "unknown case: %s\n", case_name); + return 2; +} diff --git a/tools/linux_build.py b/tools/linux_build.py index 7bdb784..fd853b3 100644 --- a/tools/linux_build.py +++ b/tools/linux_build.py @@ -145,20 +145,18 @@ def miniupnpc_sources() -> List[Path]: return sorted((MINIUPNPC_DIR / "src").glob("*.c")) +# where a HaloCustoms build's source is, which every build the site serves +# comes with (FORK.md): the site's page of it +HALOCUSTOMS_SOURCE = "halocustoms.org/source" + + def build_repository() -> str: - """the GitHub repository a build is made from, owner/name, which a client - tells the servers it joins (network_distributed.c): CI's - GITHUB_REPOSITORY, else the checkout's origin remote's (its credentials - left out: only the owner and the name are kept), else none""" - repository = os.environ.get("GITHUB_REPOSITORY", "") - if not repository: - try: - url = subprocess.run(["git", "remote", "get-url", "origin"], capture_output=True, text=True, - timeout=10, check=False).stdout.strip() - except (OSError, subprocess.SubprocessError): - url = "" - found = re.search(r"github\.com[:/]+([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?)(?:\.git)?/*$", url) - repository = found.group(1) if found else "" + """where a build's source is, owner/name, which a client tells the servers + it joins (network_distributed.c): CI's GITHUB_REPOSITORY (a build made on + GitHub's CI, from a repository there), else HaloCustoms' source page + (HALOCUSTOMS_SOURCE: the checkout's origin remote is OpenCE's, whose build + this is not)""" + repository = os.environ.get("GITHUB_REPOSITORY", "") or HALOCUSTOMS_SOURCE return repository if re.fullmatch(r"[A-Za-z0-9_.-]{1,39}/[A-Za-z0-9_.-]{1,100}", repository) else "" @@ -174,6 +172,39 @@ def updater_defines(release: bool) -> str: return (f'-DHALO_BUILD_NUMBER={number} -DHALO_BUILD_FLAVOR=\\"{flavor}\\" ' f'-DHALO_BUILD_REPOSITORY=\\"{build_repository()}\\"') + +def pinned_key(variable: str) -> str: + """the environment's key of that name when the build is configured, + "ed25519:" and 64 lowercase hex digits; "" without it. ValueError for a + key not in that form, rather than a build that leaves it out""" + key = os.environ.get(variable, "").strip() + if key and not re.fullmatch(r"ed25519:[0-9a-f]{64}", key): + raise ValueError(f"{variable} is not \"ed25519:\" and 64 lowercase hex digits") + return key + + +def pinned_key_define(variable: str) -> str: + """pinned_key's key as a define of the same name, for the unit that pins it""" + key = pinned_key(variable) + return f'-D{variable}=\\"{key}\\"' if key else "" + + +def master_key_defines() -> str: + """HaloCustoms' master server's list-signing key, built into master_list.c + (port/linux/src/halocustoms_services.h): HALOCUSTOMS_MASTER_KEY, a + release's (FORK.md, "Releases"); a build without it asks OpenCE's master + alone""" + return pinned_key_define("HALOCUSTOMS_MASTER_KEY") + + +def release_key_defines() -> str: + """HaloCustoms' release key, the public half of the offline key that its + releases' manifests are signed with, pinned in updater.c (FORK.md, + "Releases"): HALOCUSTOMS_RELEASE_KEY; a build without it never updates + itself""" + return pinned_key_define("HALOCUSTOMS_RELEASE_KEY") + + def configuration_defines(sln: Any) -> List[str]: """what configure.py's options define for every unit of a native build: --release (no assertions), --profile (the profiling build's recording, @@ -517,7 +548,8 @@ def generate_linux_build(n: Writer, sln: Any) -> None: ) # the high-res HUD's textures (port/assets/hud; port/linux/src/hud_hires.c) - embedded_assets = hud_assets_build(n, "linux", build_dir / "generated" / "hud_hires_assets.c") + embedded_assets = hud_assets_build(n, "linux", build_dir / "generated" / "hud_hires_assets.c", + overlay=getattr(sln, "port_menus_overlay", None)) # a dedicated server's admin page (port/assets/server_ui; port/linux/src/server_http.c) embedded_assets += server_ui_build(n, "linux", build_dir / "generated" / "server_ui_assets.c") @@ -618,8 +650,13 @@ def generate_linux_build(n: Writer, sln: Any) -> None: add_object(source, f"{posix_cflags} -I{MINIUPNPC_DIR / 'include'} -DMINIUPNP_STATICLIB", posix=True) elif source.name.startswith("posix_"): add_object(source, posix_cflags, posix=True) - elif source.name in ("updater.c", "server_mode.c"): + elif source.name == "updater.c": + add_object(source, f"{platform_cflags} {updater_defines(getattr(sln, 'port_release', False))} " + f"{release_key_defines()}") + elif source.name == "server_mode.c": add_object(source, f"{platform_cflags} {updater_defines(getattr(sln, 'port_release', False))}") + elif source.name == "master_list.c": + add_object(source, f"{platform_cflags} {master_key_defines()}") else: add_object(source, platform_cflags) for source in embedded_assets: diff --git a/tools/menu_check.py b/tools/menu_check.py new file mode 100644 index 0000000..a085c13 --- /dev/null +++ b/tools/menu_check.py @@ -0,0 +1,952 @@ +"""What the game checks of its menus' files when it loads them, for a build +(tools/embed_assets.py, of a set with a menu overlay) and the tests +(tools/test_linux_port.py, of the stock set) to check first. + +The game reads every embedded file whose name ends in .xml with Expat +(port/linux/src/menu_files.c) and builds its widget tags from what they hold +(port/linux/game/menu_tags.c). At the first problem it logs the file, the +line and the problem, and keeps the Xbox's menus. A build with another set +would only show that when it is run, so problems() reads a set as the game +does and names every problem it finds: + +- each XML file reads, with the game's parser (nothing before the XML + declaration), its elements where they may be, with the attributes they may + have and whole numbers where the game wants them; +- no widget, bitmap or string list is named twice; +- every name used is one of the set's: the widget of a child, a conditional, + a description or a handler, a widget's bitmaps and its string list (and + the string's number in it). A name with a backslash is a tag of the map, + which only the game can look up; +- every function and game data input is one the game has (menu_tags.c's + tables and the game's own, ui_widget_event_handler_functions.c), or + starts "unwired "; +- every picture a frame names is a file of the set, an 8-bit RGBA PNG that + is not interlaced, and a whole multiple of the frame's width and of its + height (not the same multiple of both: a background 16 units wide is + stretched over a wide screen, source/interface/ui_widget.c); +- a theme's colours (, and the + server browser's <..._text color=>): each a colour, and no more than 32 + text colours mapped; +- a theme's fonts (, as menu_files.c's + halo_menus_font_check judges them): each a tag the high-res text draws + (port/assets/fonts/fonts.json's), its file a .ttf of the set inside the + menus' files, a static TrueType font (one font of TrueType outlines, with + no variations) of at most 4 MiB whose tables lie in it, with its licence + beside it (-OFL.txt, the SIL Open Font License 1.1); +- and what else menu_tags.c refuses: its flags, types, events, colours and + bounds, anchors and fills, a bitmap's caps (whole numbers that leave a + middle in each of its frames, PNGs), a spinner's children, a handler's + widgets, the main menu's name. + +An element's platform attribute (desktop, android, web) leaves it out of the +other platforms' builds, so the set is read once for each: a string list +with one variant of a string for each platform has the same strings in all. +""" + +import re +import struct +from pathlib import Path +from typing import Dict, Iterable, List, NamedTuple, Optional, Set, Tuple +from xml.parsers import expat + +PLATFORMS = ("desktop", "android", "web") +# the game's sources the names come from (in the repository) +TAGS_SOURCE = Path("port/linux/game/menu_tags.c") +FUNCTIONS_SOURCE = Path("source/interface/ui_widget_event_handler_functions.c") +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +# the fonts' tags and files (the tags the high-res text draws), and the +# menus' header, which gives a theme font's file's limit +FONTS_LIST = Path("port/assets/fonts/fonts.json") +MENUS_HEADER = Path("port/linux/include/halo_menus.h") +# menu_files.c's font_tables_check: the tables stb_truetype reads +FONT_TABLES = (b"cmap", b"glyf", b"head", b"hhea", b"hmtx", b"loca", b"maxp") +# menu_files.c's: how deep elements nest +MAXIMUM_DEPTH = 32 +# menu_files.c's elements: the elements each may be in (None: the file), +# its attributes that are text, and those that are whole numbers (its +# tables' { name, NULL, &number }); every element takes platform too +ELEMENTS = { + "menus": ((None,), ("root",), ()), + "bitmap": (("menus",), ("name", "frames", "caps"), ("width", "height")), + "frame": (("bitmap",), ("png", "map"), ("index", "width", "height", "x", "y")), + "strings": (("menus",), ("name", "map"), ()), + "string": (("strings",), ("text",), ("map",)), + "widget": (("menus", "widget"), + ("name", "type", "controller", "flags", "bitmap", "text", "strings", "values", "setting", "font", + "color", "align", "description", "string_list", "list_flags", "text_flags", "header_bitmap", + "footer_bitmap", "header_bounds", "footer_bounds", "child_controller", "anchor", "fill"), + ("x", "y", "left", "top", "width", "height", "text_x", "text_y", "auto_close", "auto_close_fade", + "string_index")), + "child": (("widget",), ("widget", "controller", "anchor"), ("x", "y")), + "on": (("widget",), + ("event", "run", "script", "open", "replace", "close", "widget", "focus", "reload", "sound", "otherwise", + "label", "back", "branch"), ()), + "data": (("widget",), ("input",), ()), + "conditional": (("widget",), ("widget", "if_failed"), ()), + "replace": (("widget",), ("search", "function"), ()), + # a theme's colours (halo_menus.h's HALO_MENU_TEXT_ELEMENTS: the fixed + # colours of the game's own text it sets) + "color_map": (("menus",), ("from", "to"), ()), + **{name: (("menus",), ("color",), ()) for name in ("focus_text", "ping_good_text", "ping_fair_text", + "ping_poor_text", "status_text", "greyed_text")}, + # a theme's font (halo_menus.h's struct halo_menu_font) + "font": (("menus",), ("tag", "file"), ()), +} +# menu_tags.c's: a colour (#RRGGBB, or #AARRGGBB), and the text colours a set +# maps at most (halo_menus.h's HALO_MENU_MAXIMUM_COLOR_MAPS) +COLOR = re.compile(r"#(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})") +MAXIMUM_COLOR_MAPS = 32 +# menu_tags.c's caps_add: a bitmap's caps, "left,right", whole numbers of its +# frames' units, at most 2048 each +CAPS = re.compile(r"([0-9]+),([0-9]+)") +MAXIMUM_CAP = 2048 + + +class MenuFile(NamedTuple): + """a file of the set: its path in the menus (ce/...), its bytes, and + the file it was read from, for the messages""" + name: str + data: bytes + shown: str + + +class Problem(NamedTuple): + """a problem: the file as shown ("" for the whole set), the line (0: + none) and what is wrong""" + file: str + line: int + text: str + + +class Item: + """an element read: where it is, and its attributes (text as written, + numbers as whole numbers)""" + + def __init__(self, file: str, line: int, attributes: dict): + self.file, self.line, self.attributes = file, line, attributes + # the reader has said what is wrong with it + self.refused = False + + def get(self, name: str, default=None): + return self.attributes.get(name, default) + + +class Bitmap(Item): + def __init__(self, file: str, line: int, attributes: dict): + super().__init__(file, line, attributes) + self.frames: List[Item] = [] + self.frames_attribute = False + + +class Strings(Item): + def __init__(self, file: str, line: int, attributes: dict): + super().__init__(file, line, attributes) + self.strings: List[Item] = [] + + +class Widget(Item): + def __init__(self, file: str, line: int, attributes: dict): + super().__init__(file, line, attributes) + # its children in order: a inside it (with the controller + # its child_controller gives) or a + self.children: List[Tuple[Optional["Widget"], Item]] = [] + self.handlers: List[Item] = [] + self.inputs: List[Item] = [] + self.conditionals: List[Item] = [] + self.replaces: List[Item] = [] + + +class Menus: + """what a platform's game reads from the files""" + + def __init__(self, platform: str): + self.platform = platform + self.root: Optional[Item] = None # the last with a root + self.bitmaps: List[Bitmap] = [] + self.string_lists: List[Strings] = [] + self.widgets: List[Widget] = [] + # the theme's colours (, <..._text>), in their order + self.colors: List[Tuple[str, Item]] = [] + # the theme's fonts (), in their order + self.fonts: List[Item] = [] + self.problems: List[Problem] = [] + # a file did not parse: what it holds is not known + self.unread = False + + +class Names(NamedTuple): + """the names the game knows: menu_tags.c's tables and the game's own + event handler functions""" + events: frozenset + widget_flags: frozenset + widget_types: frozenset + list_flags: frozenset + text_flags: frozenset + aligns: frozenset + replaces: frozenset + functions: frozenset + inputs: frozenset + anchors: frozenset + fills: frozenset + # the font tags the high-res text draws (fonts.json's), in lower case + font_tags: frozenset = frozenset() + + +def c_strings(text: str, start: str, end: str, where: str) -> List[str]: + """the string literals of a C source from a marker (a regular + expression) to the next end""" + match = re.search(start, text) + if not match or text.find(end, match.end()) < 0: + raise ValueError(f"{where}: no table {start}: tools/menu_check.py reads the game's names from it") + return re.findall(r'"([^"\\]*)"', text[match.start():text.index(end, match.end())]) + + +def game_names(root: Path) -> Names: + """The names the menus' files may use, from the game's sources under + root (the repository); ValueError where they cannot be read.""" + try: + tags = (root / TAGS_SOURCE).read_text(encoding="latin-1") + functions = (root / FUNCTIONS_SOURCE).read_text(encoding="latin-1") + except OSError as error: + raise ValueError(f"{error.filename}: {error.strerror}: tools/menu_check.py reads the game's names " + "from it") from error + + def table(name: str) -> frozenset: + return frozenset(c_strings(tags, rf"\b{name}\[\] =", "};", str(TAGS_SOURCE))) + + return Names( + events=table("event_names"), widget_flags=table("widget_flag_names"), + widget_types=table("widget_type_names"), list_flags=table("list_flag_names"), + text_flags=table("text_flag_names"), aligns=table("align_names"), replaces=table("replace_names"), + # (the game's own: the names of its table of functions, which starts with NULL) + functions=table("port_function_names") | frozenset( + c_strings(functions, r'\t\{\n\t\t"NULL",', "}", str(FUNCTIONS_SOURCE))), + inputs=table("game_data_input_names") | table("port_game_data_input_names"), + anchors=table("anchor_names"), fills=table("fill_names"), + font_tags=font_tags(root)) + + +def font_tags(root: Path) -> frozenset: + """the font tags the high-res text draws (port/assets/fonts/fonts.json), in + lower case, as the game compares them""" + import json + + try: + fonts = json.loads((root / FONTS_LIST).read_text(encoding="utf-8"))["fonts"] + except (OSError, ValueError, KeyError) as error: + raise ValueError(f"{root / FONTS_LIST}: {error}: tools/menu_check.py reads the font tags from it") from error + return frozenset(font["tag"].lower() for font in fonts) + + +def font_maximum_size(root: Path = Path(__file__).resolve().parent.parent) -> int: + """a theme font's file's limit (halo_menus.h's HALO_MENU_MAXIMUM_FONT_SIZE)""" + found = re.search(r"\bHALO_MENU_MAXIMUM_FONT_SIZE = (\d+)", (root / MENUS_HEADER).read_text(encoding="latin-1")) + if not found: + raise ValueError(f"{root / MENUS_HEADER}: no HALO_MENU_MAXIMUM_FONT_SIZE: tools/menu_check.py reads it") + return int(found.group(1)) + + +def font_tables_problem(data: bytes) -> Optional[str]: + """menu_files.c's font_tables_check: what keeps a font's file from being + one the high-res text draws, None if nothing does""" + maximum = font_maximum_size() + if len(data) < 12 or len(data) > maximum: + return f"it is not a font of 12 bytes to {maximum}" + if data[:4] not in (b"\x00\x01\x00\x00", b"true"): + return "it is not a TrueType font (a collection, or CFF outlines, are not)" + count = struct.unpack(">H", data[4:6])[0] + if not count or 12 + 16 * count > len(data): + return "its table directory does not lie in it" + found = set() + for index in range(count): + entry = data[12 + 16 * index:28 + 16 * index] + offset, length = struct.unpack(">II", entry[8:16]) + if offset > len(data) or length > len(data) - offset: + return "a table of it lies past its end" + if entry[:4] in (b"fvar", b"gvar"): + return "it is a variable font: give a static instance of it" + found.add(entry[:4]) + for table in FONT_TABLES: + if table not in found: + return f"it has no {table.decode()} table (TrueType outlines)" + return None + + +def font_problem(tag: Optional[str], path: Optional[str], files: Dict[str, "MenuFile"], tags: frozenset) -> Optional[str]: + """menu_files.c's halo_menus_font_check: what keeps a theme's font from + drawing its tag, None if nothing does (tags: those the high-res text + draws, in lower case; files: the set's, by name)""" + if tag is None or tag.lower() not in tags: + return f"{tag if tag is not None else 'none'} is no tag the high-res text draws (port/assets/fonts/fonts.json)" + if path is None or len(path) < 5 or path[-4] != "." or path[-3:].lower() != "ttf": + return f"a theme's font is a .ttf, not {path if path is not None else 'none'}" + if not path_inside_folder(path): + return f"{path} is not a path inside the menus' files" + if path not in files: + return f"there is no file {path} in the menus" + problem = font_tables_problem(files[path].data) + if problem: + return problem + folder, _, name = path.rpartition("/") + family = re.split(r"[-.]", name, maxsplit=1)[0] + if not family: + return "its file's name has no family before its - (Family-Style.ttf)" + licence = f"{folder}/{family}-OFL.txt" if folder else f"{family}-OFL.txt" + if licence not in files: + return f"it has no licence beside it: {licence}" + text = files[licence].data.lower() + if b"sil open font license" not in text or b"version 1.1" not in text: + return f"{licence} is not the SIL Open Font License 1.1" + return None + + +def png_size(data: bytes) -> Tuple[int, int]: + """The width and height of an 8-bit RGBA PNG that is not interlaced, the + only kind the game reads (port/linux/src/hud_hires.c); ValueError for + another file.""" + if len(data) < 29 or data[:8] != PNG_SIGNATURE or data[12:16] != b"IHDR": + raise ValueError("not a PNG") + width, height, depth, colour, _, _, interlace = struct.unpack(">IIBBBBB", data[16:29]) + if depth != 8 or colour != 6 or interlace != 0: + raise ValueError("must be 8-bit RGBA and not interlaced") + return width, height + + +def words(text: Optional[str]) -> List[str]: + """the words of an attribute that holds several, as the game splits + them""" + return [word for word in re.split(r"[ \t\r\n]+", text or "") if word] + + +def path_inside_folder(path: str) -> bool: + """menu_files.c's: whether a picture's path stays inside the menus + folder: relative, with no drive and no .. (nor Windows' spellings of + it)""" + if not path or path[0] in "/\\" or ":" in path: + return False + for component in re.split(r"[/\\]+", path): + if component and not component.strip(". ") and component != ".": + return False + return True + + +class Reader: + """Reads the set's XML files as a platform's game does (menu_files.c), + but goes on after a problem, to name them all.""" + + def __init__(self, platform: str): + self.menus = Menus(platform) + self.file: Optional[MenuFile] = None + self.parser = None + self.stack: List[Tuple[str, Optional[Item]]] = [] + self.skipping = 0 + + def problem(self, text: str) -> None: + self.menus.problems.append(Problem(self.file.shown, self.parser.CurrentLineNumber, text)) + + def read(self, file: MenuFile) -> None: + self.file, self.stack, self.skipping = file, [], 0 + self.parser = expat.ParserCreate("UTF-8") + self.parser.StartElementHandler = self.start + self.parser.EndElementHandler = self.end + self.parser.CharacterDataHandler = self.text + try: + self.parser.Parse(file.data, True) + except expat.ExpatError as error: + self.menus.problems.append(Problem(file.shown, error.lineno, + f"{expat.ErrorString(error.code)} (column {error.offset})")) + self.menus.unread = True + + def for_this_platform(self, attributes: dict) -> bool: + """whether the element is for this platform (its platform attribute: + the platforms it is for, separated by spaces)""" + if "platform" not in attributes: + return True + names = [name for name in attributes["platform"].split(" ") if name] + if any(name not in PLATFORMS for name in names): + self.problem(f'platform="{attributes["platform"]}" names one that is not desktop, android or web') + return True + return self.menus.platform in names + + def attributes(self, element: str, attributes: dict) -> dict: + """the element's attributes: its text as it is, its numbers read; one + it may not have (but platform) is a problem""" + _, texts, numbers = ELEMENTS[element] + values = {} + for name, value in attributes.items(): + if name == "platform": + continue + if name in texts: + values[name] = value + elif name not in numbers: + self.problem(f"<{element}> has no attribute {name}") + # (strtol's whole number, and nothing after it; the tags keep them in shorts) + elif not re.fullmatch(r"[ \t\n\v\f\r]*[+-]?[0-9]+", value): + self.problem(f'{name}="{value}" is not a whole number') + elif not -32768 <= int(value) <= 32767: + self.problem(f'{name}="{value}" is out of range (-32768 to 32767)') + else: + values[name] = int(value) + return values + + def start(self, name: str, attributes: dict) -> None: + if self.skipping or not self.for_this_platform(attributes): + self.skipping += 1 + return + parent, owner = self.stack[-1] if self.stack else (None, None) + if len(self.stack) == MAXIMUM_DEPTH: + self.problem("elements nested too deeply") + self.skipping += 1 + return + if name not in ELEMENTS or parent not in ELEMENTS[name][0]: + self.problem(f"<{name}> cannot be here") + self.skipping += 1 + return + values = self.attributes(name, attributes) + item = Item(self.file.shown, self.parser.CurrentLineNumber, values) + menus = self.menus + if name == "menus": + if "root" in values: + menus.root = item + elif name == "bitmap": + item = Bitmap(item.file, item.line, values) + item.frames_attribute = "frames" in values + # (frames="a.png b.png", all width by height, or s) + for png in words(values.get("frames")): + item.frames.append(Item(item.file, item.line, {"png": png, "width": values.get("width", 0), + "height": values.get("height", 0)})) + if "name" not in values: + self.problem("a needs a name") + menus.bitmaps.append(item) + elif name == "frame": + width, height = values.get("width", 0), values.get("height", 0) + wrong = None + if ("png" in values) == ("map" in values): + wrong = "a has a png or a map bitmap, not both" + elif "png" in values and (width <= 0 or height <= 0): + wrong = "a with a png needs width and height" + elif "map" in values and values.get("index", -1) < 0: + wrong = "a of a map bitmap needs its index" + elif "map" in values and (width < 0 or height < 0 or (not width) != (not height)): + wrong = "a of a map bitmap is scaled to a width and a height, or neither" + elif (values.get("x", 0) or values.get("y", 0)) and ("map" not in values or not width): + wrong = "only a scaled of a map bitmap is placed at an x and y" + if wrong: + self.problem(wrong) + item.refused = True + if owner.frames_attribute: + self.problem("a bitmap's s and frames= cannot be mixed") + owner.frames.append(item) + elif name == "strings": + item = Strings(item.file, item.line, values) + if "name" not in values: + self.problem("a needs a name") + menus.string_lists.append(item) + elif name == "string": + if "text" not in values: + self.problem("a needs text") + if "map" in values and (values["map"] < 0 or "map" not in owner.attributes): + self.problem("a 's map= is an index in its ' map list") + owner.strings.append(item) + elif name == "widget": + item = Widget(item.file, item.line, values) + if "name" not in values: + self.problem("a needs a name") + menus.widgets.append(item) + if parent == "widget": + controller = {"controller": values["child_controller"]} if "child_controller" in values else {} + owner.children.append((item, Item(item.file, item.line, controller))) + elif name == "child": + if "widget" not in values: + self.problem("a needs a widget") + owner.children.append((None, item)) + elif name == "on": + if values.get("back") not in (None, "true", "false") or values.get("branch") not in (None, "true", "false"): + self.problem('back and branch are "true" or "false"') + if "event" not in values: + self.problem("an needs an event") + owner.handlers.append(item) + elif name == "data": + if "input" not in values: + self.problem("a needs an input") + owner.inputs.append(item) + elif name == "conditional": + if values.get("if_failed") not in (None, "true", "false"): + self.problem('if_failed is "true" or "false"') + if "widget" not in values: + self.problem("a needs a widget") + owner.conditionals.append(item) + elif name == "replace": + if "search" not in values: + self.problem("a needs a search") + owner.replaces.append(item) + elif name == "color_map": + if "from" not in values or "to" not in values: + self.problem("a needs a from and a to") + menus.colors.append((name, item)) + elif name.endswith("_text"): + if "color" not in values: + self.problem(f"a <{name}> needs a color") + menus.colors.append((name, item)) + elif name == "font": + if "tag" not in values or "file" not in values: + self.problem("a needs a tag and a file") + menus.fonts.append(item) + self.stack.append((name, item if name in ("bitmap", "strings", "widget") else owner)) + + def end(self, name: str) -> None: + if self.skipping: + self.skipping -= 1 + elif self.stack: + self.stack.pop() + + def text(self, characters: str) -> None: + if not self.skipping and characters.strip(" \t\r\n"): + self.problem('text belongs in an attribute (text="...")') + + +def read(files: Iterable[MenuFile], platform: str) -> Menus: + """What a platform's game reads from the set's XML files, in their + order, with the problems of their shape (menu_files.c's).""" + reader = Reader(platform) + for file in files: + if file.name.endswith(".xml"): + reader.read(file) + return reader.menus + + +def check(menus: Menus, names: Names, files: Dict[str, MenuFile]) -> None: + """Adds to menus.problems what the game refuses when it builds its tags + from them (menu_tags.c), and the pictures the frames name, among the + set's files (by their names).""" + found: List[Problem] = menus.problems + + def problem(item: Item, text: str) -> None: + found.append(Problem(item.file, item.line, text)) + + def first(items: List[Item], kind: str) -> Dict[str, Item]: + """the items by name: the game takes the first of a name""" + named: Dict[str, Item] = {} + for item in items: + name = item.get("name") + if name is None: + continue + if name in named: + problem(item, f"two {kind} are named {name} (the other: {named[name].file}:{named[name].line})") + else: + named[name] = item + return named + + widgets = first(menus.widgets, "widgets") + bitmaps = first(menus.bitmaps, "bitmaps") + string_lists = first(menus.string_lists, "string lists") + + def named(item: Item, name: Optional[str], kind: str, known: Dict[str, Item]) -> bool: + """whether the name is one of ours (a tag of the map, with a + backslash, is the game's to find); a problem if it is none""" + if name is None or "\\" in name: + return False + if name not in known: + problem(item, f"there is no {kind} named {name}") + return False + return True + + def flags(item: Item, attribute: str, known: frozenset) -> List[str]: + result = words(item.get(attribute)) + for word in result: + if word not in known: + problem(item, f"there is no flag {word}") + return result + + if not menus.widgets and not menus.unread: + found.append(Problem("", 0, "the files have no widget: the game would keep its own menus")) + + for bitmap in menus.bitmaps: + if not bitmap.frames: + problem(bitmap, f"a bitmap has no frames: {bitmap.get('name')}") + # its caps: the units at each frame's left and right drawn as they + # are, which leave a middle in every frame, a PNG's + caps = bitmap.get("caps") + if caps is not None: + match = CAPS.fullmatch(caps) + if not match or int(match.group(1)) > MAXIMUM_CAP or int(match.group(2)) > MAXIMUM_CAP: + problem(bitmap, f'caps are two whole numbers, "left,right": {caps}') + elif any(frame.get("png") is None for frame in bitmap.frames): + problem(bitmap, f"caps are for a bitmap of PNGs: {bitmap.get('name')}") + elif any(int(match.group(1)) + int(match.group(2)) >= frame.get("width", 0) for frame in bitmap.frames): + problem(bitmap, f"caps leave no middle in a frame of {bitmap.get('name')}") + for frame in bitmap.frames: + png = frame.get("png") + if png is None or frame.refused: + continue + width, height = frame.get("width", 0), frame.get("height", 0) + if not 0 < width <= 2048 or not 0 < height <= 2048: + problem(frame, f"a frame is 1 to 2048 wide and high: {png}") + continue + if not path_inside_folder(png): + problem(frame, f"{png} is not a path inside the menus folder") + elif png not in files: + problem(frame, f"there is no file {png} (a frame of the bitmap {bitmap.get('name')})") + else: + try: + size = png_size(files[png].data) + except ValueError: + # (problems() says so of the file itself) + continue + if size[0] % width or size[1] % height: + problem(frame, f"{files[png].shown} is {size[0]}x{size[1]}, not a whole multiple of its " + f"frame's {width}x{height}") + + for widget in menus.widgets: + name = widget.get("name", "(no name)") + kind = widget.get("type", "container") + if kind not in names.widget_types: + problem(widget, f"there is no widget type {kind}") + kind = "container" + if widget.get("controller") not in (None, "any", "1", "2", "3", "4"): + problem(widget, f'controller is 1 to 4, or "any": {widget.get("controller")}') + if widget.get("anchor") is not None and widget.get("anchor") not in names.anchors: + problem(widget, f"there is no anchor {widget.get('anchor')}") + if widget.get("fill") is not None and widget.get("fill") not in names.fills: + problem(widget, f"there is no fill {widget.get('fill')}") + flags(widget, "flags", names.widget_flags) + named(widget, widget.get("bitmap"), "bitmap", bitmaps) + for data in widget.inputs: + value = data.get("input") + if value is not None and not value.startswith("unwired ") and value not in names.inputs: + problem(data, f"there is no game data input {value}") + for replace in widget.replaces: + if replace.get("function") is not None and replace.get("function") not in names.replaces: + problem(replace, f"a replace function is none or controller: {replace.get('function')}") + if sum(widget.get(attribute) is not None for attribute in ("text", "strings", "string_list")) > 1: + problem(widget, f"a widget has one of text, strings and string_list: {name}") + string_list = widget.get("string_list") + # (its text's list: its own text or strings, else the list named, + # which the map has when the name has a backslash) + has_list = widget.get("text") is not None or widget.get("strings") is not None + if not has_list and string_list is not None: + has_list = "\\" in string_list or named(widget, string_list, "string list", string_lists) + if has_list and "\\" not in string_list: + count = len(string_lists[string_list].strings) + if not 0 <= widget.get("string_index", 0) < count: + problem(widget, f"string_index {widget.get('string_index', 0)} is not in {string_list}, " + f"which has {count} strings") + if widget.get("color") is not None and not re.fullmatch(r"#(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})", widget.get("color")): + problem(widget, f"a color is #RRGGBB or #AARRGGBB: {widget.get('color')}") + if widget.get("align") is not None and widget.get("align") not in names.aligns: + problem(widget, f'align is "left", "right" or "center": {widget.get("align")}') + flags(widget, "text_flags", names.text_flags) + from_strings = "items_from_strings" in flags(widget, "list_flags", names.list_flags) or ( + widget.get("strings") is not None and widget.get("text") is None) + if widget.get("strings") is not None and (kind != "spinner" or widget.children): + problem(widget, f"strings are for a spinner with no children: {name}") + if from_strings and (kind != "spinner" or widget.children or not has_list): + problem(widget, f"items_from_strings is for a spinner with strings and no children: {name}") + if widget.get("setting") is not None and (widget.get("strings") is None or widget.get("values") is None): + problem(widget, f"a setting needs strings and values: {name}") + for attribute in ("header_bitmap", "footer_bitmap"): + named(widget, widget.get(attribute), "bitmap", bitmaps) + for attribute in ("header_bounds", "footer_bounds"): + # (sscanf's four whole numbers, and nothing after them) + if widget.get(attribute) is not None and not re.fullmatch( + r"(?:[ \t\n\v\f\r]*[+-]?[0-9]+(?![0-9])){4}[ \t\n\v\f\r]*", widget.get(attribute)): + problem(widget, f'bounds are "top left bottom right": {widget.get(attribute)}') + if widget.get("description") is not None: + if kind != "column_list": + problem(widget, f"a description is for a column list: {name}") + named(widget, widget.get("description"), "widget", widgets) + if kind == "spinner" and len(widget.children) not in (0, 1, 3): + problem(widget, f"a spinner has 0, 1 or 3 children: {name}") + for nested, child in widget.children: + if nested is None: + named(child, child.get("widget"), "widget", widgets) + if child.get("controller") not in (None, "1", "2", "3", "4"): + problem(child, f"a child's controller is 1 to 4: {child.get('controller')}") + if child.get("anchor") is not None and child.get("anchor") not in names.anchors: + problem(child, f"there is no anchor {child.get('anchor')}") + for conditional in widget.conditionals: + named(conditional, conditional.get("widget"), "widget", widgets) + for handler in widget.handlers: + events = words(handler.get("event")) + for event in events: + if event not in names.events: + problem(handler, f"there is no event {event}") + if not events: + # (a handler for no event is not built) + continue + run = handler.get("run") + if run is not None and not run.startswith("unwired ") and run not in names.functions: + problem(handler, f"there is no event handler function {run}") + if handler.get("close") is not None and handler.get("close") not in ("current", "all") and not ( + handler.get("close") == "other" and handler.get("widget") is not None): + problem(handler, f'close is "current", "all" or "other" (with widget=): {handler.get("close")}') + if handler.get("reload") is not None and handler.get("reload") != "self" and not ( + handler.get("reload") == "other" and handler.get("widget") is not None): + problem(handler, f'reload is "self" or "other" (with widget=): {handler.get("reload")}') + targets = [handler.get(attribute) for attribute in ("open", "replace", "focus", "widget") + if handler.get(attribute) is not None] + if any(target != targets[0] for target in targets): + problem(handler, "a handler names one widget (open, replace, focus or widget)") + if targets: + named(handler, targets[0], "widget", widgets) + if handler.get("otherwise") is not None: + if run is None: + problem(handler, "otherwise needs run (the function whose failure it is for)") + named(handler, handler.get("otherwise"), "widget", widgets) + + # the theme: each colour's syntax, and no more text colours mapped than + # the game keeps (a later map of a from replaces an earlier one) + mapped: Set[str] = set() + for name, item in menus.colors: + for attribute in ("from", "to", "color"): + value = item.get(attribute) + if value is not None and not COLOR.fullmatch(value): + problem(item, f"a color is #RRGGBB or #AARRGGBB: {value}") + source = item.get("from") + if name == "color_map" and source is not None and COLOR.fullmatch(source): + # (#RRGGBB is #FFRRGGBB, as the game reads it) + key = ("FF" + source[1:] if len(source) == 7 else source[1:]).upper() + if key not in mapped and len(mapped) == MAXIMUM_COLOR_MAPS: + problem(item, f"the menus map {MAXIMUM_COLOR_MAPS} text colors at most; another: {source}") + else: + mapped.add(key) + + # the theme's fonts, each as the game checks it (a later font of a tag + # replaces an earlier one; no more than the high-res text's tags) + for item in menus.fonts: + if item.refused or item.get("tag") is None or item.get("file") is None: + continue + reason = font_problem(item.get("tag"), item.get("file"), files, names.font_tags) + if reason: + problem(item, f"a theme's font cannot draw its tag: {reason}") + + # the main menu: the last root named, else main_menu; without it the + # game opens the Xbox's + root = menus.root.get("root") if menus.root else "main_menu" + if menus.widgets and not menus.unread and root not in widgets: + found.append(Problem(menus.root.file if menus.root else "", menus.root.line if menus.root else 0, + f"there is no widget named {root}, the main menu")) + + +# ---------- where the screens' widgets are drawn on a wider display +# (port/assets/menus/README.md, "Anchors") + +# menu_tags.c's anchor_names, in their order (menu_functions.c's anchors from 1) +ANCHORS = ("center", "left", "right", "stretch", "fluid") +# displays a set's screens are tried on: 4:3, 16:9 and 21:9, in the menus' units +# (480 lines: d3d8_gl.c's widths, which are even) +DISPLAY_WIDTHS = (640, 852, 1120) +# menu_functions.c's: how deep a widget is placed from its screen, at most +ANCHOR_MAXIMUM_DEPTH = 32 + + +def c_divide(numerator: int, denominator: int) -> int: + """C's division of whole numbers, which goes towards zero""" + quotient = abs(numerator) // abs(denominator) + return quotient if (numerator < 0) == (denominator < 0) else -quotient + + +def anchor_place(levels: List[Tuple[Optional[str], int, int, int]], margin: int, + columns: int = 640) -> Tuple[int, int]: + """menu_functions.c's anchor_place: where the last of the levels (from its + screen down, each its anchor, one of ANCHORS or None, its offset from its + parent, and its area's left and right) is drawn on a display margin units + wider than the columns at either side: how far right of where its parent + places it (its shift), and how much wider at either side its area is + (stretched)""" + frame_left, frame_right, moved_left, moved_right = 0, columns, -margin, margin + position = origin = shift = grow = 0 + for level, (anchor, offset, area_left, area_right) in enumerate(levels): + position += offset + left, right = position + area_left, position + area_right + wider = 0 + if anchor == "left": + moved = moved_left + elif anchor == "right": + moved = moved_right + elif anchor == "stretch": + wider = c_divide(moved_right - moved_left, 2) + moved = moved_left + wider + elif anchor == "fluid" and frame_right > frame_left: + across = (moved_right - moved_left) * (left + right - 2 * frame_left) + width = 2 * (frame_right - frame_left) + moved = moved_left + c_divide(2 * across + (-width if across < 0 else width), 2 * width) + else: + moved = c_divide(moved_left + moved_right, 2) + shift, grow, origin = moved - origin, wider, moved + # (its children's frame: its area as drawn, but a screen's that is not + # anchored left, right or fluid, which is the display's) + if level or anchor in ("left", "right", "fluid"): + frame_left, frame_right, moved_left, moved_right = left, right, moved - wider, moved + wider + return shift, grow + + +class Placed(NamedTuple): + """a widget of a screen as it is drawn: the screen, the widget, its area + (left, top, right, bottom) and whether the mouse and touch can press it""" + screen: str + widget: Widget + area: Tuple[int, int, int, int] + pressable: bool + + +def screens_of(menus: Menus) -> List[str]: + """the widgets the game opens as screens: the main menu, and each one a + handler opens or replaces another with""" + found = [menus.root.get("root") if menus.root else "main_menu"] + for widget in menus.widgets: + for handler in widget.handlers: + for attribute in ("open", "replace", "otherwise"): + name = handler.get(attribute) + if name is not None and "\\" not in name and name not in found: + found.append(name) + return found + + +def placed(menus: Menus, width: int) -> List[Placed]: + """Every widget of every screen of the set, where the game draws it on a + display width units wide (menu_functions.c's pc_menu_anchor_shift, as + ui_widget.c places each): its children, a in it or a + (with the child's anchor, else its widget's), and a column list's + description. Pressable is what the mouse's targets are, roughly + (ui_widget.c's ui_mouse_note_target): a key's button (*_butn), a spinner + in a widget, a column list's item, and a widget with handlers (or a list) + in one that tabs through its children. Widgets the game makes, moves or + hides as it runs are where the files put them.""" + widgets = {} + for widget in menus.widgets: + widgets.setdefault(widget.get("name"), widget) + margin = (width - 640) // 2 + found: List[Placed] = [] + + def area(widget: Widget) -> Tuple[int, int, int, int]: + left, top = widget.get("left", 0), widget.get("top", 0) + return left, top, left + widget.get("width", 640), top + widget.get("height", 480) + + def pressable(widget: Widget, parent: Optional[Widget], description: bool) -> bool: + if parent is None or description: + return False + flags = words(parent.get("flags")) + return widget.get("name", "").rsplit("/", 1)[-1].endswith("_butn") or widget.get("type") == "spinner" or \ + parent.get("type") == "column_list" or ( + ("up_down_tabs_children" in flags or "left_right_tabs_children" in flags) and + (bool(widget.handlers) or widget.get("type") == "column_list")) + + def walk(screen: str, widget: Widget, parent: Optional[Widget], levels: list, x: int, y: int, + description: bool) -> None: + left, top, right, bottom = area(widget) + shift = grow = 0 + if margin > 0 and len(levels) <= ANCHOR_MAXIMUM_DEPTH and any(anchor for anchor, *_ in levels): + shift, grow = anchor_place(levels, margin) + x += shift + found.append(Placed(screen, widget, (x + left - grow, y + top, x + right + grow, y + bottom), + pressable(widget, parent, description))) + if len(levels) > 64: + return + for nested, child in widget.children: + # (a in it, placed by its own x, y and anchor; or a + # , by its own, and its widget's anchor if it gives none) + if nested is not None: + target, child_x, child_y, anchor = nested, nested.get("x", 0), nested.get("y", 0), nested.get("anchor") + else: + target, child_x, child_y = widgets.get(child.get("widget")), child.get("x", 0), child.get("y", 0) + anchor = child.get("anchor", target.get("anchor") if target is not None else None) + if target is not None: + child_left, _, child_right, _ = area(target) + walk(screen, target, widget, levels + [(anchor, child_x, child_left, child_right)], x + child_x, + y + child_y, False) + if widget.get("type") == "column_list" and widget.get("description") in widgets: + child = widgets[widget.get("description")] + child_left, _, child_right, _ = area(child) + walk(screen, child, widget, levels + [(child.get("anchor"), 0, child_left, child_right)], x, y, True) + + for screen in screens_of(menus): + if screen in widgets: + widget = widgets[screen] + left, _, right, _ = area(widget) + walk(screen, widget, None, [(widget.get("anchor"), 0, left, right)], 0, 0, False) + return found + + +def off_the_display(files: List[MenuFile], platforms: Tuple[str, ...] = PLATFORMS, + widths: Tuple[int, ...] = DISPLAY_WIDTHS) -> List[str]: + """Each pressable widget of the set's screens (placed) whose middle is not + on a display of each width: one that could not be pressed there""" + lines: List[str] = [] + for platform in platforms: + menus = read(files, platform) + for width in widths: + margin = (width - 640) // 2 + for item in placed(menus, width): + left, top, right, bottom = item.area + middle_x, middle_y = (left + right) // 2, (top + bottom) // 2 + if item.pressable and not (-margin <= middle_x < 640 + margin and 0 <= middle_y < 480): + line = (f"{item.widget.file}:{item.widget.line}: {item.widget.get('name')} (on " + f"{item.screen}) is off a display {width} wide (on {platform}): its middle is at " + f"{middle_x}, {middle_y}, the display from {-margin} to {640 + margin}") + if line not in lines: + lines.append(line) + return lines + + +def pictures_named(files: Iterable[MenuFile]) -> Set[str]: + """The pictures the set's XML files name, on any platform: every + 's png and every 's frames.""" + named: Set[str] = set() + + def start(name: str, attributes: dict) -> None: + if name == "frame" and "png" in attributes: + named.add(attributes["png"]) + elif name == "bitmap": + named.update(words(attributes.get("frames"))) + + for file in files: + if not file.name.endswith(".xml"): + continue + parser = expat.ParserCreate("UTF-8") + parser.StartElementHandler = start + try: + parser.Parse(file.data, True) + except expat.ExpatError: + pass + return named + + +def problems(files: List[MenuFile], root: Path, platforms: Tuple[str, ...] = PLATFORMS) -> List[str]: + """Every problem of the set (its files in the order the game reads + them), each a line naming its file: what the game of any of the + platforms would refuse, and what it would pass by in silence (a file + whose name only differs in case from another's, an XML file whose name + does not end in .xml, a PNG it cannot read). The game's names are read + from its sources under root.""" + names = game_names(root) + by_name = {file.name: file for file in files} + found: Dict[Problem, List[str]] = {} + # what is wrong with the files themselves, whatever the platform + lower: Dict[str, MenuFile] = {} + for file in files: + other = lower.setdefault(file.name.lower(), file) + if other is not file: + found[Problem(file.shown, 0, f"{file.name} and {other.name} ({other.shown}) differ only in case: " + "they are one file where a file system ignores case")] = list(platforms) + if file.name.lower().endswith(".xml") and not file.name.endswith(".xml"): + found[Problem(file.shown, 0, "the game reads only a name that ends in .xml as a menu file")] = list(platforms) + if file.name.lower().endswith(".png"): + try: + png_size(file.data) + except ValueError as error: + found[Problem(file.shown, 0, str(error))] = list(platforms) + for platform in platforms: + menus = read(files, platform) + # (the names of a file that does not parse are not known: those + # that use them would all be problems) + if not menus.unread: + check(menus, names, by_name) + for problem in menus.problems: + if platform not in found.setdefault(problem, []): + found[problem].append(platform) + lines = [] + for problem, where in found.items(): + place = problem.file + (f":{problem.line}" if problem.line else "") + ": " if problem.file else "" + lines.append(place + problem.text + ("" if len(where) == len(platforms) else f" (on {', '.join(where)})")) + return lines diff --git a/tools/port_settings.py b/tools/port_settings.py index cf75718..6338d98 100644 --- a/tools/port_settings.py +++ b/tools/port_settings.py @@ -19,13 +19,46 @@ from pathlib import Path from xml.sax.saxutils import quoteattr PE = "main_menu/settings_select/player_setup/player_profile_edit" -# the mods' screen, and its rows (_mods); the main menu's items -# (ce/main_menu.xml): MODS in Credits' place (Credits is left out: -# tools/ce_menus.py's LEFT_OUT), Quit a row below it (its CHILD_OFFSETS) +# the mods' screen, and its rows (_mods); the HaloCustoms account's screen +# (_halocustoms) MODS = "main_menu/mods" MOD_ROWS = 11 -MODS_ITEM_PLACE = (192, 391) -QUIT_ITEM_PLACE = (192, 427) +HALOCUSTOMS = "main_menu/halocustoms" +# (its FRIENDS screen: _halocustoms_friends) +HALOCUSTOMS_FRIENDS = "main_menu/halocustoms_friends" +# the main menu's HaloCustoms account panel and its welcome dialog +# (_halocustoms_panel, _halocustoms_welcome); the panel's place on the main +# menu: left of the items (from x 192), below the title (whose widget ends at +# 284), and its size (its title and nine lines, 14 apart: to 444, clear of +# the screen's foot at 480, whatever the display's shape) +HALOCUSTOMS_PANEL = "main_menu/halocustoms_panel" +HALOCUSTOMS_WELCOME = "main_menu/halocustoms_welcome" +HALOCUSTOMS_PANEL_PLACE = (12, 290) +HALOCUSTOMS_PANEL_SIZE = (176, 154) +# their pictures, under names of their own (main_menu.halocustoms_bitmaps.xml, +# which a menu overlay replaces to theme them, _halocustoms_bitmaps): the +# panel's box, the dialogs' dim's picture, and the welcome's, the alert's +HALOCUSTOMS_PANEL_BITMAP = f"{HALOCUSTOMS_PANEL}/background" +HALOCUSTOMS_WELCOME_BITMAP = f"{HALOCUSTOMS_WELCOME}/background" +# the main menu's items (ce/main_menu.xml), in order: the PC version's, with +# MODS in Credits' place (Credits is left out: tools/ce_menus.py's LEFT_OUT), +# HALOCUSTOMS and FRIENDS after it, then Quit. Eight, each 33 tall, 30 apart +# where the PC version's six are 36 apart: from 229 (the PC version's first +# is at 247), where the first was with seven 33 apart, so that the block grows +# no further up into the title (the Xbox map's picture, 256 tall from 28), down +# to Quit at 439, its box 8 above the screen's foot. At 30 apart a focused +# item's glow stays clear of its neighbours' letters (menu_item_frames: 19 +# tall, from 7 of the 33); the last 3 of each box are the next item's, which +# is drawn after it (ui_widget.c's ui_mouse_target_at). The PC version's own +# are moved by tools/ce_menus.py's CHILD_OFFSETS, ours put there by +# WIDGET_PATCHES (Load Campaign's x is the PC version's own) +MAIN_MENU_ITEMS = ["load_camp", "multiplayer", "profile", "settings", "mods", "halocustoms", "friends", "quit_game"] +MAIN_MENU_ITEM_PLACES = {name: (194 if name == "load_camp" else 192, 229 + 30 * index) + for index, name in enumerate(MAIN_MENU_ITEMS)} +MODS_ITEM_PLACE = MAIN_MENU_ITEM_PLACES["mods"] +HALOCUSTOMS_ITEM_PLACE = MAIN_MENU_ITEM_PLACES["halocustoms"] +FRIENDS_ITEM_PLACE = MAIN_MENU_ITEM_PLACES["friends"] +QUIT_ITEM_PLACE = MAIN_MENU_ITEM_PLACES["quit_game"] YES_NO = [("YES", "true"), ("NO", "false")] ON_OFF = [("ON", "true"), ("OFF", "false")] # the multiplayer armor colors, in the profile's order (player_profile.c) @@ -400,7 +433,7 @@ _audio["categories"] = [ SEED_UPLOADS = (64, 128, 256, 512, 1024, 2048, 4096, 8192, 10240) _network = SCREENS["network_setup"] # (and the multiplayer HUD's rows, and streamer mode, screens of their own: -# Network Setup has room for nine places) +# Network Setup has room for ten places, 27 apart, the last above the help) _hud_settings = ("display.player_names", "display.player_name_scale", "display.scoreboard_team_layout", "display.scoreboard_background") _hud_rows = [row for row in _network["rows"] if row[1] in _hud_settings] @@ -417,7 +450,12 @@ _network["categories"] = [ "What the Server Browser lists and its order: the\nsame settings as its Filters screen."), ("DISCORD ACCOUNT:", "network_setup/discord_account", "Link a Discord account, which lets you join\nservers that block VPNs while you use one."), + # (the profile's HaloCustoms account: the main menu's HALOCUSTOMS screen, + # for the profile being edited, CATEGORY_SCREENS) + ("HALOCUSTOMS ACCOUNT:", "network_setup/halocustoms_account", + "Sign this profile in to its HaloCustoms account,\nwhich keeps its matches, rank and campaign kills."), ] +_network["spacing"] = 27 SCREENS["network_setup/map_sharing"] = { "screen": "map_sharing_settings_screen", "header": _network["header"], "spacing": 30, "rows": [ @@ -532,7 +570,8 @@ SCREENS["network_setup/streamer_mode"] = { SCREENS["network_setup/in_game"] = { "screen": "in_game_network_settings_screen", "header": _network["header"], "spacing": 30, "platform_places": True, "rows": [], - "categories": [category for category in _network["categories"] if category[1] != "network_setup/server_browser"], + "categories": [category for category in _network["categories"] + if category[1] not in ("network_setup/server_browser", "network_setup/halocustoms_account")], } # Controls Setup: the keyboard and mouse's actions, in groups (the order of @@ -540,6 +579,14 @@ SCREENS["network_setup/in_game"] = { CONTROL_GROUPS = ["MOVEMENT", "WEAPONS", "ACTIONS", "COMMUNICATION"] CONTROL_ROWS = 7 +# the categories that open a screen that is not a settings screen: the +# screen, and the function run first (Network Setup's HALOCUSTOMS ACCOUNT: +# the main menu's HALOCUSTOMS screen, for the profile being edited; the game +# names it to the screen, menu_functions.c's "halocustoms profile edited") +CATEGORY_SCREENS = { + "network_setup/halocustoms_account": (f"{HALOCUSTOMS}/halocustoms_screen", "halocustoms profile edited"), +} + # the settings whose rows have the wider spinner (a device's name) WIDE_SETTINGS = {"audio.output_device", "audio.input_device", "streamer.slurs", "streamer.swearing", "streamer.insults", "network.listings", "network.browser_maps", "network.browser_sort"} @@ -619,13 +666,20 @@ def _widget(name: str, pairs: list, inner: list) -> list: def _strings(name: str, texts: list) -> list: + """a string list: each text, or (text, platforms) for one only those + platforms have (a text with a variant for each platform is one string + on each)""" + items = [text if isinstance(text, tuple) else (text, None) for text in texts] return [f"\t", - *[f"\t\t" for text in texts], "\t"] + *[f"\t\t" for text, platform in items], + "\t"] -def _button(name: str, caption: int, handlers: list) -> list: +def _button(name: str, caption: int, handlers: list, strings: str = "strings/common_button_captions") -> list: + """a button of the button bar: its caption a string of the PC version's + captions, or of another list""" pairs = [("type", "text"), ("width", 128), ("height", 24), ("bitmap", "bitmaps/text_button_background"), - ("string_list", "strings/common_button_captions"), ("string_index", caption), ("font", "ui\\small_ui"), + ("string_list", strings), ("string_index", caption), ("font", "ui\\small_ui"), ("color", "#FFFFFFFF"), ("align", "center"), ("text_y", 2)] return _widget(name, pairs, handlers + ['']) @@ -752,7 +806,11 @@ def _setting_screen(folder: str, spec: dict) -> list: for index, (label, category_folder, _, *category_platform) in enumerate(spec.get("categories", ())): key = category_folder.rsplit("/", 1)[-1] row = f"{base}/op_{key}" - target = f"{PE}/{category_folder}/{SCREENS[category_folder]['screen']}" + if category_folder in CATEGORY_SCREENS: + target, run = CATEGORY_SCREENS[category_folder] + run = f' run="{run}"' + else: + target, run = f"{PE}/{category_folder}/{SCREENS[category_folder]['screen']}", "" platform = category_platform[0] if category_platform else None if spec.get("platform_places"): members = platform.split() if platform else list(places) @@ -763,7 +821,7 @@ def _setting_screen(folder: str, spec: dict) -> list: rows.append((row, platform, place + 1 + index)) extra += _widget(row, [("width", 512), ("height", 28), ("flags", "pass_unhandled_to_focused_child"), ("bitmap", "bitmaps/option_bkds"), ("color", "#FF2896FF"), ("platform", platform)], - [f'', f'', + [f'', f'', '', f'']) extra += _widget(f"{base}/{key}_label", @@ -986,10 +1044,10 @@ STRING_INSERTS = { # menu_functions.c's filters_help) f"{MT}/join_game/filters/cap_filter_options": [(11, [ "Show servers on maps you do not have if their\\nhost shares them: they download as you join."])], - # (the gametype editor's OPENCE OPTIONS' words, after the PC version's: + # (the gametype editor's HALOCUSTOMS OPTIONS' words, after the PC version's: # menu_functions.c's playlist_edit_description) f"{PLAYLIST_EDIT}/playlist_edit_list_extended_dsc_text": [(8, [ - "Choose the rules of OpenCE's\\ngametypes: SWAT, Infection,\\nGun Game and Hardcore.\\nThis gametype:"])], + "Choose the rules of the\\nHaloCustoms gametypes: SWAT,\\nInfection, Gun Game and\\nHardcore.\\nThis gametype:"])], f"{PLAYER_EDIT}/player_options_labels": [(8, ["AIM ASSIST:", "SHIELD EFFECTS:"])], f"{PLAYER_EDIT}/cap_player_options": [(28, AIM_ASSIST_HELP + SHIELD_EFFECTS_HELP)], # (Teamplay Options' voice chat and vote kick rows, after its own: @@ -1037,8 +1095,9 @@ STRING_OVERRIDES.update({ "Play the campaign with a\\nfriend in split screen:\\nplayer 2 on a gamepad.", "Set all the attributes for your \\nmultiplayer gametypes and\\nkeep them for future use.", ], - # (and the port's OPENCE: OpenCE's gametypes, port/linux/game/game_modes.c) - "main_menu/gametype_select/var_gametype_banks": ["STANDARD", "OPENCE", "CUSTOM"], + # (and the port's HALOCUSTOMS: OpenCE's gametypes, port/linux/game/game_modes.c, + # under HaloCustoms' name) + "main_menu/gametype_select/var_gametype_banks": ["STANDARD", "HALOCUSTOMS", "CUSTOM"], # (the PC's weapon sets, then the Xbox's NO GRENADES: menu_functions.c's # gametype_options maps them to the engine's) "main_menu/settings_select/multiplayer_setup/item_options_edit/item_options_labels": ["INFINITE GRENADES:", "WEAPON SET:", "VEHICLE SET:", "STARTING EQUIPMENT:", @@ -1107,9 +1166,25 @@ WIDGET_PATCHES = { f"{MT}/connected/connected_map_select_wrapper": {"set": { "flags": "pass_unhandled_to_focused_child pause_game main_menu_if_no_history tag_controller_index force_handle_mouse", }}, - # (the main menu's MODS, before Quit: _mods) + # (the main menu's HaloCustoms panel, and its welcome: the panel's update + # asks for it with the right thumb's press, which nothing else on the main + # menu takes, as a screen cannot open while the main menu draws. Its + # handlers are the PC version's and that one: _halocustoms_panel) + "main_menu/main_menu": { + "handlers": ['', + '', + '', + ''], + "children": [f'']}, + # (the main menu's MODS, HALOCUSTOMS and FRIENDS, before Quit: _mods, + # _halocustoms, _halocustoms_friends) "main_menu/main_menu_select_list": {"insert_before": {"main_menu/main_menu_item_quit_game": [ - f'']}}, + f'', + f'', + f'']}}, f"{PLAYER_EDIT}/player_options_menu": {"insert_before": { f"{PLAYER_EDIT}/player_button_bar": [ f'', @@ -1263,6 +1338,7 @@ BITMAP_FRAMES = { # are (port/assets/menus/port_svg): bitmap name, text TITLES = { "main_menu/mods/header_mods": "MODS", + f"{HALOCUSTOMS}/header_halocustoms": "HALOCUSTOMS ACCOUNT", f"{MT}/join_game/header_server_browser": "SERVER BROWSER", f"{MT}/join_game/header_direct_link": "DIRECT LINK", f"{MT}/join_game/header_password": "PASSWORD", @@ -1432,6 +1508,17 @@ BROWSER_WORDS = [ "The host refused: too many players from your address", # (the line while Refresh's pings come in) "Refreshing...", + # (a server's refusals that concern the HaloCustoms account: player_link.c's + # player_link_refusal_choice. A pass asked for after the join hinted at the + # account, which could not be shown; its pass refused, in use, too new; + # and a Discord pass the server takes refused, by a server that takes only + # the site's accounts. A pass asked for with no account offered is the + # VPN's words above: such a server cannot be told from one that blocks VPNs) + "The host asked for your HaloCustoms account: sign in again", + "The host refused your HaloCustoms account: sign in again", + "The host refused: your HaloCustoms account is already playing", + "The host refused: your HaloCustoms account is too new", + "The host takes only HaloCustoms accounts: sign in to join", ] @@ -2209,7 +2296,8 @@ def _player_options_extras() -> list: def _opence_options() -> list: - """the gametype editor's OPENCE OPTIONS: its row, after the editor's own + """the gametype editor's HALOCUSTOMS OPTIONS (OpenCE's gametypes' options, + under HaloCustoms' name): its row, after the editor's own (as they are laid out), and its screen, as the gametype's options' are (_setup_option_screen), its spinners the gametype's (menu_functions.c's gametype_options), its header its words (no picture of its own)""" @@ -2217,10 +2305,10 @@ def _opence_options() -> list: lines = [line.replace("port setup options init", "mp profile init opence options") .replace("port setup options save", "mp profile set opence options") .replace(f'bitmap="{OPENCE_EDIT}/header_opence"', - 'type="text" text="OPENCE OPTIONS" font="ui\\large_ui" color="#FFFFFFFF" text_x="13" text_y="16"') + 'type="text" text="HALOCUSTOMS OPTIONS" font="ui\\large_ui" color="#FFFFFFFF" text_x="13" text_y="16"') for line in lines] lines += _widget(OPENCE_ITEM, [("type", "text"), ("left", 51), ("top", 309), ("width", 232), ("height", 32), - ("bitmap", "bitmaps/list_item_bkd"), ("text", "OPENCE OPTIONS"), + ("bitmap", "bitmaps/list_item_bkd"), ("text", "HALOCUSTOMS OPTIONS"), ("font", "ui\\large_ui"), ("color", "#FF2896FF"), ("text_x", 13), ("text_y", 5)], [f'', f'', @@ -2300,6 +2388,23 @@ def multiplayer_files() -> dict: OPENCE_EDIT.replace("/", ".") + ".port.xml": head + _opence_options() + ["", ""], MODS.replace("/", ".") + ".xml": [head[0], "", head[2]] + _mods() + ["", ""], + HALOCUSTOMS.replace("/", ".") + ".xml": [head[0], "", head[2]] + _halocustoms() + + ["", ""], + HALOCUSTOMS_FRIENDS.replace("/", ".") + ".xml": [head[0], "", head[2]] + + _halocustoms_friends() + ["", ""], + HALOCUSTOMS_PANEL.replace("/", ".") + ".xml": [head[0], "", head[2]] + + _halocustoms_panel() + ["", ""], + HALOCUSTOMS_WELCOME.replace("/", ".") + ".xml": [head[0], "", head[2]] + + _halocustoms_welcome() + ["", ""], + "main_menu.halocustoms_bitmaps.xml": [head[0], "", + head[2]] + _halocustoms_bitmaps() + ["", ""], } @@ -2320,6 +2425,14 @@ MENU_ITEM_TOP, MENU_ITEM_SCALE, MENU_ITEM_MIDDLE = 6.57, 0.9037, 130.0 # before the second, fitted (least squares) to the 53 pairs the redraws' items # and headers have (D-I, of the main menu's Credits, gives D's right side) MENU_ITEM_SPACES = {"MO": 3.17, "OD": 2.8, "DS": 2.3} +# (HALOCUSTOMS': LO, CU, US, ST and TO as the redraws space them (the mean +# where they differ), the others fitted the same way to the pairs of the +# redraws copied to svg/, which has no Credits) +MENU_ITEM_SPACES.update({"HA": 1.65, "AL": 0.75, "LO": -0.17, "OC": 1.87, "CU": 3.55, "US": 2.78, "ST": 1.16, + "TO": 0.74, "OM": 3.58, "MS": 2.75}) +# (FRIENDS': ND as the redraws space it, in Indicator Options' header, the +# others fitted as HALOCUSTOMS' were: to every pair of every redraw in svg/) +MENU_ITEM_SPACES.update({"FR": 2.77, "RI": 3.12, "IE": 3.44, "EN": 4.18, "ND": 4.27}) # the mods' status icons (port/assets/icons/lucide): a mod out of the order it # asks for, and one that cannot be played (another mod says it cannot be # played with it, or this build cannot play it): menu_functions.c's @@ -2466,3 +2579,365 @@ def _mods() -> list: lines += _button(f"{base}/button_ok", 1, ['', '']) return lines + + +# ---------- the HaloCustoms account (port/linux/src/halocustoms_account.c, +# and the matches of port/linux/game/halocustoms_stats.c): the main menu's +# HALOCUSTOMS, which opens player 1's profile's account on the HaloCustoms +# site (and Network Setup's HALOCUSTOMS ACCOUNT the edited profile's): +# signing in (a device code, and the site in the browser), the rank and +# stats the site keeps (multiplayer, campaign and co-op), and the matches not +# yet reported (menu_functions.c's "halocustoms" functions, which fill in the +# values by their leaf names). The web build has no token, key file or +# upload of its own (the site's page reports its matches): its screen shows +# who is signed in at the site that serves the page + +# the screen's rows: (key, label, help, platforms); the game sets each row's +# _value. A help is one text, or one for each of its platforms. The +# rows of what only the game itself keeps are not on the web's screen, and +# come last (each row is in its own place). No line of a help is wider than +# the settings screens' widest (in Overpass's widths, port/assets/fonts: the +# help is as wide as theirs) +HALOCUSTOMS_ROWS = [ + ("profile", "PROFILE:", + "The player profile whose account this is: each\nprofile signs in to an account of its own.", None), + ("account", "ACCOUNT:", + {"desktop android": "SIGN IN shows a code and opens the site in your\n" + "browser: confirm the code there to sign in.", + "web": "The site that serves this page signs you in:\nSIGN IN opens it in a new tab; the game stays here."}, + None), + ("rank", "RANK:", "Your rank: tier and level. Its tag shows\nbefore your player name.", None), + ("experience", "EXPERIENCE:", "Your XP in this tier, and what the next\nlevel needs.", None), + ("kills", "KILLS / DEATHS:", "Your kills and deaths over every reported\nmultiplayer match.", None), + ("games", "MATCHES / WINS:", "Matches reported, and those you won.", None), + ("streak", "BEST STREAK:", "The most kills in a row in one match.", None), + ("campaign", "CAMPAIGN:", "Kills and deaths in reported campaign missions\nplayed alone, and the missions completed.", + None), + ("coop", "CO-OP:", "Kills and deaths in reported co-op missions.", None), + # (the longest label: it must end before the values' column at 206, whatever the map's font makes of it) + ("pending", "UNSENT MATCHES:", + "Finished matches and missions not yet sent to\nthe site: they go when you are signed in and online.", + "desktop android"), + ("site", "SITE:", "The site this game reports to (halocustoms.url\nin config.toml).", "desktop android"), +] +# (eleven places: a 25px step, as Gamepads' (SCREENS["gamepad_setup"]), puts +# the last at 323, and the help's two lines below it, clear of the buttons at +# 414) +HALOCUSTOMS_SPACING, HALOCUSTOMS_HELP_TOP, HALOCUSTOMS_HELP_HEIGHT = 25, 358, 54 +# the sign-in button's captions: the game shows one by the account's state +# (menu_functions.c, which sets the string's number). A caption given as the +# widget's own text could not change: the engine copies a text box's text +# from its definition's string list each time it draws it +# (source/interface/ui_widget.c) +HALOCUSTOMS_BUTTON_CAPTIONS = ["SIGN IN", "CANCEL", "SIGN OUT", "REFRESH"] + + +def _halocustoms() -> list: + """the main menu's HALOCUSTOMS and its screen, in the settings screens' + style: rows of a label and a value the game fills in ("halocustoms + account update"), the help of the row chosen, and the buttons, placed as + the Mods screen's: the sign-in button (SIGN IN, or the caption of + HALOCUSTOMS_BUTTON_CAPTIONS the game chooses), OPEN SITE, FRIENDS + (_halocustoms_friends) and BACK. It + shows player 1's profile's account, or the one Network Setup's + HALOCUSTOMS ACCOUNT names (CATEGORY_SCREENS) until it closes""" + base = HALOCUSTOMS + lines = _widget("main_menu/main_menu_item_halocustoms", + [("type", "text"), ("width", 256), ("height", 33), ("bitmap", "main_menu/menu_halocustoms")], + [f'', + f'', + '']) + lines += _widget(f"{base}/halocustoms_screen", [("width", 640), ("height", 480), + ("flags", "pass_unhandled_to_focused_child pause_game"), + ("bitmap", "bitmaps/gradient")], + ['', '', + f'', f'']) + # (the settings screens' header, the width of the screen: no panel on the + # right, and a long title) + lines += _widget(f"{base}/header_halocustoms", [("controller", 1), ("left", 35), ("top", 11), ("width", 605), + ("height", 59), ("bitmap", f"{base}/header_halocustoms")], []) + lines += _widget(f"{base}/help", [("type", "text"), ("controller", 1), ("left", 68), ("top", HALOCUSTOMS_HELP_TOP), + ("width", 482), ("height", HALOCUSTOMS_HELP_HEIGHT), + ("string_list", f"{base}/help_strings"), + ("font", "ui\\large_ui"), ("color", "#FFFFFFFF")], []) + rows = [f'' + for index, (key, _, _, platform) in enumerate(HALOCUSTOMS_ROWS)] + # (closed, it shows player 1's profile's account again the next time: + # Network Setup's HALOCUSTOMS ACCOUNT names another first) + lines += _widget(f"{base}/options_menu", + [("type", "column_list"), ("width", 640), ("height", 480), + ("flags", "pass_unhandled_to_focused_child up_down_tabs_children"), + ("description", f"{base}/help")], + ['', '', + '', + '', *rows, + f'']) + # (each row as the settings screens' rows of a value the game sets, the + # value left-aligned and wide: the game writes long ones, two counts and + # a ratio, a site's host name. The labels have 206: the longest, MATCHES + # TO REPORT:, is 188 wide after its 13 of text_x, in Overpass's widths + # with capitals 12 units tall. The account's row has a short label and + # the longest value) + for index, (key, *_) in enumerate(HALOCUSTOMS_ROWS): + # (every value in one column, wide enough for the longest the game writes) + label_width, value_x, value_width = 206, 206, 296 + lines += _widget(f"{base}/op_{key}", [("width", 512), ("height", 28), ("flags", "pass_unhandled_to_focused_child"), + ("bitmap", "bitmaps/option_bkds"), ("color", "#FF2896FF")], + [f'', + f'']) + lines += _widget(f"{base}/{key}_label", + [("type", "text"), ("controller", 1), ("width", label_width), ("height", 22), + ("string_list", f"{base}/labels"), ("string_index", index), ("font", "ui\\large_ui"), + ("color", "#FF2896FF"), ("text_x", 13), ("text_y", 4)], []) + lines += _widget(f"{base}/{key}_value", + [("type", "text"), ("controller", 1), ("width", value_width), ("height", 22), + ("font", "ui\\large_ui"), ("color", "#FF2896FF"), ("align", "left"), ("text_y", 1), + ("text_flags", "no_focus_test")], []) + lines += _widget(f"{base}/button_bar", + [("type", "column_list"), ("width", 640), ("height", 28), + ("flags", "pass_unhandled_to_focused_child left_right_tabs_items")], + [f'', + f'', + f'', + f'']) + lines += _button(f"{base}/button_link", 0, ['', + ''], + strings=f"{base}/button_captions") + # (BACK, the common captions' own word, where the settings screens have + # Cancel: nothing is changed here to cancel, and while a sign-in waits the + # first button is CANCEL, which is the sign-in's) + lines += _button(f"{base}/button_back", 0, ['', + '']) + lines += _text_button(f"{base}/button_site", "OPEN SITE", ['', + '']) + # (the FRIENDS screen: _halocustoms_friends) + lines += _text_button(f"{base}/button_friends", "FRIENDS", + [f'', + f'']) + lines += _strings(f"{base}/button_captions", HALOCUSTOMS_BUTTON_CAPTIONS) + lines += _strings(f"{base}/labels", [label for _, label, *_ in HALOCUSTOMS_ROWS]) + # (the help of the row whose label is string n is n + 1: the buttons' is + # 0; a row's help for each of its platforms is one string on each) + helps = [""] + for _, _, help_text, _ in HALOCUSTOMS_ROWS: + variants = help_text.items() if isinstance(help_text, dict) else [(None, help_text)] + helps += [(variant.replace("\n", "\\n"), platform) for platform, variant in variants] + lines += _strings(f"{base}/help_strings", helps) + return lines + + +# ---------- FRIENDS, the main menu's and the HaloCustoms screen's +# (menu_functions.c's "halocustoms friends" functions, on +# port/linux/src/halocustoms_friends.h): player 1's +# profile's friends at the site, those playing first, each with what they are +# doing (a public game's name and map), a friend's public game joined as the +# server browser joins one; the requests to answer, accepted or declined; and +# whether the game tells the site it is online. The game fills the rows and +# the title, and chooses the help's string and the buttons' captions + +# the rows (friend_row_1 ...), 25 apart from 73 as the HALOCUSTOMS screen's, +# the last at 298, and the help's two lines below them +HALOCUSTOMS_FRIENDS_ROWS = 10 +# the title's words: the friends' view's and the requests' +HALOCUSTOMS_FRIENDS_TITLES = ["FRIENDS", "FRIEND REQUESTS"] +# the buttons' captions (menu_functions.c's _friends_caption_*, in order): +# the first button JOIN or ACCEPT, the second MORE (the next rows), REFRESH or +# DECLINE, the third the other view, the fourth whether the game tells the +# site it is online +HALOCUSTOMS_FRIENDS_CAPTIONS = ["JOIN", "ACCEPT", "MORE", "REFRESH", "DECLINE", "REQUESTS", "FRIENDS", + "APPEAR OFFLINE", "APPEAR ONLINE"] +# the help's lines (menu_functions.c's _friends_help_*, in order), the first +# empty; none wider than the settings screens' widest. In the site's words +# (opence-hub's docs/STYLE.md): sentence case, "server", "match", "mission", +# "player name"; the game's own buttons in capitals +HALOCUSTOMS_FRIENDS_HELPS = [ + "", + "Choose a friend on a server and press JOIN.\nAdd friends on the site.", + "Sign in on the HALOCUSTOMS screen to see\nyour friends here.", + "Getting your friends from the site...", + "The site didn't answer, so the list may be\nout of date. It's checked every 30 seconds.", + "No friends yet. Add them by their player\nnames on the site's Friends page.", + "That server has a password. Join it from\nthe server browser, which asks for it.", + "That server runs another version of the\ngame, so you can't join it.", + "Your friend isn't on a public server. Private\nand LAN matches and missions can't be joined.", + "ACCEPT or DECLINE the request you choose.\nThe sender isn't told when you decline.", + "You have no requests waiting for an answer.", + "Accepted. You're friends now.", + "Declined.", + "One of you has the most friends allowed.\nThe request is still waiting.", + "The site didn't take your answer. Try again.", + "You appear offline, so the game tells the\nsite nothing. APPEAR ONLINE shows you again.", + "Internet play is off (network.online in\nconfig.toml), so you can't join a server.", + "Verify your email on the site to answer\nfriend requests.", + "Verify your email on the site to see your\nfriends here.", +] + + +def _halocustoms_friends() -> list: + """the main menu's FRIENDS and the FRIENDS screen, which it and the + HALOCUSTOMS screen's FRIENDS open, in the HALOCUSTOMS screen's style: a + title the game words (a text: no header picture of its own), the rows (a + name on the left, what that friend is doing, or that the request waits, + on the right), the help of the row chosen, and the buttons: the first + (JOIN, or ACCEPT), the second (MORE, REFRESH or DECLINE), the other view + (REQUESTS or FRIENDS), APPEAR OFFLINE or ONLINE, and BACK""" + base = HALOCUSTOMS_FRIENDS + lines = _widget("main_menu/main_menu_item_friends", + [("type", "text"), ("width", 256), ("height", 33), ("bitmap", "main_menu/menu_friends")], + [f'', + f'', + '']) + lines += _strings(f"{base}/titles", HALOCUSTOMS_FRIENDS_TITLES) + lines += _strings(f"{base}/button_captions", HALOCUSTOMS_FRIENDS_CAPTIONS) + lines += _strings(f"{base}/help_strings", [text.replace("\n", "\\n") for text in HALOCUSTOMS_FRIENDS_HELPS]) + lines += _widget(f"{base}/friends_screen", [("width", 640), ("height", 480), + ("flags", "pass_unhandled_to_focused_child pause_game"), + ("bitmap", "bitmaps/gradient")], + ['', '', + f'', f'']) + # (where the headers' titles are: a text of the large font, the menus' blue) + lines += _widget(f"{base}/title", [("type", "text"), ("controller", 1), ("left", 54), ("top", 33), + ("width", 532), ("height", 30), ("string_list", f"{base}/titles"), + ("font", "ui\\large_ui"), ("color", "#FF2896FF"), + ("text_flags", "no_focus_test")], []) + lines += _widget(f"{base}/help", [("type", "text"), ("controller", 1), ("left", 68), ("top", HALOCUSTOMS_HELP_TOP), + ("width", 482), ("height", HALOCUSTOMS_HELP_HEIGHT), + ("string_list", f"{base}/help_strings"), ("font", "ui\\large_ui"), + ("color", "#FFFFFFFF")], []) + rows = [f'' + for index in range(HALOCUSTOMS_FRIENDS_ROWS)] + lines += _widget(f"{base}/friends_list", + [("type", "column_list"), ("width", 640), ("height", 480), + ("flags", "pass_unhandled_to_focused_child up_down_tabs_children"), + ("description", f"{base}/help")], + ['', + '', + '', *rows, + f'']) + # (each row as the HALOCUSTOMS screen's: a name, and what it is doing, in + # one column wide enough for a game's name and map. A on a row is the + # first button's: JOIN its game, or ACCEPT its request) + for index in range(HALOCUSTOMS_FRIENDS_ROWS): + row = f"{base}/friend_row_{index + 1}" + lines += _widget(row, [("width", 512), ("height", 28), ("flags", "pass_unhandled_to_focused_child"), + ("bitmap", "bitmaps/option_bkds"), ("color", "#FF2896FF")], + ['', + '', + '', + f'', f'']) + lines += _widget(f"{row}_name", [("type", "text"), ("controller", 1), ("width", 206), ("height", 22), + ("font", "ui\\large_ui"), ("color", "#FF2896FF"), ("text_x", 13), + ("text_y", 4), ("text_flags", "no_focus_test")], []) + lines += _widget(f"{row}_status", [("type", "text"), ("controller", 1), ("width", 296), ("height", 22), + ("font", "ui\\large_ui"), ("color", "#FF2896FF"), ("align", "left"), + ("text_y", 1), ("text_flags", "no_focus_test")], []) + lines += _widget(f"{base}/button_bar", + [("type", "column_list"), ("width", 640), ("height", 28), + ("flags", "pass_unhandled_to_focused_child left_right_tabs_items")], + [f'', + f'', + f'', + f'', + f'']) + for key, run in (("action", "halocustoms friends action"), ("second", "halocustoms friends second"), + ("view", "halocustoms friends view"), ("presence", "halocustoms presence toggle")): + lines += _button(f"{base}/button_{key}", 0, [f'', + f''], + strings=f"{base}/button_captions") + lines += _button(f"{base}/button_back", 0, ['', + '']) + return lines + +# ---------- the main menu's HaloCustoms account panel and welcome +# (menu_functions.c's "halocustoms panel update" and "halocustoms welcome" +# functions, on port/linux/src/halocustoms_account.c): player 1's profile's +# account, as the HALOCUSTOMS screen has it, in a box left of the items; and, +# once a run while that profile is signed out, a dialog that offers CREATE +# ACCOUNT, SIGN IN or PLAY AS GUEST (halocustoms.welcome) + +# the panel's words (menu_functions.c's _halocustoms_panel_word_*, in this +# order): its title; signed out, a line that says so (or that the profile +# plays as a guest) and one that invites a sign-in through the main menu's +# HALOCUSTOMS; a sign-in's states; the words of the lines of a member's. Each +# within the box's 160 units (in Overpass's widths, port/assets/fonts) +HALOCUSTOMS_PANEL_WORDS = ["HALOCUSTOMS", "SIGN IN: HALOCUSTOMS", "NOT SIGNED IN", "PLAYING AS A GUEST", + "CONNECTING...", "SIGNING IN...", "CODE", "OFFLINE", "SIGNED OUT BY THE SITE", "UNRANKED", + "MAX RANK", "XP", "KILLS", "DEATHS", "MATCHES", "WINS", "K/D", "FRIENDS ONLINE", + "FRIEND ONLINE"] +# its lines (line_0 ...: the name, the rank, the experience, the kills, the +# deaths, the matches, the wins, the kills for each death and the friends +# online), 14 apart below the title: a count of its own on each, which keeps +# the lines within the box's 160 units for the counts of most members (a +# longer text is cut at the box's edge) +HALOCUSTOMS_PANEL_LINES = 9 +# the welcome's words: its title, its line, and its buttons' captions +HALOCUSTOMS_WELCOME_WORDS = ["HALOCUSTOMS", "Sign in to keep your\\nkills, matches and rank\\non HaloCustoms.", + "CREATE ACCOUNT", "SIGN IN", "PLAY AS GUEST"] + + +def _halocustoms_panel() -> list: + """the panel: a dark box (the dialogs' dim) with the title and the lines + the game fills in ("halocustoms panel update", on the box: it also asks + for the welcome), no item of its own to take the focus""" + base = HALOCUSTOMS_PANEL + width, height = HALOCUSTOMS_PANEL_SIZE + lines = _strings(f"{base}/words", HALOCUSTOMS_PANEL_WORDS) + children = [f''] + children += [f'' + for index in range(HALOCUSTOMS_PANEL_LINES)] + lines += _widget(f"{base}/panel", [("controller", 1), ("width", width), ("height", height), + ("bitmap", HALOCUSTOMS_PANEL_BITMAP)], + ['', *children]) + lines += _widget(f"{base}/title", [("type", "text"), ("controller", 1), ("width", width - 16), ("height", 16), + ("string_list", f"{base}/words"), ("font", "ui\\small_ui"), + ("color", "#FF2896FF"), ("text_flags", "no_focus_test")], []) + for index in range(HALOCUSTOMS_PANEL_LINES): + lines += _widget(f"{base}/line_{index}", [("type", "text"), ("controller", 1), ("width", width - 16), + ("height", 15), ("font", "ui\\small_ui"), ("color", "#FFCCCCCC"), + ("text_flags", "no_focus_test")], []) + return lines + + +def _halocustoms_welcome() -> list: + """the welcome, as the main menu's Quit dialog (main_menu.quit_select.xml): + the alert's box, its title at the top left and its line in the middle, + then CREATE ACCOUNT and SIGN IN side by side as Quit's two buttons, and + PLAY AS GUEST below them in the box's bottom band. It keeps no history: B + goes back to the main menu, and so does the HALOCUSTOMS screen that CREATE + ACCOUNT and SIGN IN open, with the sign-in's code""" + base = HALOCUSTOMS_WELCOME + words = f"{base}/words" + account = f"{HALOCUSTOMS}/halocustoms_screen" + lines = _strings(words, HALOCUSTOMS_WELCOME_WORDS) + lines += _widget(f"{base}/welcome_screen", [("controller", 1), ("width", 640), ("height", 480), + ("flags", "pass_unhandled_to_focused_child no_history")], + ['', '', + f'', f'', + f'']) + lines += _widget(f"{base}/box", [("type", "text"), ("controller", 1), ("left", 180), ("top", 160), ("width", 280), + ("height", 159), ("bitmap", HALOCUSTOMS_WELCOME_BITMAP), ("string_list", words), + ("string_index", 1), ("font", "ui\\large_ui"), ("color", "#FF2896FF"), + ("align", "center"), ("text_x", -10), ("text_y", 35)], []) + lines += _widget(f"{base}/header", [("type", "text"), ("controller", 1), ("width", 250), ("height", 20), + ("string_list", words), ("font", "ui\\large_ui"), ("color", "#FF2896FF")], []) + lines += _widget(f"{base}/button_bar", [("type", "column_list"), ("width", 256), ("height", 58), + ("flags", "pass_unhandled_to_focused_child up_down_tabs_items " + "left_right_tabs_items")], + [f'', f'', + f'']) + for key, caption, run, after in (("create", 2, "halocustoms welcome create", f'open="{account}"'), + ("sign_in", 3, "halocustoms welcome sign in", f'open="{account}"'), + ("guest", 4, "halocustoms welcome guest", 'back="true"')): + lines += _button(f"{base}/button_{key}", caption, [f'', + f''], strings=words) + return lines + + +def _halocustoms_bitmaps() -> list: + """the panel's and the welcome's pictures: the stock ones (the dialogs' + dim, the alert's box), under names of their own, so that a menu overlay + themes them by replacing this file alone""" + return [f'\t', + '\t\t', "\t", + f'\t', + '\t\t', "\t"] diff --git a/tools/server_sim.py b/tools/server_sim.py index 0c2bdeb..18aa9b0 100644 --- a/tools/server_sim.py +++ b/tools/server_sim.py @@ -72,6 +72,9 @@ WS_CONNECTIONS = 8 WS_FRAME = 64 * 1024 WS_MESSAGE = 256 * 1024 BUILD = "build-206" +# (the build as the server names it to its owner: port/linux/src/server_mode.c's +# server_mode_label) +LABEL = "HaloCustoms local build (based on OpenCE build-343)" COMMAND_LINE = 511 ROTATION_TEXT = 4096 ENTRY_TEXT = 255 @@ -79,10 +82,12 @@ NOTICE_SIZE = 128 NETWORK_VERSION = 38 # the RCON's hello's capabilities, and the status's (server_http.c's server_http_capabilities) CAPABILITIES = ["events", "status", "rotation", "settings", "bans", "owner_web", "ban_address", "stop", - "event_addresses", "identity", "vpn", "link"] + "event_addresses", "identity", "vpn", "link", "account", "results"] # the VPN policy's netlist (server_netlist.c): a master's list the simulator holds, and the addresses it lists VPN_LISTED = ("198.51.100.0", "198.51.100.255") -VPN_REASONS = ("vpn_link_required", "vpn_blocked", "vpn_link_invalid", "vpn_link_too_new", "vpn_link_in_use") +# (and the link masters' accounts', server.link_required: network_link.c) +VPN_REASONS = ("vpn_link_required", "vpn_blocked", "vpn_link_invalid", "vpn_link_too_new", "vpn_link_in_use", + "account_required", "account_invalid", "account_in_use") PUBLISHER_KEY_ID = "3f9a2c7d1e5b8a04" DRAINS = ("now", "game", "empty", "cancel") @@ -345,7 +350,7 @@ def default_settings(): "into each other.", "HALO_NET_COOP_PLAYER_COLLISIONS"), _setting("network.public_address", "string", "", "Hosting: the internet address joiners reach this machine " "at, as host[:port].", "HALO_NET_PUBLIC_ADDRESS"), - _setting("server.name", "string", "OpenCE Server", "The game's name in the lobby and the server browser: 15 " + _setting("server.name", "string", "HaloCustoms", "The game's name in the lobby and the server browser: 15 " "characters of printable ASCII.", "HALO_SERVER_NAME"), _setting("server.max_players", "integer", 16, "The most players (2 to 127).", "HALO_SERVER_MAX_PLAYERS", low=2, high=127), @@ -385,6 +390,9 @@ def default_settings(): "HALO_SERVER_LOG_ADDRESSES"), _setting("server.status_file", "string", "", "A file the status (as /api/status) is written to every 2 " "seconds, for a panel without HTTP; empty for none.", "HALO_SERVER_STATUS_FILE"), + _setting("server.results_file", "string", "", "A file each finished multiplayer game adds a line of JSON to " + "(every player's tallies, with the link masters' accounts that count), for a site; empty for none.", + "HALO_SERVER_RESULTS_FILE"), _setting("server.auto_update", "string", "install", "\"off\"; \"check\" logs a newer release; \"install\" " "installs it at once and restarts when empty (a new network version: after the game).", "HALO_SERVER_AUTO_UPDATE", choices=["off", "check", "install"]), @@ -418,6 +426,10 @@ def default_settings(): "HALO_SERVER_LINK_MIN_AGE_DAYS", low=0, high=3650), _setting("server.link_one_seat", "boolean", True, "Refuse a second machine of a linked Discord account " "already in the game.", "HALO_SERVER_LINK_ONE_SEAT"), + _setting("server.link_ask", "boolean", True, "Ask a machine that says it holds a link master's pass for it " + "as it joins, once and quietly.", "HALO_SERVER_LINK_ASK"), + _setting("server.link_required", "boolean", False, "Let in only machines that show the account of a link " + "master's that counts (a ranked server).", "HALO_SERVER_LINK_REQUIRED"), _setting("server.update_check_minutes", "integer", 30, "The minutes between checks for a newer release.", "HALO_SERVER_UPDATE_CHECK_MINUTES", low=5, high=1440), _setting("server.update_game_minutes", "integer", 30, "After a new network version's install, the minutes a " @@ -446,7 +458,8 @@ def default_settings(): # what the owner's console alone changes (server_commands.c's owner_settings) -OWNER_SETTINGS = {"server.status_file", "server.auto_update", "server.log_addresses"} +OWNER_SETTINGS = {"server.status_file", "server.results_file", "server.auto_update", "server.log_addresses", + "server.link_required"} PLAYER_NAMES = ["Milenko", "Kestrel", "Wren", "Odin", "Harlow", "Juno", "Vega", "Corvid", "Tamsin", "Brask", "Ilse", "Rook", "Sable", "Quill", "Fenwick", "Marlo"] @@ -720,6 +733,10 @@ class Player: self.discord_id = str(80351110224678912 + machine) self.discord_name = name.lower() self.discord_verified = machine % 2 == 0 + # (and a link master's account that counts, of every fourth machine's: credited to the machine's first + # player, server_status.c's account) + self.account = ({"master": "39f713d0a644253f", "id": str(1569683039846400042 + machine), "name": name} + if machine % 4 == 0 else None) # (its machine's build, as its game tells the host) self.client = {"build": 282, "flavor": "release", "version": "OpenCE Linux | build 282 (release)", "repository": "OpenCommunityEdition/OpenCE", @@ -734,7 +751,7 @@ class Player: "machine": self.machine, "joined": self.joined, "muted": self.muted, "client": self.client} if addresses: value.update(address=self.address, hwid=self.hwid, discord_id=self.discord_id, - discord_name=self.discord_name, discord_verified=self.discord_verified) + discord_name=self.discord_name, discord_verified=self.discord_verified, account=self.account) return value @@ -1087,7 +1104,7 @@ class Sim: return entry variant = variant or BUILTINS[base][2] else: - entry["rejected"] = f"no gametype is named {gametype} (a built-in's, OpenCE's, or gametypes/{gametype}.toml)" + entry["rejected"] = f"no gametype is named {gametype} (a built-in's, HaloCustoms', or gametypes/{gametype}.toml)" return entry engine, teams, _ = BUILTINS[base] entry["engine_base"], entry["variant"], entry["teams"] = base, variant, teams @@ -1464,7 +1481,7 @@ class Sim: return [json.dumps(status, separators=(",", ":"))] uptime = status["uptime"] lines = [f"{status['name']}: {status['state']}, up {uptime // 3600}h{uptime // 60 % 60:02d}m, " - f"{self.games_hosted} game{'' if self.games_hosted == 1 else 's'} played; OpenCE {BUILD}, network " + f"{self.games_hosted} game{'' if self.games_hosted == 1 else 's'} played; {LABEL}, network " f"version {NETWORK_VERSION}", f"game: {self.current['gametype']} on {self.current['map']}, {len(self.players)} of " f"{self.value('server.max_players')} players"] @@ -1523,7 +1540,7 @@ class Sim: + (" (set by its environment variable)" if setting["pinned"] else "") for setting in self.settings] def c_sv_version(self, line, words, role): - return [f"OpenCE {BUILD}, network version {NETWORK_VERSION}"] + return [f"{LABEL}, network version {NETWORK_VERSION}"] def c_sv_log(self, line, words, role): count = 20 diff --git a/tools/server_ui_tests/sim.test.js b/tools/server_ui_tests/sim.test.js index 5ef32d0..bcc1785 100644 --- a/tools/server_ui_tests/sim.test.js +++ b/tools/server_ui_tests/sim.test.js @@ -205,7 +205,7 @@ test('the RCON over the cookie: live, the status every 2 s, commands answered by await until(() => live.state === 'live'); const reply = await live.command('sv_status'); assert.ok(reply.ok); - assert.match(reply.data.lines[0], /^OpenCE Server: /); + assert.match(reply.data.lines[0], /^HaloCustoms: /); // (a command that failed: ok false and its lines, the first saying why; no error object) const refused = await live.command('no_such_command'); assert.strictEqual(refused.ok, false); @@ -242,7 +242,7 @@ test('faults: fragmented frames, replies over 1 MiB, a slow consumer, dropped so const reply = await live.command('sv_version'); assert.ok(reply.ok); assert.ok(JSON.stringify(reply.data).length > 1 << 20); - assert.match(reply.data.lines[0], /^OpenCE build-/); + assert.match(reply.data.lines[0], /^HaloCustoms .*\(based on OpenCE build-\d+\)/); live.stop(); const slow = await startSim(['--still', '--tick', '0.05', '--slow-consumer']); diff --git a/tools/test_egg.py b/tools/test_egg.py index 6f1a8e0..89c3cd3 100644 --- a/tools/test_egg.py +++ b/tools/test_egg.py @@ -7,6 +7,7 @@ that works without sudo). python -m pytest -q tools/test_egg.py """ +import hashlib import http.server import io import json @@ -72,8 +73,11 @@ def test_variables_are_the_servers_settings(): assert env in names, f"{env} is no setting's environment variable" for env in ("HALO_SERVER_NAME", "HALO_SERVER_ROTATION", "HALO_SERVER_MAX_PLAYERS", "HALO_SERVER_PASSWORD", "HALO_SERVER_PUBLIC", "HALO_SERVER_WEB_PORT", "HALO_SERVER_WEB_PASSWORD", "HALO_SERVER_PUBLISHER_KEY", - "OPENCE_RELEASE", "OPENCE_REPOSITORY"): + "OPENCE_RELEASE", "HALOCUSTOMS_RELEASES"): assert env in VARIABLES + # (the release comes from HaloCustoms' release feed, not OpenCE's GitHub) + assert VARIABLES["HALOCUSTOMS_RELEASES"]["default_value"].startswith("https://halocustoms.org/") + assert "OPENCE_REPOSITORY" not in VARIABLES and "github" not in EGG["scripts"]["installation"]["script"] # (the server's name fits the game's 15 characters) assert len(VARIABLES["HALO_SERVER_NAME"]["default_value"]) <= 15 # the web port is off unless the owner gives a second allocation @@ -182,7 +186,8 @@ def fake_release(build): info.external_attr = (0o100000 | mode) << 16 archive.writestr(info, text) add("halo", "#!/bin/sh\n" - f"case \"$1\" in --version) echo 'OpenCE build-{build} (network version 38)'; exit 0;; esac\n" + f"case \"$1\" in --version) echo 'HaloCustoms build-343-hc{build} (based on OpenCE build-343) " + f"(network version 38)'; exit 0;; esac\n" "exit 1\n", 0o644) add("halo-server", '#!/bin/sh\nexec "$(dirname "$0")/halo" --server "$@"\n', 0o755) add("libSDL3.so.0", "not a library") @@ -197,8 +202,8 @@ def fake_release(build): class Handler(http.server.BaseHTTPRequestHandler): - """GitHub, as the installation script asks it: releases/latest/download, - the API's releases/tags/, and releases/download/.""" + """HaloCustoms' release feed, as the installation script asks it: its + latest and tags/, and the zips its releases name.""" files = {} @@ -218,7 +223,7 @@ class Handler(http.server.BaseHTTPRequestHandler): @pytest.fixture(scope="module") -def github(): +def feed(): why = docker_works() if why: pytest.skip(why) @@ -231,15 +236,17 @@ def github(): server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) port = server.server_address[1] base = f"http://127.0.0.1:{port}" + latest = fake_release(300) Handler.files = { - "/owner/opence/releases/latest/download/halo-linux-release.zip": fake_release(300), - "/owner/opence/releases/download/build-299/halo-linux-release.zip": fake_release(299), - "/api/repos/owner/opence/releases/tags/build-299": json.dumps({"tag_name": "build-299", "assets": [ - {"name": "halo-windows-release.zip", "browser_download_url": f"{base}/nothing.zip"}, - {"name": "halo-linux-release.zip", - "browser_download_url": f"{base}/owner/opence/releases/download/build-299/halo-linux-release.zip"}, + "/updates/releases/download/build-300/halo-linux-release.zip": latest, + "/updates/releases/download/build-299/halo-linux-release.zip": fake_release(299), + "/updates/releases/latest": json.dumps({"tag_name": "build-300", "body": "Network version: 38\n", "assets": [ + {"name": "halo-windows-release.zip", "digest": "sha256:" + "00" * 32, + "browser_download_url": f"{base}/updates/releases/download/build-300/halo-windows-release.zip"}, + {"name": "halo-linux-release.zip", "size": len(latest), + "digest": "sha256:" + hashlib.sha256(latest).hexdigest(), + "browser_download_url": f"{base}/updates/releases/download/build-300/halo-linux-release.zip"}, ]}).encode(), - "/api/repos/owner/opence/releases/tags/build-298": json.dumps({"tag_name": "build-298", "assets": []}).encode(), } thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() @@ -247,25 +254,24 @@ def github(): server.shutdown() -def install(github, folder, release): +def install(feed, folder, release): """Runs install.sh as the panel does, into folder; its output.""" script = EGG["scripts"]["installation"]["script"] result = subprocess.run([ "docker", "run", "--rm", "-i", "--network", "host", "--user", f"{os.getuid()}:{os.getgid()}", "-v", f"{folder}:/mnt/server", - "-e", f"OPENCE_RELEASE={release}", "-e", "OPENCE_REPOSITORY=owner/opence", - "-e", f"OPENCE_GITHUB_URL={github}", "-e", f"OPENCE_GITHUB_API={github}/api", + "-e", f"OPENCE_RELEASE={release}", "-e", f"HALOCUSTOMS_RELEASES={feed}/updates/releases", INSTALLER_IMAGE, "bash", "-s"], input=script, capture_output=True, text=True, timeout=300) return result.stdout + result.stderr -def test_install_latest_then_reinstall_a_tag(github, tmp_path): +def test_install_latest_then_reinstall_a_tag(feed, tmp_path): server = tmp_path / "server" server.mkdir() - output = install(github, server, "latest") + output = install(feed, server, "latest") assert "Installation completed" in output, output - assert "OpenCE build-300 (network version 38)" in output, output + assert "HaloCustoms build-343-hc300 (based on OpenCE build-343) (network version 38)" in output, output assert "upload your maps/ folder" in output for name in ("halo", "halo-server", "libSDL3.so.0", "brokers.txt", "masters.txt", "init.txt.example", "gametypes/btb_slayer.toml", "deploy/halo-server"): @@ -286,9 +292,9 @@ def test_install_latest_then_reinstall_a_tag(github, tmp_path): (server / "maps/ui.map").write_text("the game's") (server / "logs/debug.txt").write_text("a log") - output = install(github, server, "build-299") + output = install(feed, server, "build-299") assert "Installation completed" in output, output - assert "OpenCE build-299 (network version 38)" in output, output + assert "HaloCustoms build-343-hc299 (based on OpenCE build-343) (network version 38)" in output, output assert "upload your maps/ folder" not in output assert (server / "config.toml").read_text() == "[server]\nname = \"Mine\"\n" assert (server / "init.txt").read_text() == "sv_motd hello\n" @@ -304,11 +310,10 @@ def test_install_latest_then_reinstall_a_tag(github, tmp_path): @pytest.mark.parametrize("release, says", [ ("build-1", "no release build-1"), - ("build-298", "has no halo-linux-release.zip"), ("../etc", "is not a tag name"), ]) -def test_install_says_why_it_failed(github, tmp_path, release, says): - output = install(github, tmp_path, release) +def test_install_says_why_it_failed(feed, tmp_path, release, says): + output = install(feed, tmp_path, release) assert "INSTALL FAILED" in output and says in output, output assert "Installation completed" not in output assert not (tmp_path / "halo").exists() diff --git a/tools/test_halocustoms_release_sign.py b/tools/test_halocustoms_release_sign.py new file mode 100644 index 0000000..88a9137 --- /dev/null +++ b/tools/test_halocustoms_release_sign.py @@ -0,0 +1,140 @@ +"""tools/halocustoms_release_sign.py, the offline signing of HaloCustoms' releases (contract 1, FORK.md "Releases"): +a new key's seed written for its owner alone, never over another; the release vector's manifest and signature +(tools/harness/tests/release_vectors.json, which the site keeps a copy of) made again from its seed and files, byte for +byte; the releases it will not sign; and its check of a signed release. The game's code takes its releases in +tools/harness/tests/test_updater_feed.py. Needs OpenSSL 3 with Ed25519, as the tool does.""" + +import json +import os +import re +import shutil +import stat +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +TOOL = ROOT / "tools/halocustoms_release_sign.py" +VECTOR = json.loads((ROOT / "tools/harness/tests/release_vectors.json").read_text()) + + +def openssl_signs(): + if not shutil.which("openssl"): + return False + result = subprocess.run(["openssl", "list", "-public-key-algorithms"], capture_output=True, text=True) + return result.returncode == 0 and "ED25519" in result.stdout.upper() + + +pytestmark = pytest.mark.skipif(not openssl_signs(), reason="no OpenSSL with Ed25519 here") + + +def tool(*arguments): + return subprocess.run([sys.executable, str(TOOL), *map(str, arguments)], capture_output=True, text=True, + timeout=120) + + +def seed_file(folder, seed=VECTOR["seed"], mode=0o400): + path = folder / "release.seed" + path.write_text(seed + "\n") + path.chmod(mode) + return path + + +def vector_files(folder): + paths = [] + for asset in VECTOR["assets"]: + path = folder / asset["name"] + path.write_text(asset["contents"], newline="\n") + paths.append(path) + return paths + + +def test_a_new_key_is_its_owners_alone(tmp_path): + path = tmp_path / "release.seed" + made = tool("new-key", path) + assert made.returncode == 0, made.stderr + public = made.stdout.strip() + assert re.fullmatch(r"ed25519:[0-9a-f]{64}", public) + if os.name == "posix": + assert stat.S_IMODE(path.stat().st_mode) == 0o400 + assert re.fullmatch(r"[0-9a-f]{64}\n", path.read_text()) + # (the seed is never printed) + assert path.read_text().strip() not in made.stdout + made.stderr + again = tool("public-key", path) + assert again.returncode == 0 and again.stdout.strip() == public + # (and never written over) + refused = tool("new-key", path) + assert refused.returncode == 1 and "never written over" in refused.stderr + assert again.stdout.strip() == tool("public-key", path).stdout.strip() + + +def test_the_vector_is_made_again(tmp_path): + """the vector's seed and files signed: its manifest, signature and key id, exactly (the site's publishing + compares its own manifest with the tool's)""" + seed = seed_file(tmp_path) + assert tool("public-key", seed).stdout.strip() == VECTOR["public_key"] + signed = tool("sign", seed, 16, VECTOR["network"], *reversed(vector_files(tmp_path))) + assert signed.returncode == 0, signed.stderr + assert json.loads(signed.stdout) == {"halocustoms_manifest": VECTOR["manifest"], + "halocustoms_signature": VECTOR["signature"], + "halocustoms_key_id": VECTOR["key_id"]} + assert VECTOR["seed"] not in signed.stdout + signed.stderr + + +@pytest.mark.parametrize("change, says", [ + ("a name not of the feed", "is not a name of the feed's assets"), + ("a name twice", "is named twice"), + ("an empty file", "is 0 bytes"), + ("build 0", "a build number is from 1"), + ("network 0", "a network version from 1"), + ("network 65536", "a network version from 1"), + ("a seed others may read", "may be read by others"), + ("a seed that is not one", "is not a seed"), +]) +def test_what_it_will_not_sign(tmp_path, change, says): + seed = seed_file(tmp_path, mode=0o644 if change == "a seed others may read" else 0o400) + if change == "a seed that is not one": + seed.chmod(0o600) + seed.write_text("not a seed\n") + seed.chmod(0o400) + zips = vector_files(tmp_path) + build, network = 16, 41 + if change == "a name not of the feed": + zips.append(tmp_path / "halo-macos-release.zip") + zips[-1].write_bytes(b"x") + elif change == "a name twice": + (tmp_path / "again").mkdir() + zips.append(tmp_path / "again" / zips[0].name) + zips[-1].write_bytes(b"x") + elif change == "an empty file": + zips[0].write_bytes(b"") + elif change == "build 0": + build = 0 + elif change.startswith("network"): + network = int(change.split()[1]) + refused = tool("sign", seed, build, network, *zips) + assert refused.returncode == 1 and says in refused.stderr, refused.stderr + assert refused.stdout == "" + + +def test_a_signed_release_is_checked(tmp_path): + release = {"halocustoms_manifest": VECTOR["manifest"], "halocustoms_signature": VECTOR["signature"], + "halocustoms_key_id": VECTOR["key_id"]} + path = tmp_path / "release.json" + + def checked(fields, key=VECTOR["public_key"]): + path.write_text(json.dumps(fields)) + return tool("verify", key, path) + + good = checked(release) + assert good.returncode == 0 and "build-16, network version 41" in good.stdout, good.stderr + for fields, key in [ + (dict(release, halocustoms_manifest=VECTOR["manifest"].replace(" 51 ", " 52 ")), VECTOR["public_key"]), + (dict(release, halocustoms_manifest=VECTOR["manifest"].replace("\n", "\r\n")), VECTOR["public_key"]), + (dict(release, halocustoms_key_id="8f85843f7e24248d"), VECTOR["public_key"]), + (release, "ed25519:" + "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"), + ({"halocustoms_manifest": VECTOR["manifest"]}, VECTOR["public_key"]), + ]: + assert checked(fields, key).returncode == 1 diff --git a/tools/test_halocustoms_services.py b/tools/test_halocustoms_services.py new file mode 100644 index 0000000..c812eb6 --- /dev/null +++ b/tools/test_halocustoms_services.py @@ -0,0 +1,179 @@ +"""HaloCustoms' services, asked before OpenCE's (port/linux/src/halocustoms_services.h, FORK.md "Services"): +the built-in masters (master_list.c) put HaloCustoms' first, in a build given its list key, and OpenCE's after, +with no key of HaloCustoms' in the repository; the web page leaves HaloCustoms' to the site's masters.json; the +brokers (brokers.txt, p2p_signal.c's DEFAULT_BROKERS, and the web build's secure WebSockets in p2p_signal.c and +net.js) put HaloCustoms' first, then OpenCE's and public ones, within the four the game uses at once; the +self-updaters (updater.c, Android's Updater.java) ask HaloCustoms' release feed, download from the site and check +the zip's digest; and the server kits in deploy/ install from the same feed.""" + +import json +import re +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +SERVICES = ROOT / "port/linux/src/halocustoms_services.h" +OPENCE_MASTER = ("https://master.opencommunityedition.com", + "ed25519:89f4eda71229f11f3538679f49ef7fb0c2b407b380093f7f4fe02105b92225aa") + + +def text(relative): + return (ROOT / relative).read_text() + + +def macros(*sources): + """the string #defines of C sources: name -> value, a macro of the same sources named in one replaced by its + value and adjacent literals joined, as the compiler joins them""" + found = {} + for source in sources: + for name, body in re.findall(r"^#define (\w+) (.+)$", source, re.M): + if '"' in body: + found[name] = body + values = {} + + def value(name, depth=0): + if name in values: + return values[name] + assert depth < 8, name + parts = re.findall(r'"((?:[^"\\]|\\.)*)"|(\w+)', found[name]) + joined = "".join(literal if literal or not word else value(word, depth + 1) for literal, word in parts) + values[name] = joined + return joined + + return {name: value(name) for name in found} + + +SERVICE = macros(SERVICES.read_text()) + + +def test_the_services_are_the_sites(): + """each service at the site's address: its master under /master (a path the master signs), its broker on the + site's own host, and its release feed and downloads under /updates; the master's key is no file's""" + assert SERVICE["HALOCUSTOMS_MASTER_URL"] == "https://halocustoms.org/master" + assert SERVICE["HALOCUSTOMS_BROKER"] == "halocustoms.org:1883" + assert SERVICE["HALOCUSTOMS_BROKER_WEBSOCKET"] == "wss://halocustoms.org/mqtt" + assert SERVICE["HALOCUSTOMS_RELEASES_URL"] == "https://halocustoms.org/updates/releases/latest" + assert SERVICE["HALOCUSTOMS_DOWNLOADS_URL"] == "https://halocustoms.org/updates/releases/download/" + # (the release's key is given to its build, never kept here) + assert "HALOCUSTOMS_MASTER_KEY" not in SERVICE + for path in ("port/linux/src/master_list.c", "port/assets/network/masters.txt", "port/web/site/servers.js"): + assert not re.search(r"#define HALOCUSTOMS_MASTER_KEY", text(path)), path + + +def test_the_built_in_masters_are_halocustoms_then_opence(): + """master_list.c's masters asked when masters.txt names none: HaloCustoms' first when the build is given its + key, OpenCE's always; the web page asks OpenCE's when the site's masters.json names none""" + source = text("port/linux/src/master_list.c") + table = source[source.index("int master_list_read_defaults("):] + table = table[table.index("masters[] =") + len("masters[] ="):table.index("};")] + assert re.search(r"#ifdef HALOCUSTOMS_MASTER_KEY\s*\{ HALOCUSTOMS_MASTER_URL, HALOCUSTOMS_MASTER_KEY \},\s*#endif", + table) + entries = re.findall(r'\{\s*(\w+|"[^"]*"),\s*(\w+|"[^"]*")\s*\}', table) + assert [tuple(part.strip('"') for part in entry) for entry in entries] == \ + [("HALOCUSTOMS_MASTER_URL", "HALOCUSTOMS_MASTER_KEY"), OPENCE_MASTER] + page = text("port/web/site/servers.js") + defaults = page[page.index("const DEFAULT_MASTERS = ["):] + assert re.findall(r"url: '([^']*)', key: '([^']*)'", defaults[:defaults.index("];")]) == [OPENCE_MASTER] + + +def test_the_release_key_is_given_to_master_list_c_alone(monkeypatch): + """the build takes the key from HALOCUSTOMS_MASTER_KEY, refuses one not in the form, and gives it to + master_list.c in each build""" + sys.path.insert(0, str(ROOT)) + from tools import linux_build + + monkeypatch.delenv("HALOCUSTOMS_MASTER_KEY", raising=False) + assert linux_build.master_key_defines() == "" + monkeypatch.setenv("HALOCUSTOMS_MASTER_KEY", "ed25519:" + "0a" * 32) + assert linux_build.master_key_defines() == '-DHALOCUSTOMS_MASTER_KEY=\\"ed25519:' + "0a" * 32 + '\\"' + for wrong in ("ed25519:" + "0A" * 32, "ed25519:" + "0a" * 31, "0a" * 32, 'ed25519:"; rm -rf /'): + monkeypatch.setenv("HALOCUSTOMS_MASTER_KEY", wrong) + with pytest.raises(ValueError): + linux_build.master_key_defines() + for script in ("tools/linux_build.py", "tools/windows_build.py", "tools/android_build.py", "tools/web_build.py"): + assert re.search(r'source\.name == "master_list\.c"', text(script)) and "master_key_defines()" in text(script), \ + script + + +def test_the_release_key_is_pinned_in_the_updaters_alone(monkeypatch): + """the release key (FORK.md, "Releases") is given when the build is configured, refused in another form, and + pinned in updater.c (the desktop builds) and the Android app's BuildConfig; none is in the repository, so a + build without it never updates itself""" + sys.path.insert(0, str(ROOT)) + from tools import linux_build + + monkeypatch.delenv("HALOCUSTOMS_RELEASE_KEY", raising=False) + assert linux_build.release_key_defines() == "" + monkeypatch.setenv("HALOCUSTOMS_RELEASE_KEY", "ed25519:" + "d7" * 32) + assert linux_build.release_key_defines() == '-DHALOCUSTOMS_RELEASE_KEY=\\"ed25519:' + "d7" * 32 + '\\"' + monkeypatch.setenv("HALOCUSTOMS_RELEASE_KEY", "ed25519:" + "D7" * 32) + with pytest.raises(ValueError): + linux_build.release_key_defines() + for script in ("tools/linux_build.py", "tools/windows_build.py"): + assert re.search(r'source\.name == "updater\.c":\n.*\n\s*f"\{release_key_defines\(\)\}"', text(script)), script + assert 'pinned_key("HALOCUSTOMS_RELEASE_KEY")' in text("tools/android_build.py") + gradle = text("port/android/app/build.gradle") + assert 'buildConfigField "String", "HALOCUSTOMS_RELEASE_KEY"' in gradle and "halocustomsReleaseKey" in gradle + updater = text("port/linux/src/updater.c") + assert "#ifdef HALOCUSTOMS_RELEASE_KEY\n#define UPDATER_RELEASE_KEY HALOCUSTOMS_RELEASE_KEY" in updater + assert not re.search(r"#define HALOCUSTOMS_RELEASE_KEY", updater) + android = text("port/android/app/src/main/java/com/halo/decomp/Updater.java") + assert "BuildConfig.HALOCUSTOMS_RELEASE_KEY, asset())" in android + + +def test_the_brokers_are_halocustoms_then_opence_and_public_ones(): + """brokers.txt, the game's own copy of it and the web build's: HaloCustoms' first, then OpenCE's (the web + build's: the public ones, OpenCE's having no secure WebSocket), four at most (MAXIMUM_BROKERS), on the + topics OpenCE's machines use too""" + listed = [line.split("#", 1)[0].strip() for line in text("port/assets/network/brokers.txt").splitlines()] + listed = [line for line in listed if line] + signalling = text("port/linux/src/p2p_signal.c") + defined = macros(SERVICES.read_text(), signalling) + maximum = int(re.search(r"MAXIMUM_BROKERS = (\d+),", signalling).group(1)) + assert defined["DEFAULT_BROKERS"].split(",") == listed + assert listed[:2] == [SERVICE["HALOCUSTOMS_BROKER"], "opence.milenko.org:1883"] and len(listed) <= maximum + web = defined["DEFAULT_WEB_BROKERS"].split(",") + assert web[0] == SERVICE["HALOCUSTOMS_BROKER_WEBSOCKET"] and len(web) <= maximum + assert all(url.startswith("wss://") for url in web) + page = text("port/web/site/net.js") + brokers = page[page.index("const BROKERS = ["):] + assert re.findall(r"'(wss://[^']*)'", brokers[:brokers.index("];")]) == web + # (the public ones are those OpenCE's machines use too: they meet on them, on the topics the site's broker + # lets through, hceu/3/# and opence/web/1/room/#) + assert {"broker.emqx.io:1883", "broker.hivemq.com:1883"} <= set(listed) + assert "const TOPIC_PREFIX = 'opence/web/1/room/';" in page + + +def test_the_updaters_ask_the_release_feed(): + """updater.c and Updater.java ask HaloCustoms' feed, not GitHub, download the release's zip from the site, and + check it against the size and SHA-256 of its signed manifest before they unpack it""" + updater = text("port/linux/src/updater.c") + assert "update_download(HALOCUSTOMS_RELEASES_URL," in updater + assert 'HALOCUSTOMS_DOWNLOADS_URL "build-%ld/" UPDATE_ASSET' in updater + install = updater[updater.index("static int updater_install("):] + assert install.index("updater_zip_checks(zip_path") < install.index("updater_unpack(zip_path") + assert "github.com" not in updater.lower() + android = text("port/android/app/src/main/java/com/halo/decomp/Updater.java") + assert re.search(r'String FEED = "([^"]*)";', android).group(1) == SERVICE["HALOCUSTOMS_RELEASES_URL"] + assert re.search(r'String DOWNLOADS = "([^"]*)";', android).group(1) == SERVICE["HALOCUSTOMS_DOWNLOADS_URL"] + assert android.index("zip.length() != size || !digest.equals(sha256(zip))") < android.index("extractApk(zip, apk);") + assert "github.com" not in android.lower() + + +def test_the_server_kits_install_from_the_feed(): + """the Dockerfile, the Pterodactyl egg (its script and its feed's default), PufferPanel's and MCSManager's + take HaloCustoms' releases from the site, none from OpenCE's GitHub""" + feed = SERVICE["HALOCUSTOMS_RELEASES_URL"].rsplit("/", 1)[0] + assert f"ARG RELEASES={feed}" in text("deploy/docker/Dockerfile") + assert f'RELEASES="${{HALOCUSTOMS_RELEASES:-{feed}}}"' in text("deploy/pterodactyl/install.sh") + egg = json.loads(text("deploy/pterodactyl/egg-opence-dedicated-server.json")) + variables = {variable["env_variable"]: variable for variable in egg["variables"]} + assert variables["HALOCUSTOMS_RELEASES"]["default_value"] == feed + puffer = json.loads(text("deploy/pufferpanel/template.json")) + assert json.dumps(puffer).count(feed) >= 1 + assert feed in text("deploy/mcsmanager/mcsmanager-config.json") + for path in ("deploy/docker/Dockerfile", "deploy/pterodactyl/install.sh", "deploy/pufferpanel/template.json", + "deploy/mcsmanager/mcsmanager-config.json"): + assert "github.com/OpenCommunityEdition" not in text(path), path diff --git a/tools/test_linux_port.py b/tools/test_linux_port.py index ed13766..67efb7c 100644 --- a/tools/test_linux_port.py +++ b/tools/test_linux_port.py @@ -9,7 +9,7 @@ from pathlib import Path import pytest -from tools import linux_build, linux_link_check, linux_msvc_semantics +from tools import linux_build, linux_link_check, linux_msvc_semantics, menu_check # for tests that compile for the Linux target (and run what they build) linux_host_only = pytest.mark.skipif(not sys.platform.startswith("linux"), @@ -177,128 +177,83 @@ def test_game_sources_leave_out_the_excluded(tmp_path, monkeypatch): MENUS = Path(__file__).resolve().parent.parent / "port/assets/menus" -MENU_ATTRIBUTES = { - "menus": {"root"}, - "bitmap": {"name", "width", "height", "frames", "platform"}, - "frame": {"png", "map", "index", "width", "height", "x", "y", "platform"}, - "strings": {"name", "map", "platform"}, - "string": {"text", "map", "platform"}, - "widget": {"name", "type", "controller", "flags", "bitmap", "text", "strings", "values", "setting", "font", - "color", "align", "description", "string_list", "list_flags", "text_flags", "header_bitmap", - "footer_bitmap", "header_bounds", "footer_bounds", "child_controller", "x", "y", "left", "top", - "width", "height", "text_x", "text_y", "auto_close", "auto_close_fade", "string_index", "platform"}, - "child": {"widget", "controller", "x", "y", "platform"}, - "on": {"event", "run", "script", "open", "replace", "close", "widget", "focus", "reload", "sound", "otherwise", - "label", "back", "branch", "platform"}, - "data": {"input", "platform"}, - "conditional": {"widget", "if_failed", "platform"}, - "replace": {"search", "function", "platform"}, -} - - -def c_strings(path: Path, start: str, end: str) -> list: - """the string literals of a C file between two markers""" - text = path.read_text(encoding="latin-1") - text = text[text.index(start):] - return re.findall(r'"([^"\\]*)"', text[:text.index(end)]) - - -# menu_files.c's attributes read as whole numbers (its tables' { name, NULL, &long }) -MENU_INTEGER_ATTRIBUTES = {"auto_close", "auto_close_fade", "height", "index", "left", "string_index", "text_x", - "text_y", "top", "width", "x", "y"} def test_menus_are_well_formed(): """What port/linux/src/menu_files.c and port/linux/game/menu_tags.c check - when the game loads them, but the map's own names (paths with backslashes), - which only the map has.""" + when the game loads them (tools/menu_check.py, which a build checks a + menu overlay's set with too), but the map's own names (paths with + backslashes), which only the map has; and what the stock set keeps to + besides.""" import json - import xml.etree.ElementTree as ElementTree root = MENUS.parent.parent.parent - tags = root / "port/linux/game/menu_tags.c" - events = c_strings(tags, "event_names[] =", "};") - flags = c_strings(tags, "widget_flag_names[] =", "};") - functions = set(c_strings(root / "source/interface/ui_widget_event_handler_functions.c", '\t{\n\t\t"NULL",', "}")) - functions |= set(c_strings(tags, "port_function_names[] =", "};")) - inputs = set(c_strings(tags, "game_data_input_names[] =", "};")) - inputs |= set(c_strings(tags, "port_game_data_input_names[] =", "};")) listed = json.loads((MENUS / "menus.json").read_text())["files"] - files = sorted(MENUS.rglob("*.xml")) - assert sorted(path.relative_to(MENUS).as_posix() for path in files) == \ + assert sorted(path.relative_to(MENUS).as_posix() for path in MENUS.rglob("*.xml")) == \ sorted(name for name in listed if name.endswith(".xml")) - widgets, bitmaps, strings, references, roots = {}, {}, {}, [], [] - for path in files: - tree = ElementTree.parse(path) - assert tree.getroot().tag == "menus", path - if tree.getroot().get("root"): - roots.append(tree.getroot().get("root")) - for element in tree.getroot().iter(): - where = f"{path.name}: <{element.tag} {element.get('name', '')}>" - assert element.tag in MENU_ATTRIBUTES, where - assert set(element.attrib) <= MENU_ATTRIBUTES[element.tag], where - # (the platforms that show it: menu_files.c's MENU_PLATFORM) - assert element.get("platform") is None or \ - set(element.get("platform").split()) <= {"desktop", "android", "web"}, where - # (menu_files.c's whole numbers, which the tags keep in shorts, - # its true/false attributes, and no text but whitespace outside - # s) - for attribute in MENU_INTEGER_ATTRIBUTES & set(element.attrib): - value = element.get(attribute) - assert re.fullmatch(r"-?[0-9]+", value) and -32768 <= int(value) <= 32767, f"{where} {attribute}" - for attribute in ("back", "branch", "if_failed"): - assert element.get(attribute) in (None, "true", "false"), f"{where} {attribute}" - assert not (element.text or "").strip() and not (element.tail or "").strip(), where - if element.tag == "bitmap": - bitmaps[element.get("name")] = element - for frame in element.iter("frame"): - assert (frame.get("png") is None) != (frame.get("map") is None), where - if frame.get("map"): - assert "\\" in frame.get("map") and int(frame.get("index")) >= 0, where - continue - assert frame.get("png") in listed, where - with (MENUS / frame.get("png")).open("rb") as png: - header = png.read(24) - width, height = int.from_bytes(header[16:20], "big"), int.from_bytes(header[20:24], "big") - logical = int(frame.get("width")), int(frame.get("height")) - assert width % logical[0] == 0 and width // logical[0] == height // logical[1], where - elif element.tag == "strings": - strings[element.get("name")] = element - # (a string's map= is its number in the list's map list, menu_tags.c) - for string in element.findall("string"): - assert string.get("map") is None or (element.get("map") and string.get("map").isdigit()), where - elif element.tag == "widget": - name = element.get("name") - assert name and name not in widgets, where - widgets[name] = element - assert element.get("type", "container") in ("container", "text", "spinner", "column_list"), where - assert set(element.get("flags", "").split()) <= set(flags), where - children = element.findall("widget") + element.findall("child") - if element.get("type") == "spinner": - assert len(children) in (0, 1, 3), where - if element.get("strings") or "items_from_strings" in element.get("list_flags", ""): - assert element.get("type") == "spinner" and not children, where - if element.get("setting"): - assert len(element.get("strings").split("|")) == len(element.get("values").split("|")), where - for attribute in ("bitmap", "header_bitmap", "footer_bitmap"): - references.append((where, bitmaps, element.get(attribute))) - references.append((where, strings, element.get("string_list"))) - references.append((where, widgets, element.get("description"))) - elif element.tag == "child" or element.tag == "conditional": - references.append((where, widgets, element.get("widget"))) - elif element.tag == "on": - assert set(element.get("event").split()) <= set(events), where - run = element.get("run") - assert run is None or run in functions or run.startswith("unwired "), where - assert element.get("otherwise") is None or run, where - for attribute in ("open", "replace", "focus", "widget", "otherwise"): - references.append((where, widgets, element.get(attribute))) - elif element.tag == "data": - assert element.get("input") in inputs or element.get("input").startswith("unwired "), where - assert all(root_name in widgets for root_name in roots) - for where, names, name in references: - if name and "\\" not in name: - assert name in names, f"{where} {name}" + files = [menu_check.MenuFile(name, (MENUS / name).read_bytes(), name) for name in listed] + assert menu_check.problems(files, root) == [] + data = {file.name: file.data for file in files} + for platform in menu_check.PLATFORMS: + menus = menu_check.read(files, platform) + assert menus.widgets and menus.root is not None, platform + for bitmap in menus.bitmaps: + for frame in bitmap.frames: + where = f"{platform}: {frame.file}:{frame.line}" + if frame.get("map"): + assert "\\" in frame.get("map"), where + continue + # (a stock picture is as many times its frame's width as its height) + width, height = menu_check.png_size(data[frame.get("png")]) + assert width // frame.get("width") == height // frame.get("height"), where + for widget in menus.widgets: + if widget.get("setting"): + assert len(widget.get("strings").split("|")) == len(widget.get("values").split("|")), \ + f"{platform}: {widget.file}:{widget.line}" + + +def test_menu_check_has_the_games_elements(): + """tools/menu_check.py's elements are menu_files.c's: each one's + attributes that are text ({ name, &text }) and whole numbers ({ name, + NULL, &number }), the elements it may be in, and how deep they nest.""" + source = (MENUS.parent.parent.parent / "port/linux/src/menu_files.c").read_text() + header = (MENUS.parent.parent.parent / "port/linux/include/halo_menus.h").read_text() + readers = {"menus": "read_menus", "bitmap": "read_bitmap", "frame": "read_frame", "strings": "read_strings", + "string": "read_string", "widget": "read_widget", "child": "read_child", "on": "read_handler", + "data": "read_input", "conditional": "read_conditional", "replace": "read_replace", + # (a theme's font: , read by read_font) + "font": "read_font"} + # (a theme's colours: , and a <..._text color=> of each + # of halo_menus.h's HALO_MENU_TEXT_ELEMENTS, all read by read_color, all ') + names = header[header.index("#define HALO_MENU_TEXT_ELEMENTS"):] + theme_texts = re.findall(r'"(\w+)"', names[:names.index("}")]) + assert theme_texts and all(name.endswith("_text") for name in theme_texts) + assert set(readers) | {"color_map", *theme_texts} == set(menu_check.ELEMENTS) + color = source[source.index("static void read_color("):] + assert re.search(r'map_table\[\] = \{ \{ "from", &color\.from \}, \{ "to", &color\.to \} \};', color) + assert re.search(r'text_table\[\] = \{ \{ "color", &color\.color \} \};', color) + assert menu_check.ELEMENTS["color_map"] == (("menus",), ("from", "to"), ()) + assert all(menu_check.ELEMENTS[name] == (("menus",), ("color",), ()) for name in theme_texts) + for element, function in readers.items(): + body = source[source.index(f"static void {function}("):] + table = body[body.index("struct attribute table[] ="):] + entries = re.findall(r'\{\s*"(\w+)",\s*(NULL|&[\w.]+)', table[:table.index("};")]) + assert entries, element + _, texts, numbers = menu_check.ELEMENTS[element] + assert sorted(texts) == sorted(name for name, kind in entries if kind != "NULL"), element + assert sorted(numbers) == sorted(name for name, kind in entries if kind == "NULL"), element + # (element_start: an element and the parents it names for it, then those + # that are a widget's) + start = source[source.index("static void XMLCALL element_start("):source.index("static void XMLCALL element_end(")] + parents = {name: {None if first == "none" else first} | ({second} if second else set()) + for name, first, second in re.findall( + r'!strcmp\(name, "(\w+)"\) && \(?parent == _element_(\w+)(?: \|\| parent == _element_(\w+))?', start)} + for name in re.findall(r'!strcmp\(name, "(\w+)"\)', start[start.index("else if (parent == _element_widget)"):]): + parents[name] = {"widget"} + assert '(!strcmp(name, "color_map") || text_element(name) != HALO_MENU_NONE) && parent == _element_menus' in start + parents.update({name: {"menus"} for name in ("color_map", *theme_texts)}) + assert parents == {element: set(where) for element, (where, _, _) in menu_check.ELEMENTS.items()} + assert f"#define MAXIMUM_DEPTH {menu_check.MAXIMUM_DEPTH}\n" in source def test_menu_settings_exist(): @@ -328,8 +283,12 @@ def test_default_brokers_are_brokers_txt(): root = MENUS.parent.parent.parent listed = [line.split("#", 1)[0].strip() for line in (root / "port/assets/network/brokers.txt").read_text().splitlines()] - default = re.search(r'^#define DEFAULT_BROKERS "([^"]*)"', - (root / "port/linux/src/p2p_signal.c").read_text(), re.M).group(1) + # (its first, HaloCustoms', a macro of port/linux/src/halocustoms_services.h) + services = (root / "port/linux/src/halocustoms_services.h").read_text() + definition = re.search(r'^#define DEFAULT_BROKERS (\w+) "([^"]*)"', + (root / "port/linux/src/p2p_signal.c").read_text(), re.M) + first = re.search(rf'^#define {definition.group(1)} "([^"]*)"', services, re.M).group(1) + default = first + definition.group(2) assert default.split(",") == [line for line in listed if line] diff --git a/tools/test_server_sim.py b/tools/test_server_sim.py index 6630a15..17de921 100644 --- a/tools/test_server_sim.py +++ b/tools/test_server_sim.py @@ -438,7 +438,7 @@ def test_settings_secrets_and_pinned(sim): "choices", "min", "max"} # (typed values, and choices, min and max where they are known) assert settings["server.public"]["value"] is True and settings["server.max_players"]["value"] == 16 - assert settings["server.name"]["value"] == "OpenCE Server" and settings["server.name"]["choices"] is None + assert settings["server.name"]["value"] == "HaloCustoms" and settings["server.name"]["choices"] is None assert settings["server.log_format"]["choices"] == ["text", "json"] assert (settings["server.max_players"]["min"], settings["server.max_players"]["max"]) == (2, 127) status, _, body = sim.request("PUT", "/api/settings", { @@ -652,10 +652,11 @@ def test_rcon_hello_auth_commands_and_events(sim): rcon = opence_rcon.Rcon(sim.url, password="opence-admin", by="tests") assert rcon.hello["type"] == "hello" and rcon.hello["protocol"] == 1 assert rcon.hello["capabilities"] == ["events", "status", "rotation", "settings", "bans", "owner_web", "ban_address", - "stop", "event_addresses", "identity", "vpn", "link"] + "stop", "event_addresses", "identity", "vpn", "link", "account", + "results"] assert rcon.role == "admin" reply = rcon.command("sv_status") - assert reply["ok"] and reply["id"] == 1 and reply["lines"][0].startswith("OpenCE Server: playing") + assert reply["ok"] and reply["id"] == 1 and reply["lines"][0].startswith("HaloCustoms: playing") # (a command that failed: its lines and ok false, as the real server answers; no error object) refused = rcon.command("sv_map nowhere") assert refused["ok"] is False and "error" not in refused @@ -799,7 +800,7 @@ def rcon_cli(sim, *args): def test_the_rcon_client_exit_codes(sim): done = rcon_cli(sim, "--password", "opence-admin", "sv_version") - assert done.returncode == 0 and done.stdout.startswith("OpenCE build-206") + assert done.returncode == 0 and done.stdout.startswith("HaloCustoms local build (based on OpenCE build-343)") failed = rcon_cli(sim, "--password", "opence-mod", "sv_set", "server.name", "X") assert failed.returncode == 1 and "admin role" in failed.stdout + failed.stderr refused = rcon_cli(sim, "--password", "wrong", "sv_version") @@ -867,18 +868,21 @@ def test_vpn_policy_and_discord_bans(fresh): status, _, body = sim.request("GET", "/api/vpn", headers=admin) assert status == 200 and body["policy"] == "link" and body["held"], body assert set(body["refused"]) == {"vpn_link_required", "vpn_blocked", "vpn_link_invalid", "vpn_link_too_new", - "vpn_link_in_use"} and body["refused"]["vpn_link_required"] == 1, body + "vpn_link_in_use", "account_required", "account_invalid", + "account_in_use"} and body["refused"]["vpn_link_required"] == 1, body assert sim.request("POST", "/api/vpn/check", {"ip": "198.51.100.9"}, headers=moderator)[2] == \ {"ok": True, "listed": True, "by": "netlist"} assert sim.request("POST", "/api/vpn/check", {"ip": "203.0.113.9"}, headers=moderator)[2]["listed"] is False assert sim.request("POST", "/api/vpn/check", {"ip": "nope"}, headers=moderator)[0] == 400 - assert {"vpn", "link"} <= set(sim.request("GET", "/api/status", headers=admin)[2]["capabilities"]) + assert {"vpn", "link", "account", "results"} <= set(sim.request("GET", "/api/status", headers=admin)[2]["capabilities"]) refused = [event for event in sim.request("GET", "/api/events?after=0", headers=admin)[2]["events"] if event["event"] == "join_refused"] assert refused and refused[0]["reason"] == "vpn_link_required" and "address" not in refused[0], refused # (the owner sees each player's Discord account, with their address; an admin without server.log_addresses not) players = sim.request("GET", "/api/players", headers=owner)[2]["players"] - assert all({"discord_id", "discord_name", "discord_verified"} <= set(player) for player in players), players + assert all({"discord_id", "discord_name", "discord_verified", "account"} <= set(player) for player in players), \ + players + assert any(player["account"] and len(player["account"]["master"]) == 16 for player in players), players assert "discord_id" not in sim.request("GET", "/api/players", headers=admin)[2]["players"][0] verified = [player for player in players if player["discord_verified"]][0] status, _, body = sim.request("POST", "/api/bans", {"discord_id": verified["discord_id"], "reason": "test"}, diff --git a/tools/test_touch_menu.py b/tools/test_touch_menu.py index 5f220ba..866eb76 100644 --- a/tools/test_touch_menu.py +++ b/tools/test_touch_menu.py @@ -38,3 +38,66 @@ def test_touch_menu(tmp_path): assert build.returncode == 0, build.stdout + build.stderr result = subprocess.run([str(binary)], capture_output=True, text=True) assert result.returncode == 0, result.stdout + result.stderr + + +# ---------- the menus' pressable widgets on the display (tools/menu_check.py's +# off_the_display): where a finger or the mouse can reach them at 4:3, 16:9 +# and 21:9, the anchors placing them (port/assets/menus/README.md, "Anchors") + +MENUS = ROOT / "port" / "assets" / "menus" + + +def menu_files(overlay=None): + """the stock menu set's files, or a build's with a menu overlay (a folder + laid out as port/assets/menus: tools/embed_assets.py's menu_sources)""" + from tools import embed_assets, menu_check + + return [menu_check.MenuFile(name, path.read_bytes(), str(path)) + for name, path in embed_assets.menu_sources(Path(overlay) if overlay else None)] + + +def test_every_pressable_menu_widget_is_on_the_display(): + """the stock set's, and a menu overlay's where HALO_MENUS_OVERLAY names + one (a site's: configure.py --menus-overlay's folder)""" + import os + from tools import menu_check + + assert menu_check.off_the_display(menu_files()) == [] + overlay = os.environ.get("HALO_MENUS_OVERLAY") + if overlay: + assert menu_check.off_the_display(menu_files(overlay)) == [] + + +def test_an_anchored_widget_off_the_display_is_named(): + """a widget placed past the display's side at one width or all of them; + one anchored in from a side, a stretched list's spread items and a + description stay on it""" + from tools import menu_check + + screen = ('\n\n' + '\t\n' + '\t\t\n' + '\t\t\t\n' + '\t\t\t\n' + '\t\t\t\n' + '\t\t\t\n' + '\t\t\t\n' + '\t\t\n' + '\t\n' + '\t\n' + '\n') + files = [menu_check.MenuFile("ce/test.xml", screen.encode(), "test.xml")] + off = menu_check.off_the_display(files, ("desktop",)) + named = [(line.split(": ")[1].split(" (on")[0], line.split(" wide")[0].rsplit(" ", 1)[1]) for line in off] + # (its middle at 690: on the display from 852 wide) + assert ("test/past_right", "640") in named and ("test/past_right", "852") not in named + assert all(("test/right_past_right", width) in named for width in ("640", "852", "1120")) + assert not [name for name, _ in named if name not in ("test/past_right", "test/right_past_right")], off + # (where they are: the left one 12 in from the display's left, the fluid + # ones spread across it, the description at its right) + menus = menu_check.read(files, "desktop") + at = {item.widget.get("name"): item.area for item in menu_check.placed(menus, 1120)} + assert at["test/near_left"][0] == 12 - 240 + # (their middles at 60 and 580 of 640: 45 units in from 480 more, either way) + assert at["test/fluid_first"][0] == 20 - 240 + 45 and at["test/fluid_last"][2] == 620 + 240 - 45 + assert at["test/help"][0] == 240 and at["test/list"] == (-240, 0, 880, 480) diff --git a/tools/test_web_build.py b/tools/test_web_build.py index b9708e7..8e301ec 100644 --- a/tools/test_web_build.py +++ b/tools/test_web_build.py @@ -1,8 +1,11 @@ """The web build (tools/web_build.py, port/web): the ninja graph's flags, what it compiles and leaves out and what the site holds; the memory layout the build and the sources agree on; version.json and licenses.txt; the sockets inside -the page (port/web/tests/web_net_test.c, built for this computer); and the -menus' platforms. +the page (port/web/tests/web_net_test.c, built for this computer); the +menus' platforms; and a menu overlay (configure.py --menus-overlay): in the +graphs, in what tools/embed_assets.py generates, checks and leaves out, and +in what ninja runs again when the overlay changes (where ninja is +installed). python -m pytest -q tools/test_web_build.py @@ -11,10 +14,14 @@ Needs no Emscripten: the graph is generated with a stand-in for emcc. import io import json +import os import re +import shlex import shutil +import struct import subprocess import sys +import zlib from pathlib import Path from types import SimpleNamespace @@ -33,12 +40,12 @@ def in_the_repository(monkeypatch): monkeypatch.chdir(ROOT) -def graph(release=False, emcc="/opt/emsdk/upstream/emscripten/emcc"): +def graph(release=False, emcc="/opt/emsdk/upstream/emscripten/emcc", menus_overlay=None): """build.ninja's web part, as configure.py writes it, its long lines joined again""" out = io.StringIO() sln = SimpleNamespace(build_dir=Path("build"), web_emcc=emcc, port_release=release, port_profile=False, - compiler_launcher=None) + compiler_launcher=None, port_menus_overlay=menus_overlay) web_build.generate_web_build(ninja_syntax.Writer(out), sln) return re.sub(r" \$\n +", " ", out.getvalue()) @@ -170,6 +177,15 @@ def test_version_json_names_the_build_and_its_files(tmp_path, monkeypatch): (site / "halo.js").write_text("js changed") again = stamp_version.stamp(site / "version.json", [site / "halo.js", site / "icons" / "icon.png"], "release") assert again["version"] != written["version"] + # a build with a menu overlay says its menus are embedded; one without + # leaves the key out, for older pages + assert "embedded_menus" not in written + embedded = stamp_version.stamp(site / "version.json", [site / "halo.js"], "release", embedded_menus=True) + assert embedded["embedded_menus"] is True + assert json.loads((site / "version.json").read_text())["embedded_menus"] is True + subprocess.run([sys.executable, str(ROOT / "port/web/stamp_version.py"), "--flavor", "release", "--embedded-menus", + str(site / "version.json"), str(site / "halo.js")], check=True) + assert json.loads((site / "version.json").read_text())["embedded_menus"] is True def test_licenses_txt_has_every_notice(): @@ -192,6 +208,650 @@ def test_the_sockets_inside_the_page(tmp_path): assert "web_net: ok" in result.stdout +# ---------- a menu overlay (configure.py --menus-overlay): a site's own +# menu files embedded in place of and with port/assets/menus' + +MENUS = ROOT / "port/assets/menus" +# (ninja's own reading of the rules, where it is installed; its commands are a POSIX shell's here) +with_ninja = pytest.mark.skipif(not shutil.which("ninja") or os.name == "nt", reason="needs ninja and a POSIX shell") + + +def png(width, height, colour, pixel): + """a PNG of one colour: 8-bit, not interlaced, of colour type colour + (6: RGBA, 2: RGB) with pixel's bytes for each pixel""" + def chunk(kind, data): + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) + rows = b"".join(b"\0" + pixel * width for _ in range(height)) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, colour, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b"")) + + +def embedded_menu_table(text): + """the menu files of a generated hud_hires_assets.c: each one's path, + array and size, in the table's order""" + start = text.rindex("const struct menu_file_embedded menu_files_embedded[] =\n{\n") + return re.findall(r'\{ "([^"]*)", (menu\d+), (\d+) \}', text[start:text.index("\n};", start)]) + + +def embedded_menu_names(text): + return [path for path, _, _ in embedded_menu_table(text)] + + +def embedded_menu_file(text, name): + """the bytes of one of them""" + array, size = next((array, int(size)) for path, array, size in embedded_menu_table(text) if path == name) + start = text.index(f"static const unsigned int {array}[] = {{\n") + return b"".join(struct.pack("", "\n")) + (overlay / "ce/port/test/dot__0.png").write_bytes(picture or png(1, 1, 6, b"\x10\x20\x30\xff")) + (overlay / "ce/zz_last.png").write_bytes(png(1, 1, 6, b"\x40\x50\x60\xff")) + (overlay / "ce/.thumbnails/dot__0.png").write_bytes(png(1, 1, 6, b"\x70\x80\x90\xff")) + (overlay / "ce/notes.txt").write_text("not a menu file") + (overlay / "manifest.json").write_text("{}") + (overlay / ".halocustoms").write_text("version") + return overlay + + +def embed(overlay, output, *options, cwd=ROOT): + """tools/embed_assets.py run with a menu overlay""" + return subprocess.run([sys.executable, str(ROOT / "tools/embed_assets.py"), "--menus-overlay", str(overlay), + *options, str(output)], capture_output=True, text=True, cwd=cwd) + + +def rule(text, name): + """a rule of a graph: its lines after the first""" + return re.search(rf"^rule {name}\n((?: [^\n]*\n)+)", text, re.M).group(1) + + +def shell_words(command): + """the words the shell makes of a rule's command as ninja hands it over + ($$ a dollar, $ and a space a space), its program and $out apart: the + arguments the program is run with""" + assert command.startswith("$python tools/embed_assets.py ") + script = command.replace("$$", "\0").replace("$python tools/embed_assets.py ", "printf '%s\\n' ", 1) + script = script.replace("$out", "OUT").replace("$ ", " ").replace("\0", "$") + words = subprocess.run(["sh", "-c", script], capture_output=True, text=True, check=True).stdout.splitlines() + assert words == shlex.split(script)[2:] + return words + + +def test_the_graph_embeds_a_menu_overlay_and_says_so(tmp_path): + from tools import embed_assets + + overlay = overlay_folder(tmp_path) + text = graph(release=True, menus_overlay=overlay) + # the step is given the folder and writes what ninja runs it again for: + # the overlay's files and folders are in that depfile, not in the graph, + # which would only know those there were when it was written + lines = rule(text, "web_embed_assets") + folder = embed_assets.command_argument(str(overlay)) + assert f" command = $python tools/embed_assets.py --menus-overlay {folder} --depfile $out.d $out\n" in lines + assert " depfile = $out.d\n" in lines and " deps = gcc\n" in lines and " restat = 1\n" in lines + inputs = re.search(r"^build build/web/generated/hud_hires_assets\.c: web_embed_assets \| ([^\n]*)", text, re.M).group(1) + assert not [name for name in inputs.split() if str(tmp_path) in name] + for name in ("tools/embed_assets.py", "tools/menu_check.py", "port/assets/menus/menus.json", + "port/assets/menus/ce/main_menu.mods.xml", "port/assets/menus/ce/main_menu.xml"): + assert name in inputs.split(), name + assert rule(text, "web_version").startswith( + " command = $python port/web/stamp_version.py --flavor release --embedded-menus $out $in\n") + # configure.py is not run again for the overlay: only the step is + assert not [path for path in embed_assets.hud_configure_inputs() if str(tmp_path) in str(path)] + assert Path("port/assets/menus") in embed_assets.hud_configure_inputs() + assert str(tmp_path) not in " ".join(str(path) for path in web_build.web_configure_inputs()) + # and without an overlay, as before (the output is written only when it + # changes, so ninja looks at it again: restat) + text = graph(release=True) + assert rule(text, "web_embed_assets") == (" command = $python tools/embed_assets.py $out\n" + " description = WEB EMBED $out\n restat = 1\n") + assert rule(text, "web_version").startswith( + " command = $python port/web/stamp_version.py --flavor release $out $in\n") + # an overlay that gives the game no file (the step fails for it) is not + # said to be embedded + nothing = tmp_path / "nothing" + (nothing / "ce/.git").mkdir(parents=True) + (nothing / "ce/.git/packed.png").write_bytes(png(1, 1, 6, b"\0\0\0\xff")) + (nothing / "ce/README.md").write_text("no menu files yet") + (nothing / "manifest.json").write_text("{}") + text = graph(release=True, menus_overlay=nothing) + assert "--menus-overlay" in rule(text, "web_embed_assets") + assert "--embedded-menus" not in rule(text, "web_version") + + +def test_a_menu_overlay_reaches_every_build(tmp_path): + """the Linux, Windows and Android builds embed it as the web build does""" + from tools import embed_assets, linux_build + + overlay = overlay_folder(tmp_path) + out = io.StringIO() + linux_build.generate_linux_build(ninja_syntax.Writer(out), SimpleNamespace( + build_dir=Path("build"), linux_cc="clang", compiler_launcher=None, port_release=False, port_lto="off", + port_portable=True, port_pgo="off", port_pgo_profile=None, port_profile=False, port_menus_overlay=overlay)) + text = re.sub(r" \$\n +", " ", out.getvalue()) + assert f"--menus-overlay {embed_assets.command_argument(str(overlay))} --depfile $out.d $out\n" in rule( + text, "linux_embed_assets") + # (the Windows and Android graphs need their toolchains: their generators + # hand the step the overlay in the same words) + for name, prefix in (("linux_build.py", "linux"), ("windows_build.py", "windows"), ("android_build.py", "android"), + ("web_build.py", "web")): + source = re.sub(r"\s+", " ", (ROOT / "tools" / name).read_text()) + call = re.search(rf'hud_assets_build\(n, "{prefix}", [^()]*(?:\([^()]*\)[^()]*)*\)', source) + assert call, name + assert 'overlay=getattr(sln, "port_menus_overlay", None)' in call.group(0) or ( + "overlay=overlay" in call.group(0) and 'overlay = getattr(sln, "port_menus_overlay", None)' in source), name + + +@pytest.mark.skipif(os.name == "nt", reason="a POSIX shell's quoting") +def test_an_overlay_folder_is_one_argument_whatever_its_name(tmp_path): + from tools import embed_assets + + # an argument with nothing special is as it was: no build.ninja changes + for plain in ("--release", "--pgo=off", "--menus-overlay", "/opt/emsdk/upstream/emscripten/emcc", "build-343"): + assert embed_assets.command_argument(plain) == plain + assert embed_assets.command_argument("my theme") == "'my$ theme'" + assert embed_assets.command_argument("a$b") == "'a$$b'" + with pytest.raises(ValueError): + embed_assets.command_argument("two\nlines") + for name in ("my theme", "a$b", "two spaces", "halo(customs)", "R&D", "semi;colon", "bob's", "a\\b", '"quoted"', + "$HOME `id` $(id) * ? [x] {y} #z !w ~v %u"): + overlay = overlay_folder(tmp_path, name=name) + command = rule(graph(menus_overlay=overlay), "web_embed_assets").split("\n")[0] + assert command.startswith(" command = $python tools/embed_assets.py --menus-overlay ") + assert shell_words(command[len(" command = "):]) == ["--menus-overlay", str(overlay), "--depfile", "OUT.d", + "OUT"], name + # (and ninja_syntax breaks no line inside it) + out = io.StringIO() + embed_assets.hud_assets_build(ninja_syntax.Writer(out), "web", Path("out.c"), overlay=overlay) + assert embed_assets.command_argument(str(overlay)) in out.getvalue().replace("$\n ", ""), name + + +def test_embed_assets_takes_a_menu_overlay(tmp_path, monkeypatch, capsys): + from tools import embed_assets + + overlay = overlay_folder(tmp_path) + # its bitmaps name a picture of its own for the Mods screen's title (twice + # its frame's width and once its height: the game stretches it), and no + # longer the picture the game's code names itself + bitmaps = stock_with("ce/bitmaps.xml", '', + '') + blank = '\t\n\t\t\n\t\n' + assert bitmaps.count(blank) == 1 + (overlay / "ce/bitmaps.xml").write_text(bitmaps.replace(blank, "")) + (overlay / "ce/port/test/header.png").write_bytes(png(1024, 64, 6, b"\x10\x20\x30\xff")) + # a link to a folder of pictures kept elsewhere + shared = tmp_path / "shared art" + shared.mkdir() + (shared / "linked__0.png").write_bytes(png(1, 1, 6, b"\x01\x02\x03\xff")) + try: + os.symlink(shared, overlay / "ce/port/linked", target_is_directory=True) + linked = ["ce/port/linked/linked__0.png"] + except OSError: + linked = [] + # a name that C writes otherwise and a depfile cannot hold (where a file + # can have it) + quoted = [] if os.name == "nt" else ['ce/port/test/a "b".png'] + for name in quoted: + (overlay / name).write_bytes(png(1, 1, 6, b"\x04\x05\x06\xff")) + output = tmp_path / "hud_hires_assets.c" + depfile = tmp_path / "hud_hires_assets.c.d" + result = embed(overlay, output, "--depfile", str(depfile)) + assert result.returncode == 0, result.stderr + text = output.read_text() + assert "--menus-overlay" in text.splitlines()[1] and overlay.as_posix() in text.splitlines()[1] + stock = json.loads((MENUS / "menus.json").read_text())["files"] + assert "ce/port/main_menu/mods/header_mods.png" in stock and "ce/port/blank__0.png" in stock + # the stock files in their order, but the picture nothing names any more + # (the picture the game names stays); then the overlay's others in the + # order of their paths: only its .xml and .png files, and none of a dot + # folder + assert embedded_menu_names(text) == [name for name in stock if name != "ce/port/main_menu/mods/header_mods.png"] + [ + *linked, *(embed_assets.c_string(name) for name in quoted), "ce/port/test/dot__0.png", + "ce/port/test/header.png", "ce/zz_last.png"] + assert "ce/port/blank__0.png" in embed_assets.MENU_FILES_THE_GAME_NAMES + assert f"menu_files_embedded_count = {len(stock) - 1 + len(linked) + len(quoted) + 3};" in text + mods = embedded_menu_file(text, "ce/main_menu.mods.xml") + assert mods == (overlay / "ce/main_menu.mods.xml").read_bytes() != (MENUS / "ce/main_menu.mods.xml").read_bytes() + assert b"overlay marker: restyled for the test" in mods + assert embedded_menu_file(text, "ce/main_menu.xml") == (MENUS / "ce/main_menu.xml").read_bytes() + assert embedded_menu_file(text, "ce/port/test/dot__0.png") == (overlay / "ce/port/test/dot__0.png").read_bytes() + assert embedded_menu_file(text, "ce/zz_last.png") == (overlay / "ce/zz_last.png").read_bytes() + # the step names what it leaves out, but for the dot files and the + # site's list + said = [line for line in result.stdout.splitlines() if "left out" in line] + assert len(said) == 1 and "ce/notes.txt" in said[0] and " 1 file " in said[0] + assert "manifest.json" not in result.stdout and ".halocustoms" not in result.stdout and ".thumbnails" not in result.stdout + # the depfile: the output's rule, with the folder that holds the + # overlay, every folder of it that was walked (a link's as the overlay + # names it), its files that were read and the game's sources the check + # reads + def written(path): + """a path as the depfile names it: relative to the folder the step ran in, if it is under it""" + return embed_assets.depfile_name(str(path.relative_to(ROOT) if ROOT in path.parents else path)) + + target, *names = [line.strip() for line in depfile.read_text().replace(" \\\n", "\n").splitlines()] + assert target == f"{embed_assets.depfile_name(str(output))}:" + assert names == [written(path) for path in ( + tmp_path, overlay, overlay / "ce", overlay / "ce/port", *(overlay / name for name in ("ce/port/linked",) if linked), + overlay / "ce/port/test", overlay / "ce/bitmaps.xml", overlay / "ce/main_menu.mods.xml", + *(overlay / name for name in linked), overlay / "ce/port/test/dot__0.png", + overlay / "ce/port/test/header.png", overlay / "ce/zz_last.png")] + [ + "port/linux/game/menu_tags.c", "source/interface/ui_widget_event_handler_functions.c", + # (the file a depfile cannot name: one that is never made, for the step to run on every build) + *(embed_assets.depfile_name(f"{output}.every-build") for _ in quoted)] + assert ("ninja cannot follow 1 of" in result.stdout) == bool(quoted) + assert not Path(f"{output}.every-build").exists() + # (the folder that holds the overlay is not among them where ninja runs + # in it: ninja's own files change that folder's time on every build) + monkeypatch.chdir(tmp_path) + _, followed = embed_assets.menu_overlay_sources(overlay) + assert followed[0] == overlay and tmp_path not in followed + monkeypatch.chdir(ROOT) + _, followed = embed_assets.menu_overlay_sources(overlay) + assert followed[:2] == [tmp_path, overlay] + capsys.readouterr() + # a name is written as a C string holds it, whatever is in it + assert embed_assets.c_string('ce/a "b" \\ caf\u00e9.png') == 'ce/a \\042b\\042 \\134 caf\\303\\251.png' + assert all('"' not in embed_assets.c_string(name) for name in quoted) + assert embed_assets.c_string("ce/port/blank__0.png") == "ce/port/blank__0.png" + # a link that leads back to a folder it is in is refused + if linked: + os.symlink(overlay / "ce", overlay / "ce/port/around", target_is_directory=True) + result = embed(overlay, tmp_path / "around.c") + assert result.returncode != 0 and "around" in result.stderr and "link back" in result.stderr + assert "Traceback" not in result.stderr and not (tmp_path / "around.c").exists() + + +def test_the_menu_files_the_game_names_are_listed(): + """a menu file the game's code names by its path stays embedded with a + menu overlay whose XML does not name it: embed_assets.py lists them""" + from tools import embed_assets + + named = set() + for folder in ("port/linux/game", "port/linux/src"): + for source in sorted((ROOT / folder).glob("*.[ch]")): + named |= set(re.findall(r'"(ce/[^"\\\n]*\.png)"', source.read_text(encoding="latin-1"))) + assert "ce/port/blank__0.png" in named, "no name found: the pattern is stale" + assert named <= set(embed_assets.MENU_FILES_THE_GAME_NAMES), sorted(named - set(embed_assets.MENU_FILES_THE_GAME_NAMES)) + stock = json.loads((MENUS / "menus.json").read_text())["files"] + assert set(embed_assets.MENU_FILES_THE_GAME_NAMES) <= set(stock) + + +def test_embed_assets_checks_the_merged_set(tmp_path): + """a set the game would refuse when it runs (and keep the Xbox's menus) + fails the step, which names the file and the problem""" + def refused(name, files): + overlay = overlay_folder(tmp_path / name) + for path, content in files.items(): + (overlay / path).parent.mkdir(parents=True, exist_ok=True) + (overlay / path).write_text(content) + output = tmp_path / name / "hud_hires_assets.c" + depfile = tmp_path / name / "hud_hires_assets.c.d" + # (the folder given relative to where the command is run, not to the + # repository: the step makes it absolute there) + result = embed("overlay", output, "--depfile", str(depfile), cwd=tmp_path / name) + assert result.returncode != 0, name + assert not output.exists() and not depfile.exists(), name + assert "Traceback" not in result.stderr, result.stderr + return overlay, result.stderr + + mods = (MENUS / "ce/main_menu.mods.xml").read_text() + # a stale copy beside the file it was copied from: every widget twice + overlay, said = refused("copy", {"ce/main_menu.mods.old.xml": mods}) + assert f"{overlay / 'ce/main_menu.mods.old.xml'}:4: two widgets are named main_menu/main_menu_item_mods" in said + # a function the game has not + overlay, said = refused("function", {"ce/main_menu.mods.xml": stock_with( + "ce/main_menu.mods.xml", 'run="port mods init"', 'run="port mods begin"')}) + assert re.search(rf"{re.escape(str(overlay / 'ce/main_menu.mods.xml'))}:\d+: there is no event handler function " + "port mods begin", said) + # a picture that is not there + overlay, said = refused("picture", {"ce/bitmaps.xml": stock_with( + "ce/bitmaps.xml", "ce/port/main_menu/mods/header_mods.png", "ce/port/test/header_mods.png")}) + assert re.search(rf"{re.escape(str(overlay / 'ce/bitmaps.xml'))}:\d+: there is no file ce/port/test/header_mods.png", said) + # a picture that is not a whole multiple of its frame + overlay, said = refused("multiple", {"ce/bitmaps.xml": stock_with( + "ce/bitmaps.xml", '', + '')}) + assert "not a whole multiple of its frame's 500x64" in said + # a file Expat refuses: something before the XML declaration + overlay, said = refused("declaration", {"ce/main_menu.mods.xml": "\n" + mods}) + assert f"{overlay / 'ce/main_menu.mods.xml'}:2: XML or text declaration not at start of entity" in said + # a name used that nothing defines, and one that only another platform has + overlay, said = refused("name", {"ce/main_menu.mods.xml": stock_with( + "ce/main_menu.mods.xml", '\n\n\t\n' + '\t\n\t\n' + '\t\n\t\n\n') + overlay = overlay_folder(tmp_path) + (overlay / "ce/theme.xml").write_text(theme) + output = tmp_path / "hud_hires_assets.c" + result = embed(overlay, output) + assert result.returncode == 0, result.stderr + text = output.read_text() + stock = json.loads((MENUS / "menus.json").read_text())["files"] + assert embedded_menu_names(text)[:len(stock)] == stock and "ce/theme.xml" in embedded_menu_names(text)[len(stock):] + assert embedded_menu_file(text, "ce/theme.xml") == theme.encode() + for name, content, problem in ( + ("bad-colour", theme.replace("#FFFFE0A0", "#FFE0A0F"), "a color is #RRGGBB or #AARRGGBB: #FFE0A0F"), + ("bad-element", theme.replace(" cannot be here"), + ("too-many", theme.replace("\n", "\n" + "".join( + f'\t\n' for index in range(31))), + "the menus map 32 text colors at most; another: #FFFFFF")): + overlay = overlay_folder(tmp_path / name) + (overlay / "ce/theme.xml").write_text(content) + result = embed(overlay, tmp_path / name / "out.c") + assert result.returncode != 0 and problem in result.stderr, (name, result.stderr) + + +@pytest.mark.skipif(shutil.which("ninja") is None, reason="needs ninja") +def test_the_licences_step_names_each_theme_fonts_licence_apart(tmp_path): + """the web build's licenses.txt step, as ninja runs it: the SDL licence and then each of an overlay's theme font + licences, every one an argument of its own (ninja drops a variable's leading whitespace, so a space the variable + began with once joined the first licence to the SDL licence's path), a path with a space in it quoted""" + overlay = overlay_folder(tmp_path, name="the overlay") + (overlay / "ce/fonts").mkdir() + for family in ("Alpha", "Beta"): + (overlay / f"ce/fonts/{family}-OFL.txt").write_text("licence") + sln = SimpleNamespace(build_dir=Path("build"), web_emcc="/opt/emsdk/upstream/emscripten/emcc", port_release=True, + port_profile=False, compiler_launcher=None, port_menus_overlay=overlay) + + def licences_command(sln): + out = io.StringIO() + web_build.generate_web_build(ninja_syntax.Writer(out), sln) + (tmp_path / "build.ninja").write_text(out.getvalue()) + command = subprocess.run(["ninja", "-f", str(tmp_path / "build.ninja"), "-t", "commands", "-s", + "build/web/site/licenses.txt"], capture_output=True, text=True, check=True).stdout + return shlex.split(command.strip().splitlines()[-1]) + + licences = [str(overlay / "ce/fonts/Alpha-OFL.txt"), str(overlay / "ce/fonts/Beta-OFL.txt")] + assert licences_command(sln)[-3:] == ["build/web/SDL3-LICENSE.txt", *licences] + # (and without an overlay, the SDL licence alone) + assert licences_command(SimpleNamespace(**{**vars(sln), "port_menus_overlay": None}))[-2:] == [ + "build/web/site/licenses.txt", "build/web/SDL3-LICENSE.txt"] + + +def test_a_menu_overlay_carries_its_theme_fonts(tmp_path): + """an overlay's theme fonts (port/assets/menus/README.md, "Theme fonts"): its ce/theme.xml's s, the .ttf + files and their -OFL.txt licences embedded with its menus, and the licences in the web build's licenses.txt; a + font without its licence, a variable font, or a tag the high-res text does not draw fails the step""" + from tools import embed_assets + + font = (ROOT / "port/assets/fonts/Overpass-750.ttf").read_bytes() + licence = (ROOT / "port/assets/fonts/Overpass-OFL.txt").read_text() + theme = ('\n\n' + '\t\n\n') + overlay = overlay_folder(tmp_path) + (overlay / "ce/theme.xml").write_text(theme) + (overlay / "ce/fonts").mkdir() + (overlay / "ce/fonts/Theme-SemiBold.ttf").write_bytes(font) + (overlay / "ce/fonts/Theme-OFL.txt").write_text(licence) + output = tmp_path / "hud_hires_assets.c" + result = embed(overlay, output) + assert result.returncode == 0, result.stderr + text = output.read_text() + names = embedded_menu_names(text) + assert {"ce/theme.xml", "ce/fonts/Theme-SemiBold.ttf", "ce/fonts/Theme-OFL.txt"} <= set(names) + assert embedded_menu_file(text, "ce/fonts/Theme-SemiBold.ttf") == font + assert embed_assets.menu_overlay_font_licences(overlay) == [overlay / "ce/fonts/Theme-OFL.txt"] + # (the web build's licenses.txt carries the font's licence) + licenses = tmp_path / "licenses.txt" + sdl = tmp_path / "SDL3-LICENSE.txt" + sdl.write_text("SDL's licence") + subprocess.run([sys.executable, str(ROOT / "port/web/licenses.py"), str(licenses), str(sdl), + str(overlay / "ce/fonts/Theme-OFL.txt")], check=True) + assert "==================== Theme (a font of the menus' theme)" in licenses.read_text() + variable = bytearray(font) + count = int.from_bytes(font[4:6], "big") + entry = next(12 + 16 * index for index in range(count) if font[12 + 16 * index:16 + 16 * index] == b"post") + variable[entry:entry + 4] = b"fvar" + for name, change, problem in ( + ("unlicensed", lambda folder: (folder / "ce/fonts/Theme-OFL.txt").unlink(), + "it has no licence beside it: ce/fonts/Theme-OFL.txt"), + ("variable", lambda folder: (folder / "ce/fonts/Theme-SemiBold.ttf").write_bytes(bytes(variable)), + "it is a variable font: give a static instance of it"), + ("other-tag", lambda folder: (folder / "ce/theme.xml").write_text(theme.replace("small_ui", "hud_number")), + "is no tag the high-res text draws")): + overlay = overlay_folder(tmp_path / name) + (overlay / "ce/theme.xml").write_text(theme) + (overlay / "ce/fonts").mkdir() + (overlay / "ce/fonts/Theme-SemiBold.ttf").write_bytes(font) + (overlay / "ce/fonts/Theme-OFL.txt").write_text(licence) + change(overlay) + result = embed(overlay, tmp_path / name / "out.c") + assert result.returncode != 0 and problem in result.stderr, (name, result.stderr) + + +def test_a_menu_overlay_lays_its_screens_over_a_wide_display(tmp_path): + """an overlay's widgets anchored to the display's sides, a bitmap whose ends are kept (caps), wrapped text and + a bar filled to the account's experience (port/assets/menus/README.md, "Anchors") are embedded; an anchor, + caps, a fill or a text flag the game would refuse fails the step""" + fluid = ('\n\n' + '\t\n' + '\t\t\n' + '\t\n' + '\t\n' + '\t\t\n' + '\t\t\t\n' + '\t\t\t\n' + '\t\t\n' + '\t\t\n' + '\t\t\n' + '\t\n' + '\t\n' + '\n') + overlay = overlay_folder(tmp_path) + (overlay / "ce/fluid.xml").write_text(fluid) + (overlay / "ce/port/test/bar__0.png").write_bytes(png(32, 16, 6, b"\x20\x40\x60\xff")) + output = tmp_path / "hud_hires_assets.c" + result = embed(overlay, output) + assert result.returncode == 0, result.stderr + assert embedded_menu_file(output.read_text(), "ce/fluid.xml") == fluid.encode() + for name, old, new, problem in ( + ("anchor", 'anchor="fluid"', 'anchor="middle"', "there is no anchor middle"), + ("child-anchor", 'x="460" y="100" anchor="right"', 'x="460" y="100" anchor="top"', "there is no anchor top"), + ("fill", 'fill="halocustoms_xp"', 'fill="xp"', "there is no fill xp"), + ("flag", 'text_flags="wrap"', 'text_flags="wrap words"', "there is no flag words"), + ("no-middle", 'caps="4,6"', 'caps="10,6"', "caps leave no middle in a frame of test/bar"), + ("one-cap", 'caps="4,6"', 'caps="4"', 'caps are two whole numbers, "left,right": 4'), + ("spaced-caps", 'caps="4,6"', 'caps="4, 6"', 'caps are two whole numbers, "left,right": 4, 6'), + ("map-caps", '', + '\n' + '\t\t', "caps are for a bitmap of PNGs: test/bar")): + assert fluid.count(old) == 1, name + overlay = overlay_folder(tmp_path / name) + (overlay / "ce/fluid.xml").write_text(fluid.replace(old, new)) + (overlay / "ce/port/test/bar__0.png").write_bytes(png(32, 16, 6, b"\x20\x40\x60\xff")) + result = embed(overlay, tmp_path / name / "out.c") + assert result.returncode != 0 and problem in result.stderr, (name, result.stderr) + + +def test_embed_assets_refuses_an_overlay_picture_the_game_cannot_read(tmp_path): + overlay = overlay_folder(tmp_path, picture=png(1, 1, 2, b"\x10\x20\x30")) + output = tmp_path / "hud_hires_assets.c" + result = embed(overlay, output) + assert result.returncode != 0 + assert "dot__0.png" in result.stderr and "8-bit RGBA" in result.stderr + assert not output.exists() + # (whatever the case of its suffix) + (overlay / "ce/port/test/dot__0.png").unlink() + (overlay / "ce/port/test/UPPER.PNG").write_bytes(b"not a picture") + result = embed(overlay, output) + assert result.returncode != 0 and "UPPER.PNG: not a PNG" in result.stderr + assert not output.exists() + + +def test_a_folder_that_is_not_a_menu_overlay_is_refused(tmp_path, monkeypatch): + """an empty path (which would be the folder the command is run in), what + is not a folder, and a folder with no menu file under ce/: an empty one, + or one a level too high, whose files the game would read as more menus""" + from tools import embed_assets + import ci_build + + site = tmp_path / "site" + overlay = overlay_folder(site / "play", name="menus") + (tmp_path / "empty").mkdir() + (tmp_path / "file").write_text("not a folder") + for folder, problem in (("", "an empty path"), (" ", "an empty path"), (tmp_path / "missing", "not a folder"), + (tmp_path / "file", "not a folder"), (tmp_path / "empty", "no .xml or .png file under ce/"), + (site / "play", "no .xml or .png file under ce/")): + with pytest.raises(ValueError, match=problem) as refusal: + embed_assets.menu_overlay_folder(str(folder)) + assert "laid out as port/assets/menus" in str(refusal.value) + result = embed(folder, tmp_path / "out.c") + assert result.returncode != 0 and problem in result.stderr and "Traceback" not in result.stderr, folder + assert not (tmp_path / "out.c").exists() + # (configure.py, run where it writes nothing of the repository's: it stops before it writes) + result = subprocess.run([sys.executable, str(ROOT / "configure.py"), "--menus-overlay", str(folder)], + capture_output=True, text=True, cwd=tmp_path) + assert result.returncode == 2 and problem in result.stderr, folder + assert not (tmp_path / "build.ninja").exists() + with pytest.raises(SystemExit): + ci_build.arguments(["web", "release", "--menus-overlay", str(folder)]) + # a folder given relative to where the command is run is made absolute + # there, once: tools/ci_build.py runs configure.py in the repository + monkeypatch.chdir(site) + assert embed_assets.menu_overlay_folder("play/menus") == overlay + assert ci_build.arguments(["web", "release", "--menus-overlay", "play/menus"]).menus_overlay == overlay + assert ci_build.arguments(["linux", "debug"]).menus_overlay is None + assert embed_assets.menu_overlay_folder(str(overlay)) == overlay + # (and a .. after a link is the system's folder, not the path's) + (tmp_path / "elsewhere/deep").mkdir(parents=True) + try: + os.symlink(tmp_path / "elsewhere/deep", site / "link", target_is_directory=True) + except OSError: + return + os.rename(overlay, tmp_path / "elsewhere/menus") + assert embed_assets.menu_overlay_folder("link/../menus") == tmp_path / "elsewhere/menus" + + +@with_ninja +def test_ninja_reads_the_depfile_names(tmp_path): + """a depfile's names as embed_assets.py writes them are the paths, to + ninja; those it cannot be given are known""" + from tools import embed_assets + + names = ["plain/path.png", "with space/a b.png", "two spaces", "a$b", "$HOME", "a#b", "#first", "halo(customs)", + "{braces}[x]", "a+b,c=d@e%f!g~h", "C:/drive", "a:b c", "caf\u00e9/\u00fc.png", + "back\\slash", "back\\ space", "back\\\\ space", "back\\#hash", "back\\$dollar", "back\\:colon", + "back\\;semi", "a\\$$b"] + # (ninja 1.13 reads the quotes, the & and the ? too; those before it do not) + for name in ("semi;colon", "star*", "ab", "a|b", "a^b", "a`b", "tab\there", "line\nbreak", "ends:", + "ends\\", "", "R&D", "bob's", '"quoted"', "what?"): + assert embed_assets.depfile_name(name) is None, name + lines = ["rule dep", " command = cp $out.given $out.d && touch $out", " depfile = $out.d", " deps = gcc"] + for index, name in enumerate(names): + written = embed_assets.depfile_name(name) + assert written is not None, name + (tmp_path / f"out{index}.given").write_bytes(os.fsencode(f"out{index}: \\\n {written} \\\n last\n")) + lines.append(f"build out{index}: dep") + (tmp_path / "build.ninja").write_text("\n".join(lines) + "\n") + subprocess.run(["ninja", "-C", str(tmp_path)], check=True, capture_output=True) + listed = subprocess.run(["ninja", "-C", str(tmp_path), "-t", "deps"], check=True, capture_output=True, + text=True).stdout + read = {block.split(":", 1)[0]: [line[4:] for line in block.split("\n")[1:]] + for block in listed.strip("\n").split("\n\n")} + for index, name in enumerate(names): + assert read[f"out{index}"] == [name, "last"], name + + +@with_ninja +def test_ninja_embeds_again_when_the_overlay_changes(tmp_path): + """whatever changes in a menu overlay (a file changed, added with an old + time, renamed or removed, a folder removed) ninja runs the step once + more, and then has nothing to do; a change that leaves the generated + source as it was compiles nothing""" + from tools import embed_assets + + # (a folder whose name the shell and ninja both have a use for) + overlay = overlay_folder(tmp_path / "my theme $HOME (v2)", name="menus") + project = tmp_path / "project" + project.mkdir() + for name in ("tools", "port"): + os.symlink(ROOT / name, project / name, target_is_directory=True) + out = io.StringIO() + n = ninja_syntax.Writer(out) + n.variable("python", embed_assets.command_argument(sys.executable)) + (source,) = embed_assets.hud_assets_build(n, "test", Path("build/hud_hires_assets.c"), overlay=overlay) + n.rule(name="use", command="head -c 64 $in > $out", description="USE $out") + n.build(outputs="build/used", rule="use", inputs=source) + (project / "build.ninja").write_text(out.getvalue()) + + def build(): + """how many times the step and what uses its output ran""" + result = subprocess.run(["ninja", "-C", str(project), "build/used"], capture_output=True, text=True) + assert result.returncode == 0, result.stdout + result.stderr + return result.stdout.count(" EMBED "), result.stdout.count(" USE "), "no work to do" in result.stdout + + def embedded(): + return [path for path in embedded_menu_names((project / "build/hud_hires_assets.c").read_text()) + if path.startswith("ce/port/test/")] + + assert build() == (1, 1, False) + assert build() == (0, 0, True) + assert embedded() == ["ce/port/test/dot__0.png"] + old = overlay / "ce/port/test/dot__0.png" + os.utime(old, (1_000_000_000, 1_000_000_000)) + + def old_file_in_a_new_folder(): + (overlay / "ce/port/test/more").mkdir() + shutil.copy2(old, overlay / "ce/port/test/more/old__0.png") + + def nothing_embedded_changes(): + os.utime(overlay / "ce/zz_last.png") + (overlay / "ce/port/README.md").write_text("a note") + + changes = [ + # a file added with the time of an old one (cp -p), in a new folder + (old_file_in_a_new_folder, ["ce/port/test/dot__0.png", "ce/port/test/more/old__0.png"], 1), + (lambda: os.rename(overlay / "ce/port/test/more/old__0.png", overlay / "ce/port/test/more/renamed__0.png"), + ["ce/port/test/dot__0.png", "ce/port/test/more/renamed__0.png"], 1), + # a file changed where it is + (lambda: (overlay / "ce/main_menu.mods.xml").write_text( + (overlay / "ce/main_menu.mods.xml").read_text() + "\n"), + ["ce/port/test/dot__0.png", "ce/port/test/more/renamed__0.png"], 1), + # a folder removed, with its file + (lambda: shutil.rmtree(overlay / "ce/port/test/more"), ["ce/port/test/dot__0.png"], 1), + # the step runs, and nothing after it: its output is as it was + (nothing_embedded_changes, ["ce/port/test/dot__0.png"], 0), + ] + for index, (change, files, used) in enumerate(changes): + change() + assert build() == (1, used, False), index + assert embedded() == files, index + assert build() == (0, 0, True), index + + def test_the_menus_name_known_platforms_and_match_their_generator(): import port_settings @@ -207,3 +867,59 @@ def test_the_menus_name_known_platforms_and_match_their_generator(): for row in ("op_allow_upnp", "op_join_from_clipboard", "op_auto"): assert re.search(rf']*platform="desktop android"', network), row assert re.search(r']*platform="desktop android web"', network) + # the HaloCustoms account's screen: the rows of what only the game itself + # keeps are not on a browser's, where the site's page signs in and + # reports; the account's help says so there, as the same string of the + # list. The sign-in button's caption is a string the game chooses: a text + # of its own the engine would put back each time it draws it + import menu_check + + base = port_settings.HALOCUSTOMS + files = [menu_check.MenuFile(name, (MENUS / name).read_bytes(), name) + for name in json.loads((MENUS / "menus.json").read_text())["files"] if name.endswith(".xml")] + for platform in menu_check.PLATFORMS: + menus = menu_check.read(files, platform) + widgets = {widget.get("name"): widget for widget in menus.widgets} + lists = {strings.get("name"): [string.get("text") for string in strings.strings] for strings in menus.string_lists} + rows = [child.get("widget") for _, child in widgets[f"{base}/options_menu"].children] + assert rows[:9] == [f"{base}/op_{key}" for key in ("profile", "account", "rank", "experience", "kills", "games", + "streak", "campaign", "coop")] + assert rows[9:] == ([] if platform == "web" else [f"{base}/op_pending", f"{base}/op_site"]) + [f"{base}/button_bar"] + # (each row in its own place, the last clear of the help, which is clear of the buttons) + places = [int(child.get("y")) for _, child in widgets[f"{base}/options_menu"].children][:-1] + help_widget = widgets[f"{base}/help"] + assert places == sorted(set(places)) and places[-1] + 28 <= int(help_widget.get("top")) + assert int(help_widget.get("top")) + int(help_widget.get("height")) <= 414 + assert len(lists[f"{base}/help_strings"]) == 1 + len(port_settings.HALOCUSTOMS_ROWS) == 1 + len(lists[f"{base}/labels"]) + assert lists[f"{base}/help_strings"][2].startswith( + "The site that serves this page" if platform == "web" else "SIGN IN shows a code"), platform + # (the screen is player 1's profile's account until Network Setup's HALOCUSTOMS ACCOUNT names the edited + # profile's, and again once it is closed) + options = widgets[f"{base}/options_menu"] + assert [on.get("run") for on in options.handlers if on.get("event") == "deleted"] == ["halocustoms screen close"] + network = f"{port_settings.PE}/network_setup" + entry = widgets[f"{network}/op_halocustoms_account"] + assert [(on.get("run"), on.get("open")) for on in entry.handlers if on.get("event") in ("a", "start")] == [ + ("halocustoms profile edited", f"{base}/halocustoms_screen")] * 2 + entries = [child.get("widget") for _, child in widgets[f"{network}/options_menu"].children] + assert entries[entries.index(f"{network}/op_discord_account") + 1] == f"{network}/op_halocustoms_account" + assert f"{network}/in_game/op_halocustoms_account" not in widgets + link = widgets[f"{base}/button_link"] + assert link.get("text") is None and link.get("string_list") == f"{base}/button_captions" + assert lists[f"{base}/button_captions"][link.get("string_index")] == "SIGN IN" + assert lists[f"{base}/button_captions"] == ["SIGN IN", "CANCEL", "SIGN OUT", "REFRESH"] + assert widgets[f"{base}/button_site"].get("text") == "OPEN SITE" + # (BACK at the right, the common captions' first: the screen changes nothing that Cancel would undo, and + # while a sign-in waits the first button is that sign-in's CANCEL) + assert [(child.get("widget"), child.get("x", 0)) for _, child in widgets[f"{base}/button_bar"].children] == [ + (f"{base}/button_link", 0), (f"{base}/button_site", 130), (f"{base}/button_friends", 260), + (f"{base}/button_back", 510)] + # (FRIENDS, on every platform: the web's lists the friends of the member signed in at the site) + friends = widgets[f"{base}/button_friends"] + assert friends.get("text") == "FRIENDS" and friends.get("platform") is None + assert [on.get("open") for on in friends.handlers if on.get("event") == "a"] == [ + f"{port_settings.HALOCUSTOMS_FRIENDS}/friends_screen"] + assert f"{port_settings.HALOCUSTOMS_FRIENDS}/friends_screen" in widgets + back = widgets[f"{base}/button_back"] + assert back.get("string_list") == "strings/common_button_captions" and back.get("string_index") == 0 + assert [on.get("run") for on in back.handlers if on.get("event") == "a"] == ["mouse emit back event"] diff --git a/tools/test_windows_resources.py b/tools/test_windows_resources.py new file mode 100644 index 0000000..af4938a --- /dev/null +++ b/tools/test_windows_resources.py @@ -0,0 +1,91 @@ +"""halo.exe's resources (tools/windows_build.py): port/windows/halo.rc's icon, then the version information +tools/windows_version_rc.py adds (HaloCustoms, based on OpenCE; the version of the build's label, of its number and +configuration; its licence), compiled as the Windows build compiles them, with llvm-rc and no preprocessor, and read +back from the .res file, when llvm-rc is here.""" + +import shutil +import struct +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +from tools import windows_build, windows_version_rc # noqa: E402 + +OPENCE = windows_version_rc.opence_build() +RT_ICON, RT_GROUP_ICON, RT_VERSION = 3, 14, 16 + + +def test_the_strings_name_the_build(): + release = windows_version_rc.version_strings(16, "release", OPENCE) + assert release["ProductName"] == "HaloCustoms" + assert release["FileDescription"] == "HaloCustoms, based on OpenCE" + assert release["ProductVersion"] == f"build-{OPENCE}-hc16" + assert release["FileVersion"] == f"build-{OPENCE}-hc16 (release), based on OpenCE build-{OPENCE}" + assert "AGPL-3.0-or-later" in release["LegalCopyright"] + local = windows_version_rc.version_strings(0, "debug", OPENCE) + assert local["ProductVersion"] == "local build" + assert local["FileVersion"] == f"local build (debug), based on OpenCE build-{OPENCE}" + + +def test_a_version_out_of_its_range_is_refused(): + for number, flavor in ((0x10000, "release"), (-1, "release"), (16, "profile")): + with pytest.raises(ValueError): + windows_version_rc.version_resource(number, flavor, OPENCE) + + +def test_the_build_gives_its_number_and_configuration(monkeypatch): + monkeypatch.delenv("HALO_BUILD_NUMBER", raising=False) + assert windows_build.version_resource_arguments(True) == "--build-number 0 --flavor release" + monkeypatch.setenv("HALO_BUILD_NUMBER", "16") + assert windows_build.version_resource_arguments(False) == "--build-number 16 --flavor debug" + monkeypatch.setenv("HALO_BUILD_NUMBER", "16; rm") + assert windows_build.version_resource_arguments(True) == "--build-number 0 --flavor release" + + +def resources(data): + """the (type, name) of each resource of a .res file""" + def identifier(offset): + if struct.unpack_from(" None: for source in sorted(platform_dir.glob("*.c")): if source.name in WEB_EXCLUDE: continue - add_object(source, posix_cflags if source.name.startswith("posix_") else platform_cflags) - for source in hud_assets_build(n, "web", build_dir / "generated" / "hud_hires_assets.c"): + add_object(source, posix_cflags if source.name.startswith("posix_") else + f"{platform_cflags} {master_key_defines()}" if source.name == "master_list.c" else platform_cflags) + overlay = getattr(sln, "port_menus_overlay", None) + for source in hud_assets_build(n, "web", build_dir / "generated" / "hud_hires_assets.c", overlay=overlay): add_object(source, platform_cflags) for source in sorted((PORT_DIR / "src").glob("*.c")): add_object(source, posix_cflags if source.name in WEB_POSIX_SOURCES else platform_cflags) @@ -361,16 +367,25 @@ def generate_web_build(n: Writer, sln: Any) -> None: n.build(outputs=target, rule="web_copy", inputs=Path("port/assets/fonts") / name) site_files.append(target) licenses = site_dir / "licenses.txt" + # (and the licences of a menu overlay's theme fonts, which the game embeds) + overlay = getattr(sln, "port_menus_overlay", None) + theme_licences = menu_overlay_font_licences(overlay) if overlay else [] + # (the space before them is the command's: ninja drops a value's leading whitespace) n.rule( name="web_licenses", - command=f"$python {PORT_DIR}/licenses.py $out {sdl_license}", + command=f"$python {PORT_DIR}/licenses.py $out {sdl_license} $theme_licences", description="WEB LICENSES $out", ) - n.build(outputs=licenses, rule="web_licenses", implicit=[PORT_DIR / "licenses.py", Path("LICENSE"), sdl_license]) + n.build(outputs=licenses, rule="web_licenses", + implicit=[PORT_DIR / "licenses.py", Path("LICENSE"), sdl_license, *theme_licences], + variables={"theme_licences": " ".join(shlex.quote(str(path)) for path in theme_licences)}) site_files.append(licenses) + # (a site's own menus are in the game where its overlay replaced or added + # a file: the embed step fails for one that would give it none) n.rule( name="web_version", - command=f"$python {PORT_DIR}/stamp_version.py --flavor {'release' if release else 'debug'} $out $in", + command=f"$python {PORT_DIR}/stamp_version.py --flavor {'release' if release else 'debug'}" + f"{' --embedded-menus' if menu_overlay_embeds(overlay) else ''} $out $in", description="WEB VERSION $out", ) version_file = site_dir / "version.json" diff --git a/tools/windows_build.py b/tools/windows_build.py index 4e9d824..37cd48d 100644 --- a/tools/windows_build.py +++ b/tools/windows_build.py @@ -23,7 +23,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional from .linux_build import (LINUX_PROFILE, MINIUPNPC_DIR, OPTIMISATION, STB_VORBIS_SOURCE, WINDOWS_PROFILE, XDK_INCLUDE, - build_repository, + build_repository, master_key_defines, release_key_defines, lto_mode, march_flag, miniupnpc_sources, pgo_mode, compile_launcher, configuration_defines, game_defines_and_includes, game_sources, musl_math_cflags, musl_math_sources, opus_cflags, opus_sources, pgo_profile, profile_use_flags, xdk_headers) @@ -206,6 +206,17 @@ PROFILE_RUNTIME_HEADERS = [ ] +def version_resource_arguments(release: bool) -> str: + """tools/windows_version_rc.py's arguments for halo.exe's version + information: the build's number, from HALO_BUILD_NUMBER as + updater_defines takes it (read when the build is configured; 0 for none), + and its configuration""" + number = os.environ.get("HALO_BUILD_NUMBER", "0") + if not number.isdigit(): + number = "0" + return f"--build-number {int(number)} --flavor {'release' if release else 'debug'}" + + def windows_rc(cc: str) -> str: """LLVM's resource compiler of the clang named cc: beside it, where cc is a path, else the one on the PATH""" @@ -331,16 +342,28 @@ def generate_windows_build(n: Writer, sln: Any) -> None: rspfile="$out.rsp", rspfile_content="$in_newline", ) - # the executable's resources (port/windows/halo.rc: its icon), compiled - # by LLVM's resource compiler, beside the clang that builds the game + # the executable's resources (port/windows/halo.rc: its icon; and its + # version information, which tools/windows_version_rc.py adds for the + # build's number and configuration), compiled by LLVM's resource + # compiler, beside the clang that builds the game (the icon found in + # port/windows) n.variable("windows_rc", windows_rc(cc)) + n.rule( + name="windows_version_rc", + command="$python tools/windows_version_rc.py $version_arguments $in $out", + description="WINDOWS VERSION $out", + ) n.rule( name="windows_rc", - command="$windows_rc /no-preprocess /FO $out $in", + command=f"$windows_rc /no-preprocess /I {PORT_DIR.as_posix()} /FO $out $in", description="WINDOWS RC $out", ) + resource_script = BUILD / "halo.rc" + n.build(outputs=resource_script, rule="windows_version_rc", inputs=PORT_DIR / "halo.rc", + implicit=[Path("tools/windows_version_rc.py"), LINUX_DIR / "include/halocustoms_build.h"], + variables={"version_arguments": version_resource_arguments(getattr(sln, "port_release", False))}) resources = BUILD / "halo.res" - n.build(outputs=resources, rule="windows_rc", inputs=PORT_DIR / "halo.rc", implicit=[PORT_DIR / "opence-icon.ico"]) + n.build(outputs=resources, rule="windows_rc", inputs=resource_script, implicit=[PORT_DIR / "opence-icon.ico"]) n.rule( name="windows_copy", command="$python -c \"import shutil,sys; shutil.copyfile(sys.argv[1], sys.argv[2])\" $in $out", @@ -348,7 +371,8 @@ def generate_windows_build(n: Writer, sln: Any) -> None: ) # the high-res HUD's textures (port/assets/hud; port/linux/src/hud_hires.c) - embedded_assets = hud_assets_build(n, "windows", BUILD / "generated" / "hud_hires_assets.c") + embedded_assets = hud_assets_build(n, "windows", BUILD / "generated" / "hud_hires_assets.c", + overlay=getattr(sln, "port_menus_overlay", None)) embedded_assets += server_ui_build(n, "windows", BUILD / "generated" / "server_ui_assets.c") # (a debug build checks its stack frames (/GS), and stops at the first @@ -461,8 +485,13 @@ def generate_windows_build(n: Writer, sln: Any) -> None: if source.name == "posix_dtls.c": # (Mbed TLS sees the Windows SDK, as the win32_*.c do) add_object(source, f"{win32_cflags} {mbedtls_include}") - elif source.name in ("updater.c", "server_mode.c"): + elif source.name == "updater.c": + add_object(source, f"{platform_cflags} {updater_defines(getattr(sln, 'port_release', False))} " + f"{release_key_defines()}") + elif source.name == "server_mode.c": add_object(source, f"{platform_cflags} {updater_defines(getattr(sln, 'port_release', False))}") + elif source.name == "master_list.c": + add_object(source, f"{platform_cflags} {master_key_defines()}") else: add_object(source, platform_cflags) miniupnpc_include = f"-I{MINIUPNPC_DIR / 'include'} -DMINIUPNP_STATICLIB" diff --git a/tools/windows_version_rc.py b/tools/windows_version_rc.py new file mode 100644 index 0000000..e2299ac --- /dev/null +++ b/tools/windows_version_rc.py @@ -0,0 +1,102 @@ +"""halo.exe's resources for the Windows build (tools/windows_build.py): port/windows/halo.rc (its icon) followed by its +version information, which the file's properties in Explorer show: HaloCustoms, based on OpenCE; its version the +build's label's, build--hc (port/linux/include/halocustoms_build.h, and the build's number, given +when it is configured), or a local build for one built without a number; and its licence. Written for llvm-rc +without its preprocessor (/no-preprocess), so in numbers and plain strings alone. + + python tools/windows_version_rc.py --build-number --flavor release|debug port/windows/halo.rc +""" + +import argparse +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BUILD_HEADER = ROOT / "port/linux/include/halocustoms_build.h" + +# VERSIONINFO's numbers (the Windows SDK's names for them, which the resource compiler is not given here) +VS_FFI_FILEFLAGSMASK = 0x3F +VS_FF_DEBUG = 0x1 +VOS_NT_WINDOWS32 = 0x40004 +VFT_APP = 0x1 +LANGUAGE = 0x409 # English (United States) +CODE_PAGE = 1200 # Unicode + + +def opence_build() -> int: + """the OpenCE release the fork is based on (halocustoms_build.h)""" + found = re.search(r"^#define HALOCUSTOMS_OPENCE_BUILD (\d+)$", BUILD_HEADER.read_text(encoding="utf-8"), re.M) + if not found: + raise LookupError(f"{BUILD_HEADER} has no HALOCUSTOMS_OPENCE_BUILD") + return int(found.group(1)) + + +def version_strings(number: int, flavor: str, opence: int) -> dict: + """the version information's strings, by name""" + product = f"build-{opence}-hc{number}" if number else "local build" + return { + "FileDescription": "HaloCustoms, based on OpenCE", + "FileVersion": f"{product} ({flavor}), based on OpenCE build-{opence}", + "InternalName": "halo", + "LegalCopyright": "The OpenCE and HaloCustoms authors. Free software under AGPL-3.0-or-later (LICENSE.txt)", + "LegalTrademarks": "Halo and Halo: Combat Evolved are trademarks of Microsoft Corporation", + "OriginalFilename": "halo.exe", + "ProductName": "HaloCustoms", + "ProductVersion": product, + } + + +def version_resource(number: int, flavor: str, opence: int) -> str: + """the VERSIONINFO resource of a build of this number (0 for none), configuration and OpenCE release""" + if not 0 <= number <= 0xFFFF or not 0 < opence <= 0xFFFF: + raise ValueError(f"a version's numbers are 16-bit: OpenCE build {opence}, build {number}") + if flavor not in ("release", "debug"): + raise ValueError(f"no configuration {flavor!r}: release or debug") + strings = version_strings(number, flavor, opence) + for value in strings.values(): + if not value.isascii() or '"' in value or "\\" in value: + raise ValueError(f"a version string the resource compiler would read otherwise: {value!r}") + values = "".join(f' VALUE "{name}", "{value}"\n' for name, value in strings.items()) + return ( + "/* its version information (tools/windows_version_rc.py) */\n" + "1 VERSIONINFO\n" + f"FILEVERSION {opence},{number},0,0\n" + f"PRODUCTVERSION {opence},{number},0,0\n" + f"FILEFLAGSMASK 0x{VS_FFI_FILEFLAGSMASK:x}L\n" + f"FILEFLAGS 0x{VS_FF_DEBUG if flavor == 'debug' else 0:x}L\n" + f"FILEOS 0x{VOS_NT_WINDOWS32:x}L\n" + f"FILETYPE 0x{VFT_APP:x}L\n" + "FILESUBTYPE 0x0L\n" + "BEGIN\n" + ' BLOCK "StringFileInfo"\n' + " BEGIN\n" + f' BLOCK "{LANGUAGE:04x}{CODE_PAGE:04x}"\n' + " BEGIN\n" + f"{values}" + " END\n" + " END\n" + ' BLOCK "VarFileInfo"\n' + " BEGIN\n" + f' VALUE "Translation", 0x{LANGUAGE:x}, {CODE_PAGE}\n' + " END\n" + "END\n" + ) + + +def main(arguments) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--build-number", type=int, default=0) + parser.add_argument("--flavor", choices=("release", "debug"), default="release") + parser.add_argument("resources", type=Path, help="port/windows/halo.rc") + parser.add_argument("output", type=Path) + args = parser.parse_args(arguments) + text = (args.resources.read_text(encoding="utf-8").rstrip("\n") + "\n\n" + + version_resource(args.build_number, args.flavor, opence_build())) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(text, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))