diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml index 781029c577..4d04f95f1a 100644 --- a/.github/workflows/apple.yml +++ b/.github/workflows/apple.yml @@ -50,6 +50,14 @@ jobs: run: python3 tools/test_macos_runtime.py --standalone - name: Signed iOS instruction image run: python3 tools/test_ios_runtime.py + - name: iOS ISO importer safety checks + run: | + python3 tools/ios_xiso_test.py + python3 -m unittest tools.test_config_defaults + - name: iOS controller routing + run: | + python3 -m pip install pytest + python3 -m pytest tools/test_ios_controller_routing.py -q - name: Controls, rendering and workload verification run: python3 -m unittest tools.test_input_bindings tools.test_visibility_queries tools.test_geometry_cache tools.test_presentation tools.test_macos_preflight tools.test_macos_benchmark - name: Version 11 network protocol diff --git a/docs/apple-build.md b/docs/apple-build.md index 4773ba1692..670ebb6248 100644 --- a/docs/apple-build.md +++ b/docs/apple-build.md @@ -186,3 +186,32 @@ but does not turn a locally built app into an approved redistributable package. Keep compiled apps, logs and provisioning profiles local. When contributing source, inspect `git diff --cached --name-only` and `git diff --cached` before committing; ignore rules cannot prevent an explicit `git add -f`. + +### iOS guest address bounds + +The device guest uses `0x7000000000` (448 GiB); the simulator keeps the +Mac's 1-TiB base. Compiler lowering, GL/import bridges and host translation +must agree. XNU reserves 64–448 GiB for GPU mappings. The iPhone 15 Pro +runtime reports a 512-GiB upper limit with Extended Virtual Addressing active. +Direct on-device probes rejected 16 GiB (reserved), 64 GiB (GPU carveout) and +1 TiB (outside the map), and successfully reserved the full arena at 448 GiB. +See [XNU ARM VM definitions](https://github.com/apple-oss-distributions/xnu/blob/main/osfmk/mach/arm/vm_param.h). +The signed-image test checks both device bounds and consistent pointer lowering. + +### iPhone/iPad graphics and diagnostics + +Shake the device to open Graphics & Diagnostics, including with a hardware +controller. Metal (ANGLE) remains the default; native Apple OpenGL ES is an +alternative. Driver and render-resolution changes take effect on relaunch. +Choose 480p, 720p (default), 900p, 1080p or native, with a 30/60/120 FPS render +limit (default 60). Smooth motion restores the older Apple build's rendering +interpolation, without changing the original 30-Hz game simulation. + +MetalFX spatial upscaling and the FPS overlay can be toggled live; upscaling +is available only with the active Metal renderer. Ordinary game-console chatter +is hidden; logs are retained. Share Play-session Report opens the system share +sheet (AirDrop or Save to Files) with bounded log excerpts, settings, the last +ten minutes of half-second timing summaries and shader diagnostics. It does +not include maps, the ISO or saves. First-use GPU-stall capture is opt-in because +its GPU synchronization changes performance. Normal timing capture avoids that +synchronization. diff --git a/docs/apple-ios-migration.md b/docs/apple-ios-migration.md new file mode 100644 index 0000000000..a14575d8d3 --- /dev/null +++ b/docs/apple-ios-migration.md @@ -0,0 +1,73 @@ +# Apple integration onto innerhat/OpenCE + +## Bases and attribution + +- New base: [innerhat-dev/OpenCE main](https://github.com/innerhat-dev/OpenCE/tree/036b9fe609bdba518711464ce24d2a2d9a3d7caf), `036b9fe609bdba518711464ce24d2a2d9a3d7caf`. + This fork already has Mac and iOS signed-code runtimes, compiler lowering, + renderer fixes, native menus, co-op and newer native/browser networking. +- Previous contribution: [AttilaTheFun/halo-ce-universal](https://github.com/AttilaTheFun/halo-ce-universal/commit/6702fb72efd8d97a780939d9b4bc04ab78d05aaf), `6702fb72efd8d97a780939d9b4bc04ab78d05aaf`, formerly PR #64 in OpenCommunityEdition/OpenCE. +- Earlier iOS foundation: [NicholasDominici/halo-ce-ios ios-port](https://github.com/NicholasDominici/halo-ce-ios/tree/3f2c14101d3ae1c7f0c0a11993a43407fadbeb46). +- Earlier browser foundation: [fqlx/halo-ce-universal fqlx/browser-webgl-stream-batching](https://github.com/fqlx/halo-ce-universal/tree/eaa82e6803f3d3e67c91d7f2fa2b15ee04e195f5), upstream PR #12. +- Shared decompilation lineage: OpenCommunityEdition/OpenCE and bnunu/halo-1. + +This is a selective port of the earlier Apple additions onto the requested +new base, not a replacement of innerhat's engine with the older PR #12 tree. +The old branch has a local backup and bundle before it is retired. + +## Decisions + +| Earlier contribution | Decision | +| --- | --- | +| Assembly-rewritten ILP32 runtime, memory and ABI bridges | Superseded by innerhat's LLVM-pass runtime and shared Mac/iOS bridges; retain upstream's Extended Virtual Addressing device requirement. | +| Game/network rejoin, lifecycle, migration and compatibility patches | Retain the newer base's game/network implementation; do not reintroduce protocol-6/11 compatibility or old migration structures. | +| WebKit room-code UI, browser relay, WASM build scaffolding | Retire from this contribution: new base owns browser networking and its protocol. No claim of compatibility with older deployed WASM clients. | +| Missing data importer and distribution separation | Carry over transactional Files ISO/XISO import and private image/map storage; remove default bundling of proprietary maps. | +| Mac disc imports | Retain a SHA-256-verified private ISO/XISO beside extracted maps; store that private image path so later launches and community tooling do not depend on the selected external file. | +| Existing saves/imports | Preserve old branch Documents/save and generation store, plus innerhat's previous extracted-map and save paths. IDs/accounts must remain unchanged for in-place updates. | +| iPad, touch and controller handling | Enable universal device family, bring over touch layout/D-pad/primary-controller routing and landscape handling. Correct the earlier swapped flashlight/grenade labels. | +| Metal / OpenGL switch | Metal remains the default; native OpenGL ES was restored with explicit driver dispatch and EAGL window framebuffer handling. Both are selected in the persisted shake menu. | +| MetalFX and shader diagnostics | Adapt optional MetalFX, bounded ANGLE blob cache and shareable source/timing reports to the upstream context. Separate Mac graphics window; shake modal on iOS. | +| Mac menus, audio during menus, Quit | Retain upstream's native menu/audio integration rather than overwrite it with the older host. | +| Mouse sensitivity | Carry over 1–100, default 20 (0.10 multiplier), preserving existing config units and vertical SAME. Expand matching menu capacities to 128. | +| App icon/splash | Keep upstream artwork and display names; personal distribution branding lives only on the downstream internal branch. | +| IPA packaging/re-signing | Carry over asset exclusion and local signer; adapt to upstream Halo executable and required memory entitlement. | + +## Validation + +- Native ARM Mac and signed device app compile; simulator builds use a separate + 1-TB guest arena because recent simulators reserve the device's 16-GB range. + The compiler pass, import trampolines and nested GL pointers all use the same + platform bias. Device and simulator images have separate build directories. +- Signed-image ABI/runtime probe passes; 16 ISO/XISO safety tests pass under + ASan/UBSan. A full private retail image was imported in the iPad simulator, + including verification, private copy and extraction; relaunch uses that copy. +- Mac and iPad simulator reach and render the main menu. The iPad graphics + modal is visible, and Mac MetalFX creates its scaler and runs to a clean exit. + Simulator MetalFX is explicitly unavailable, rather than linked against a + framework that the simulator SDK does not provide. +- Menu XML/capacity validation passes for 100 horizontal and 101 vertical entries. + Fresh Mac/iOS config generation, TOML parsing, sensitivity save and process + relaunch pass. Reordered upstream's misplaced display options to prevent + duplicate `[display]` sections that invalidated generated settings on relaunch. +- The broader input, geometry, presentation, visibility, game-settings and audio + fixture run has the same 5 failures, 7 errors and 1 skip on pristine + `036b9fe6` and this branch before the focused config fix. Those outdated fixture + failures are not claimed as passing or suppressed in CI. +- Device signing uses an explicit distribution identifier supplied at build time, + with Extended Virtual Addressing. Packaging excludes game images/maps, saves, + private keys and development configuration. Repository defaults remain unchanged. + +Still to validate on physical hardware: iPhone/iPad startup and gameplay with the +new memory layout, controller connect/disconnect, MetalFX quality/performance, +background/resume, and a full online match with a current matching web client. +No physical devices were installed or launched by the test tooling. The draft +status is intentional; compilation and simulator smoke tests do not establish +retail fidelity or network interoperability with older deployed clients. + +Mac importer regression checks also delete the original selected ISO and verify +that the retained image, extracted maps and reloaded preferences remain usable. +Failed imports leave the previous selection untouched. + +## Current networking + +See [protocol 28 and discovery validation](apple-network-28.md). diff --git a/docs/apple-network-28.md b/docs/apple-network-28.md new file mode 100644 index 0000000000..0aaf626199 --- /dev/null +++ b/docs/apple-network-28.md @@ -0,0 +1,139 @@ +# Apple server discovery and protocol 28 + +The Apple baseline is innerhat `036b9fe609bdba518711464ce24d2a2d9a3d7caf` (protocol 26). +This targeted update uses OpenCommunityEdition/OpenCE `b1ef7f7e` (protocol 28), +with the original authors retained on cherry-picked commits. It does not merge +upstream wholesale. The old protocol-11 narrative in the inherited fidelity +policy predates this Apple migration; it is not the protocol of these builds. + +## Changes and behavior + +- iOS previously omitted `brokers.txt`. Bundle it and atomically refresh the + default beside `save/config.toml` on launch. A separately named custom broker + file is untouched. +- Adopt the required map-sharing protocol and downloading-player admission, + multiplayer BSP/light behavior and reconnect fixes. Retain the original 30-Hz + simulation and the existing Apple graphics settings. +- Rank open games with room and no previous join failure first, by player count. + Sort the entire directory before truncating to a caller's visible rows, so a + busy game outside the first eight received records is not lost. +- Empty results name the supported network version. Incompatible versions are + still excluded; pretending to use their version would not be safe compatibility. +- Keep scenario music text suppressed, native Apple UI, personal branding split, + importer isolation and existing local transport/PB capability fixes. + +## Validation + +- The source-derived wire manifest exactly matches upstream version 28, including + the map-share structs and array dimensions (`tools/network_format.py`). +- Device guest and native Mac guest/host compile successfully. +- Isolated native Mac browser test joined the populated public "The Dude" + Blood Gulch server, loaded into play and exchanged replicated gameplay updates; + the process exited normally. No user saves were used, no automated shooting, + no map seeding and no router UPnP changes. This is a short interoperability + check, not full-match or physical-iPhone acceptance. +- Lobby ranking/truncation test under ASan/UBSan and both menu checks passed. +- Cache-format tests: 128 passed, four platform-dependent skips. +- Map-share filename/piece tests and their negative controls: 15 passed with + the native Mac compiler. The original harness's `-m32` cannot link on Apple + Silicon; this supplemental run omits that flag, so it is not a 32-bit ABI test. + The actual ILP32 device build and source wire check cover separate ABI evidence. +- Broker probing found populated v28 listings alongside older versions; player + counts are transient. Two of the four brokers answered the probe; discovery + intentionally uses redundant brokers. + +## Upstream decisions + +These are the 62 non-merge upstream commits not ancestors of the inherited +Apple baseline. Some were already cherry-picked/adapted by that baseline. Only +the selected networking changes and their dependencies are integrated here; +remaining renderer/web/presentation work is explicitly deferred. The selected +patches were checked against retained Apple changes rather than replacing whole +network files. This ledger is not a claim of gameplay validation for deferred +changes. + +| Commit | Purpose | Decision | +| --- | --- | --- | +| `d36c15c313dd96fbeb0f1a03431f000e18bd63e0` | Your own legs in first person, seen looking down (display.first_person_legs) | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `8664d2f24b2d855ea0369c27a5cb007f7757c570` | Your energy shield in a multiplayer armor color (display.shield_color) | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `2af955e1d6548e584066a4e68ce7431bb02c8994` | Space the campaign pause rows like the profile settings list. | Already equivalent in the inherited Apple baseline; no reapplication. | +| `1463c87090022950bc8b9e230fbaea4b0204e50b` | Energy shields glow as they flare, in their own color (display.shield_glow) | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `af7ad15d3c3bac0e459a719f6036f25db444b1e2` | Keep a respawned vehicle on the name its teleporter uses. | Already equivalent in the inherited Apple baseline; no reapplication. | +| `6ea142ceee1481eaf587b90a81f870e92da3fd94` | Fix co-op stale player handle crash | Already equivalent in the inherited Apple baseline; no reapplication. | +| `77794aa1b7a4dd70f20b4f2068a1a3a2482cd842` | Stand-in machines send the join request's hardware id | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `0f9a8904cd674e1cd3a5aad4a2a4c3eb69cc182c` | Test the stand-in machines' messages against network_messages.c | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `fb97e52663fde1c5a6b3bc116027113414fe1368` | Stale player handles forgotten in the port's loop (#250) | Already equivalent in the inherited Apple baseline; no reapplication. | +| `ebb1f093aed4ec7fac36dba81b7e37928b7f054f` | Campaign pause rows 30 apart, as the PC version's (#246) | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `21db5f906519d467c477309d56b18ccf7b751a06` | Vehicle homes: no other home's vehicle taken, no ridden one deleted (#249) | Already equivalent in the inherited Apple baseline; no reapplication. | +| `f735ca152d8a433245847b0a40b117d1ad3db26e` | First-person legs: one mesh kept, its waist forgotten on a new map (#243) | Already equivalent in the inherited Apple baseline; no reapplication. | +| `1f48f0555d0ec986af6cf4b012250f968a623677` | Shield glow: one light per unit in split screen, light show not clamped (#247) | Already equivalent in the inherited Apple baseline; no reapplication. | +| `095973133ee38e4d680fca9bff4138edc81abe43` | Shield color on the first-person arms too (#244) | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `acfa9a2a4ddcea41737d2f511fd15b819992f128` | Web build | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `a55a18208637c34bcd4ab49e94b222e0c1ed2dff` | Web deploy, tests, docs | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `532407588ed39392c091e716735016b6a76409ed` | Web loading indicator | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `4a9e515d54f9dac7e2de21f21ca49bc273989bba` | Web touch controls | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `47b7eac9a8911b879e236a067a48285f09df1f21` | Web rooms over WebRTC | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `887f40bd2c128b7b784193892c23ac41d8cae98c` | Web opaque canvas | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `6dc423fdbfadc2aaa0cf8a90de54075c73a46b2c` | Internet play with browsers | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `c89eb80d3abb404ecf283ea3650c87b74d1eddbc` | Web: no return addresses, SIMD page hash | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `19f1bd9f5dd27863ade762914b7a3ab5df73421c` | Web: pointer lock taken back on a click, wheel notches | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `248210cedbb731b628727d69212a12692ee71c35` | Keep first-person shield flares close to the arms | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `2e45259beafaced7c0d1cfca179d0e865a973ea5` | Delete tools/test_viewmodel_shield.py | Already equivalent in the inherited Apple baseline; no reapplication. | +| `0bfe2112892ec47aa6d78b5b5e8fc1da6434c1fa` | Web: stable pages hashed less often, fewer clock reads | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `01afcb654a1ce7f331ca500d6baeb881ccb0b1ce` | First-person shield flare kept close only with display.viewmodel_shield (#254) | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `6399ed9d4ab1e6bbcd6154d56703883dfcc17c22` | Text drawn a string at a time, web immediate draws packed | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `a3277d94dc51bfbeafd1cba52f279fa6da220f80` | Fewer draws and GL calls | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `5cd65a0cb0c3df69079e6c9af0bbd31d0df3dab9` | Web: meter blend in the shader | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `17a57832d8fc79867fc546eb2e5d3e5cb587d446` | Windows: tag block accessor builds again | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `e0b581b157999e90382668dbc5578528e85701cc` | Web: disclaimer footer | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `59a74b0c4b2975be46de751ee1818f514f4fd683` | The pregame screen opened through the port's menus everywhere | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `568f8c1fe7714423eb4a43843f50e98eac025e5f` | Web draws' uploads put in buffers no queued draw reads, so Metal does not wait | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `0a5a235f5e7813d4d6f6dae804352bc450d3173c` | Ask STUN and the brokers again after the p2p thread was stopped | Adopt: reconnect broker sockets and STUN after suspension. | +| `dc9b842ce2f155cf3274c3ce209231ac147c2920` | Draw with a base vertex on OpenGL ES 3.0 and 3.1 with the extension | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `1e7946659c5497c0448712bd1c3c60d1facc1da9` | config.toml with each section once, and a file with [display] twice read again | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `2059b6cef5339f4717b14c6cc98ec477aedfdd9e` | A player joining or leaving the lobby no longer restarts the countdown. | Adopt: do not restart the lobby countdown for playerless joining/leaving machines. | +| `71ceb363f19200d81b5ce71908577781487ccbc8` | Players who already have the map no longer wait for a machine that is still downloading it. | Adopt: a downloading machine does not hold up players with the map. | +| `a2aad18f65e6a5878bcb4daf553a2068e52897ee` | Custom Edition map sharing | Partial: protocol-27 map-transfer messages, verified pieces, peer reachability, cache support and settings. Retain native menu placement; exclude upstream AGENTS/web-only documentation changes. | +| `7a6fc70eb91078a1713cbf719114d48ceed4756f` | Fit the high-res text's fonts to the widths of a Custom Edition loc.map's letters | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `13ddcf802a0302291d534e9f75d50920d4e50483` | Keep a scenario's titles under the menus | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `67f22142197e40a35a36679751b5d794eeaba7bc` | A player still downloading the map no longer spawns until that player joins. | Adopt: hold downloading players out of simulation. | +| `05dd9ccdea94d8d3686ae55a9b71bc466d52b4e8` | Create the custom_maps folder on startup when it is missing. | Adopt: create the custom map directory before downloading. | +| `baab0780f6c230ef8a994e843057e6f1d1e16f16` | Adding a player after a map download uses the same checks as any other late join. | Adopt: admit downloaded players through the normal late-join queue. | +| `d6df944886a9e63807ef2af927ff07b8287df7be` | Bring a co-op team inside with a scripted teleport. | Adopt: host-authoritative co-op teleports to the script destination, including larger groups. | +| `5e5cb08820bbc422e199b4d88f82f90853d5d2cc` | Test that a scenario's titles stay under the menus | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `417de7e2ad0ad3b3b70f0502231db6b72d9db7df` | A multiplayer match keeps the flashlight past the Xbox player and vehicle limits. | Adopt: retain flashlights/lights beyond original player limits, required for version-28 agreement. | +| `c3365b1c6979b946c4a77cc274f75f9d8dd45650` | A multiplayer script that switches BSP to cycle the sky no longer reloads the level. | Adopt: prevent repeated multiplayer scripted BSP reloads. | +| `b71bd6a0faf5fa887eed3c22905afcbfe459facf` | Keep a multiplayer map's first scripted BSP switch so the host's game type chooses the sky. | Adopt: keep the first scripted multiplayer BSP chosen by the host game type. | +| `0b4a03561b004bc93e8a1178b01fad1463de9fab` | A large co-op fight respawns beside the group after ten seconds. | Already equivalent in the inherited Apple baseline; no reapplication. | +| `679bade466d55a49ebcaa818ae0d38e11f5a0f07` | Menu rows keep one vertical step. | Already equivalent in the inherited Apple baseline; no reapplication. | +| `2598dbe8b660ca5759a37e0a090a876179fd2e9b` | An overshield's drain is not a shield hit, and a second weapon is readied. | Already equivalent in the inherited Apple baseline; no reapplication. | +| `1068da0c3a98af143bdc7fb8b660aed5e7b0fe1b` | Compare shader keys and draw uniform inputs a word at a time | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `2c6c85cf38a7993d0a1740b04cc3e51ad754556e` | Raise the transparent geometry groups a frame from 384 to 2048 | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `9180bd37c81487218177fc5326d84a0361591c7a` | NETCODE.md: network versions 22 to 25 | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `fab098a7bd6f7d0a3114b5dd66c408016b37a24f` | Record the wire format of the network version | Partial: adopt the wire-format checker and manifest at their final upstream revisions. | +| `eea1c3641a2645330940effaeb8bc342601d5978` | Keep the debug model vertices off the stack | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `e791cf9ae08f5a139e8a1fadbcbc765b6b2a6f41` | Bodies the host moves are no longer drawn floating or in walls on clients. | Defer: no changes selected for this native discovery/protocol update; inherited adaptations remain. | +| `5a7c655a60555215864391433716ddd966ba3079` | Testing branch review fixes | Partial: protocol 28, map-download timeout/admission fixes and safe co-op spawn/teleport positions. Exclude unrelated menu-coordinate and shield-HUD edits. | +| `3d67397cdd3c9ee1515b62cf5a8168cf3def0730` | Record the wire format at network version 28 (#253) | Adopt: version-28 wire manifest. | +| `c6a2f9004bb182ac6c11fa0fe902d59f3a7cfcb4` | Record the map sharing messages and the sizes of the wire format's arrays (#253) | Adopt: include map-sharing structs and array-size definitions in the wire manifest/checker. | + +## Menu integration correction (builds iOS 24 / macOS 17) + +The first protocol-28 release contained `platform="desktop android"` on the +map-sharing widget. This branch's runtime XML reader accepts a single platform +name; the invalid value rejected the entire custom menu set and fell back to +Xbox menus, hiding the server browser. The live Mac network test bypassed this +menu path and therefore did not establish browser usability. Removing the +unnecessary platform attribute restores the shared widget on both platforms. +The XML validation now matches the runtime's single-value rule. + +With the corrected guest image, an iPhone simulator loaded 1006 menu widgets. +Opening Multiplayer's Server Browser displayed 35 public servers, ordered by +joinable population (38 players on the first listing at capture time). +An XCTest-driven simulator check confirmed landscape application geometry +with portrait, landscape-left and landscape-right device orientations, plus +background/resume. The scene's launch-side lock was removed so the two supported +landscape orientations can follow rotation. Device and simulator builds passed; +physical-device rotation has not yet been retested. Screenshot pixel dimensions +alone are not an orientation assertion: XCTest image orientation metadata may +leave raw image dimensions portrait even when the app frame is landscape. diff --git a/docs/apple-original-pr-review.md b/docs/apple-original-pr-review.md new file mode 100644 index 0000000000..6a7c7a48f7 --- /dev/null +++ b/docs/apple-original-pr-review.md @@ -0,0 +1,58 @@ +# Follow-up to the original Apple PR review + +Reviewed [OpenCommunityEdition/OpenCE #64](https://github.com/OpenCommunityEdition/OpenCE/pull/64) +on 2026-10-12. GitHub returned two discussion comments and no inline review +comments or submitted reviews. This follow-up targets +[innerhat-dev/OpenCE #1](https://github.com/innerhat-dev/OpenCE/pull/1). + +## Gameplay and controls + +Source: [UEMxMDE's six-part report](https://github.com/OpenCommunityEdition/OpenCE/pull/64#issuecomment-6028960061). + +| Feedback | Current disposition | +| --- | --- | +| No ordinary split-screen multiplayer or co-op entry | The innerhat base supplies multiplayer lobby **Add Player** / controller **Start** joining and **Co-op Campaign**. The menu fallback bug that hid the current menus is fixed. This revision also removes the desktop single-gamepad reassignment rule on iOS, where touch already occupies player one's port. Physical multi-controller gameplay still needs testing. | +| Cannot use touch and a controller as separate players | Added opt-in **Touch as a separate player** to the shake settings, persisted across launches. After relaunch, touch stays visible on player one and hardware controllers have separate slots. The default remains automatic touch hiding and shared player one, as requested by this fork's owner. No hide-controls switch is offered when touch is the only input. | +| SWAP G and LIGHT swapped | Already corrected in the migrated iOS controls: LIGHT → SDL left shoulder → Xbox white → flashlight; SWAP G → SDL right shoulder → Xbox black → switch grenades, with the default profile bindings. This is not a new upstream fix in this revision. | +| Pause-menu navigation moves the player | Already addressed by upstream [129d3483](https://github.com/innerhat-dev/OpenCE/commit/129d3483dcf0602eb46b033738dd92cae960500c). `player_control_port_menu_has_controller` suppresses gameplay input for the player whose controller owns a menu. A network match itself continues running; other players are not paused. Retained without replacing upstream's fix. | +| Custom Edition support and cross-play | Partial: the current protocol-28 integration includes upstream community-map discovery/transfer and download admission. It does not provide an iOS standalone Custom Edition import/switcher, and no claim is made that arbitrary Xbox and CE content can share a match. Retail ISO import remains the startup path; full CE content import and end-to-end CE-map interoperability remain follow-up work. | +| Move/remap/add/remove touch controls, reset, named layouts; physical remapping | A full touch-layout editor and named controller-layout manager are not implemented. These are feature requests, not closed by the routing fix. Track separately rather than claim the graphics settings or default profile bindings satisfy them. | + +## Hosting and CI + +Source: [cybersecurity's hosting/testing concern](https://github.com/OpenCommunityEdition/OpenCE/pull/64#issuecomment-6075561927). + +The contribution moved to the Apple-maintained innerhat fork, whose base already +contains macOS workflows. It asks for no workflow or runner changes in the old +OpenCommunityEdition repository. This does not eliminate Apple runner costs or +transfer physical-device testing responsibility to the original maintainer. +The controller regression checks extend the existing Apple job; they add no +new runner job. Personal bundle identifiers and artwork remain downstream on +`apple/ios-internal`, outside this PR. + +## Validation and limits + +`tools/test_ios_controller_routing.py` compiles the production routing functions +with deterministic discovery/input and ASan/UBSan. It checks default sharing, +separate-player isolation, controller disconnect/reconnect, bounded enumeration, +modal input suppression, and the different iOS/desktop single-controller rules. +These checks establish routing behavior, not a complete multiplayer match. +The device build validates the Objective-C settings/controls and iOS guest code. +No physical iPhone/iPad installation or play session was performed for this review. + +## LiveContainer startup follow-up + +[UEMxMDE's follow-up](https://github.com/innerhat-dev/OpenCE/pull/1#issuecomment-6139443664) +reports successful import followed by `KERN_INVALID_ADDRESS` reserving the +fixed guest arena on iPhone 17 Pro Max / iOS 26.0. The logs do not distinguish +missing host-process Extended Virtual Addressing from other container VM +restrictions. LiveContainer documents that guest entitlements do not apply to +its host; its file-picker workaround does not resolve this requirement. + +The startup memory check now runs before import, logs the requested base/size +and kernel code, and directs users to standalone signing without deleting data. +The fatal SDL message box is replaced by a dedicated scene-backed, scrollable +UIKit screen. Its Copy details and Close buttons remain outside the scrolling +text. A forced-error XCTest on an iPhone simulator exercised both buttons on +three launches with orientation changes. An unsigned device build also passed. +This is failure-path validation, not a LiveContainer compatibility result. diff --git a/docs/custom_edition_caches.md b/docs/custom_edition_caches.md index 2b5177f1f9..215b51b7b7 100644 --- a/docs/custom_edition_caches.md +++ b/docs/custom_edition_caches.md @@ -33,6 +33,26 @@ network games, and is never taken for one of the campaign's levels (saved games, progress, the next level). The game engine keeps 63 characters of a level name, so a map whose file name is longer than 51 is not listed. +A map in `custom_maps` is named for its contents once the map lists (or a +host picking it) find it: `hugeass.map` becomes `hugeass_0e2b3ee4.map`, the +first 8 hexadecimal digits of its hash after `_`, its `.bmp` and `.txt` +renamed with it (`port/linux/game/map_share_files.c`; the name is cut short +to keep the level name within 63 characters). A network game's level name +then says which version of the map it is on, a player without it downloads +it under that name (`port/linux/NETCODE.md`, "Map sharing"), and two +versions of a map keep apart. A level name without the hash (a saved last +map, a test's) finds the one map of that name in `custom_maps`. The menus +show a map without its hash, unless two versions of it are listed. Maps in +a Custom Edition install's folder keep their names. + +A map needs only the resource maps that hold its tags: its tag index says +which (`cache_file_resource_maps_needed`, `cache_file_formats.c`: a tag +whose entry's word at 0x18 is set is held whole by `bitmaps.map` for a +bitmap, `sounds.map` for a sound, `loc.map` for a font, string list or HUD +message text). A map without such tags of a kind plays without that +resource map; one that lacks a resource map it needs is refused, saying +which. + ### In the menus The map lists (New Game's, and the Map screen of Create Game's INTERNET and @@ -438,8 +458,14 @@ a campaign level at the difficulty chosen; the Map screen's (LAN) listed its difficulties, set up co-op in Server Setup and showed it in the lobby as co-op. With `debug.network_test`, two machines on loopback played `custom_maps\a30` as network co-op and `custom_maps\hugeass` as slayer; a -joining machine without the map, without the resource maps, or with a maps -folder missing the host's Xbox map was shown each error and left the game. +joining machine with a maps folder missing the host's Xbox map was shown the +error and left the game. A joining machine without `hugeass` (or without +`sounds.map` and `loc.map` too) downloaded them and played (map sharing, +`port/linux/NETCODE.md`): in the lobby, into a game in progress, from the +host and another player at once with a second machine downloading beside it +(on loopback, and through internet play's tunnels with a local broker), each +file the same as the host's; with `network.map_download` off, or a host with +`network.map_share` off, it was shown the error and left. ### Not tested diff --git a/docs/xbox-fidelity.md b/docs/xbox-fidelity.md index d0b18f65a8..8b9eaf95ce 100644 --- a/docs/xbox-fidelity.md +++ b/docs/xbox-fidelity.md @@ -6,6 +6,13 @@ decompilation, core performance, stability and platform improvements should stay current through selective upstream review. Original game presentation and rules remain the baseline; an upstream change is not accepted merely because it is new. +## Apple migration and current networking + +The current Apple contribution inherits innerhat `036b9fe6` (protocol 26), +then selectively integrates protocol 28 and discovery fixes. See the +[Apple network review](apple-network-28.md) for source revisions, decisions and +validation. The historical review below describes the earlier fork baseline. + ## Pinned baseline and upstream review The last full upstream integration is `c55e4e2b` (build 64), merged into the diff --git a/port/apple/game_store.h b/port/apple/game_store.h new file mode 100644 index 0000000000..cebc8acada --- /dev/null +++ b/port/apple/game_store.h @@ -0,0 +1,7 @@ +#import +#include "xiso.h" +/* The root contains an atomically published generation holding the private + image, integrity manifest and extracted map cache. Saves live outside it. */ +NSString *halo_game_store_current(NSString *root); +BOOL halo_game_store_import(NSURL *image, NSString *root, xiso_progress_proc progress, + void *context, NSString **failure); diff --git a/port/apple/game_store.m b/port/apple/game_store.m new file mode 100644 index 0000000000..be8d74d1fb --- /dev/null +++ b/port/apple/game_store.m @@ -0,0 +1,86 @@ +#import "game_store.h" +#import +#include +#include +#include + +static NSString *digest_file(NSString *path, FILE *copy, xiso_progress_proc progress, + void *context, const char *phase, NSString **failure) { + FILE *input=fopen(path.fileSystemRepresentation,"rb"); + if(!input){*failure=@"Could not read the disc image.";return nil;} + struct stat before,after; + if(fstat(fileno(input),&before) || !S_ISREG(before.st_mode) || before.st_size<=0) { + fclose(input);*failure=@"Choose a non-empty disc image file.";return nil; + } + CC_SHA256_CTX hash;CC_SHA256_Init(&hash); + unsigned char *buffer=malloc(1024*1024);uint64_t done=0;BOOL ok=buffer!=NULL; + while(ok) { + if(progress && !progress(context,phase,done,before.st_size)){*failure=@"Import cancelled.";ok=NO;break;} + size_t count=fread(buffer,1,1024*1024,input); + if(!count){if(ferror(input))ok=NO;break;} + if(copy && fwrite(buffer,1,count,copy)!=count){ok=NO;break;} + CC_SHA256_Update(&hash,buffer,(CC_LONG)count);done+=count; + } + if(fstat(fileno(input),&after) || before.st_size!=after.st_size || done!=(uint64_t)before.st_size || + before.st_mtimespec.tv_sec!=after.st_mtimespec.tv_sec || before.st_mtimespec.tv_nsec!=after.st_mtimespec.tv_nsec)ok=NO; + free(buffer);fclose(input); + if(!ok){if(!*failure)*failure=@"Could not copy the complete image. Check available storage and try again.";return nil;} + unsigned char digest[CC_SHA256_DIGEST_LENGTH];CC_SHA256_Final(digest,&hash); + NSMutableString *result=[NSMutableString new];for(int i=0;i +void halo_debug_present(void); +void halo_debug_install_menu(void); +void halo_debug_initialize(void); +int halo_debug_is_presented(void); +void halo_debug_use_program(uint32_t program); +double halo_debug_draw_begin(void); +void halo_debug_draw_end(double started); +void halo_debug_shader_source(uint32_t shader,int count,const char *const *sources,const int *lengths); +void halo_debug_attach(uint32_t program,uint32_t shader); +double halo_debug_begin(void); +void halo_debug_end(uint32_t object,int program,double started); +void halo_debug_forget(uint32_t object,int program); +int halo_metalfx_enabled(void); +void halo_metalfx_set_enabled(int enabled); +int halo_metalfx_supported(void); +int host_ios_metalfx_present(unsigned int texture,unsigned int width,unsigned int height,unsigned int output_width,unsigned int output_height); +unsigned int host_ios_render_height(void); + +void halo_debug_replay_shader_report(void); +void halo_debug_test_export(void); + +void halo_debug_configure_launch(void); +int halo_debug_frame_limit(void); +void halo_debug_frame(double milliseconds); + +int halo_metalfx_take_presented(void); diff --git a/port/apple/host_debug.m b/port/apple/host_debug.m new file mode 100644 index 0000000000..ca7b453867 --- /dev/null +++ b/port/apple/host_debug.m @@ -0,0 +1,459 @@ +/* Shader-source diagnostics are exportable data, never game maps or saves. */ +#if !defined(HALO_IOS) +#import +#else +#import +#endif +#import +#include +#import +#include +#include +#include +#include "host_debug.h" +#ifdef HALO_IOS +void host_ios_touch_focus(void); +void host_ios_touch_reset(void); +#endif +#include "host.h" +#include "host_gl_dispatch.h" + +static atomic_bool recording; +static NSMutableDictionary *shaderKeys,*sources,*attachments; +static NSMutableArray *events; +static NSMutableSet *drawnPrograms; +static uint32_t currentProgram; +static NSString *glRenderer; +#ifndef HALO_BUILD_REVISION +#define HALO_BUILD_REVISION "unknown" +#endif +static NSUInteger sourceBytes,dropped; +static int frameLimit = 60; +static BOOL showFPS; +static NSMutableArray *frameSamples; +static double frameTotal,frameWorst; +static NSUInteger frameCount; +static double lastSample; +#ifdef HALO_IOS +static UILabel *fpsLabel; +static void exportReport(UIViewController *presenter); +#endif +int halo_debug_renderer_preference(void) { +#ifdef HALO_IOS + NSString *forced=NSProcessInfo.processInfo.environment[@"HALO_IOS_TEST_RENDERER"]; + NSString *setting=forced?:[NSUserDefaults.standardUserDefaults stringForKey:@"HaloRenderer"]; + return ![setting isEqualToString:@"opengl"]; +#else + return 1; +#endif +} +void halo_debug_set_renderer_preference(int metal) { + [NSUserDefaults.standardUserDefaults setObject:metal?@"metal":@"opengl" forKey:@"HaloRenderer"]; +} +int halo_debug_frame_limit(void) {return frameLimit;} +void halo_debug_configure_launch(void) { +#ifdef HALO_IOS + NSUserDefaults *settings=NSUserDefaults.standardUserDefaults; + [settings registerDefaults:@{@"HaloRenderHeight":@720,@"HaloFrameLimit":@60,@"HaloFPSOverlay":@NO,@"HaloInterpolation":@YES}]; + int height=(int)[settings integerForKey:@"HaloRenderHeight"]; + if (!height) { + CGSize pixels=UIScreen.mainScreen.nativeBounds.size; + height=(int)MIN(pixels.width,pixels.height); + } + char value[32];snprintf(value,sizeof(value),"%d",height); + setenv("HALO_IOS_RENDER_HEIGHT",value,0); + setenv("HALO_CONSOLE_LOG","none",0); + setenv("HALO_INTERPOLATION",[settings boolForKey:@"HaloInterpolation"]?"1":"0",0); + setenv("HALO_IOS_REFRESH_RATE","60",0); + frameLimit=(int)[settings integerForKey:@"HaloFrameLimit"]; + if(frameLimit!=30 && frameLimit!=60 && frameLimit!=120)frameLimit=60; + if(frameLimit==120)setenv("HALO_IOS_REFRESH_RATE","120",1); + showFPS=[settings boolForKey:@"HaloFPSOverlay"]; +#endif +} +void halo_debug_frame(double milliseconds) { +#ifdef HALO_IOS + if(milliseconds<=0)return; + frameCount++;frameTotal+=milliseconds;frameWorst=MAX(frameWorst,milliseconds); + double now=CACurrentMediaTime(); + if(now-lastSample<0.5)return; + if(!frameSamples)frameSamples=[NSMutableArray new]; + double mean=frameTotal/MAX((NSUInteger)1,frameCount); + [frameSamples addObject:@{@"time":@(NSDate.date.timeIntervalSince1970),@"frames":@(frameCount),@"mean_ms":@(mean),@"worst_ms":@(frameWorst),@"thermal_state":@(NSProcessInfo.processInfo.thermalState)}]; + if(frameSamples.count>1200)[frameSamples removeObjectAtIndex:0]; + if(showFPS && !fpsLabel) { + UIWindow *window=nil; + for(UIWindowScene *scene in UIApplication.sharedApplication.connectedScenes) + if([scene isKindOfClass:UIWindowScene.class])for(UIWindow *w in scene.windows)if(w.isKeyWindow)window=w; + if(window) { + fpsLabel=[[UILabel alloc]initWithFrame:CGRectMake(window.safeAreaInsets.left+12,window.safeAreaInsets.top+8,160,26)]; + fpsLabel.font=[UIFont monospacedDigitSystemFontOfSize:13 weight:UIFontWeightSemibold]; + fpsLabel.textColor=UIColor.whiteColor;fpsLabel.backgroundColor=[UIColor colorWithWhite:0 alpha:.6]; + fpsLabel.userInteractionEnabled=NO;fpsLabel.accessibilityLabel=@"Frame rate"; + [window.rootViewController.view addSubview:fpsLabel]; + } + } + if(fpsLabel.superview) { + UIEdgeInsets insets=fpsLabel.superview.safeAreaInsets; + fpsLabel.frame=CGRectMake(insets.left+12,insets.top+8,160,26); + } + fpsLabel.hidden=!showFPS; + fpsLabel.text=[NSString stringWithFormat:@" %.0f FPS · %.1f ms",1000.0/mean,mean]; + frameCount=0;frameTotal=frameWorst=0;lastSample=now; + static BOOL testExported; + if(!testExported && frameSamples.count>=10 && [NSProcessInfo.processInfo.environment[@"HALO_IOS_TEST_REPORT_EXPORT"] boolValue]) { + testExported=YES;exportReport(nil); + } +#else + (void)milliseconds; +#endif +} + +#if !defined(HALO_IOS) +static BOOL debugPresented; +#else +static __weak UIViewController *debugController; +#endif +void halo_debug_initialize(void) { + NSUserDefaults *settings=NSUserDefaults.standardUserDefaults; + atomic_store(&recording,[settings boolForKey:@"HaloRecordShaderStalls"] || [NSProcessInfo.processInfo.environment[@"HALO_IOS_TEST_SHADER_CAPTURE"] boolValue]); + halo_metalfx_set_enabled([settings boolForKey:@"HaloMetalFX"] || [NSProcessInfo.processInfo.environment[@"HALO_IOS_TEST_METALFX"] boolValue]); +} +static void prepare(void) { + if(!shaderKeys){shaderKeys=[NSMutableDictionary new];sources=[NSMutableDictionary new];attachments=[NSMutableDictionary new];events=[NSMutableArray new];drawnPrograms=[NSMutableSet new];} +} +void halo_debug_shader_source(uint32_t shader,int count,const char *const *strings,const int *lengths) { + prepare();if(!glRenderer){const GLubyte *name=glGetString(GL_RENDERER);if(name)glRenderer=[NSString stringWithUTF8String:(const char *)name];} + [shaderKeys removeObjectForKey:@(shader)];NSMutableData *data=[NSMutableData new]; + for(int i=0;i=0?(NSUInteger)lengths[i]:strlen(strings[i]); + if(length>1024*1024 || data.length+length>1024*1024){dropped++;return;} + [data appendBytes:strings[i] length:length]; + } + unsigned char digest[CC_SHA256_DIGEST_LENGTH];CC_SHA256(data.bytes,(CC_LONG)data.length,digest); + NSMutableString *key=[NSMutableString new];for(int i=0;i32*1024*1024){dropped++;return;} + GLint type=0;glGetShaderiv(shader,GL_SHADER_TYPE,&type); + sources[key]=@{@"source":text,@"type":type==GL_VERTEX_SHADER?@"vertex":@"fragment"};sourceBytes+=data.length; + } + shaderKeys[@(shader)]=key; +} +void halo_debug_attach(uint32_t program,uint32_t shader) { + prepare();NSMutableArray *list=attachments[@(program)]; + if(!list){list=[NSMutableArray new];attachments[@(program)]=list;} + NSString *key=shaderKeys[@(shader)];if(key && ![list containsObject:key])[list addObject:key]; +} +double halo_debug_begin(void) {return CACurrentMediaTime();} +void halo_debug_end(uint32_t object,int program,double started) { + if(!started)return; + GLint ok=0; + if(program)glGetProgramiv(object,GL_LINK_STATUS,&ok);else glGetShaderiv(object,GL_COMPILE_STATUS,&ok); + double ms=(CACurrentMediaTime()-started)*1000; + if(ms<2 && ok)return; + prepare();if(events.count>=4096){dropped++;return;} + NSArray *keys=program?(attachments[@(object)]?:@[]):(shaderKeys[@(object)]?@[shaderKeys[@(object)]]:@[]); + [events addObject:@{@"stage":program?@"link":@"compile",@"milliseconds":@(ms),@"success":@(ok!=0),@"sources":keys,@"time":@([NSDate date].timeIntervalSince1970)}]; +} +void halo_debug_use_program(uint32_t program) {currentProgram=program;} +double halo_debug_draw_begin(void) { + if(!atomic_load(&recording) || !currentProgram)return 0; + prepare();if([drawnPrograms containsObject:@(currentProgram)])return 0; + [drawnPrograms addObject:@(currentProgram)]; + /* Drain older work outside the timed interval. This is deliberately + intrusive diagnostics, never enabled during normal rendering. */ + glFinish();return CACurrentMediaTime(); +} +void halo_debug_draw_end(double started) { + if(!started)return; + glFinish();double ms=(CACurrentMediaTime()-started)*1000; + if(ms<2)return; + if(events.count>=4096){dropped++;return;} + [events addObject:@{@"stage":@"first_draw_wait",@"milliseconds":@(ms),@"sources":[attachments[@(currentProgram)] copy]?:@[],@"time":@([NSDate date].timeIntervalSince1970)}]; +} +#if !defined(HALO_IOS) +int halo_debug_is_presented(void) {return debugPresented;} +#else +int halo_debug_is_presented(void) {return debugController!=nil;} +#endif +void halo_debug_forget(uint32_t object,int program) { + if(program)[drawnPrograms removeObject:@(object)]; + if(program)[attachments removeObjectForKey:@(object)];else [shaderKeys removeObjectForKey:@(object)]; +} +#if !defined(HALO_IOS) +static void exportReport(id presenter) { +#else +static void exportReport(UIViewController *presenter) { +#endif + prepare();struct utsname device;uname(&device); + NSMutableDictionary *programs=[NSMutableDictionary new]; + for(NSNumber *name in attachments)programs[name.stringValue]=[attachments[name] copy]; + NSMutableDictionary *report=[@{@"schema":@3,@"renderer":halo_graphics_metal()?@"Metal (ANGLE)":@"OpenGL ES (Apple)",@"gpu":glRenderer?:@"unknown",@"revision":@HALO_BUILD_REVISION,@"metalfx":@(halo_metalfx_enabled()!=0),@"device":[NSString stringWithUTF8String:device.machine],@"os":NSProcessInfo.processInfo.operatingSystemVersionString, + @"build":[NSBundle.mainBundle objectForInfoDictionaryKey:@"CFBundleVersion"]?:@"unknown",@"threshold_ms":@2, + @"note":@"CPU compile/link completion timings are always recorded. Synchronized first-use draw waits are opt-in. First-use waits include draw cost and may include deferred driver compilation; they are not isolated shader-compiler timings. Shader sources can guide warm-up updates.", + @"first_use_gpu_recording":@(atomic_load(&recording)),@"programs":programs, + @"requested_render_height":@([NSUserDefaults.standardUserDefaults integerForKey:@"HaloRenderHeight"]),@"frame_limit":@(frameLimit),@"fps_overlay":@(showFPS),@"frame_samples":[frameSamples copy]?:@[],@"dropped":@(dropped),@"events":[events copy],@"sources":[sources copy]} mutableCopy]; +#ifdef HALO_IOS + report[@"render_height"]=@(atoi(getenv("HALO_IOS_RENDER_HEIGHT")?:"720")); + report[@"interpolation"]=@(!strcmp(getenv("HALO_INTERPOLATION")?:"0","1")); +#endif + NSError *error=nil;NSData *data=[NSJSONSerialization dataWithJSONObject:report options:NSJSONWritingPrettyPrinted error:&error]; + NSURL *folder=[[NSFileManager.defaultManager URLsForDirectory:NSCachesDirectory inDomains:NSUserDomainMask].firstObject URLByAppendingPathComponent:@"ShaderReports" isDirectory:YES]; + [NSFileManager.defaultManager createDirectoryAtURL:folder withIntermediateDirectories:YES attributes:nil error:&error]; + NSArray *oldReports=[[NSFileManager.defaultManager contentsOfDirectoryAtPath:folder.path error:nil] filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSString *name,NSDictionary *bindings){(void)bindings;return [name hasPrefix:@"halo-shaders-"] && [name hasSuffix:@".json"];}]]; + oldReports=[oldReports sortedArrayUsingSelector:@selector(compare:)]; + for(NSUInteger i=0;i+42*1024*1024?length-2*1024*1024:0]; + NSData *tail=[input readDataToEndOfFile];[input closeFile]; + NSURL *log=[folder URLByAppendingPathComponent:[NSString stringWithFormat:@"halo-session-%d.txt",i]]; + if([tail writeToURL:log atomically:YES])[files addObject:log]; + } + } + if([NSProcessInfo.processInfo.environment[@"HALO_IOS_TEST_REPORT_EXPORT"] boolValue])return; + UIActivityViewController *share=[[UIActivityViewController alloc]initWithActivityItems:files applicationActivities:nil]; + share.popoverPresentationController.sourceView=presenter.view;share.popoverPresentationController.sourceRect=CGRectMake(CGRectGetMidX(presenter.view.bounds),40,1,1); + [presenter presentViewController:share animated:YES completion:nil]; +} +@interface HaloDebugController : UITableViewController +@end +@implementation HaloDebugController +- (void)viewDidLoad { + [super viewDidLoad];self.title=@"Graphics & Diagnostics"; + self.navigationItem.rightBarButtonItem=[[UIBarButtonItem alloc]initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:self action:@selector(done)]; +} +- (void)done {[self dismissViewControllerAnimated:YES completion:^{debugController=nil;host_ios_touch_focus();}];} +- (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController { + (void)presentationController;debugController=nil;host_ios_touch_focus(); +} +- (NSInteger)tableView:(UITableView *)table numberOfRowsInSection:(NSInteger)section {(void)table;(void)section;return 10;} +- (UITableViewCell *)tableView:(UITableView *)table cellForRowAtIndexPath:(NSIndexPath *)path { + (void)table;UITableViewCell *cell=[[UITableViewCell alloc]initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:nil]; + cell.detailTextLabel.numberOfLines=0; + NSUserDefaults *settings=NSUserDefaults.standardUserDefaults; + switch(path.row) { + case 0: { + cell.textLabel.text=@"Renderer"; + cell.detailTextLabel.text=@"Metal (ANGLE) or native OpenGL ES. Relaunch to apply."; + UISegmentedControl *control=[[UISegmentedControl alloc]initWithItems:@[@"OpenGL",@"Metal"]]; + control.selectedSegmentIndex=halo_graphics_prefer_metal()?1:0; + [control addTarget:self action:@selector(rendererChanged:) forControlEvents:UIControlEventValueChanged]; + cell.accessoryView=control;break; + } + case 1: { + NSInteger height=[settings integerForKey:@"HaloRenderHeight"]; + cell.textLabel.text=height?[NSString stringWithFormat:@"Render resolution: %ldp",(long)height]:@"Render resolution: Native"; + cell.detailTextLabel.text=@"720p balances detail and GPU load. Lower values trade detail for speed. Relaunch to apply."; + cell.accessoryType=UITableViewCellAccessoryDisclosureIndicator;break; + } + case 2: + cell.textLabel.text=[NSString stringWithFormat:@"Frame limit: %d FPS",frameLimit]; + cell.detailTextLabel.text=@"Limits rendering only; game simulation stays at 30 Hz. 120 FPS needs a compatible display and relaunch."; + cell.accessoryType=UITableViewCellAccessoryDisclosureIndicator;break; + case 3: case 4: case 5: { + UISwitch *toggle=[UISwitch new];toggle.tag=path.row; + if(path.row==3) { + cell.textLabel.text=@"MetalFX spatial upscaling"; + cell.detailTextLabel.text=@"Upscale the render image to the display. Requires the active Metal renderer."; + toggle.enabled=halo_graphics_metal() && halo_metalfx_supported();toggle.on=halo_metalfx_enabled(); + } else if(path.row==4) { + cell.textLabel.text=@"FPS overlay";cell.detailTextLabel.text=@"Show measured game frames and frame time.";toggle.on=showFPS; + } else { + cell.textLabel.text=@"Record first-use GPU stalls"; + cell.detailTextLabel.text=@"Diagnostic only: synchronization can slow play. Normal session timing and shader compilation are recorded without it."; + toggle.on=atomic_load(&recording); + } + [toggle addTarget:self action:@selector(changed:) forControlEvents:UIControlEventValueChanged];cell.accessoryView=toggle;break; + } + case 6:cell.textLabel.text=@"Share play-session report…";cell.detailTextLabel.text=@"AirDrop logs, graphics settings, frame timings and shader diagnostics to your Mac. No game image or saves.";break; + case 8: { + UISwitch *toggle=[UISwitch new];toggle.tag=8;toggle.on=[settings boolForKey:@"HaloInterpolation"]; + cell.textLabel.text=@"Smooth motion";cell.detailTextLabel.text=@"Interpolate rendered motion as in the earlier Apple build. Simulation stays at 30 Hz. Relaunch to apply."; + [toggle addTarget:self action:@selector(changed:) forControlEvents:UIControlEventValueChanged];cell.accessoryView=toggle;break; + } + case 9: { + UISwitch *toggle=[UISwitch new];toggle.tag=9;toggle.on=[settings boolForKey:@"HaloSeparateTouchPlayer"]; + cell.textLabel.text=@"Touch as a separate player"; + cell.detailTextLabel.text=@"Touch controls stay visible for player 1; controllers join as other players. Use Add Player in the lobby. Relaunch to apply."; + [toggle addTarget:self action:@selector(changed:) forControlEvents:UIControlEventValueChanged];cell.accessoryView=toggle;break; + } + case 7:cell.textLabel.text=@"Clear recorded diagnostics";cell.detailTextLabel.text=@"Start a fresh measurement for your next play session.";break; + } + return cell; +} +- (void)rendererChanged:(UISegmentedControl *)sender { + halo_graphics_set_preference(sender.selectedSegmentIndex==1);[self.tableView reloadData]; +} +- (void)changed:(UISwitch *)sender { + NSUserDefaults *settings=NSUserDefaults.standardUserDefaults; + if(sender.tag==9)[settings setBool:sender.on forKey:@"HaloSeparateTouchPlayer"]; + if(sender.tag==8)[settings setBool:sender.on forKey:@"HaloInterpolation"]; + if(sender.tag==3){halo_metalfx_set_enabled(sender.on);[settings setBool:sender.on forKey:@"HaloMetalFX"];} + if(sender.tag==4){showFPS=sender.on;fpsLabel.hidden=!showFPS;[settings setBool:sender.on forKey:@"HaloFPSOverlay"];} + if(sender.tag==5){atomic_store(&recording,sender.on);[drawnPrograms removeAllObjects];[settings setBool:sender.on forKey:@"HaloRecordShaderStalls"];} +} +- (void)tableView:(UITableView *)table didSelectRowAtIndexPath:(NSIndexPath *)path { + [table deselectRowAtIndexPath:path animated:YES]; + if(path.row==6){exportReport(self);return;} + if(path.row==7){[events removeAllObjects];[drawnPrograms removeAllObjects];[frameSamples removeAllObjects];frameTotal=frameWorst=0;frameCount=0;dropped=0;[table reloadData];return;} + if(path.row!=1 && path.row!=2)return; + BOOL resolution=path.row==1; + UIAlertController *choice=[UIAlertController alertControllerWithTitle:resolution?@"Render resolution":@"Frame limit" message:resolution?@"Resolution changes apply after relaunch.":@"Higher limits use more power and may increase heat." preferredStyle:UIAlertControllerStyleActionSheet]; + NSArray *values=resolution?@[@480,@720,@900,@1080,@0]:@[@30,@60,@120]; + for(NSNumber *value in values) { + NSString *title=resolution?(value.intValue?[NSString stringWithFormat:@"%@p",value]:@"Native"):[NSString stringWithFormat:@"%@ FPS",value]; + [choice addAction:[UIAlertAction actionWithTitle:title style:UIAlertActionStyleDefault handler:^(UIAlertAction *action){ + (void)action; + [NSUserDefaults.standardUserDefaults setInteger:value.integerValue forKey:resolution?@"HaloRenderHeight":@"HaloFrameLimit"]; + if(!resolution)frameLimit=value.intValue; + [table reloadData]; + }]]; + } + [choice addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + choice.popoverPresentationController.sourceView=[table cellForRowAtIndexPath:path]; + choice.popoverPresentationController.sourceRect=[table cellForRowAtIndexPath:path].bounds; + [self presentViewController:choice animated:YES completion:nil]; +} +@end +void halo_debug_present(void) { + if(debugController)return;prepare(); + UIWindow *window=nil;for(UIScene *scene in UIApplication.sharedApplication.connectedScenes)if([scene isKindOfClass:UIWindowScene.class])for(UIWindow *w in ((UIWindowScene*)scene).windows)if(w.isKeyWindow)window=w; + UIViewController *presenter=window.rootViewController; + while(presenter.presentedViewController)presenter=presenter.presentedViewController; + if(!presenter)return; + host_ios_touch_reset();HaloDebugController *controller=[[HaloDebugController alloc]initWithStyle:UITableViewStyleInsetGrouped]; + UINavigationController *navigation=[[UINavigationController alloc]initWithRootViewController:controller]; + navigation.modalPresentationStyle=UIModalPresentationPageSheet;debugController=navigation; + [presenter presentViewController:navigation animated:YES completion:nil]; + navigation.presentationController.delegate=controller; +} + +#endif +#if !defined(HALO_IOS) +@interface HaloDebugActions : NSWindowController +@property NSButton *upscaleButton; +@property NSButton *recordButton; +@property NSTextField *eventCount; +@end +@implementation HaloDebugActions +- (instancetype)init { + NSWindow *window=[[NSWindow alloc]initWithContentRect:NSMakeRect(0,0,480,360) + styleMask:NSWindowStyleMaskTitled|NSWindowStyleMaskClosable backing:NSBackingStoreBuffered defer:NO]; + self=[super initWithWindow:window]; + if(self) { + window.title=@"Graphics Diagnostics";window.releasedWhenClosed=NO;window.delegate=self; + NSStackView *stack=[NSStackView new];stack.orientation=NSUserInterfaceLayoutOrientationVertical; + stack.alignment=NSLayoutAttributeLeading;stack.spacing=14;stack.translatesAutoresizingMaskIntoConstraints=NO; + [window.contentView addSubview:stack]; + [NSLayoutConstraint activateConstraints:@[ + [stack.leadingAnchor constraintEqualToAnchor:window.contentView.leadingAnchor constant:24], + [stack.trailingAnchor constraintEqualToAnchor:window.contentView.trailingAnchor constant:-24], + [stack.topAnchor constraintEqualToAnchor:window.contentView.topAnchor constant:24], + [stack.bottomAnchor constraintLessThanOrEqualToAnchor:window.contentView.bottomAnchor constant:-24]]]; + [stack addArrangedSubview:[NSTextField labelWithString:@"Renderer: Metal (ANGLE)"]]; + self.upscaleButton=[NSButton checkboxWithTitle:@"MetalFX spatial upscaling" target:self action:@selector(upscale:)]; + [stack addArrangedSubview:self.upscaleButton]; + self.recordButton=[NSButton checkboxWithTitle:@"Record first-use GPU stalls" target:self action:@selector(record:)]; + [stack addArrangedSubview:self.recordButton]; + NSTextField *note=[NSTextField wrappingLabelWithString:@"Compile/link stalls are recorded automatically. First-use GPU recording adds synchronization overhead; disable it for normal play."]; + [stack addArrangedSubview:note];[note.widthAnchor constraintEqualToAnchor:stack.widthAnchor].active=YES; + [stack addArrangedSubview:[NSButton buttonWithTitle:@"AirDrop shader report…" target:self action:@selector(share:)]]; + [stack addArrangedSubview:[NSButton buttonWithTitle:@"Clear recorded events" target:self action:@selector(clear:)]]; + self.eventCount=[NSTextField labelWithString:@""];[stack addArrangedSubview:self.eventCount]; + [window center]; + } + return self; +} +-(void)refresh { + self.upscaleButton.enabled=halo_metalfx_supported();self.upscaleButton.state=halo_metalfx_enabled(); + self.recordButton.state=atomic_load(&recording); + self.eventCount.stringValue=[NSString stringWithFormat:@"%lu events; %lu dropped",(unsigned long)events.count,(unsigned long)dropped]; +} +-(void)openSettings:(id)sender { + (void)sender;[self refresh];debugPresented=YES;[self showWindow:nil];[self.window makeKeyAndOrderFront:nil]; + NSString *capture=NSProcessInfo.processInfo.environment[@"HALO_MAC_TEST_SETTINGS_IMAGE"]; + if(capture.length) { + NSView *view=self.window.contentView;[view layoutSubtreeIfNeeded]; + NSBitmapImageRep *bitmap=[view bitmapImageRepForCachingDisplayInRect:view.bounds]; + [view cacheDisplayInRect:view.bounds toBitmapImageRep:bitmap]; + [[bitmap representationUsingType:NSBitmapImageFileTypePNG properties:@{}]writeToFile:capture atomically:YES]; + } +} +-(void)windowWillClose:(NSNotification *)notification {(void)notification;debugPresented=NO;} +-(void)network:(NSButton *)item {[NSUserDefaults.standardUserDefaults setBool:item.state==NSControlStateValueOn forKey:@"HaloBrowserRooms"];} +-(void)upscale:(NSButton *)item {BOOL on=item.state==NSControlStateValueOn;halo_metalfx_set_enabled(on);[NSUserDefaults.standardUserDefaults setBool:on forKey:@"HaloMetalFX"];[self refresh];} +-(void)record:(NSButton *)item {BOOL on=item.state==NSControlStateValueOn;atomic_store(&recording,on);[drawnPrograms removeAllObjects];[NSUserDefaults.standardUserDefaults setBool:on forKey:@"HaloRecordShaderStalls"];[self refresh];} +-(void)share:(id)sender {(void)sender;exportReport(nil);} +-(void)clear:(id)sender {(void)sender;[events removeAllObjects];[drawnPrograms removeAllObjects];dropped=0;[self refresh];} +@end +static HaloDebugActions *actions; +void halo_debug_install_menu(void) { + if(actions)return;prepare();actions=[HaloDebugActions new]; + NSMenu *menu=NSApp.mainMenu.itemArray.firstObject.submenu; + if(!menu)return; + NSMenuItem *item=[[NSMenuItem alloc] initWithTitle:@"Graphics Diagnostics…" action:@selector(openSettings:) keyEquivalent:@""]; + item.target=actions;[menu insertItem:item atIndex:MIN(2,menu.numberOfItems)]; +} +void halo_debug_present(void) {halo_debug_install_menu();[actions openSettings:nil];} +#endif + +/* Developer regression: compile every source in a received report using the + actual bundled driver. No game state, textures or buffers are changed. */ +void halo_debug_replay_shader_report(void) { +#if !defined(HALO_IOS) + NSString *path=NSProcessInfo.processInfo.environment[@"HALO_MAC_TEST_SHADER_REPORT"]; + if(!path.length)return; + NSData *data=[NSData dataWithContentsOfFile:path]; + NSDictionary *report=data?[NSJSONSerialization JSONObjectWithData:data options:0 error:nil]:nil; + NSDictionary *entries=[report isKindOfClass:NSDictionary.class]?report[@"sources"]:nil; + if(![entries isKindOfClass:NSDictionary.class] || entries.count>4096)host_fatal("Invalid shader replay report"); + PFNGLCREATESHADERPROC create=halo_graphics_proc("glCreateShader"); + PFNGLSHADERSOURCEPROC source=halo_graphics_proc("glShaderSource"); + PFNGLCOMPILESHADERPROC compile=halo_graphics_proc("glCompileShader"); + PFNGLDELETESHADERPROC destroy=halo_graphics_proc("glDeleteShader"); + PFNGLGETSHADERINFOLOGPROC getLog=halo_graphics_proc("glGetShaderInfoLog"); + unsigned passed=0,failed=0; + for(NSString *key in entries) { + NSDictionary *entry=entries[key]; + if(![entry isKindOfClass:NSDictionary.class])host_fatal("Invalid shader replay entry"); + NSString *text=entry[@"source"],*kind=entry[@"type"]; + if(![text isKindOfClass:NSString.class] || text.length>1024*1024 || + (![kind isEqualToString:@"vertex"] && ![kind isEqualToString:@"fragment"]))host_fatal("Invalid shader replay entry"); + GLuint shader=create([kind isEqualToString:@"vertex"]?GL_VERTEX_SHADER:GL_FRAGMENT_SHADER); + const char *utf8=text.UTF8String;source(shader,1,&utf8,NULL);compile(shader); + GLint ok=0;glGetShaderiv(shader,GL_COMPILE_STATUS,&ok); + if(ok)passed++;else {char log[4096];getLog(shader,sizeof(log),NULL,log);host_logf(HOST_LOG_ERROR,"Shader replay %s failed: %s",key.UTF8String,log);failed++;} + destroy(shader); + } + host_logf(HOST_LOG_INFO,"Shader replay: %u compiled, %u failed",passed,failed); +#endif +} + +void halo_debug_test_export(void) { +#if !defined(HALO_IOS) + if(NSProcessInfo.processInfo.environment[@"HALO_MAC_TEST_SHADER_EXPORT"].length)exportReport(nil); +#endif +} diff --git a/port/apple/host_gl_diagnostics.c b/port/apple/host_gl_diagnostics.c new file mode 100644 index 0000000000..b053d1c329 --- /dev/null +++ b/port/apple/host_gl_diagnostics.c @@ -0,0 +1,26 @@ +/* Host GL arguments have already been widened by the shared guest bridge. */ +#include +#include "host_gl_dispatch.h" +#include +#include "host_debug.h" +static void shader_source(GLuint shader, GLsizei count, const GLchar *const *text, const GLint *lengths) { + halo_debug_shader_source(shader, count, text, lengths); + glShaderSource(shader, count, text, lengths); +} +static void compile_shader(GLuint shader) { double t=halo_debug_begin(); glCompileShader(shader); halo_debug_end(shader,0,t); } +static void link_program(GLuint program) { double t=halo_debug_begin(); glLinkProgram(program); halo_debug_end(program,1,t); } +static void attach_shader(GLuint program, GLuint shader) { halo_debug_attach(program,shader); glAttachShader(program,shader); } +static void use_program(GLuint program) { halo_debug_use_program(program); glUseProgram(program); } +static void delete_shader(GLuint shader) { halo_debug_forget(shader,0); glDeleteShader(shader); } +static void delete_program(GLuint program) { halo_debug_forget(program,1); glDeleteProgram(program); } +static void draw_arrays(GLenum mode, GLint first, GLsizei count) { double t=halo_debug_draw_begin(); glDrawArrays(mode,first,count); halo_debug_draw_end(t); } +static void draw_elements(GLenum mode, GLsizei count, GLenum type, const void *indices) { double t=halo_debug_draw_begin(); glDrawElements(mode,count,type,indices); halo_debug_draw_end(t); } +void *halo_debug_gl_resolve(const char *name) { +#define WRAP(gl, fn) if (!strcmp(name, #gl)) return (void *)fn + WRAP(glShaderSource,shader_source); WRAP(glCompileShader,compile_shader); WRAP(glLinkProgram,link_program); + WRAP(glAttachShader,attach_shader); WRAP(glUseProgram,use_program); + WRAP(glDeleteShader,delete_shader); WRAP(glDeleteProgram,delete_program); + WRAP(glDrawArrays,draw_arrays); WRAP(glDrawElements,draw_elements); +#undef WRAP + return 0; +} diff --git a/port/apple/host_gl_dispatch.h b/port/apple/host_gl_dispatch.h new file mode 100644 index 0000000000..990f6e9787 --- /dev/null +++ b/port/apple/host_gl_dispatch.h @@ -0,0 +1,45 @@ +/* Include after GL declarations. Driver choice is fixed for the process. */ +#pragma once +#include "host_graphics.h" +#define HALO_GL_PROC(name) ({static __typeof__(&name) fn; if(!fn)fn=(__typeof__(&name))halo_graphics_proc(#name); fn;}) +#define glBindFramebuffer HALO_GL_PROC(glBindFramebuffer) +#define glBindTexture HALO_GL_PROC(glBindTexture) +#define glBlitFramebuffer HALO_GL_PROC(glBlitFramebuffer) +#define glCheckFramebufferStatus HALO_GL_PROC(glCheckFramebufferStatus) +#define glDisable HALO_GL_PROC(glDisable) +#define glEnable HALO_GL_PROC(glEnable) +#define glFinish HALO_GL_PROC(glFinish) +#define glFramebufferTexture2D HALO_GL_PROC(glFramebufferTexture2D) +#define glGenFramebuffers HALO_GL_PROC(glGenFramebuffers) +#define glGetIntegerv HALO_GL_PROC(glGetIntegerv) +#define glGetProgramiv HALO_GL_PROC(glGetProgramiv) +#define glGetShaderiv HALO_GL_PROC(glGetShaderiv) +#define glGetString HALO_GL_PROC(glGetString) +#define glIsEnabled HALO_GL_PROC(glIsEnabled) +#define glAttachShader HALO_GL_PROC(glAttachShader) +#define glBindBuffer HALO_GL_PROC(glBindBuffer) +#define glBufferData HALO_GL_PROC(glBufferData) +#define glBufferSubData HALO_GL_PROC(glBufferSubData) +#define glClientWaitSync HALO_GL_PROC(glClientWaitSync) +#define glCompileShader HALO_GL_PROC(glCompileShader) +#define glCompressedTexImage3D HALO_GL_PROC(glCompressedTexImage3D) +#define glCopyImageSubData HALO_GL_PROC(glCopyImageSubData) +#define glDeleteProgram HALO_GL_PROC(glDeleteProgram) +#define glDeleteShader HALO_GL_PROC(glDeleteShader) +#define glDeleteSync HALO_GL_PROC(glDeleteSync) +#define glDisableVertexAttribArray HALO_GL_PROC(glDisableVertexAttribArray) +#define glDrawArrays HALO_GL_PROC(glDrawArrays) +#define glDrawElements HALO_GL_PROC(glDrawElements) +#define glEnableVertexAttribArray HALO_GL_PROC(glEnableVertexAttribArray) +#define glFenceSync HALO_GL_PROC(glFenceSync) +#define glGetQueryObjectuiv HALO_GL_PROC(glGetQueryObjectuiv) +#define glGetStringi HALO_GL_PROC(glGetStringi) +#define glLinkProgram HALO_GL_PROC(glLinkProgram) +#define glMapBufferRange HALO_GL_PROC(glMapBufferRange) +#define glShaderSource HALO_GL_PROC(glShaderSource) +#define glTexImage2D HALO_GL_PROC(glTexImage2D) +#define glTexImage3D HALO_GL_PROC(glTexImage3D) +#define glTexSubImage2D HALO_GL_PROC(glTexSubImage2D) +#define glUnmapBuffer HALO_GL_PROC(glUnmapBuffer) +#define glUseProgram HALO_GL_PROC(glUseProgram) +#define glVertexAttribPointer HALO_GL_PROC(glVertexAttribPointer) diff --git a/port/apple/host_graphics.c b/port/apple/host_graphics.c new file mode 100644 index 0000000000..fb97f88ab9 --- /dev/null +++ b/port/apple/host_graphics.c @@ -0,0 +1,65 @@ +#include +#include +#include +#include +#include +#include +#include "host_graphics.h" +#include "host_debug.h" +#include "host_shader_cache.h" +static EGLDisplay display; +static EGLConfig config; +static EGLSurface sharedSurface; +static GLuint sharedTexture; +static int sharedBound; +static int metal = 1; +extern int halo_debug_renderer_preference(void); +extern void halo_debug_set_renderer_preference(int); +int halo_graphics_metal(void) { return metal; } +int halo_graphics_prefer_metal(void) { return halo_debug_renderer_preference(); } +void halo_graphics_set_preference(int value) { halo_debug_set_renderer_preference(value); } +void halo_graphics_initialize(void) { +#ifdef HALO_IOS + metal = halo_graphics_prefer_metal(); +#endif +} +void *halo_graphics_proc(const char *name) { +#ifdef HALO_IOS + if (!metal) return (void *)SDL_GL_GetProcAddress(name); +#endif + return (void *)eglGetProcAddress(name); +} +void halo_graphics_attach(EGLDisplay current, EGLConfig configuration) { + display=current; config=configuration; + halo_debug_initialize(); + halo_shader_cache_install(display, eglGetProcAddress); +} +unsigned int halo_graphics_shared_create(void *surface,unsigned int w,unsigned int h) { + halo_graphics_shared_destroy(); + EGLint attrs[]={EGL_WIDTH,(EGLint)w,EGL_HEIGHT,(EGLint)h,EGL_IOSURFACE_PLANE_ANGLE,0,EGL_TEXTURE_TARGET,EGL_TEXTURE_2D,EGL_TEXTURE_FORMAT,EGL_TEXTURE_RGBA,EGL_TEXTURE_TYPE_ANGLE,GL_UNSIGNED_BYTE,EGL_TEXTURE_INTERNAL_FORMAT_ANGLE,GL_BGRA_EXT,EGL_NONE}; + sharedSurface=eglCreatePbufferFromClientBuffer(display,EGL_IOSURFACE_ANGLE,surface,config,attrs); + if(!sharedSurface)return 0; + void (*genTextures)(GLsizei,GLuint *)=halo_graphics_proc("glGenTextures");genTextures(1,&sharedTexture); + return halo_graphics_shared_begin()?sharedTexture:0; +} +int halo_graphics_shared_begin(void) { + if(sharedBound)return 1; + if(!sharedSurface)return 0; + void (*bindTexture)(GLenum,GLuint)=halo_graphics_proc("glBindTexture");bindTexture(GL_TEXTURE_2D,sharedTexture); + sharedBound=eglBindTexImage(display,sharedSurface,EGL_BACK_BUFFER);return sharedBound; +} +int halo_graphics_shared_end(void) { + if(!sharedBound)return 1; + EGLBoolean released=eglReleaseTexImage(display,sharedSurface,EGL_BACK_BUFFER); + /* Complete both the rendering and any release-time work before another + Metal command queue reads the IOSurface. */ + void (*finish)(void)=halo_graphics_proc("glFinish");finish(); + if(released)sharedBound=0; + return released; +} +void halo_graphics_shared_destroy(void) { + halo_graphics_shared_end(); + if(sharedTexture){void (*deleteTextures)(GLsizei,const GLuint *)=halo_graphics_proc("glDeleteTextures");deleteTextures(1,&sharedTexture);sharedTexture=0;} + if(sharedSurface){eglDestroySurface(display,sharedSurface);sharedSurface=EGL_NO_SURFACE;} + sharedBound=0; +} diff --git a/port/apple/host_graphics.h b/port/apple/host_graphics.h new file mode 100644 index 0000000000..3aaf4740a7 --- /dev/null +++ b/port/apple/host_graphics.h @@ -0,0 +1,18 @@ +#pragma once +#include +struct SDL_Window; +void halo_graphics_initialize(void); +int halo_graphics_metal(void); +int halo_graphics_prefer_metal(void); +void halo_graphics_set_preference(int metal); +void *halo_graphics_proc(const char *name); +int halo_graphics_has_extension(const char *name); +void *halo_graphics_create(struct SDL_Window *window); +int halo_graphics_make_current(void); +int halo_graphics_swap(void); +int halo_graphics_swap_interval(int interval); +/* IOSurface ownership is transferred to/from ANGLE around each MetalFX copy. */ +unsigned int halo_graphics_shared_create(void *iosurface,unsigned int width,unsigned int height); +int halo_graphics_shared_begin(void); +int halo_graphics_shared_end(void); +void halo_graphics_shared_destroy(void); diff --git a/port/apple/host_metalfx.m b/port/apple/host_metalfx.m new file mode 100644 index 0000000000..9ee65d9571 --- /dev/null +++ b/port/apple/host_metalfx.m @@ -0,0 +1,165 @@ +#include +#if TARGET_OS_SIMULATOR +#include "host_debug.h" +int halo_metalfx_take_presented(void) {return 0;} +int halo_metalfx_supported(void) {return 0;} +int halo_metalfx_enabled(void) {return 0;} +void halo_metalfx_set_enabled(int enabled) {(void)enabled;} +unsigned int host_ios_render_height(void) {return 0;} +int host_ios_metalfx_present(unsigned int t,unsigned int w,unsigned int h,unsigned int ow,unsigned int oh) {(void)t;(void)w;(void)h;(void)ow;(void)oh;return 0;} +#else +/* Optional ANGLE Metal -> IOSurface -> MetalFX presentation. Both APIs share the + input image; explicit completion fences protect ownership across APIs. */ +#if !defined(HALO_IOS) +#import +#else +#import +#endif +#include +#include + +#import +#import +#import +#import +#include "host.h" +#include "host_gl_dispatch.h" +#include "host_debug.h" + +static BOOL enabled; +static int presented; +int halo_metalfx_take_presented(void) {int value=presented;presented=0;return value;} +static id device; +static id queue; +static id pending; +static id scaler; + +static CVMetalTextureCacheRef metalCache; +static CVPixelBufferRef pixels; +static GLuint sharedTexture; +static CVMetalTextureRef metalImage; +static id output; +#if !defined(HALO_IOS) +@interface HaloMetalSurface : NSView +@end +@implementation HaloMetalSurface +-(NSView *)hitTest:(NSPoint)point {(void)point;return nil;} +@end +static NSView *surface; +#else +static UIView *surface; +#endif +static CAMetalLayer *layer; +static GLuint readFBO,writeFBO; +static unsigned inputW,inputH,outputW,outputH; + +int halo_metalfx_supported(void) { + if(!device)device=MTLCreateSystemDefaultDevice(); + return device && [MTLFXSpatialScalerDescriptor supportsDevice:device]; +} +int halo_metalfx_enabled(void) {return enabled;} +void halo_metalfx_set_enabled(int value) { + enabled=value && halo_graphics_metal() && halo_metalfx_supported(); + if(!enabled)surface.hidden=YES; + host_logf(HOST_LOG_INFO,"MetalFX spatial upscaling %s",enabled?"enabled":"disabled"); +} +unsigned int host_ios_render_height(void) {return enabled?720:0;} +static void releaseImages(void) { + [pending waitUntilCompleted];pending=nil; + halo_graphics_shared_destroy();sharedTexture=0; + if(metalImage){CFRelease(metalImage);metalImage=NULL;} + if(pixels){CFRelease(pixels);pixels=NULL;} + if(metalCache)CVMetalTextureCacheFlush(metalCache,0); + scaler=nil;output=nil;inputW=inputH=outputW=outputH=0; +} +static BOOL configure(unsigned w,unsigned h,unsigned ow,unsigned oh) { + releaseImages(); + if(!queue)queue=[device newCommandQueue]; + if(!queue)return NO; + if(!metalCache && CVMetalTextureCacheCreate(NULL,NULL,device,NULL,&metalCache)!=kCVReturnSuccess)return NO; + NSDictionary *attributes=@{(id)kCVPixelBufferIOSurfacePropertiesKey:@{},(id)kCVPixelBufferMetalCompatibilityKey:@YES}; + if(CVPixelBufferCreate(NULL,w,h,kCVPixelFormatType_32BGRA,(__bridge CFDictionaryRef)attributes,&pixels)!=kCVReturnSuccess)return NO; + sharedTexture=halo_graphics_shared_create(CVPixelBufferGetIOSurface(pixels),w,h); + if(!sharedTexture)return NO; + MTLFXSpatialScalerDescriptor *desc=[MTLFXSpatialScalerDescriptor new]; + desc.inputWidth=w;desc.inputHeight=h;desc.outputWidth=ow;desc.outputHeight=oh; + desc.colorTextureFormat=desc.outputTextureFormat=MTLPixelFormatBGRA8Unorm; + desc.colorProcessingMode=MTLFXSpatialScalerColorProcessingModePerceptual; + scaler=[desc newSpatialScalerWithDevice:device];if(!scaler)return NO; + NSDictionary *textureAttributes=@{(id)kCVMetalTextureUsage:@(scaler.colorTextureUsage)}; + if(CVMetalTextureCacheCreateTextureFromImage(NULL,metalCache,pixels,(__bridge CFDictionaryRef)textureAttributes,MTLPixelFormatBGRA8Unorm,w,h,0,&metalImage)!=kCVReturnSuccess)return NO; + MTLTextureDescriptor *texture=[MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatBGRA8Unorm width:ow height:oh mipmapped:NO]; + texture.storageMode=MTLStorageModePrivate;texture.usage=scaler.outputTextureUsage; + output=[device newTextureWithDescriptor:texture];if(!output)return NO; + scaler.colorTexture=CVMetalTextureGetTexture(metalImage);scaler.outputTexture=output; + scaler.inputContentWidth=w;scaler.inputContentHeight=h; + if(!readFBO)glGenFramebuffers(1,&readFBO); + if(!writeFBO)glGenFramebuffers(1,&writeFBO); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER,writeFBO); + glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER,GL_COLOR_ATTACHMENT0,GL_TEXTURE_2D,sharedTexture,0); + if(glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER)!=GL_FRAMEBUFFER_COMPLETE)return NO; + inputW=w;inputH=h;outputW=ow;outputH=oh; + host_logf(HOST_LOG_INFO,"MetalFX configured %ux%u -> %ux%u",w,h,ow,oh); + return YES; +} +int host_ios_metalfx_present(unsigned int texture,unsigned int w,unsigned int h,unsigned int ow,unsigned int oh) { + if(!enabled || !w || !h || !ow || !oh)return 0; + @autoreleasepool { + GLint oldRead,oldWrite,oldTexture;glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING,&oldRead);glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING,&oldWrite);glGetIntegerv(GL_TEXTURE_BINDING_2D,&oldTexture); + BOOL scissor=glIsEnabled(GL_SCISSOR_TEST),success=YES; + [pending waitUntilCompleted]; + if(pending.status==MTLCommandBufferStatusError){host_logf(HOST_LOG_ERROR,"MetalFX: %s",pending.error.localizedDescription.UTF8String);success=NO;} + pending=nil; + if(success && (w!=inputW || h!=inputH || ow!=outputW || oh!=outputH))success=configure(w,h,ow,oh); + if(success && !surface) { +#if !defined(HALO_IOS) + extern void *host_macos_game_window(void); + NSWindow *window=(__bridge NSWindow *)host_macos_game_window(); + NSView *root=window.contentView; + if(root){surface=[[HaloMetalSurface alloc]initWithFrame:root.bounds];surface.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable;surface.wantsLayer=YES;layer=[CAMetalLayer layer];layer.device=device;layer.pixelFormat=MTLPixelFormatBGRA8Unorm;layer.framebufferOnly=NO;[surface.layer addSublayer:layer];[root addSubview:surface];} + else success=NO; +#else + UIWindow *window=nil; + for(UIScene *scene in UIApplication.sharedApplication.connectedScenes)if([scene isKindOfClass:UIWindowScene.class])for(UIWindow *candidate in ((UIWindowScene*)scene).windows)if(candidate.isKeyWindow)window=candidate; + UIView *root=window.rootViewController.view; + if(root){surface=[[UIView alloc]initWithFrame:root.bounds];surface.userInteractionEnabled=NO;surface.autoresizingMask=UIViewAutoresizingFlexibleWidth|UIViewAutoresizingFlexibleHeight;layer=[CAMetalLayer layer];layer.device=device;layer.pixelFormat=MTLPixelFormatBGRA8Unorm;layer.framebufferOnly=NO;[surface.layer addSublayer:layer];[root insertSubview:surface atIndex:MIN((NSUInteger)1,root.subviews.count)];} + else success=NO; +#endif + } + if(success)success=halo_graphics_shared_begin(); + if(success) { + glBindFramebuffer(GL_READ_FRAMEBUFFER,readFBO);glFramebufferTexture2D(GL_READ_FRAMEBUFFER,GL_COLOR_ATTACHMENT0,GL_TEXTURE_2D,texture,0); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER,writeFBO); + glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER,GL_COLOR_ATTACHMENT0,GL_TEXTURE_2D,sharedTexture,0);glDisable(GL_SCISSOR_TEST); + /* Guest row zero already denotes the top, as Metal expects. */ + glBlitFramebuffer(0,0,w,h,0,0,w,h,GL_COLOR_BUFFER_BIT,GL_NEAREST); + success=halo_graphics_shared_end(); + layer.frame=surface.bounds; +#if !defined(HALO_IOS) + layer.contentsScale=surface.window.backingScaleFactor; +#else + layer.contentsScale=surface.window.screen.scale; +#endif + CGSize size=CGSizeMake(round(surface.bounds.size.width*layer.contentsScale),round(surface.bounds.size.height*layer.contentsScale)); + if(!CGSizeEqualToSize(layer.drawableSize,size))layer.drawableSize=size; + id drawable=[layer nextDrawable]; + if(success && drawable && ow<=drawable.texture.width && oh<=drawable.texture.height) { + id commands=[queue commandBuffer]; + [scaler encodeToCommandBuffer:commands]; + MTLRenderPassDescriptor *pass=[MTLRenderPassDescriptor renderPassDescriptor]; + pass.colorAttachments[0].texture=drawable.texture;pass.colorAttachments[0].loadAction=MTLLoadActionClear;pass.colorAttachments[0].storeAction=MTLStoreActionStore;pass.colorAttachments[0].clearColor=MTLClearColorMake(0,0,0,1); + [[commands renderCommandEncoderWithDescriptor:pass]endEncoding]; + id blit=[commands blitCommandEncoder]; + [blit copyFromTexture:output sourceSlice:0 sourceLevel:0 sourceOrigin:MTLOriginMake(0,0,0) sourceSize:MTLSizeMake(ow,oh,1) toTexture:drawable.texture destinationSlice:0 destinationLevel:0 destinationOrigin:MTLOriginMake((drawable.texture.width-ow)/2,(drawable.texture.height-oh)/2,0)]; + [blit endEncoding];[commands presentDrawable:drawable];[commands commit];pending=commands;surface.hidden=NO; + } else {host_logf(HOST_LOG_WARN,"MetalFX drawable unavailable or too small (%ux%u requested)",ow,oh);surface.hidden=YES;success=NO;} + } + glBindFramebuffer(GL_READ_FRAMEBUFFER,oldRead);glBindFramebuffer(GL_DRAW_FRAMEBUFFER,oldWrite);glBindTexture(GL_TEXTURE_2D,oldTexture); + if(scissor)glEnable(GL_SCISSOR_TEST);else glDisable(GL_SCISSOR_TEST); + if(!success){halo_metalfx_set_enabled(0);releaseImages();} + presented=success; + return success; + } +} + +#endif diff --git a/port/apple/host_shader_cache.h b/port/apple/host_shader_cache.h new file mode 100644 index 0000000000..6fd67ff8e2 --- /dev/null +++ b/port/apple/host_shader_cache.h @@ -0,0 +1,4 @@ +#pragma once +#include +#include +void halo_shader_cache_install(EGLDisplay display, PFNEGLGETPROCADDRESSPROC get_proc); diff --git a/port/apple/host_shader_cache.m b/port/apple/host_shader_cache.m new file mode 100644 index 0000000000..7c670bcf4d --- /dev/null +++ b/port/apple/host_shader_cache.m @@ -0,0 +1,107 @@ +/* ANGLE owns the opaque keys and validates compiled blobs. The application + provides a bounded private disk cache, scoped to the GPU, OS and ANGLE build. + Writes run off the rendering thread; eviction/corruption is just a miss. */ +#import +#import +#import +#include +#include "host_shader_cache.h" +#include "host.h" + +static NSString *cacheDirectory; +static NSCache *memoryCache; +static dispatch_queue_t writer; +static NSLock *pendingLock; +static NSUInteger pendingBytes; +static const NSUInteger maximumBlob=16*1024*1024, maximumDisk=64*1024*1024; +static BOOL loggedHit,loggedStore; + +static NSString *digest(NSData *data) { + unsigned char bytes[CC_SHA256_DIGEST_LENGTH];CC_SHA256(data.bytes,(CC_LONG)data.length,bytes); + NSMutableString *result=[NSMutableString new]; + for(unsigned i=0;i65536)return nil; + return digest([NSData dataWithBytes:key length:(NSUInteger)size]); +} +static void trimDisk(void) { + NSFileManager *files=NSFileManager.defaultManager; + NSArray *urls=[files contentsOfDirectoryAtURL:[NSURL fileURLWithPath:cacheDirectory] + includingPropertiesForKeys:@[NSURLFileSizeKey,NSURLContentModificationDateKey] options:0 error:nil]; + NSMutableArray *entries=[NSMutableArray new];NSUInteger total=0; + for(NSURL *url in urls) { + if(![url.pathExtension isEqualToString:@"blob"])continue; + NSDictionary *values=[url resourceValuesForKeys:@[NSURLFileSizeKey,NSURLContentModificationDateKey] error:nil]; + NSUInteger size=[values[NSURLFileSizeKey] unsignedIntegerValue];total+=size; + [entries addObject:@{@"url":url,@"size":@(size),@"date":values[NSURLContentModificationDateKey]?:NSDate.distantPast}]; + } + [entries sortUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"date" ascending:YES]]]; + for(NSDictionary *entry in entries) { + if(total<=maximumDisk)break; + [files removeItemAtURL:entry[@"url"] error:nil];total-=[entry[@"size"] unsignedIntegerValue]; + } +} +static void storeBlob(const void *key,EGLsizeiANDROID keySize,const void *value,EGLsizeiANDROID valueSize) { + @autoreleasepool { + NSString *name=keyName(key,keySize); + if(!name || !value || valueSize<=0 || valueSize>maximumBlob)return; + NSData *bytes=[NSData dataWithBytes:value length:(NSUInteger)valueSize]; + [memoryCache setObject:bytes forKey:name cost:bytes.length]; + [pendingLock lock]; + BOOL queued=pendingBytes+bytes.length<=maximumDisk; + if(queued)pendingBytes+=bytes.length; + [pendingLock unlock]; + if(!queued)return; + dispatch_async(writer,^{@autoreleasepool { + NSString *path=[cacheDirectory stringByAppendingPathComponent:[name stringByAppendingPathExtension:@"blob"]]; + BOOL ok=[bytes writeToFile:path options:NSDataWritingAtomic error:nil]; + if(ok && !loggedStore){loggedStore=YES;host_logf(HOST_LOG_INFO,"ANGLE shader cache: stored compiled blob");} + trimDisk(); + [pendingLock lock];pendingBytes-=bytes.length;[pendingLock unlock]; + }}); + } +} +static EGLsizeiANDROID getBlob(const void *key,EGLsizeiANDROID keySize,void *value,EGLsizeiANDROID valueSize) { + @autoreleasepool { + NSString *name=keyName(key,keySize);if(!name)return 0; + NSData *bytes=[memoryCache objectForKey:name];BOOL diskHit=NO; + if(!bytes) { + NSString *path=[cacheDirectory stringByAppendingPathComponent:[name stringByAppendingPathExtension:@"blob"]]; + unsigned long long size=[[NSFileManager.defaultManager attributesOfItemAtPath:path error:nil] fileSize]; + if(size==0 || size>maximumBlob)return 0; + bytes=[NSData dataWithContentsOfFile:path options:0 error:nil]; + if(!bytes || bytes.length>maximumBlob)return 0; + [memoryCache setObject:bytes forKey:name cost:bytes.length];diskHit=YES; + } + if(value && valueSize>=0 && (NSUInteger)valueSize>=bytes.length) { + memcpy(value,bytes.bytes,bytes.length); + + } + [pendingLock lock];BOOL log=diskHit && !loggedHit;if(diskHit)loggedHit=YES;[pendingLock unlock]; + if(log)host_logf(HOST_LOG_INFO,"ANGLE shader cache: loaded compiled blob from disk"); + return (EGLsizeiANDROID)bytes.length; + } +} +void halo_shader_cache_install(EGLDisplay display,PFNEGLGETPROCADDRESSPROC get_proc) { + PFNEGLQUERYSTRINGPROC query=(void *)get_proc("eglQueryString"); + const char *extensions=query?query(display,EGL_EXTENSIONS):NULL; + PFNEGLSETBLOBCACHEFUNCSANDROIDPROC install=(void *)get_proc("eglSetBlobCacheFuncsANDROID"); + if(!extensions || !strstr(extensions,"EGL_ANDROID_blob_cache") || !install) { + host_logf(HOST_LOG_INFO,"ANGLE shader cache: extension unavailable");return; + } + NSString *root=[NSSearchPathForDirectoriesInDomains(NSCachesDirectory,NSUserDomainMask,YES).firstObject + stringByAppendingPathComponent:@"HaloShaderCache-v1"]; + NSString *angle=NSBundle.mainBundle.privateFrameworksPath; + NSDictionary *version=[NSDictionary dictionaryWithContentsOfFile:[angle stringByAppendingPathComponent:@"libGLESv2.framework/Info.plist"]]; + NSString *identity=[NSString stringWithFormat:@"%@|%@|%@|%@",MTLCreateSystemDefaultDevice().name, + NSProcessInfo.processInfo.operatingSystemVersionString,version[@"CFBundleVersion"],version[@"CFBundleShortVersionString"]]; + cacheDirectory=[root stringByAppendingPathComponent:digest([identity dataUsingEncoding:NSUTF8StringEncoding])]; + if(![NSFileManager.defaultManager createDirectoryAtPath:cacheDirectory withIntermediateDirectories:YES attributes:nil error:nil])return; + memoryCache=[NSCache new];memoryCache.totalCostLimit=maximumDisk; + pendingLock=[NSLock new];writer=dispatch_queue_create("org.haloce.shader-cache",DISPATCH_QUEUE_SERIAL); + dispatch_async(writer,^{trimDisk();}); + install(display,storeBlob,getBlob); + host_logf(HOST_LOG_INFO,"ANGLE shader cache: enabled (64 MiB disk budget)"); +} diff --git a/port/apple/xiso.c b/port/apple/xiso.c new file mode 100644 index 0000000000..6fab70e6bf --- /dev/null +++ b/port/apple/xiso.c @@ -0,0 +1,249 @@ +/* +XISO.C + +Copies the maps folder out of an Xbox disc image (an "xiso"), for the +native iOS app's first start without game data. + +The image's file system is XDVDFS, read as extract-xiso does +(https://github.com/XboxDev/extract-xiso, extract-xiso.c, whose format +handling this follows; its license is below and in +port/third_party/extract-xiso/LICENSE.TXT): 2048-byte sectors; a +volume descriptor at 0x10000 that starts and ends with +"MICROSOFT*XBOX*MEDIA" and gives the root directory's sector and size; and +directories whose entries form a binary tree (each entry: left and right +subtree offsets in 4-byte units, the start sector, the size, attributes, the +name's length and the name, 4-byte aligned). Images made from a whole disc +put the game partition further in, at one of the offsets below. + +The native importer validates the complete map set, then writes to a private +staging directory. A failed or cancelled extraction never publishes maps. + +Some parts of this code are copyright in@fishtank.com. This product +includes software developed by in . + + * Copyright (c) 2003 in + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software + * must display the following acknowledgement: + * + * This product includes software developed by in . + * + * 4. Neither the name of "in" nor the email address "in@fishtank.com" + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED `AS IS' AND ANY EXPRESS OR IMPLIED WARRANTIES + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * AUTHOR OR ANY CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +#include "xiso.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { SECTOR = 2048, MAX_DIRECTORY = 4 << 20, MAX_FILES = 256, COPY_SIZE = 1 << 20 }; +static const char magic[] = "MICROSOFT*XBOX*MEDIA"; +static const uint64_t partitions[] = {0, 0x0FD90000, 0x02080000, 0x18300000}; +static const char *required[] = {"ui.map", "a10.map", "a30.map", "a50.map", "b30.map", "b40.map", + "c10.map", "c20.map", "c40.map", "d20.map", "d40.map"}; +struct entry { char name[256]; uint32_t sector, size; }; +struct image { int fd; uint64_t length, partition; char *error; size_t error_size; }; + +static int fail(struct image *image, const char *format, ...) { + va_list args; va_start(args, format); + if (image->error && image->error_size) vsnprintf(image->error, image->error_size, format, args); + va_end(args); return 0; +} +static uint32_t u32(const unsigned char *p) { + return (uint32_t)p[0] | (uint32_t)p[1]<<8 | (uint32_t)p[2]<<16 | (uint32_t)p[3]<<24; +} +static int read_at(struct image *image, uint64_t offset, void *out, size_t size) { + if (offset > image->length || size > image->length-offset) return 0; + unsigned char *p=out; + while (size) { + ssize_t count=pread(image->fd,p,size,(off_t)offset); + if (count<0 && errno==EINTR) continue; + if (count<=0) return 0; + p+=count; offset+=(uint64_t)count; size-=(size_t)count; + } + return 1; +} +static int cache_header(struct image *image, const unsigned char *header, const char *name, char build[33]) { + if (memcmp(header,"daeh",4) || memcmp(header+2044,"toof",4) || u32(header+4)!=5) + return fail(image,"%s is not an original Xbox Halo map. PC, Custom Edition, and MCC images are unsupported.",name); + memcpy(build,header+64,32); build[32]=0; + if (strcmp(build,"01.01.14.2342") && strcmp(build,"01.10.12.2276")) + return fail(image,"Unsupported Xbox map build in %s. Use Halo: Combat Evolved PAL or NTSC-US.",name); + return 1; +} +int xiso_maps_ready(const char *maps, char *error, size_t error_size) { + struct image image={.error=error,.error_size=error_size}; + char path[2048], expected[33]={0}; + for (size_t i=0;i= (int)sizeof(path)) + return fail(&image,"Game data path is too long."); + image.fd=open(path,O_RDONLY|O_NOFOLLOW); + struct stat st; unsigned char header[2048]; char build[33]; + int ok=image.fd>=0 && !fstat(image.fd,&st) && S_ISREG(st.st_mode) && st.st_size>=2048; + if (ok) {image.length=(uint64_t)st.st_size;ok=read_at(&image,0,header,sizeof(header));} + if (image.fd>=0) close(image.fd); + if (!ok) return fail(&image,"Game data is missing or incomplete. Choose your Halo XISO to import it."); + if (!cache_header(&image,header,required[i],build)) return 0; + if (i && strcmp(build,expected)) return fail(&image,"Game maps contain mixed Xbox releases. Import one complete disc image."); + strcpy(expected,build); + } + return 1; +} +struct walk { struct image *image; const unsigned char *table; unsigned char *visited; + uint32_t size; struct entry *files; int count, directories; }; +static int walk_tree(struct walk *walk, uint32_t offset, unsigned depth) { + if (depth>64 || offset+14>walk->size || (offset&3) || walk->visited[offset/4]) + return fail(walk->image,"The disc image has a damaged directory tree."); + walk->visited[offset/4]=1; + const unsigned char *p=walk->table+offset; + unsigned left=p[0]|p[1]<<8, right=p[2]|p[3]<<8, count=p[13]; + if (left==0xffff) return 1; + if (!count || offset+14+count>walk->size) return fail(walk->image,"The disc image has a damaged filename."); + if (left && !walk_tree(walk,left*4,depth+1)) return 0; + if (!!(p[12]&16)==walk->directories) { + char name[256]; + for (unsigned i=0;i126 || c=='/' || c=='\\' || c==':') + return fail(walk->image,"The disc image contains an unsafe filename."); + name[i]=(char)tolower(c); + } + name[count]=0; + if (!strcmp(name,".") || !strcmp(name,"..")) return fail(walk->image,"The disc image contains an unsafe filename."); + if (walk->directories || (count>4 && !strcmp(name+count-4,".map"))) { + if (walk->count>=MAX_FILES) return fail(walk->image,"The disc image has too many map files."); + for (int i=0;icount;i++) if (!strcmp(walk->files[i].name,name)) + return fail(walk->image,"The disc image contains duplicate filenames."); + struct entry *entry=&walk->files[walk->count++]; + strcpy(entry->name,name); entry->sector=u32(p+4); entry->size=u32(p+8); + uint64_t start=walk->image->partition+(uint64_t)entry->sector*SECTOR; + if (start>walk->image->length || entry->size>walk->image->length-start) + return fail(walk->image,"The disc image is incomplete or truncated."); + } + } + return !right || walk_tree(walk,right*4,depth+1); +} +static int directory(struct image *image, uint32_t sector, uint32_t size, int directories, + struct entry *files, int *count) { + if (!size || size>MAX_DIRECTORY) return fail(image,"The disc image has a damaged directory size."); + unsigned char *table=malloc(size), *visited=calloc((size+3)/4,1); + int ok=0; + if (!table || !visited) {fail(image,"Not enough memory to inspect the disc image.");goto done;} + if (!read_at(image,image->partition+(uint64_t)sector*SECTOR,table,size)) { + fail(image,"Could not read the disc image directory. The image may be incomplete.");goto done; + } + struct walk walk={image,table,visited,size,files,0,directories}; + ok=walk_tree(&walk,0,0); *count=walk.count; +done: free(table);free(visited);return ok; +} +int xiso_extract_maps(const char *image_path, const char *destination, + xiso_progress_proc progress, void *context, char *error, size_t error_size) { + struct image image={.fd=-1,.error=error,.error_size=error_size}; + struct entry *files=calloc(MAX_FILES,sizeof(*files)); + unsigned char *buffer=NULL, header[2048]; + char partial[2048], final[2048], path[2400], expected[33]={0}; + struct stat st; int ok=0,count=0,found=0; + uint32_t root_sector=0,root_size=0; uint64_t total=0,done=0; + if (!files) {fail(&image,"Not enough memory to import the disc image.");goto cleanup;} + image.fd=open(image_path,O_RDONLY); + if (image.fd<0 || fstat(image.fd,&st) || !S_ISREG(st.st_mode)) { + fail(&image,"Could not open the disc image. Download it in Files first, then try again.");goto cleanup; + } + image.length=(uint64_t)st.st_size; + for (size_t i=0;i(uint64_t)space.f_bavail*space.f_frsize) { + fail(&image,"Not enough storage. Free at least %.2f GB for the extracted game maps.",(double)total/1e9);goto cleanup; + } + if (snprintf(partial,sizeof(partial),"%s/maps.partial",destination)>=(int)sizeof(partial) || + snprintf(final,sizeof(final),"%s/maps",destination)>=(int)sizeof(final)) {fail(&image,"Import path is too long.");goto cleanup;} + if (!lstat(final,&st) || errno!=ENOENT) {fail(&image,"The import destination already contains maps.");goto cleanup;} + if (mkdir(partial,0700)) {fail(&image,"Could not create the import folder.");goto cleanup;} + buffer=malloc(COPY_SIZE); + if (!buffer) {fail(&image,"Not enough memory to copy the game maps.");goto cleanup;} + for (int i=0;iCOPY_SIZE?COPY_SIZE:(size_t)remaining; + if (!read_at(&image,start,buffer,chunk)) {fail(&image,"Could not read %s. The image may be incomplete.",files[i].name);copied=0;break;} + size_t written=0; + while (written=0) close(image.fd); + free(buffer);free(files);return ok; +} diff --git a/port/apple/xiso.h b/port/apple/xiso.h new file mode 100644 index 0000000000..8f69a2a390 --- /dev/null +++ b/port/apple/xiso.h @@ -0,0 +1,17 @@ +#ifndef HALO_XISO_H +#define HALO_XISO_H +#include +#include + +/* Return zero to cancel. Called on the extraction thread. */ +typedef int (*xiso_progress_proc)(void *context, const char *file, uint64_t done, uint64_t total); + +/* Validate original Xbox cache headers and the complete campaign map set. */ +int xiso_maps_ready(const char *maps, char *error, size_t error_size); + +/* Extract into a new /maps, staging in maps.partial first. + destination must be a private, existing directory. Existing files are never + replaced. On failure the caller removes its private staging directory. */ +int xiso_extract_maps(const char *image_path, const char *destination, + xiso_progress_proc progress, void *context, char *error, size_t error_size); +#endif diff --git a/port/assets/menus/ce/bitmaps.xml b/port/assets/menus/ce/bitmaps.xml index 129b2b1f83..1b1038e5b9 100644 --- a/port/assets/menus/ce/bitmaps.xml +++ b/port/assets/menus/ce/bitmaps.xml @@ -335,6 +335,9 @@ + + + diff --git a/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml b/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml index 732cb636c3..36c6c2fbf7 100644 --- a/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml +++ b/port/assets/menus/ce/main_menu.multiplayer_type_select.server_settings.xml @@ -15,18 +15,19 @@ - - - - - - - - - - - - + + + + + + + + + + + + + @@ -90,6 +91,14 @@ + + + + + + + + @@ -194,6 +203,7 @@ + @@ -211,6 +221,7 @@ + @@ -478,4 +489,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.mouse_settings.xml b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.mouse_settings.xml index a359068141..9e1dab7c78 100644 --- a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.mouse_settings.xml +++ b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.mouse_settings.xml @@ -30,7 +30,7 @@ - + @@ -39,7 +39,7 @@ - + diff --git a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.map_sharing.xml b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.map_sharing.xml new file mode 100644 index 0000000000..e0f74662bc --- /dev/null +++ b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.map_sharing.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 c5677d850f..7c67b967ec 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 @@ -21,6 +21,7 @@ + @@ -100,6 +101,14 @@ + + + + + + + + @@ -119,6 +128,7 @@ + @@ -130,5 +140,6 @@ + diff --git a/port/assets/menus/ce/port/main_menu/multiplayer_type_select/server_settings/map_share_options/header_map_share_options.png b/port/assets/menus/ce/port/main_menu/multiplayer_type_select/server_settings/map_share_options/header_map_share_options.png new file mode 100644 index 0000000000..b09f447c9c Binary files /dev/null and b/port/assets/menus/ce/port/main_menu/multiplayer_type_select/server_settings/map_share_options/header_map_share_options.png differ diff --git a/port/assets/menus/menus.json b/port/assets/menus/menus.json index 893825064c..ee64e0adc7 100644 --- a/port/assets/menus/menus.json +++ b/port/assets/menus/menus.json @@ -45,6 +45,7 @@ "ce/main_menu.settings_select.player_setup.player_profile_edit.gamepad_setup.xml", "ce/main_menu.settings_select.player_setup.player_profile_edit.mouse_settings.xml", "ce/main_menu.settings_select.player_setup.player_profile_edit.name_edit.xml", + "ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.map_sharing.xml", "ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.xml", "ce/main_menu.settings_select.player_setup.player_profile_edit.video_settings.fov_viewmodels.xml", "ce/main_menu.settings_select.player_setup.player_profile_edit.video_settings.graphics.xml", @@ -62,6 +63,7 @@ "ce/port/main_menu/multiplayer_type_select/lobby/header_add_player.png", "ce/port/main_menu/multiplayer_type_select/lobby/header_lobby.png", "ce/port/main_menu/multiplayer_type_select/server_settings/coop_options/header_coop_options.png", + "ce/port/main_menu/multiplayer_type_select/server_settings/map_share_options/header_map_share_options.png", "ce/port/main_menu/multiplayer_type_select/server_settings/voice_options/header_voice_options.png", "ce/port/pause/pausebox_center__0.png", "ce/port/pause/pausebox_center__1.png", diff --git a/port/assets/menus/port_svg/main_menu/multiplayer_type_select/server_settings/map_share_options/header_map_share_options.svg b/port/assets/menus/port_svg/main_menu/multiplayer_type_select/server_settings/map_share_options/header_map_share_options.svg new file mode 100644 index 0000000000..e0ec56b2c6 --- /dev/null +++ b/port/assets/menus/port_svg/main_menu/multiplayer_type_select/server_settings/map_share_options/header_map_share_options.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/port/ios/Assets.xcassets/AppArtwork.imageset/AppArtwork.png b/port/ios/Assets.xcassets/AppArtwork.imageset/AppArtwork.png new file mode 100644 index 0000000000..db03b85450 Binary files /dev/null and b/port/ios/Assets.xcassets/AppArtwork.imageset/AppArtwork.png differ diff --git a/port/ios/Assets.xcassets/AppArtwork.imageset/Contents.json b/port/ios/Assets.xcassets/AppArtwork.imageset/Contents.json new file mode 100644 index 0000000000..b3cc6df47e --- /dev/null +++ b/port/ios/Assets.xcassets/AppArtwork.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"AppArtwork.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} diff --git a/port/ios/CMakeLists.txt b/port/ios/CMakeLists.txt index 5de508f5dd..62232596ae 100644 --- a/port/ios/CMakeLists.txt +++ b/port/ios/CMakeLists.txt @@ -1,7 +1,12 @@ cmake_minimum_required(VERSION 3.28) project(HaloIOS LANGUAGES C CXX OBJC ASM) get_filename_component(HALO_ROOT "../.." ABSOLUTE) -set(HALO_BUILD "${HALO_ROOT}/build/ios") +if(CMAKE_OSX_SYSROOT MATCHES "simulator") + set(HALO_BUILD "${HALO_ROOT}/build/ios-simulator") + add_compile_definitions(HALO_IOS_SIMULATOR=1) +else() + set(HALO_BUILD "${HALO_ROOT}/build/ios") +endif() set(HALO_BUNDLE_IDENTIFIER "local.halo.ce-universal.ios" CACHE STRING "Unique iOS app bundle identifier") set(SDL_SHARED OFF CACHE BOOL "" FORCE) set(SDL_STATIC ON CACHE BOOL "" FORCE) @@ -9,41 +14,56 @@ set(SDL_TEST_LIBRARY OFF CACHE BOOL "" FORCE) set(SDL_TESTS OFF CACHE BOOL "" FORCE) add_subdirectory("${HALO_ROOT}/build/android/third_party/SDL3" "sdl") file(GLOB HOST_SOURCES "${HALO_ROOT}/port/macos/host/*.c") +file(GLOB MBEDTLS_SOURCES "${HALO_ROOT}/port/third_party/mbedtls/library/*.c") file(GLOB MINIUPNPC_SOURCES "${HALO_ROOT}/port/third_party/miniupnpc/src/*.c") list(FILTER HOST_SOURCES EXCLUDE REGEX "host_(main|loader)\\.c$") -add_executable(Halo MACOSX_BUNDLE ${HOST_SOURCES} ${MINIUPNPC_SOURCES} host/host_loader.c host/host_main.m host/host_controls.m +add_executable(Halo MACOSX_BUNDLE ${HOST_SOURCES} ${MINIUPNPC_SOURCES} ${MBEDTLS_SOURCES} host/host_loader.c host/host_main.m host/host_controls.m host/host_import.m host/host_orientation.m host/host_startup_error.m + "${HALO_ROOT}/port/macos/native/HaloMapDownloads.m" "${HALO_ROOT}/port/apple/host_gl_diagnostics.c" "${HALO_ROOT}/port/apple/host_graphics.c" "${HALO_ROOT}/port/apple/host_debug.m" + "${HALO_ROOT}/port/apple/host_metalfx.m" "${HALO_ROOT}/port/apple/host_shader_cache.m" + "${HALO_ROOT}/port/apple/game_store.m" "${HALO_ROOT}/port/apple/xiso.c" + "${HALO_ROOT}/port/linux/src/posix_dtls.c" "${HALO_ROOT}/port/macos/host/entry.s" "${HALO_BUILD}/host/host_import_table.c" "${HALO_BUILD}/embed/guest_image.s") target_sources(Halo PRIVATE Assets.xcassets) +# Internet discovery reads this beside config.toml; host_main installs the default. +target_sources(Halo PRIVATE "${HALO_ROOT}/port/assets/network/brokers.txt") +set_source_files_properties("${HALO_ROOT}/port/assets/network/brokers.txt" + PROPERTIES MACOSX_PACKAGE_LOCATION Resources) set_source_files_properties(Assets.xcassets PROPERTIES MACOSX_PACKAGE_LOCATION Resources) -target_compile_definitions(Halo PRIVATE HALO_IOS=1 HALO_MACOS=1 _DARWIN_C_SOURCE=1) +target_compile_definitions(Halo PRIVATE HALO_IOS=1 HALO_MACOS=1 HALO_APPLE_GRAPHICS=1 _DARWIN_C_SOURCE=1) target_compile_definitions(Halo PRIVATE MINIUPNP_STATICLIB MINIUPNPC_SET_SOCKET_TIMEOUT MINIUPNPC_GET_SRC_ADDR _BSD_SOURCE _DEFAULT_SOURCE) target_compile_options(Halo PRIVATE -O2 -g -Wall -Wextra -Wno-unused-parameter -Wno-unused-function "$<$:-fobjc-arc>") target_include_directories(Halo PRIVATE "${HALO_ROOT}" "${HALO_ROOT}/port/macos/host" "${HALO_ROOT}/port/android/include" "${HALO_ROOT}/port/linux/src" + "${HALO_ROOT}/port/apple" "${HALO_ROOT}/port/third_party/mbedtls/include" "${HALO_ROOT}/port/third_party/mbedtls/library" "${HALO_ROOT}/build/macos/toolchain/gl" "${HALO_BUILD}/embed" "${HALO_ROOT}/port/third_party/miniupnpc/include" "${HALO_ROOT}/port/third_party/miniupnpc/src") if(CMAKE_OSX_SYSROOT MATCHES "simulator") set(ANGLE_PLATFORM ios-arm64-simulator) else() set(ANGLE_PLATFORM ios-arm64) + target_link_libraries(Halo PRIVATE "-framework MetalFX") endif() +# SDL UIKit uses direct GL calls for its EAGL drawable. Bind those to Apple, +# while the game and diagnostics select ANGLE/Apple through host_gl_dispatch. +target_link_libraries(Halo PRIVATE "-framework OpenGLES") foreach(NAME EGL GLESv2) set(FRAMEWORK "${HALO_ROOT}/build/macos/angle/dist/${NAME}.xcframework/${ANGLE_PLATFORM}/lib${NAME}.framework") list(APPEND ANGLE_FRAMEWORKS "${FRAMEWORK}") target_link_libraries(Halo PRIVATE "${FRAMEWORK}") endforeach() -target_link_libraries(Halo PRIVATE SDL3::SDL3-static "-framework Foundation" "-framework UIKit" "-framework GameController") +target_link_libraries(Halo PRIVATE SDL3::SDL3-static "-framework Foundation" "-framework UIKit" "-framework GameController" "-framework UniformTypeIdentifiers" "-framework Metal" "-framework QuartzCore" "-framework CoreVideo" "-framework IOSurface") set_target_properties(Halo PROPERTIES MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist.in" XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${HALO_BUNDLE_IDENTIFIER}" XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_APPICON_NAME "AppIcon" - XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1" + XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1,2" XCODE_ATTRIBUTE_CODE_SIGN_STYLE "Automatic" XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CMAKE_CURRENT_SOURCE_DIR}/Halo.entitlements" XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME NO + XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_ARC YES XCODE_EMBED_FRAMEWORKS "${ANGLE_FRAMEWORKS}" XCODE_EMBED_FRAMEWORKS_CODE_SIGN_ON_COPY YES XCODE_EMBED_FRAMEWORKS_REMOVE_HEADERS_ON_COPY YES @@ -51,8 +71,30 @@ set_target_properties(Halo PROPERTIES if(HALO_DEVELOPMENT_TEAM) set_target_properties(Halo PROPERTIES XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "${HALO_DEVELOPMENT_TEAM}") endif() -if(HALO_BUNDLE_MAPS) - file(GLOB HALO_MAPS "${HALO_ROOT}/assets/maps/*") - target_sources(Halo PRIVATE ${HALO_MAPS}) - set_source_files_properties(${HALO_MAPS} PROPERTIES MACOSX_PACKAGE_LOCATION "GameData/maps") -endif() +# Game data is always supplied by the user after installation, never bundled. + +# License texts ship with the code; retail game images never do. +set(LICENSE_INPUTS + "LICENSE.md" "port/macos/licenses/ANGLE.txt" + "build/android/third_party/SDL3/LICENSE.txt" + "build/android/third_party/musl-1.2.5/COPYRIGHT" + "port/third_party/kcp/LICENSE" "port/third_party/miniupnpc/LICENSE" + "port/third_party/tomlc17/LICENSE" "port/third_party/mbedtls/LICENSE" + "port/third_party/zlib/LICENSE" "port/third_party/expat/COPYING" + "port/third_party/stb/LICENSE" "port/third_party/smaa/LICENSE") +foreach(INPUT IN LISTS LICENSE_INPUTS) + string(REPLACE "/" "-" NAME "${INPUT}") + set(OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/Licenses/${NAME}.txt") + configure_file("${HALO_ROOT}/${INPUT}" "${OUTPUT}" COPYONLY) + target_sources(Halo PRIVATE "${OUTPUT}") + set_source_files_properties("${OUTPUT}" PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/Licenses") +endforeach() + +# Ship the same app artwork as a standard PNG for IPA catalog readers. +# Asset-catalog icons may be Apple CgBI PNGs, which web image decoders reject. +configure_file(Assets.xcassets/AppIcon.appiconset/AppIcon.png + "${CMAKE_CURRENT_BINARY_DIR}/AppIcon1024.png" COPYONLY) +target_sources(Halo PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/AppIcon1024.png") +set_source_files_properties("${CMAKE_CURRENT_BINARY_DIR}/AppIcon1024.png" + PROPERTIES MACOSX_PACKAGE_LOCATION Resources) +set_target_properties(Halo PROPERTIES XCODE_ATTRIBUTE_COMPRESS_PNG_FILES NO) diff --git a/port/ios/Info.plist.in b/port/ios/Info.plist.in index 3b7c11869c..999ad71c71 100644 --- a/port/ios/Info.plist.in +++ b/port/ios/Info.plist.in @@ -6,12 +6,13 @@ CFBundleNameHalo OG CFBundleDisplayNameHalo OG CFBundlePackageTypeAPPL -CFBundleShortVersionString0.1.0 -CFBundleVersion1 +CFBundleShortVersionString0.2.0 +CFBundleVersion16 LSRequiresIPhoneOS MinimumOSVersion26.0 UIRequiredDeviceCapabilitiesarm64metal UISupportedInterfaceOrientationsUIInterfaceOrientationLandscapeLeftUIInterfaceOrientationLandscapeRight +UISupportedInterfaceOrientations~ipadUIInterfaceOrientationLandscapeLeftUIInterfaceOrientationLandscapeRight UIRequiresFullScreen CADisableMinimumFrameDurationOnPhone UIStatusBarHidden @@ -20,5 +21,6 @@ UIFileSharingEnabled UIApplicationSupportsIndirectInputEvents LSSupportsOpeningDocumentsInPlace +NSMicrophoneUsageDescriptionUse your microphone when you enable multiplayer voice chat. NSLocalNetworkUsageDescriptionFind Halo games on your local network. diff --git a/port/ios/README.md b/port/ios/README.md index 9dc8f972d2..11ae518721 100644 --- a/port/ios/README.md +++ b/port/ios/README.md @@ -1,203 +1,89 @@ -# iPhone development build +# iPhone and iPad -This port targets iPhone 17 Pro Max, arm64, iOS 26.0 or later. It uses the -existing game and Xbox SDK inputs from the local Mac setup. The renderer uses -ANGLE's Metal backend and inherits the Mac vertex-upload and occlusion-query -fixes. The first build uses the display's landscape aspect ratio at a 480-pixel -internal height, scaled to the native drawable. +Native ARM64 iOS/iPadOS 26+ build using innerhat's signed ILP32 runtime and +ANGLE Metal renderer. Both iPhone and iPad are supported by the app bundle. +No game data is packaged: first launch presents **Choose Halo ISO** in Files. +Import validates supported original Xbox PAL/NTSC-US maps, verifies a retained +private image by SHA-256 readback, and atomically publishes the new map cache. +Subsequent launches reuse it. Existing imports/saves from our earlier branch +and existing innerhat `Documents/GameData/maps` installations remain readable +when the same bundle ID and account are used. Different IDs have separate data. -## Build - -First complete [Apple build setup](../../docs/apple-build.md), including full -Xcode 26+, CMake, the public dependencies, your local game maps and XDK headers. -You do not need to build the Mac app or the simulator first. -`DEVELOPER_DIR` defaults to `/Applications/Xcode.app/Contents/Developer`; -the build does not change the system's selected developer directory. +## Download a test IPA -Sign into Xcode → Settings → Accounts with your own Apple account. Connect and -unlock the iPhone, trust the Mac, and enable Developer Mode in Settings → -Privacy & Security (restart and confirm when prompted). +[Download the unsigned IPA and re-signing kit](https://github.com/AttilaTheFun/halo-ce-universal/releases/tag/apple-ios-pr1-20261013) for this PR. +Follow [the re-signing guide](RESIGNING.md). A profile granting Extended Virtual +Addressing is required; free-account AltStore installation is not a tested path. +You supply your own game image on first launch. No device ID needs to be sent +to the maintainer when you use your own signing account. -A paid development team was required to provision -`com.apple.developer.kernel.extended-virtual-addressing` in testing; Xcode rejected -this capability for Personal Teams. The generic capability is in -[Halo.entitlements](Halo.entitlements). It expands virtual address space without -allocating that amount of physical RAM. If provisioning fails, check that your -team/profile supports this capability; removing it will not produce a working -physical-device build. +## Build -Find your own Team ID in your Apple developer membership, and choose a unique -reverse-DNS app identifier. Replace both example values below: +Follow [Apple build setup](../../docs/apple-build.md). LLVM 22, full Xcode, +CMake and Ninja are required; no private Xbox SDK or game image is needed to +compile. Do not bundle maps in an app distributed to others. ```sh +export HALO_MACOS_LLVM_BIN="$(brew --prefix llvm@22)/bin" +export PATH="$(brew --prefix lld@22)/bin:$PATH" +python3 tools/macos_setup.py +python3 tools/ios_build.py --platform simulator python3 tools/ios_build.py --platform device --sign \ --team YOUR_TEAM_ID --bundle-id com.yourname.halo +python3 tools/ios_package.py build/ios/device/Release-iphoneos/Halo.app build/Halo.ipa ``` -This compiles the game, builds SDL and the native host, packages the maps, and -asks Xcode to provision/sign with your local account. No signing identity is -provided by this repository. Outputs: - -- `build/ios/device/Release-iphoneos/Halo.app` -- `build/ios/device/HaloIOS.xcodeproj` (select the **Halo** scheme) - -Omit `--sign` and `--team` for an unsigned compile check. To use the Xcode UI, -open the generated project, choose your team under Signing & Capabilities, -select your connected iPhone, and run **Halo**. Use Release configuration for -performance testing. Rerunning the build script regenerates the project from -source, so pass your team and bundle ID on subsequent signed command-line builds. - -`--host-only` reuses the compiled game image for signing/UI-only changes. Omit it -when game sources, compiler code or generated bridges change. `--platform both` -builds the simulator and device hosts from the same engine image. - -## Install on a phone - -With `DEVELOPER_DIR` set as in the setup guide: - -```sh -xcrun devicectl list devices -xcrun devicectl device install app \ - --device DEVICE_ID build/ios/device/Release-iphoneos/Halo.app -xcrun devicectl device process launch \ - --device DEVICE_ID com.yourname.halo -``` - -Replace `DEVICE_ID` with your phone's identifier and use the bundle ID passed -at build time. Alternatively, use Xcode's Run button. The default bundle ID is -`local.halo.ce-universal.ios`; choose your own for device provisioning to avoid -registration conflicts. - -The default local build includes `assets/maps` (roughly 1.8 GB). Use -`--no-bundle-maps` to omit them, open the app once, then copy your `maps` folder -into its `Documents/GameData/` through Finder file sharing or Files → On My iPhone -→ Halo. Reopen the app after copying. It prefers `Documents/GameData/maps` over -bundled maps. Saves, `config.toml` and `halo.log` are in `Library/Application Support/Halo` -inside the app container. Do not publish the app bundle or local signing logs. - -For multiplayer on a physical phone, see [Mac and iPhone System Link](../../docs/apple-multiplayer.md). - -## Simulator - -No device development team is needed: - -```sh -python3 tools/ios_build.py --platform simulator -xcrun simctl list devices available -``` - -Choose an installed iPhone simulator's UUID, then: - -```sh -xcrun simctl boot SIMULATOR_ID # skip if already booted -open -a Simulator -xcrun simctl bootstatus SIMULATOR_ID -b -xcrun simctl install SIMULATOR_ID build/ios/simulator/Release-iphonesimulator/Halo.app -SIMCTL_CHILD_HALO_TOUCH_CONTROLS=1 xcrun simctl launch \ - SIMULATOR_ID local.halo.ce-universal.ios -``` - -Use your selected `--bundle-id` in the launch command if you overrode the default. -Simulator performance does not predict phone performance. - -## Controls - -Touch input uses two sticks plus A/B/X/Y, triggers, crouch, zoom, flashlight, -grenade type, Back and Pause. These feed the existing Xbox controller mapping. -The layout is an initial playable implementation; sensitivity and ergonomics -still need testing on the phone. Physical controllers use SDL's GameController -backend, and touch controls hide when a system controller is present. Physical -gamepad gameplay has not yet been validated on the iPhone. - -For simulator touch testing, launch with `SIMCTL_CHILD_HALO_TOUCH_CONTROLS=1` -before `xcrun simctl launch`; the simulator may advertise a system gamepad. -`HALO_TOUCH_CONTROLS=0` hides the overlay. The Mac window uses neither setting. - -## Frame rate - -The app opts into ProMotion and SDL requests the display's maximum refresh -rate: 120 Hz on supported iPhones, 60 Hz on other displays. ANGLE/Metal vsync -paces presentation. The engine interpolates rendered frames between its -unchanged 30 Hz simulation ticks, so higher frame rates do not speed up gameplay. -Keep `display.interpolation` and `display.vsync` enabled in `config.toml`. - -For a 60 Hz comparison or lower-power session, set `HALO_IOS_REFRESH_RATE=60` -in the Xcode scheme's environment or pass it through `devicectl`: - -```sh -xcrun devicectl device process launch --terminate-existing --device DEVICE_ID \ - --environment-variables '{"HALO_IOS_REFRESH_RATE":"60"}' com.yourname.halo -``` - -Omit the variable (or set it to `120`) to request the display's maximum again. -This is a refresh-rate preference, not a guarantee of sustained 120 FPS: iOS can -reduce the rate for Low Power Mode, the Accessibility → Motion → Limit Frame -Rate setting, or thermal conditions. See Apple's -[ProMotion guidance](https://developer.apple.com/documentation/quartzcore/optimizing-iphone-and-ipad-apps-to-support-promotion-displays). - -For device measurements, set `HALO_PERF_LOG=1` at launch. This writes per-frame -timings to `Library/Application Support/Halo/frames.csv` in the app container; -each profiled launch overwrites that file. `frame_ms` measures the interval -between completed buffer swaps (about 8.33 ms at 120 FPS or 16.67 ms at 60 FPS), -not GPU execution time or proof that every frame reached the screen. Use -Instruments' Game Performance/Metal timeline to inspect GPU and presentation -behavior. Profiling is off on ordinary launches. - -## Runtime design - -The game retains 32-bit pointers and structure layouts. Its ARM instructions -are embedded into the app's signed Mach-O `__TEXT,__halo` section at build time. -The ELF is an intermediate build artifact, not runtime-loaded executable code. -No JIT, writable executable mapping or runtime instruction patching is used. - -The LLVM adapter materializes original game addresses and translates data -accesses into a separate non-executable arena at `0x400000000`. Only the span -below guest address `0x90000000` is reserved (2.25 GB of virtual space); actual -memory is committed as used. Indirect calls add an immutable code-image delta. -Native callbacks use the same translation. Thread-local control blocks are -lowered before rebasing. iOS has a distinct ABI version to prevent mixing its -image with the Mac or Android loaders. - -## Verification - -```sh -python3 tools/test_ios_runtime.py -python3 tools/ios_mac_runtime_check.py -python3 tools/macos_benchmark.py \ - --host build/ios/mac-runtime-check/halo --guest build/ios/halo_guest.elf \ - --output build/ios/mac-runtime-check/campaign --effects --seconds 45 -``` - -The standalone signed-image test covers globals, indirect calls, stack pointers, -unaligned accesses, atomics, varargs and Xbox memory addresses. The Mac diagnostic -host exercises the complete iPhone engine layout independently of UIKit and -device signing. It is not an iPhone performance benchmark. - -On September 27, 2026, the 45-second Mac diagnostic campaign with repeated -explosions exited successfully: median frame time 16.66 ms, p95 17.11 ms, -maximum measured footprint 465 MB. Mac allocator and audio regression probes -also passed. The iPhone simulator rendered the menu at 2868×1320 with Metal and -displayed touch controls. A development build on iPhone 17 Pro Max with -iOS 26.6.2 confirmed the signed engine, 2868×1320 Metal drawable and A19 Pro GPU. -Campaign gameplay, movement/aiming, touch controls and audio were tested on the -phone. - -ProMotion validation on the same phone measured the following buffer-swap -intervals with optional frame logging enabled: - -| Session | Duration | Average FPS | Median frame | p95 frame | -| --- | ---: | ---: | ---: | ---: | -| 60 Hz override, main menu | 15 s | 59.99 | 16.66 ms | 16.82 ms | -| 120 Hz target, campaign play | 179 s | 119.42 | 8.33 ms | 8.61 ms | - -The campaign capture included 21,368 frames, a 9.65 ms p99, an isolated -114.97 ms maximum frame, and a 455 MiB peak physical footprint. A separate -1.70-second sequence of 205 Metal drawable presentation callbacks in Instruments -averaged 119.97 FPS; the trace reported a nominal thermal state. Movement, -aiming, firing and audio were confirmed on the device. The menu and campaign -rows cover different workloads; these short checks do not establish a sustained -120 FPS minimum across every level or long thermal sessions. - -Bink movies remain unsupported, as in the Mac port. Multiplayer, sustained -thermal behavior, background/resume behavior and the full campaign require -separate device testing before treating this as a finished iPhone release. +The repository default identifier remains `local.halo.ce-universal.ios`. +Choose your own identifier for distribution; personal account settings do not +belong in this repository. `--no-bundle-maps` remains accepted for compatibility, +but map bundling has been removed. `--host-only` is suitable only when reusing an +unchanged guest image. Any game/compiler/bridge change requires the normal build. + +The signed device runtime reserves a virtual arena above iOS's guard region. +Its profile must grant **Extended Virtual Addressing**; upstream testing found +Personal Teams could not provision it. No JIT entitlement is used. A development +or Ad Hoc profile must cover the installing device. See the [IPA re-signing +guide](RESIGNING.md) for a locally held identity/profile workflow. + +## Play and settings + +Touch controls hide when a physical controller connects, and return on +disconnect. The primary hardware controller shares player one with the touch +controller; additional controllers retain separate slots. D-pad buttons support +menu navigation. Flashlight and grenade-switch labels match the default game +bindings. + +For touch plus a controller as **different players**, shake to open settings, +enable **Touch as a separate player**, then relaunch. Touch remains player one +and stays visible; the first hardware controller becomes player two. Create or +join a multiplayer lobby, choose **Add Player**, and use the additional +controller to select its profile. This also permits the second controller's +profile selection in **Co-op Campaign**. With the option off (the default), +connecting a controller still hides touch controls. Hardware split-screen +play has not yet been verified on a physical device with this revision. + +Mac uses its existing keyboard/mouse/controller path without touch. + +Shake the phone/tablet for the settings modal. It offers optional MetalFX +spatial upscaling, bounded shader-stall recording and a share sheet for reports. +The graphics settings select Metal (ANGLE) or native OpenGL ES, with a relaunch +to change renderer. The separate WebKit room-code transport remains retired. The in-game browser +uses upstream's current native/browser networking. The default renderer is +Metal and MetalFX remains off unless explicitly enabled. + +The ANGLE blob cache is private and bounded to 64 MiB; misses compile normally. +Reports contain shader sources and timing, not maps or saves. First-use GPU +recording adds synchronization overhead. MetalFX benefit depends on hardware +and selected render resolution and is not a guaranteed performance gain. + +## Validation + +[Migration and validation record](../../docs/apple-ios-migration.md) records +what has been carried over and what still needs device testing. Simulator +results do not prove physical iPhone/iPad memory reservation or GPU performance. +Use your own device through your usual installer; test tooling should not +replace or launch apps on someone else's device. + +Simulator builds keep a separate signed image in `build/ios-simulator` and use +the Mac address arena because recent simulators reserve the device arena. +`--host-only` reuses only the image for the requested platform. diff --git a/port/ios/RESIGNING.md b/port/ios/RESIGNING.md new file mode 100644 index 0000000000..0f82d3fba4 --- /dev/null +++ b/port/ios/RESIGNING.md @@ -0,0 +1,144 @@ +# Install the unsigned IPA with your own Apple account + +Use an Apple Silicon Mac with Xcode 26+ and Python 3. This runtime requires +iOS/iPadOS 26+ and a profile granting Extended Virtual Addressing; a paid +development team was required in upstream testing. Start with a device IPA +provided by the builder, or build one using [the iOS build instructions](README.md) +and `tools/ios_package.py`. Download the unsigned device IPA and re-signing kit from the +[PR #1 testing release](https://github.com/AttilaTheFun/halo-ce-universal/releases/tag/apple-ios-pr1-20261013). The release is built from +`apple/ios-installer`, not the private branding branch. GitHub Actions does not +yet publish an unsigned IPA automatically. A simulator app cannot run on an iPhone. + +The script signs locally using a certificate/private key already in your +Keychain and a provisioning profile issued for your account. It does not +sign in to Apple, create certificates, register devices, or bypass provisioning. +You do not need to send your device ID to this project's maintainer when +you use your own account. + +## Set up signing once + +1. Add your Apple account in **Xcode → Settings → Accounts**. Connect your + iPhone, trust the Mac, and enable Developer Mode if requested. +2. If you do not already have a suitable iOS development profile, create a + temporary iOS App project in Xcode. Select your team, enable automatic + signing, add the **Extended Virtual Addressing** capability, and set a unique bundle identifier such as `com.yourname.haloce`. + Build/run that temporary app on your phone so Xcode creates the identity + and a profile covering the device. Then delete the temporary app from + the phone before installing Halo with the same identifier. +3. Locate the temporary app's build product using **Products → Show in + Finder**, then **Show Package Contents**. Copy its + `embedded.mobileprovision` somewhere convenient. Alternatively, use an + existing development or Ad Hoc `.mobileprovision` downloaded from Apple. +4. List available identities: + + ```sh + security find-identity -v -p codesigning + ``` + + Use the matching **Apple Development** identity (or **Apple Distribution** + identity for an Ad Hoc profile). Its private key must be in your Keychain, + and its certificate must be included in the profile. An App Store profile + cannot install this build directly on a phone. + +## Sign and install + +Extract `OpenCE-iOS-resigning-kit.zip` and run the command below from its +folder. Alternatively, check out `apple/ios-installer` from +`AttilaTheFun/halo-ce-universal` and run it from the repository root: + +```sh +./tools/ios_resign.sh ~/Downloads/OpenCE-iOS-PR1-unsigned.ipa \ + --identity 'Apple Development: Your Name (IDENTITY_ID)' \ + --profile ~/Downloads/embedded.mobileprovision \ + --bundle-id com.yourname.haloce \ + --device YOUR_DEVICE_UDID \ + --output ~/Downloads/Halo-signed.ipa +``` + +The exact identity name or its SHA-1 fingerprint is accepted. `--device` +is optional but recommended: it verifies that the supplied UDID is covered. +Use `xcrun devicectl list devices` or Xcode's Devices and Simulators window +to identify your phone. The output must not already exist. + +The script checks profile expiry, platform, bundle ID, device and signing +certificate; updates the bundle ID; signs ANGLE/SDL frameworks and the app +inside-out; generates DER entitlements; verifies signatures; and writes a +new IPA plus a SHA-256 checksum. It never changes the downloaded IPA. +The shell entry point uses the adjacent `ios_resign.py`, so keep both files. + +Install `Halo-signed.ipa` with Apple Configurator, or extract and install +the app with Xcode's command-line device tools: + +```sh +mkdir -p ~/Downloads/Halo-signed-extracted +ditto -x -k ~/Downloads/Halo-signed.ipa ~/Downloads/Halo-signed-extracted +xcrun devicectl device install app --device YOUR_DEVICE_UDID \ + ~/Downloads/Halo-signed-extracted/Payload/Halo.app +xcrun devicectl device process launch --device YOUR_DEVICE_UDID com.yourname.haloce +``` + +On first launch, choose your own supported original Xbox Halo ISO/XISO. +No game image or maps are included in the IPA. Keep the same account and +bundle ID for updates to retain imported data. Renew the provisioning +profile and re-sign before it expires; a free Personal Team's validity and +device limits differ from paid developer membership. Back up saves before +uninstalling. Do not upload your signed IPA/profile or private key to this +repository; they contain your signing/device information. + +## AltStore / similar sideloaders + +Do not assume that a free Apple ID plus AltStore Classic will run this build. +The runtime requires `com.apple.developer.kernel.extended-virtual-addressing`; +the provisioning profile must authorize it and the re-signer must retain it. +Our tested setup uses a paid Apple Developer team. Personal Team provisioning +and third-party sideloaders have not been validated for this port. + +The unsigned IPA has no embedded profile or code signature. The kit includes +`Required-entitlements.plist` for tools that accept an entitlement file, but +adding that file does not grant the capability: the profile must contain it. +If a sideloader cannot provision or preserve it, use the script above with an +appropriate profile. Installation alone is not proof of a working runtime. +This is a manually re-signable test artifact, not an AltStore PAL listing. + +## LiveContainer and memory reservation errors + +A reported iPhone 17 Pro Max / iOS 26.0 LiveContainer launch failed with +`Guest arena reservation: (os/kern) invalid address` after ISO import. This +happens before the game image starts, not while parsing the ISO. Re-importing +or reinstalling the same image does not change process address-space limits. + +[LiveContainer documents](https://github.com/LiveContainer/LiveContainer#limitations) +that guest entitlements are not applied to its host process. Granting Extended +Virtual Addressing to the Halo guest therefore does not prove the hosting +process has it. The file-picker workaround does not grant memory capabilities. +The error is consistent with a missing process capability or an incompatible +address-space layout; the log alone does not distinguish them. + +Try the standalone installation described above with a profile authorizing +Extended Virtual Addressing. LiveContainer remains unverified even if its host +is entitled: the required address range must also be available. Do not change +the compiled runtime's fixed base address as a signing workaround. + +The startup preflight now checks memory before importing an ISO. Failures show +a scrollable native screen with **Copy details** and **Close**, and log the +requested address/size and kernel result in `Documents/save/halo.log` (or the +existing legacy save directory). Existing imports and saves are preserved. + +## Build it yourself + +```sh +git clone --branch apple/ios-installer https://github.com/AttilaTheFun/halo-ce-universal.git +cd halo-ce-universal +``` + +Follow [the build instructions](README.md#build), supplying your own team and +unique bundle ID. Source builds need the same Extended Virtual Addressing +capability; compiling locally does not remove that requirement. + +The public IPA is checked by re-signing it locally and verifying the nested +framework and app signatures and required entitlement. This does not establish +compatibility with every device or signing service. + +Apple references: [signing setup](https://developer.apple.com/documentation/xcode/running-your-app-on-simulated-or-physical-devices), +[provisioning profiles](https://developer.apple.com/documentation/technotes/tn3125-inside-code-signing-provisioning-profiles), +and [nested signatures and DER entitlements](https://developer.apple.com/documentation/xcode/using-the-latest-code-signature-format). diff --git a/port/ios/host/host_controls.m b/port/ios/host/host_controls.m index d039cd5fb1..ddea8e9bc8 100644 --- a/port/ios/host/host_controls.m +++ b/port/ios/host/host_controls.m @@ -1,158 +1,257 @@ -/* UIKit controls drive SDL's virtual gamepad and the existing Xbox input path. */ +#include "../../apple/host_debug.h" +/* UIKit controls feed a standard SDL gamepad. The first hardware controller + shares player one with touch by default. Optional separate-player mode keeps + touch on player one and gives hardware controllers their own ports. */ #import -#import #include #include "host.h" -static SDL_Joystick *touch_pad; -@interface HaloStick : UIView -@property int axis; -@property(strong) UIView *thumb; -@end -@implementation HaloStick -- (instancetype)initWithFrame:(CGRect)frame { - if ((self = [super initWithFrame:frame])) { - self.backgroundColor = [UIColor colorWithWhite:0 alpha:0.18]; - self.layer.borderColor = [UIColor colorWithWhite:1 alpha:0.45].CGColor; - self.layer.borderWidth = 1; self.layer.cornerRadius = frame.size.width / 2; - self.thumb = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 46, 46)]; - self.thumb.backgroundColor = [UIColor colorWithWhite:1 alpha:0.25]; - self.thumb.layer.cornerRadius = 23; self.thumb.userInteractionEnabled = NO; - [self addSubview:self.thumb]; - self.thumb.center = CGPointMake(frame.size.width / 2, frame.size.height / 2); - self.isAccessibilityElement = YES; +#import "host_orientation.h" +#include + +static SDL_Joystick *touch_joystick; +static SDL_JoystickID touch_id, primary_hardware; +static SDL_Gamepad *primary_gamepad; +static BOOL separate_players; +static NSString *const controllerChanged = @"HaloControllerChanged"; + +static void button_state(int button, BOOL down) { + if(touch_joystick) SDL_SetJoystickVirtualButton(touch_joystick,button,down); +} +static void axis_state(int axis, float value) { + if(touch_joystick) SDL_SetJoystickVirtualAxis(touch_joystick,axis,(Sint16)(fmaxf(-1,fminf(1,value))*32767)); +} +void host_ios_touch_initialize(void) { + separate_players=[NSUserDefaults.standardUserDefaults boolForKey:@"HaloSeparateTouchPlayer"]; + SDL_VirtualJoystickDesc desc; + SDL_INIT_INTERFACE(&desc); + desc.type=SDL_JOYSTICK_TYPE_GAMEPAD; + desc.naxes=SDL_GAMEPAD_AXIS_COUNT; + desc.nbuttons=SDL_GAMEPAD_BUTTON_COUNT; + desc.axis_mask=(1u<0 && touch_id)out[used++]=touch_id; + for(int i=0;iabs(value))value=physical; + } + return value; +} +int host_ios_gamepad_button(SDL_Gamepad *pad,int button) { + if(halo_debug_is_presented())return 0; + return SDL_GetGamepadButton(pad,button) || + (SDL_GetGamepadID(pad)==touch_id && !separate_players && primary_hardware && + SDL_GetGamepadButton(primary_gamepad,button)); +} +void host_ios_touch_reset(void) { + for(int i=0;i=SDL_GAMEPAD_AXIS_LEFT_TRIGGER?-1:0); +} + +@interface HaloButton : UIButton +@property(nonatomic) int gameButton; +@property(nonatomic) int gameAxis; +@end +@implementation HaloButton +- (void)sendDown:(BOOL)down { + self.highlighted=down; + if(self.gameAxis>=0)axis_state(self.gameAxis,down?1:-1); + else button_state(self.gameButton,down); +} +- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event { + [super beginTrackingWithTouch:touch withEvent:event];[self sendDown:YES];return YES; } +- (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event { + [super endTrackingWithTouch:touch withEvent:event];[self sendDown:NO]; +} +- (void)cancelTrackingWithEvent:(UIEvent *)event { + [super cancelTrackingWithEvent:event];[self sendDown:NO]; +} +- (BOOL)accessibilityActivate { + [self sendDown:YES]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW,150*NSEC_PER_MSEC),dispatch_get_main_queue(),^{[self sendDown:NO];}); + return YES; +} +@end + +@interface HaloStick : UIControl +@property(nonatomic) int firstAxis; +@property(nonatomic) CGPoint position; +@property(nonatomic,copy) NSString *caption; +@end +@implementation HaloStick - (void)updateTouch:(UITouch *)touch { - CGPoint p = [touch locationInView:self]; - CGFloat radius = self.bounds.size.width / 2, travel = radius - 23; - CGFloat x = (p.x - radius) / travel, y = (p.y - radius) / travel; - CGFloat length = hypot(x, y); - if (length > 1) { x /= length; y /= length; } - self.thumb.center = CGPointMake(radius + x * travel, radius + y * travel); - SDL_SetJoystickVirtualAxis(touch_pad, self.axis, (Sint16)(x * 32767)); - SDL_SetJoystickVirtualAxis(touch_pad, self.axis + 1, (Sint16)(y * 32767)); -} -- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { (void)event; [self updateTouch:touches.anyObject]; } -- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { (void)event; [self updateTouch:touches.anyObject]; } -- (void)reset { - self.thumb.center = CGPointMake(self.bounds.size.width / 2, self.bounds.size.height / 2); - SDL_SetJoystickVirtualAxis(touch_pad, self.axis, 0); - SDL_SetJoystickVirtualAxis(touch_pad, self.axis + 1, 0); -} -- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { (void)touches; (void)event; [self reset]; } -- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event { [self touchesEnded:touches withEvent:event]; } + CGPoint p=[touch locationInView:self];CGFloat radius=self.bounds.size.width*.34; + CGFloat x=(p.x-self.bounds.size.width/2)/radius,y=(p.y-self.bounds.size.height/2)/radius; + CGFloat length=hypot(x,y);if(length>1){x/=length;y/=length;} + self.position=CGPointMake(x,y);axis_state(self.firstAxis,x);axis_state(self.firstAxis+1,y); + [self setNeedsDisplay]; +} +- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event {[self updateTouch:touch];return YES;} +- (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event {[self updateTouch:touch];return YES;} +- (void)reset {self.position=CGPointZero;axis_state(self.firstAxis,0);axis_state(self.firstAxis+1,0);[self setNeedsDisplay];} +- (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event {[self reset];} +- (void)cancelTrackingWithEvent:(UIEvent *)event {[self reset];} +- (void)drawRect:(CGRect)rect { + CGRect circle=CGRectInset(self.bounds,2,2); + [[UIColor colorWithWhite:0 alpha:.2]setFill];UIBezierPath *path=[UIBezierPath bezierPathWithOvalInRect:circle];[path fill]; + [[UIColor colorWithWhite:1 alpha:.5]setStroke];path.lineWidth=1.5;[path stroke]; + CGFloat size=self.bounds.size.width,r=size*.34; + CGRect knob=CGRectMake(size/2+self.position.x*r-21,size/2+self.position.y*r-21,42,42); + [[UIColor colorWithWhite:1 alpha:.25]setFill];[[UIBezierPath bezierPathWithOvalInRect:knob]fill]; + NSDictionary *attrs=@{NSFontAttributeName:[UIFont systemFontOfSize:10 weight:UIFontWeightSemibold],NSForegroundColorAttributeName:[UIColor colorWithWhite:1 alpha:.8]}; + CGSize text=[self.caption sizeWithAttributes:attrs]; + [self.caption drawAtPoint:CGPointMake((size-text.width)/2,size-20) withAttributes:attrs]; +} @end + @interface HaloControls : UIView -@property(strong) HaloStick *moveStick; -@property(strong) HaloStick *lookStick; -@property(strong) NSMutableArray *buttons; +@property(nonatomic,strong) HaloStick *moveStick; +@property(nonatomic,strong) HaloStick *lookStick; +@property(nonatomic,strong) NSMutableArray *buttons; @end +static __weak HaloControls *activeControls; +void host_ios_touch_focus(void) {[activeControls becomeFirstResponder];} @implementation HaloControls -- (UIButton *)button:(NSString *)title input:(int)input { - UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom]; - [button setTitle:title forState:UIControlStateNormal]; - button.titleLabel.font = [UIFont boldSystemFontOfSize:14]; - button.backgroundColor = [UIColor colorWithWhite:0 alpha:0.3]; - button.layer.borderColor = [UIColor colorWithWhite:1 alpha:0.5].CGColor; - button.layer.borderWidth = 1; button.layer.cornerRadius = 22; button.tag = input; - [button addTarget:self action:@selector(down:) forControlEvents:UIControlEventTouchDown | UIControlEventTouchDragEnter]; - [button addTarget:self action:@selector(up:) forControlEvents:UIControlEventTouchUpInside | UIControlEventTouchUpOutside | UIControlEventTouchCancel | UIControlEventTouchDragExit]; - [self addSubview:button]; [self.buttons addObject:button]; - return button; +- (BOOL)canBecomeFirstResponder {return YES;} +- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event { + if(motion==UIEventSubtypeMotionShake)halo_debug_present(); + else [super motionEnded:motion withEvent:event]; } -- (instancetype)initWithFrame:(CGRect)frame { - if ((self = [super initWithFrame:frame])) { - self.multipleTouchEnabled = YES; self.buttons = [NSMutableArray new]; - self.moveStick = [[HaloStick alloc] initWithFrame:CGRectMake(0, 0, 126, 126)]; - self.moveStick.axis = SDL_GAMEPAD_AXIS_LEFTX; self.moveStick.accessibilityLabel = @"Move"; - self.lookStick = [[HaloStick alloc] initWithFrame:CGRectMake(0, 0, 126, 126)]; - self.lookStick.axis = SDL_GAMEPAD_AXIS_RIGHTX; self.lookStick.accessibilityLabel = @"Look"; - [self addSubview:self.moveStick]; [self addSubview:self.lookStick]; - [self button:@"A" input:SDL_GAMEPAD_BUTTON_SOUTH].accessibilityLabel = @"A: Jump or confirm"; - [self button:@"B" input:SDL_GAMEPAD_BUTTON_EAST].accessibilityLabel = @"B: Melee or back"; - [self button:@"X" input:SDL_GAMEPAD_BUTTON_WEST].accessibilityLabel = @"X: Reload or interact"; - [self button:@"Y" input:SDL_GAMEPAD_BUTTON_NORTH].accessibilityLabel = @"Y: Switch weapon"; - [self button:@"Fire" input:100 + SDL_GAMEPAD_AXIS_RIGHT_TRIGGER]; - [self button:@"Grenade" input:100 + SDL_GAMEPAD_AXIS_LEFT_TRIGGER]; - [self button:@"Pause" input:SDL_GAMEPAD_BUTTON_START]; - [self button:@"Light" input:SDL_GAMEPAD_BUTTON_LEFT_SHOULDER]; - [self button:@"Type" input:SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER].accessibilityLabel = @"Switch grenade type"; - [self button:@"Crouch" input:SDL_GAMEPAD_BUTTON_LEFT_STICK]; - [self button:@"Zoom" input:SDL_GAMEPAD_BUTTON_RIGHT_STICK]; - [self button:@"Back" input:SDL_GAMEPAD_BUTTON_BACK]; +- (void)openGraphicsDebug:(id)sender {(void)sender;halo_debug_present();} +- (void)buildMenuWithBuilder:(id)builder { + [super buildMenuWithBuilder:builder]; + if(builder.system==UIMenuSystem.mainSystem) { + UIKeyCommand *command=[UIKeyCommand commandWithTitle:@"Graphics Debug…" image:nil action:@selector(openGraphicsDebug:) input:@"d" modifierFlags:UIKeyModifierCommand|UIKeyModifierShift propertyList:nil]; + [builder insertSiblingMenu:[UIMenu menuWithTitle:@"Debug" image:nil identifier:@"halo.graphics-debug" options:0 children:@[command]] beforeMenuForIdentifier:UIMenuWindow]; } +} +- (HaloButton *)addButton:(NSString *)title label:(NSString *)label button:(int)button axis:(int)axis { + HaloButton *b=[HaloButton buttonWithType:UIButtonTypeCustom];b.gameButton=button;b.gameAxis=axis; + [b setTitle:title forState:UIControlStateNormal];b.titleLabel.font=[UIFont systemFontOfSize:14 weight:UIFontWeightSemibold]; + b.backgroundColor=[UIColor colorWithWhite:0 alpha:.28];b.layer.borderColor=[UIColor colorWithWhite:1 alpha:.5].CGColor;b.layer.borderWidth=1; + b.accessibilityLabel=label;b.exclusiveTouch=NO;[self addSubview:b];[self.buttons addObject:b];return b; +} +- (instancetype)initWithFrame:(CGRect)frame { + if(!(self=[super initWithFrame:frame]))return nil; + self.autoresizingMask=UIViewAutoresizingFlexibleWidth|UIViewAutoresizingFlexibleHeight; + self.multipleTouchEnabled=YES;self.buttons=[NSMutableArray array]; + self.moveStick=[[HaloStick alloc]init];self.moveStick.firstAxis=SDL_GAMEPAD_AXIS_LEFTX;self.moveStick.caption=@"MOVE"; + self.lookStick=[[HaloStick alloc]init];self.lookStick.firstAxis=SDL_GAMEPAD_AXIS_RIGHTX;self.lookStick.caption=@"LOOK"; + for(HaloStick *s in @[self.moveStick,self.lookStick]){s.backgroundColor=UIColor.clearColor;s.isAccessibilityElement=YES;s.accessibilityLabel=s.caption;[self addSubview:s];} + [self addButton:@"A" label:@"A — Jump or select" button:SDL_GAMEPAD_BUTTON_SOUTH axis:-1]; + [self addButton:@"B" label:@"B — Melee or back" button:SDL_GAMEPAD_BUTTON_EAST axis:-1]; + [self addButton:@"X" label:@"X — Reload or use" button:SDL_GAMEPAD_BUTTON_WEST axis:-1]; + [self addButton:@"Y" label:@"Y — Switch weapon" button:SDL_GAMEPAD_BUTTON_NORTH axis:-1]; + [self addButton:@"FIRE" label:@"Fire" button:0 axis:SDL_GAMEPAD_AXIS_RIGHT_TRIGGER]; + [self addButton:@"GRENADE" label:@"Throw grenade" button:0 axis:SDL_GAMEPAD_AXIS_LEFT_TRIGGER]; + [self addButton:@"↑" label:@"Menu up" button:SDL_GAMEPAD_BUTTON_DPAD_UP axis:-1]; + [self addButton:@"↓" label:@"Menu down" button:SDL_GAMEPAD_BUTTON_DPAD_DOWN axis:-1]; + [self addButton:@"←" label:@"Menu left" button:SDL_GAMEPAD_BUTTON_DPAD_LEFT axis:-1]; + [self addButton:@"→" label:@"Menu right" button:SDL_GAMEPAD_BUTTON_DPAD_RIGHT axis:-1]; + [self addButton:@"PAUSE" label:@"Pause or start" button:SDL_GAMEPAD_BUTTON_START axis:-1]; + [self addButton:@"CROUCH" label:@"Crouch" button:SDL_GAMEPAD_BUTTON_LEFT_STICK axis:-1]; + [self addButton:@"ZOOM" label:@"Zoom" button:SDL_GAMEPAD_BUTTON_RIGHT_STICK axis:-1]; + [self addButton:@"LIGHT" label:@"Flashlight" button:SDL_GAMEPAD_BUTTON_LEFT_SHOULDER axis:-1]; + [self addButton:@"SWAP G" label:@"Switch grenade" button:SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER axis:-1]; + [[NSNotificationCenter defaultCenter]addObserver:self selector:@selector(reset) name:UIApplicationWillResignActiveNotification object:nil]; + [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(controllerChanged) name:controllerChanged object:nil]; + [self controllerChanged]; return self; } -- (void)layoutSubviews { - [super layoutSubviews]; - CGRect safe = UIEdgeInsetsInsetRect(self.bounds, self.safeAreaInsets); - CGFloat left = CGRectGetMinX(safe) + 18, right = CGRectGetMaxX(safe) - 40; - CGFloat bottom = CGRectGetMaxY(safe) - 14, top = CGRectGetMinY(safe) + 12; - self.moveStick.frame = CGRectMake(left, bottom - 126, 126, 126); - self.lookStick.frame = CGRectMake(right - 254, bottom - 126, 126, 126); - CGPoint points[] = {{right-50,bottom-44},{right,bottom-94},{right-100,bottom-94},{right-50,bottom-144}, - {right-45,top+32},{left+45,top+32},{CGRectGetMidX(safe),top+12}, - {left+158,top+12},{right-158,top+12},{left+158,bottom-20},{right-254,bottom-160}, - {CGRectGetMidX(safe)-85,top+12}}; - for (NSUInteger i = 0; i < self.buttons.count; i++) { - UIButton *button = self.buttons[i]; CGFloat width = i < 4 ? 44 : 70; - button.frame = CGRectMake(points[i].x-width/2, points[i].y-22, width, 44); - } +- (void)controllerChanged { + BOOL connected=primary_hardware!=0; + [self setControlsHidden:connected && !separate_players]; } -- (void)down:(UIButton *)button { - button.alpha = 0.65; - if (button.tag >= 100) SDL_SetJoystickVirtualAxis(touch_pad, (int)button.tag - 100, 32767); - else SDL_SetJoystickVirtualButton(touch_pad, (int)button.tag, true); +- (void)setControlsHidden:(BOOL)hidden { + [self reset]; + self.moveStick.hidden=self.lookStick.hidden=hidden; + for(UIView *button in self.buttons)button.hidden=hidden; } -- (void)up:(UIButton *)button { - button.alpha = 1; - if (button.tag >= 100) SDL_SetJoystickVirtualAxis(touch_pad, (int)button.tag - 100, -32768); - else SDL_SetJoystickVirtualButton(touch_pad, (int)button.tag, false); +- (void)reset {host_ios_touch_reset();[self.moveStick reset];[self.lookStick reset];} +- (void)layoutSubviews { + [super layoutSubviews];CGRect r=UIEdgeInsetsInsetRect(self.bounds,self.safeAreaInsets); + CGFloat left=r.origin.x+16,right=CGRectGetMaxX(r)-16,bottom=CGRectGetMaxY(r)-12,top=r.origin.y+10; + CGFloat stick=MIN(136,r.size.height*.32),s=44,gap=4; + self.moveStick.frame=CGRectMake(left,bottom-stick,stick,stick); + self.lookStick.frame=CGRectMake(right-3*s-20-stick,bottom-stick,stick,stick); + // Four face buttons in the traditional diamond. + CGPoint face[4]={{right-2*s-gap,bottom-s},{right-s,bottom-2*s-gap},{right-3*s-2*gap,bottom-2*s-gap},{right-2*s-gap,bottom-3*s-2*gap}}; + for(int i=0;i<4;i++)self.buttons[i].frame=CGRectMake(face[i].x,face[i].y,s,s); + self.buttons[4].frame=CGRectMake(right-84,top+55,84,48); + self.buttons[5].frame=CGRectMake(left,top+55,90,48); + CGFloat d=34,dx=left+stick+12,dy=bottom-3*d; + self.buttons[6].frame=CGRectMake(dx+d,dy,d,d); + self.buttons[7].frame=CGRectMake(dx+d,dy+2*d,d,d); + self.buttons[8].frame=CGRectMake(dx,dy+d,d,d); + self.buttons[9].frame=CGRectMake(dx+2*d,dy+d,d,d); + self.buttons[10].frame=CGRectMake(CGRectGetMidX(r)-38,top,76,34); + self.buttons[11].frame=CGRectMake(left,bottom-stick-42,74,34); + self.buttons[12].frame=CGRectMake(right-3*s-20-stick,bottom-stick-42,74,34); + self.buttons[13].frame=CGRectMake(right-180,top+55,80,36); + self.buttons[14].frame=CGRectMake(left+104,top+55,80,36); + for(UIButton *b in self.buttons){b.layer.cornerRadius=MIN(b.bounds.size.width,b.bounds.size.height)/2;b.titleLabel.adjustsFontSizeToFitWidth=YES;} } - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event { - for (UIView *view in self.subviews) - if (!view.hidden && [view pointInside:[view convertPoint:point fromView:self] withEvent:event]) return YES; + for(UIView *view in self.subviews)if(!view.hidden && [view pointInside:[self convertPoint:point toView:view] withEvent:event])return YES; return NO; } -- (void)reset { - [self.moveStick reset]; [self.lookStick reset]; - for (UIButton *button in self.buttons) [self up:button]; -} @end -static HaloControls *controls; -static void refresh_controls(void) { - BOOL physical = NO; - for (GCController *controller in GCController.controllers) - if (controller.extendedGamepad) physical = YES; - const char *setting = SDL_getenv("HALO_TOUCH_CONTROLS"); - if (setting) physical = !SDL_atoi(setting); - controls.hidden = physical; [controls reset]; - host_logf(HOST_LOG_INFO, "Touch controls %s; %lu system controllers", - physical ? "hidden" : "visible", (unsigned long)GCController.controllers.count); -} -void host_ios_controls_initialize(SDL_Window *window) { - if (controls) return; - SDL_VirtualJoystickDesc descriptor; - SDL_INIT_INTERFACE(&descriptor); - descriptor.type = SDL_JOYSTICK_TYPE_GAMEPAD; - descriptor.naxes = SDL_GAMEPAD_AXIS_COUNT; descriptor.nbuttons = SDL_GAMEPAD_BUTTON_COUNT; - descriptor.axis_mask = (1u << SDL_GAMEPAD_AXIS_COUNT) - 1; - descriptor.button_mask = (1u << SDL_GAMEPAD_BUTTON_COUNT) - 1; - descriptor.name = "Halo OG Touch Controls"; - SDL_JoystickID id = SDL_AttachVirtualJoystick(&descriptor); - touch_pad = SDL_OpenJoystick(id); - if (!touch_pad) { host_logf(HOST_LOG_WARN, "Touch controls: %s", SDL_GetError()); return; } - UIWindow *native = (__bridge UIWindow *)SDL_GetPointerProperty(SDL_GetWindowProperties(window), SDL_PROP_WINDOW_UIKIT_WINDOW_POINTER, NULL); - UIView *view = native.rootViewController.view; - controls = [[HaloControls alloc] initWithFrame:view.bounds]; - controls.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - [view addSubview:controls]; - for (NSNotificationName name in @[GCControllerDidConnectNotification, GCControllerDidDisconnectNotification]) - [NSNotificationCenter.defaultCenter addObserverForName:name object:nil queue:NSOperationQueue.mainQueue - usingBlock:^(NSNotification *notification) { (void)notification; refresh_controls(); }]; - [NSNotificationCenter.defaultCenter addObserverForName:UIApplicationWillResignActiveNotification object:nil queue:NSOperationQueue.mainQueue - usingBlock:^(NSNotification *notification) { (void)notification; [controls reset]; }]; - refresh_controls(); - host_logf(HOST_LOG_INFO, "Touch controls ready (%u)", id); + +void host_ios_touch_attach(SDL_Window *window) { + UIWindow *native=(__bridge UIWindow *)SDL_GetPointerProperty(SDL_GetWindowProperties(window),SDL_PROP_WINDOW_UIKIT_WINDOW_POINTER,NULL); + /* Retire SDL's temporary launch window before entering the non-returning + game loop. Its fade animation can otherwise keep accessibility/input + attached to the launch controller on recent scene-based iOS versions. */ + for(UIWindow *candidate in native.windowScene.windows) { + if(candidate!=native && [NSStringFromClass(candidate.rootViewController.class) hasPrefix:@"SDLLaunch"]) + candidate.hidden=YES; + } + [native makeKeyAndVisible]; + host_ios_require_landscape(native); + UIView *root=native.rootViewController.view; + HaloControls *controls=[[HaloControls alloc]initWithFrame:root.bounds]; + [root addSubview:controls]; + activeControls=controls; + [controls becomeFirstResponder]; + [UIMenuSystem.mainSystem setNeedsRebuild]; + if([NSProcessInfo.processInfo.environment[@"HALO_IOS_TEST_DEBUG_UI"] boolValue]) + dispatch_after(dispatch_time(DISPATCH_TIME_NOW,NSEC_PER_SEC*3),dispatch_get_main_queue(),^{[native.rootViewController motionEnded:UIEventSubtypeMotionShake withEvent:nil];}); + host_logf(HOST_LOG_INFO,"touch controls attached, %.0fx%.0f",root.bounds.size.width,root.bounds.size.height); } + +void host_ios_controls_initialize(SDL_Window *window) { host_ios_touch_initialize(); host_ios_touch_attach(window); } diff --git a/port/ios/host/host_import.m b/port/ios/host/host_import.m new file mode 100644 index 0000000000..5323b3495c --- /dev/null +++ b/port/ios/host/host_import.m @@ -0,0 +1,194 @@ +/* Native Files picker and transactional XISO import before the game starts. */ +#import +#import +#include "host.h" +#include "../../apple/xiso.h" +#import "../../apple/game_store.h" +#import "host_orientation.h" + +#if !__has_feature(objc_arc) +#error The asynchronous importer requires Objective-C ARC. +#endif + +@interface HaloImportController : HaloLandscapeController +@property(nonatomic, copy) NSString *documents; +@property(nonatomic, strong) UILabel *statusLabel; +@property(nonatomic, strong) UIButton *chooseButton; +@property(nonatomic, strong) UIButton *cancelButton; +@property(nonatomic, strong) UIProgressView *progress; +@property(nonatomic, strong) UIActivityIndicatorView *spinner; +@property(nonatomic, strong) NSURL *pendingImage; +@property(atomic) BOOL cancelled; +@property(atomic, strong) NSFileCoordinator *coordinator; +@property(nonatomic) BOOL importing; +@property(nonatomic) BOOL ready; +@property(nonatomic) UIBackgroundTaskIdentifier backgroundTask; +@property(nonatomic) double lastProgressTime; /* extraction thread only */ +@end + +static int import_progress(void *context, const char *file, uint64_t done, uint64_t total) { + HaloImportController *controller=(__bridge HaloImportController *)context; + if (controller.cancelled) return 0; + double now=NSProcessInfo.processInfo.systemUptime; + if (done!=total && done && now-controller.lastProgressTime<0.1) return 1; + controller.lastProgressTime=now; + NSString *name=[NSString stringWithUTF8String:file]; + float fraction=total?(float)((double)done/total):0; + dispatch_async(dispatch_get_main_queue(), ^{ + controller.progress.hidden=NO; + [controller.spinner stopAnimating]; + controller.progress.progress=fraction; + controller.statusLabel.text=[NSString stringWithFormat:@"Importing %@ · %.0f%%",name,fraction*100]; + }); + return 1; +} + +@implementation HaloImportController +- (void)viewDidLoad { + [super viewDidLoad]; + self.backgroundTask=UIBackgroundTaskInvalid; + self.view.backgroundColor=[UIColor colorWithRed:0.025 green:0.045 blue:0.055 alpha:1]; + self.overrideUserInterfaceStyle=UIUserInterfaceStyleDark; + UIScrollView *scroll=[UIScrollView new];scroll.translatesAutoresizingMaskIntoConstraints=NO; + [self.view addSubview:scroll]; + UIView *content=[UIView new];content.translatesAutoresizingMaskIntoConstraints=NO;[scroll addSubview:content]; + UIStackView *stack=[UIStackView new];stack.axis=UILayoutConstraintAxisVertical;stack.spacing=14; + stack.alignment=UIStackViewAlignmentCenter;stack.translatesAutoresizingMaskIntoConstraints=NO;[content addSubview:stack]; + UIImageView *icon=[[UIImageView alloc] initWithImage:[UIImage imageNamed:@"AppArtwork"]]; + icon.contentMode=UIViewContentModeScaleAspectFill;icon.clipsToBounds=YES;icon.layer.cornerRadius=16; + [icon.widthAnchor constraintEqualToConstant:72].active=YES;[icon.heightAnchor constraintEqualToConstant:72].active=YES; + [stack addArrangedSubview:icon]; + UILabel *title=[UILabel new];title.text=@"Halo: CE";title.font=[UIFont preferredFontForTextStyle:UIFontTextStyleLargeTitle]; + title.adjustsFontForContentSizeCategory=YES;title.textAlignment=NSTextAlignmentCenter;[stack addArrangedSubview:title]; + UILabel *body=[UILabel new];body.text=@"Choose your own Halo: Combat Evolved Xbox XISO.\nWe'll verify and keep a private copy, then start the game."; + body.font=[UIFont preferredFontForTextStyle:UIFontTextStyleBody];body.adjustsFontForContentSizeCategory=YES; + body.numberOfLines=0;body.textAlignment=NSTextAlignmentCenter;body.textColor=UIColor.secondaryLabelColor;[stack addArrangedSubview:body]; + self.chooseButton=[UIButton buttonWithType:UIButtonTypeSystem]; + UIButtonConfiguration *style=UIButtonConfiguration.filledButtonConfiguration; + style.title=@"Choose Halo ISO";style.baseBackgroundColor=UIColor.systemBlueColor; + style.baseForegroundColor=UIColor.whiteColor;style.cornerStyle=UIButtonConfigurationCornerStyleLarge; + style.contentInsets=NSDirectionalEdgeInsetsMake(14,24,14,24);self.chooseButton.configuration=style; + self.chooseButton.accessibilityIdentifier=@"chooseXISO"; + [self.chooseButton addTarget:self action:@selector(chooseImage) forControlEvents:UIControlEventTouchUpInside];[stack addArrangedSubview:self.chooseButton]; + self.statusLabel=[UILabel new];self.statusLabel.font=[UIFont preferredFontForTextStyle:UIFontTextStyleFootnote]; + self.statusLabel.adjustsFontForContentSizeCategory=YES;self.statusLabel.numberOfLines=0;self.statusLabel.textAlignment=NSTextAlignmentCenter; + self.statusLabel.text=@"Original Xbox PAL or NTSC-US · .iso or .xiso\nNo game files are included."; + self.statusLabel.accessibilityIdentifier=@"importStatus";[stack addArrangedSubview:self.statusLabel]; + self.spinner=[[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; + self.spinner.hidesWhenStopped=YES;[stack addArrangedSubview:self.spinner]; + self.progress=[[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault];self.progress.hidden=YES; + self.progress.accessibilityLabel=@"Game import progress";[stack addArrangedSubview:self.progress]; + [self.progress.widthAnchor constraintEqualToAnchor:stack.widthAnchor].active=YES; + self.cancelButton=[UIButton buttonWithType:UIButtonTypeSystem];[self.cancelButton setTitle:@"Cancel import" forState:UIControlStateNormal]; + self.cancelButton.accessibilityIdentifier=@"cancelImport"; + [self.cancelButton addTarget:self action:@selector(cancelImport) forControlEvents:UIControlEventTouchUpInside]; + self.cancelButton.hidden=YES;[stack addArrangedSubview:self.cancelButton]; + NSLayoutConstraint *width=[stack.widthAnchor constraintEqualToAnchor:content.widthAnchor constant:-48];width.priority=999; + [NSLayoutConstraint activateConstraints:@[ + [scroll.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor], + [scroll.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor], + [scroll.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],[scroll.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [content.topAnchor constraintEqualToAnchor:scroll.contentLayoutGuide.topAnchor],[content.bottomAnchor constraintEqualToAnchor:scroll.contentLayoutGuide.bottomAnchor], + [content.leadingAnchor constraintEqualToAnchor:scroll.contentLayoutGuide.leadingAnchor],[content.trailingAnchor constraintEqualToAnchor:scroll.contentLayoutGuide.trailingAnchor], + [content.widthAnchor constraintEqualToAnchor:scroll.frameLayoutGuide.widthAnchor],[content.heightAnchor constraintGreaterThanOrEqualToAnchor:scroll.frameLayoutGuide.heightAnchor], + [stack.centerXAnchor constraintEqualToAnchor:content.centerXAnchor],[stack.centerYAnchor constraintEqualToAnchor:content.centerYAnchor], + [stack.topAnchor constraintGreaterThanOrEqualToAnchor:content.topAnchor constant:24], + [stack.bottomAnchor constraintLessThanOrEqualToAnchor:content.bottomAnchor constant:-24], + [stack.widthAnchor constraintLessThanOrEqualToConstant:520],width]]; +} +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + if (self.pendingImage && !self.importing) {NSURL *url=self.pendingImage;self.pendingImage=nil;[self importImage:url];} +} +- (void)chooseImage { + if (self.importing) return; + /* Some Files providers classify .xiso as generic data. Validate the file's + contents ourselves, allowing both that type and normal ISO disk images. */ + UIDocumentPickerViewController *picker=[[HaloLandscapeDocumentPicker alloc] initForOpeningContentTypes:@[UTTypeData] asCopy:NO]; + picker.delegate=self;picker.allowsMultipleSelection=NO; + [self presentViewController:picker animated:YES completion:nil]; +} +- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls { + (void)controller;if (urls.count) [self importImage:urls.firstObject]; +} +- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller { + (void)controller;self.statusLabel.text=@"Choose your Halo XISO whenever you're ready."; +} +- (void)cancelImport {self.cancelled=YES;[self.coordinator cancel];self.cancelButton.enabled=NO;self.statusLabel.text=@"Cancelling import…";} +- (void)importImage:(NSURL *)url { + if (self.importing) return; + self.importing=YES;self.cancelled=NO;self.lastProgressTime=0; + self.chooseButton.hidden=YES;self.cancelButton.hidden=NO;self.cancelButton.enabled=YES; + self.progress.hidden=YES;self.progress.progress=0;[self.spinner startAnimating]; + self.statusLabel.text=@"Opening disc image… If it's in iCloud, it may need to download first."; + BOOL scoped=[url startAccessingSecurityScopedResource]; + self.backgroundTask=[UIApplication.sharedApplication beginBackgroundTaskWithName:@"Import Halo" expirationHandler:^{self.cancelled=YES;[self.coordinator cancel];}]; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED,0), ^{ + @autoreleasepool { + __block NSString *failure=nil;__block BOOL imported=NO; + NSFileCoordinator *coordinator=[[NSFileCoordinator alloc] initWithFilePresenter:nil];NSError *coordinationError=nil; + self.coordinator=coordinator; + if(self.cancelled)[coordinator cancel]; + [coordinator coordinateReadingItemAtURL:url options:0 error:&coordinationError byAccessor:^(NSURL *readable) { + if(self.cancelled){failure=@"Import cancelled.";return;} + imported=halo_game_store_import(readable,self.documents,import_progress,(__bridge void *)self,&failure); + }]; + if(coordinationError && !imported)failure=self.cancelled?@"Import cancelled.":@"Could not access the image. Download it in Files, then choose it again."; + self.coordinator=nil; + if (scoped) [url stopAccessingSecurityScopedResource]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self.spinner stopAnimating];self.cancelButton.hidden=YES;self.importing=NO; + if (self.backgroundTask!=UIBackgroundTaskInvalid) { [UIApplication.sharedApplication endBackgroundTask:self.backgroundTask];self.backgroundTask=UIBackgroundTaskInvalid; } + if (imported) {self.statusLabel.text=@"Ready. Starting Halo…";self.ready=YES;} + else {self.statusLabel.text=failure?:@"Could not import this disc image.";self.chooseButton.hidden=NO;self.progress.hidden=YES;} + }); + } + }); +} +@end + +void host_ios_prepare_assets(const char *root) { + NSString *documents=[NSString stringWithUTF8String:root]; + NSFileManager *files=NSFileManager.defaultManager; + NSString *maps=[documents stringByAppendingPathComponent:@"maps"]; + /* Recover only directories carrying our own marker. Interrupted copies + can be discarded; a displaced maps folder is restored before retrying. */ + for (NSString *name in [files contentsOfDirectoryAtPath:documents error:nil]) { + if (![name hasPrefix:@".halo-import-"] || ![[NSUUID alloc] initWithUUIDString:[name substringFromIndex:13]]) continue; + NSString *staging=[documents stringByAppendingPathComponent:name]; + if (![[NSString stringWithContentsOfFile:[staging stringByAppendingPathComponent:@"owner.txt"] encoding:NSUTF8StringEncoding error:nil] isEqualToString:@"Halo XISO import v1"]) continue; + NSString *backup=[staging stringByAppendingPathComponent:@"previous-maps"]; + if ([files fileExistsAtPath:backup]) { + if (![files fileExistsAtPath:maps]) [files moveItemAtPath:backup toPath:maps error:nil]; + if ([files fileExistsAtPath:backup] && !xiso_maps_ready(maps.fileSystemRepresentation,NULL,0)) continue; + } + [files removeItemAtPath:staging error:nil]; + } + if (halo_game_store_current(documents)) return; + UIWindowScene *scene=nil; + for (UIScene *candidate in UIApplication.sharedApplication.connectedScenes) + if ([candidate isKindOfClass:UIWindowScene.class]) {scene=(UIWindowScene *)candidate;break;} + if (!scene) host_fatal("Could not open the game import screen."); + HaloImportController *controller=[HaloImportController new];controller.documents=documents; + /* Dropping an XISO into the app with Finder/Files also works. Only choose + automatically when exactly one image is present; never alter the source. */ + NSMutableArray *images=[NSMutableArray new]; + for (NSString *directory in @[documents,[documents stringByAppendingPathComponent:@"Inbox"]]) { + for (NSURL *url in [files contentsOfDirectoryAtURL:[NSURL fileURLWithPath:directory] includingPropertiesForKeys:@[NSURLIsRegularFileKey] options:NSDirectoryEnumerationSkipsHiddenFiles error:nil]) { + NSString *extension=url.pathExtension.lowercaseString;NSNumber *regular=nil;[url getResourceValue:®ular forKey:NSURLIsRegularFileKey error:nil]; + if (regular.boolValue && ([extension isEqualToString:@"iso"] || [extension isEqualToString:@"xiso"])) [images addObject:url]; + } + } + if (images.count==1) controller.pendingImage=images.firstObject; + UIWindow *window=[[UIWindow alloc] initWithWindowScene:scene];window.frame=scene.coordinateSpace.bounds; + window.windowLevel=UIWindowLevelNormal+2;window.rootViewController=controller;[window makeKeyAndVisible]; + while (!controller.ready) { + @autoreleasepool { + /* Like SDL's UIKit event pump, service tracking as well as the + default mode so controls inside the scroll view receive taps. */ + CFRunLoopRunInMode(kCFRunLoopDefaultMode,0.01,true); + CFRunLoopRunInMode((__bridge CFStringRef)UITrackingRunLoopMode,0.01,true); + } + } + window.hidden=YES;window.rootViewController=nil; +} diff --git a/port/ios/host/host_main.m b/port/ios/host/host_main.m index bcdbc526c0..113f4c53b8 100644 --- a/port/ios/host/host_main.m +++ b/port/ios/host/host_main.m @@ -1,8 +1,14 @@ /* SDL owns UIApplication and invokes this entry on the UIKit main thread. */ #include "host.h" +#include "image.h" +extern void host_ios_show_fatal(const char *) __attribute__((noreturn)); +#include "../../apple/host_graphics.h" +#include "../../apple/host_debug.h" #include #include #import +#import "../../apple/game_store.h" +extern void host_ios_prepare_assets(const char *root); #include #include #include @@ -19,8 +25,7 @@ void host_fatal(const char *format, ...) { char message[2048]; va_list a; va_start(a, format); vsnprintf(message, sizeof(message), format, a); va_end(a); host_logf(HOST_LOG_ERROR, "%s", message); - SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Halo OG could not start", message, NULL); - exit(1); + host_ios_show_fatal(message); } void host_abort(const char *reason) { host_fatal("Game stopped: %s", reason); } void host_exit(int code) { host_logf(HOST_LOG_INFO, "Game exited (%d)", code); exit(code); } @@ -28,6 +33,34 @@ void host_fatal(const char *format, ...) { void host_android_path(int which, char *buffer, uint32_t size) { snprintf(buffer, size, "%s", which ? save_root : data_root); } +/* UIKit touch controls use an SDL virtual gamepad; disable the Android overlay. */ +void host_gesture_insets(int *insets) { + if (!insets) + return; + insets[0] = insets[1] = insets[2] = insets[3] = 0; +} +void host_touch_read(int *state) { + int i; + + if (!state) + return; + for (i = 0; i < 7; i++) + state[i] = 0; +} +void host_touch_look_read(float *delta) { + int i; + + if (!delta) + return; + for (i = 0; i < 4; i++) + delta[i] = 0.0f; +} +void host_touch_rumble(unsigned int low, unsigned int high) { + (void)low; + (void)high; +} +void host_touch_scene(int scene) { (void)scene; } +void host_touch_bindings(const int *controls) { (void)controls; } extern char **environ; static uint32_t make_boot(void) { size_t size = 0x20000; @@ -51,19 +84,57 @@ static uint32_t make_boot(void) { extern void macos_enter_guest_stack(void *, uint32_t) __attribute__((noreturn)); int main(int argc, char **argv) { (void)argc; (void)argv; + halo_debug_configure_launch(); + halo_graphics_initialize(); + SDL_SetHint(SDL_HINT_ORIENTATIONS, "LandscapeLeft LandscapeRight"); + SDL_SetHint(SDL_HINT_IOS_HIDE_HOME_INDICATOR, "2"); + if (!SDL_InitSubSystem(SDL_INIT_VIDEO | SDL_INIT_GAMEPAD)) + host_fatal("Cannot initialize display: %s", SDL_GetError()); + if (!halo_graphics_metal() && !SDL_GL_LoadLibrary(NULL)) + host_fatal("Cannot load native OpenGL ES: %s", SDL_GetError()); @autoreleasepool { NSFileManager *files = NSFileManager.defaultManager; NSString *documents = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject; - NSString *data = [documents stringByAppendingPathComponent:@"GameData"]; - [files createDirectoryAtPath:data withIntermediateDirectories:YES attributes:nil error:NULL]; - if (![files fileExistsAtPath:[data stringByAppendingPathComponent:@"maps/ui.map"]]) - data = [NSBundle.mainBundle.resourcePath stringByAppendingPathComponent:@"GameData"]; - NSString *saves = [NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES).firstObject stringByAppendingPathComponent:@"Halo"]; + NSString *saves = [documents stringByAppendingPathComponent:@"save"]; + NSString *legacySaves = [NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES).firstObject stringByAppendingPathComponent:@"Halo"]; + if (![files fileExistsAtPath:saves] && [files fileExistsAtPath:legacySaves]) saves = legacySaves; + setenv("HALO_CONFIG_ROOT", documents.fileSystemRepresentation, 1); [files createDirectoryAtPath:saves withIntermediateDirectories:YES attributes:nil error:NULL]; - snprintf(data_root, sizeof(data_root), "%s", data.fileSystemRepresentation); snprintf(save_root, sizeof(save_root), "%s", saves.fileSystemRepresentation); freopen([[saves stringByAppendingPathComponent:@"halo.log"] fileSystemRepresentation], "w", stderr); setbuf(stderr, NULL); + // Fail before asking the user to spend time importing a large image. + if ([NSProcessInfo.processInfo.environment[@"HALO_IOS_TEST_STARTUP_ERROR"] boolValue] || + host_memory_initialize(HALO_IOS_IMAGE_BASE, HALO_IOS_IMAGE_SIZE)) + host_fatal("iOS could not reserve the game's required memory range (0x%llx, %llu MiB).\n\n" + "The running process needs Extended Virtual Addressing. A missing signing capability " + "or a container's address-space restrictions can cause this failure.\n\n" + "Try installing this app directly using the re-signing guide and a profile that grants " + "Extended Virtual Addressing. LiveContainer does not inherit a guest app's entitlements; " + "Fix File Picker does not change memory permissions. LiveContainer compatibility is not verified.\n\n" + "Your imported game and saves have not been removed. Re-importing the ISO will not fix " + "this memory error. Details are in save/halo.log.", + (unsigned long long)HALO_MACOS_BIAS,(unsigned long long)(HALO_ARENA_SIZE/(1024*1024))); + NSString *data = halo_game_store_current(documents); + NSString *legacy = [documents stringByAppendingPathComponent:@"GameData"]; + // Existing innerhat extracted maps and our retained ISO generations + // both remain readable; new installations import through Files. + if (!data && xiso_maps_ready([legacy stringByAppendingPathComponent:@"maps"].fileSystemRepresentation, NULL, 0)) + data = legacy; + if (!data) { + host_ios_prepare_assets(documents.fileSystemRepresentation); + data = halo_game_store_current(documents); + } + snprintf(data_root, sizeof(data_root), "%s", data.fileSystemRepresentation); + // Refresh the shipped default; custom broker files use a separate + // network.brokers_file path and remain untouched. + NSURL *brokers = [NSBundle.mainBundle URLForResource:@"brokers" withExtension:@"txt"]; + NSError *brokerError = nil; + NSData *brokerData = brokers ? [NSData dataWithContentsOfURL:brokers options:0 error:&brokerError] : nil; + if (!brokerData || ![brokerData writeToFile:[saves stringByAppendingPathComponent:@"brokers.txt"] + options:NSDataWritingAtomic error:&brokerError]) + host_logf(HOST_LOG_ERROR, "Cannot install server discovery brokers: %s", + brokerError.localizedDescription.UTF8String ?: "bundled brokers.txt is missing"); // A sandbox-relative shortcut for opt-in device frame profiling. const char *profile = getenv("HALO_PERF_LOG"); if (profile && !strcmp(profile, "1")) @@ -73,10 +144,6 @@ int main(int argc, char **argv) { } host_install_signal_handlers(); setenv("HALO_DATA_ROOT", data_root, 1); setenv("HALO_SAVE_ROOT", save_root, 1); - SDL_SetHint(SDL_HINT_ORIENTATIONS, "LandscapeLeft LandscapeRight"); - SDL_SetHint(SDL_HINT_IOS_HIDE_HOME_INDICATOR, "2"); - if (!SDL_InitSubSystem(SDL_INIT_VIDEO | SDL_INIT_GAMEPAD)) - host_fatal("Cannot initialize display: %s", SDL_GetError()); const SDL_DisplayMode *display = SDL_GetDesktopDisplayMode(SDL_GetPrimaryDisplay()); if (display && display->h > 0 && display->w > 0) { int long_side = display->w > display->h ? display->w : display->h; @@ -84,7 +151,7 @@ int main(int argc, char **argv) { char width[32]; snprintf(width, sizeof(width), "%d", (480 * long_side / short_side) & ~1); setenv("HALO_DISPLAY_WIDTH", width, 0); } - if (host_load_image(NULL, 0)) host_fatal("Cannot initialize game memory. Check the app's Extended Virtual Addressing signing capability."); + if (host_load_image(NULL, 0)) host_fatal("Cannot initialize game memory. See save/halo.log for the failed memory reservation or image load."); uint32_t boot = make_boot(); size_t stack_size = 16 * 1024 * 1024; char *stack = host_low_map(stack_size + HALO_MACOS_PAGE, PROT_READ | PROT_WRITE); diff --git a/port/ios/host/host_orientation.h b/port/ios/host/host_orientation.h new file mode 100644 index 0000000000..b4e5e59592 --- /dev/null +++ b/port/ios/host/host_orientation.h @@ -0,0 +1,11 @@ +#pragma once +#import + +/* Keep launch, import, Files, and SDL on the same landscape policy. */ +@interface HaloLandscapeController : UIViewController +@end + +@interface HaloLandscapeDocumentPicker : UIDocumentPickerViewController +@end + +void host_ios_require_landscape(UIWindow *window); diff --git a/port/ios/host/host_orientation.m b/port/ios/host/host_orientation.m new file mode 100644 index 0000000000..5d38386a2f --- /dev/null +++ b/port/ios/host/host_orientation.m @@ -0,0 +1,75 @@ +#import "host_orientation.h" +#include "host.h" +#include "../../apple/host_debug.h" + +/* SDL 3.4.16 owns these controllers. These categories add only orientation + preferences inherited from UIKit, never replace SDL's own methods. Recheck + the selectors when upgrading SDL; no runtime class mutation is needed. */ +@interface SDL_uikitviewcontroller : UIViewController +@end +@interface SDLLaunchScreenController : UIViewController +@end +@interface SDLUIKitSceneDelegate : NSObject +@end + +/* Restrict the allowed orientations without locking the scene to whichever + landscape side happened to win at launch. A scene lock also prevents the + other supported landscape orientation from following the user's rotation. */ +#define HALO_LANDSCAPE_PREFERENCES \ +- (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation { return UIInterfaceOrientationLandscapeRight; } \ +- (BOOL)prefersInterfaceOrientationLocked { return NO; } + +@implementation HaloLandscapeController +- (UIInterfaceOrientationMask)supportedInterfaceOrientations { return UIInterfaceOrientationMaskLandscape; } +HALO_LANDSCAPE_PREFERENCES +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + host_ios_require_landscape(self.view.window); +} +@end + +@implementation HaloLandscapeDocumentPicker +- (UIInterfaceOrientationMask)supportedInterfaceOrientations { return UIInterfaceOrientationMaskLandscape; } +HALO_LANDSCAPE_PREFERENCES +@end + +@implementation SDL_uikitviewcontroller (HaloLandscape) +- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event { + if (motion == UIEventSubtypeMotionShake) halo_debug_present(); + else [super motionEnded:motion withEvent:event]; +} +HALO_LANDSCAPE_PREFERENCES +@end + +@implementation SDLLaunchScreenController (HaloLandscape) +HALO_LANDSCAPE_PREFERENCES +@end + +@implementation SDLUIKitSceneDelegate (HaloLandscape) +/* iPadOS 27 consults the scene delegate in preference to Info.plist. */ +- (UIInterfaceOrientationMask)supportedInterfaceOrientationsForWindowScene:(UIWindowScene *)scene { + (void)scene; + return UIInterfaceOrientationMaskLandscape; +} +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 260000 +- (void)windowScene:(UIWindowScene *)scene didUpdateEffectiveGeometry:(UIWindowSceneGeometry *)previousGeometry API_AVAILABLE(ios(26.0)) { + CGSize size=scene.effectiveGeometry.coordinateSpace.bounds.size; + host_logf(HOST_LOG_INFO,"scene geometry %.0fx%.0f orientation=%ld locked=%d", + size.width,size.height,(long)scene.effectiveGeometry.interfaceOrientation, + scene.effectiveGeometry.isInterfaceOrientationLocked); +} +#endif +@end + +void host_ios_require_landscape(UIWindow *window) { + if (!window) return; + [window.rootViewController setNeedsUpdateOfSupportedInterfaceOrientations]; +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 260000 + if (@available(iOS 26.0,*)) [window.rootViewController setNeedsUpdateOfPrefersInterfaceOrientationLocked]; +#endif + UIWindowSceneGeometryPreferencesIOS *geometry=[[UIWindowSceneGeometryPreferencesIOS alloc] + initWithInterfaceOrientations:UIInterfaceOrientationMaskLandscape]; + [window.windowScene requestGeometryUpdateWithPreferences:geometry errorHandler:^(NSError *error) { + host_logf(HOST_LOG_WARN,"landscape request: %s",error.localizedDescription.UTF8String); + }]; +} diff --git a/port/ios/host/host_startup_error.m b/port/ios/host/host_startup_error.m new file mode 100644 index 0000000000..f3c1b9339e --- /dev/null +++ b/port/ios/host/host_startup_error.m @@ -0,0 +1,78 @@ +/* A startup failure must remain usable without an SDL game window. */ +#import "host_orientation.h" +#include "host.h" +#include + +@interface HaloStartupErrorController : HaloLandscapeController +@property(nonatomic, copy) NSString *message; +@end +@implementation HaloStartupErrorController +- (void)loadView { + self.view=[UIView new];self.view.backgroundColor=UIColor.systemBackgroundColor; + UILabel *title=[UILabel new];title.text=@"Halo could not start"; + title.font=[UIFont preferredFontForTextStyle:UIFontTextStyleHeadline]; + title.numberOfLines=0; + UITextView *details=[UITextView new];details.text=self.message;details.editable=NO; + details.font=[UIFont preferredFontForTextStyle:UIFontTextStyleBody]; + details.accessibilityIdentifier=@"startup-error-details"; + UIButton *copy=[UIButton buttonWithType:UIButtonTypeSystem]; + [copy setTitle:@"Copy details" forState:UIControlStateNormal]; + [copy addTarget:self action:@selector(copyDetails:) forControlEvents:UIControlEventTouchUpInside]; + UIButton *close=[UIButton buttonWithType:UIButtonTypeSystem]; + [close setTitle:@"Close" forState:UIControlStateNormal]; + [close addTarget:self action:@selector(close:) forControlEvents:UIControlEventTouchUpInside]; + UIStackView *buttons=[[UIStackView alloc]initWithArrangedSubviews:@[copy,close]]; + buttons.distribution=UIStackViewDistributionFillEqually;buttons.spacing=16; + UIStackView *stack=[[UIStackView alloc]initWithArrangedSubviews:@[title,details,buttons]]; + stack.axis=UILayoutConstraintAxisVertical;stack.spacing=12;stack.translatesAutoresizingMaskIntoConstraints=NO; + [self.view addSubview:stack]; + UILayoutGuide *safe=self.view.safeAreaLayoutGuide; + [NSLayoutConstraint activateConstraints:@[ + [stack.topAnchor constraintEqualToAnchor:safe.topAnchor constant:16], + [stack.bottomAnchor constraintEqualToAnchor:safe.bottomAnchor constant:-16], + [stack.leadingAnchor constraintEqualToAnchor:safe.leadingAnchor constant:20], + [stack.trailingAnchor constraintEqualToAnchor:safe.trailingAnchor constant:-20], + [buttons.heightAnchor constraintEqualToConstant:48]]]; +} +- (void)copyDetails:(UIButton *)sender { + UIPasteboard.generalPasteboard.string=[NSString stringWithFormat:@"%@\n%@",self.message, + NSProcessInfo.processInfo.operatingSystemVersionString]; + [sender setTitle:@"Copied" forState:UIControlStateNormal]; +} +- (void)close:(id)sender {(void)sender;exit(1);} +@end + +static UIWindow *errorWindow; +static void show_error(NSString *message) { + if(errorWindow)return; + UIWindowScene *chosen=nil; + for(UIScene *scene in UIApplication.sharedApplication.connectedScenes) { + if(![scene isKindOfClass:UIWindowScene.class])continue; + if(!chosen || scene.activationState==UISceneActivationStateForegroundActive)chosen=(UIWindowScene *)scene; + if(scene.activationState==UISceneActivationStateForegroundActive)break; + } + if(!chosen) {host_logf(HOST_LOG_ERROR,"No UIKit scene available for startup error");exit(1);} + HaloStartupErrorController *controller=[HaloStartupErrorController new];controller.message=message; + errorWindow=[[UIWindow alloc]initWithWindowScene:chosen]; + errorWindow.frame=chosen.coordinateSpace.bounds; + errorWindow.windowLevel=UIWindowLevelAlert+1; + errorWindow.rootViewController=controller;[errorWindow makeKeyAndVisible]; + /* Hide SDL's fading launch window, which otherwise competes for focus. */ + for(UIWindow *window in chosen.windows) + if(window!=errorWindow)window.hidden=YES; +} + +void host_ios_show_fatal(const char *text) __attribute__((noreturn)); +void host_ios_show_fatal(const char *text) { + NSString *message=[NSString stringWithUTF8String:text] ?: @"Unknown startup error."; + if(!NSThread.isMainThread) { + dispatch_async(dispatch_get_main_queue(),^{show_error(message);}); + dispatch_semaphore_wait(dispatch_semaphore_create(0),DISPATCH_TIME_FOREVER); + abort(); + } + show_error(message); + for(;;) {@autoreleasepool { + CFRunLoopRunInMode(kCFRunLoopDefaultMode,0.01,true); + CFRunLoopRunInMode((__bridge CFStringRef)UITrackingRunLoopMode,0.01,true); + }} +} diff --git a/port/ios/host_imports.list b/port/ios/host_imports.list new file mode 100644 index 0000000000..ac9d9ef95c --- /dev/null +++ b/port/ios/host_imports.list @@ -0,0 +1,5 @@ +# Shared custom-map cache hooks on iOS. +halo_map_download_directory +halo_map_download_request + +host_ios_metalfx_present diff --git a/port/ios/tests/signed_image.c b/port/ios/tests/signed_image.c index 27d5b4d606..78cb6e82a4 100644 --- a/port/ios/tests/signed_image.c +++ b/port/ios/tests/signed_image.c @@ -10,7 +10,7 @@ #ifdef HALO_IOS_MAC_CHECK #define BIAS UINT64_C(0x10000000000) #else -#define BIAS UINT64_C(0x400000000) +#define BIAS UINT64_C(0x7000000000) #endif static void *run(void *unused) { (void)unused; @@ -21,6 +21,11 @@ static void *run(void *unused) { return (void *)(uintptr_t)(result != 93); } int main(void) { +#ifndef HALO_IOS_MAC_CHECK + _Static_assert(BIAS >= UINT64_C(0x7000000000), "Device arena overlaps GPU carveout"); + _Static_assert(BIAS + UINT64_C(0x100000000) <= UINT64_C(0x8000000000), + "Device arena exceeds iOS extended VA limit"); +#endif mach_vm_address_t address = BIAS; kern_return_t reserved = mach_vm_allocate(mach_task_self(), &address, UINT64_C(0x100000000), VM_FLAGS_FIXED); if (reserved) { diff --git a/port/ios/tests/xiso_probe.c b/port/ios/tests/xiso_probe.c new file mode 100644 index 0000000000..4d3fa9ef43 --- /dev/null +++ b/port/ios/tests/xiso_probe.c @@ -0,0 +1,23 @@ +#include "xiso.h" +#include +#include +#include +static uint64_t cancel_after, previous; +static int progress(void *context, const char *file, uint64_t done, uint64_t total) { + (void)context;(void)file; + if (donetotal) abort(); + previous=done; + return !cancel_after || done4) cancel_after=strtoull(argv[4],NULL,10); + ok=xiso_extract_maps(argv[2],argv[3],progress,NULL,error,sizeof(error)); + } + printf("%s: %s\n",ok?"OK":"ERROR",ok?"maps validated":error); + return ok?0:1; +} diff --git a/port/linux/NETCODE.md b/port/linux/NETCODE.md index d08c6efcbb..67b1811f70 100644 --- a/port/linux/NETCODE.md +++ b/port/linux/NETCODE.md @@ -187,7 +187,13 @@ times (a client waits for a player's blow before its body dies without one), and switches co-op's BSP on the host's crossing alone; version 26 has each machine say its WebRTC in internet play's signalling (a native build's certificate, a browser's ICE credentials too), so that browsers play -(port/linux/README.md, "Browsers"). +(port/linux/README.md, "Browsers"); version 27 shares Custom Edition maps +(below, "Map sharing"): a client asks the host for a map it lacks, and the +game's players reach each other to send it; version 28 holds the players of a +machine still downloading the map out of the game until it has the map, keeps +a multiplayer map's first scripted BSP, leaves dynamic lights and the +flashlight on past the Xbox game's player and vehicle limits, and readies a +second weapon picked up while firing. This Mac build plays that options record (time limit, loadout, friendly fire, vehicle respawn, team balance, radar, vehicle sets, and no-map-weapons) and @@ -326,6 +332,70 @@ machine in the pregame refuses, and which the others no longer need), only a pregame keep-alive every five seconds from the host (`network_server_manager.c`, `network_server_message_handler.c`). +## Map sharing + +A client may join a game on a Custom Edition map it does not have +(`port/linux/game/map_share.c`, `map_share_files.c`). Every map in the +custom_maps folder is named for its contents: once the map lists (or a host +picking it) find it, `hugeass.map` is renamed `hugeass_<8 hex digits>.map`, +the first digits of its hash, with its picture and description, so a game's +level name (`custom_maps\hugeass_0e2b3ee4`) says which version of the map it +is, and two versions never meet. A file's hash is BLAKE2b's (32 bytes) of a +label, its size and the BLAKE2b hashes of its pieces (256 KiB each). The +menus show a map without its hash, unless two versions are listed. + +- **Asking.** A client whose settings name a map it lacks + (`cache_files_map_present`), or one whose resource maps it lacks, asks the + host over its connection (`_distributed_message_map_share_request`: the + level, and which of the map, bitmaps.map, sounds.map and loc.map it + lacks) instead of leaving, if `network.map_download` lets it; the map must + be named for its hash. Its players wait to join until the host's settings + have named the map, and for a game in progress until it is downloaded; in + the lobby they join at once, and the host's start waits for the machine to + precache the map, as it waits for any machine. +- **The offer.** A host that shares maps (`network.map_share`) hashes the + map, and the resource maps the map needs (its tag index says which: a tag + whose entry's word at 0x18 is set is held whole by bitmaps.map for a + bitmap, sounds.map for a sound, loc.map for a font, string list or HUD + message text) that the client lacks, on a thread of their own, and + answers with each file's size and hash (`_offer`, or why it does not: + sharing off, or not the game's map), and which of its other machines may + have them (`_sources`: an address on a LAN, internet play's key hash on the + internet), again every ten seconds. A map whose hash is not its name's is + not offered. The client checks that the map's hash is its name's, and + that no file is larger than the cache loader takes. +- **The swarm.** The client connects to the host and up to eight of the + others on port 5153 (of two downloading, the one of the lower machine + connects). Each end says which files it has, whole or by piece, and tells + the other of each piece it gets; the client asks for the rarest pieces + first (from a random place, so that clients ask for different pieces), two + at a time from each connection, sixteen in all, and the last few again of + whoever is quicker. The pieces' hashes come from any end that has them, + checked against the file's hash; each piece is checked against its own as + it arrives, and an end that sends a wrong one is not asked again. A machine + sends of the game's files (its map, the resource maps the map needs, a + download's pieces) no faster than `network.map_share_upload` (the host, + 1024 KB/s) or `network.map_seed_upload` (a player, 256 KB/s), round all + its connections a 16 KiB block at a time, so that the game's own traffic + keeps the rest of the upload; `network.map_seed = false` sends none. A + player that has downloaded the map sends it on at once. +- **Internet play.** The players of an internet game reach its host alone. + A player that shares answers the holders of the invite it joined with as + its host does (`p2p_set_answering_players`), and a downloading client asks + another player, by the key hash the host told, for a session as it asked + the host (`p2p_reach_player`: up to eight at once, alongside the request to + the host, in one topic of the invite's token). Its map sharing connection + then goes through their tunnel, as the game's connections go. +- **Writing.** A download is written to `.part` beside its file in + custom_maps, and renamed when every piece is had. A part file found again + is checked piece by piece on the hashing thread and taken up where it was. + The client checks the map again once it has it (`custom_edition_cache_present`), + and tells the host its progress every two seconds (`_progress`); the host + does not drop a machine downloading (and for 20 seconds after) for adding + no players or loading slowly. A download that brings nothing for a minute, + or a host that does not answer in 30 seconds, leaves the game, saying why. +- The download's progress is drawn at the top of the screen. + ## Stages 1. (Done) Decoupled ticks: clients tick on their own clock with local input diff --git a/port/linux/README.md b/port/linux/README.md index 1f88d6aae0..c468fbe80e 100644 --- a/port/linux/README.md +++ b/port/linux/README.md @@ -429,6 +429,11 @@ display. Firing, animation, sound and world lights continue. | `network.votekick` | `true` | `HALO_NET_VOTEKICK` | When you host: `true`, the players can vote to kick a player. Refer to "Security". `false`: no votes. VOTE KICK in Server Setup > Teamplay Options (in co-op, Voice and Voting) writes its choice here. | | `network.votekick_minutes` | `5` | `HALO_NET_VOTEKICK_MINUTES` | When you host: the minutes that a player must play on the server before the player can start a vote to kick (`0` to `60`). To vote, a player must play for 2 minutes, or for this time if it is less. | | `network.votekick_ban_minutes` | `30` | `HALO_NET_VOTEKICK_BAN_MINUTES` | When you host: the minutes that a player who is kicked by a vote cannot join again (`1` to `1440`). | +| `network.map_share` | `true` | `HALO_NET_MAP_SHARE` | When you host: send a player who joins without the game's Custom Edition map the map, and the resource maps that it needs and that the player does not have. SHARE MAPS in Server Setup > Map Sharing writes its choice here. Refer to "Map sharing". | +| `network.map_share_upload` | `1024` | `HALO_NET_MAP_SHARE_UPLOAD` | When you host: the most kilobytes per second that the game sends of maps (`16` to `65536`). UPLOAD LIMIT in Server Setup > Map Sharing writes its choice here. | +| `network.map_seed` | `true` | `HALO_NET_MAP_SEED` | When you play in the game of another machine: help send its map to players who join without it. HELP SEND MAPS in Settings > Network Setup > Map Sharing. | +| `network.map_seed_upload` | `256` | `HALO_NET_MAP_SEED_UPLOAD` | When you play in the game of another machine: the most kilobytes per second that the game sends of maps to other players (`16` to `65536`). UPLOAD LIMIT in Settings > Network Setup > Map Sharing. | +| `network.map_download` | `true` | `HALO_NET_MAP_DOWNLOAD` | When you join a game on a Custom Edition map that you do not have: download it from the players of the game. `false`: leave the game and show which map is missing. DOWNLOAD MAPS in Settings > Network Setup > Map Sharing. | | `network.coop_public` | `false` | `HALO_NET_COOP_PUBLIC` | `true`: an online co-op game (Create Game > Internet, a SINGLEPLAYER map) starts as PUBLIC. `false`: it starts as PRIVATE. LISTING in co-op's Server Setup writes its choice here. Refer to "Server browser". | | `network.brokers_file` | `"brokers.txt"` | `HALO_NET_BROKERS_FILE` | The file of the public MQTT brokers that let the machines of an invite find each other, and that carry the listings of the server browser: next to `config.toml`, unless a full path. One `host:port` on each line, up to 4; `#` starts a comment. | | `network.stun_servers` | Google and Cloudflare | `HALO_NET_STUN` | The public STUN servers (`host:port`, with commas between them) that give the internet address of a machine. | @@ -798,6 +803,48 @@ MQTT 5 if the broker has it, else MQTT 3.1.1. A broker that does not keep retained messages, or does not let clients subscribe with wildcards, carries only invites, not listings. +### Map sharing + +When you join a game on a Custom Edition map that you do not have, the game +downloads the map from the players of the game, if the host shares its maps +(SHARE MAPS in Server Setup > Map Sharing, `network.map_share`, on by +default). This operates on a local network and +on the internet. It also downloads the resource maps that the map needs +(`bitmaps.map`, `sounds.map`, `loc.map`) if you do not have them. A line at +the top of the screen shows the progress. In the lobby, the game starts when +every player has the map. If the game already operates, you join it when +the download is complete. If no player sends any of the map for a minute, +you leave the game, and the main menu tells you why. + +All the players help (HELP SEND MAPS in Settings > Network Setup > Map +Sharing, `network.map_seed`), as the peers of a torrent do: +the downloaded map comes in pieces from the host and from each player who +has it or has a part of it. The game checks each piece against its hash, so +a player cannot send a different map. A player who sends a bad piece is not +asked again. To keep the upload free for the game, the host sends at most +`network.map_share_upload` (1024 KB/s: UPLOAD LIMIT in Server Setup > Map +Sharing) and each other player at most `network.map_seed_upload` (256 KB/s: +UPLOAD LIMIT in Settings > Network Setup > Map Sharing). Set these lower on +a slow connection. DOWNLOAD MAPS there (`network.map_download`) turns +downloading off: you then leave a game whose map you do not have. + +The name of each map in `custom_maps` ends with the start of its hash: when +the map lists find `hugeass.map`, the game renames it `hugeass_0e2b3ee4.map` +(and its picture and description). Thus two versions of a map have different +names, and a game always loads the version of the host. The menus show the +map without the hash, and with it only when you have two versions of the +map. The downloaded map has the same name. A download that stops leaves a +`.part` file in `custom_maps`; the next download of that map continues from +it. Maps in the folder of a Halo Custom Edition install +(`paths.custom_edition`) keep their names, and the host does not share them. +Copy a map to `custom_maps` to share it. + +Over the internet, the players of a game connect only to the host. To send +each other a map, a player answers the other players of the game (who have +its invite) as the host does, and a player who downloads connects to them in +the same way that it connected to the host. Map sharing uses port 5153 for +its connections. Refer to `NETCODE.md`, "Map sharing". + ### Security Only machines with the invite can find the game: @@ -1139,6 +1186,7 @@ Other changes: | `rasterizer/xbox/rasterizer_xbox_decals.c` | Decals drawn one after another with the same blend function and bitmap are one draw, with each decal's color in its vertices, not a draw each: a long fight leaves hundreds in view. Within a run of decals whose blend functions commute (multiply, add, min, max, reverse subtract), they are drawn by bitmap, so more of them share a draw. | | `rasterizer/xbox/rasterizer_xbox_widgets.c`, `objects/widgets/light_volumes.c` | A light volume's sprites (a plasma bolt's glow, a couple of hundred) are one draw. | | `structures/structure_render.c` | The structure's visible surfaces are put in order by a radix sort, not `qsort_4byte`: the same order. | +| `cache/cache_files.c`, `networking/network_client_manager.c`, `networking/network_server_manager.c`, `networking/network_server_message_handler.c`, `main/main.c`, `interface/interface.c` | Map sharing (`game/map_share.c`): a client that does not have the game's Custom Edition map asks the host for it instead of leaving, is not given time to precache it until it has it, and adds its players to a game in progress only then; the host names the game's map for its hash, takes the requests from any machine in the game, lobby too, and does not drop a machine that downloads; the transfers run each frame, and their progress is drawn over the screen. | | `tag_files/tag_groups.h` | `TAG_BLOCK_GET_ELEMENT` returns an element in the block without a call, and calls `tag_block_get_element_with_size` for anything else. | The x86 inline assembly of the game is replaced by C. Thus the compiler diff --git a/port/linux/game/cache_file_formats.c b/port/linux/game/cache_file_formats.c index bffd73e0cf..77f85dc102 100644 --- a/port/linux/game/cache_file_formats.c +++ b/port/linux/game/cache_file_formats.c @@ -2117,6 +2117,88 @@ enum cache_file_status resource_map_open( return _cache_file_status_ok; } +enum cache_file_status cache_file_resource_maps_needed( + struct cache_file_source *source, + uint32_t *types) +{ + struct cache_file_identity identity; + uint8_t tag_index[TAG_INDEX_BYTES]; + uint8_t *instances; + uint32_t instances_address; + uint32_t instances_offset; + int32_t tag_count; + int32_t tag_index_value; + enum cache_file_status status = cache_file_identify(source, &identity); + + *types = 0; + if (status == _cache_file_status_ok && identity.format != _cache_file_format_custom_edition_cache) + { + status = _cache_file_status_unsupported_version; + } + if (status != _cache_file_status_ok) + { + return status; + } + if (identity.tag_data_size < TAG_INDEX_BYTES || + !source->read(source->context, identity.tag_data_offset, TAG_INDEX_BYTES, tag_index)) + { + return _cache_file_status_read_failed; + } + if (read_u32(tag_index + TAG_INDEX_SIGNATURE_OFFSET) != TAG_INDEX_SIGNATURE) + { + return _cache_file_status_bad_tag_index_signature; + } + /* (the instances lie in the tag data, at the address the tag cache + gives them) */ + tag_count = read_s32(tag_index + TAG_INDEX_COUNT_OFFSET); + instances_address = read_u32(tag_index + TAG_INDEX_INSTANCES_OFFSET); + if (tag_count <= 0 || (uint32_t)tag_count > ABSOLUTE_INDEX_MASK + 1 || + instances_address < CUSTOM_EDITION_TAG_CACHE_ADDRESS || + !range_fits(instances_address - CUSTOM_EDITION_TAG_CACHE_ADDRESS, (uint32_t)tag_count * TAG_INSTANCE_BYTES, + identity.tag_data_size)) + { + return _cache_file_status_bad_tag_instances_range; + } + instances_offset = instances_address - CUSTOM_EDITION_TAG_CACHE_ADDRESS; + instances = malloc((size_t)tag_count * TAG_INSTANCE_BYTES); + if (!instances) + { + return _cache_file_status_out_of_memory; + } + if (!source->read(source->context, identity.tag_data_offset + instances_offset, + (uint32_t)tag_count * TAG_INSTANCE_BYTES, instances)) + { + free(instances); + return _cache_file_status_read_failed; + } + for (tag_index_value = 0; tag_index_value < tag_count; tag_index_value++) + { + uint8_t const *instance = instances + (uint32_t)tag_index_value * TAG_INSTANCE_BYTES; + + if (!read_u32(instance + TAG_INSTANCE_IN_RESOURCE_MAP_OFFSET)) + { + continue; + } + switch (read_u32(instance + TAG_INSTANCE_GROUP_OFFSET)) + { + case BITMAP_GROUP_TAG: + *types |= 1u << _resource_map_bitmaps; + break; + case SOUND_GROUP_TAG: + *types |= 1u << _resource_map_sounds; + break; + case FONT_GROUP_TAG: + case UNICODE_STRING_LIST_GROUP_TAG: + case HUD_MESSAGE_TEXT_GROUP_TAG: + *types |= 1u << _resource_map_locale; + break; + } + } + free(instances); + + return _cache_file_status_ok; +} + void resource_map_close( struct resource_map *map) { diff --git a/port/linux/game/cache_file_formats.h b/port/linux/game/cache_file_formats.h index b629ad5a4a..616cc919b6 100644 --- a/port/linux/game/cache_file_formats.h +++ b/port/linux/game/cache_file_formats.h @@ -346,6 +346,16 @@ enum cache_file_status cache_file_identify( struct cache_file_source *source, struct cache_file_identity *identity); +/* The resource maps a Custom Edition cache needs, from its tag index alone: +in *types a bit (1 << type) for each resource_map_type holding one of its +tags whole, which the tag's index entry says (its word at 0x18): bitmaps +(bitm) bitmaps.map, sounds (snd!) sounds.map, fonts, string lists and HUD +message text (font, ustr, hmt ) loc.map (map sharing sends them with the +map: port/linux/game/map_share.c). */ +enum cache_file_status cache_file_resource_maps_needed( + struct cache_file_source *source, + uint32_t *types); + /* The format of a cache header already in memory (CACHE_FILE_HEADER_BYTES bytes), from its signatures and version alone: for callers that hold a header but not its file. */ diff --git a/port/linux/game/coop_scripts.c b/port/linux/game/coop_scripts.c index 0853c587e6..fa54408351 100644 --- a/port/linux/game/coop_scripts.c +++ b/port/linux/game/coop_scripts.c @@ -20,7 +20,9 @@ fires for whoever walks into it, and what it starts is sent to the clients player0 and player1 (the first two in the players list), to put them in a Pelican, take them out of it, stage them for a cutscene or give them a starting profile. Everyone past the second would be left out, so they - follow player0: wherever the scripts put player0, they go too. + follow player0: wherever the scripts put player0, they go too. A crowd + does not fit on one spot (Truth and Reconciliation's grav lift leaves + them on the beach), so they spread around that flag. */ /* ---------- headers */ @@ -30,8 +32,10 @@ fires for whoever walks into it, and what it starts is sent to the clients #include "game/game.h" #include "game/players.h" #include "hs/object_lists.h" +#include "math/real_math.h" #include "objects/objects.h" #include "objects/object_types.h" +#include "physics/collisions.h" #include "scenario/scenario.h" #include "units/bipeds.h" #include "units/units.h" @@ -157,17 +161,195 @@ static short players_following( return count; } +/* Clear structure between the two points at chest height, so a spot around +a corner or through a wall is not used. The floor is below the line. */ +static boolean line_of_sight_clear( + real_point3d const *from, + real_point3d const *to) +{ + real_point3d raised_from; + real_point3d raised_to; + struct collision_result collision; + + if (distance_squared3d(from, to) < 0.25f) + return TRUE; + raised_from = *from; + raised_to = *to; + raised_from.z += 0.5f; + raised_to.z += 0.5f; + return !collision_test_line( + FLAG(_collision_test_front_facing_surfaces_bit) | + FLAG(_collision_test_ignore_invisible_surfaces_bit) | + FLAG(_collision_test_structure_bit), + &raised_from, + &raised_to, + NONE, + &collision); +} + +/* Facing to copy: an arrived player who is on foot. A vehicle as the source +would search around the vehicle and ignore the point. */ +static long facing_unit( + long const *arrived_unit_indices, + short arrived_count) +{ + short index; + + for (index = 0; index < arrived_count; index++) + { + if (object_get_ultimate_parent(arrived_unit_indices[index]) == arrived_unit_indices[index]) + return arrived_unit_indices[index]; + } + + return NONE; +} + +static boolean teleport_to( + long player_index, + long face_unit, + real_point3d const *point, + boolean stack) +{ + boolean arrived; + + if (stack) + biped_fix_ignore_players(TRUE); + arrived = player_teleport(player_index, face_unit, point); + if (stack) + biped_fix_ignore_players(FALSE); + + return arrived; +} + +boolean coop_scripts_unit_near( + long unit_index, + real_point3d const *destination) +{ + real_point3d origin; + + if (unit_index == NONE || !destination) + return FALSE; + + /* (in the world: a seated unit's position is its seat's, in the vehicle) */ + return distance_squared3d(destination, object_get_origin(unit_index, &origin)) <= + COOP_SCRIPT_TELEPORT_RADIUS * COOP_SCRIPT_TELEPORT_RADIUS; +} + +boolean coop_scripts_arrive_at( + long player_index, + real_point3d const *destination, + long const *arrived_unit_indices, + short arrived_count) +{ + static real const ring_radius[] = { 1.5f, 3.f, 4.5f, 6.f, 7.5f }; + static real const ring_direction[][2] = + { + { 1.f, 0.f }, + { 0.70710677f, 0.70710677f }, + { 0.f, 1.f }, + { -0.70710677f, 0.70710677f }, + { -1.f, 0.f }, + { -0.70710677f, -0.70710677f }, + { 0.f, -1.f }, + { 0.70710677f, -0.70710677f } + }; + struct player_datum *player; + long face_unit; + long unit_index; + real_point3d original; + short index; + short radius_index; + short direction_index; + boolean stacked; + + if (!coop_scripts_host() || player_index == NONE || !destination) + return FALSE; + player = player_get(player_index); + unit_index = player->unit_index; + if (unit_index == NONE) + return FALSE; + object_get_origin(unit_index, &original); + face_unit = facing_unit(arrived_unit_indices, arrived_count); + if (!player_position_switches_bsp(destination) && teleport_to(player_index, face_unit, destination, FALSE)) + return TRUE; + for (index = 0; index < arrived_count; index++) + { + real_point3d spot; + + object_get_origin(arrived_unit_indices[index], &spot); + if (!coop_scripts_unit_near(arrived_unit_indices[index], destination) || + !line_of_sight_clear(destination, &spot) || + player_position_switches_bsp(&spot)) + { + continue; + } + if (teleport_to(player_index, face_unit, &spot, FALSE)) + return TRUE; + } + for (radius_index = 0; radius_index < (short)NUMBEROF(ring_radius); radius_index++) + { + for (direction_index = 0; direction_index < (short)NUMBEROF(ring_direction); direction_index++) + { + real_point3d spot; + + spot.x = destination->x + ring_radius[radius_index] * ring_direction[direction_index][0]; + spot.y = destination->y + ring_radius[radius_index] * ring_direction[direction_index][1]; + spot.z = destination->z; + if (!line_of_sight_clear(destination, &spot) || player_position_switches_bsp(&spot)) + continue; + if (teleport_to(player_index, face_unit, &spot, FALSE)) + return TRUE; + } + } + /* Still outside: stand on the flag itself, sharing it with whoever got + there. The next ticks ease the overlap apart inside the room. A refused + spot can already have moved them, so a failure here steps back. */ + stacked = !player_position_switches_bsp(destination) && teleport_to(player_index, face_unit, destination, TRUE); + if (!stacked) + teleport_to(player_index, NONE, &original, FALSE); + + return stacked; +} + void coop_scripts_teleport_followers( - long unit_index) + long unit_index, + real_point3d const *destination) { long followers[HALO_PORT_MAXIMUM_NETWORK_PLAYERS]; + long arrived[HALO_PORT_MAXIMUM_NETWORK_PLAYERS]; short count = players_following(unit_index, followers, NUMBEROF(followers)); + short arrived_count = 0; short index; + long player0_index; + real_point3d origin; + if (count == 0 || !destination) + return; + /* player0's own teleport only searches an arm's length. If that missed, + try the wider search before anyone stands beside them. A spot inside a + BSP switch does not count: the next tick would switch the level. */ + player0_index = player_index_from_unit_index(unit_index); + if (coop_scripts_unit_near(unit_index, destination) && + !player_position_switches_bsp(object_get_origin(unit_index, &origin))) + { + arrived[arrived_count++] = unit_index; + } + else if (player0_index != NONE && + coop_scripts_arrive_at(player0_index, destination, arrived, arrived_count) && + arrived_count < NUMBEROF(arrived)) + { + arrived[arrived_count++] = unit_index; + } for (index = 0; index < count; index++) { - player_teleport(player_index_from_unit_index(followers[index]), unit_index, - &object_get(unit_index)->object.position); + long player_index = player_index_from_unit_index(followers[index]); + + if (player_index != NONE && + coop_scripts_arrive_at(player_index, destination, arrived, arrived_count) && + arrived_count < NUMBEROF(arrived)) + { + arrived[arrived_count++] = followers[index]; + } } } diff --git a/port/linux/game/coop_scripts.h b/port/linux/game/coop_scripts.h index 5738404e8f..34e959ddbd 100644 --- a/port/linux/game/coop_scripts.h +++ b/port/linux/game/coop_scripts.h @@ -8,6 +8,8 @@ Campaign scripts made to work for every player in network co-op #ifndef __COOP_SCRIPTS_H #define __COOP_SCRIPTS_H +#include "math/real_math.h" + /* ---------- prototypes */ /* hs_library_external.c's volume_test_objects_all: whether to count the @@ -25,9 +27,28 @@ void coop_scripts_gather_in_volume( (the "followers"). Each does nothing unless `unit_index` is player0's in a co-op host's game. */ -/* hs_library_external.c's object_teleport: around player0 */ +/* How far from a script's teleport the other players may stand. Farther +than this, an open doorway would walk the group into the next room. */ +#define COOP_SCRIPT_TELEPORT_RADIUS 8.f + +/* hs_library_external.c's object_teleport: player0's followers go to +`destination` (the cutscene flag), spreading around it so a large group +fits. `destination` is the flag even when player0 did not reach it. */ void coop_scripts_teleport_followers( - long unit_index); + long unit_index, + real_point3d const *destination); +/* TRUE if the unit is within COOP_SCRIPT_TELEPORT_RADIUS of `destination` */ +boolean coop_scripts_unit_near( + long unit_index, + real_point3d const *destination); +/* Place this player at `destination`, or on the floor around players +already listed in `arrived_unit_indices`. Returns whether they are there +afterwards. Nothing happens unless this machine is the co-op host. */ +boolean coop_scripts_arrive_at( + long player_index, + real_point3d const *destination, + long const *arrived_unit_indices, + short arrived_count); /* units.c's unit_suspended */ void coop_scripts_suspend_followers( long unit_index, diff --git a/port/linux/game/custom_edition_cache.c b/port/linux/game/custom_edition_cache.c index 01de111c9c..1dc4159572 100644 --- a/port/linux/game/custom_edition_cache.c +++ b/port/linux/game/custom_edition_cache.c @@ -30,8 +30,10 @@ where its offset falls in their combined offset space. #include "cache_file_formats.h" #include "custom_edition_cache.h" #include "tag_schema.h" +#include "map_share.h" #include +#include /* ---------- constants */ @@ -71,6 +73,9 @@ struct custom_edition_file struct custom_edition_cache_globals { boolean tags_loaded; + /* the loaded map's file (custom_edition_cache_loaded_path), while it + is open */ + char map_path[MAP_PATH_SIZE]; /* the Halo PC behaviours the loaded map relies on (enum custom_edition_behaviour, flags) */ uint32_t behaviours; @@ -204,13 +209,64 @@ static boolean maps_folder_has( return file_path_exists(path); } +/* The custom_maps folder's one map named _.map, for a name +without the hash its file has: its file name (without the extension) in +`hashed` (MAP_PATH_SIZE characters). Maps there are named for their hash as +the map lists find them (map_share_name_map), so a level name of before (a +saved last map, a test's) still finds its map; FALSE if there are more. */ +static boolean maps_folder_hashed_name( + char const *name, + char *hashed) +{ + char pattern[MAP_PATH_SIZE]; + WIN32_FIND_DATAA data; + HANDLE find; + short count = 0; + + if (map_share_name_hashed(name) || + strlen(CUSTOM_EDITION_MAP_DIRECTORY) + strlen(name) + 1 + MAP_SHARE_NAME_DIGITS + 4 >= MAP_PATH_SIZE) + { + return FALSE; + } + sprintf(pattern, "%s%s_*%s", CUSTOM_EDITION_MAP_DIRECTORY, name, MAP_FILE_EXTENSION); + find = FindFirstFileA(pattern, &data); + if (find == INVALID_HANDLE_VALUE) + return FALSE; + do + { + char found[sizeof(data.cFileName) + 1]; + size_t length; + + memcpy(found, data.cFileName, sizeof(data.cFileName)); + found[sizeof(data.cFileName)] = 0; + length = strlen(found); + if ((data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) || length <= strlen(MAP_FILE_EXTENSION)) + continue; + found[length - strlen(MAP_FILE_EXTENSION)] = 0; + /* (the name and a hash: not a longer name that starts the same) */ + if (!map_share_name_hashed(found) || (size_t)map_share_name_base_length(found) != strlen(name) || + _strnicmp(found, name, strlen(name))) + { + continue; + } + if (count++) + break; + strcpy(hashed, found); + } while (FindNextFileA(find, &data)); + CloseHandle(find); + + return count == 1; +} + /* the file that holds the map `map_name` names (a level name or a file -name): \.map */ +name): \.map (custom_maps' _.map for +a name without the hash, if it has one map of that name) */ static boolean custom_edition_map_path( char const *map_name, char *path) { char const *name = tag_name_strip_path(map_name); + char hashed[MAP_PATH_SIZE]; short folder; for (folder = 0; folder < NUMBER_OF_MAPS_FOLDERS; folder++) @@ -221,7 +277,8 @@ static boolean custom_edition_map_path( } } - return FALSE; + return maps_folder_hashed_name(name, hashed) && + maps_folder_has(maps_folder(0), hashed, MAP_FILE_EXTENSION, path); } /* A cache's resource map of `type`, bitmaps.map and so on, in the first maps @@ -261,6 +318,7 @@ static void custom_edition_cache_files_close( custom_edition_file_close(&globals->resource_files[type]); } custom_edition_file_close(&globals->map); + globals->map_path[0] = 0; return; } @@ -568,6 +626,54 @@ boolean custom_edition_level_name( !_strnicmp(level_name, CUSTOM_EDITION_LEVEL_NAME_PREFIX, csstrlen(CUSTOM_EDITION_LEVEL_NAME_PREFIX)); } +boolean custom_edition_map_file_path( + char const *map_name, + char *path) +{ + return custom_edition_map_path(map_name, path); +} + +boolean custom_edition_resource_map_file_path( + short type, + char *path) +{ + short folder; + + if (type <= _resource_map_none || type >= NUMBER_OF_RESOURCE_MAP_TYPES) + return FALSE; + for (folder = 0; folder < NUMBER_OF_MAPS_FOLDERS; folder++) + { + if (maps_folder_has(maps_folder(folder), resource_map_type_describe((enum resource_map_type)type), + MAP_FILE_EXTENSION, path)) + { + return TRUE; + } + } + return FALSE; +} + +unsigned long custom_edition_map_resource_maps_needed( + char const *level_name) +{ + char path[MAP_PATH_SIZE]; + struct custom_edition_file file; + uint32_t types = 0; + + if (custom_edition_map_path(level_name, path) && custom_edition_file_open(&file, path)) + { + if (cache_file_resource_maps_needed(&file.source, &types) != _cache_file_status_ok) + types = 0; + custom_edition_file_close(&file); + } + return types; +} + +char const *custom_edition_cache_loaded_path( + void) +{ + return custom_edition_cache_globals.map.stream ? custom_edition_cache_globals.map_path : NULL; +} + boolean custom_edition_map_file_present( char const *map_name) { @@ -608,13 +714,14 @@ boolean custom_edition_cache_present( struct custom_edition_file file; struct cache_file_identity identity; enum cache_file_status status = _cache_file_status_read_failed; + uint32_t needed = 0; short folder; short type; if (!custom_edition_map_path(level_name, path)) { - snprintf(message, message_size, "You don't have the map %.64s.map. If you have it, copy it into custom_maps.", - name); + snprintf(message, message_size, "You don't have the map %.*s.map. If you have it, copy it into custom_maps.", + (int)MIN(64, map_share_name_base_length(name)), name); return FALSE; } if (!halo_custom_edition_tag_cache()) @@ -628,6 +735,13 @@ boolean custom_edition_cache_present( if (custom_edition_file_open(&file, path)) { status = cache_file_identify(&file.source, &identity); + /* (the resource maps the map's tags are read from: those its tag + index says it holds tags in) */ + if (status == _cache_file_status_ok && identity.format == _cache_file_format_custom_edition_cache && + cache_file_resource_maps_needed(&file.source, &needed) != _cache_file_status_ok) + { + needed = (1u << _resource_map_bitmaps) | (1u << _resource_map_sounds) | (1u << _resource_map_locale); + } custom_edition_file_close(&file); } if (status != _cache_file_status_ok || identity.format != _cache_file_format_custom_edition_cache) @@ -648,12 +762,13 @@ boolean custom_edition_cache_present( "Your %.64s.map is a different version from the host's. Copy the host's into custom_maps.", name); return FALSE; } - /* (the resource maps every Custom Edition map's tags are read from) */ for (type = _resource_map_bitmaps; type < NUMBER_OF_RESOURCE_MAP_TYPES; type++) { char const *resource_name = resource_map_type_describe((enum resource_map_type)type); char resource_path[MAP_PATH_SIZE]; + if (!(needed & (1u << type))) + continue; for (folder = 0; folder < NUMBER_OF_MAPS_FOLDERS; folder++) { if (maps_folder_has(maps_folder(folder), resource_name, MAP_FILE_EXTENSION, resource_path)) @@ -664,7 +779,7 @@ boolean custom_edition_cache_present( error(_error_silent, "custom edition: the map '%s' needs %s.map, which no maps folder has", level_name, resource_name); snprintf(message, message_size, - "The map %.64s needs Custom Edition's bitmaps.map, sounds.map and loc.map in custom_maps.", name); + "The map %.64s needs Custom Edition's %s.map in custom_maps.", name, resource_name); return FALSE; } } @@ -723,6 +838,7 @@ struct cache_file_tag_header *custom_edition_cache_tags_load( map_name); return NULL; } + strcpy(globals->map_path, path); status = cache_file_identify(&globals->map.source, &identity); if (status != _cache_file_status_ok || identity.format != _cache_file_format_custom_edition_cache || diff --git a/port/linux/game/custom_edition_cache.h b/port/linux/game/custom_edition_cache.h index 55782a5aab..c2bfc3eb0c 100644 --- a/port/linux/game/custom_edition_cache.h +++ b/port/linux/game/custom_edition_cache.h @@ -85,11 +85,32 @@ versions of a map; 0 if it has no file or the file is not a Custom Edition cache. */ unsigned long custom_edition_map_checksum( char const *level_name); +/* The file of the map `map_name` (a level name or a file name) in the +Custom Edition maps folders, into `path` (256 characters): the custom_maps +folder's map named for its hash (_.map, map_share.h) for a name +without it, if there is one map of that name. FALSE if none. */ +boolean custom_edition_map_file_path( + char const *map_name, + char *path); +/* The resource map of `type` (enum resource_map_type: bitmaps.map, +sounds.map, loc.map) in the first maps folder that has it, into `path` (256 +characters); FALSE if none has it. */ +boolean custom_edition_resource_map_file_path( + short type, + char *path); +/* The resource maps the level's map needs (a bit, 1 << type, for each +resource map its tags are held in: cache_file_resource_maps_needed); 0 if +it has no file, or none. */ +unsigned long custom_edition_map_resource_maps_needed( + char const *level_name); +/* the file of the Custom Edition map loaded, or NULL */ +char const *custom_edition_cache_loaded_path( + void); /* Whether this machine can play the level `level_name` (custom_maps\), as a network game's client must: its file present and a Custom Edition cache of the host's version (`checksum`, custom_edition_map_checksum's on the host; 0 for any), Custom Edition maps able to run, and the resource maps -present. When not, `message` (`message_size` characters) says why, for the +it needs present. When not, `message` (`message_size` characters) says why, for the player, and what to do. */ boolean custom_edition_cache_present( char const *level_name, diff --git a/port/linux/game/custom_edition_maps.c b/port/linux/game/custom_edition_maps.c index e40f0013c7..85253e5db0 100644 --- a/port/linux/game/custom_edition_maps.c +++ b/port/linux/game/custom_edition_maps.c @@ -15,10 +15,15 @@ A map's level name is custom_maps\ (CUSTOM_EDITION_LEVEL_NAME_PREFIX), which the cache loader reads from the custom maps folders alone: a map named as one of the game's own levels (a30.map, bloodgulch.map) is listed and played as itself. The game engine keeps 63 characters of a level name, so a -map whose name is longer than 51 is skipped. +map whose name is longer than 51 is skipped. A map in the custom_maps folder +is named for its hash (_.map) once it has been found: a network +game's level name then says which version of the map it is on, and a player +without it can download it (map_share.h). The menus show the name without +the hash, unless two versions of a map are listed. Each map can have, beside it in its folder: -- .bmp, its picture (bmp_files.c). The middle is cropped to the +- .bmp, its picture (bmp_files.c; for a map named for its hash, a + picture named without the hash also does). The middle is cropped to the menus' picture shape. It is read the first time it is drawn, and freed when the maps are rescanned. Without one, the unknown level's picture is shown. @@ -41,6 +46,7 @@ Each map can have, beside it in its folder: #include "bmp_files.h" #include "custom_edition_cache.h" #include "custom_edition_maps.h" +#include "map_share.h" #include #include @@ -180,29 +186,32 @@ static void custom_edition_maps_forget( return; } -/* the map's index in stock_maps, or NONE */ +/* the map's index in stock_maps, or NONE (by its name without its hash) */ static short stock_map_index( char const *name) { + short length = map_share_name_base_length(name); short index; for (index = 0; index < NUMBEROF(stock_maps); index++) { - if (!csstrcasecmp(name, stock_maps[index].file)) + if ((short)csstrlen(stock_maps[index].file) == length && !_strnicmp(name, stock_maps[index].file, length)) return index; } return NONE; } /* A map's menu name: Halo PC's name for its own maps, otherwise the file -name with underscores as spaces and words capitalized ("the_bay_of_pigs" -becomes "The Bay Of Pigs"). */ +name (without the hash it ends in, unless `hash`) with underscores as spaces +and words capitalized ("the_bay_of_pigs" becomes "The Bay Of Pigs"). */ static void display_name_make( char const *name, + boolean hash, wchar_t *display_name) { boolean word_start = TRUE; - short index = stock_map_index(name); + short index = hash ? NONE : stock_map_index(name); + short length = hash ? (short)csstrlen(name) : map_share_name_base_length(name); if (index != NONE) { @@ -210,7 +219,7 @@ static void display_name_make( return; } - for (index = 0; name[index]; index++) + for (index = 0; index < length; index++) { unsigned char character = (unsigned char)name[index]; @@ -245,6 +254,13 @@ static void custom_edition_map_description_read( csprintf(path, "%s%s%s", map->folder, map->name, DESCRIPTION_EXTENSION); stream = fopen(path, "rb"); + /* (a map named for its hash: its description named without it too) */ + if (!stream && map_share_name_hashed(map->name)) + { + csprintf(path, "%s%.*s%s", map->folder, (int)map_share_name_base_length(map->name), map->name, + DESCRIPTION_EXTENSION); + stream = fopen(path, "rb"); + } if (stream) { text_size = (long)fread(text, 1, sizeof(text), stream); @@ -371,8 +387,11 @@ static void custom_edition_map_add( csstrcpy(map->name, name); csstrncpy(map->folder, folder, sizeof(map->folder) - 1); csprintf(map->level_name, "%s%s", CUSTOM_EDITION_LEVEL_NAME_PREFIX, name); - display_name_make(name, map->display_name); + display_name_make(name, FALSE, map->display_name); custom_edition_map_description_read(map); + /* (named for its hash, renamed: map_share_files.c) */ + if (!map_share_name_hashed(name)) + map_share_name_map(folder, name); return; } @@ -386,6 +405,28 @@ static int custom_edition_map_compare( ((struct custom_edition_map const *)second)->name); } +/* Two versions of a map (its name the same but for its hash) listed: each +shown with its hash, to tell them apart. The maps are sorted by name. */ +static void custom_edition_maps_tell_versions_apart( + struct custom_edition_map *maps, + short count) +{ + short index; + + for (index = 0; index + 1 < count; index++) + { + struct custom_edition_map *map = &maps[index]; + struct custom_edition_map *next = &maps[index + 1]; + short length = map_share_name_base_length(map->name); + + if (length == map_share_name_base_length(next->name) && !_strnicmp(map->name, next->name, length)) + { + display_name_make(map->name, TRUE, map->display_name); + display_name_make(next->name, TRUE, next->display_name); + } + } +} + static void custom_edition_maps_look_for( void) { @@ -441,6 +482,8 @@ static void custom_edition_maps_look_for( } qsort(globals->maps, globals->map_count, sizeof(globals->maps[0]), custom_edition_map_compare); qsort(globals->campaigns, globals->campaign_count, sizeof(globals->campaigns[0]), custom_edition_map_compare); + custom_edition_maps_tell_versions_apart(globals->maps, globals->map_count); + custom_edition_maps_tell_versions_apart(globals->campaigns, globals->campaign_count); error(_error_silent, "custom edition: %d multiplayer maps for the level list, %d campaign maps", globals->map_count, globals->campaign_count); @@ -462,6 +505,13 @@ static struct bitmap_data *custom_edition_map_picture_read( csprintf(path, "%s%s%s", map->folder, map->name, PICTURE_EXTENSION); stream = fopen(path, "rb"); + /* (a map named for its hash: its picture named without it too) */ + if (!stream && map_share_name_hashed(map->name)) + { + csprintf(path, "%s%.*s%s", map->folder, (int)map_share_name_base_length(map->name), map->name, + PICTURE_EXTENSION); + stream = fopen(path, "rb"); + } if (!stream) { return NULL; diff --git a/port/linux/game/map_share.c b/port/linux/game/map_share.c new file mode 100644 index 0000000000..505da4fc02 --- /dev/null +++ b/port/linux/game/map_share.c @@ -0,0 +1,3096 @@ +/* +MAP_SHARE.C + +Custom Edition maps shared over network games (map_share.h, +port/linux/NETCODE.md "Map sharing"). + +A client joining a game on a map it does not have asks the host, over its +connection (_distributed_message_map_share_request: the level, and which +of its files it lacks: the map, bitmaps.map, sounds.map, loc.map). A host +that shares maps (network.map_share) hashes them (map_share_files.c) and +answers with each file's size and hash (_offer), and which of the game's +other machines may have them (_sources, again every few seconds: they are +the tracker's). The client then fetches the files from all of them at once, +as a torrent's peers do, over connections of their own on MAP_SHARE_PORT: + +- each end says which files it has, whole or by piece, and tells the other + of each piece it gets; +- the client asks for the rarest pieces first, a few at a time from each, + and checks each against its hash (the pieces' hashes come from any of + them: they are checked against the file's hash, the host's word); a + machine that sends a wrong piece is not asked again; +- a machine sends what it has of the game's files (its map, the resource + maps the map needs, a download's pieces) no faster than its limit + (network.map_share_upload for the host, network.map_seed_upload for the + others, network.map_seed turning that off), so the game's own traffic + keeps the rest of the upload. + +Over the internet the players reach their host alone: the host also says how +another player is reached (its key's hash), and a player asks that one for a +session as it asked the host (p2p.c: a player answers holders of the invite +it joined with while it may share). Each downloaded file is written beside +the others as .part and renamed when whole (a .part found again is +checked piece by piece and taken up where it was), into custom_maps under +the map's own name, which is its hash's. + +A machine still downloading does not hold the lobby: the machines that +have the map start, and it joins the game in progress once the download is +done. The host does not drop a machine that says it is downloading. +*/ + +/* ---------- headers */ + +#include "cseries.h" +#include "cseries/errors.h" +#include "tag_files/tag_groups.h" +#include "tag_files/tag_files.h" +#include "bungie_net/network/transport.h" +#include "bungie_net/network/transport_endpoint_winsock.h" +#include "bungie_net/network/transport_address_constants.h" +#include "game/game.h" +#include "interface/interface.h" +#include "interface/ui_widget.h" +#include "networking/network_game_globals.h" +#include "networking/network_game_manager.h" +#include "networking/network_client_manager.h" +#include "networking/network_connection.h" +#include "render/render.h" +#include "text/draw_string.h" +#include "text/font_group.h" +#include "rasterizer/rasterizer.h" +#include "network_distributed.h" +#include "cache_file_formats.h" +#include "custom_edition_cache.h" +#include "custom_edition_maps.h" +#include "map_share.h" +#include "../src/p2p.h" + +#include +#include +#include + +/* (the C library's allocator, not the game's tracked one (cseries.h): the +hashing thread allocates what the game's thread frees) */ +#undef malloc +#undef free +#undef realloc + +/* port_config.c's, network_game_globals.c's, network_server_message_handler.c's, +network_server_manager.c's, cseries_windows.c's */ +int config_boolean(const char *name); +long config_integer(const char *name); +boolean network_distributed_client_send_reliably(void *message, word size); +boolean network_distributed_server_send_to_machine_reliably(long machine_index, void *message, word size); +unsigned long network_game_server_machine_address(long machine_index); +word network_game_server_get_state(struct network_game_server *server, short *substate); +unsigned long system_milliseconds(void); + +/* ---------- constants */ + +enum +{ + /* where each machine takes the others' connections */ + MAP_SHARE_PORT = P2P_MAP_SHARE_PORT, + MAP_SHARE_MACHINES = HALO_PORT_MAXIMUM_NETWORK_MACHINES, + /* the files of a map: the map and its three resource maps */ + MAXIMUM_FILES = NUMBER_OF_MAP_SHARE_FILE_KINDS, + /* the machines the host names to a client, and those a client reaches + at once (the host, and others) */ + MAXIMUM_SOURCES = 32, + MAXIMUM_OUTGOING = 8, + /* connections at once, those other machines made included, and of + those, from one address */ + MAXIMUM_CONNECTIONS = 24, + MAXIMUM_CONNECTIONS_PER_ADDRESS = 4, + /* a connection's buffers: a whole frame in (the largest a block), and + a few blocks out */ + INPUT_BYTES = 0x6000, + OUTPUT_BYTES = 0xC000, + MAXIMUM_FRAME_BYTES = INPUT_BYTES - 4, + /* pieces asked of one connection at once, and of all of them; pieces + another machine may ask of this one at once */ + REQUESTS_PER_CONNECTION = 2, + MAXIMUM_PIECES_IN_FLIGHT = 16, + SERVING_PER_CONNECTION = 8, + /* the pieces' hashes sent at once */ + HASHES_PER_FRAME = 256, + /* a level name as a message carries it */ + LEVEL_NAME_BYTES = 64, + + /* milliseconds */ + OFFER_TIMEOUT = 30000, + STALL_TIMEOUT = 60000, + PROGRESS_INTERVAL = 2000, + SOURCES_INTERVAL = 10000, + /* the host takes a machine for downloading this long after it said so */ + DOWNLOADING_TIMEOUT = 20000, + CONNECTION_IDLE_TIMEOUT = 30000, + CONNECT_TIMEOUT = 10000, + KEEP_ALIVE_INTERVAL = 10000, + REQUEST_TIMEOUT = 30000, + SOURCE_RETRY_INTERVAL = 15000, + LISTEN_RETRY_INTERVAL = 10000, + RATE_WINDOW = 2000, + LOG_INTERVAL = 10000, + + /* the largest file of each kind the cache loader takes + (custom_edition_cache.c's combined offset space), so that a host cannot + have its players fill their disks */ + MAXIMUM_MAP_BYTES = 0x30000000, + MAXIMUM_RESOURCE_MAP_BYTES = 0x20000000, + + /* the kilobytes a second a machine sends, at least and at most */ + MINIMUM_UPLOAD_KILOBYTES = 16, + MAXIMUM_UPLOAD_KILOBYTES = 65536, + + /* network_server_manager.c's host state in game, transport_endpoint_winsock.c's + stream endpoint and hud_messaging.c's centred text (their enums are their + own) */ + _map_share_server_state_ingame = 1, + _map_share_endpoint_tcp = 0x12, + _map_share_text_centered = 2, +}; + +/* a connection's frames: its length (4 bytes, little-endian, of what +follows), its type, and its body */ +enum +{ + /* the files the connection is about: its magic, a version, the count, + and each file's hash (the end that connected says; the other repeats + them) */ + _frame_hello = 1, + /* what this end has of a file: none, all, or a byte of pieces each + eight (the first the lowest bit) */ + _frame_state, + /* a piece this end got (the file, the piece) */ + _frame_have, + /* a piece asked for (the file, the piece) */ + _frame_request, + /* part of a piece (the file, the piece, its offset, the bytes) */ + _frame_block, + /* a piece this end does not send (the file, the piece) */ + _frame_refuse, + /* the pieces' hashes asked for (the file), and some of them (the file, + the first, the count, the hashes) */ + _frame_hashes_request, + _frame_hashes, + _frame_keep_alive, +}; + +#define FRAME_MAGIC 0x3153484DUL /* 'MHS1' */ +#define FRAME_VERSION 1 + +enum +{ + _have_none, + _have_all, + _have_pieces, +}; + +/* the host's answer to a request */ +enum +{ + _offer_ok, + /* it does not share maps (network.map_share) */ + _offer_off, + /* not the game's map, not named for its hash, or not readable */ + _offer_unavailable, +}; + +enum +{ + _offer_in_progress_bit, +}; + +enum +{ + /* the machine is reached through internet play (its key hash), else + at its address */ + _source_internet_bit, + /* it is downloading the files too */ + _source_downloading_bit, +}; + +enum +{ + _progress_downloading, + _progress_done, + _progress_failed, +}; + +enum +{ + _download_none, + /* asked the host; waiting for its offer */ + _download_asking, + _download_transferring, + _download_done, + _download_failed, +}; + +enum +{ + /* its pieces' hashes not known yet */ + _file_awaiting_hashes, + /* its part file being checked */ + _file_checking, + _file_transferring, + _file_complete, +}; + +enum +{ + _connection_connecting, + _connection_open, +}; + +/* ---------- structures */ + +struct distributed_map_share_request +{ + char level_name[LEVEL_NAME_BYTES]; + unsigned long version; + /* the files the client lacks: a bit each (_map_share_file_*) */ + byte wanted; + byte pad[3]; +}; + +struct distributed_map_share_offer +{ + char level_name[LEVEL_NAME_BYTES]; + byte result; + byte file_count; + byte flags; + byte pad; +}; + +struct distributed_map_share_offer_file +{ + byte kind; + byte pad[3]; + unsigned long size; + byte hash[MAP_SHARE_HASH_BYTES]; +}; + +struct distributed_map_share_sources +{ + byte count; + byte flags; + byte pad[2]; +}; + +struct distributed_map_share_source +{ + byte machine_index; + byte flags; + byte pad[2]; + /* IPv4, host byte order */ + unsigned long address; + byte identifier[6]; + byte pad2[2]; + byte key_hash[P2P_PLAYER_KEY_HASH_SIZE]; +}; + +struct distributed_map_share_progress +{ + byte state; + byte pad[3]; + unsigned long done; + unsigned long total; +}; + +typedef char distributed_map_share_request_size_assert[sizeof(struct distributed_map_share_request) == 72 ? 1 : -1]; +typedef char distributed_map_share_offer_size_assert[sizeof(struct distributed_map_share_offer) == 68 ? 1 : -1]; +typedef char distributed_map_share_offer_file_size_assert[sizeof(struct distributed_map_share_offer_file) == 40 ? 1 : -1]; +typedef char distributed_map_share_source_size_assert[sizeof(struct distributed_map_share_source) == 32 ? 1 : -1]; +typedef char distributed_map_share_progress_size_assert[sizeof(struct distributed_map_share_progress) == 12 ? 1 : -1]; + +/* a file of the download */ +struct download_file +{ + short kind; + short state; + unsigned long size; + byte hash[MAP_SHARE_HASH_BYTES]; + long piece_count; + /* known once a machine sent them, checked against the hash */ + byte *piece_hashes; + /* the pieces' hashes as they come (from one connection at a time) */ + byte *hashes_coming; + long hashes_received; + short hashes_connection; + unsigned long hashes_asked_at; + /* the pieces had (a bit each), and asked for (a count each) */ + byte *have; + byte *asked; + long have_count; + char part_path[MAP_SHARE_PATH_SIZE]; + char final_path[MAP_SHARE_PATH_SIZE]; + FILE *stream; +}; + +/* a machine the host named */ +struct source +{ + byte machine_index; + byte flags; + unsigned long address; + byte identifier[6]; + byte key_hash[P2P_PLAYER_KEY_HASH_SIZE]; + /* the host itself (the client's own connection's address) */ + boolean host; + /* sent a wrong piece: not asked again */ + boolean banned; + short connection; + unsigned long tried_at; +}; + +struct download +{ + short state; + /* this download's number, which its part files' checks are asked for + with (map_share_pieces_hash: a check of the last's is not this one's) */ + long serial; + char level_name[LEVEL_NAME_BYTES]; + unsigned long version; + /* why the map cannot be played (custom_edition_cache_present's), for a + download that fails */ + char message[256]; + byte wanted; + boolean in_progress; + short file_count; + struct download_file files[MAXIMUM_FILES]; + short source_count; + struct source sources[MAXIMUM_SOURCES + 1]; + unsigned long asked_at; + unsigned long progress_at; + unsigned long reported_at; + /* the bytes of the pieces had, of all of them; and for the rate shown, + those got in this window and the last's */ + unsigned long done_bytes; + unsigned long total_bytes; + unsigned long window_at; + unsigned long window_bytes; + unsigned long last_window_bytes; + unsigned long last_window_time; + unsigned long logged_at; +}; + +/* a piece asked of a connection */ +struct request +{ + short file; + long piece; + unsigned long received; + unsigned long asked_at; + byte *buffer; +}; + +/* a piece another machine asked of this one, sent a block at a time */ +struct serving +{ + short file; + long piece; + unsigned long offset; +}; + +struct connection +{ + boolean used; + short state; + boolean outgoing; + /* the download's source this reached (outgoing), or NONE */ + short source; + /* IPv4, host byte order */ + unsigned long address; + struct transport_endpoint *endpoint; + unsigned long created_at; + unsigned long heard_at; + unsigned long sent_at; + /* the bytes of pieces it brought and took, for the log */ + unsigned long bytes_in; + unsigned long bytes_out; + boolean hello_heard; + /* the files it is about, and for each: the download's file, the + catalog's file this machine sends (map_share_files.c), what the other + end has, whether this end said what it has, and the next of the pieces' + hashes to send it (NONE: none asked) */ + short file_count; + byte file_hashes[MAXIMUM_FILES][MAP_SHARE_HASH_BYTES]; + short download_file[MAXIMUM_FILES]; + short catalog_file[MAXIMUM_FILES]; + short remote_has[MAXIMUM_FILES]; + byte *remote_pieces[MAXIMUM_FILES]; + boolean state_heard[MAXIMUM_FILES]; + boolean state_told[MAXIMUM_FILES]; + long hashes_next[MAXIMUM_FILES]; + short request_count; + struct request requests[REQUESTS_PER_CONNECTION]; + short serving_count; + struct serving serving[SERVING_PER_CONNECTION]; + /* (allocated with the connection: INPUT_BYTES, OUTPUT_BYTES) */ + byte *input; + long input_size; + byte *output; + long output_size; +}; + +/* what the host knows of each client machine's download */ +struct host_machine +{ + boolean asked; + struct distributed_map_share_request request; + unsigned long asked_at; + boolean offered; + short catalog_files[MAXIMUM_FILES]; + short file_kinds[MAXIMUM_FILES]; + short file_count; + unsigned long sources_at; + /* downloading (and for DOWNLOADING_TIMEOUT after it said it was done: + its players join then), and done */ + boolean downloading; + boolean downloaded; + unsigned long heard_at; + unsigned long done; + unsigned long total; + unsigned long address; +}; + +struct map_share_globals +{ + struct download download; + struct connection connections[MAXIMUM_CONNECTIONS]; + short next_connection; + struct host_machine machines[MAP_SHARE_MACHINES]; + struct transport_endpoint *listener; + unsigned long listen_tried_at; + boolean listen_failed_logged; + /* the upload's allowance (bytes) and when it was topped up */ + long upload_tokens; + unsigned long upload_at; + /* the catalog files of the game's map and its resource maps, which + this machine sends (NONE: none), and the level they are of */ + char current_level[LEVEL_NAME_BYTES]; + short current_files[MAXIMUM_FILES]; +}; + +/* ---------- globals */ + +static struct map_share_globals map_share_globals; + +/* ---------- little-endian bytes */ + +static void put_long( + byte *bytes, + unsigned long value) +{ + bytes[0] = (byte)value; + bytes[1] = (byte)(value >> 8); + bytes[2] = (byte)(value >> 16); + bytes[3] = (byte)(value >> 24); +} + +static unsigned long get_long( + byte const *bytes) +{ + return (unsigned long)bytes[0] | (unsigned long)bytes[1] << 8 | (unsigned long)bytes[2] << 16 | + (unsigned long)bytes[3] << 24; +} + +static unsigned long network_order( + unsigned long value) +{ + byte bytes[4]; + unsigned long result; + + bytes[0] = (byte)(value >> 24); + bytes[1] = (byte)(value >> 16); + bytes[2] = (byte)(value >> 8); + bytes[3] = (byte)value; + csmemcpy(&result, bytes, sizeof(result)); + return result; +} + +static unsigned long host_order( + unsigned long value) +{ + byte const *bytes = (byte const *)&value; + + return (unsigned long)bytes[0] << 24 | (unsigned long)bytes[1] << 16 | (unsigned long)bytes[2] << 8 | bytes[3]; +} + +/* whether `time` milliseconds have passed from `since` to `now` (a time +taken after now has not) */ +static boolean elapsed( + unsigned long since, + unsigned long now, + unsigned long time) +{ + return (long)(now - since) >= (long)time; +} + +/* whether an address (host byte order) is internet play's (100.64.0.0/10) */ +static boolean address_internet( + unsigned long address) +{ + return (address & 0xFFC00000UL) == 0x64400000UL; +} + +/* ---------- this machine's network game */ + +static struct network_game *map_share_network_game( + void) +{ + struct network_game_client *client = global_network_game_client_get(); + + return client ? network_game_client_get_game(client) : NULL; +} + +static boolean map_share_host( + void) +{ + return global_network_game_server_get() != NULL; +} + +/* the host's address as this client's connection has it (host byte +order; 0 if none) */ +static unsigned long map_share_host_address( + void) +{ + struct network_game_client *client = global_network_game_client_get(); + struct network_connection *connection = client ? network_game_client_get_connection(client) : NULL; + struct transport_address reliable; + struct transport_address unreliable; + + if (!connection || map_share_host()) + return 0; + csmemset(&reliable, 0, sizeof(reliable)); + network_connection_get_address(connection, &reliable, &unreliable); + return reliable.address_length == IPV4_ADDRESS_LENGTH ? reliable.address.long_words[0] : 0; +} + +static long upload_limit( + void) +{ + long kilobytes = map_share_host() ? config_integer("network.map_share_upload") : + config_integer("network.map_seed_upload"); + + return PIN(kilobytes, MINIMUM_UPLOAD_KILOBYTES, MAXIMUM_UPLOAD_KILOBYTES) * 1024; +} + +/* whether this machine sends the game's files to the others: in a game on +a Custom Edition map, as its settings say */ +static boolean map_share_sending( + void) +{ + struct network_game *game = map_share_network_game(); + + if (!game || !custom_edition_level_name(game->map.name) || network_game_client_get_local_machine_index() == NONE) + return FALSE; + return map_share_host() ? config_boolean("network.map_share") != 0 : config_boolean("network.map_seed") != 0; +} + +/* a message's headers (voice_fill_header's) */ +static void map_share_fill_header( + struct distributed_message_header *header, + byte type, + word size) +{ + header->header = 0; + build_message_header(&header->header, size, 2, 0); + header->type = type; + header->count = 1; + header->game_time = game_in_progress() ? game_time_get() : 0; +} + +/* a level name copied into a message's field, ended within it */ +static void level_name_copy( + char *destination, + char const *source) +{ + csmemset(destination, 0, LEVEL_NAME_BYTES); + csstrncpy(destination, source, LEVEL_NAME_BYTES - 1); +} + +static boolean level_name_valid( + char const *name) +{ + return memchr(name, 0, LEVEL_NAME_BYTES) != NULL && name[0]; +} + +/* ---------- the game's files: those this machine sends */ + +/* the catalog files of the level's map and of the resource maps there are +(asked to be hashed only when another machine asks for them: hashing a +resource map reads hundreds of megabytes) */ +static void current_files_update( + boolean hash) +{ + struct map_share_globals *globals = &map_share_globals; + struct network_game *game = map_share_network_game(); + char const *level_name = game ? game->map.name : ""; + short kind; + + if (csstrncmp(globals->current_level, level_name, LEVEL_NAME_BYTES - 1)) + { + level_name_copy(globals->current_level, level_name); + for (kind = 0; kind < MAXIMUM_FILES; kind++) + globals->current_files[kind] = NONE; + } + if (!hash || !custom_edition_level_name(level_name)) + return; + for (kind = 0; kind < MAXIMUM_FILES; kind++) + { + char path[MAP_SHARE_PATH_SIZE]; + boolean present = kind == _map_share_file_map ? custom_edition_map_file_path(level_name, path) : + custom_edition_resource_map_file_path(kind, path); + + globals->current_files[kind] = present ? map_share_file_hash(path, kind) : NONE; + } +} + +/* whether the current files are all hashed (or failed): what this machine +has of a hash asked for is known */ +static boolean current_files_hashed( + void) +{ + struct map_share_globals *globals = &map_share_globals; + short kind; + + for (kind = 0; kind < MAXIMUM_FILES; kind++) + { + struct map_share_file *file = map_share_file_get(globals->current_files[kind]); + + if (file && !file->ready && !file->failed) + return FALSE; + } + return TRUE; +} + +/* the catalog file with this hash that this machine sends, or NONE */ +static short current_file_with_hash( + byte const *hash) +{ + struct map_share_globals *globals = &map_share_globals; + short kind; + + for (kind = 0; kind < MAXIMUM_FILES; kind++) + { + struct map_share_file *file = map_share_file_get(globals->current_files[kind]); + + if (file && file->ready && !csmemcmp(file->hash, hash, MAP_SHARE_HASH_BYTES)) + return globals->current_files[kind]; + } + return NONE; +} + +/* the download's file with this hash, or NONE */ +static short download_file_with_hash( + byte const *hash) +{ + struct download *download = &map_share_globals.download; + short index; + + if (download->state != _download_transferring) + return NONE; + for (index = 0; index < download->file_count; index++) + { + if (!csmemcmp(download->files[index].hash, hash, MAP_SHARE_HASH_BYTES)) + return index; + } + return NONE; +} + +/* ---------- connections: frames */ + +static void connection_close( + struct connection *connection, + char const *reason); + +/* room for a frame of `size` bytes of body in the connection's output */ +static boolean frame_room( + struct connection *connection, + long size) +{ + return connection->output_size + 5 + size <= OUTPUT_BYTES; +} + +/* a frame's head written to the output, its body to follow (frame_room +checked) */ +static byte *frame_begin( + struct connection *connection, + byte type, + long size) +{ + byte *frame = connection->output + connection->output_size; + + put_long(frame, (unsigned long)(size + 1)); + frame[4] = type; + connection->output_size += 5 + size; + return frame + 5; +} + +static void frame_piece( + struct connection *connection, + byte type, + short file, + long piece) +{ + byte *body; + + if (!frame_room(connection, 5)) + return; + body = frame_begin(connection, type, 5); + body[0] = (byte)file; + put_long(body + 1, (unsigned long)piece); +} + +static void send_hello( + struct connection *connection) +{ + byte *body; + short index; + + if (!frame_room(connection, 6 + connection->file_count * MAP_SHARE_HASH_BYTES)) + return; + body = frame_begin(connection, _frame_hello, 6 + connection->file_count * MAP_SHARE_HASH_BYTES); + put_long(body, FRAME_MAGIC); + body[4] = FRAME_VERSION; + body[5] = (byte)connection->file_count; + for (index = 0; index < connection->file_count; index++) + csmemcpy(body + 6 + index * MAP_SHARE_HASH_BYTES, connection->file_hashes[index], MAP_SHARE_HASH_BYTES); +} + +/* what this end has of a connection's file, once it knows */ +static void send_state( + struct connection *connection, + short index) +{ + struct download *download = &map_share_globals.download; + short download_file = connection->download_file[index]; + byte *body; + + if (connection->catalog_file[index] != NONE) + { + if (!frame_room(connection, 2)) + return; + body = frame_begin(connection, _frame_state, 2); + body[0] = (byte)index; + body[1] = _have_all; + } + else if (download_file != NONE && download->files[download_file].state == _file_transferring) + { + struct download_file *file = &download->files[download_file]; + long bytes = (file->piece_count + 7) / 8; + + if (!frame_room(connection, 2 + bytes)) + return; + body = frame_begin(connection, _frame_state, 2 + bytes); + body[0] = (byte)index; + body[1] = _have_pieces; + csmemcpy(body + 2, file->have, bytes); + } + else if (download_file != NONE && download->files[download_file].state == _file_complete) + { + if (!frame_room(connection, 2)) + return; + body = frame_begin(connection, _frame_state, 2); + body[0] = (byte)index; + body[1] = _have_all; + } + else + { + /* (not known yet: said once it is) */ + if (download_file != NONE || !current_files_hashed()) + return; + if (!frame_room(connection, 2)) + return; + body = frame_begin(connection, _frame_state, 2); + body[0] = (byte)index; + body[1] = _have_none; + } + connection->state_told[index] = TRUE; +} + +/* the connection's files matched to this machine's: the download's, and +those it sends */ +static void connection_match_files( + struct connection *connection) +{ + short index; + + for (index = 0; index < connection->file_count; index++) + { + short catalog_file = current_file_with_hash(connection->file_hashes[index]); + + connection->download_file[index] = download_file_with_hash(connection->file_hashes[index]); + if (catalog_file != connection->catalog_file[index] && map_share_sending()) + { + connection->catalog_file[index] = catalog_file; + /* (had now: said again) */ + if (catalog_file != NONE) + connection->state_told[index] = FALSE; + } + } +} + +/* ---------- connections */ + +static struct connection *connection_new( + void) +{ + struct map_share_globals *globals = &map_share_globals; + short index; + short file; + + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + { + struct connection *connection = &globals->connections[index]; + + if (!connection->used) + { + csmemset(connection, 0, sizeof(*connection)); + connection->input = malloc(INPUT_BYTES); + connection->output = malloc(OUTPUT_BYTES); + if (!connection->input || !connection->output) + { + free(connection->input); + free(connection->output); + connection->input = NULL; + connection->output = NULL; + return NULL; + } + connection->used = TRUE; + connection->source = NONE; + connection->created_at = system_milliseconds(); + connection->heard_at = connection->created_at; + connection->sent_at = connection->created_at; + for (file = 0; file < MAXIMUM_FILES; file++) + { + connection->download_file[file] = NONE; + connection->catalog_file[file] = NONE; + connection->hashes_next[file] = NONE; + } + return connection; + } + } + return NULL; +} + +static void connection_free( + struct connection *connection) +{ + struct download *download = &map_share_globals.download; + short index; + + for (index = 0; index < connection->request_count; index++) + { + struct request *request = &connection->requests[index]; + + if (request->file < download->file_count && download->files[request->file].asked && + download->files[request->file].asked[request->piece]) + { + download->files[request->file].asked[request->piece]--; + } + free(request->buffer); + } + for (index = 0; index < MAXIMUM_FILES; index++) + { + free(connection->remote_pieces[index]); + if (index < download->file_count && download->files[index].hashes_connection == + (short)(connection - map_share_globals.connections)) + { + download->files[index].hashes_connection = NONE; + } + } + if (connection->endpoint) + delete_transport_endpoint(connection->endpoint); + free(connection->input); + free(connection->output); + csmemset(connection, 0, sizeof(*connection)); +} + +static void connection_close( + struct connection *connection, + char const *reason) +{ + struct download *download = &map_share_globals.download; + + if (!connection->used) + return; + if (reason || connection->bytes_in || connection->bytes_out) + { + error(_error_silent, "map sharing: the connection with %d.%d.%d.%d closed: %s (%lu bytes in, %lu out)", + (int)(connection->address >> 24), (int)(connection->address >> 16 & 255), + (int)(connection->address >> 8 & 255), (int)(connection->address & 255), reason ? reason : "done", + connection->bytes_in, connection->bytes_out); + } + if (connection->source != NONE && connection->source < download->source_count) + download->sources[connection->source].connection = NONE; + connection->source = NONE; + connection_free(connection); +} + +static void connection_set_files( + struct connection *connection) +{ + struct download *download = &map_share_globals.download; + short index; + + connection->file_count = download->file_count; + for (index = 0; index < download->file_count; index++) + csmemcpy(connection->file_hashes[index], download->files[index].hash, MAP_SHARE_HASH_BYTES); +} + +/* connects to a source (its address known: host byte order), without +waiting: the connect is looked at each frame (connection_connected; the +transport's own waits up to ten seconds, or has a thread of its own) */ +static void connection_connect( + struct connection *connection, + unsigned long address) +{ + struct sockaddr_in remote; + unsigned long nonblocking = 1; + SOCKET socket_handle; + + connection->address = address; + connection->endpoint = create_transport_endpoint(_map_share_endpoint_tcp); + if (!connection->endpoint) + { + connection_close(connection, "no endpoint"); + return; + } + socket_handle = socket(AF_INET, SOCK_STREAM, 0); + if (socket_handle == INVALID_SOCKET) + { + connection_close(connection, "no socket"); + return; + } + connection->endpoint->socket = (long)socket_handle; + csmemset(&remote, 0, sizeof(remote)); + remote.sin_family = AF_INET; + remote.sin_port = (word)SWAP2(MAP_SHARE_PORT); + remote.sin_addr.s_addr = network_order(address); + if (ioctlsocket(socket_handle, FIONBIO, &nonblocking) != 0 || + (connect(socket_handle, (struct sockaddr *)&remote, sizeof(remote)) != 0 && + WSAGetLastError() != WSAEWOULDBLOCK)) + { + connection_close(connection, "could not connect"); + return; + } + SET_FLAG(connection->endpoint->flags, _transport_endpoint_nonblocking_bit, TRUE); + connection->state = _connection_connecting; +} + +/* whether the connect is over: TRUE when it is, *connected saying how */ +static boolean connection_connected( + struct connection *connection, + boolean *connected) +{ + SOCKET socket_handle = (SOCKET)connection->endpoint->socket; + fd_set writeable; + fd_set failed; + struct timeval timeout; + long ready; + + writeable.fd_array[0] = socket_handle; + writeable.fd_count = 1; + failed.fd_array[0] = socket_handle; + failed.fd_count = 1; + timeout.tv_sec = 0; + timeout.tv_usec = 0; + /* (a failed connect is in the error set: transport_endpoint_winsock.c) */ + ready = select(1, NULL, &writeable, &failed, &timeout); + if (ready < 0) + { + *connected = FALSE; + return TRUE; + } + if (!ready) + return FALSE; + *connected = !(failed.fd_count && __WSAFDIsSet(socket_handle, &failed)); + if (*connected) + SET_FLAG(connection->endpoint->flags, _transport_endpoint_connected_bit, TRUE); + return TRUE; +} + +static void connection_accept( + void) +{ + struct map_share_globals *globals = &map_share_globals; + struct transport_endpoint *endpoint; + struct connection *connection; + struct transport_address address; + short count; + short index; + + while (globals->listener && endpoint_readable(globals->listener, 0)) + { + count = 0; + endpoint = accept_endpoint(globals->listener); + if (!endpoint) + return; + csmemset(&address, 0, sizeof(address)); + if (set_endpoint_blocking(endpoint, FALSE) != _transport_error_none || + get_endpoint_address(endpoint, &address) != _transport_error_none || + (!map_share_sending() && map_share_globals.download.state != _download_transferring)) + { + delete_transport_endpoint(endpoint); + continue; + } + /* (a few from one address: anyone can connect) */ + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + { + count += globals->connections[index].used && + globals->connections[index].address == address.address.long_words[0]; + } + connection = count < MAXIMUM_CONNECTIONS_PER_ADDRESS ? connection_new() : NULL; + if (!connection) + { + delete_transport_endpoint(endpoint); + continue; + } + connection->endpoint = endpoint; + connection->address = address.address.long_words[0]; + connection->state = _connection_open; + connection->outgoing = FALSE; + } +} + +/* the listener for the others' connections: while this machine is in a +network game and sends maps or downloads one */ +static void listener_update( + void) +{ + struct map_share_globals *globals = &map_share_globals; + boolean want = map_share_network_game() && + (map_share_sending() || globals->download.state == _download_transferring); + unsigned long now = system_milliseconds(); + + if (!want) + { + if (globals->listener) + { + delete_transport_endpoint(globals->listener); + globals->listener = NULL; + } + return; + } + if (globals->listener || (globals->listen_tried_at && !elapsed(globals->listen_tried_at, now, LISTEN_RETRY_INTERVAL))) + return; + globals->listen_tried_at = now | 1; + globals->listener = create_transport_endpoint(_map_share_endpoint_tcp); + if (globals->listener) + { + struct transport_address address; + + csmemset(&address, 0, sizeof(address)); + address.address_length = IPV4_ADDRESS_LENGTH; + address.port = MAP_SHARE_PORT; + if (bind_endpoint(globals->listener, &address) != _transport_error_none || + listen_endpoint(globals->listener) != _transport_error_none || + set_endpoint_blocking(globals->listener, FALSE) != _transport_error_none) + { + if (!globals->listen_failed_logged) + { + error(_error_silent, "map sharing: cannot take connections on port %d (in use?)", MAP_SHARE_PORT); + globals->listen_failed_logged = TRUE; + } + delete_transport_endpoint(globals->listener); + globals->listener = NULL; + } + } +} + +/* ---------- the download */ + +static void download_report( + byte state); +static void request_remove( + struct connection *connection, + short index); + +/* the download over: its files closed (a part file is kept, to be taken +up again), and the connections it made closed, unless they are kept for +what this machine sends now that it has the files (keep_connections) */ +static void download_end( + boolean keep_connections) +{ + struct map_share_globals *globals = &map_share_globals; + struct download *download = &globals->download; + short index; + + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + { + struct connection *connection = &globals->connections[index]; + short file; + + if (!connection->used) + continue; + connection->source = NONE; + if (connection->outgoing && !keep_connections) + { + connection_close(connection, NULL); + continue; + } + /* (another's connection, which may still fetch what this machine + sends: what it asked of the download let go) */ + while (connection->request_count) + request_remove(connection, (short)(connection->request_count - 1)); + for (file = 0; file < MAXIMUM_FILES; file++) + connection->download_file[file] = NONE; + } + for (index = 0; index < download->file_count; index++) + { + struct download_file *file = &download->files[index]; + + if (file->stream) + fclose(file->stream); + free(file->piece_hashes); + free(file->hashes_coming); + free(file->have); + free(file->asked); + } + csmemset(download, 0, sizeof(*download)); +} + +/* the download failed: the player told why when the main menu comes, and +the game left */ +static void download_fail( + char const *reason) +{ + struct download *download = &map_share_globals.download; + wchar_t text[512]; + char message[512]; + short index; + + error(_error_silent, "map sharing: the download of %s failed: %s", download->level_name, reason); + snprintf(message, sizeof(message), "%s\r\n\r\n%s", download->message, reason); + for (index = 0; message[index] && index < NUMBEROF(text) - 1; index++) + text[index] = (wchar_t)(unsigned char)message[index]; + text[index] = 0; + download_report(_progress_failed); + download_end(FALSE); + download->state = _download_failed; + display_error_text_when_main_menu_loaded(text); + network_game_abort(); +} + +static void download_report( + byte state) +{ + struct download *download = &map_share_globals.download; + struct + { + struct distributed_message_header header; + struct distributed_map_share_progress progress; + } message; + + csmemset(&message, 0, sizeof(message)); + map_share_fill_header(&message.header, _distributed_message_map_share_progress, (word)sizeof(message)); + message.progress.state = state; + message.progress.done = download->done_bytes; + message.progress.total = download->total_bytes; + network_distributed_client_send_reliably(&message, (word)sizeof(message)); + download->reported_at = system_milliseconds(); +} + +static void download_ask( + void) +{ + struct download *download = &map_share_globals.download; + struct + { + struct distributed_message_header header; + struct distributed_map_share_request request; + } message; + + csmemset(&message, 0, sizeof(message)); + map_share_fill_header(&message.header, _distributed_message_map_share_request, (word)sizeof(message)); + level_name_copy(message.request.level_name, download->level_name); + message.request.version = download->version; + message.request.wanted = download->wanted; + network_distributed_client_send_reliably(&message, (word)sizeof(message)); +} + +/* the files this machine lacks for the level: a bit each */ +static byte files_wanted( + char const *level_name) +{ + char path[MAP_SHARE_PATH_SIZE]; + byte wanted = 0; + short kind; + + if (!custom_edition_map_file_path(level_name, path)) + wanted |= FLAG(_map_share_file_map); + for (kind = _map_share_file_bitmaps; kind < MAXIMUM_FILES; kind++) + { + if (!custom_edition_resource_map_file_path(kind, path)) + wanted |= FLAG(kind); + } + return wanted; +} + +boolean map_share_download_begin( + char const *level_name, + unsigned long version, + char const *message) +{ + struct download *download = &map_share_globals.download; + char const *name; + byte wanted; + + if (!level_name || !custom_edition_level_name(level_name) || csstrlen(level_name) >= LEVEL_NAME_BYTES || + !config_boolean("network.map_download") || map_share_host() || !global_network_game_client_get() || + !halo_custom_edition_tag_cache()) + { + return FALSE; + } + if (download->state == _download_asking || download->state == _download_transferring) + { + if (!csstrcmp(download->level_name, level_name) && download->version == version) + return TRUE; + download_end(FALSE); + } + name = tag_name_strip_path(level_name); + wanted = files_wanted(level_name); + /* (the map named for its hash, so that the download is the host's + version under its own name, a file of custom_maps alone, whatever the + host's level name holds; a map this machine has of another version is + not replaced) */ + if (!wanted || !map_share_name_safe(name) || + (TEST_FLAG(wanted, _map_share_file_map) && !map_share_name_hashed(name)) || + (!TEST_FLAG(wanted, _map_share_file_map) && custom_edition_map_checksum(level_name) != version && version)) + { + return FALSE; + } + { + static long serial; + + csmemset(download, 0, sizeof(*download)); + download->serial = ++serial; + } + download->state = _download_asking; + level_name_copy(download->level_name, level_name); + download->version = version; + csstrncpy(download->message, message ? message : "", sizeof(download->message) - 1); + download->wanted = wanted; + download->asked_at = system_milliseconds(); + download->progress_at = download->asked_at; + error(_error_silent, "map sharing: asking the host for %s (files 0x%X)", level_name, (unsigned)wanted); + download_ask(); + return TRUE; +} + +boolean map_share_downloading( + char const *level_name) +{ + struct download *download = &map_share_globals.download; + + return (download->state == _download_asking || download->state == _download_transferring) && + level_name && !csstrcmp(download->level_name, level_name); +} + +boolean map_share_holds_players( + void) +{ + struct download *download = &map_share_globals.download; + struct network_game *game = map_share_network_game(); + + /* (a client whose host has not named the game's map yet: whether this + machine has it is not known, and a game in progress would start it at + once. Asking: whether the game is in progress is not known yet) */ + if (!map_share_host() && game && !game->map.name[0]) + return TRUE; + /* (failed: the game is being left) */ + return download->state == _download_asking || download->state == _download_failed || + (download->state == _download_transferring && download->in_progress); +} + +/* the download's file made anew for writing: its part file the file's size */ +static boolean download_file_create( + struct download_file *file) +{ + FILE *stream = fopen(file->part_path, "wb"); + + if (!stream) + return FALSE; + if (file->size && (fseek(stream, (long)file->size - 1, SEEK_SET) != 0 || fputc(0, stream) == EOF)) + { + fclose(stream); + return FALSE; + } + fclose(stream); + file->stream = fopen(file->part_path, "r+b"); + if (!file->stream) + return FALSE; + file->state = _file_transferring; + return TRUE; +} + +/* the download's file opened for writing: a part file of its size there +already checked piece by piece first (on the hashing thread, asked again +while it is busy: download_update), else one made anew */ +static boolean download_file_open( + struct download_file *file, + short index) +{ + FILE *stream = fopen(file->part_path, "rb"); + long size = -1; + + if (stream) + { + if (fseek(stream, 0, SEEK_END) == 0) + size = ftell(stream); + fclose(stream); + } + if (size == (long)file->size && file->size) + { + file->state = _file_checking; + map_share_pieces_hash(file->part_path, map_share_globals.download.serial << 3 | index); + return TRUE; + } + return download_file_create(file); +} + +/* the host's offer: the files to fetch */ +static void download_offered( + struct distributed_map_share_offer const *offer, + struct distributed_map_share_offer_file const *files) +{ + struct download *download = &map_share_globals.download; + char const *name = tag_name_strip_path(download->level_name); + byte offered = 0; + short index; + + if (download->state != _download_asking || csstrcmp(offer->level_name, download->level_name)) + return; + if (offer->result == _offer_off) + { + download_fail("The host does not share its maps."); + return; + } + if (offer->result != _offer_ok || !offer->file_count || offer->file_count > MAXIMUM_FILES) + { + download_fail("The host cannot share this map."); + return; + } + download->in_progress = TEST_FLAG(offer->flags, _offer_in_progress_bit); + download->file_count = offer->file_count; + for (index = 0; index < download->file_count; index++) + { + struct download_file *file = &download->files[index]; + char digits[MAP_SHARE_NAME_DIGITS + 1]; + + csmemset(file, 0, sizeof(*file)); + file->kind = files[index].kind; + file->size = files[index].size; + file->hashes_connection = NONE; + csmemcpy(file->hash, files[index].hash, MAP_SHARE_HASH_BYTES); + file->piece_count = map_share_piece_count(file->size); + map_share_hash_digits(file->hash, digits); + /* (a file this machine did not ask for, or offered twice, or a map + whose hash is not its name's) */ + if (file->kind >= MAXIMUM_FILES || !TEST_FLAG(download->wanted, file->kind) || + TEST_FLAG(offered, file->kind) || !file->size || + file->size > (unsigned long)(file->kind == _map_share_file_map ? MAXIMUM_MAP_BYTES : + MAXIMUM_RESOURCE_MAP_BYTES) || + (file->kind == _map_share_file_map && csstrcmp(name + map_share_name_base_length(name) + 1, digits))) + { + download->file_count = index; + download_fail("The host's offer was not of this map."); + return; + } + offered |= (byte)FLAG(file->kind); + if (file->kind == _map_share_file_map) + { + snprintf(file->final_path, sizeof(file->final_path), "%s%s.map", CUSTOM_EDITION_MAP_DIRECTORY, name); + } + else + { + snprintf(file->final_path, sizeof(file->final_path), "%s%s.map", CUSTOM_EDITION_MAP_DIRECTORY, + resource_map_type_describe((enum resource_map_type)file->kind)); + } + snprintf(file->part_path, sizeof(file->part_path), "%s.part", file->final_path); + file->have = calloc((unsigned long)(file->piece_count + 7) / 8, 1); + file->asked = calloc((unsigned long)file->piece_count, 1); + if (!file->have || !file->asked) + { + download->file_count = index + 1; + download_fail("There is not enough memory to download the map."); + return; + } + download->total_bytes += file->size; + } + /* (the hashes come first: no piece can be checked without them) */ + for (index = 0; index < download->file_count; index++) + download->files[index].state = _file_awaiting_hashes; + download->state = _download_transferring; + download->progress_at = system_milliseconds(); + error(_error_silent, "map sharing: downloading %s, %d files, %lu bytes", download->level_name, + (int)download->file_count, download->total_bytes); +} + +/* the host's sources: the client's to reach */ +static void download_sources( + struct distributed_map_share_sources const *sources, + struct distributed_map_share_source const *entries) +{ + struct download *download = &map_share_globals.download; + short index; + + if (download->state != _download_asking && download->state != _download_transferring) + return; + download->in_progress = TEST_FLAG(sources->flags, _offer_in_progress_bit); + /* the host, first (its address, the connection's) */ + if (!download->source_count) + { + csmemset(&download->sources[0], 0, sizeof(download->sources[0])); + download->sources[0].host = TRUE; + download->sources[0].address = map_share_host_address(); + download->sources[0].connection = NONE; + download->source_count = 1; + } + for (index = 0; index < sources->count && index < MAXIMUM_SOURCES; index++) + { + struct distributed_map_share_source const *entry = &entries[index]; + struct source *source = NULL; + short known; + + if (entry->machine_index == network_game_client_get_local_machine_index()) + continue; + for (known = 1; known < download->source_count; known++) + { + if (download->sources[known].machine_index == entry->machine_index && + !csmemcmp(download->sources[known].key_hash, entry->key_hash, sizeof(entry->key_hash)) && + download->sources[known].address == entry->address) + { + source = &download->sources[known]; + break; + } + } + if (!source) + { + if (download->source_count >= NUMBEROF(download->sources)) + continue; + source = &download->sources[download->source_count++]; + csmemset(source, 0, sizeof(*source)); + source->machine_index = entry->machine_index; + source->address = entry->address; + csmemcpy(source->identifier, entry->identifier, sizeof(source->identifier)); + csmemcpy(source->key_hash, entry->key_hash, sizeof(source->key_hash)); + source->connection = NONE; + } + source->flags = entry->flags; + } +} + +/* (internet play: the source reached, and its address) */ +static boolean source_address( + struct source *source, + unsigned long *address) +{ + unsigned long virtual_address; + + if (source->host) + { + *address = source->address; + return *address != 0; + } + if (!TEST_FLAG(source->flags, _source_internet_bit)) + { + *address = source->address; + return TRUE; + } + if (!p2p_peer_reached(source->identifier, &virtual_address)) + return FALSE; + *address = host_order(virtual_address); + return TRUE; +} + +/* the sources reached: the host, and up to MAXIMUM_OUTGOING others */ +static void download_reach_sources( + void) +{ + struct map_share_globals *globals = &map_share_globals; + struct download *download = &globals->download; + unsigned long host_address = map_share_host_address(); + boolean internet = address_internet(host_address); + short local_machine = network_game_client_get_local_machine_index(); + unsigned long now = system_milliseconds(); + short outgoing = 0; + short index; + + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + outgoing += globals->connections[index].used && globals->connections[index].outgoing; + for (index = 0; index < download->source_count && outgoing < MAXIMUM_OUTGOING + 1; index++) + { + struct source *source = &download->sources[index]; + struct connection *connection; + unsigned long address; + + if (source->banned || source->connection != NONE || + (source->tried_at && !elapsed(source->tried_at, now, SOURCE_RETRY_INTERVAL))) + { + continue; + } + if (!source->host) + { + /* (the internet's players over internet play alone, a LAN's at + their addresses alone; of two downloading, the one of the lower + machine connects) */ + if (internet != TEST_FLAG(source->flags, _source_internet_bit)) + continue; + if (TEST_FLAG(source->flags, _source_downloading_bit) && source->machine_index < local_machine) + continue; + } + if (!source_address(source, &address)) + { + if (TEST_FLAG(source->flags, _source_internet_bit)) + p2p_reach_player(source->key_hash); + continue; + } + source->tried_at = now | 1; + connection = connection_new(); + if (!connection) + return; + connection->outgoing = TRUE; + connection->source = index; + source->connection = (short)(connection - globals->connections); + connection_set_files(connection); + connection_connect(connection, address); + outgoing++; + } +} + +/* a piece had: written, told to every connection about its file */ +static void download_piece_had( + short file_index, + long piece, + byte const *data) +{ + struct map_share_globals *globals = &map_share_globals; + struct download *download = &globals->download; + struct download_file *file = &download->files[file_index]; + unsigned long offset = (unsigned long)piece * MAP_SHARE_PIECE_BYTES; + unsigned long bytes = MIN(MAP_SHARE_PIECE_BYTES, file->size - offset); + short index; + + if (file->have[piece >> 3] & (1 << (piece & 7))) + return; + if (!file->stream || fseek(file->stream, (long)offset, SEEK_SET) != 0 || + fwrite(data, 1, bytes, file->stream) != bytes) + { + download_fail("The map could not be written (is the disk full?)."); + return; + } + file->have[piece >> 3] |= (byte)(1 << (piece & 7)); + file->have_count++; + download->done_bytes += bytes; + download->window_bytes += bytes; + download->progress_at = system_milliseconds(); + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + { + struct connection *connection = &globals->connections[index]; + short connection_file; + + if (!connection->used || connection->state != _connection_open || !connection->hello_heard) + continue; + for (connection_file = 0; connection_file < connection->file_count; connection_file++) + { + if (connection->download_file[connection_file] == file_index) + frame_piece(connection, _frame_have, connection_file, piece); + } + } +} + +/* the download's files, all had: renamed, the map checked */ +static void download_finish( + void) +{ + struct download *download = &map_share_globals.download; + char message[256]; + short index; + + for (index = 0; index < download->file_count; index++) + { + struct download_file *file = &download->files[index]; + + if (file->stream) + { + fclose(file->stream); + file->stream = NULL; + } + if (!MoveFileA(file->part_path, file->final_path)) + { + download_fail("The downloaded map could not be put in custom_maps (a file of its name is there)."); + return; + } + } + custom_edition_maps_look_again(); + if (!custom_edition_cache_present(download->level_name, download->version, message, sizeof(message))) + { + csstrncpy(download->message, message, sizeof(download->message) - 1); + download_fail("The map was downloaded, but cannot be played."); + return; + } + error(_error_silent, "map sharing: downloaded %s (%lu bytes)", download->level_name, download->done_bytes); + download_report(_progress_done); + /* (sent on to the others from here, without hashing them again) */ + for (index = 0; index < download->file_count; index++) + { + struct download_file *file = &download->files[index]; + + map_share_file_hashed(file->final_path, file->kind, file->size, file->piece_hashes, file->piece_count, + file->hash); + } + download_end(TRUE); + download->state = _download_done; + if (map_share_sending()) + current_files_update(TRUE); +} + +/* the rarest piece of a file the connection has that this machine needs +and has not asked anyone for (any it has not asked this connection for, +once every piece has been asked for: the last few from whoever is +quickest), or NONE */ +static long piece_choose( + struct connection *connection, + short connection_file, + boolean again) +{ + struct map_share_globals *globals = &map_share_globals; + struct download_file *file = &globals->download.files[connection->download_file[connection_file]]; + long best = NONE; + short best_count = 0x7FFF; + long start; + long step; + + if (!file->piece_count) + return NONE; + /* (from a random place, so the machines downloading ask for different + pieces of the same rarity) */ + start = (long)(((unsigned long)rand() << 15 ^ (unsigned long)rand()) % (unsigned long)file->piece_count); + for (step = 0; step < file->piece_count; step++) + { + long piece = (start + step) % file->piece_count; + short count = 0; + short index; + + if ((file->have[piece >> 3] & (1 << (piece & 7))) || (!again && file->asked[piece]) || + (again && file->asked[piece] >= 2)) + { + continue; + } + if (connection->remote_has[connection_file] != _have_all && + !(connection->remote_has[connection_file] == _have_pieces && connection->remote_pieces[connection_file] && + (connection->remote_pieces[connection_file][piece >> 3] & (1 << (piece & 7))))) + { + continue; + } + for (index = 0; index < connection->request_count; index++) + { + if (connection->requests[index].file == connection->download_file[connection_file] && + connection->requests[index].piece == piece) + { + break; + } + } + if (index < connection->request_count) + continue; + /* (its rarity: how many of the connections have it, each by its own + index of the file) */ + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + { + struct connection const *other = &globals->connections[index]; + short other_file; + + if (!other->used || other->state != _connection_open || !other->hello_heard) + continue; + for (other_file = 0; other_file < other->file_count; other_file++) + { + if (other->download_file[other_file] == connection->download_file[connection_file]) + break; + } + if (other_file < other->file_count && + (other->remote_has[other_file] == _have_all || + (other->remote_has[other_file] == _have_pieces && other->remote_pieces[other_file] && + (other->remote_pieces[other_file][piece >> 3] & (1 << (piece & 7)))))) + { + count++; + } + } + if (count < best_count) + { + best = piece; + best_count = count; + } + } + return best; +} + +/* whether any piece needed has not been asked for */ +static boolean download_unasked_pieces( + void) +{ + struct download *download = &map_share_globals.download; + short index; + + for (index = 0; index < download->file_count; index++) + { + struct download_file const *file = &download->files[index]; + long piece; + + if (file->state != _file_transferring) + { + if (file->state != _file_complete) + return TRUE; + continue; + } + for (piece = 0; piece < file->piece_count; piece++) + { + if (!(file->have[piece >> 3] & (1 << (piece & 7))) && !file->asked[piece]) + return TRUE; + } + } + return FALSE; +} + +/* the connection asked for pieces, while it may */ +static void connection_ask( + struct connection *connection) +{ + struct download *download = &map_share_globals.download; + long in_flight = 0; + short index; + + if (download->state != _download_transferring || !connection->hello_heard) + return; + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + in_flight += map_share_globals.connections[index].used ? map_share_globals.connections[index].request_count : 0; + while (connection->request_count < REQUESTS_PER_CONNECTION && in_flight < MAXIMUM_PIECES_IN_FLIGHT && + frame_room(connection, 5)) + { + struct request *request; + short connection_file; + long piece = NONE; + short again; + + for (again = 0; again < 2 && piece == NONE; again++) + { + /* (the last pieces asked again of whoever is quicker, once + every piece needed has been asked for) */ + if (again && download_unasked_pieces()) + break; + for (connection_file = 0; connection_file < connection->file_count; connection_file++) + { + short file_index = connection->download_file[connection_file]; + + if (file_index == NONE || download->files[file_index].state != _file_transferring) + continue; + piece = piece_choose(connection, connection_file, again != 0); + if (piece != NONE) + break; + } + } + if (piece == NONE) + return; + request = &connection->requests[connection->request_count]; + request->buffer = malloc(MAP_SHARE_PIECE_BYTES); + if (!request->buffer) + return; + request->file = connection->download_file[connection_file]; + request->piece = piece; + request->received = 0; + request->asked_at = system_milliseconds(); + download->files[request->file].asked[piece]++; + connection->request_count++; + in_flight++; + frame_piece(connection, _frame_request, connection_file, piece); + } +} + +/* the request of the connection's at `index` done with (had, refused or +given up on) */ +static void request_remove( + struct connection *connection, + short index) +{ + struct download *download = &map_share_globals.download; + struct request *request = &connection->requests[index]; + + if (request->file < download->file_count && download->files[request->file].asked && + download->files[request->file].asked[request->piece]) + { + download->files[request->file].asked[request->piece]--; + } + free(request->buffer); + connection->request_count--; + csmemmove(request, request + 1, (connection->request_count - index) * sizeof(*request)); + /* (the next is sent now: its time from here) */ + if (!index && connection->request_count) + connection->requests[0].asked_at = system_milliseconds(); +} + +/* ---------- a connection's frames, as they come */ + +static void frame_hello( + struct connection *connection, + byte const *body, + long size) +{ + short count; + short index; + + if (size < 6 || get_long(body) != FRAME_MAGIC || body[4] != FRAME_VERSION || connection->hello_heard) + { + connection_close(connection, "not a map sharing connection"); + return; + } + count = body[5]; + if (count < 1 || count > MAXIMUM_FILES || size != 6 + count * MAP_SHARE_HASH_BYTES) + { + connection_close(connection, "a bad hello"); + return; + } + /* (the end that connected says which files; this end repeats them) */ + if (!connection->outgoing) + { + connection->file_count = count; + for (index = 0; index < count; index++) + csmemcpy(connection->file_hashes[index], body + 6 + index * MAP_SHARE_HASH_BYTES, MAP_SHARE_HASH_BYTES); + send_hello(connection); + /* (asked for files this machine may have: those it sends hashed, to + know) */ + if (map_share_sending()) + current_files_update(TRUE); + } + else if (count != connection->file_count || + csmemcmp(connection->file_hashes, body + 6, count * MAP_SHARE_HASH_BYTES)) + { + connection_close(connection, "another file's hello"); + return; + } + connection->hello_heard = TRUE; + connection_match_files(connection); +} + +static void frame_state( + struct connection *connection, + byte const *body, + long size) +{ + struct download *download = &map_share_globals.download; + short index; + + if (size < 2 || body[0] >= connection->file_count) + return; + index = body[0]; + free(connection->remote_pieces[index]); + connection->remote_pieces[index] = NULL; + connection->remote_has[index] = _have_none; + connection->state_heard[index] = TRUE; + if (body[1] == _have_all) + { + connection->remote_has[index] = _have_all; + } + else if (body[1] == _have_pieces) + { + /* (which pieces only matters to a machine downloading the file too: + to another, the end is downloading it) */ + connection->remote_has[index] = _have_pieces; + if (connection->download_file[index] != NONE) + { + long bytes = (download->files[connection->download_file[index]].piece_count + 7) / 8; + + connection->remote_pieces[index] = size == 2 + bytes ? malloc(bytes) : NULL; + if (connection->remote_pieces[index]) + csmemcpy(connection->remote_pieces[index], body + 2, bytes); + } + } +} + +static void frame_have( + struct connection *connection, + byte const *body, + long size) +{ + struct download *download = &map_share_globals.download; + struct download_file *file; + short index; + long piece; + + if (size != 5 || body[0] >= connection->file_count || connection->download_file[body[0]] == NONE) + return; + index = body[0]; + file = &download->files[connection->download_file[index]]; + piece = (long)get_long(body + 1); + if (piece < 0 || piece >= file->piece_count || connection->remote_has[index] == _have_all) + return; + if (!connection->remote_pieces[index]) + { + connection->remote_pieces[index] = calloc((unsigned long)(file->piece_count + 7) / 8, 1); + if (!connection->remote_pieces[index]) + return; + } + connection->remote_pieces[index][piece >> 3] |= (byte)(1 << (piece & 7)); + connection->remote_has[index] = _have_pieces; +} + +/* whether this machine has the connection's file's piece, to send */ +static boolean piece_sendable( + struct connection *connection, + short index, + long piece) +{ + struct download *download = &map_share_globals.download; + struct map_share_file *catalog_file = map_share_file_get(connection->catalog_file[index]); + + if (catalog_file && catalog_file->ready && map_share_sending()) + return piece >= 0 && piece < catalog_file->piece_count; + if (connection->download_file[index] != NONE) + { + struct download_file *file = &download->files[connection->download_file[index]]; + + return piece >= 0 && piece < file->piece_count && (file->have[piece >> 3] & (1 << (piece & 7))); + } + return FALSE; +} + +static void frame_request( + struct connection *connection, + byte const *body, + long size) +{ + long piece; + + if (size != 5 || body[0] >= connection->file_count) + return; + piece = (long)get_long(body + 1); + if (!piece_sendable(connection, body[0], piece) || connection->serving_count >= SERVING_PER_CONNECTION) + { + frame_piece(connection, _frame_refuse, body[0], piece); + return; + } + connection->serving[connection->serving_count].file = body[0]; + connection->serving[connection->serving_count].piece = piece; + connection->serving[connection->serving_count].offset = 0; + connection->serving_count++; +} + +static void frame_block( + struct connection *connection, + byte const *body, + long size) +{ + struct download *download = &map_share_globals.download; + struct download_file *file; + struct request *request = NULL; + unsigned long offset; + unsigned long bytes; + unsigned long piece_bytes; + byte hash[MAP_SHARE_HASH_BYTES]; + short index; + long piece; + + if (size < 9 || body[0] >= connection->file_count || connection->download_file[body[0]] == NONE) + return; + piece = (long)get_long(body + 1); + offset = get_long(body + 5); + bytes = (unsigned long)(size - 9); + for (index = 0; index < connection->request_count; index++) + { + if (connection->requests[index].file == connection->download_file[body[0]] && + connection->requests[index].piece == piece) + { + request = &connection->requests[index]; + break; + } + } + if (!request) + return; + file = &download->files[request->file]; + piece_bytes = MIN(MAP_SHARE_PIECE_BYTES, file->size - (unsigned long)piece * MAP_SHARE_PIECE_BYTES); + if (offset != request->received || bytes > piece_bytes - offset || !bytes) + { + connection_close(connection, "a block out of place"); + return; + } + csmemcpy(request->buffer + offset, body + 9, bytes); + request->received += bytes; + connection->bytes_in += bytes; + request->asked_at = system_milliseconds(); + if (request->received < piece_bytes) + return; + p2p_blake2b_256(request->buffer, piece_bytes, hash); + if (!file->piece_hashes || csmemcmp(hash, file->piece_hashes + piece * MAP_SHARE_HASH_BYTES, MAP_SHARE_HASH_BYTES)) + { + if (connection->source != NONE) + download->sources[connection->source].banned = TRUE; + connection_close(connection, "a piece that is not the map's"); + return; + } + download_piece_had(request->file, piece, request->buffer); + /* (the download may have ended) */ + if (connection->used && index < connection->request_count) + request_remove(connection, index); +} + +static void frame_refuse( + struct connection *connection, + byte const *body, + long size) +{ + short index; + long piece; + + if (size != 5 || body[0] >= connection->file_count) + return; + piece = (long)get_long(body + 1); + for (index = 0; index < connection->request_count; index++) + { + if (connection->requests[index].file == connection->download_file[body[0]] && + connection->requests[index].piece == piece) + { + request_remove(connection, index); + /* (what it has is not what it said: asked again only by what it + says next) */ + if (connection->remote_has[body[0]] == _have_all) + connection->remote_has[body[0]] = _have_none; + else if (connection->remote_pieces[body[0]]) + connection->remote_pieces[body[0]][piece >> 3] &= (byte)~(1 << (piece & 7)); + return; + } + } +} + +static void frame_hashes_request( + struct connection *connection, + byte const *body, + long size) +{ + if (size != 1 || body[0] >= connection->file_count) + return; + connection->hashes_next[body[0]] = 0; +} + +static void frame_hashes( + struct connection *connection, + byte const *body, + long size) +{ + struct download *download = &map_share_globals.download; + struct download_file *file; + unsigned long first; + long count; + byte hash[MAP_SHARE_HASH_BYTES]; + short connection_index = (short)(connection - map_share_globals.connections); + + if (size < 7 || body[0] >= connection->file_count || connection->download_file[body[0]] == NONE) + return; + file = &download->files[connection->download_file[body[0]]]; + first = get_long(body + 1); + count = body[5] | body[6] << 8; + if (file->piece_hashes || file->hashes_connection != connection_index || !file->hashes_coming || + size != 7 + count * MAP_SHARE_HASH_BYTES || first != (unsigned long)file->hashes_received || + count > file->piece_count - file->hashes_received) + { + return; + } + csmemcpy(file->hashes_coming + first * MAP_SHARE_HASH_BYTES, body + 7, count * MAP_SHARE_HASH_BYTES); + file->hashes_received += count; + file->hashes_asked_at = system_milliseconds(); + if (file->hashes_received < file->piece_count) + return; + /* (checked against the file's hash, the host's word) */ + map_share_file_hash_of_pieces(file->size, file->hashes_coming, file->piece_count, hash); + file->hashes_connection = NONE; + if (csmemcmp(hash, file->hash, MAP_SHARE_HASH_BYTES)) + { + file->hashes_received = 0; + if (connection->source != NONE) + download->sources[connection->source].banned = TRUE; + connection_close(connection, "pieces' hashes that are not the map's"); + return; + } + file->piece_hashes = file->hashes_coming; + file->hashes_coming = NULL; + if (!download_file_open(file, connection->download_file[body[0]])) + download_fail("The map could not be written to custom_maps."); +} + +/* the frames read in, handled */ +static void connection_read( + struct connection *connection) +{ + for (;;) + { + long result; + long offset = 0; + + if (connection->input_size < INPUT_BYTES) + { + result = read_endpoint(connection->endpoint, connection->input + connection->input_size, + INPUT_BYTES - connection->input_size); + if (result == _transport_result_operation_would_block) + result = 0; + else if (result <= 0) + { + connection_close(connection, "lost"); + return; + } + connection->input_size += result; + if (!result && connection->input_size < 5) + return; + if (result) + connection->heard_at = system_milliseconds(); + } + while (connection->used && connection->input_size - offset >= 5) + { + unsigned long length = get_long(connection->input + offset); + byte type; + byte const *body; + long size; + + if (!length || length > MAXIMUM_FRAME_BYTES) + { + connection_close(connection, "a frame too large"); + return; + } + if ((unsigned long)(connection->input_size - offset - 4) < length) + break; + type = connection->input[offset + 4]; + body = connection->input + offset + 5; + size = (long)length - 1; + offset += 4 + (long)length; + if (type != _frame_hello && !connection->hello_heard) + { + connection_close(connection, "no hello"); + return; + } + switch (type) + { + case _frame_hello: frame_hello(connection, body, size); break; + case _frame_state: frame_state(connection, body, size); break; + case _frame_have: frame_have(connection, body, size); break; + case _frame_request: frame_request(connection, body, size); break; + case _frame_block: frame_block(connection, body, size); break; + case _frame_refuse: frame_refuse(connection, body, size); break; + case _frame_hashes_request: frame_hashes_request(connection, body, size); break; + case _frame_hashes: frame_hashes(connection, body, size); break; + } + } + if (!connection->used) + return; + if (offset) + { + csmemmove(connection->input, connection->input + offset, connection->input_size - offset); + connection->input_size -= offset; + } + /* (nothing more to read, or a frame not whole yet) */ + if (connection->input_size < INPUT_BYTES) + return; + } +} + +/* the output written, as much as the connection takes */ +static void connection_flush( + struct connection *connection) +{ + while (connection->output_size > 0) + { + long result = write_endpoint(connection->endpoint, connection->output, connection->output_size); + + if (result == _transport_result_operation_would_block) + return; + if (result <= 0) + { + connection_close(connection, "lost"); + return; + } + csmemmove(connection->output, connection->output + result, connection->output_size - result); + connection->output_size -= result; + connection->sent_at = system_milliseconds(); + } +} + +/* the pieces' hashes this machine was asked for, that fit */ +static void connection_send_hashes( + struct connection *connection) +{ + struct download *download = &map_share_globals.download; + short index; + + for (index = 0; index < connection->file_count; index++) + { + byte const *hashes = NULL; + long piece_count = 0; + struct map_share_file *catalog_file = map_share_file_get(connection->catalog_file[index]); + + if (connection->hashes_next[index] == NONE) + continue; + if (catalog_file && catalog_file->ready) + { + hashes = catalog_file->piece_hashes; + piece_count = catalog_file->piece_count; + } + else if (connection->download_file[index] != NONE && + download->files[connection->download_file[index]].piece_hashes) + { + hashes = download->files[connection->download_file[index]].piece_hashes; + piece_count = download->files[connection->download_file[index]].piece_count; + } + if (!hashes) + { + /* (not known here: asked of another) */ + if (current_files_hashed()) + connection->hashes_next[index] = NONE; + continue; + } + while (connection->hashes_next[index] < piece_count) + { + long first = connection->hashes_next[index]; + long count = MIN(HASHES_PER_FRAME, piece_count - first); + byte *body; + + if (!frame_room(connection, 7 + count * MAP_SHARE_HASH_BYTES)) + return; + body = frame_begin(connection, _frame_hashes, 7 + count * MAP_SHARE_HASH_BYTES); + body[0] = (byte)index; + put_long(body + 1, (unsigned long)first); + body[5] = (byte)count; + body[6] = (byte)(count >> 8); + csmemcpy(body + 7, hashes + first * MAP_SHARE_HASH_BYTES, count * MAP_SHARE_HASH_BYTES); + connection->hashes_next[index] += count; + } + connection->hashes_next[index] = NONE; + } +} + +/* the size of a connection's file this machine sends (0: none) */ +static unsigned long serving_file_size( + struct connection *connection, + short index) +{ + struct download *download = &map_share_globals.download; + struct map_share_file *catalog_file = map_share_file_get(connection->catalog_file[index]); + + if (catalog_file && catalog_file->ready && map_share_sending()) + return catalog_file->size; + if (connection->download_file[index] != NONE) + return download->files[connection->download_file[index]].size; + return 0; +} + +/* a block of a piece asked for, read from this machine's file */ +static boolean block_read( + struct connection *connection, + struct serving const *serving, + byte *buffer, + unsigned long *bytes) +{ + struct download *download = &map_share_globals.download; + struct map_share_file *catalog_file = map_share_file_get(connection->catalog_file[serving->file]); + unsigned long size; + unsigned long offset = (unsigned long)serving->piece * MAP_SHARE_PIECE_BYTES + serving->offset; + FILE *stream; + boolean read; + + if (catalog_file && catalog_file->ready && map_share_sending()) + { + size = catalog_file->size; + stream = fopen(catalog_file->path, "rb"); + } + else if (connection->download_file[serving->file] != NONE) + { + size = download->files[connection->download_file[serving->file]].size; + stream = download->files[connection->download_file[serving->file]].stream; + if (!stream) + return FALSE; + *bytes = MIN(MAP_SHARE_BLOCK_BYTES, MIN(size - offset, MAP_SHARE_PIECE_BYTES - serving->offset)); + return fseek(stream, (long)offset, SEEK_SET) == 0 && fread(buffer, 1, *bytes, stream) == *bytes; + } + else + { + return FALSE; + } + if (!stream) + return FALSE; + *bytes = MIN(MAP_SHARE_BLOCK_BYTES, MIN(size - offset, MAP_SHARE_PIECE_BYTES - serving->offset)); + read = fseek(stream, (long)offset, SEEK_SET) == 0 && fread(buffer, 1, *bytes, stream) == *bytes; + fclose(stream); + return read; +} + +/* the pieces asked of this machine, a block at a time, round all the +connections, as the upload's allowance lets */ +static void serve_blocks( + void) +{ + struct map_share_globals *globals = &map_share_globals; + unsigned long now = system_milliseconds(); + long limit = upload_limit(); + boolean sent = TRUE; + + /* (the allowance: the limit a second, a quarter second of it at once) */ + globals->upload_tokens += (long)(MIN(now - globals->upload_at, 1000UL) * (unsigned long)limit / 1000); + globals->upload_tokens = MIN(globals->upload_tokens, MAX(limit / 4, MAP_SHARE_BLOCK_BYTES * 2)); + globals->upload_at = now; + while (sent && globals->upload_tokens >= MAP_SHARE_BLOCK_BYTES) + { + short step; + + sent = FALSE; + for (step = 0; step < MAXIMUM_CONNECTIONS && globals->upload_tokens >= MAP_SHARE_BLOCK_BYTES; step++) + { + short index = (short)((globals->next_connection + step) % MAXIMUM_CONNECTIONS); + struct connection *connection = &globals->connections[index]; + struct serving *serving; + unsigned long bytes; + byte *body; + + if (!connection->used || connection->state != _connection_open || !connection->serving_count || + !frame_room(connection, 9 + MAP_SHARE_BLOCK_BYTES)) + { + continue; + } + serving = &connection->serving[0]; + body = connection->output + connection->output_size + 5; + if (!block_read(connection, serving, body + 9, &bytes) || !bytes) + { + frame_piece(connection, _frame_refuse, serving->file, serving->piece); + connection->serving_count--; + csmemmove(serving, serving + 1, connection->serving_count * sizeof(*serving)); + continue; + } + body = frame_begin(connection, _frame_block, 9 + (long)bytes); + body[0] = (byte)serving->file; + put_long(body + 1, (unsigned long)serving->piece); + put_long(body + 5, serving->offset); + serving->offset += bytes; + connection->bytes_out += bytes; + globals->upload_tokens -= (long)bytes; + sent = TRUE; + if ((unsigned long)serving->piece * MAP_SHARE_PIECE_BYTES + serving->offset >= + serving_file_size(connection, serving->file) || serving->offset >= MAP_SHARE_PIECE_BYTES) + { + connection->serving_count--; + csmemmove(serving, serving + 1, connection->serving_count * sizeof(*serving)); + } + connection_flush(connection); + } + globals->next_connection = (short)((globals->next_connection + 1) % MAXIMUM_CONNECTIONS); + } +} + +/* each connection: connected, read, its states said, its pieces and +hashes asked for, written */ +static void connections_update( + void) +{ + struct map_share_globals *globals = &map_share_globals; + struct download *download = &globals->download; + unsigned long now = system_milliseconds(); + short index; + + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + { + struct connection *connection = &globals->connections[index]; + short file_index; + + if (!connection->used) + continue; + if (connection->state == _connection_connecting) + { + boolean connected; + + if (!connection_connected(connection, &connected)) + { + if (elapsed(connection->created_at, now, CONNECT_TIMEOUT)) + connection_close(connection, "could not connect in time"); + continue; + } + if (!connected) + { + connection_close(connection, "could not connect"); + continue; + } + connection->state = _connection_open; + connection->heard_at = now; + send_hello(connection); + } + /* (matched first: the download may have changed since) */ + if (connection->hello_heard) + connection_match_files(connection); + connection_read(connection); + if (!connection->used) + continue; + if (elapsed(connection->heard_at, now, CONNECTION_IDLE_TIMEOUT)) + { + connection_close(connection, "silent"); + continue; + } + if (!connection->hello_heard) + { + connection_flush(connection); + continue; + } + for (file_index = 0; file_index < connection->file_count; file_index++) + { + short download_file = connection->download_file[file_index]; + + if (!connection->state_told[file_index]) + send_state(connection, file_index); + /* (the pieces' hashes asked of one that has the file whole) */ + if (download_file != NONE && download->files[download_file].state == _file_awaiting_hashes && + download->files[download_file].hashes_connection == NONE && + connection->remote_has[file_index] == _have_all && frame_room(connection, 1)) + { + struct download_file *file = &download->files[download_file]; + byte *body; + + free(file->hashes_coming); + file->hashes_coming = malloc((unsigned long)MAX(file->piece_count, 1) * MAP_SHARE_HASH_BYTES); + if (!file->hashes_coming) + continue; + file->hashes_received = 0; + file->hashes_connection = index; + file->hashes_asked_at = now; + body = frame_begin(connection, _frame_hashes_request, 1); + body[0] = (byte)file_index; + } + } + /* (nothing left to give each other: the other end has every file + whole, and this one needs none of them) */ + for (file_index = 0; file_index < connection->file_count; file_index++) + { + short download_file = connection->download_file[file_index]; + + if (connection->remote_has[file_index] != _have_all || + (download_file != NONE && download->files[download_file].state != _file_complete)) + { + break; + } + } + if (file_index == connection->file_count && !connection->serving_count && !connection->output_size) + { + connection_close(connection, NULL); + continue; + } + /* (nor anything to give each other: the other end has none of the + files, and downloads none of them, or it would say what it has) */ + for (file_index = 0; file_index < connection->file_count; file_index++) + { + if (!connection->state_heard[file_index] || connection->remote_has[file_index] != _have_none) + break; + } + if (file_index == connection->file_count && !connection->serving_count && !connection->output_size) + { + connection_close(connection, "it has none of the files"); + continue; + } + /* (a piece asked for and not coming: asked of another. The pieces + come in turn, so the first's time is since its last block. Likewise + the pieces' hashes) */ + if (connection->request_count && elapsed(connection->requests[0].asked_at, now, REQUEST_TIMEOUT)) + { + connection_close(connection, "a piece did not come"); + continue; + } + for (file_index = 0; file_index < download->file_count; file_index++) + { + if (download->files[file_index].hashes_connection == index && + elapsed(download->files[file_index].hashes_asked_at, now, REQUEST_TIMEOUT)) + { + break; + } + } + if (file_index < download->file_count) + { + connection_close(connection, "the pieces' hashes did not come"); + continue; + } + connection_send_hashes(connection); + connection_ask(connection); + if (elapsed(connection->sent_at, now, KEEP_ALIVE_INTERVAL) && !connection->output_size && frame_room(connection, 0)) + frame_begin(connection, _frame_keep_alive, 0); + connection_flush(connection); + } +} + +/* ---------- the client's download, each frame */ + +static void download_update( + void) +{ + struct download *download = &map_share_globals.download; + struct network_game *game = map_share_network_game(); + unsigned long now = system_milliseconds(); + short index; + + if (download->state != _download_asking && download->state != _download_transferring) + return; + /* (the game left, or its map changed) */ + if (!game || csstrcmp(game->map.name, download->level_name)) + { + error(_error_silent, "map sharing: the download of %s stopped: the game is on another map now", + download->level_name); + download_end(FALSE); + return; + } + if (download->state == _download_asking) + { + if (elapsed(download->asked_at, now, OFFER_TIMEOUT)) + download_fail("The host did not answer the request for the map."); + return; + } + for (index = 0; index < download->file_count; index++) + { + struct download_file *file = &download->files[index]; + byte *piece_hashes; + long piece_count; + unsigned long size; + + /* a part file checked: the pieces it has taken */ + if (file->state == _file_checking && + map_share_pieces_hashed(download->serial << 3 | index, &piece_hashes, &piece_count, &size)) + { + long piece; + + file->stream = piece_hashes && piece_count == file->piece_count && size == file->size ? + fopen(file->part_path, "r+b") : NULL; + for (piece = 0; file->stream && piece < file->piece_count; piece++) + { + if (!csmemcmp(piece_hashes + piece * MAP_SHARE_HASH_BYTES, file->piece_hashes + piece * MAP_SHARE_HASH_BYTES, + MAP_SHARE_HASH_BYTES)) + { + file->have[piece >> 3] |= (byte)(1 << (piece & 7)); + file->have_count++; + download->done_bytes += MIN(MAP_SHARE_PIECE_BYTES, file->size - (unsigned long)piece * MAP_SHARE_PIECE_BYTES); + } + } + free(piece_hashes); + if (file->stream) + { + error(_error_silent, "map sharing: took up %s where it was (%ld of %ld pieces)", file->part_path, + file->have_count, file->piece_count); + file->state = _file_transferring; + } + /* (unreadable, or changed meanwhile: made anew) */ + else if (!download_file_create(file)) + { + download_fail("The map could not be written to custom_maps."); + return; + } + download->progress_at = now; + } + else if (file->state == _file_checking) + { + /* (the hashing thread busy with another's: asked again) */ + map_share_pieces_hash(file->part_path, download->serial << 3 | index); + } + if (file->state == _file_transferring && file->have_count == file->piece_count) + file->state = _file_complete; + } + for (index = 0; index < download->file_count && download->files[index].state == _file_complete; index++) + ; + if (index == download->file_count) + { + download_finish(); + return; + } + if (elapsed(download->progress_at, now, STALL_TIMEOUT)) + { + download_fail("The download stopped: no player sent any of the map for a minute."); + return; + } + download_reach_sources(); + if (elapsed(download->reported_at, now, PROGRESS_INTERVAL)) + download_report(_progress_downloading); + if (elapsed(download->logged_at, now, LOG_INTERVAL)) + { + short connections = 0; + + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + { + connections += map_share_globals.connections[index].used && + map_share_globals.connections[index].state == _connection_open; + } + download->logged_at = now; + error(_error_silent, "map sharing: %lu of %lu bytes, %d connections, %lu bytes a second", + download->done_bytes, download->total_bytes, (int)connections, + download->last_window_time ? download->last_window_bytes * 1000 / download->last_window_time : 0); + } + if (elapsed(download->window_at, now, RATE_WINDOW)) + { + download->last_window_bytes = download->window_bytes; + download->last_window_time = now - download->window_at; + download->window_bytes = 0; + download->window_at = now; + } +} + +/* ---------- the host */ + +static boolean host_machine_valid( + long machine_index) +{ + return machine_index >= 0 && machine_index < MAP_SHARE_MACHINES; +} + +boolean map_share_machine_downloading( + long machine_index) +{ + struct host_machine const *machine = host_machine_valid(machine_index) ? + &map_share_globals.machines[machine_index] : NULL; + + return machine && machine->downloading && !elapsed(machine->heard_at, system_milliseconds(), DOWNLOADING_TIMEOUT); +} + +boolean map_share_machine_needs_map( + long machine_index) +{ + struct host_machine const *machine = host_machine_valid(machine_index) ? + &map_share_globals.machines[machine_index] : NULL; + + return machine && machine->downloading && !machine->downloaded && + !elapsed(machine->heard_at, system_milliseconds(), DOWNLOADING_TIMEOUT); +} + +static boolean host_in_game( + void) +{ + struct network_game_server *server = global_network_game_server_get(); + + return server && network_game_server_get_state(server, NULL) == _map_share_server_state_ingame; +} + +/* the answer to a machine's request: its files' sizes and hashes */ +static void host_offer( + long machine_index, + byte result) +{ + struct host_machine *machine = &map_share_globals.machines[machine_index]; + struct + { + struct distributed_message_header header; + struct distributed_map_share_offer offer; + struct distributed_map_share_offer_file files[MAXIMUM_FILES]; + } message; + short count = result == _offer_ok ? machine->file_count : 0; + word size = (word)(sizeof(message.header) + sizeof(message.offer) + count * sizeof(message.files[0])); + short index; + + csmemset(&message, 0, sizeof(message)); + map_share_fill_header(&message.header, _distributed_message_map_share_offer, size); + csmemcpy(message.offer.level_name, machine->request.level_name, LEVEL_NAME_BYTES); + message.offer.result = result; + message.offer.file_count = (byte)count; + message.offer.flags = host_in_game() ? FLAG(_offer_in_progress_bit) : 0; + for (index = 0; index < count; index++) + { + struct map_share_file *file = map_share_file_get(machine->catalog_files[index]); + + message.files[index].kind = (byte)machine->file_kinds[index]; + message.files[index].size = file->size; + csmemcpy(message.files[index].hash, file->hash, MAP_SHARE_HASH_BYTES); + } + network_distributed_server_send_to_machine_reliably(machine_index, &message, size); + machine->offered = TRUE; + machine->asked = FALSE; + error(_error_silent, "map sharing: offered machine #%ld %s (%d files)%s", machine_index, + machine->request.level_name, (int)count, result == _offer_ok ? "" : + result == _offer_off ? ": not shared (network.map_share)" : ": unavailable"); +} + +/* the machines a downloading one may fetch from: the game's others */ +static void host_sources( + long machine_index) +{ + struct map_share_globals *globals = &map_share_globals; + struct + { + struct distributed_message_header header; + struct distributed_map_share_sources sources; + struct distributed_map_share_source entries[MAXIMUM_SOURCES]; + } message; + short local_machine = network_game_client_get_local_machine_index(); + short count = 0; + long index; + word size; + + csmemset(&message, 0, sizeof(message)); + for (index = 0; index < MAP_SHARE_MACHINES && count < MAXIMUM_SOURCES; index++) + { + struct distributed_map_share_source *entry = &message.entries[count]; + unsigned long address = network_game_server_machine_address(index); + + if (index == machine_index || index == local_machine || !address) + continue; + entry->machine_index = (byte)index; + entry->address = address; + if (address_internet(address)) + { + if (!p2p_peer_key_hash(network_order(address), entry->identifier, entry->key_hash)) + continue; + entry->flags |= FLAG(_source_internet_bit); + } + if (map_share_machine_downloading(index) && !globals->machines[index].downloaded) + entry->flags |= FLAG(_source_downloading_bit); + count++; + } + size = (word)(sizeof(message.header) + sizeof(message.sources) + count * sizeof(message.entries[0])); + map_share_fill_header(&message.header, _distributed_message_map_share_sources, size); + message.sources.count = (byte)count; + message.sources.flags = host_in_game() ? FLAG(_offer_in_progress_bit) : 0; + network_distributed_server_send_to_machine_reliably(machine_index, &message, size); + globals->machines[machine_index].sources_at = system_milliseconds(); +} + +/* a machine's request: its files asked to be hashed, the answer given once +they are */ +static void host_request( + long machine_index, + struct distributed_map_share_request const *request) +{ + struct host_machine *machine = &map_share_globals.machines[machine_index]; + struct network_game *game = map_share_network_game(); + char const *name; + char path[MAP_SHARE_PATH_SIZE]; + unsigned long needed; + short kind; + + csmemset(machine, 0, sizeof(*machine)); + machine->request = *request; + machine->asked_at = system_milliseconds(); + machine->heard_at = machine->asked_at; + machine->address = network_game_server_machine_address(machine_index); + if (!config_boolean("network.map_share")) + { + host_offer(machine_index, _offer_off); + return; + } + /* (the game's map alone, a Custom Edition map's, named for its hash if + it is asked for) */ + name = tag_name_strip_path(request->level_name); + if (!game || csstrcmp(game->map.name, request->level_name) || !custom_edition_level_name(request->level_name) || + !custom_edition_map_file_path(request->level_name, path) || + (TEST_FLAG(request->wanted, _map_share_file_map) && !map_share_name_hashed(name))) + { + host_offer(machine_index, _offer_unavailable); + return; + } + needed = custom_edition_map_resource_maps_needed(request->level_name) | FLAG(_map_share_file_map); + for (kind = 0; kind < MAXIMUM_FILES; kind++) + { + if (!TEST_FLAG(request->wanted, kind) || !TEST_FLAG(needed, kind)) + continue; + if (kind != _map_share_file_map && !custom_edition_resource_map_file_path(kind, path)) + continue; + machine->catalog_files[machine->file_count] = map_share_file_hash(path, kind); + machine->file_kinds[machine->file_count] = kind; + if (machine->catalog_files[machine->file_count] != NONE) + machine->file_count++; + /* (the map's path again, after a resource map's) */ + custom_edition_map_file_path(request->level_name, path); + } + if (!machine->file_count) + { + host_offer(machine_index, _offer_unavailable); + return; + } + machine->asked = TRUE; + machine->downloading = TRUE; + error(_error_silent, "map sharing: machine #%ld asks for %s (files 0x%X)", machine_index, request->level_name, + (unsigned)request->wanted); +} + +/* the host's machines: answered once their files are hashed, told the +sources again now and then, forgotten once gone */ +static void host_update( + void) +{ + struct map_share_globals *globals = &map_share_globals; + unsigned long now = system_milliseconds(); + long index; + + for (index = 0; index < MAP_SHARE_MACHINES; index++) + { + struct host_machine *machine = &globals->machines[index]; + unsigned long address = network_game_server_machine_address(index); + short file_index; + + if (!machine->asked && !machine->downloading) + continue; + /* (left: another machine may take its slot) */ + if (!address || address != machine->address) + { + csmemset(machine, 0, sizeof(*machine)); + continue; + } + if (machine->asked) + { + boolean ready = TRUE; + boolean failed = FALSE; + + for (file_index = 0; file_index < machine->file_count; file_index++) + { + struct map_share_file *file = map_share_file_get(machine->catalog_files[file_index]); + + if (!file) + failed = TRUE; + else if (file->failed) + failed = TRUE; + else if (!file->ready) + ready = FALSE; + } + if (failed) + { + host_offer(index, _offer_unavailable); + machine->downloading = FALSE; + continue; + } + if (!ready) + continue; + /* (the map's hash its name's, as the client checks) */ + for (file_index = 0; file_index < machine->file_count; file_index++) + { + struct map_share_file *file = map_share_file_get(machine->catalog_files[file_index]); + char const *name = tag_name_strip_path(machine->request.level_name); + char digits[MAP_SHARE_NAME_DIGITS + 1]; + + map_share_hash_digits(file->hash, digits); + if (machine->file_kinds[file_index] == _map_share_file_map && + csstrcmp(name + map_share_name_base_length(name) + 1, digits)) + { + error(_error_silent, "map sharing: %s is not named for its hash (%s): not shared", file->path, + digits); + failed = TRUE; + } + } + host_offer(index, failed ? _offer_unavailable : _offer_ok); + if (failed) + { + machine->downloading = FALSE; + continue; + } + host_sources(index); + } + if (machine->downloading && !machine->downloaded && machine->offered && + elapsed(machine->sources_at, now, SOURCES_INTERVAL) && + !elapsed(machine->heard_at, now, DOWNLOADING_TIMEOUT)) + { + host_sources(index); + } + } +} + +/* a machine's progress */ +static void host_progress( + long machine_index, + struct distributed_map_share_progress const *progress) +{ + struct host_machine *machine = &map_share_globals.machines[machine_index]; + + if (!machine->offered) + return; + machine->heard_at = system_milliseconds(); + machine->done = progress->done; + machine->total = progress->total; + machine->downloading = progress->state != _progress_failed; + machine->downloaded = progress->state == _progress_done; + if (progress->state != _progress_downloading) + { + error(_error_silent, "map sharing: machine #%ld %s %s", machine_index, + progress->state == _progress_done ? "downloaded" : "could not download", machine->request.level_name); + } +} + +/* ---------- messages */ + +boolean map_share_handles_message( + word const *message, + word size) +{ + struct distributed_message_header header; + + if (size < sizeof(header)) + return FALSE; + csmemcpy(&header, message, sizeof(header)); + return header.type == _distributed_message_map_share_request || header.type == _distributed_message_map_share_offer || + header.type == _distributed_message_map_share_sources || header.type == _distributed_message_map_share_progress; +} + +void map_share_handle_message( + long machine_index, + word const *message, + word size, + boolean from_stream) +{ + struct distributed_message_header header; + byte const *entries = (byte const *)message + sizeof(header); + long length = (long)size - (long)sizeof(header); + + /* (over the sender's connection alone: a datagram can be sent as from + anyone) */ + if (!map_share_handles_message(message, size) || !from_stream) + return; + csmemcpy(&header, message, sizeof(header)); + switch (header.type) + { + case _distributed_message_map_share_request: + { + struct distributed_map_share_request request; + + if (machine_index == NONE || !map_share_host() || !host_machine_valid(machine_index) || + length != (long)sizeof(request)) + { + return; + } + csmemcpy(&request, entries, sizeof(request)); + if (!level_name_valid(request.level_name)) + return; + host_request(machine_index, &request); + break; + } + case _distributed_message_map_share_progress: + { + struct distributed_map_share_progress progress; + + if (machine_index == NONE || !map_share_host() || !host_machine_valid(machine_index) || + length != (long)sizeof(progress)) + { + return; + } + csmemcpy(&progress, entries, sizeof(progress)); + host_progress(machine_index, &progress); + break; + } + case _distributed_message_map_share_offer: + { + struct distributed_map_share_offer offer; + struct distributed_map_share_offer_file files[MAXIMUM_FILES]; + + if (machine_index != NONE || map_share_host() || length < (long)sizeof(offer)) + return; + csmemcpy(&offer, entries, sizeof(offer)); + if (!level_name_valid(offer.level_name) || offer.file_count > MAXIMUM_FILES || + length != (long)(sizeof(offer) + offer.file_count * sizeof(files[0]))) + { + return; + } + csmemcpy(files, entries + sizeof(offer), offer.file_count * sizeof(files[0])); + download_offered(&offer, files); + break; + } + case _distributed_message_map_share_sources: + { + struct distributed_map_share_sources sources; + struct distributed_map_share_source sources_entries[MAXIMUM_SOURCES]; + + if (machine_index != NONE || map_share_host() || length < (long)sizeof(sources)) + return; + csmemcpy(&sources, entries, sizeof(sources)); + if (sources.count > MAXIMUM_SOURCES || + length != (long)(sizeof(sources) + sources.count * sizeof(sources_entries[0]))) + { + return; + } + csmemcpy(sources_entries, entries + sizeof(sources), sources.count * sizeof(sources_entries[0])); + download_sources(&sources, sources_entries); + break; + } + } +} + +/* ---------- every frame */ + +void map_share_update( + void) +{ + struct map_share_globals *globals = &map_share_globals; + struct network_game *game = map_share_network_game(); + unsigned long host_address = map_share_host_address(); + short index; + + map_share_files_update(); + /* (internet play: a player that shares answers the others, who reach it + by its key hash) */ + p2p_set_answering_players(!map_share_host() && address_internet(host_address) && map_share_sending()); + /* (none in a build that cannot run Custom Edition maps: the web's) */ + if (!game || !halo_custom_edition_tag_cache()) + { + /* (one done or failed is forgotten too: the next game's players are + not held for it) */ + if (globals->download.state != _download_none) + download_end(FALSE); + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + { + if (globals->connections[index].used) + connection_close(&globals->connections[index], NULL); + } + connections_update(); + listener_update(); + csmemset(globals->machines, 0, sizeof(globals->machines)); + return; + } + current_files_update(FALSE); + if (map_share_host()) + host_update(); + download_update(); + listener_update(); + connection_accept(); + connections_update(); + serve_blocks(); +} + +void map_share_render( + void) +{ + struct download *download = &map_share_globals.download; + long font_tag_index; + real_argb_color color = { 1.0f, 1.0f, 1.0f, 1.0f }; + rectangle2d bounds; + char text[160]; + char const *name; + short base_length; + long percent; + + if (download->state != _download_asking && download->state != _download_transferring) + return; + font_tag_index = interface_get_tag_index(_interface_font_terminal); + if (font_tag_index == NONE) + return; + name = tag_name_strip_path(download->level_name); + base_length = map_share_name_base_length(name); + if (download->state == _download_asking) + { + snprintf(text, sizeof(text), "Asking the host for the map %.*s...", (int)base_length, name); + } + else + { + unsigned long rate = download->last_window_time ? + download->last_window_bytes * 1000 / download->last_window_time : 0; + short sources = 0; + short index; + + for (index = 0; index < MAXIMUM_CONNECTIONS; index++) + { + struct connection const *connection = &map_share_globals.connections[index]; + + sources += connection->used && connection->state == _connection_open && connection->hello_heard; + } + percent = download->total_bytes ? (long)(download->done_bytes / (download->total_bytes / 100 + 1)) : 0; + snprintf(text, sizeof(text), "Downloading the map %.*s: %ld%% of %lu MB from %d player%s, %lu KB/s", + (int)base_length, name, MIN(percent, 100), (download->total_bytes + 0x80000) >> 20, (int)sources, + sources == 1 ? "" : "s", rate >> 10); + } + /* (at the top of the screen, above the menus' safe area, where the + lobby's title is) */ + bounds = render.camera.window_bounds; + offset_rectangle2d(&bounds, -render.camera.viewport_bounds.x0, -render.camera.viewport_bounds.y0); + bounds.y0 = 4; + bounds.y1 = 22; + draw_string_set_draw_mode(font_tag_index, NONE, _map_share_text_centered, 0, &color); + rasterizer_draw_string(&bounds, NULL, NULL, 0, text); +} diff --git a/port/linux/game/map_share.h b/port/linux/game/map_share.h new file mode 100644 index 0000000000..6c26df8d7a --- /dev/null +++ b/port/linux/game/map_share.h @@ -0,0 +1,202 @@ +/* +MAP_SHARE.H + +Custom Edition maps shared over network games (map_share.c, map_share_files.c; +port/linux/NETCODE.md, "Map sharing"). + +Every map in the custom_maps folder is named for its contents: its file name +ends in _ and the first 8 hexadecimal digits of its hash (hugeass.map becomes +hugeass_1a2b3c4d.map, renamed as the map lists find it), so the level name a +host's game is on (custom_maps\hugeass_1a2b3c4d) says which version of the map +it is. A client joining without that file asks the host for it. If the host +shares maps (network.map_share), it says which files make up the map (the +map, and the resource maps it needs that the client lacks) and which other +players have them, and the client downloads them in pieces from all of them +at once, as a torrent's peers do, each piece checked against its hash. The +downloaded map keeps its name, so two versions of a map never meet. +*/ + +#ifndef __MAP_SHARE_H +#define __MAP_SHARE_H + +/* ---------- constants */ + +enum +{ + /* a file's hash, of its pieces' (BLAKE2b, 32 bytes) */ + MAP_SHARE_HASH_BYTES = 32, + /* the hexadecimal digits of the hash a map file's name ends in, after _ */ + MAP_SHARE_NAME_DIGITS = 8, + /* a file is hashed and sent in pieces of this size (the last shorter), + each piece in blocks of this */ + MAP_SHARE_PIECE_BYTES = 0x40000, + MAP_SHARE_BLOCK_BYTES = 0x4000, + /* the files of a map: the map, and resource maps (as enum + resource_map_type numbers them, cache_file_formats.h) */ + _map_share_file_map = 0, + _map_share_file_bitmaps, + _map_share_file_sounds, + _map_share_file_locale, + NUMBER_OF_MAP_SHARE_FILE_KINDS, + /* a path of the platform's (d:\custom_maps\...) */ + MAP_SHARE_PATH_SIZE = 256, +}; + +/* ---------- prototypes/MAP_SHARE_FILES.C */ + +/* Whether a map's file name (without its extension) ends in _ and +MAP_SHARE_NAME_DIGITS lowercase hexadecimal digits: named for its hash. */ +boolean map_share_name_hashed( + char const *name); +/* Whether a map's name (from another machine's level name) is only a file +name of the custom_maps folder: printable ASCII, no separator or character +a file system refuses, no dot or space at either end (so never "." or +".."). */ +boolean map_share_name_safe( + char const *name); +/* the length of a map's file name without the hash it ends in (all of it, +when it ends in none) */ +short map_share_name_base_length( + char const *name); + +/* The map lists found the map file .map, a Custom Edition +map, whose name is not its hash's: in the custom_maps folder it is hashed, +on a thread of its own, and renamed _.map, with its picture and +description beside it (.bmp, .txt); the map lists are scanned +again once it has been (custom_edition_maps_look_again). Not while the map +is loaded (a file in use is not renamed on Windows): it is asked for again. */ +void map_share_name_map( + char const *folder, + char const *name); + +/* A host's game is on the level `level_name` (a buffer of `size`): a +Custom Edition map's named without its hash (custom_maps\hugeass, a saved +last map's or a test's) is named with it (custom_maps\hugeass_1a2b3c4d), the +map's file renamed for its hash first if it has not been yet, so that the +players can download it (network_server_manager.c). */ +void map_share_level_name_hash( + char *level_name, + long size); + +/* ---------- prototypes/MAP_SHARE.C */ + +/* A client joining a network game on the level `level_name` (with the +host's `version`, its header checksum) that this machine cannot play +(custom_edition_cache_present said why in `message`): TRUE if it asks the +host for it instead (a Custom Edition map named for its hash, or one whose +resource maps are missing, with network.map_download on). The download +runs in the lobby (the host waits for it to precache), or, for a game in +progress, before this machine's players join. */ +boolean map_share_download_begin( + char const *level_name, + unsigned long version, + char const *message); +/* whether the level's files are being downloaded (cache_files.c gives the +map no time to precache until they are) */ +boolean map_share_downloading( + char const *level_name); +/* whether this machine's players wait to join the host's game: until its +settings name the map (a game in progress would start them at once), and +for a game in progress until the map is downloaded (network_client_manager.c) */ +boolean map_share_holds_players( + void); +/* (the host) whether a client machine is downloading the game's map, as it +said lately: it is not dropped for adding no players or loading slowly +(network_server_manager.c) */ +boolean map_share_machine_downloading( + long machine_index); +/* (the host) whether that download is not finished: the machines that have +the map do not wait for it (network_server_manager.c) */ +boolean map_share_machine_needs_map( + long machine_index); + +/* the distributed messages of map sharing (network_distributed.c, and the +host's message handler), in the lobby too: from_stream says the message came +over the sender's connection, not as a datagram (anyone can send one) */ +boolean map_share_handles_message( + word const *message, + word size); +void map_share_handle_message( + long machine_index, + word const *message, + word size, + boolean from_stream); + +/* every frame (main.c): the hashing, the transfers and the messages */ +void map_share_update( + void); +/* the download's progress, drawn over the screen (interface.c) */ +void map_share_render( + void); + +/* ---------- prototypes/MAP_SHARE_FILES.C (map_share.c's) */ + +/* A file hashed for sharing: its size, its pieces' hashes and its hash. +map_share_file_hash asks for the file at a path (a map, or a resource map +of a kind, _map_share_file_*) to be hashed on the hashing thread, if it is +not (or has changed since); its index in the catalog, or NONE. */ +struct map_share_file +{ + boolean used; + short kind; + char path[MAP_SHARE_PATH_SIZE]; + /* hashed: ready (pieces and hash known) or failed */ + boolean ready; + boolean failed; + unsigned long size; + unsigned long modified; + long piece_count; + byte *piece_hashes; + byte hash[MAP_SHARE_HASH_BYTES]; +}; + +short map_share_file_hash( + char const *path, + short kind); +/* ... and a file whose pieces' hashes are known (a download's, which were +checked): taken as it is, not hashed again */ +short map_share_file_hashed( + char const *path, + short kind, + unsigned long size, + byte const *piece_hashes, + long piece_count, + byte const *hash); +struct map_share_file *map_share_file_get( + short index); +/* the ready file with this hash, or NONE */ +short map_share_file_find( + byte const *hash); +/* the pieces of a file of `size` bytes */ +long map_share_piece_count( + unsigned long size); +/* the hash of a file of `size` bytes from its pieces' hashes */ +void map_share_file_hash_of_pieces( + unsigned long size, + byte const *piece_hashes, + long piece_count, + byte *hash); +/* the hash's first MAP_SHARE_NAME_DIGITS digits, as a name ends in */ +void map_share_hash_digits( + byte const *hash, + char *digits); + +/* Asks for the pieces of the file at `path` (a download's, part written) +to be hashed, for the caller (`owner`) to compare with what they should be: +FALSE if the hashing thread is busy with another such request. Done, with +the pieces' hashes, when map_share_pieces_hashed returns TRUE (failed: no +hashes, the file unreadable). */ +boolean map_share_pieces_hash( + char const *path, + long owner); +boolean map_share_pieces_hashed( + long owner, + byte **piece_hashes, + long *piece_count, + unsigned long *size); + +/* the hashing thread's work: started and collected each frame */ +void map_share_files_update( + void); + +#endif diff --git a/port/linux/game/map_share_files.c b/port/linux/game/map_share_files.c new file mode 100644 index 0000000000..ff299483f0 --- /dev/null +++ b/port/linux/game/map_share_files.c @@ -0,0 +1,720 @@ +/* +MAP_SHARE_FILES.C + +The files map sharing hashes (map_share.h): a map's name made from its +hash, and the hashes of the files this machine shares, worked out on a +thread of their own a file at a time, so that a map of hundreds of +megabytes costs the game no frames. + +A file's hash is BLAKE2b's (32 bytes) of a label, its size and its pieces' +hashes (each piece MAP_SHARE_PIECE_BYTES of it, the last shorter, hashed with +BLAKE2b): a client that knows a file's hash, from the host, can check the +pieces' hashes any player sends it, and each piece as it arrives. +*/ + +/* ---------- headers */ + +#include "cseries.h" +#include "errors.h" +#include "tag_files/tag_files.h" +#include "bungie_net/common/thread.h" +#include "cache_file_formats.h" +#include "custom_edition_cache.h" +#include "custom_edition_maps.h" +#include "map_share.h" + +#include +#include +#include +#include + +/* (the C library's allocator, not the game's tracked one (cseries.h): the +hashing thread allocates what the game's thread frees) */ +#undef malloc +#undef free +#undef realloc + +/* (p2p_crypto.c's) */ +void p2p_blake2b_256(const void *data, unsigned long size, unsigned char *digest); + +/* ---------- constants */ + +enum +{ + /* the files hashed for sharing at once: a game's map and its three + resource maps, a download's, and the last few games' */ + MAXIMUM_SHARE_FILES = 16, + /* the maps waiting to be named, and those named or given up on this + run (not looked at again) */ + MAXIMUM_NAMING_QUEUE = 64, + MAXIMUM_NAMED = 1024, + /* the longest map file name the map lists take (custom_edition_maps.c: + the level name, custom_maps\ and it, kept whole by the game engine) */ + MAXIMUM_MAP_NAME_LENGTH = CUSTOM_EDITION_MAXIMUM_LEVEL_NAME_LENGTH - (sizeof(CUSTOM_EDITION_LEVEL_NAME_PREFIX) - 1), + /* the largest file hashed (the combined offset space's resource maps + are smaller: custom_edition_cache.c) */ + MAXIMUM_FILE_BYTES = 0x7FFFFFFF, +}; + +/* what a hash is of: these bytes, the file's size (4 bytes, little-endian) +and its pieces' hashes */ +static char const file_hash_label[] = "OpenCE map 1"; +#define FILE_HASH_LABEL_BYTES 12 + +enum +{ + _hash_job_none, + /* a catalog file's (map_share_file_hash) */ + _hash_job_file, + /* a map to name for its hash (map_share_name_map) */ + _hash_job_name, + /* a download's pieces (map_share_pieces_hash) */ + _hash_job_pieces, +}; + +/* ---------- structures */ + +/* the hashing thread's work: the file it reads, and what it finds */ +struct hash_job +{ + char path[MAP_SHARE_PATH_SIZE]; + unsigned long size; + long piece_count; + byte *piece_hashes; + byte hash[MAP_SHARE_HASH_BYTES]; + /* written by the thread: done (ready or failed) when it has exited */ + volatile long failed; +}; + +struct naming +{ + char folder[MAP_SHARE_PATH_SIZE]; + char name[MAP_SHARE_PATH_SIZE]; +}; + +struct map_share_files_globals +{ + struct map_share_file files[MAXIMUM_SHARE_FILES]; + /* the file catalog's next to replace, when full */ + short next_file; + + /* the job running (_hash_job_*), its thread, and whose it is (a + catalog index, or the pieces' owner) */ + short job_type; + struct thread_reference *thread; + struct hash_job job; + long job_owner; + struct naming job_naming; + + /* the maps to name, and those looked at already (folder and name) */ + short naming_count; + struct naming naming_queue[MAXIMUM_NAMING_QUEUE]; + long named_count; + unsigned long named_hashes[MAXIMUM_NAMED]; + + /* the pieces asked for, and what was found for their owner */ + boolean pieces_wanted; + char pieces_path[MAP_SHARE_PATH_SIZE]; + long pieces_owner; + boolean pieces_done; + long pieces_done_owner; + byte *pieces_hashes; + long pieces_count; + unsigned long pieces_size; +}; + +/* ---------- globals */ + +static struct map_share_files_globals map_share_files_globals; + +/* ---------- names */ + +static boolean hex_digit( + char character) +{ + return (character >= '0' && character <= '9') || (character >= 'a' && character <= 'f'); +} + +boolean map_share_name_hashed( + char const *name) +{ + long length = (long)csstrlen(name); + long index; + + if (length < MAP_SHARE_NAME_DIGITS + 2 || name[length - MAP_SHARE_NAME_DIGITS - 1] != '_') + return FALSE; + for (index = length - MAP_SHARE_NAME_DIGITS; index < length; index++) + { + if (!hex_digit(name[index])) + return FALSE; + } + return TRUE; +} + +boolean map_share_name_safe( + char const *name) +{ + long length = (long)csstrlen(name); + long index; + + if (!length || name[0] == '.' || name[0] == ' ' || name[length - 1] == '.' || name[length - 1] == ' ') + return FALSE; + for (index = 0; index < length; index++) + { + unsigned char character = (unsigned char)name[index]; + + if (character < ' ' || character > '~' || strchr("/\\:*?\"<>|", character)) + return FALSE; + } + return TRUE; +} + +short map_share_name_base_length( + char const *name) +{ + short length = (short)csstrlen(name); + + return map_share_name_hashed(name) ? (short)(length - MAP_SHARE_NAME_DIGITS - 1) : length; +} + +void map_share_hash_digits( + byte const *hash, + char *digits) +{ + static char const hex[] = "0123456789abcdef"; + short index; + + for (index = 0; index < MAP_SHARE_NAME_DIGITS / 2; index++) + { + digits[index * 2] = hex[hash[index] >> 4]; + digits[index * 2 + 1] = hex[hash[index] & 15]; + } + digits[MAP_SHARE_NAME_DIGITS] = 0; +} + +/* ---------- hashes */ + +long map_share_piece_count( + unsigned long size) +{ + return (long)((size + MAP_SHARE_PIECE_BYTES - 1) / MAP_SHARE_PIECE_BYTES); +} + +void map_share_file_hash_of_pieces( + unsigned long size, + byte const *piece_hashes, + long piece_count, + byte *hash) +{ + unsigned long bytes = FILE_HASH_LABEL_BYTES + 4 + (unsigned long)piece_count * MAP_SHARE_HASH_BYTES; + byte *data = malloc(bytes); + short index; + + if (!data) + { + csmemset(hash, 0, MAP_SHARE_HASH_BYTES); + return; + } + csmemcpy(data, file_hash_label, FILE_HASH_LABEL_BYTES); + for (index = 0; index < 4; index++) + data[FILE_HASH_LABEL_BYTES + index] = (byte)(size >> (index * 8)); + if (piece_count) + csmemcpy(data + FILE_HASH_LABEL_BYTES + 4, piece_hashes, (unsigned long)piece_count * MAP_SHARE_HASH_BYTES); + p2p_blake2b_256(data, bytes, hash); + free(data); +} + +/* the hashing thread: the job's file read a piece at a time */ +static unsigned long __stdcall hash_job_run( + void *input) +{ + struct hash_job *job = input; + byte *piece = malloc(MAP_SHARE_PIECE_BYTES); + FILE *stream = fopen(job->path, "rb"); + long size = -1; + long index; + + job->piece_hashes = NULL; + if (!piece || !stream || fseek(stream, 0, SEEK_END) != 0 || (size = ftell(stream)) < 0 || + fseek(stream, 0, SEEK_SET) != 0) + { + size = -1; + } + if (size >= 0) + { + job->size = (unsigned long)size; + job->piece_count = map_share_piece_count(job->size); + job->piece_hashes = malloc((unsigned long)MAX(job->piece_count, 1) * MAP_SHARE_HASH_BYTES); + } + for (index = 0; job->piece_hashes && index < job->piece_count; index++) + { + unsigned long bytes = MIN(MAP_SHARE_PIECE_BYTES, job->size - (unsigned long)index * MAP_SHARE_PIECE_BYTES); + + if (fread(piece, 1, bytes, stream) != bytes) + { + free(job->piece_hashes); + job->piece_hashes = NULL; + break; + } + p2p_blake2b_256(piece, bytes, job->piece_hashes + index * MAP_SHARE_HASH_BYTES); + } + if (stream) + fclose(stream); + free(piece); + if (job->piece_hashes) + map_share_file_hash_of_pieces(job->size, job->piece_hashes, job->piece_count, job->hash); + else + job->failed = TRUE; + + return 0; +} + +static boolean hash_job_start( + short type, + char const *path, + long owner) +{ + struct map_share_files_globals *globals = &map_share_files_globals; + + csmemset(&globals->job, 0, sizeof(globals->job)); + csstrncpy(globals->job.path, path, sizeof(globals->job.path) - 1); + if (!create_thread(1, hash_job_run, &globals->job, &globals->thread)) + { + globals->thread = NULL; + return FALSE; + } + globals->job_type = type; + globals->job_owner = owner; + return TRUE; +} + +/* the file's size and when it was last written, as the file system says */ +static boolean file_stamp( + char const *path, + unsigned long *size, + unsigned long *modified) +{ + WIN32_FILE_ATTRIBUTE_DATA data; + + if (!GetFileAttributesExA(path, GetFileExInfoStandard, &data) || data.nFileSizeHigh) + return FALSE; + *size = data.nFileSizeLow; + *modified = data.ftLastWriteTime.dwLowDateTime ^ data.ftLastWriteTime.dwHighDateTime; + return TRUE; +} + +/* ---------- the catalog */ + +short map_share_file_hash( + char const *path, + short kind) +{ + struct map_share_files_globals *globals = &map_share_files_globals; + struct map_share_file *file = NULL; + unsigned long size; + unsigned long modified; + short index; + + if (!file_stamp(path, &size, &modified) || size > MAXIMUM_FILE_BYTES) + return NONE; + for (index = 0; index < MAXIMUM_SHARE_FILES; index++) + { + if (globals->files[index].used && !csstrcasecmp(globals->files[index].path, path)) + { + file = &globals->files[index]; + break; + } + } + /* (changed since: hashed again) */ + if (file && (file->ready || file->failed) && (file->size != size || file->modified != modified)) + { + free(file->piece_hashes); + file->piece_hashes = NULL; + file->ready = FALSE; + file->failed = FALSE; + } + if (!file) + { + /* a free one, else the next in turn not being hashed */ + for (index = 0; index < MAXIMUM_SHARE_FILES && globals->files[index].used; index++) + ; + if (index == MAXIMUM_SHARE_FILES) + { + index = globals->next_file; + if (globals->job_type == _hash_job_file && globals->job_owner == index) + index = (short)((index + 1) % MAXIMUM_SHARE_FILES); + globals->next_file = (short)((index + 1) % MAXIMUM_SHARE_FILES); + free(globals->files[index].piece_hashes); + } + file = &globals->files[index]; + csmemset(file, 0, sizeof(*file)); + file->used = TRUE; + csstrncpy(file->path, path, sizeof(file->path) - 1); + } + file->kind = kind; + file->size = size; + file->modified = modified; + + return (short)(file - globals->files); +} + +short map_share_file_hashed( + char const *path, + short kind, + unsigned long size, + byte const *piece_hashes, + long piece_count, + byte const *hash) +{ + short index = map_share_file_hash(path, kind); + struct map_share_file *file = map_share_file_get(index); + + if (!file || file->size != size || piece_count != map_share_piece_count(size)) + return index; + free(file->piece_hashes); + file->piece_hashes = malloc((unsigned long)MAX(piece_count, 1) * MAP_SHARE_HASH_BYTES); + if (!file->piece_hashes) + return index; + csmemcpy(file->piece_hashes, piece_hashes, (unsigned long)piece_count * MAP_SHARE_HASH_BYTES); + file->piece_count = piece_count; + csmemcpy(file->hash, hash, MAP_SHARE_HASH_BYTES); + file->ready = TRUE; + file->failed = FALSE; + return index; +} + +struct map_share_file *map_share_file_get( + short index) +{ + return index >= 0 && index < MAXIMUM_SHARE_FILES && map_share_files_globals.files[index].used ? + &map_share_files_globals.files[index] : NULL; +} + +short map_share_file_find( + byte const *hash) +{ + short index; + + for (index = 0; index < MAXIMUM_SHARE_FILES; index++) + { + struct map_share_file const *file = &map_share_files_globals.files[index]; + + if (file->used && file->ready && !csmemcmp(file->hash, hash, MAP_SHARE_HASH_BYTES)) + return index; + } + return NONE; +} + +/* ---------- a download's pieces */ + +boolean map_share_pieces_hash( + char const *path, + long owner) +{ + struct map_share_files_globals *globals = &map_share_files_globals; + + if (globals->pieces_wanted || (globals->job_type == _hash_job_pieces)) + return globals->pieces_owner == owner; + globals->pieces_wanted = TRUE; + globals->pieces_owner = owner; + csstrncpy(globals->pieces_path, path, sizeof(globals->pieces_path) - 1); + globals->pieces_path[sizeof(globals->pieces_path) - 1] = 0; + return TRUE; +} + +boolean map_share_pieces_hashed( + long owner, + byte **piece_hashes, + long *piece_count, + unsigned long *size) +{ + struct map_share_files_globals *globals = &map_share_files_globals; + + if (!globals->pieces_done || globals->pieces_done_owner != owner) + return FALSE; + *piece_hashes = globals->pieces_hashes; + *piece_count = globals->pieces_count; + *size = globals->pieces_size; + globals->pieces_done = FALSE; + globals->pieces_hashes = NULL; + return TRUE; +} + +/* ---------- naming */ + +/* a folder and name's own number, to know it again */ +static unsigned long naming_key( + char const *folder, + char const *name) +{ + unsigned long key = 2166136261UL; + char const *text; + + for (text = folder; *text; text++) + key = (key ^ (unsigned char)(*text | 0x20)) * 16777619UL; + for (text = name; *text; text++) + key = (key ^ (unsigned char)(*text | 0x20)) * 16777619UL; + return key; +} + +static boolean naming_known( + unsigned long key) +{ + struct map_share_files_globals *globals = &map_share_files_globals; + long index; + + for (index = 0; index < globals->named_count; index++) + { + if (globals->named_hashes[index] == key) + return TRUE; + } + return FALSE; +} + +static void naming_remember( + unsigned long key) +{ + struct map_share_files_globals *globals = &map_share_files_globals; + + if (globals->named_count < MAXIMUM_NAMED) + globals->named_hashes[globals->named_count++] = key; +} + +void map_share_name_map( + char const *folder, + char const *name) +{ + struct map_share_files_globals *globals = &map_share_files_globals; + unsigned long key = naming_key(folder, name); + short index; + + /* (the custom_maps folder's alone: an install of Custom Edition is + left as it is, for Custom Edition) */ + if (csstrcasecmp(folder, CUSTOM_EDITION_MAP_DIRECTORY) || map_share_name_hashed(name) || naming_known(key)) + return; + for (index = 0; index < globals->naming_count; index++) + { + if (!csstrcasecmp(globals->naming_queue[index].name, name) && + !csstrcasecmp(globals->naming_queue[index].folder, folder)) + { + return; + } + } + if (globals->job_type == _hash_job_name && !csstrcasecmp(globals->job_naming.name, name) && + !csstrcasecmp(globals->job_naming.folder, folder)) + { + return; + } + if (globals->naming_count == MAXIMUM_NAMING_QUEUE) + return; + csstrncpy(globals->naming_queue[globals->naming_count].folder, folder, MAP_SHARE_PATH_SIZE - 1); + csstrncpy(globals->naming_queue[globals->naming_count].name, name, MAP_SHARE_PATH_SIZE - 1); + globals->naming_count++; +} + +/* renamed , if it is there +and that is not */ +static boolean rename_beside( + char const *folder, + char const *from, + char const *to, + char const *extension) +{ + char from_path[MAP_SHARE_PATH_SIZE]; + char to_path[MAP_SHARE_PATH_SIZE]; + + if (csstrlen(folder) + csstrlen(from) + csstrlen(extension) >= MAP_SHARE_PATH_SIZE || + csstrlen(folder) + csstrlen(to) + csstrlen(extension) >= MAP_SHARE_PATH_SIZE) + { + return FALSE; + } + csprintf(from_path, "%s%s%s", folder, from, extension); + csprintf(to_path, "%s%s%s", folder, to, extension); + return MoveFileA(from_path, to_path) != 0; +} + +/* a map hashed: renamed _.map (the name cut short to fit the +map lists), with its picture and description */ +static void naming_finish( + struct naming const *naming, + struct hash_job const *job) +{ + char digits[MAP_SHARE_NAME_DIGITS + 1]; + char hashed[MAP_SHARE_PATH_SIZE]; + char path[MAP_SHARE_PATH_SIZE]; + short base_length = (short)csstrlen(naming->name); + char const *loaded = custom_edition_cache_loaded_path(); + + csprintf(path, "%s%s.map", naming->folder, naming->name); + /* (in use: named another time) */ + if (loaded && !csstrcasecmp(loaded, path)) + return; + naming_remember(naming_key(naming->folder, naming->name)); + if (job->failed) + { + error(_error_silent, "map sharing: '%s' could not be read to name it for its hash", path); + return; + } + map_share_hash_digits(job->hash, digits); + base_length = (short)MIN(base_length, MAXIMUM_MAP_NAME_LENGTH - MAP_SHARE_NAME_DIGITS - 1); + csmemcpy(hashed, naming->name, base_length); + csprintf(hashed + base_length, "_%s", digits); + if (!rename_beside(naming->folder, naming->name, hashed, ".map")) + { + error(_error_silent, "map sharing: '%s' was not renamed %s.map (that file exists, or this one is in use)", + path, hashed); + return; + } + rename_beside(naming->folder, naming->name, hashed, ".bmp"); + rename_beside(naming->folder, naming->name, hashed, ".txt"); + error(_error_silent, "map sharing: %s.map is now %s.map, named for its hash", naming->name, hashed); + custom_edition_maps_look_again(); +} + +void map_share_level_name_hash( + char *level_name, + long size) +{ + struct naming naming; + struct hash_job job; + char path[MAP_SHARE_PATH_SIZE]; + char const *name; + char const *hashed; + + if (!level_name || !custom_edition_level_name(level_name)) + return; + name = tag_name_strip_path(level_name); + if (map_share_name_hashed(name) || !custom_edition_map_file_path(level_name, path)) + return; + /* (the file's own name, which may have its hash already) */ + hashed = tag_name_strip_path(path); + if (_strnicmp(path, CUSTOM_EDITION_MAP_DIRECTORY, csstrlen(CUSTOM_EDITION_MAP_DIRECTORY)) || + strchr(path + csstrlen(CUSTOM_EDITION_MAP_DIRECTORY), '\\')) + { + return; + } + if (!map_share_name_hashed(name) && csstrlen(hashed) > 4 && csstrlen(hashed) - 4 == csstrlen(name) && + !_strnicmp(hashed, name, csstrlen(name))) + { + /* named now: hashed here (a map's hash takes a fraction of a second + to work out) and renamed, unless it is loaded */ + csmemset(&naming, 0, sizeof(naming)); + csmemset(&job, 0, sizeof(job)); + csstrncpy(naming.folder, CUSTOM_EDITION_MAP_DIRECTORY, sizeof(naming.folder) - 1); + csstrncpy(naming.name, name, sizeof(naming.name) - 1); + csstrncpy(job.path, path, sizeof(job.path) - 1); + hash_job_run(&job); + naming_finish(&naming, &job); + free(job.piece_hashes); + if (!custom_edition_map_file_path(level_name, path)) + return; + hashed = tag_name_strip_path(path); + } + /* the level name of the file found: custom_maps\_ */ + if (csstrlen(hashed) <= 4 || csstrlen(CUSTOM_EDITION_LEVEL_NAME_PREFIX) + csstrlen(hashed) - 4 >= (unsigned long)size) + return; + csprintf(level_name, "%s%.*s", CUSTOM_EDITION_LEVEL_NAME_PREFIX, (int)(csstrlen(hashed) - 4), hashed); + if (!map_share_name_hashed(tag_name_strip_path(level_name))) + return; + error(_error_silent, "map sharing: the game's map is %s", level_name); +} + +/* ---------- the hashing thread's work */ + +void map_share_files_update( + void) +{ + struct map_share_files_globals *globals = &map_share_files_globals; + short index; + + if (globals->thread) + { + if (!thread_has_exited(globals->thread)) + return; + dispose_thread(globals->thread); + globals->thread = NULL; + switch (globals->job_type) + { + case _hash_job_file: + { + struct map_share_file *file = map_share_file_get((short)globals->job_owner); + + /* (replaced meanwhile: the catalog's file is another's now) */ + if (file && !csstrcasecmp(file->path, globals->job.path)) + { + /* (changed meanwhile: hashed again) */ + if (globals->job.failed || globals->job.size != file->size) + { + file->failed = globals->job.failed; + free(globals->job.piece_hashes); + } + else + { + file->piece_count = globals->job.piece_count; + file->piece_hashes = globals->job.piece_hashes; + csmemcpy(file->hash, globals->job.hash, MAP_SHARE_HASH_BYTES); + file->ready = TRUE; + } + } + else + { + free(globals->job.piece_hashes); + } + break; + } + case _hash_job_name: + naming_finish(&globals->job_naming, &globals->job); + free(globals->job.piece_hashes); + break; + case _hash_job_pieces: + free(globals->pieces_hashes); + globals->pieces_done = TRUE; + globals->pieces_done_owner = globals->job_owner; + globals->pieces_hashes = globals->job.failed ? NULL : globals->job.piece_hashes; + globals->pieces_count = globals->job.failed ? 0 : globals->job.piece_count; + globals->pieces_size = globals->job.size; + break; + } + globals->job.piece_hashes = NULL; + globals->job_type = _hash_job_none; + } + + /* the next: a download's pieces, a file to share, then a map to name */ + if (globals->pieces_wanted) + { + globals->pieces_wanted = FALSE; + if (!hash_job_start(_hash_job_pieces, globals->pieces_path, globals->pieces_owner)) + { + globals->pieces_done = TRUE; + globals->pieces_done_owner = globals->pieces_owner; + globals->pieces_hashes = NULL; + globals->pieces_count = 0; + } + return; + } + for (index = 0; index < MAXIMUM_SHARE_FILES; index++) + { + struct map_share_file *file = &globals->files[index]; + + if (file->used && !file->ready && !file->failed) + { + if (!hash_job_start(_hash_job_file, file->path, index)) + file->failed = TRUE; + return; + } + } + while (globals->naming_count > 0) + { + char path[MAP_SHARE_PATH_SIZE]; + + globals->job_naming = globals->naming_queue[0]; + globals->naming_count--; + csmemmove(&globals->naming_queue[0], &globals->naming_queue[1], + globals->naming_count * sizeof(globals->naming_queue[0])); + if (csstrlen(globals->job_naming.folder) + csstrlen(globals->job_naming.name) + 4 >= MAP_SHARE_PATH_SIZE) + continue; + csprintf(path, "%s%s.map", globals->job_naming.folder, globals->job_naming.name); + if (hash_job_start(_hash_job_name, path, NONE)) + return; + } +} diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index 76622c831e..2d14e433fd 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -126,7 +126,7 @@ void pc_menu_game_data_function_invoke(struct widget_instance *widget, long func /* ---------- constants */ -#define MAXIMUM_STRINGS 64 +#define MAXIMUM_STRINGS 128 /* the PC version's custom activation event (this engine never sends it) */ #define EVENT_CUSTOM_ACTIVATION 32 #define BUTTON_A 0 @@ -2839,10 +2839,11 @@ static char const *const server_settings_gametype_rows[] = /* the most players a co-op game hosted starts with (maximum_players') */ #define COOPERATIVE_DEFAULT_PLAYERS 16 /* Server Setup's help for co-op's options' rows (CO-OP OPTIONS, VOICE AND -VOTING), and PASSWORD's (its help_strings, tools/port_settings.py) */ +VOTING), PASSWORD's and MAP SHARING's (its help_strings, tools/port_settings.py) */ #define COOPERATIVE_OPTIONS_HELP 12 #define COOPERATIVE_VOICE_OPTIONS_HELP 13 #define SERVER_PASSWORD_HELP 14 +#define MAP_SHARE_OPTIONS_HELP 15 /* co-op's EXTRA ENEMIES' choices (network.coop_enemies_mode's values, as gametype_options' coop_extra_enemies_spinner has them, in this order): its @@ -2982,6 +2983,9 @@ static void server_settings_update(struct widget_instance *list) help->parameters.text_box.string_list_index = COOPERATIVE_OPTIONS_HELP; else if (help && list->focused_child == named(list, "op_voice_options", 0)) help->parameters.text_box.string_list_index = COOPERATIVE_VOICE_OPTIONS_HELP; + /* (MAP SHARING's, any game's: map_share.c) */ + else if (help && list->focused_child == named(list, "op_map_share_options", 0)) + help->parameters.text_box.string_list_index = MAP_SHARE_OPTIONS_HELP; } } /* LISTING (an internet game's): PUBLIC, listed in everyone's server @@ -3612,8 +3616,10 @@ static void lobby_browser_update(struct widget_instance *list) } else if (!lobby_browser.count) { - usnprintf(text, NUMBEROF(text) - 1, L"%s", now - lobby_browser.begin_time < LOBBY_BROWSER_LOOK_TIME ? - L"Looking for public games..." : L"No public games found"); + if (now - lobby_browser.begin_time < LOBBY_BROWSER_LOOK_TIME) + usnprintf(text, NUMBEROF(text) - 1, L"Looking for public games..."); + else + usnprintf(text, NUMBEROF(text) - 1, L"No public games for network version %d", HALO_PORT_NETWORK_VERSION); } else if (chosen < lobby_browser.count) { @@ -4748,6 +4754,11 @@ static struct gametype_option const gametype_options[] = { "2", "4", "8", "16", "32" } }, { "coop_player_collisions_spinner", _option_setting, 0, 0, 2, { 0 }, "network.coop_player_collisions", { "true", "false" } }, + /* map sharing's (Server Setup's MAP SHARING: tools/port_settings.py's + MAP_SHARE_SETUP; map_share.c) */ + { "map_share_spinner", _option_setting, 0, 0, 2, { 0 }, "network.map_share", { "true", "false" } }, + { "map_share_upload_spinner", _option_setting, 0, 0, 6, { 0 }, "network.map_share_upload", + { "256", "512", "1024", "2048", "4096", "8192" } }, /* vehicle options (the side's set and counts: vehicles_update) */ { "vehicles_respawn_spinner", _option_short, OPTIONS_FIELD(vehicle_respawn_time), 0, 7, { 0, 30, 60, 90, 120, 180, 300 } }, diff --git a/port/linux/game/menu_tags.c b/port/linux/game/menu_tags.c index 2fa0038a1a..0a0e311be9 100644 --- a/port/linux/game/menu_tags.c +++ b/port/linux/game/menu_tags.c @@ -89,7 +89,7 @@ adds to */ #define UNICODE_STRING_LIST_TAG 'ustr' #define FONT_TAG 'font' #define SOUND_TAG 'snd!' -#define MAXIMUM_STRINGS 64 +#define MAXIMUM_STRINGS 128 #define MAXIMUM_TEXT 2048 /* the size of a menu bitmap's D3D texture, which only stands for its PNG */ #define PLACEHOLDER_SIZE 4 diff --git a/port/linux/game/network_distributed.c b/port/linux/game/network_distributed.c index 76dce7a426..d53964ccaf 100644 --- a/port/linux/game/network_distributed.c +++ b/port/linux/game/network_distributed.c @@ -64,6 +64,7 @@ machine (their datum identifiers need not be). #include "main/main.h" #include "game/player_queues_new.h" #include "networking/network_game_globals.h" +#include "networking/network_connection.h" #include "objects/objects.h" #include "objects/damage.h" #include "scenario/scenario.h" @@ -75,6 +76,7 @@ machine (their datum identifiers need not be). #include "network_coop.h" #include "network_distributed.h" #include "network_voice.h" +#include "map_share.h" #include "profile_sections.h" #include @@ -3976,6 +3978,14 @@ void network_distributed_handle_message( network_voice_handle_message(machine_index, message, size); return; } + /* (map sharing's, in the lobby too, over the host's connection alone: + map_share.c) */ + if (map_share_handles_message(message, size)) + { + map_share_handle_message(machine_index, message, size, machine_index != NONE ? + distributed_handling_stream_message : !network_connection_last_read_was_unreliable()); + return; + } /* (none between games: loading, or in the menus) */ if (size < sizeof(header) || !game_in_progress()) return; diff --git a/port/linux/game/network_distributed.h b/port/linux/game/network_distributed.h index 0ba551911c..097fc599f8 100644 --- a/port/linux/game/network_distributed.h +++ b/port/linux/game/network_distributed.h @@ -118,6 +118,13 @@ enum _distributed_message_voice_up = 80, _distributed_message_voice_down = 81, _distributed_message_voice_config = 82, + /* map sharing (map_share.c), in the lobby too, reliably: a client's + request for the game's map, the host's offer (the files, their sizes and + hashes) and the machines that may have them, and the client's progress */ + _distributed_message_map_share_request = 83, + _distributed_message_map_share_offer = 84, + _distributed_message_map_share_sources = 85, + _distributed_message_map_share_progress = 86, NUMBER_OF_DISTRIBUTED_MESSAGES }; diff --git a/port/linux/game/network_test.c b/port/linux/game/network_test.c index eefd512ea2..0434f69fc5 100644 --- a/port/linux/game/network_test.c +++ b/port/linux/game/network_test.c @@ -946,7 +946,8 @@ void network_test_update( } count = p2p_lobby_games(games, NUMBEROF(games)); /* (the first that can be joined: no password, not failed) */ - while (count > 0 && (!games[0].invite[0] || games[0].failed)) + while (count > 0 && (!games[0].invite[0] || games[0].failed || !games[0].open || + games[0].player_count >= games[0].maximum_player_count)) { memmove(games, games + 1, sizeof(*games) * (count - 1)); count--; diff --git a/port/linux/include/halo_port_limits.h b/port/linux/include/halo_port_limits.h index 783d160681..8e8f67fa6e 100644 --- a/port/linux/include/halo_port_limits.h +++ b/port/linux/include/halo_port_limits.h @@ -66,7 +66,7 @@ is newer (network_client_manager.c). A host advertises it, with its netcode, in its game's advertisement's reserved bytes (network_server_message_handler.c), which hosts built before there was a version send as zeros: version 0. Raise it with any change to what the machines send each other. */ -#define HALO_PORT_NETWORK_VERSION 26 +#define HALO_PORT_NETWORK_VERSION 28 /* ... the advertisement's reserved bytes: the version (a little-endian word), then flags */ #define HALO_PORT_ADVERTISED_VERSION_OFFSET 0 diff --git a/port/linux/network_format.txt b/port/linux/network_format.txt new file mode 100644 index 0000000000..51e8727645 --- /dev/null +++ b/port/linux/network_format.txt @@ -0,0 +1,271 @@ +version 28 +bound DISCORD_ID_SIZE 24 +bound DISCORD_NAME_SIZE 40 +bound DISTRIBUTED_INPUT_HISTORY 4 +bound DISTRIBUTED_RELAYED_ACTION_MAXIMUM_SIZE 14 + 2 * (DISTRIBUTED_INPUT_HISTORY - 1) +bound DISTRIBUTED_RELAYED_ACTION_MINIMUM_SIZE 14 +bound DISTRIBUTED_UNIT_STATE_MAXIMUM_SIZE 3 + 4 + (4 + 2) + (12 + 3 * 6) + 2 + 2 * 2 + 4 * 2 + 2 * NUMBER_OF_PLAYER_POWERUPS + 2 + 1 +bound DISTRIBUTED_UNIT_STATE_MINIMUM_SIZE 3 +bound GAME_ALLEGIANCE_BITVECTOR_LONGS 4 +bound LEVEL_NAME_BYTES 64 +bound MAP_SHARE_HASH_BYTES 32 +bound MAXIMUM_DEVICE_GROUPS 1024 +bound MAXIMUM_DEVICE_STATES_PER_MESSAGE 64 +bound MAXIMUM_ENTRIES_PER_MESSAGE 64 +bound MAXIMUM_OBJECT_LOOKS_PER_MESSAGE 64 +bound MAXIMUM_OBJECT_NAMES_PER_SCENARIO 0x200 +bound MAXIMUM_OBJECT_TRANSFORMS_PER_MESSAGE 64 +bound MAXIMUM_PINGS_PER_MESSAGE 128 +bound MAXIMUM_QUEUED_DAMAGE_ANIMATIONS 32 +bound MAXIMUM_QUEUED_EVENTS 128 +bound MAXIMUM_REGIONS_PER_OBJECT 8 +bound MAXIMUM_STATISTICS_PER_MESSAGE 64 +bound MAXIMUM_WEAPONS_PER_UNIT 4 +bound NUMBER_OF_ANGLES enum f0ce7d0319b6 +bound NUMBER_OF_OBJECT_CHANGE_COLORS enum b00724872e2d +bound NUMBER_OF_PLAYER_POWERUPS enum 43075f42087c +bound NUMBER_OF_UNIT_GRENADE_TYPES enum a0462dbcbdd9 +bound OBJECT_NAME_BYTES MAXIMUM_OBJECT_NAMES_PER_SCENARIO / 8 +bound P2P_PLAYER_KEY_HASH_SIZE 16 +define DISTRIBUTED_ANGULAR_VELOCITY_SCALE 4096.0f +define DISTRIBUTED_UNIT_SCALE 32767.0f +define DISTRIBUTED_VELOCITY_SCALE 1024.0f +enum _distributed_actor_camouflaged_bit 4 +enum _distributed_actor_impulse_aligned_bit 6 +enum _distributed_actor_rides_bit 0 +enum _distributed_actor_running_blindly_bit 7 +enum _distributed_actor_shield_charging_bit 2 +enum _distributed_actor_shield_depleted_bit 1 +enum _distributed_actor_shield_over_charging_bit 3 +enum _distributed_actor_super_camouflaged_bit 5 +enum _distributed_message_actor_damage 73 +enum _distributed_message_actor_states 64 +enum _distributed_message_batch 16 +enum _distributed_message_client_identity 18 +enum _distributed_message_client_ready 9 +enum _distributed_message_coop_device_groups 68 +enum _distributed_message_coop_device_states 77 +enum _distributed_message_coop_events 71 +enum _distributed_message_coop_object_looks 74 +enum _distributed_message_coop_object_names 69 +enum _distributed_message_coop_object_transforms 72 +enum _distributed_message_coop_presentation 66 +enum _distributed_message_coop_screen_effect 76 +enum _distributed_message_coop_skip_vote 70 +enum _distributed_message_coop_sounds_retired 67 +enum _distributed_message_damage_animations 75 +enum _distributed_message_damage_events 10 +enum _distributed_message_game_state 7 +enum _distributed_message_hit_reports 11 +enum _distributed_message_inventories 4 +enum _distributed_message_map_share_offer 84 +enum _distributed_message_map_share_progress 86 +enum _distributed_message_map_share_request 83 +enum _distributed_message_map_share_sources 85 +enum _distributed_message_notice 17 +enum _distributed_message_object_changes 5 +enum _distributed_message_object_states 6 +enum _distributed_message_objects_synchronized 8 +enum _distributed_message_pickups 13 +enum _distributed_message_pings 19 +enum _distributed_message_player_inputs 14 +enum _distributed_message_player_prediction 1 +enum _distributed_message_player_statistics 3 +enum _distributed_message_relayed_actions 15 +enum _distributed_message_structure_bsp 65 +enum _distributed_message_unit_states 2 +enum _distributed_message_vehicle_prediction 12 +enum _distributed_message_voice_config 82 +enum _distributed_message_voice_down 81 +enum _distributed_message_voice_up 80 +enum _distributed_message_votekick 78 +enum _distributed_message_votekick_status 79 +enum _distributed_object_at_rest_bit 1 +enum _distributed_object_carried_bit 0 +enum _distributed_object_dead_bit 2 +enum _distributed_object_light_on_bit 5 +enum _distributed_object_light_sent_bit 4 +enum _distributed_object_predicted_bit 3 +enum _distributed_send_action_bit 1 +enum _distributed_send_sees_bit 2 +enum _distributed_send_state_bit 0 +enum _distributed_to_clients 0 +enum _distributed_to_clients_reliably 1 +enum _distributed_to_host 2 +enum _distributed_to_host_reliably 3 +enum _distributed_unit_alive_bit 0 +enum _distributed_unit_camouflaged_bit 0 +enum _distributed_unit_friendly_fire_bit 2 +enum _distributed_unit_killed_by_vehicle_bit 3 +enum _distributed_unit_part_camouflage_bit 4 +enum _distributed_unit_part_damage_bit 2 +enum _distributed_unit_part_killer_bit 5 +enum _distributed_unit_part_powerups_bit 3 +enum _distributed_unit_part_riding_bit 0 +enum _distributed_unit_part_up_bit 1 +enum _distributed_unit_placed_bit 1 +enum _distributed_unit_predicted_bit 7 +enum _distributed_unit_shield_charging_bit 5 +enum _distributed_unit_shield_depleted_bit 4 +enum _distributed_unit_shield_over_charging_bit 6 +enum _distributed_unit_super_camouflaged_bit 1 +enum _message_client_add_player_request_ingame 26 +enum _message_client_add_player_request_pregame 13 +enum _message_client_broadcast_game_search 0 +enum _message_client_game_start_request 17 +enum _message_client_game_update 25 +enum _message_client_graceful_game_exit_postgame 34 +enum _message_client_graceful_game_exit_pregame 18 +enum _message_client_host_crashed_cry_for_help 28 +enum _message_client_join_game_request 12 +enum _message_client_join_new_host 29 +enum _message_client_loaded 24 +enum _message_client_map_is_precached_pregame 19 +enum _message_client_ping 1 +enum _message_client_player_settings_request 16 +enum _message_client_remove_player_request_ingame 27 +enum _message_client_remove_player_request_postgame 32 +enum _message_client_remove_player_request_pregame 14 +enum _message_client_settings_request 15 +enum _message_client_switch_to_pregame 33 +enum _message_server_add_player_ingame 21 +enum _message_server_begin_game 8 +enum _message_server_game_advertise 2 +enum _message_server_game_over 23 +enum _message_server_game_settings_update 6 +enum _message_server_game_update 20 +enum _message_server_graceful_game_exit_postgame 31 +enum _message_server_graceful_game_exit_pregame 9 +enum _message_server_machine_accepted 4 +enum _message_server_machine_rejected 5 +enum _message_server_pong 3 +enum _message_server_postgame_keep_alive 11 +enum _message_server_pregame_countdown 7 +enum _message_server_pregame_keep_alive 10 +enum _message_server_remove_player_ingame 22 +enum _message_server_switch_to_pregame 30 +fields client_add_player_request_ingame 06ff007a9b35 +fields client_add_player_request_pregame 06ff007a9b35 +fields client_broadcast_game_search f31a5fac9a46 +fields client_game_start_request 758215ada4e1 +fields client_game_update 1134bf6a029b +fields client_graceful_game_exit_postgame 678699dea271 +fields client_graceful_game_exit_pregame 678699dea271 +fields client_host_crashed_cry_for_help 52eb2291ec03 +fields client_join_game_request 28f7514cbcd9 +fields client_join_new_host 52eb2291ec03 +fields client_loaded 678699dea271 +fields client_map_is_precached_pregame 0073b9d65051 +fields client_ping 0d113e26a89a +fields client_player_settings_request 06ff007a9b35 +fields client_remove_player_request_ingame 06ff007a9b35 +fields client_remove_player_request_postgame 06ff007a9b35 +fields client_remove_player_request_pregame 06ff007a9b35 +fields client_settings_request d6a7048d6284 +fields client_switch_to_pregame 678699dea271 +fields server_add_player_ingame 06ff007a9b35 +fields server_begin_game 678699dea271 +fields server_game_advertise 2d5f42b781a4 +fields server_game_over 678699dea271 +fields server_game_settings_update 4c2744d8b8e8 +fields server_game_update beee6fb1929d +fields server_graceful_game_exit_postgame 678699dea271 +fields server_graceful_game_exit_pregame 678699dea271 +fields server_machine_accepted 0d113e26a89a +fields server_machine_rejected 758215ada4e1 +fields server_pong 678699dea271 +fields server_postgame_keep_alive 758215ada4e1 +fields server_pregame_countdown 758215ada4e1 +fields server_pregame_keep_alive 758215ada4e1 +fields server_remove_player_ingame 1718d4189972 +fields server_switch_to_pregame 678699dea271 +size message_client_add_player_request_ingame 0x20 +size message_client_add_player_request_pregame 0x20 +size message_client_broadcast_game_search 0x0C +size message_client_game_start_request 0x02 +size message_client_game_update 0x88 +size message_client_graceful_game_exit_postgame 0x04 +size message_client_graceful_game_exit_pregame 0x04 +size message_client_host_crashed_cry_for_help 0x10 +size message_client_join_game_request 0x70 +size message_client_join_new_host 0x10 +size message_client_loaded 0x04 +size message_client_map_is_precached_pregame 0x100 +size message_client_ping 0x08 +size message_client_player_settings_request 0x20 +size message_client_remove_player_request_ingame 0x20 +size message_client_remove_player_request_postgame 0x20 +size message_client_remove_player_request_pregame 0x20 +size message_client_settings_request 0x44 +size message_client_switch_to_pregame 0x04 +size message_server_add_player_ingame 0x20 +size message_server_begin_game 0x04 +size message_server_game_advertise 0x114 +size message_server_game_over 0x04 +size message_server_game_settings_update 8 + HALO_PORT_NETWORK_GAME_SETTINGS_FRAGMENT_SIZE +size message_server_game_update 0x10 + HALO_PORT_MAXIMUM_NETWORK_PLAYERS * 0x20 +size message_server_graceful_game_exit_postgame 0x04 +size message_server_graceful_game_exit_pregame 0x04 +size message_server_machine_accepted 0x08 +size message_server_machine_rejected 0x02 +size message_server_pong 0x04 +size message_server_postgame_keep_alive 0x02 +size message_server_pregame_countdown 0x02 +size message_server_pregame_keep_alive 0x02 +size message_server_remove_player_ingame 0x24 +size message_server_switch_to_pregame 0x04 +struct distributed_actor_damage 96a57e13e2b8 +struct distributed_actor_damage_message f985e899a708 +struct distributed_actor_state 8bd5e771659d +struct distributed_actor_state_message 51a56e04a536 +struct distributed_client_identity 4d9e62025616 +struct distributed_coop_device_group 4aa3499be2cc +struct distributed_coop_device_groups_message b96ad0030ccc +struct distributed_coop_device_state 727444a5c79b +struct distributed_coop_device_states_message 48f17c3da6f5 +struct distributed_coop_event 62bbd76ba4af +struct distributed_coop_events_message 562bdd70bfae +struct distributed_coop_object_look 77cbae00e4ad +struct distributed_coop_object_looks_message 51dbb474aa17 +struct distributed_coop_object_names f3ecad927448 +struct distributed_coop_object_names_message 7d743ad3710e +struct distributed_coop_object_transform 055897bbff8f +struct distributed_coop_object_transforms_message de95468f7f6d +struct distributed_coop_presentation f34649b3fc6d +struct distributed_coop_presentation_message eb4c7a895272 +struct distributed_coop_screen_effect_message a0a226e0858e +struct distributed_coop_skip_vote c37a12a02932 +struct distributed_coop_skip_vote_message d21260f32711 +struct distributed_damage d2bdb398fb2b +struct distributed_damage_animation ae158e21c915 +struct distributed_damage_animations_message 863e25ca3614 +struct distributed_damage_event 659e4d49733b +struct distributed_damage_event_message c954363dcf26 +struct distributed_hit_report 9a44ca43a88b +struct distributed_hit_report_message 37279f2addca +struct distributed_inventory 05a279a76ba5 +struct distributed_inventory_message 11825a84dc29 +struct distributed_map_share_offer 4bea857123c2 +struct distributed_map_share_offer_file 204a3b73d193 +struct distributed_map_share_progress 2c3831013302 +struct distributed_map_share_request 3cdcbc8c3a49 +struct distributed_map_share_source 940f8a4c9849 +struct distributed_message_header 0e026a4933a1 +struct distributed_object_change 343e570cfa1d +struct distributed_object_change_message 4c3679cd4dae +struct distributed_object_state 1b656bbda892 +struct distributed_object_state_message 004c4b7aa530 +struct distributed_pickup 46e6f981ac40 +struct distributed_pings_message 35c6009dee29 +struct distributed_player_input b8d724b59f5c +struct distributed_player_ping 377e0e3045f4 +struct distributed_player_statistics 2a44afb8f96c +struct distributed_statistics_message 79134e6e9811 +struct distributed_structure_bsp 53f7d5067cc9 +struct distributed_structure_bsp_message 096278a05681 +struct distributed_vector e80d7f9fc970 +struct distributed_voice_config 665bdc14478b +struct distributed_voice_down e3751d692f16 +struct distributed_voice_up 3d74ee44e4cb +struct distributed_votekick_request d5f913f22d81 +struct distributed_votekick_status 966041386843 diff --git a/port/linux/src/d3d8_gl.c b/port/linux/src/d3d8_gl.c index 8e05e57ec8..daa4c08d0d 100644 --- a/port/linux/src/d3d8_gl.c +++ b/port/linux/src/d3d8_gl.c @@ -28,6 +28,7 @@ Conventions carried over from the Xbox: #include "main/console.h" #ifdef HALO_MACOS #include "../../macos/renderer_config.h" +extern int host_ios_metalfx_present(unsigned int texture, unsigned int width, unsigned int height, unsigned int output_width, unsigned int output_height); #endif #include @@ -189,6 +190,13 @@ static void screen_mode_choose(long *width, float scale[2]) *width = 1600; *width &= ~1L; scale[0] = scale[1] = 1.0f; +#ifdef HALO_IOS + const char *render_height = getenv("HALO_IOS_RENDER_HEIGHT"); + int height = render_height ? atoi(render_height) : 720; + if (height < 480) height = 480; + if (height > 2160) height = 2160; + scale[0] = scale[1] = height / 480.0f; +#endif #else long display_width, display_height; @@ -5720,6 +5728,11 @@ void WINAPI D3DDevice_Present(CONST RECT *source_rectangle, CONST RECT *destinat } x = (window_width - width) / 2; y = (window_height - height) / 2; +#ifdef HALO_MACOS + if (!host_ios_metalfx_present(back_buffer->target.texture, back_buffer->target.gl_width, + back_buffer->target.gl_height, width, height)) +#endif + { /* Creating an FBO binds both read and draw targets. Resolve it before selecting the window, including on the first/loading frame. */ read_framebuffer = framebuffer_get(back_buffer->target.texture, 0); @@ -5732,6 +5745,7 @@ void WINAPI D3DDevice_Present(CONST RECT *source_rectangle, CONST RECT *destinat /* row 0 of the render target is the top of the picture */ glBlitFramebuffer(0, 0, (GLint)back_buffer->target.gl_width, (GLint)back_buffer->target.gl_height, x, y + height, x + width, y, GL_COLOR_BUFFER_BIT, GL_LINEAR); + } platform_video_swap(); gl_check_errors("present"); xgpu_gl_state_invalidate(); diff --git a/port/linux/src/p2p.c b/port/linux/src/p2p.c index e790175d26..b2c8cf8873 100644 --- a/port/linux/src/p2p.c +++ b/port/linux/src/p2p.c @@ -121,6 +121,8 @@ enum PEER_TIMEOUT = 20000, PUNCH_TIMEOUT = 30000, JOIN_TIMEOUT = 90000, + /* another player of the game joined, asked for (p2p_reach_player) */ + PLAYER_REACH_TIMEOUT = 30000, STREAM_LINGER_TIME = 10000, PROXY_IDLE_TIME = 60000, STUN_RETRY_INTERVAL = 500, @@ -182,6 +184,11 @@ struct peer unsigned long long receive_window; unsigned long virtual_address; int is_host; + /* (a host's: another player of its game, reached for map sharing: + p2p_reach_player) */ + int is_player; + /* the hash of its public key, which another machine reaches it by */ + unsigned char key_hash[P2P_KEY_HASH_SIZE]; int connected; /* the WebRTC connection carrying its tunnel (p2p_webrtc.c: a browser's), or -1 (the UDP tunnel) */ @@ -363,6 +370,22 @@ static struct int reported_player_count; int reported_player_maximum; + /* answering the other players of the game joined (map sharing: + p2p_set_answering_players): wanted, and doing (p2p_signal_host with the + token joined with) */ + int answer_players; + int answering_players; + /* the other players of the game joined being reached (p2p_reach_player): + the hash of each one's key, and since when */ + struct + { + int used; + int asked; + unsigned char hash[P2P_KEY_HASH_SIZE]; + unsigned char identifier[P2P_IDENTIFIER_SIZE]; + unsigned long time; + } reaching[P2P_MAXIMUM_PLAYER_JOINS]; + /* joining: until the host is reached, or JOIN_TIMEOUT */ int join_requested; int joining; @@ -904,7 +927,8 @@ static void drop_peer(struct peer *peer, const char *reason) struct retired_session *retired = &p2p.retired[p2p.retired_next++ % MAXIMUM_RETIRED_SESSIONS]; int ask_again = p2p.joining && peer->is_host && !memcmp(peer->identifier, p2p.join_host, P2P_IDENTIFIER_SIZE); - platform_log("Internet play: %s %s: %s", peer->is_host ? "host" : "player", peer->name, reason); + platform_log("Internet play: %s %s: %s", peer->is_host && !peer->is_player ? "host" : "player", peer->name, + reason); if (peer->connected) { unsigned char bye = _packet_bye; @@ -997,10 +1021,12 @@ int p2p_peer_turned_away(const unsigned char *peer_identifier, int is_host) return 1; } -int p2p_peer_offered(const unsigned char *peer_identifier, const unsigned char *secret, - const struct p2p_candidate *candidates, int count, const struct p2p_webrtc *webrtc, int is_host) +int p2p_peer_offered(const unsigned char *peer_identifier, const unsigned char *public_key, const unsigned char *secret, + const struct p2p_candidate *candidates, int count, const struct p2p_webrtc *webrtc, int offered) { struct peer *peer = find_peer(peer_identifier); + /* (a player answering as a host does: the keys of a host's session) */ + int is_host = offered != _p2p_offered_joiner; if (!memcmp(peer_identifier, identifier, P2P_IDENTIFIER_SIZE)) return 0; @@ -1033,9 +1059,11 @@ int p2p_peer_offered(const unsigned char *peer_identifier, const unsigned char * peer->receive_window = 1; peer->virtual_address = virtual_address_for(peer_identifier); peer->is_host = is_host; + peer->is_player = offered == _p2p_offered_player; + p2p_key_hash(public_key, peer->key_hash); peer->connection = -1; peer->offered_time = p2p_now(); - platform_log("Internet play: reaching %s %s%s", is_host ? "host" : "player", peer->name, + platform_log("Internet play: reaching %s %s%s", offered == _p2p_offered_host ? "host" : "player", peer->name, webrtc->kind == _p2p_webrtc_browser ? " (a browser)" : ""); } add_candidates(peer, candidates, count); @@ -1110,9 +1138,9 @@ static void peer_heard(struct peer *peer, unsigned long address, unsigned short peer->endpoint.address = address; peer->endpoint.port = port; peer->endpoint_heard_time = now; - platform_log("Internet play: connected to %s %s at %s", peer->is_host ? "host" : "player", peer->name, - address_text(address, port, text)); - if (peer->is_host) + platform_log("Internet play: connected to %s %s at %s", peer->is_host && !peer->is_player ? "host" : "player", + peer->name, address_text(address, port, text)); + if (peer->is_host && !peer->is_player) { if (p2p.joining && !memcmp(p2p.join_host, peer->identifier, P2P_IDENTIFIER_SIZE)) { @@ -1408,6 +1436,26 @@ static int stun_settled(void) return 1; } +/* the p2p thread was stopped for longer than a peer lasts: the process was +suspended (a phone app in the background) or the machine slept (a laptop). +The NAT's mapping of the tunnel may have lapsed, so this machine's public +address is asked again before an offer names it, and the brokers' +connections, which the network may have dropped, are made anew rather than +at their silence timeout */ +static void network_resumed(unsigned long stopped) +{ + int index; + + for (index = 0; index < p2p.stun_count; index++) + { + p2p.stun[index].has_mapped = 0; + p2p.stun[index].attempts = 0; + } + p2p_signal_reconnect(); + platform_log("Internet play: the game was stopped for %lu seconds; its public address is asked again and " + "the brokers reconnected", stopped / 1000); +} + /* ---------- stand-ins for peers' ports */ static void close_socket(int *socket) @@ -1806,8 +1854,9 @@ void p2p_socket_port(int socket, int stream, int listening, unsigned short port) entry->stream = (unsigned char)(stream != 0); entry->listening |= (unsigned char)(listening != 0); } - /* the game listens for connections while it hosts */ - if (stream && listening) + /* the game listens for connections while it hosts (on any port but map + sharing's, which every player of a game listens on) */ + if (stream && listening && port != network_short(P2P_MAP_SHARE_PORT)) p2p.hosting_socket = socket; /* (and no stand-in that closed has the port now) */ forget_closed(stream != 0, port); @@ -2459,6 +2508,8 @@ static void update_joining(void) p2p.join_requested = 0; p2p.joining = 1; p2p.join_time = p2p_now(); + /* (the last game's players are not this one's) */ + memset(p2p.reaching, 0, sizeof(p2p.reaching)); p2p_hex(p2p.join_host, P2P_IDENTIFIER_SIZE, name); platform_log("Internet play: joining %s's game", name); p2p_signal_start(); @@ -2472,6 +2523,70 @@ static void update_joining(void) } } +/* the host of the game this machine joined, if the tunnel reaches it */ +static struct peer *joined_host(void) +{ + struct peer *peer = find_peer(p2p.join_host); + + return peer && peer->connected && peer->is_host && !peer->is_player ? peer : NULL; +} + +/* answering the other players of the game joined (with the token joined +with), as its host does, while asked and not hosting */ +static void update_answering(void) +{ + int want = p2p.answer_players && !p2p.hosting && joined_host() != NULL; + + if (want && !p2p.answering_players) + { + p2p.answering_players = 1; + p2p_signal_start(); + p2p_signal_host(p2p.join_token); + platform_log("Internet play: the game's other players may reach this machine (map sharing)"); + } + else if (!want && p2p.answering_players) + { + p2p.answering_players = 0; + /* (hosting took the topic over) */ + if (!p2p.hosting) + p2p_signal_stop_hosting(); + } +} + +/* the other players of the game joined that are asked for: until reached, +or PLAYER_REACH_TIMEOUT */ +static void update_reaching(void) +{ + int index; + + for (index = 0; index < P2P_MAXIMUM_PLAYER_JOINS; index++) + { + struct peer *peer; + + if (!p2p.reaching[index].used) + continue; + peer = find_peer(p2p.reaching[index].identifier); + if ((peer && peer->connected) || elapsed(p2p.reaching[index].time, PLAYER_REACH_TIMEOUT) || !joined_host()) + { + if (p2p.reaching[index].asked) + p2p_signal_stop_joining_player(p2p.reaching[index].identifier); + p2p.reaching[index].used = 0; + continue; + } + /* (the offer carries the public address, if there is one) */ + if (!p2p.reaching[index].asked && stun_settled()) + { + p2p_signal_start(); + if (!p2p_signal_join_player(p2p.reaching[index].hash, p2p.join_token)) + { + p2p.reaching[index].used = 0; + continue; + } + p2p.reaching[index].asked = 1; + } + } +} + static int connected_player_count(void) { int count = 0; @@ -2566,6 +2681,95 @@ static void update_hosting(void) } } +/* ---------- map sharing between the players of a game joined */ + +int p2p_peer_key_hash(unsigned long virtual_address, unsigned char *peer_identifier, unsigned char *key_hash) +{ + struct peer *peer; + int result = 0; + + if (!p2p.running) + return 0; + pthread_mutex_lock(&p2p_lock); + peer = find_peer_by_address(virtual_address); + if (peer) + { + memcpy(peer_identifier, peer->identifier, P2P_IDENTIFIER_SIZE); + memcpy(key_hash, peer->key_hash, P2P_KEY_HASH_SIZE); + result = 1; + } + pthread_mutex_unlock(&p2p_lock); + return result; +} + +void p2p_set_answering_players(int on) +{ + /* (only the game's thread writes it: unchanged, it need not wait for the + lock) */ + if (!p2p.running || !on == !p2p.answer_players) + return; + pthread_mutex_lock(&p2p_lock); + p2p.answer_players = on != 0; + pthread_mutex_unlock(&p2p_lock); +} + +int p2p_reach_player(const unsigned char *key_hash) +{ + unsigned char peer_identifier[P2P_IDENTIFIER_SIZE]; + int free_index = -1; + int result = 0; + int index; + + if (!p2p.running) + return 0; + p2p_identifier_from_hash(key_hash, peer_identifier); + pthread_mutex_lock(&p2p_lock); + if (!memcmp(peer_identifier, identifier, P2P_IDENTIFIER_SIZE) || !joined_host()) + { + pthread_mutex_unlock(&p2p_lock); + return 0; + } + /* (reached, or being reached: by its offer, or by the request below) */ + if (find_peer(peer_identifier)) + result = 1; + for (index = 0; index < P2P_MAXIMUM_PLAYER_JOINS && !result; index++) + { + if (p2p.reaching[index].used && !memcmp(p2p.reaching[index].hash, key_hash, P2P_KEY_HASH_SIZE)) + result = 1; + else if (!p2p.reaching[index].used && free_index < 0) + free_index = index; + } + if (!result && free_index >= 0) + { + memset(&p2p.reaching[free_index], 0, sizeof(p2p.reaching[free_index])); + p2p.reaching[free_index].used = 1; + memcpy(p2p.reaching[free_index].hash, key_hash, P2P_KEY_HASH_SIZE); + memcpy(p2p.reaching[free_index].identifier, peer_identifier, P2P_IDENTIFIER_SIZE); + p2p.reaching[free_index].time = p2p_now(); + result = 1; + } + pthread_mutex_unlock(&p2p_lock); + return result; +} + +int p2p_peer_reached(const unsigned char *peer_identifier, unsigned long *address) +{ + struct peer *peer; + int result = 0; + + if (!p2p.running) + return 0; + pthread_mutex_lock(&p2p_lock); + peer = find_peer(peer_identifier); + if (peer && peer->connected) + { + *address = peer->virtual_address; + result = 1; + } + pthread_mutex_unlock(&p2p_lock); + return result; +} + void p2p_set_hosting_allowed(int allowed) { pthread_mutex_lock(&p2p_lock); @@ -2952,6 +3156,7 @@ static void *p2p_thread(void *unused) streams; otherwise the thread can sleep longer */ int wait = webrtc_busy ? LOOP_INTERVAL : LOOP_INTERVAL * 5; int index, asked; + unsigned long waited; /* what to wait for */ read_owners[read_count] = _owner_tunnel; @@ -3012,6 +3217,7 @@ static void *p2p_thread(void *unused) memcpy(asked_write, write, sizeof(*write) * (size_t)write_count); pthread_mutex_unlock(&p2p_lock); + waited = p2p_now(); if (posix_socket_select(read, &read_count, write, &write_count, NULL, &error_count, 0, wait * 1000, 0) < 0) { @@ -3021,6 +3227,13 @@ static void *p2p_thread(void *unused) #ifdef HALO_PROFILE profile_trace_begin(p2p_profile_names.pass); #endif + /* the wait lasted far longer than asked (the process was suspended + or the machine slept): what the sockets report is stale */ + if (elapsed(waited, (unsigned long)wait + PEER_TIMEOUT)) + { + network_resumed(p2p_now() - waited); + read_count = write_count = 0; + } /* what is ready (the lists now hold only ready sockets, in the order asked, so each one's owner is found going along both); a socket @@ -3132,6 +3345,8 @@ static void *p2p_thread(void *unused) } } update_joining(); + update_answering(); + update_reaching(); update_upnp(); p2p_discord_update(); #ifdef HALO_ANDROID diff --git a/port/linux/src/p2p.h b/port/linux/src/p2p.h index 682a305ff6..c3d0ea3fb5 100644 --- a/port/linux/src/p2p.h +++ b/port/linux/src/p2p.h @@ -143,8 +143,8 @@ struct p2p_listing unsigned char signing_key[P2P_LISTING_SIGNING_KEY_SIZE]; unsigned char sealed_token[P2P_LISTING_SEALED_TOKEN_SIZE]; }; -/* a copy of the public games found, in the order shown (the most players -first; then those not failed, the open ones, by name); returns their count */ +/* a copy of public games: joinable games first, then most players, then +failed/open status and name; returns their count */ int p2p_lobby_games(struct p2p_listing *games, int maximum_count); /* a game the browser could not join: kept, marked failed, for this run */ void p2p_lobby_mark_failed(const unsigned char *identifier); @@ -180,4 +180,39 @@ virtual address (network byte order): where its packets come from; 0 if it is no peer's */ unsigned long p2p_peer_endpoint_address(unsigned long virtual_address); +/* ---- map sharing between the players of a game (port/linux/game/map_share.c) + +The players of an internet game reach its host alone. To share a map they +reach each other too: a host tells a player the key hash of another, which +answers holders of the invite it joined with as its host does (only while +answering), and the player asks it for a session as it asked the host. */ + +enum +{ + /* the hash of a machine's public key, which reaches it */ + P2P_PLAYER_KEY_HASH_SIZE = 16, + /* the port each machine takes the others' map sharing connections on: + its game listens there without hosting */ + P2P_MAP_SHARE_PORT = 5153, +}; + +/* the identifier (6 bytes) and key hash of the internet play peer of this +virtual address (network byte order): 0 if it is no peer's */ +int p2p_peer_key_hash(unsigned long virtual_address, unsigned char *identifier, unsigned char *key_hash); +/* whether this machine, a player of an internet game (not its host), +answers the game's other players: holders of the invite it joined with */ +void p2p_set_answering_players(int on); +/* asks for a session with another player of the internet game this +machine joined, whose key has this hash (as the game's host told), if there +is none: nonzero if there is one, or it is asked for (for some seconds, or +until reached; asked again, it is asked for anew) */ +int p2p_reach_player(const unsigned char *key_hash); +/* whether the tunnel reaches the machine with this identifier, and the +virtual address (network byte order) the game reaches it at */ +int p2p_peer_reached(const unsigned char *identifier, unsigned long *address); + +/* BLAKE2b with a 32-byte digest (Monocypher's): a map's pieces +(map_share.c) */ +void p2p_blake2b_256(const void *data, unsigned long size, unsigned char *digest); + #endif diff --git a/port/linux/src/p2p_crypto.c b/port/linux/src/p2p_crypto.c index 935c00a36e..3ddcc2afc1 100644 --- a/port/linux/src/p2p_crypto.c +++ b/port/linux/src/p2p_crypto.c @@ -715,3 +715,10 @@ void p2p_webrtc_credentials(const unsigned char *secret, char *ufrag, char *pass p2p_hmac_sha256(secret, P2P_SHA256_SIZE, "webrtc ice-pwd", 14, digest); p2p_hex(digest, 16, password); } + +/* ---------- BLAKE2b (Monocypher's): map sharing's pieces (map_share.c) */ + +void p2p_blake2b_256(const void *data, unsigned long size, unsigned char *digest) +{ + crypto_blake2b(digest, 32, (const uint8_t *)data, (size_t)size); +} diff --git a/port/linux/src/p2p_internal.h b/port/linux/src/p2p_internal.h index b3e54d6a64..3bd8328959 100644 --- a/port/linux/src/p2p_internal.h +++ b/port/linux/src/p2p_internal.h @@ -31,6 +31,22 @@ enum /* the most machines one tunnels to: a host and the rest of a system link game's 128 machines (include/halo_port_limits.h) */ P2P_MAXIMUM_PEERS = 127, + /* the other players of its game a machine asks to reach at once + (p2p_reach_player) */ + P2P_MAXIMUM_PLAYER_JOINS = 8, +}; + +typedef char p2p_player_key_hash_size_assert[P2P_PLAYER_KEY_HASH_SIZE == P2P_KEY_HASH_SIZE ? 1 : -1]; + +/* who offered a session (p2p_peer_offered): a joiner (to this machine, +hosting or answering its game's players), the invite's host, or another +player of the host's game this machine asked for (p2p_reach_player), which +answers as a host does */ +enum +{ + _p2p_offered_joiner, + _p2p_offered_host, + _p2p_offered_player, }; typedef char p2p_listing_invite_size_assert[P2P_LISTING_INVITE_SIZE == P2P_LINK_SIZE ? 1 : -1]; @@ -85,13 +101,14 @@ void p2p_identifier_from_hash(const unsigned char *hash, unsigned char *identifi lets go of the p2p lock while it works it out */ int p2p_shared_secret(const unsigned char *public_key, unsigned char *shared); /* a joiner (on the host) or the host (on a joiner) offered its addresses and -its WebRTC through signalling, with the secret of a session (P2P_SHA256_SIZE bytes) its -tunnel's keys come from; the tunnel starts reaching it. Returns 0 if it was -turned away: another session with that machine lives (it must lapse first), -this one has ended, or there is no room */ +its WebRTC through signalling, with its public key and the secret of a +session (P2P_SHA256_SIZE bytes) its tunnel's keys come from; the tunnel +starts reaching it. offered is who it is (_p2p_offered_*). Returns 0 if it +was turned away: another session with that machine lives (it must lapse +first), this one has ended, or there is no room */ struct p2p_webrtc; -int p2p_peer_offered(const unsigned char *identifier, const unsigned char *secret, - const struct p2p_candidate *candidates, int count, const struct p2p_webrtc *webrtc, int is_host); +int p2p_peer_offered(const unsigned char *identifier, const unsigned char *public_key, const unsigned char *secret, + const struct p2p_candidate *candidates, int count, const struct p2p_webrtc *webrtc, int offered); /* whether p2p_peer_offered would turn a new session with that machine away now (it is this machine, a session with it lives, there is no room, or, as a host, too many players are being reached): checked before its secret is @@ -128,8 +145,19 @@ asks anew, with a new nonce (as after the session with the host ended before the tunnel reached it: the host makes one session of a request) */ void p2p_signal_join(const unsigned char *host_hash, const unsigned char *token); void p2p_signal_stop_joining(void); +/* ... and another player of the host's game (whose key has this hash, as +the host told), which answers holders of the token as its host does +(p2p_signal_host with the token it joined with), alongside the request to +the host; 0 if there is no room, or the request to the host is of another +token. Stopped by its identifier */ +int p2p_signal_join_player(const unsigned char *player_hash, const unsigned char *token); +void p2p_signal_stop_joining_player(const unsigned char *identifier); /* whether any broker is connected */ int p2p_signal_connected(void); +/* every broker's connection made anew at once: after the p2p thread was +stopped (the process was suspended, or the machine slept), the old ones +may carry nothing, and would be noticed only at their silence timeout */ +void p2p_signal_reconnect(void); /* the server browser's topics: the own slot and the queries (a listed game), and every slot (browsing) */ void p2p_signal_lobby_topics(int listed, int browsing); diff --git a/port/linux/src/p2p_lobby.c b/port/linux/src/p2p_lobby.c index 105d7a0891..18a7fb6364 100644 --- a/port/linux/src/p2p_lobby.c +++ b/port/linux/src/p2p_lobby.c @@ -815,11 +815,15 @@ int p2p_lobby_browsing(void) return lobby.browsing; } -/* the order shown: the most players first; then those joining has not -failed, the open ones, and by name */ +/* Prefer games that can be joined, then the most players, then name. */ static int listing_order(const void *first, const void *second) { const struct p2p_listing *a = first, *b = second; + int a_joinable = a->open && !a->failed && a->player_count < a->maximum_player_count; + int b_joinable = b->open && !b->failed && b->player_count < b->maximum_player_count; + + if (a_joinable != b_joinable) + return b_joinable - a_joinable; if (a->player_count != b->player_count) return b->player_count - a->player_count; @@ -832,20 +836,27 @@ static int listing_order(const void *first, const void *second) int p2p_lobby_games(struct p2p_listing *games, int maximum_count) { + struct p2p_listing found[MAXIMUM_GAMES]; int count = 0; int index; + if (maximum_count <= 0) + return 0; pthread_mutex_lock(&p2p_lock); - for (index = 0; index < MAXIMUM_GAMES && count < maximum_count; index++) + for (index = 0; index < MAXIMUM_GAMES; index++) { if (!lobby.games[index].used) continue; - games[count] = lobby.games[index].listing; - games[count].failed = (unsigned char)identifier_failed(games[count].identifier); + found[count] = lobby.games[index].listing; + found[count].failed = (unsigned char)identifier_failed(found[count].identifier); count++; } pthread_mutex_unlock(&p2p_lock); - qsort(games, (size_t)count, sizeof(*games), listing_order); + /* Rank every listing before truncating to the caller's visible rows. */ + qsort(found, (size_t)count, sizeof(*found), listing_order); + if (count > maximum_count) + count = maximum_count; + memcpy(games, found, (size_t)count * sizeof(*games)); return count; } diff --git a/port/linux/src/p2p_signal.c b/port/linux/src/p2p_signal.c index a3c90eac02..4d4173968f 100644 --- a/port/linux/src/p2p_signal.c +++ b/port/linux/src/p2p_signal.c @@ -83,6 +83,9 @@ enum /* the joiners a host made sessions for, so a repeated request gets the same session: as many as it takes */ MAXIMUM_JOINERS = P2P_MAXIMUM_PEERS + 1, + /* the requests to join a machine makes at once: the invite's host, and + other players of its game (p2p_signal_join_player) */ + MAXIMUM_JOINS = 1 + P2P_MAXIMUM_PLAYER_JOINS, /* the nonces of an asker's key it keeps the answer times of */ ASKER_NONCES = 4, /* the requests not proven yet whose keys' work a host keeps, one a key; @@ -273,6 +276,29 @@ struct joiner int used; }; +/* a request to join: to the invite's host, or to another player of its +game (who answers as a host does) */ +struct join +{ + int active; + /* the identifier of the machine asked, and the hash of its key (the + invite's, or as the game's host told) */ + unsigned char host[P2P_IDENTIFIER_SIZE]; + unsigned char host_hash[P2P_KEY_HASH_SIZE]; + unsigned char nonce[NONCE_SIZE]; + /* its public key, once it answered, and pair_base's */ + int has_base; + unsigned char host_public[P2P_KEY_SIZE]; + unsigned char base[P2P_SHA256_SIZE]; + char host_topic[TOPIC_SIZE]; + unsigned long sent_time; + /* when nonce was made, and whether the machine answered it (with + host_nonce, which the proof carries) */ + unsigned long nonce_time; + int answered; + unsigned char host_nonce[NONCE_SIZE]; +}; + /* work allowed: some at once, and one more each interval */ struct budget { @@ -329,24 +355,13 @@ static struct struct budget key_work; struct budget unproven_answers; - /* joining: the host's identifier, and the hash of its key (the invite's) */ - int joining; - unsigned char join_host[P2P_IDENTIFIER_SIZE]; - unsigned char join_host_hash[P2P_KEY_HASH_SIZE]; + /* joining: the invite's host (joins[0]), and the other players of its + game this machine asks for as their host does for it (p2p_signal_join_player); + all of one token, whose answers come to one topic */ + struct join joins[MAXIMUM_JOINS]; + unsigned char join_token[P2P_TOKEN_SIZE]; unsigned char join_key[P2P_SHA256_SIZE]; - unsigned char join_nonce[NONCE_SIZE]; - /* the host's public key, once it answered, and pair_base's */ - int join_has_base; - unsigned char join_host_public[P2P_KEY_SIZE]; - unsigned char join_base[P2P_SHA256_SIZE]; - char join_host_topic[TOPIC_SIZE]; char join_topic[TOPIC_SIZE]; - unsigned long join_sent_time; - /* when join_nonce was made, and whether the host answered it (with - join_host_nonce, which its proof carries) */ - unsigned long join_nonce_time; - int join_answered; - unsigned char join_host_nonce[NONCE_SIZE]; } signalling; static int elapsed(unsigned long since, unsigned long time) @@ -384,6 +399,10 @@ static int budget_left(struct budget *budget, int maximum, int interval, int spe return 1; } +static int joins_active(void); +static void send_join(struct join *join); +static void sync_all_topics(void); + /* ---------- what is derived from a token */ static void derive(const unsigned char *token, const char *label, const unsigned char *identifier, @@ -660,7 +679,7 @@ static void broker_sync_topics(struct broker *broker) if (broker->state != _broker_ready) return; wanted[_topic_host] = signalling.hosting ? signalling.host_topic : ""; - wanted[_topic_join] = signalling.joining ? signalling.join_topic : ""; + wanted[_topic_join] = joins_active() ? signalling.join_topic : ""; wanted[_topic_own_slot] = lobby && signalling.lobby_listed ? signalling.own_slot : ""; wanted[_topic_query] = lobby && signalling.lobby_listed ? P2P_LOBBY_QUERY_TOPIC : ""; wanted[_topic_slots] = lobby && signalling.lobby_browsing ? P2P_LOBBY_SLOT_PREFIX "+" : ""; @@ -1047,7 +1066,7 @@ static int host_nonce_current(const unsigned char *public_key, const unsigned ch /* ---------- the messages */ -static void send_join(void) +static void send_join(struct join *join) { unsigned char message[MAXIMUM_MESSAGE_SIZE]; unsigned char sealed[MAXIMUM_MESSAGE_SIZE + P2P_SEAL_OVERHEAD]; @@ -1057,21 +1076,49 @@ static void send_join(void) message[size++] = MESSAGE_VERSION; memcpy(message + size, p2p_public_key(), P2P_KEY_SIZE); size += P2P_KEY_SIZE; - memcpy(message + size, signalling.join_nonce, NONCE_SIZE); + memcpy(message + size, join->nonce, NONCE_SIZE); size += NONCE_SIZE; - size += put_addresses(message + size, signalling.join_host, 1); + size += put_addresses(message + size, join->host, 1); /* answered: the proof that this machine holds its key, of which the host makes the session */ - if (signalling.join_answered) + if (join->answered) { - memcpy(message + size, signalling.join_host_nonce, NONCE_SIZE); + memcpy(message + size, join->host_nonce, NONCE_SIZE); size += NONCE_SIZE; - message_tag(signalling.join_base, "join", message, size, message + size); + message_tag(join->base, "join", message, size, message + size); size += TAG_SIZE; } size = p2p_seal(signalling.join_key, message, size, sealed); - publish_everywhere(signalling.join_host_topic, sealed, size); - signalling.join_sent_time = p2p_now(); + publish_everywhere(join->host_topic, sealed, size); + join->sent_time = p2p_now(); +} + +/* whether any request to join is under way */ +static int joins_active(void) +{ + int index; + + for (index = 0; index < MAXIMUM_JOINS; index++) + { + if (signalling.joins[index].active) + return 1; + } + return 0; +} + +/* starts a request to the machine whose key has this hash, with a new +nonce (the host makes one session of a request) */ +static void join_start(struct join *join, const unsigned char *host_hash) +{ + memset(join, 0, sizeof(*join)); + memcpy(join->host_hash, host_hash, P2P_KEY_HASH_SIZE); + p2p_identifier_from_hash(host_hash, join->host); + make_topic(signalling.join_token, "host", join->host, join->host_topic); + posix_random_bytes(join->nonce, NONCE_SIZE); + join->nonce_time = p2p_now(); + join->active = 1; + sync_all_topics(); + send_join(join); } /* the request of a key and nonce (NULL: any) in a list */ @@ -1286,7 +1333,7 @@ static void join_received(struct broker *broker, const unsigned char *message, i if (signalling.used_request_count == MAXIMUM_USED_REQUESTS && !elapsed(used->time, USED_REQUEST_TIME)) return; session_secret(base, nonce, host_nonce, secret); - if (!p2p_peer_offered(identifier, secret, candidates, count, &webrtc, 0)) + if (!p2p_peer_offered(identifier, public_key, secret, candidates, count, &webrtc, _p2p_offered_joiner)) return; memcpy(used->request, request, sizeof(request)); used->time = p2p_now(); @@ -1308,7 +1355,7 @@ static void join_received(struct broker *broker, const unsigned char *message, i send_accept(broker, identifier, nonce, host_nonce, base, 1); } -/* the joiner: the host answered */ +/* the joiner: the host (or the player) asked answered */ static void accept_received(const unsigned char *message, int size) { struct p2p_candidate candidates[P2P_MAXIMUM_CANDIDATES]; @@ -1319,44 +1366,57 @@ static void accept_received(const unsigned char *message, int size) const unsigned char *nonce = host_public + P2P_KEY_SIZE; const unsigned char *host_nonce = nonce + NONCE_SIZE; int fixed = 2 + P2P_KEY_SIZE + 2 * NONCE_SIZE; + struct join *join = NULL; int count; + int index; - if (size < fixed + 1 + TAG_SIZE || memcmp(nonce, signalling.join_nonce, NONCE_SIZE)) + if (size < fixed + 1 + TAG_SIZE) + return; + /* (the request it answers: by its nonce) */ + for (index = 0; index < MAXIMUM_JOINS && !join; index++) + { + if (signalling.joins[index].active && !memcmp(nonce, signalling.joins[index].nonce, NONCE_SIZE)) + join = &signalling.joins[index]; + } + if (!join) return; /* the first answer to the request holds: the proof carries its host nonce (the host's changes every HOST_NONCE_PERIOD) */ - if (signalling.join_answered && (memcmp(host_public, signalling.join_host_public, P2P_KEY_SIZE) || - memcmp(host_nonce, signalling.join_host_nonce, NONCE_SIZE))) + if (join->answered && (memcmp(host_public, join->host_public, P2P_KEY_SIZE) || + memcmp(host_nonce, join->host_nonce, NONCE_SIZE))) { return; } - /* the invite's host: its key has the hash in the invite */ + /* the machine asked: its key has the hash in the invite (or the one + the game's host told) */ p2p_key_hash(host_public, hash); - if (memcmp(hash, signalling.join_host_hash, P2P_KEY_HASH_SIZE)) + if (memcmp(hash, join->host_hash, P2P_KEY_HASH_SIZE)) return; - if (!signalling.join_has_base || memcmp(signalling.join_host_public, host_public, P2P_KEY_SIZE)) + if (!join->has_base || memcmp(join->host_public, host_public, P2P_KEY_SIZE)) { - if (!pair_base(p2p_public_key(), host_public, host_public, signalling.join_base)) + if (!pair_base(p2p_public_key(), host_public, host_public, join->base)) return; - memcpy(signalling.join_host_public, host_public, P2P_KEY_SIZE); - signalling.join_has_base = 1; + memcpy(join->host_public, host_public, P2P_KEY_SIZE); + join->has_base = 1; } /* and the answer is its */ - if (!tag_right(signalling.join_base, "accept", message, size)) + if (!tag_right(join->base, "accept", message, size)) return; if (get_addresses(message + fixed, size - TAG_SIZE - fixed, candidates, &count, &webrtc) != size - TAG_SIZE - fixed) { return; } - session_secret(signalling.join_base, nonce, host_nonce, secret); - if (!p2p_peer_offered(signalling.join_host, secret, candidates, count, &webrtc, 1) || - signalling.join_answered) + session_secret(join->base, nonce, host_nonce, secret); + if (!p2p_peer_offered(join->host, host_public, secret, candidates, count, &webrtc, + join == &signalling.joins[0] ? _p2p_offered_host : _p2p_offered_player) || join->answered) + { return; - memcpy(signalling.join_host_nonce, host_nonce, NONCE_SIZE); - signalling.join_answered = 1; + } + memcpy(join->host_nonce, host_nonce, NONCE_SIZE); + join->answered = 1; /* the proof, at once */ - send_join(); + send_join(join); } static void publish_received(struct broker *broker, const char *topic, const unsigned char *payload, int size, @@ -1383,7 +1443,7 @@ static void publish_received(struct broker *broker, const char *topic, const uns if (message_size >= 2 && message[0] == _message_join && message[1] == MESSAGE_VERSION) join_received(broker, message, message_size); } - else if (signalling.joining && !strcmp(topic, signalling.join_topic)) + else if (joins_active() && !strcmp(topic, signalling.join_topic)) { message_size = p2p_open(signalling.join_key, payload, size, message); if (message_size >= 2 && message[0] == _message_accept && message[1] == MESSAGE_VERSION) @@ -1491,9 +1551,16 @@ static int broker_acknowledged(struct broker *broker, const unsigned char *body, broker->publish_tokens = PUBLISH_BURST; broker->publish_time = p2p_now(); broker_sync_topics(broker); - /* a joiner's first request need not wait for the next repeat */ - if (signalling.joining) - send_join(); + /* a joiner's first requests need not wait for the next repeat */ + { + int index; + + for (index = 0; index < MAXIMUM_JOINS; index++) + { + if (signalling.joins[index].active) + send_join(&signalling.joins[index]); + } + } return 1; } @@ -1822,14 +1889,21 @@ void p2p_signal_update(const int *read, int read_count, const int *write, int wr } broker_update_lobby(broker); } - if (signalling.joining && !signalling.join_answered && elapsed(signalling.join_nonce_time, UNANSWERED_TIME)) + for (index = 0; index < MAXIMUM_JOINS; index++) { - posix_random_bytes(signalling.join_nonce, NONCE_SIZE); - signalling.join_nonce_time = p2p_now(); - send_join(); + struct join *join = &signalling.joins[index]; + + if (!join->active) + continue; + if (!join->answered && elapsed(join->nonce_time, UNANSWERED_TIME)) + { + posix_random_bytes(join->nonce, NONCE_SIZE); + join->nonce_time = p2p_now(); + send_join(join); + } + if (elapsed(join->sent_time, JOIN_INTERVAL)) + send_join(join); } - if (signalling.joining && elapsed(signalling.join_sent_time, JOIN_INTERVAL)) - send_join(); } int p2p_signal_connected(void) @@ -1844,6 +1918,23 @@ int p2p_signal_connected(void) return 0; } +void p2p_signal_reconnect(void) +{ + int index; + + for (index = 0; index < signalling.broker_count; index++) + { + struct broker *broker = &signalling.brokers[index]; + + if (broker->state == _broker_idle && broker->socket < 0 && !broker->failures) + continue; + broker_close(broker, 0); + broker->failures = 0; + /* (again at once) */ + broker->state_time = p2p_now() - MAXIMUM_RETRY_INTERVAL; + } +} + static void sync_all_topics(void) { int index; @@ -1876,28 +1967,72 @@ void p2p_signal_stop_hosting(void) void p2p_signal_join(const unsigned char *host_hash, const unsigned char *token) { - memcpy(signalling.join_host_hash, host_hash, P2P_KEY_HASH_SIZE); - p2p_identifier_from_hash(host_hash, signalling.join_host); + int index; + + /* (the requests to the last game's players end: they hold another + token, whose answers come to another topic) */ + for (index = 1; index < MAXIMUM_JOINS; index++) + signalling.joins[index].active = 0; + memcpy(signalling.join_token, token, P2P_TOKEN_SIZE); derive(token, "seal", NULL, signalling.join_key); - make_topic(token, "host", signalling.join_host, signalling.join_host_topic); make_topic(token, "joiner", p2p_identifier(), signalling.join_topic); /* (new each time: the host makes one session of a request; a browser's connection for the last goes, unless a session took it up) */ p2p_webrtc_new_request(); - posix_random_bytes(signalling.join_nonce, NONCE_SIZE); - signalling.join_nonce_time = p2p_now(); - signalling.join_answered = 0; - signalling.joining = 1; - sync_all_topics(); - send_join(); + join_start(&signalling.joins[0], host_hash); } void p2p_signal_stop_joining(void) { - signalling.joining = 0; + signalling.joins[0].active = 0; sync_all_topics(); } +int p2p_signal_join_player(const unsigned char *player_hash, const unsigned char *token) +{ + struct join *free_join = NULL; + int index; + + /* (another token's requests would be answered on another topic) */ + if (signalling.joins[0].active && memcmp(token, signalling.join_token, P2P_TOKEN_SIZE)) + return 0; + for (index = 1; index < MAXIMUM_JOINS; index++) + { + struct join *join = &signalling.joins[index]; + + if (join->active && !memcmp(join->host_hash, player_hash, P2P_KEY_HASH_SIZE)) + return 1; + if (!join->active && !free_join) + free_join = join; + } + if (!free_join) + return 0; + if (!joins_active() || memcmp(token, signalling.join_token, P2P_TOKEN_SIZE)) + { + memcpy(signalling.join_token, token, P2P_TOKEN_SIZE); + derive(token, "seal", NULL, signalling.join_key); + make_topic(token, "joiner", p2p_identifier(), signalling.join_topic); + for (index = 1; index < MAXIMUM_JOINS; index++) + signalling.joins[index].active = 0; + } + join_start(free_join, player_hash); + return 1; +} + +void p2p_signal_stop_joining_player(const unsigned char *identifier) +{ + int index; + + for (index = 1; index < MAXIMUM_JOINS; index++) + { + if (signalling.joins[index].active && !memcmp(signalling.joins[index].host, identifier, P2P_IDENTIFIER_SIZE)) + { + signalling.joins[index].active = 0; + sync_all_topics(); + } + } +} + void p2p_signal_lobby_topics(int listed, int browsing) { signalling.lobby_listed = listed; diff --git a/port/linux/src/port_config.c b/port/linux/src/port_config.c index b803f9a7a6..a57e5e5bf2 100644 --- a/port/linux/src/port_config.c +++ b/port/linux/src/port_config.c @@ -176,6 +176,24 @@ static const struct config_setting config_settings[] = "of each vertex shows across curved surfaces; false lights each vertex,\n" "as the Xbox does." }, + { "display.viewmodel_shield", _config_boolean, "false", "HALO_VIEWMODEL_SHIELD", _environment_value, _platform_all, + "The energy shield's flare on the first-person arms too, as on the body\n" + "(the stock game draws it on the body only)." }, + { "display.first_person_legs", _config_boolean, "false", "HALO_FIRST_PERSON_LEGS", _environment_value, _platform_all, + "Your own legs in first person, seen looking down: your body as others\n" + "see it, drawn from the waist down, moving as it moves." }, + { "display.shield_glow", _config_boolean, "false", "HALO_SHIELD_GLOW", _environment_value, _platform_all, + "Energy shields light what is around them as they flare, as plasma\n" + "does, in the shield's own color." }, + { "display.shield_glow_intensity", _config_string, "\"default\"", "HALO_SHIELD_GLOW_INTENSITY", _environment_value, _platform_all, + "How far the shield glow reaches: \"default\" or \"light_show\" (three\n" + "times as far, and at full strength from a third of the flare)." }, + { "display.shield_color", _config_string, "\"default\"", "HALO_SHIELD_COLOR", _environment_value, _platform_all, + "Your energy shield's color, as this machine draws it: \"default\" (the\n" + "shield's own) or a multiplayer armor color: white, black, red, blue,\n" + "gray, yellow, green, pink, purple, cyan, cobalt, orange, teal, sage,\n" + "brown, tan, maroon or salmon." }, + { "audio.enabled", _config_boolean, "true", "HALO_NO_AUDIO", _environment_set_is_false, _platform_all, "Play sound." }, { "audio.volume", _config_real, "1.0", "HALO_VOLUME", _environment_value, _platform_all, @@ -235,7 +253,7 @@ static const struct config_setting config_settings[] = "The touch controls' swipe aiming gets a controller's aim assist: the\n" "aim slows over a target and follows a moving one. false: none, as a\n" "mouse (the bullets' own autoaim stays)." }, - { "input.mouse_sensitivity", _config_real, "1.0", "HALO_MOUSE_SENSITIVITY", _environment_value, _platform_desktop, + { "input.mouse_sensitivity", _config_real, "0.1", "HALO_MOUSE_SENSITIVITY", _environment_value, _platform_desktop, "How far the view turns for the mouse's movement." }, { "input.invert_mouse", _config_boolean, "false", "HALO_MOUSE_INVERT", _environment_set_is_true, _platform_desktop, "Moving the mouse forward looks down." }, @@ -298,7 +316,7 @@ static const struct config_setting config_settings[] = #include "input_bindings.def" #undef BINDING - { "game.console_log", _config_string, "\"important\"", NULL, _environment_value, _platform_all, + { "game.console_log", _config_string, "\"important\"", "HALO_CONSOLE_LOG", _environment_value, _platform_all, "What the game's console shows on screen of what it logs: \"important\"\n" "(bans, players dropped for cheating, what refuses a command, and the\n" "asserts that stop the game), \"all\" (every line, the game's own\n" @@ -321,23 +339,6 @@ static const struct config_setting config_settings[] = "as CUSTOM SINGLEPLAYER and CUSTOM MULTIPLAYER. Their tags are checked\n" "as the game's own maps' are before they run; false refuses them\n" "(docs/custom_edition_caches.md)." }, - { "display.viewmodel_shield", _config_boolean, "false", "HALO_VIEWMODEL_SHIELD", _environment_value, _platform_all, - "The energy shield's flare on the first-person arms too, as on the body\n" - "(the stock game draws it on the body only)." }, - { "display.first_person_legs", _config_boolean, "false", "HALO_FIRST_PERSON_LEGS", _environment_value, _platform_all, - "Your own legs in first person, seen looking down: your body as others\n" - "see it, drawn from the waist down, moving as it moves." }, - { "display.shield_glow", _config_boolean, "false", "HALO_SHIELD_GLOW", _environment_value, _platform_all, - "Energy shields light what is around them as they flare, as plasma\n" - "does, in the shield's own color." }, - { "display.shield_glow_intensity", _config_string, "\"default\"", "HALO_SHIELD_GLOW_INTENSITY", _environment_value, _platform_all, - "How far the shield glow reaches: \"default\" or \"light_show\" (three\n" - "times as far, and at full strength from a third of the flare)." }, - { "display.shield_color", _config_string, "\"default\"", "HALO_SHIELD_COLOR", _environment_value, _platform_all, - "Your energy shield's color, as this machine draws it: \"default\" (the\n" - "shield's own) or a multiplayer armor color: white, black, red, blue,\n" - "gray, yellow, green, pink, purple, cyan, cobalt, orange, teal, sage,\n" - "brown, tan, maroon or salmon." }, { "paths.data", _config_string, "\"\"", "HALO_DATA_ROOT", _environment_value, _platform_desktop, "The folder holding the game data's maps folder; empty looks in the\n" @@ -414,6 +415,28 @@ static const struct config_setting config_settings[] = _platform_all, "Hosting: the minutes a player kicked by a vote cannot join again (1 to\n" "1440)." }, + { "network.map_share", _config_boolean, "true", "HALO_NET_MAP_SHARE", _environment_value, _platform_all, + "Hosting: send a player who joins without the game's Custom Edition map\n" + "the map (from custom_maps), and the resource maps it needs that the\n" + "player lacks (bitmaps.map, sounds.map, loc.map). The game's other\n" + "players help (network.map_seed)." }, + { "network.map_share_upload", _config_integer, "1024", "HALO_NET_MAP_SHARE_UPLOAD", _environment_value, + _platform_all, + "Hosting: the most kilobytes a second the host sends of maps (16 to\n" + "65536), so that the game's own traffic keeps the rest of the upload." }, + { "network.map_seed", _config_boolean, "true", "HALO_NET_MAP_SEED", _environment_value, _platform_all, + "Playing in another's game: help send its map to players who join\n" + "without it, as a torrent's peers do (the pieces this machine has, at\n" + "most network.map_seed_upload)." }, + { "network.map_seed_upload", _config_integer, "256", "HALO_NET_MAP_SEED_UPLOAD", _environment_value, + _platform_all, + "Playing in another's game: the most kilobytes a second this machine\n" + "sends of maps to other players (16 to 65536)." }, + { "network.map_download", _config_boolean, "true", "HALO_NET_MAP_DOWNLOAD", _environment_value, _platform_all, + "Joining a game on a Custom Edition map this machine does not have:\n" + "download it (and the resource maps it needs) from the game's players\n" + "into custom_maps, if its host shares it (network.map_share); false\n" + "leaves the game, saying which map is missing." }, { "network.coop_public", _config_boolean, "false", "HALO_NET_COOP_PUBLIC", _environment_value, _platform_all, "Whether an online co-op game (Create Game > Internet, a SINGLEPLAYER\n" "map) starts as PUBLIC or, false, PRIVATE: Server Setup's LISTING in\n" diff --git a/port/linux/src/xinput_sdl.c b/port/linux/src/xinput_sdl.c index 6869d38aa1..4b9a812842 100644 --- a/port/linux/src/xinput_sdl.c +++ b/port/linux/src/xinput_sdl.c @@ -105,7 +105,7 @@ worn stick's drift */ #define STICK_AIMING_DEFLECTION 8000 /* (input.mouse_vertical_sensitivity, read with it) */ -static float vertical_sensitivity = 1.0f; +static float vertical_sensitivity = 0.1f; static float mouse_sensitivity(void) { @@ -117,7 +117,7 @@ static float mouse_sensitivity(void) read_at = config_changes(); sensitivity = (float)config_real("input.mouse_sensitivity"); if (sensitivity <= 0.0f) - sensitivity = 1.0f; + sensitivity = 0.1f; vertical_sensitivity = (float)config_real("input.mouse_vertical_sensitivity"); if (vertical_sensitivity <= 0.0f) vertical_sensitivity = sensitivity; @@ -807,6 +807,7 @@ a button held across the change would be pressed again on the other port (the B that leaves a profile screen leaving the game as player 1's) */ static SDL_Gamepad *port_gamepad(SDL_Gamepad *gamepads[PORT_COUNT], int count, int port) { +#ifndef HALO_IOS if (count == 1) { BOOL split = pc_menu_split_players() != 0; @@ -816,6 +817,9 @@ static SDL_Gamepad *port_gamepad(SDL_Gamepad *gamepads[PORT_COUNT], int count, i if (lone_gamepad_split) return port == 1 ? gamepads[0] : NULL; } +#endif + /* iOS always has a touch controller in port 0, even without hardware. + It has no keyboard-only player to leave behind on this port. */ return port < count ? gamepads[port] : NULL; } diff --git a/port/macos/compiler/guest_rebase.cpp b/port/macos/compiler/guest_rebase.cpp index 4848277ae6..bc55a30084 100644 --- a/port/macos/compiler/guest_rebase.cpp +++ b/port/macos/compiler/guest_rebase.cpp @@ -27,7 +27,7 @@ struct RebasePass : PassInfoMixin { bool macCheck = false; explicit RebasePass(bool ios = false, bool macCheck = false) : ios(ios), macCheck(macCheck) {} PreservedAnalyses run(Module &module, ModuleAnalysisManager &) { - const uint64_t bias = ios && !macCheck ? 0x400000000ULL : GuestBias; + const uint64_t bias = ios && !macCheck ? 0x7000000000ULL : GuestBias; if (module.getDataLayout().getPointerSize(0) != 4 || module.getDataLayout().getPointerSize(272) != 8) report_fatal_error("Halo rebase requires arm64_32 with 64-bit address space 272"); diff --git a/port/macos/dependencies.json b/port/macos/dependencies.json index a8953dad7f..a688482662 100644 --- a/port/macos/dependencies.json +++ b/port/macos/dependencies.json @@ -64,6 +64,11 @@ "path": "KHR/khrplatform.h", "url": "https://raw.githubusercontent.com/KhronosGroup/EGL-Registry/db3425b8246136faccb5e2782b5694960bd6edf1/api/KHR/khrplatform.h", "sha256": "7b1e01aaa7ad8f6fc34b5c7bdf79ebf5189bb09e2c4d2e79fc5d350623d11e83" + }, + { + "path": "EGL/eglext_angle.h", + "url": "https://raw.githubusercontent.com/google/angle/c053bf85793bbb83016b1196d04e5df3594b9bcc/include/EGL/eglext_angle.h", + "sha256": "85c92699d39526c9157c939e46e1a49c5016495713182d21ebd78fc1ef5292e4" } ] } diff --git a/port/macos/host/host.h b/port/macos/host/host.h index 78500cd493..826de7ee5d 100644 --- a/port/macos/host/host.h +++ b/port/macos/host/host.h @@ -12,7 +12,13 @@ #include "halo_android_abi.h" #ifdef HALO_IOS -#define HALO_MACOS_BIAS UINT64_C(0x400000000) +#if defined(HALO_IOS_SIMULATOR) +#define HALO_MACOS_BIAS UINT64_C(0x10000000000) +#else +// iOS reserves 64–448 GB for the GPU; extended VA ends at 512 GB. +// The device arena starts just after the carveout and stays below that limit. +#define HALO_MACOS_BIAS UINT64_C(0x7000000000) +#endif /* The allocator, Xbox window and linked image all fit below 0x90000000. * Reserve only that span so UIKit/Metal can use the rest of the address space. */ #define HALO_ARENA_SIZE UINT64_C(0x90000000) diff --git a/port/macos/host/host_gl.c b/port/macos/host/host_gl.c index 1039a3430a..722077ee40 100644 --- a/port/macos/host/host_gl.c +++ b/port/macos/host/host_gl.c @@ -10,15 +10,49 @@ already have host types by then. Only strings need copying back. #include #include +#ifdef HALO_APPLE_GRAPHICS +#include "../../apple/host_gl_dispatch.h" +#endif #include #include void *host_gl_bridge(const char *name); +#ifdef HALO_IOS +extern unsigned int host_sdl_ios_default_framebuffer(void); +static void native_bind_framebuffer(GLenum target, GLuint framebuffer) { + /* EAGL's window drawable is an SDL-owned FBO, not framebuffer zero. */ + glBindFramebuffer(target, framebuffer ? framebuffer : host_sdl_ios_default_framebuffer()); +} +static void unsupported_gles_entry(void) { + host_fatal("The selected OpenGL ES driver was asked to use an unsupported optional feature."); +} +#endif void *host_gl_resolve(const char *name) { +#ifdef HALO_IOS + if (!halo_graphics_metal() && !strcmp(name,"glBindFramebuffer")) + return (void *)native_bind_framebuffer; +#endif void *profile = host_perf_resolve(name); if (profile) return profile; +#ifdef HALO_APPLE_GRAPHICS + extern void *halo_debug_gl_resolve(const char *); + void *diagnostics = halo_debug_gl_resolve(name); + if (diagnostics) return diagnostics; +#endif void *bridge = host_gl_bridge(name); +#ifdef HALO_APPLE_GRAPHICS + void *driver = bridge ? bridge : halo_graphics_proc(name); +#ifdef HALO_IOS + /* ES 3.1/3.2 calls stay in the import table, but capability checks exclude + them on Apple's ES 3.0 driver. Keep a fail-fast entry if that regresses. */ + if (!driver && !halo_graphics_metal() && + (!strcmp(name,"glMemoryBarrier") || !strcmp(name,"glDrawElementsBaseVertex"))) + return (void *)unsupported_gles_entry; +#endif + return driver; +#else return bridge ? bridge : (void *)eglGetProcAddress(name); +#endif } void host_gl_get_string(uint32_t name, int index, char *buffer, uint32_t size) { diff --git a/port/macos/host/host_gl_bridge.c b/port/macos/host/host_gl_bridge.c index c2743a5f7e..fc9401cda0 100644 --- a/port/macos/host/host_gl_bridge.c +++ b/port/macos/host/host_gl_bridge.c @@ -1,5 +1,8 @@ /* Darwin packs stack arguments; the Android guest bridge widens each slot. */ #include +#ifdef HALO_APPLE_GRAPHICS +#include "../../apple/host_gl_dispatch.h" +#endif #include static void bridge_glCopyImageSubData(GLuint a0, GLenum a1, GLint a2, GLint a3, GLint a4, GLint a5, GLuint a6, GLenum a7, long long a8, long long a9, diff --git a/port/macos/host/host_memory.c b/port/macos/host/host_memory.c index f4c4863b4b..cec93c6b4c 100644 --- a/port/macos/host/host_memory.c +++ b/port/macos/host/host_memory.c @@ -69,7 +69,9 @@ int host_memory_initialize(uint32_t image_base, uint32_t image_size) { kern_return_t error = vm_allocate(mach_task_self(), &address, HALO_ARENA_SIZE, VM_FLAGS_FIXED); if (error != KERN_SUCCESS) { - host_logf(HOST_LOG_ERROR, "Guest arena reservation: %s", mach_error_string(error)); + host_logf(HOST_LOG_ERROR, "Guest arena reservation at 0x%llx, size 0x%llx: %s (kern=%d)", + (unsigned long long)HALO_MACOS_BIAS, (unsigned long long)HALO_ARENA_SIZE, + mach_error_string(error), error); vm_address_t occupied = HALO_MACOS_BIAS; vm_size_t length = 0; vm_region_basic_info_data_64_t info; diff --git a/port/macos/host/host_perf.c b/port/macos/host/host_perf.c index 3019520fd8..39800edf32 100644 --- a/port/macos/host/host_perf.c +++ b/port/macos/host/host_perf.c @@ -1,6 +1,9 @@ /* Opt-in frame and driver diagnostics. No per-draw work when disabled. */ #include "host.h" #include +#ifdef HALO_APPLE_GRAPHICS +#include "../../apple/host_gl_dispatch.h" +#endif #include #include #include diff --git a/port/macos/host/host_sdl.c b/port/macos/host/host_sdl.c index 558847726a..e1bb816dd3 100644 --- a/port/macos/host/host_sdl.c +++ b/port/macos/host/host_sdl.c @@ -11,6 +11,10 @@ SDL's stream lock recursively from a different thread. */ #include "host.h" +#ifdef HALO_APPLE_GRAPHICS +#include "../../apple/host_debug.h" +#include "../../apple/host_graphics.h" +#endif #ifndef HALO_IOS #include "../native/host_menu.h" #include "../../linux/include/native_input_events.h" @@ -161,6 +165,9 @@ static SDL_HitTestResult SDLCALL window_hit_test(SDL_Window *window, const SDL_P uint32_t host_sdl_create_window(const char *title, int width, int height, int64_t flags) { const char *windowed = SDL_getenv("HALO_WINDOWED"); SDL_WindowFlags mode = SDL_WINDOW_METAL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY; +#ifdef HALO_IOS + if (!halo_graphics_metal()) mode = (mode & ~SDL_WINDOW_METAL) | SDL_WINDOW_OPENGL; +#endif metal_window_hidden = (flags & SDL_WINDOW_HIDDEN) != 0; if (metal_window_hidden) mode |= SDL_WINDOW_HIDDEN; else if (!windowed || !SDL_atoi(windowed)) mode |= SDL_WINDOW_FULLSCREEN; @@ -192,7 +199,7 @@ uint32_t host_sdl_create_window(const char *title, int width, int height, int64_ #endif int w, h; SDL_GetWindowSizeInPixels(metal_window, &w, &h); - host_logf(HOST_LOG_INFO, "Metal drawable %dx%d (%s)", w, h, + host_logf(HOST_LOG_INFO, "%s drawable %dx%d (%s)", halo_graphics_metal() ? "Metal" : "OpenGL", w, h, (mode & SDL_WINDOW_FULLSCREEN) ? "borderless fullscreen" : "windowed"); } return handle_new(_handle_window, metal_window); @@ -268,13 +275,32 @@ int host_sdl_set_relative_mouse(uint32_t window, int enabled) { return object ? SDL_SetWindowRelativeMouseMode(object, enabled != 0) : 0; } +#ifdef HALO_IOS +unsigned int host_sdl_ios_default_framebuffer(void) { + return (unsigned int)SDL_GetNumberProperty(SDL_GetWindowProperties(metal_window), + SDL_PROP_WINDOW_UIKIT_OPENGL_FRAMEBUFFER_NUMBER, 0); +} +#endif int host_sdl_gl_set_attribute(int attribute, int value) { +#ifdef HALO_IOS + if (!halo_graphics_metal()) return SDL_GL_SetAttribute(attribute, value); +#endif if (attribute == SDL_GL_CONTEXT_MINOR_VERSION) requested_minor = value; return 1; } uint32_t host_sdl_gl_create_context(uint32_t window) { SDL_Window *object = handle_get(window, _handle_window); +#ifdef HALO_IOS + if (!halo_graphics_metal()) { + SDL_GLContext native = object ? SDL_GL_CreateContext(object) : NULL; + if (!native) return 0; + halo_debug_initialize(); + extern void host_ios_controls_initialize(SDL_Window *); + host_ios_controls_initialize(object); + return handle_new(_handle_context, native); + } +#endif if (!object || requested_minor > 0) { SDL_SetError("ANGLE Metal uses ES 3.0"); return 0; @@ -289,7 +315,7 @@ uint32_t host_sdl_gl_create_context(uint32_t window) { return 0; } const EGLint configAttributes[] = {EGL_SURFACE_TYPE, - metal_window_hidden ? EGL_PBUFFER_BIT : EGL_WINDOW_BIT, + metal_window_hidden ? EGL_PBUFFER_BIT : EGL_WINDOW_BIT | EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT, EGL_RED_SIZE, @@ -301,13 +327,17 @@ uint32_t host_sdl_gl_create_context(uint32_t window) { EGL_ALPHA_SIZE, 8, EGL_DEPTH_SIZE, - 24, + 0, EGL_STENCIL_SIZE, - 8, + 0, EGL_NONE}; EGLConfig config; if (!eglChooseConfig(metal_display, configAttributes, &config, 1, &count) || !count) return 0; +#ifdef HALO_APPLE_GRAPHICS + extern void halo_graphics_attach(EGLDisplay, EGLConfig); + halo_graphics_attach(metal_display, config); +#endif eglBindAPI(EGL_OPENGL_ES_API); const EGLint contextAttributes[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE}; metal_context = eglCreateContext(metal_display, config, EGL_NO_CONTEXT, contextAttributes); @@ -328,6 +358,9 @@ uint32_t host_sdl_gl_create_context(uint32_t window) { return 0; } eglMakeCurrent(metal_display, metal_surface, metal_surface, metal_context); +#if defined(HALO_APPLE_GRAPHICS) && !defined(HALO_IOS) + halo_debug_install_menu(); +#endif #if defined(HALO_IOS) && !defined(HALO_IOS_MAC_CHECK) // Attach controls after SDL has installed the Metal view, otherwise // the drawable can cover the controller's UIKit overlay. @@ -337,18 +370,37 @@ uint32_t host_sdl_gl_create_context(uint32_t window) { return handle_new(_handle_context, metal_context); } int host_sdl_gl_make_current(uint32_t window, uint32_t context) { +#ifdef HALO_IOS + if (!halo_graphics_metal()) return SDL_GL_MakeCurrent(handle_get(window, _handle_window), handle_get(context, _handle_context)); +#endif (void)window; return eglMakeCurrent(metal_display, metal_surface, metal_surface, handle_get(context, _handle_context)); } int host_sdl_gl_set_swap_interval(int interval) { +#ifdef HALO_IOS + if (!halo_graphics_metal()) return SDL_GL_SetSwapInterval(interval); +#endif metal_swap_interval = interval; return eglSwapInterval(metal_display, interval); } int host_sdl_gl_swap_window(uint32_t window) { (void)window; uint64_t start = SDL_GetTicksNS(); - int result = eglSwapBuffers(metal_display, metal_surface); + int result; +#ifdef HALO_IOS + result = halo_graphics_metal() ? (halo_metalfx_take_presented() ? 1 : eglSwapBuffers(metal_display, metal_surface)) : SDL_GL_SwapWindow(handle_get(window, _handle_window)); + static uint64_t last_frame; + int limit = halo_debug_frame_limit(); + uint64_t now = SDL_GetTicksNS(); + uint64_t period = limit > 0 ? 1000000000ull / limit : 0; + if (last_frame && now - last_frame < period) SDL_DelayNS(period - (now - last_frame)); + now = SDL_GetTicksNS(); + if (last_frame) halo_debug_frame((now-last_frame)/1e6); + last_frame = now; +#else + result = eglSwapBuffers(metal_display, metal_surface); +#endif host_perf_frame((SDL_GetTicksNS() - start) / 1e6); if (metal_window_hidden && metal_swap_interval > 0) { uint64_t elapsed = SDL_GetTicksNS() - start; @@ -416,6 +468,10 @@ int host_sdl_show_simple_message_box(uint32_t flags, const char *title, const ch /* ---------- gamepads */ int host_sdl_get_gamepads(uint32_t *ids, int capacity) { +#if defined(HALO_IOS) && !defined(HALO_IOS_MAC_CHECK) + extern int host_ios_gamepads(uint32_t *, int); + return host_ios_gamepads(ids, capacity); +#endif int count = 0, index; SDL_JoystickID *list = SDL_GetGamepads(&count); @@ -452,21 +508,42 @@ void host_sdl_close_gamepad(uint32_t gamepad) { } int host_sdl_gamepad_axis(uint32_t gamepad, int axis) { +#ifdef HALO_APPLE_GRAPHICS + if (halo_debug_is_presented()) return 0; +#endif SDL_Gamepad *object = handle_get(gamepad, _handle_gamepad); +#if defined(HALO_IOS) && !defined(HALO_IOS_MAC_CHECK) + extern int host_ios_gamepad_axis(SDL_Gamepad *, int); + return object ? host_ios_gamepad_axis(object, axis) : 0; +#else return object ? SDL_GetGamepadAxis(object, (SDL_GamepadAxis)axis) : 0; +#endif } int host_sdl_gamepad_button(uint32_t gamepad, int button) { +#ifdef HALO_APPLE_GRAPHICS + if (halo_debug_is_presented()) return 0; +#endif SDL_Gamepad *object = handle_get(gamepad, _handle_gamepad); +#if defined(HALO_IOS) && !defined(HALO_IOS_MAC_CHECK) + extern int host_ios_gamepad_button(SDL_Gamepad *, int); + return object ? host_ios_gamepad_button(object, button) : 0; +#else return object ? SDL_GetGamepadButton(object, (SDL_GamepadButton)button) : 0; +#endif } int host_sdl_gamepad_type(uint32_t gamepad) { SDL_Gamepad *object = handle_get(gamepad, _handle_gamepad); +#if defined(HALO_IOS) && !defined(HALO_IOS_MAC_CHECK) + extern int host_ios_gamepad_type(SDL_Gamepad *); + return object ? host_ios_gamepad_type(object) : SDL_GAMEPAD_TYPE_UNKNOWN; +#else return object ? SDL_GetGamepadType(object) : SDL_GAMEPAD_TYPE_UNKNOWN; +#endif } int host_sdl_rumble_gamepad(uint32_t gamepad, uint32_t low, uint32_t high, uint32_t milliseconds) { @@ -699,3 +776,9 @@ uint32_t host_sdl_audio_device_id(int recording, const char *name) { SDL_free(devices); return found; } + +#ifndef HALO_IOS +void *host_macos_game_window(void) { + return metal_window ? SDL_GetPointerProperty(SDL_GetWindowProperties(metal_window), SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, NULL) : NULL; +} +#endif diff --git a/port/macos/host_imports.list b/port/macos/host_imports.list index d65b4fd871..d3882d87ba 100644 --- a/port/macos/host_imports.list +++ b/port/macos/host_imports.list @@ -5,3 +5,5 @@ halo_map_download_request host_sdl_audio_device_count host_sdl_audio_device_name host_sdl_audio_device_id + +host_ios_metalfx_present diff --git a/port/macos/native/HaloPreferences.m b/port/macos/native/HaloPreferences.m index 004b669c8a..66b221df82 100644 --- a/port/macos/native/HaloPreferences.m +++ b/port/macos/native/HaloPreferences.m @@ -142,6 +142,8 @@ - (BOOL)setCommunityDownloadsEnabled:(BOOL)enabled error:(NSError **)error { return root; } +static NSString *copiedFileHash(NSURL *file); + NSURL *HaloImportDiscImage(NSURL *image, NSURL *supportDirectory, xiso_progress_proc progress, void *context, NSError **error) { /* A new directory for every import. Failed imports never replace working data. */ @@ -149,13 +151,28 @@ - (BOOL)setCommunityDownloadsEnabled:(BOOL)enabled error:(NSError **)error { URLByAppendingPathComponent:NSUUID.UUID.UUIDString isDirectory:YES]; if (![NSFileManager.defaultManager createDirectoryAtURL:destination withIntermediateDirectories:YES attributes:nil error:error]) return nil; + BOOL scoped = [image startAccessingSecurityScopedResource]; + NSURL *privateImage = [destination URLByAppendingPathComponent:@"disc.iso"]; + if (progress) progress(context, "Copying and verifying disc image", 0, 1); + NSString *digest = copiedFileHash(image); + BOOL copied = digest && [NSFileManager.defaultManager copyItemAtURL:image toURL:privateImage error:error]; + BOOL verified = copied && [digest isEqual:copiedFileHash(privateImage)]; + if (scoped) [image stopAccessingSecurityScopedResource]; char detail[512] = {0}; - BOOL extracted = xiso_extract_maps(image.fileSystemRepresentation, destination.fileSystemRepresentation, + BOOL extracted = verified && xiso_extract_maps(privateImage.fileSystemRepresentation, destination.fileSystemRepresentation, progress, context, detail, sizeof(detail)); NSURL *validated = extracted ? HaloValidateGameData(destination, error) : nil; + if (validated) { + NSDictionary *attributes = [NSFileManager.defaultManager attributesOfItemAtPath:privateImage.path error:error]; + NSDictionary *record = @{@"version": @1, @"sha256": digest, + @"size": attributes[NSFileSize] ?: @0, @"originalName": image.lastPathComponent}; + if (!attributes || ![record writeToURL:[destination URLByAppendingPathComponent:@"integrity.plist"] atomically:YES]) validated = nil; + [destination setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:nil]; + } if (!validated) { [NSFileManager.defaultManager removeItemAtURL:destination error:nil]; - if (!extracted && error) *error = failure([NSString stringWithUTF8String:detail] ?: @"The disc image could not be read."); + if (error) *error = failure(!verified ? @"Could not copy and verify the disc image. Check available storage and try again." + : (*detail ? [NSString stringWithUTF8String:detail] : @"Could not save the private game data.")); } return validated; } diff --git a/port/macos/native/host_menu.m b/port/macos/native/host_menu.m index cb8302635b..b9c45a756a 100644 --- a/port/macos/native/host_menu.m +++ b/port/macos/native/host_menu.m @@ -188,7 +188,7 @@ - (void)buildMenus { item(statusMenu, @"Enter Full Screen", @selector(toggleFullscreen:), @""); item(statusMenu, @"Settings…", @selector(showSettings:), @","); [statusMenu addItem:NSMenuItem.separatorItem]; - item(statusMenu, @"Choose Disc Image…", @selector(selectImage:), @""); + item(statusMenu, @"Choose Halo ISO…", @selector(selectImage:), @""); item(statusMenu, @"Choose Maps Folder…", @selector(selectFolder:), @""); item(statusMenu, @"Open Saves Folder", @selector(openSaves:), @""); item(statusMenu, @"Edit Controls and Advanced Settings…", @selector(openConfig:), @""); @@ -317,7 +317,7 @@ - (void)buildSettings { label(content, @"Game Data", NSMakeRect(24, 235, 472, 22), NO).font = [NSFont boldSystemFontOfSize:13]; self.dataLabel = label(content, @"No maps selected", NSMakeRect(24, 206, 472, 20), YES); self.sourceLabel = label(content, @"", NSMakeRect(24, 182, 472, 20), YES); - button(content, @"Choose Disc Image…", @selector(selectImage:), NSMakeRect(20, 143, 183, 32)); + button(content, @"Choose Halo ISO…", @selector(selectImage:), NSMakeRect(20, 143, 183, 32)); button(content, @"Choose Maps Folder…", @selector(selectFolder:), NSMakeRect(211, 143, 193, 32)); label(content, @"Changes to game data take effect when Halo OG next opens.", NSMakeRect(24, 116, 472, 19), YES).font = [NSFont systemFontOfSize:11]; self.automaticUpdatesButton = [NSButton checkboxWithTitle:@"Automatically check for updates" @@ -561,7 +561,7 @@ - (BOOL)chooseImage { host_sdl_release_mouse(); NSOpenPanel *panel = NSOpenPanel.openPanel; panel.title = @"Choose Your Xbox Halo Disc Image"; - panel.message = @"The app imports only the maps from your local disc image."; + panel.message = @"The app verifies and keeps a private copy of your ISO/XISO for future launches."; panel.canChooseDirectories = NO; panel.canChooseFiles = YES; panel.allowsMultipleSelection = NO; @@ -578,7 +578,7 @@ - (BOOL)chooseImage { - (void)importImage:(NSURL *)image completion:(void (^)(BOOL))completion { NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 470, 130) styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO]; - window.title = @"Importing Halo OG Maps"; + window.title = @"Importing Halo Disc Image"; window.level = NSFloatingWindowLevel; NSTextField *progressLabel = label(window.contentView, @"Reading disc image…", NSMakeRect(24, 78, 422, 22), NO); NSProgressIndicator *bar = [[NSProgressIndicator alloc] initWithFrame:NSMakeRect(24, 48, 422, 18)]; @@ -598,7 +598,7 @@ - (void)importImage:(NSURL *)image completion:(void (^)(BOOL))completion { self.importing = NO; [window orderOut:self]; NSError *selectionError = error; - BOOL succeeded = imported && [self.preferences selectDataRoot:imported iso:image error:&selectionError]; + BOOL succeeded = imported && [self.preferences selectDataRoot:imported iso:[imported URLByAppendingPathComponent:@"disc.iso"] error:&selectionError]; if (!succeeded) { if (imported) [NSFileManager.defaultManager removeItemAtURL:imported error:nil]; showError(selectionError); @@ -640,7 +640,7 @@ - (void)selectImage:(id)sender { [self showSettings:nil]; NSOpenPanel *panel = NSOpenPanel.openPanel; panel.title = @"Choose Your Xbox Halo Disc Image"; - panel.message = @"The app imports only the maps from your local disc image."; + panel.message = @"The app verifies and keeps a private copy of your ISO/XISO for future launches."; panel.canChooseDirectories = NO; panel.canChooseFiles = YES; panel.allowsMultipleSelection = NO; @@ -749,7 +749,7 @@ int host_menu_prepare(const char *support, const char *fallback, char *data, siz NSAlert *alert = [[NSAlert alloc] init]; alert.messageText = @"Choose your Halo OG game data"; alert.informativeText = @"Use your own original Xbox Halo disc image or extracted maps folder. The app does not include game data."; - [alert addButtonWithTitle:@"Choose Disc Image…"]; + [alert addButtonWithTitle:@"Choose Halo ISO…"]; [alert addButtonWithTitle:@"Choose Maps Folder…"]; [alert addButtonWithTitle:@"Quit"]; NSModalResponse answer = [alert runModal]; diff --git a/port/macos/tests/menu_preferences.m b/port/macos/tests/menu_preferences.m index 09180f4148..fb2bc69938 100644 --- a/port/macos/tests/menu_preferences.m +++ b/port/macos/tests/menu_preferences.m @@ -203,11 +203,15 @@ int main(int argc, const char **argv) { assert([[NSData dataWithContentsOfURL:settings] isEqual:unchanged]); NSURL *image = [test URLByAppendingPathComponent:@"disc.iso"]; NSURL *imported = HaloImportDiscImage(image, support, progress, NULL, &error); - assert(imported && progressCalls == 2); + assert(imported && progressCalls >= 2); + NSURL *privateImage = [imported URLByAppendingPathComponent:@"disc.iso"]; + assert([[NSData dataWithContentsOfURL:privateImage] isEqual:[NSData dataWithContentsOfURL:image]]); + NSDictionary *integrity = [NSDictionary dictionaryWithContentsOfURL:[imported URLByAppendingPathComponent:@"integrity.plist"]]; + assert([integrity[@"sha256"] length] == 64); assert([preferences.dataPath isEqualToString:valid.path]); assert([[NSData dataWithContentsOfURL:[imported URLByAppendingPathComponent:@"maps/ui.map"]] isEqualToData:[NSData dataWithContentsOfURL:[valid URLByAppendingPathComponent:@"maps/ui.map"]]]); - assert([preferences selectDataRoot:imported iso:image error:&error]); + assert([preferences selectDataRoot:imported iso:privateImage error:&error]); before = [NSData dataWithContentsOfURL:settings]; NSURL *imports = [support URLByAppendingPathComponent:@"Game Data"]; NSUInteger count = [NSFileManager.defaultManager contentsOfDirectoryAtURL:imports @@ -218,8 +222,11 @@ int main(int argc, const char **argv) { includingPropertiesForKeys:nil options:0 error:nil].count == count); assert([[NSData dataWithContentsOfURL:settings] isEqualToData:before]); } + assert([NSFileManager.defaultManager removeItemAtURL:image error:&error]); + assert(HaloValidateGameData(imported, &error)); + assert([NSData dataWithContentsOfURL:privateImage].length > 0); preferences = [[HaloPreferences alloc] initWithSupportDirectory:support]; - assert(preferences.windowed && [preferences.isoPath isEqualToString:image.path]); + assert(preferences.windowed && [preferences.isoPath isEqualToString:privateImage.path]); assert([preferences.dataPath isEqualToString:imported.path]); saved = [NSJSONSerialization JSONObjectWithData:before options:0 error:nil]; assert([saved[@"future_setting"] isEqualToString:@"preserve me"]); @@ -236,7 +243,7 @@ int main(int argc, const char **argv) { assert(!HaloUpdateConfigurationIsValid(@{@"SUFeedURL":url, @"SUPublicEDKey":key})); assert(!HaloUpdateConfigurationIsValid(@{})); assert(!HaloUpdateConfigurationIsValid(@{@"SUFeedURL":@"https://example.com/feed.xml", @"SUPublicEDKey":@"invalid"})); - migrationFixtures(test, valid, image); + migrationFixtures(test, valid, privateImage); puts("Map validation, XISO import, failed-import rollback, persistent preferences and update configuration passed"); } return 0; diff --git a/port/tools/cache_file_report.c b/port/tools/cache_file_report.c index 8182f5394b..7d9355a594 100644 --- a/port/tools/cache_file_report.c +++ b/port/tools/cache_file_report.c @@ -177,15 +177,30 @@ static int report_custom_edition_cache( }; struct stdio_source resource_sources[NUMBER_OF_RESOURCE_MAP_TYPES]; struct resource_map resource_map_storage[NUMBER_OF_RESOURCE_MAP_TYPES]; + static char const *const resource_map_names[NUMBER_OF_RESOURCE_MAP_TYPES] = + { + "none", + "bitmaps", + "sounds", + "loc", + }; struct resource_map *resource_maps[NUMBER_OF_RESOURCE_MAP_TYPES]; struct custom_edition_load_report report; uint32_t tag_cache_bytes = CUSTOM_EDITION_TAG_CACHE_BYTES; + uint32_t needed; uint8_t *tag_cache; enum cache_file_status status; int type; memset(resource_sources, 0, sizeof(resource_sources)); memset(resource_maps, 0, sizeof(resource_maps)); + /* the resource maps its tag index says it needs (map sharing sends + them with the map) */ + status = cache_file_resource_maps_needed(source, &needed); + if (status == _cache_file_status_ok) + print_flags("resource_maps_needed", needed, resource_map_names, NUMBER_OF_RESOURCE_MAP_TYPES); + else + printf("resource_maps_needed: %s\n", cache_file_status_describe(status)); for (type = _resource_map_bitmaps; type < NUMBER_OF_RESOURCE_MAP_TYPES; type++) { char path[PATH_BYTES]; diff --git a/source/cache/cache_files.c b/source/cache/cache_files.c index 87da1fb4cc..878c9697a4 100644 --- a/source/cache/cache_files.c +++ b/source/cache/cache_files.c @@ -140,6 +140,7 @@ symbols in this file: #include "tag_schema.h" #include "custom_edition_cache.h" /* port: port/linux/game/custom_edition_cache.c */ #include "cache_file_formats.h" /* port: CUSTOM_EDITION_TAG_CACHE_BYTES */ +#include "map_share.h" /* port: port/linux/game/map_share.c */ /* ---------- constants */ @@ -1180,7 +1181,10 @@ gives (cache_files_give_time_to_precache). A Halo Custom Edition map (custom_maps\) is looked for in the Custom Edition maps folders (port/linux/game/custom_edition_cache.c), and must be the host's version (`version`, cache_files_map_version's on the host); any -other map in the game's own. */ +other map in the game's own. One this machine lacks (or whose resource maps +it lacks) is asked of the host instead, if it may be (map sharing: +port/linux/game/map_share.c): it is taken as present, and downloaded before +it is precached. */ boolean cache_files_map_present( char const *map_name, unsigned long version) @@ -1195,8 +1199,11 @@ boolean cache_files_map_present( return TRUE; if (custom_edition_level_name(map_name)) { - if (custom_edition_cache_present(map_name, version, message, sizeof(message))) + if (custom_edition_cache_present(map_name, version, message, sizeof(message)) || + map_share_download_begin(map_name, version, message)) + { return TRUE; + } } else { @@ -1247,6 +1254,10 @@ boolean cache_files_give_time_to_precache( taken for a map missing from the disc (the damaged disc error) */ if (!map_name || !map_name[0]) return FALSE; + /* port: a map being downloaded (port/linux/game/map_share.c) is not + there yet: precached once it is */ + if (map_share_downloading(map_name)) + return FALSE; if (cache_files_precache_map_loaded(map_name)) { result = TRUE; diff --git a/source/game/game_engine.c b/source/game/game_engine.c index 584af45718..93f97397fb 100644 --- a/source/game/game_engine.c +++ b/source/game/game_engine.c @@ -4216,7 +4216,6 @@ static void game_engine_build_lighting( void) { long player_count = 0; - long object_count = 0; { struct data_iterator iterator; @@ -4226,55 +4225,10 @@ static void game_engine_build_lighting( player_count++; } - if (global_variant.universal_variant.vehicle_set != _game_engine_vehicles_none) - { - if (global_variant.game_engine_index == game_engine_race) - { - struct scenario *scenario = global_scenario_get(); - /* port: a long counter, for a map's long count */ - long flag_index; - - for (flag_index = 0; - flag_index < scenario->netgame_flags.count; - flag_index++) - { - struct scenario_netgame_flag *flag = TAG_BLOCK_GET_ELEMENT( - &scenario->netgame_flags, - flag_index, - struct scenario_netgame_flag); - - if (flag->type == _netgame_flag_race_vehicle) - object_count++; - } - - if (object_count > player_count) - object_count = player_count; - } - else - { - struct object_iterator object_iterator; - - object_iterator_new( - &object_iterator, - _object_mask_vehicle, - 0); - while (object_iterator_next(&object_iterator)) - object_count++; - } - } - - if ((player_count > 8 && object_count >= 2) || - player_count >= 13) - { - SET_FLAG(game_engine_globals.flags, _game_engine_disable_dynamic_light_bit, TRUE); - } - - if (player_count > 4 || - object_count >= 4 || - TEST_FLAG(game_engine_globals.flags, _game_engine_disable_dynamic_light_bit)) - { - SET_FLAG(game_engine_globals.flags, _game_engine_disable_integrated_lights_bit, TRUE); - } + /* port: the Xbox turned dynamic lights off past 13 players, or past 8 + with two vehicles, and the flashlight off past 4 players or 4 vehicles. + A match on a map with a few vehicles then had a flashlight, and the next + one did not. The beam is a dynamic light, so both stay on. */ if (player_count >= 5) SET_FLAG(game_engine_globals.flags, _game_engine_5_or_more_players_bit, TRUE); diff --git a/source/game/players.c b/source/game/players.c index d53568b54d..04dcec5aa9 100644 --- a/source/game/players.c +++ b/source/game/players.c @@ -2744,10 +2744,10 @@ game uses the largest group of living teammates, jumping, riding, or standing where the BSP has no cluster (a beach) included. The respawn is beside someone in that group who is safe to spawn next to. When that whole group is in a fight, it waits out COOP_RESPAWN_FALLBACK_TICKS and then uses -the group anyway, someone standing in the BSP if the group has one. A safe +someone standing in the BSP anyway, in the group if it has one. A safe player outside the group does not win. A dead host has no unit, so the same count applies to them. NONE while the group is still unsafe, or while -nobody is alive. */ +nobody alive is standing in the BSP. */ static long players_coop_respawn_unit( void) { @@ -2821,29 +2821,35 @@ static long players_coop_respawn_unit( return unit_indices[safe_index]; if (game_time_get() + 1 - players_coop_state.respawn_wait_since >= COOP_RESPAWN_FALLBACK_TICKS) { - short fallback_index = anchor_index; + short fallback_index = -1; short fallback_count = 0; + boolean fallback_in_group = FALSE; - /* someone standing in the BSP, even under fire, before a teammate - who is in the air or outside the BSP */ + /* someone standing in the BSP, even under fire: in the group if it + has one, else anyone. Never someone in the air, outside the BSP or + riding an AI vehicle (the Pelican): NONE waits for one */ for (index = 0; index < count; index++) { short near_count; + boolean in_group; - if (distance_squared3d(&positions[anchor_index], &positions[index]) > radius_squared || - !players_coop_unit_grounded(unit_indices[index]) || + if (!players_coop_unit_grounded(unit_indices[index]) || !players_coop_unit_spawnable(unit_indices[index])) { continue; } + in_group = distance_squared3d(&positions[anchor_index], &positions[index]) <= radius_squared; + if (fallback_in_group && !in_group) + continue; near_count = players_coop_near_count(positions, count, index, radius_squared); - if (near_count > fallback_count) + if ((in_group && !fallback_in_group) || near_count > fallback_count) { fallback_count = near_count; fallback_index = index; + fallback_in_group = in_group; } } - return unit_indices[fallback_index]; + return fallback_index >= 0 ? unit_indices[fallback_index] : NONE; } return NONE; @@ -3323,6 +3329,30 @@ void players_respawn_at_checkpoint( return; } +boolean player_position_switches_bsp( + real_point3d const *position) +{ + struct scenario *scenario = global_scenario_get(); + short index; + + if (!position || !scenario || global_structure_bsp_index == NONE) + return FALSE; + /* port: a short counter stops at SHORT_MAX (a map's count) */ + for (index = 0; index < MIN(scenario->bsp_switch_trigger_volumes.count, SHORT_MAX); index++) + { + struct scenario_bsp_switch_trigger_volume *volume = TAG_BLOCK_GET_ELEMENT( + &scenario->bsp_switch_trigger_volumes, index, struct scenario_bsp_switch_trigger_volume); + + if (volume->source_structure_bsp_index == global_structure_bsp_index && + scenario_trigger_volume_test_point(volume->trigger_volume_index, position)) + { + return TRUE; + } + } + + return FALSE; +} + /* port: the trigger that switches away from the loaded structure BSP the unit stands in, or NONE */ static short players_coop_bsp_switch_trigger( diff --git a/source/game/players.h b/source/game/players.h index 34e297493b..a2ea43ba72 100644 --- a/source/game/players.h +++ b/source/game/players.h @@ -388,6 +388,10 @@ boolean players_coop_waiting_to_start( /* port: the co-op host's bringto command: everyone comes to the host */ boolean players_coop_bring_to_host( void); +/* port: TRUE if `position` is inside a switch trigger for the BSP that is +loaded. A player left there switches the level on the next tick. */ +boolean player_position_switches_bsp( + real_point3d const *position); void players_note_checkpoint( void); void players_respawn_at_checkpoint( diff --git a/source/hs/hs.c b/source/hs/hs.c index 213fd10d51..c4a2d471da 100644 --- a/source/hs/hs.c +++ b/source/hs/hs.c @@ -13549,11 +13549,20 @@ void hs_node_gc( return; } +/* port: some multiplayer maps keep a copy of the place for each game type, +with its own sky. The script's first switch_bsp is the copy for the type the +host started. A later switch to another copy reloads the level every couple +of seconds, so that request is ignored. Cleared for each map. */ +static short scripted_multiplayer_bsp = NONE; +static boolean scripted_multiplayer_bsp_reported = FALSE; + void hs_initialize_for_new_map( void) { struct scenario *scenario; + scripted_multiplayer_bsp = NONE; + scripted_multiplayer_bsp_reported = FALSE; scenario = global_scenario_index != NONE ? global_scenario_get() : NULL; hs_allocate(); if (scenario && scenario->hs_syntax_data.size) @@ -14957,7 +14966,41 @@ HS_EVALUATE_VOID_FROM_ARGUMENTS(debug_player_teleport_evaluate, struct hs_argume HS_EVALUATE_VOID_STRING(main_set_map_name_evaluate, main_set_map_name) HS_EVALUATE_VOID_STRING(main_set_multiplayer_map_name_evaluate, main_set_multiplayer_map_name) HS_EVALUATE_VOID_UNSIGNED_SHORT(main_set_difficulty_evaluate, main_set_difficulty) -HS_EVALUATE_VOID_FROM_ARGUMENTS(scenario_switch_structure_bsp_evaluate, struct hs_arguments_word, (scenario_switch_structure_bsp(arguments->value))) +/* port: a campaign script still switches, and so does a player walking +through a BSP trigger (main_switch_structure_bsp, not this). */ +static void scenario_switch_structure_bsp_evaluate( + short function_index, + long thread_index, + boolean initialize) +{ + struct hs_arguments_word const *arguments; + short structure_bsp_index; + + arguments = (struct hs_arguments_word const *)hs_macro_function_evaluate(function_index, thread_index, initialize); + if (arguments) + { + structure_bsp_index = (short)arguments->value; + if (!game_engine_running() || global_structure_bsp_index_get() == NONE) + { + scripted_multiplayer_bsp = NONE; + scripted_multiplayer_bsp_reported = FALSE; + scenario_switch_structure_bsp(structure_bsp_index); + } + else if (scripted_multiplayer_bsp == NONE || + scripted_multiplayer_bsp == structure_bsp_index) + { + scripted_multiplayer_bsp = structure_bsp_index; + scenario_switch_structure_bsp(structure_bsp_index); + } + else if (!scripted_multiplayer_bsp_reported) + { + scripted_multiplayer_bsp_reported = TRUE; + error(_error_silent, "switch_bsp from a script is ignored after the multiplayer game has chosen its bsp"); + } + hs_return(thread_index, 0); + } + return; +} HS_EVALUATE_VOID_STRING(main_crash_evaluate, main_crash) HS_EVALUATE_VOID_STRING(debug_dump_memory_for_file_evaluate, debug_dump_memory_for_file) HS_EVALUATE_VOID_STRING(profile_dump_to_file_evaluate, profile_dump_to_file) diff --git a/source/hs/hs_library_external.c b/source/hs/hs_library_external.c index 73dde5cf6c..7018e5c288 100644 --- a/source/hs/hs_library_external.c +++ b/source/hs/hs_library_external.c @@ -176,7 +176,12 @@ void hs_print( (0x4b8970 +0x0c pushes it as terminal_printf's format), so a '%' in it, from a scenario script or typed at the console, read arguments that were never passed */ - terminal_printf(global_real_argb_green, "%s", message); + /* Scenario scripts print music cues and other development chatter. + Keep it in debug.txt, but respect the same screen filter as engine logs. + Explicit console commands still show their requested output. */ + error(_error_log, "%s", message); + if (terminal_shows(terminal_command_running ? _terminal_message_serious : _terminal_message_chatter)) + terminal_printf(global_real_argb_green, "%s", message); return; } @@ -1038,9 +1043,18 @@ void hs_object_teleport( long object_index, short cutscene_flag_index) { - hs_object_orient(object_index, cutscene_flag_index, TRUE, TRUE); - /* port: co-op players the scripts can't name go with player0 */ - coop_scripts_teleport_followers(object_index); + /* port: followers go to the flag, not to wherever player0 was left when + that teleport did not fit */ + if (object_index != NONE) + { + struct scenario_cutscene_flag *flag = TAG_BLOCK_GET_ELEMENT( + &global_scenario_get()->cutscene_flags, + cutscene_flag_index, + struct scenario_cutscene_flag); + + hs_object_orient(object_index, cutscene_flag_index, TRUE, TRUE); + coop_scripts_teleport_followers(object_index, &flag->position); + } return; } @@ -1059,9 +1073,20 @@ void hs_teleport_players_not_in_trigger_volume( short cutscene_flag_index) { long player_index; - /* port: in network co-op the first player moved goes to the flag and - the rest around them, instead of all into the same spot */ - long first_unit_index = NONE; + /* port: in network co-op the host spreads players around the flag. + A client does not move them; the host's copies arrive with the units. */ + long arrived[HALO_PORT_MAXIMUM_NETWORK_PLAYERS]; + short arrived_count = 0; + boolean coop = network_coop_active(); + struct scenario_cutscene_flag *flag = NULL; + + if (coop && game_connection() == _game_connection_network_server) + { + flag = TAG_BLOCK_GET_ELEMENT( + &global_scenario_get()->cutscene_flags, + cutscene_flag_index, + struct scenario_cutscene_flag); + } for (player_index = data_next_index(player_data, NONE); player_index != NONE; @@ -1070,23 +1095,35 @@ void hs_teleport_players_not_in_trigger_volume( struct player_datum *player; player = player_get(player_index); - if (player->unit_index != NONE && - !scenario_trigger_volume_test_object( + if (player->unit_index == NONE || + scenario_trigger_volume_test_object( trigger_volume_index, player->unit_index)) { - if (first_unit_index != NONE && network_coop_active()) - { - player_teleport(player_index, first_unit_index, &object_get(first_unit_index)->object.position); - continue; - } - hs_object_orient( - player->unit_index, - cutscene_flag_index, - TRUE, - TRUE); - first_unit_index = player->unit_index; + continue; + } + if (coop && !flag) + continue; + if (flag) + { + boolean here = FALSE; + real_point3d origin; + + if (arrived_count == 0) + hs_object_orient(player->unit_index, cutscene_flag_index, TRUE, TRUE); + here = coop_scripts_unit_near(player->unit_index, &flag->position) && + !player_position_switches_bsp(object_get_origin(player->unit_index, &origin)); + if (!here) + here = coop_scripts_arrive_at(player_index, &flag->position, arrived, arrived_count); + if (here && arrived_count < NUMBEROF(arrived)) + arrived[arrived_count++] = player->unit_index; + continue; } + hs_object_orient( + player->unit_index, + cutscene_flag_index, + TRUE, + TRUE); } return; diff --git a/source/interface/interface.c b/source/interface/interface.c index e0d616f804..a55a641c7b 100644 --- a/source/interface/interface.c +++ b/source/interface/interface.c @@ -104,6 +104,7 @@ symbols in this file: #include "text/draw_string.h" #include "units/unit_definitions.h" #include "units/units.h" +#include "map_share.h" /* port: port/linux/game/map_share.c */ /* ---------- constants */ @@ -381,6 +382,8 @@ void interface_draw_fullscreen_overlays( cinematic_render(); interface_splitscreen_render(); hud_render_timer(); + /* port: a map's download (port/linux/game/map_share.c) */ + map_share_render(); terminal_draw(); main_framerate_render(); render_debug_profile(); diff --git a/source/main/main.c b/source/main/main.c index a3432233eb..bb0c0deee9 100644 --- a/source/main/main.c +++ b/source/main/main.c @@ -381,6 +381,7 @@ symbols in this file: #include "networking/network_server_manager.h" /* port: a co-op game's level won */ #include "network_coop.h" /* port: port/linux/game/network_coop.c */ #include "network_voice.h" /* port: port/linux/game/network_voice.c */ +#include "map_share.h" /* port: port/linux/game/map_share.c */ #include "profile_sections.h" /* port: port/linux/include/profile_sections.h */ #ifdef HALO_PROFILE #include "profile_trace.h" /* port: port/linux/src/profile_trace.c */ @@ -3482,6 +3483,8 @@ void main_loop( network_test_update(main_globals.main_menu_scenario_loaded, main_globals.seconds_elapsed); /* port: voice chat, in the lobby and in game (port/linux/game/network_voice.c) */ network_voice_update(); + /* port: map sharing, in the lobby and in game (port/linux/game/map_share.c) */ + map_share_update(); profile_scope_enter(main_network_start_frame_section) connection = main_globals.connection; if (connection==_game_connection_network_client) diff --git a/source/networking/network_client_manager.c b/source/networking/network_client_manager.c index dbb8ad6b42..1f0801d72f 100644 --- a/source/networking/network_client_manager.c +++ b/source/networking/network_client_manager.c @@ -398,6 +398,7 @@ symbols in this file: #include "networking/network_server_manager.h" #include "networking/network_server_manager_internal.h" #include "text/unicode.h" +#include "map_share.h" /* port: port/linux/game/map_share.c */ /* ---------- constants */ @@ -1515,6 +1516,12 @@ boolean network_game_client_add_player( 0x530, client && (local_player_index>=0) && (local_player_indexgame, sizeof(server->game)) && ((message = create_network_game_message( _message_server_begin_game, &begin_game, sizeof(begin_game))) != NULL)) { - if (network_game_server_send_message_to_all_machines(server, message)) + boolean told = TRUE; + long machine_index; + + /* port: a machine still downloading the map is not told to load + with the others. It joins once the download is done + (network_game_server_release_map_fetchers). */ + for (machine_index = 0; machine_index < MAXIMUM_NETWORK_MACHINE_COUNT; machine_index++) + { + struct network_game_server_client_machine *machine = + &server->client_machines[machine_index]; + + if (!network_game_server_client_machine_is_joined_to_game(server, machine)) + { + network_game_server_map_pending[machine_index] = FALSE; + network_game_server_discard_held_players(machine_index); + continue; + } + if (map_share_machine_needs_map(machine->machine_index)) + { + network_game_server_map_pending[machine_index] = TRUE; + network_event("machine #%d is still downloading the map; the others will not wait", + machine->machine_index); + continue; + } + network_game_server_map_pending[machine_index] = FALSE; + if (!network_game_server_send_message_to_client_machine(server, machine, message)) + told = FALSE; + } + if (told) network_event("signalling client machines to begin loading for network game"); else network_event("signalling client machines to begin loading for network game (some machines missed it)"); @@ -1949,6 +2000,7 @@ boolean network_game_server_start_network_game( } else { + network_game_server_restore_held_players(server); network_event("failed to signal client machines to begin loading for network game"); } } @@ -2410,6 +2462,7 @@ void network_game_server_client_machine_game_loading_complete( /* (the machines in the game: not a connection that has not joined) */ if (network_game_server_client_machine_is_joined_to_game(server, client_machine) && + !map_share_machine_needs_map(client_machine->machine_index) && !TEST_FLAG(client_machine->flags, _network_client_machine_level_loaded_bit)) { loading_machine_count++; @@ -2451,6 +2504,7 @@ static void network_game_server_check_loading_complete( &server->client_machines[client_machine_index]; if (network_game_server_client_machine_is_joined_to_game(server, client_machine) && + !map_share_machine_needs_map(client_machine->machine_index) && !TEST_FLAG(client_machine->flags, _network_client_machine_level_loaded_bit)) { return; @@ -2630,6 +2684,8 @@ void network_game_server_update_ticks( long client_machine_index; short tick_index; + network_game_server_release_map_fetchers(server); + for (tick_index = 0; tick_index < tick_count; tick_index++) { struct message_server_game_update game_update; @@ -2915,6 +2971,143 @@ static void network_game_server_start_late_joiner( begin_game.unused); } +/* port: a machine still downloading the map is not in the game the others +start, and neither are its players. They are put back when it joins. */ +static void network_game_server_hold_map_fetcher_players( + struct network_game_server *server) +{ + long index; + + network_game_server_held_player_count = 0; + for (index = 0; index < MAXIMUM_NETWORK_PLAYER_COUNT; index++) + { + struct network_player player = server->game.players[index]; + + if (!network_player_is_valid(&player) || !map_share_machine_needs_map(player.machine_index)) + continue; + if (network_game_server_held_player_count < MAXIMUM_NETWORK_PLAYER_COUNT) + { + network_game_server_held_players[network_game_server_held_player_count++] = player; + } + if (!network_game_remove_player(&server->game, &player)) + network_event("failed to hold a player still downloading the map"); + else + network_event("holding a player on machine #%d until the map is downloaded", + player.machine_index); + } +} + +static void network_game_server_restore_held_players( + struct network_game_server *server) +{ + long index; + + for (index = 0; index < network_game_server_held_player_count; index++) + { + struct network_player player = network_game_server_held_players[index]; + + if (!network_game_add_player(&server->game, &player)) + network_event("failed to put back a player held for a map download"); + } + network_game_server_held_player_count = 0; +} + +static void network_game_server_discard_held_players( + long machine_index) +{ + long index = 0; + + while (index < network_game_server_held_player_count) + { + if (network_game_server_held_players[index].machine_index != machine_index) + { + index++; + continue; + } + network_game_server_held_player_count--; + if (index < network_game_server_held_player_count) + { + network_game_server_held_players[index] = + network_game_server_held_players[network_game_server_held_player_count]; + } + } +} + +/* the held players are queued to join the game that is already going, as +any late joiner's players are (network_game_server_queue_player_for_addition): +the same checks, a name made unique against whoever joined meanwhile, and the +machine started, or refused, once they are in. FALSE if it had none held. */ +static boolean network_game_server_queue_held_players( + struct network_game_server *server, + long machine_index) +{ + boolean any = FALSE; + long index = 0; + + while (index < network_game_server_held_player_count) + { + struct network_player player; + + if (network_game_server_held_players[index].machine_index != machine_index) + { + index++; + continue; + } + player = network_game_server_held_players[index]; + network_game_server_held_player_count--; + if (index < network_game_server_held_player_count) + { + network_game_server_held_players[index] = + network_game_server_held_players[network_game_server_held_player_count]; + } + player.player_list_index = NONE; + network_game_server_queue_player_for_addition(server, &player); + network_event("a player from machine #%ld joins now that the map is downloaded", + machine_index); + any = TRUE; + } + return any; +} + +/* port: a machine that was still downloading the map when the others +started. Once the download is done and the game has begun, its players are +queued to join, and it starts when they are in. */ +static void network_game_server_release_map_fetchers( + struct network_game_server *server) +{ + long machine_index; + + for (machine_index = 0; machine_index < MAXIMUM_NETWORK_MACHINE_COUNT; machine_index++) + { + struct network_game_server_client_machine *machine = + &server->client_machines[machine_index]; + + if (!network_game_server_map_pending[machine_index]) + continue; + if (!network_game_server_client_machine_is_joined_to_game(server, machine)) + { + network_game_server_map_pending[machine_index] = FALSE; + network_game_server_discard_held_players(machine_index); + continue; + } + if (map_share_machine_needs_map(machine->machine_index)) + continue; + if (server->state != _network_game_server_state_ingame) + continue; + network_game_server_map_pending[machine_index] = FALSE; + if (TEST_FLAG(machine->flags, _network_client_machine_level_loaded_bit) || + TEST_FLAG(machine->flags, _network_client_machine_started_late_bit)) + { + network_game_server_discard_held_players(machine_index); + continue; + } + /* (one that had no players added before the start has none held: it + starts now, and its add request is queued as any late joiner's) */ + if (!network_game_server_queue_held_players(server, machine_index)) + network_game_server_start_late_joiner(server, machine); + } +} + /* the machines joining the game in progress (in the pregame, or loading) hear none of the game's messages, and drop a connection they hear nothing on for 15 seconds: a pregame keep-alive every 5 */ @@ -3271,11 +3464,14 @@ static boolean server_alone( return server->game.player_count == 1; } +/* port: a machine joining or leaving is in the game before its player is. +The countdown used to stop for that, and then start over, so a lobby rarely +finished. The players already in the game decide. A machine with no player +can join the game in progress. */ boolean server_ok_to_countdown( struct network_game_server *server) { if (server_has_enough_machines(server) && - server_has_a_player_on_each_machine(server) && (!server_needs_more_teams(server) || server_alone(server)) && (server->game.player_count >= server->game.minimum_players || server_alone(server))) { @@ -3490,6 +3686,9 @@ void network_game_server_change_map_name( map_name, NETWORK_GAME_MAP_NAME_LENGTH - 1); server->game.map.name[NETWORK_GAME_MAP_NAME_LENGTH - 1] = 0; + /* port: a Custom Edition map by the name its hash makes, which the + players download it by (port/linux/game/map_share.c) */ + map_share_level_name_hash(server->game.map.name, NETWORK_GAME_MAP_NAME_LENGTH); server->game.map.version = (long)cache_files_map_version(server->game.map.name); if (!network_game_server_send_game_data_pregame(server)) @@ -3682,6 +3881,19 @@ static boolean network_game_server_client_machine_timed_out( } if (TEST_FLAG(machine->flags, _network_client_machine_level_loaded_bit)) return silence > NETWORK_GAME_SERVER_CLIENT_TIMEOUT; + /* (port: downloading the map, which it says every few seconds, before + its players join: port/linux/game/map_share.c) */ + if (map_share_machine_downloading(machine->machine_index)) + return FALSE; + /* (its players were held out of the game until the download finishes, + so it has none yet: only its silence times it out, as a crashed one or + one whose download failed would otherwise keep its slot and its players + held all game) */ + if (machine->machine_index >= 0 && machine->machine_index < MAXIMUM_NETWORK_MACHINE_COUNT && + network_game_server_map_pending[machine->machine_index]) + { + return silence > NETWORK_GAME_SERVER_LATE_JOINER_TIMEOUT; + } /* (joining the game in progress: waiting for its players to be added, or loading; port: one that adds none in the time a machine has to in the pregame holds its slot for nothing) */ @@ -4233,6 +4445,8 @@ static void network_game_server_cooperative_round( server->game.variant_options.friendly_fire = friendly_fire; csstrncpy(server->game.map.name, network_game_server_cooperative_next_map, sizeof(server->game.map.name) - 1); server->game.map.name[sizeof(server->game.map.name) - 1] = 0; + /* port: by the name its hash makes (port/linux/game/map_share.c) */ + map_share_level_name_hash(server->game.map.name, sizeof(server->game.map.name)); server->game.map.version = (long)cache_files_map_version(server->game.map.name); main_set_multiplayer_map_name(server->game.map.name); server->game.maximum_teams = 1; @@ -4314,6 +4528,8 @@ static boolean network_game_server_setup_game_from_playlist( network_game_generate_local_machine_name(machine_name); ustrncpy(server->game.name, machine_name, NETWORK_GAME_NAME_LENGTH - 1); server->game.name[NETWORK_GAME_NAME_LENGTH - 1] = L'\0'; + /* port: by the name its hash makes (port/linux/game/map_share.c) */ + map_share_level_name_hash(server->game.map.name, sizeof(server->game.map.name)); server->game.map.version = (long)cache_files_map_version(server->game.map.name); server->game.minimum_players = 2; server->game.maximum_players = MAXIMUM_NETWORK_PLAYER_COUNT; @@ -4716,7 +4932,15 @@ static boolean network_game_server_have_all_machines_have_precached( if (network_game_server_client_machine_is_joined_to_game(server, machine)) { - boolean client_has_precached = TEST_FLAG( + boolean client_has_precached; + + /* port: a machine with no player is joining or leaving, and one + still downloading the map does not have it. Neither holds the + start. A download joins the game in progress once it is done. */ + if (!network_game_server_machine_has_players(server, machine->machine_index) || + map_share_machine_needs_map(machine->machine_index)) + continue; + client_has_precached = TEST_FLAG( server->client_machines[i].flags, _network_client_machine_precached_bit); @@ -4743,6 +4967,11 @@ static boolean network_game_server_idle_pregame_tasks( long now = (long)system_milliseconds(); boolean success = TRUE; + /* port: a machine that finished downloading the map after the others + were told to start loads now, or joins in progress if the game has begun */ + if (server->sent_start_game_message) + network_game_server_release_map_fetchers(server); + if (server->sent_start_game_message == FALSE) { long itr; @@ -4792,12 +5021,13 @@ static boolean network_game_server_idle_pregame_tasks( server, client_machine); } - /* port: a machine that joined and can add no player (the lobby - filled after it joined), or adds none, holds the countdown for - ever: refused */ + /* port: a machine that joined and adds no player (the lobby filled + after it joined, or it never adds one) is refused, so it does not + stay in the lobby */ else if (network_game_server_client_machine_is_joined_to_game(server, client_machine) && !network_game_server_client_machine_is_local(server, client_machine) && !network_game_server_machine_has_players(server, client_machine->machine_index) && + !map_share_machine_downloading(client_machine->machine_index) && (!network_game_has_free_player_slot(&server->game) || system_milliseconds() - network_game_server_client_machine_join_times[client_machine->machine_index] > NETWORK_GAME_SERVER_PLAYERLESS_MACHINE_TIMEOUT)) @@ -4929,7 +5159,8 @@ static boolean network_game_server_idle_pregame_tasks( _network_client_machine_connected_bit) && !TEST_FLAG( server->client_machines[i].flags, - _network_client_machine_level_loaded_bit)) + _network_client_machine_level_loaded_bit) && + !map_share_machine_needs_map(server->client_machines[i].machine_index)) { char ascii_name[MAXIMUM_MACHINE_NAME_LENGTH]; boolean removed; @@ -4970,6 +5201,9 @@ boolean network_game_server_reset_to_pregame( match_assert(NETWORK_SERVER_MANAGER_FILE, 0x324, server); csmemset(&server->countdown_state, 0, sizeof(server->countdown_state)); + csmemset(network_game_server_map_pending, 0, sizeof(network_game_server_map_pending)); + csmemset(network_game_server_held_players, 0, sizeof(network_game_server_held_players)); + network_game_server_held_player_count = 0; server->next_update_number = 0; server->time_of_first_client_loading_completion = 0; server->sent_start_game_message = FALSE; diff --git a/source/networking/network_server_message_handler.c b/source/networking/network_server_message_handler.c index 19b6cff651..974d4c7328 100644 --- a/source/networking/network_server_message_handler.c +++ b/source/networking/network_server_message_handler.c @@ -272,6 +272,9 @@ void network_distributed_handle_message(long machine_index, word const *message, /* port/linux/game/network_voice.c's: voice chat, in the lobby too */ boolean network_voice_handles_message(word const *message, word size); void network_voice_handle_message(long machine_index, word const *message, word size); +/* port/linux/game/map_share.c's: map sharing, in the lobby too */ +boolean map_share_handles_message(word const *message, word size); +void map_share_handle_message(long machine_index, word const *message, word size, boolean from_stream); void network_distributed_handle_stream_message(long machine_index, word const *message, word size); /* ---------- constants */ @@ -1395,6 +1398,20 @@ boolean network_game_server_handle_client_message( } break; } + /* (port: map sharing's from any machine that joined, in the + lobby too, from its stream alone: map_share.c) */ + if (map_share_handles_message(message, message_buffer_size)) + { + long machine_index; + + if (network_game_server_client_machine_is_joined_to_game(server, machine) && + network_game_server_get_client_machine(server, machine, &machine_index)) + { + network_game_server_client_machine_heard(server, machine); + map_share_handle_message(machine_index, message, message_buffer_size, TRUE); + } + break; + } /* (in game, from a machine that has loaded it, as a datagram is: else dropped) */ if (network_game_server_client_machine_is_joined_to_game(server, machine) && diff --git a/source/shell/shell.c b/source/shell/shell.c index feb0b44fab..260d6135b7 100644 --- a/source/shell/shell.c +++ b/source/shell/shell.c @@ -8,6 +8,7 @@ SHELL.C #include "shell.h" #include "errors.h" #include "tag_files.h" +#include "files.h" #include "real_math.h" #include "game_state.h" #include "rasterizer.h" @@ -36,6 +37,9 @@ boolean shell_initialize( errors_initialize(); tag_files_open(); + /* port: Custom Edition maps live in custom_maps, beside maps. + Make that folder when this launch does not already have it. */ + directory_create("custom_maps"); real_math_initialize(); game_state_initialize(); diff --git a/source/tag_files/files.c b/source/tag_files/files.c index b6280dfc48..048be1b1ce 100644 --- a/source/tag_files/files.c +++ b/source/tag_files/files.c @@ -291,6 +291,25 @@ void directory_create_or_delete_contents( return; } +/* port: make the folder when it is missing. An existing folder is left +alone, including whatever is already in it. */ +void directory_create( + const char *directory_name) +{ + struct file_reference directory; + struct file_reference_info *info = (struct file_reference_info *)&directory; + + memset(info, 0, FILE_REFERENCE_SIZE); + info->signature = FILE_REFERENCE_SIGNATURE; + info->location = NONE; + + file_reference_add_directory(&directory, directory_name); + if (!file_exists(&directory)) + file_create(&directory); + + return; +} + boolean datastore_read( const char *file_name, const char *field_name, diff --git a/source/tag_files/files.h b/source/tag_files/files.h index b8b2316115..4494c42b7b 100644 --- a/source/tag_files/files.h +++ b/source/tag_files/files.h @@ -146,6 +146,8 @@ char *file_reference_get_name(const struct file_reference *reference, unsigned l boolean file_references_equal(const struct file_reference *reference0, const struct file_reference *reference1); struct file_reference *file_reference_create_from_path(struct file_reference *reference, const char *path, boolean is_directory); void directory_create_or_delete_contents(const char *directory_name); +/* port: makes the folder when it is missing (shell_initialize: custom_maps) */ +void directory_create(const char *directory_name); boolean datastore_read(const char *file_name, const char *field_name, long length, void *data); boolean datastore_write(const char *file_name, const char *field_name, long length, const void *data); diff --git a/source/units/bipeds.c b/source/units/bipeds.c index 73015ac97a..f5f741d9eb 100644 --- a/source/units/bipeds.c +++ b/source/units/bipeds.c @@ -540,6 +540,17 @@ boolean rider_ejection = TRUE; static struct profile_section biped_update_section = {"biped_update", NONE, TRUE}; +/* port: coop_scripts.c sets this around one teleport */ +static boolean biped_fix_ignores_players; + +void biped_fix_ignore_players( + boolean ignore) +{ + biped_fix_ignores_players = ignore; + + return; +} + extern boolean debug_objects_biped_autoaim_pills; extern boolean debug_objects_biped_physics_pills; @@ -1163,8 +1174,10 @@ boolean biped_fix_position( _collision_test_for_bipeds_passthrough_living_flags : _collision_test_for_bipeds_living_flags; /* port: (where it fits among the other players, as it moves: - _biped_physics_in_pass_through_players_bit) */ - if (biped->unit.player_index != NONE && !network_coop_player_collisions()) + _biped_physics_in_pass_through_players_bit). A scripted co-op + teleport can also stand on a teammate for this one call. */ + if (biped_fix_ignores_players || + (biped->unit.player_index != NONE && !network_coop_player_collisions())) collision_flags |= FLAG(_collision_test_skip_player_bipeds_bit); } diff --git a/source/units/bipeds.h b/source/units/bipeds.h index 3c171d2aae..bc5e3fd4b3 100644 --- a/source/units/bipeds.h +++ b/source/units/bipeds.h @@ -136,6 +136,11 @@ boolean biped_fix_position( boolean fix_below_new_position, boolean dont_teleport, boolean use_radius_as_multiplier); +/* port: the next biped_fix_position calls skip other players, so a co-op +script can stand someone on a spot a teammate already occupies. Cleared by +the caller before it returns. */ +void biped_fix_ignore_players( + boolean ignore); void biped_accelerate( long biped_index, real_vector3d *acceleration); diff --git a/tools/android_build.py b/tools/android_build.py index 0a0552e583..abfa182ab4 100644 --- a/tools/android_build.py +++ b/tools/android_build.py @@ -238,7 +238,8 @@ def android_configure_inputs() -> List[Path]: def generate_android_build(n: Writer, sln: Any) -> None: macos = getattr(sln, "macos", False) ios = getattr(sln, "ios", False) - build_root = Path("build/ios") if ios else Path("build/macos") if macos else BUILD + ios_simulator = ios and os.environ.get("HALO_IOS_SIMULATOR") == "1" + build_root = Path("build/ios-simulator" if ios_simulator else "build/ios") if ios else Path("build/macos") if macos else BUILD config_path = LINUX_DIR / "port.json" if not config_path.is_file() or not (PORT_DIR / "host").is_dir(): return @@ -289,6 +290,8 @@ def generate_android_build(n: Writer, sln: Any) -> None: config: Dict[str, Any] = json.loads(config_path.read_text(encoding="utf-8")) guest_cc = getattr(sln, "android_guest_cc", None) or "clang" + if ios_simulator: + guest_cc += " --simulator" guest_dir = build_root / "guest" obj_dir = guest_dir / "obj" @@ -374,10 +377,10 @@ def generate_android_build(n: Writer, sln: Any) -> None: imports_s = gen_dir / "imports.s" host_table_c = build_root / "host" / "host_import_table.c" host_imports_list = PORT_DIR / "host_imports.list" - platform_imports = [Path("port/macos/host_imports.list")] if macos else [] + platform_imports = [Path("port/macos/host_imports.list")] if macos else [Path("port/ios/host_imports.list")] if ios else [] n.rule( name="android_imports", - command=f"{python} tools/android_imports.py {'--ios ' if ios else ''}--host-table {host_table_c} {imports_s} $in", + command=f"{python} tools/android_imports.py {'--ios ' if ios else ''}{'--simulator ' if ios_simulator else ''}--host-table {host_table_c} {imports_s} $in", description="ANDROID IMPORTS", ) n.build(outputs=[imports_s, host_table_c], rule="android_imports", @@ -415,7 +418,7 @@ def generate_android_build(n: Writer, sln: Any) -> None: platform = "ios" if ios else "macos" n.rule(name="macos_rebase_plugin", command=f"{python} tools/{platform}_build.py --plugin-only", description="MACOS COMPILER PASS") - plugin = build_root / "guest_rebase.dylib" + plugin = (Path("build/ios") if ios else build_root) / "guest_rebase.dylib" n.build(outputs=plugin, rule="macos_rebase_plugin", inputs=[Path("port/macos/compiler/guest_rebase.cpp")], implicit=[Path(f"tools/{platform}_build.py")]) diff --git a/tools/android_gl_stubs.py b/tools/android_gl_stubs.py index db473fe474..bb5cf81385 100644 --- a/tools/android_gl_stubs.py +++ b/tools/android_gl_stubs.py @@ -63,8 +63,8 @@ def main(): "#include ", "#include ", "#include ", - "#ifdef HALO_IOS", - "#define HOST_POINTER(p) ((p) ? (0x400000000ull | (unsigned int)(p)) : 0ull)", + "#if defined(HALO_IOS) && !defined(HALO_IOS_SIMULATOR)", + "#define HOST_POINTER(p) ((p) ? (0x7000000000ull | (unsigned int)(p)) : 0ull)", "#elif defined(HALO_MACOS)", "#define HOST_POINTER(p) ((p) ? (0x10000000000ull | (unsigned int)(p)) : 0ull)", "#else", diff --git a/tools/android_imports.py b/tools/android_imports.py index c687174412..e655b3680b 100644 --- a/tools/android_imports.py +++ b/tools/android_imports.py @@ -22,6 +22,9 @@ def main(): ios = "--ios" in arguments if ios: arguments.remove("--ios") + simulator = "--simulator" in arguments + if simulator: + arguments.remove("--simulator") host_table = None if arguments[0] == "--host-table": host_table = arguments[1] @@ -45,7 +48,7 @@ def main(): if ios: out += [f"\tmovz w16, #:abs_g0_nc:__host_import_table+{offset}", f"\tmovk w16, #:abs_g1_nc:__host_import_table+{offset}", - "\torr x16, x16, #0x400000000", "\tldr x16, [x16]"] + f"\torr x16, x16, #{'0x10000000000' if simulator else '0x7000000000'}", "\tldr x16, [x16]"] else: out += [f"\tadrp x16, __host_import_table+{offset}", f"\tldr x16, [x16, :lo12:__host_import_table+{offset}]"] diff --git a/tools/harness/tests/map_share_names.c b/tools/harness/tests/map_share_names.c new file mode 100644 index 0000000000..626b0fe32a --- /dev/null +++ b/tools/harness/tests/map_share_names.c @@ -0,0 +1,120 @@ +/* +MAP_SHARE_NAMES.C (test) + +The real names of map sharing (port/linux/game/map_share_files.c): a map's +file name ends in _ and the first digits of its hash, which the menus leave +out and a download is checked against. test_map_share_names.py takes the +constants (config.inc) and the code (under_test.inc). +*/ + +#include "harness.h" +#include "config.inc" + +#include +#include + +#define csstrlen(string) strlen(string) +#define MIN(a, b) ((a) < (b) ? (a) : (b)) +#define NUMBEROF(array) ((short)(sizeof(array) / sizeof((array)[0]))) +#define ustrcpy(destination, source) wcscpy(destination, source) +#define _strnicmp(first, second, count) strncasecmp(first, second, count) + +#include "under_test.inc" + +/* the menus' name of a map file's name (custom_edition_maps.c) */ +static char const *menu_name(char const *name, boolean hash) +{ + static wchar_t display_name[64]; + static char text[64]; + int index; + + display_name_make(name, hash, display_name); + for (index = 0; display_name[index] && index < 63; index++) + text[index] = (char)display_name[index]; + text[index] = 0; + return text; +} + +int main(int argc, char **argv) +{ + char const *case_name = argc > 1 ? argv[1] : ""; + + /* a name ends in _ and the hash's digits, lowercase hexadecimal */ + CASE("hashed-names") + { + CHECK(map_share_name_hashed("hugeass_0e2b3ee4"), "a hashed name was not taken for one"); + CHECK(map_share_name_hashed("a_00000000"), "a short hashed name was not taken for one"); + CHECK(!map_share_name_hashed("hugeass"), "a name without a hash was taken for one"); + CHECK(!map_share_name_hashed("hugeass_0E2B3EE4"), "uppercase digits were taken (the hash's are lowercase)"); + CHECK(!map_share_name_hashed("hugeass_0e2b3ee"), "seven digits were taken"); + CHECK(!map_share_name_hashed("hugeass0e2b3ee4"), "digits without _ were taken"); + CHECK(!map_share_name_hashed("hugeass_0e2b3eg4"), "a letter past f was taken"); + CHECK(!map_share_name_hashed("_0e2b3ee4"), "a hash with no name before it was taken"); + CHECK(!map_share_name_hashed(""), "nothing was taken"); + return 0; + } + /* a host's level name names a file of custom_maps alone */ + CASE("safe-names") + { + static char const *const unsafe[] = + { + "", ".", "..", ".x_0e2b3ee4", "../x_0e2b3ee4", "..\\x", "a/b", "c:x", "x.", " x", "x ", "a*b", "a?b", "a\"b", "ab", "a|b", "a\tb", "caf\xE9", + }; + int index; + + CHECK(map_share_name_safe("hugeass_0e2b3ee4"), "a map's name was refused"); + CHECK(map_share_name_safe("Elite_Alpha_Siege (v2).final"), "a name with spaces and dots inside was refused"); + for (index = 0; index < (int)(sizeof(unsafe) / sizeof(unsafe[0])); index++) + CHECK(!map_share_name_safe(unsafe[index]), "the name \"%s\" was taken", unsafe[index]); + return 0; + } + /* the menus' name leaves the hash out */ + CASE("base-length") + { + CHECK(map_share_name_base_length("hugeass_0e2b3ee4") == 7, "gave %d", (int)map_share_name_base_length("hugeass_0e2b3ee4")); + CHECK(map_share_name_base_length("the_bay_of_pigs") == 15, "a name without a hash was cut"); + CHECK(map_share_name_base_length("the_bay_of_pigs_12345678") == 15, "a name with _ in it lost more than its hash"); + return 0; + } + /* the digits are the hash's first bytes, as a name ends in them */ + CASE("digits") + { + static unsigned char const hash[32] = { 0x0E, 0x2B, 0x3E, 0xE4, 0xFF }; + char digits[MAP_SHARE_NAME_DIGITS + 1]; + char name[64]; + + memset(digits, 'x', sizeof(digits)); + map_share_hash_digits(hash, digits); + CHECK(!strcmp(digits, "0e2b3ee4"), "made %s", digits); + snprintf(name, sizeof(name), "hugeass_%s", digits); + CHECK(map_share_name_hashed(name), "a name made with the digits is not a hashed one"); + return 0; + } + /* a file's pieces: the last one shorter */ + CASE("pieces") + { + CHECK(map_share_piece_count(0) == 0, "an empty file has pieces"); + CHECK(map_share_piece_count(1) == 1, "one byte is not one piece"); + CHECK(map_share_piece_count(MAP_SHARE_PIECE_BYTES) == 1, "a piece's bytes are not one piece"); + CHECK(map_share_piece_count(MAP_SHARE_PIECE_BYTES + 1) == 2, "a byte more is not a second piece"); + CHECK(map_share_piece_count(59221172UL) == 226, "hugeass.map's pieces: %ld", map_share_piece_count(59221172UL)); + return 0; + } + /* the menus show a map without its hash, unless asked to tell versions + apart (Halo PC's own maps by their names) */ + CASE("display-names") + { + CHECK(!strcmp(menu_name("the_bay_of_pigs_0c7e7075", FALSE), "The Bay Of Pigs"), "made %s", + menu_name("the_bay_of_pigs_0c7e7075", FALSE)); + CHECK(!strcmp(menu_name("the_bay_of_pigs", FALSE), "The Bay Of Pigs"), "a name without a hash: %s", + menu_name("the_bay_of_pigs", FALSE)); + CHECK(!strcmp(menu_name("icefields_0e2b3ee4", FALSE), "Ice Fields"), "Halo PC's map: %s", + menu_name("icefields_0e2b3ee4", FALSE)); + CHECK(!strcmp(menu_name("hugeass_0e2b3ee4", TRUE), "Hugeass 0e2b3ee4"), "two versions: %s", + menu_name("hugeass_0e2b3ee4", TRUE)); + return 0; + } + fprintf(stderr, "unknown case: %s\n", case_name); + return 2; +} diff --git a/tools/harness/tests/test_map_share_names.py b/tools/harness/tests/test_map_share_names.py new file mode 100644 index 0000000000..de14505a9c --- /dev/null +++ b/tools/harness/tests/test_map_share_names.py @@ -0,0 +1,68 @@ +"""The real names of map sharing (map_share_files.c): a map named for its hash is known by its name's end, the menus +leave the hash out, and a download is checked against the digits the hash gives.""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from harness import CHECK_FAILED, build, constant, function, mutated, read, run # noqa: E402 + +CASES = ["hashed-names", "safe-names", "base-length", "digits", "pieces", "display-names"] + +# a fault in map_share_files.c, the function it is in, and the case that must catch it +NEGATIVE_CONTROLS = { + "takes-uppercase": (("(character >= 'a' && character <= 'f')", "(character >= 'A' && character <= 'f')"), + "hex_digit", "hashed-names"), + "lets-slashes-through": (("strchr(\"/\\\\:", "strchr(\"\\\\:"), "map_share_name_safe", "safe-names"), + "lets-dots-through": (("name[0] == '.' || ", ""), "map_share_name_safe", "safe-names"), + "no-underscore": (("|| name[length - MAP_SHARE_NAME_DIGITS - 1] != '_'", ""), "map_share_name_hashed", + "hashed-names"), + "keeps-the-underscore": (("length - MAP_SHARE_NAME_DIGITS - 1)", "length - MAP_SHARE_NAME_DIGITS)"), + "map_share_name_base_length", "base-length"), + "nibbles-swapped": (("hex[hash[index] >> 4]", "hex[hash[index] & 15]"), "map_share_hash_digits", "digits"), + "pieces-rounded-down": (("(size + MAP_SHARE_PIECE_BYTES - 1)", "(size)"), "map_share_piece_count", "pieces"), + "shows-the-hash": (("map_share_name_base_length(name)", "(short)csstrlen(name)"), "display_name_make", + "display-names"), + "stock-by-whole-name": (("map_share_name_base_length(name)", "(short)csstrlen(name)"), "stock_map_index", + "display-names"), +} + + +def generated(fault=None, faulty_function=None): + header = read("port/linux/game/map_share.h") + source = read("port/linux/game/map_share_files.c") + config = "" + for name in ("MAP_SHARE_NAME_DIGITS", "MAP_SHARE_PIECE_BYTES"): + config += f"#define {name} {constant(header, name)}\n" + text = "" + for name in ("hex_digit", "map_share_name_hashed", "map_share_name_safe", "map_share_name_base_length", + "map_share_hash_digits", "map_share_piece_count"): + code = function(source, name) + if fault and name == faulty_function: + code = mutated(code, *fault) + text += code + "\n" + # the menus' names (custom_edition_maps.c): Halo PC's own maps, and the rest + maps = read("port/linux/game/custom_edition_maps.c") + start = maps.index("static struct\n{\n\tchar const *file;") + text += maps[start:maps.index("};", start) + 2] + "\n" + for name in ("stock_map_index", "display_name_make"): + code = function(maps, name) + if fault and name == faulty_function: + code = mutated(code, *fault) + text += code + "\n" + return (("config.inc", config), ("under_test.inc", text)) + + +@pytest.mark.parametrize("case", CASES) +def test_case(case): + status, output = run(build("map_share_names", 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("map_share_names", generated(fault, faulty_function)), case) + assert status == CHECK_FAILED, f"map_share_files.c with a fault ({control}) passed '{case}': the test cannot see it" diff --git a/tools/ios_build.py b/tools/ios_build.py index f746e3ba85..0c7c4572cf 100644 --- a/tools/ios_build.py +++ b/tools/ios_build.py @@ -28,7 +28,7 @@ def build_app(platform, args): run(cmake, '-S', 'port/ios', '-B', directory, '-G', 'Xcode', '-DCMAKE_SYSTEM_NAME=iOS', f'-DCMAKE_OSX_SYSROOT={sdk}', '-DCMAKE_OSX_ARCHITECTURES=arm64', '-DCMAKE_OSX_DEPLOYMENT_TARGET=26.0', - f'-DHALO_BUNDLE_MAPS={"OFF" if args.no_bundle_maps else "ON"}', + f'-DHALO_BUNDLE_IDENTIFIER={args.bundle_id}', f'-DHALO_DEVELOPMENT_TEAM={args.team or ""}') settings = ['-jobs', str(args.jobs)] @@ -53,7 +53,7 @@ def main(): p.add_argument('--guest-only', action='store_true') p.add_argument('--host-only', action='store_true', help='Reuse the compiled game image') p.add_argument('--platform', choices=['simulator', 'device', 'both'], default='simulator') - p.add_argument('--no-bundle-maps', action='store_true', help='Install game files separately through Files/Finder') + p.add_argument('--no-bundle-maps', action='store_true', help='Compatibility option: game data is always imported after installation') p.add_argument('--team', help='Apple development team ID for device signing') p.add_argument('--bundle-id', default='local.halo.ce-universal.ios', help='Unique reverse-DNS app identifier registered to your development team') @@ -66,16 +66,20 @@ def main(): if args.plugin_only: build_plugin() return - if not args.host_only: - run(sys.executable, 'configure.py', '--ios', '--android-guest-llvm-bin', 'build/macos/toolchain/bin', - '--android-guest-gl-include', 'build/macos/toolchain/gl', '--pgo', 'off') - ninja = shutil.which('ninja') or ROOT / 'build/macos/toolchain/venv/bin/ninja' - if not Path(ninja).is_file(): - raise RuntimeError('Install Ninja and run tools/macos_setup.py first; see docs/apple-build.md') - run(ninja, f'-j{args.jobs}', 'ios_guest') - run(sys.executable, 'tools/ios_embed.py', BUILD / 'halo_guest.elf', BUILD / 'embed') - if not args.guest_only: - for platform in (['simulator', 'device'] if args.platform == 'both' else [args.platform]): + for platform in (['simulator', 'device'] if args.platform == 'both' else [args.platform]): + # Device code uses a 448-GB arena above iOS's GPU carveout. The simulator's + # signed image uses the Mac's 1-TB arena; never reuse it on a device. + os.environ['HALO_IOS_SIMULATOR'] = '1' if platform == 'simulator' else '0' + guest = ROOT / ('build/ios-simulator' if platform == 'simulator' else 'build/ios') + if not args.host_only: + run(sys.executable, 'configure.py', '--ios', '--android-guest-llvm-bin', 'build/macos/toolchain/bin', + '--android-guest-gl-include', 'build/macos/toolchain/gl', '--pgo', 'off') + ninja = shutil.which('ninja') or ROOT / 'build/macos/toolchain/venv/bin/ninja' + if not Path(ninja).is_file(): + raise RuntimeError('Install Ninja and run tools/macos_setup.py first; see docs/apple-build.md') + run(ninja, f'-j{args.jobs}', 'ios_guest') + run(sys.executable, 'tools/ios_embed.py', guest / 'halo_guest.elf', guest / 'embed') + if not args.guest_only: build_app(platform, args) if __name__ == '__main__': diff --git a/tools/ios_extract_assets.py b/tools/ios_extract_assets.py new file mode 100644 index 0000000000..4616ab6e34 --- /dev/null +++ b/tools/ios_extract_assets.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Inspect and extract Halo map files from a user's Xbox XISO without changing them.""" +import argparse +import hashlib +import json +import struct +from pathlib import Path + + +def map_entries(image): + """Read bounded XDVDFS directory trees and return map file extents.""" + length=image.seek(0,2) + for base in (0,0xFD90000,0x2080000,0x18300000): + image.seek(base+32*2048);header=image.read(2048) + if header[:20]==b'MICROSOFT*XBOX*MEDIA':break + else:raise ValueError('No Xbox filesystem header found') + root,size=struct.unpack_from('length or size>16*1024*1024 or sector in visited: + raise ValueError('Invalid or cyclic directory extent') + visited.add(sector);image.seek(extent);data=image.read(size);nodes=set() + def node(offset): + if offset in nodes or offset+14>len(data):raise ValueError('Invalid directory tree') + nodes.add(offset) + left,right,sector,size,attr,count=struct.unpack_from('len(data):raise ValueError('Invalid filename length') + name=data[offset+14:offset+14+count].decode('ascii') + if not name or name in ('.','..') or any(c in name for c in '/\\\0'):raise ValueError('Invalid filename') + if left:yield from node(left*4) + path=prefix/name + if attr&16: + yield from directory(sector,size,path) + elif path.parts[0].lower()=='maps' and path.suffix.lower()=='.map': + start=base+sector*2048 + if start+size>length:raise ValueError('Map extends beyond image') + yield path,start,size + if right:yield from node(right*4) + yield from node(0) + return list(directory(root,size,Path())) + + +def main(): + """Report exact cache build IDs; optionally copy the maps unchanged.""" + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('image',type=Path) + parser.add_argument('--output',type=Path,help='new output directory; existing files are never replaced') + args=parser.parse_args();manifest=[] + with args.image.open('rb') as image: + for path,start,size in map_entries(image): + image.seek(start);header=image.read(2048) + if header[:4]!=b'daeh' or header[-4:]!=b'toof':raise ValueError(f'Invalid Halo cache header: {path}') + build=header[64:96].split(b'\0',1)[0].decode('ascii') + entry={'path':str(path),'bytes':size,'build':build,'version':struct.unpack_from('> 16 + if (path.is_absolute() or '..' in path.parts or '\\' in entry.filename + or not path.parts or path.parts[0] != 'Payload' + or stat.S_ISLNK(mode)): + parser.error('Expected a Halo IPA containing only Payload, without unsafe paths or symlinks.') + archive.extractall(stage) + apps = list((stage/'Payload').glob('*.app')) + if len(apps) != 1: + parser.error('Expected exactly one application in Payload.') + app = apps[0] + info_path = app/'Info.plist' + info = plistlib.loads(info_path.read_bytes()) + if (info.get('CFBundleExecutable') not in ('Halo', 'HaloCE') + or info.get('CFBundleSupportedPlatforms') != ['iPhoneOS'] + or not (app/info['CFBundleExecutable']).is_file()): + parser.error('Expected a HaloCE iPhone/iPad device build.') + if any(app.rglob('*.appex')) or any(app.rglob('*.app')): + parser.error('Nested apps/extensions require separate profiles and are unsupported.') + for signature in app.rglob('_CodeSignature'): + shutil.rmtree(signature) + info['CFBundleIdentifier'] = args.bundle_id + info_path.write_bytes(plistlib.dumps(info)) + shutil.copyfile(args.profile, app/'embedded.mobileprovision') + entitlement_path = stage/'entitlements.plist' + entitlement_path.write_bytes(plistlib.dumps(entitlements)) + # ZIP extraction does not preserve executable mode. Restore bundle + # executables before signing, including SDL and ANGLE frameworks. + nested = list(app.rglob('*.framework')) + list(app.rglob('*.dylib')) + for target in sorted(nested, key=lambda p: len(p.parts), reverse=True): + if target.suffix == '.framework': + framework = plistlib.loads((target/'Info.plist').read_bytes()) + executable = target/framework['CFBundleExecutable'] + else: + executable = target + executable.chmod(0o755) + run('codesign', '--force', '--sign', identity, '--timestamp=none', target) + (app/info['CFBundleExecutable']).chmod(0o755) + run('codesign', '--force', '--sign', identity, '--timestamp=none', + '--generate-entitlement-der', '--entitlements', entitlement_path, app) + run('codesign', '--verify', '--deep', '--strict', app) + args.output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(args.output, 'x', zipfile.ZIP_DEFLATED) as archive: + for path in sorted(app.rglob('*')): + if path.is_file(): + archive.write(path, path.relative_to(stage)) + digest = hashlib.sha256(args.output.read_bytes()).hexdigest() + args.output.with_suffix(args.output.suffix+'.sha256').write_text(f'{digest} {args.output.name}\n') + print(f'Signed IPA: {args.output}\nBundle ID: {args.bundle_id}\nProfile expires: {profile["ExpirationDate"]} UTC') + print('Install with Apple Configurator, or extract the IPA and use xcrun devicectl device install app.') + print('Keep your signing account and bundle ID unchanged when updating to preserve imported game data.') + + +if __name__ == '__main__': + try: + main() + except (OSError, ValueError, KeyError, zipfile.BadZipFile, subprocess.CalledProcessError) as error: + sys.exit(f'Re-signing failed: {error}') diff --git a/tools/ios_resign.sh b/tools/ios_resign.sh new file mode 100755 index 0000000000..21c725304c --- /dev/null +++ b/tools/ios_resign.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Re-sign the public Halo IPA on macOS using your own keychain identity/profile. +set -euo pipefail +exec python3 "$(dirname "$0")/ios_resign.py" "$@" diff --git a/tools/ios_xiso_test.py b/tools/ios_xiso_test.py new file mode 100644 index 0000000000..8de99ff178 --- /dev/null +++ b/tools/ios_xiso_test.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Test native XISO import with synthetic, non-proprietary disc images.""" +from pathlib import Path +import hashlib +import os +import random +import struct +import subprocess +import tempfile +import unittest +from ios_extract_assets import map_entries + +ROOT=Path(__file__).resolve().parents[1] +BUILD=ROOT/'build/ios/xiso-tests' +BUILD.mkdir(parents=True,exist_ok=True) +PROBE=BUILD/'probe' +subprocess.run(['xcrun','clang','-std=c11','-Wall','-Wextra','-Werror','-g', + '-fsanitize=address,undefined','-Iport/apple','port/apple/xiso.c', + 'port/ios/tests/xiso_probe.c','-o',str(PROBE)],cwd=ROOT,check=True) +NAMES=['ui.map','a10.map','a30.map','a50.map','b30.map','b40.map','c10.map','c20.map','c40.map','d20.map','d40.map'] +MAGIC=b'MICROSOFT*XBOX*MEDIA' + + +def fixture(path, build='01.10.12.2276', names=None, partition=0): + names=NAMES if names is None else names + entries=bytearray(2048);payloads={} + with path.open('wb') as image: + header=bytearray(2048);header[:20]=header[0x7ec:]=MAGIC + struct.pack_into(' list: ("header_bounds", "7 -13 19 -7" if wide else "7 -6 19 0"), ("footer_bounds", "7 208 19 214" if wide else "7 150 19 156")], ['']) - for index, (label, category_folder, _) in enumerate(spec.get("categories", ())): + # (each category's row: its label, the screen it opens, its help, and the + # platforms it is for, as a row's: every one's when it names none) + 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']}" + platform = category_platform[0] if category_platform else None if spec.get("platform_places"): - for name in places: + members = platform.split() if platform else list(places) + for name in members: places[name] += 1 if places["desktop"] == places["android"]: rows.append((row, None, places["desktop"])) else: rows += [(row, name, places[name]) for name in ("desktop", "android")] else: - rows.append((row, None, place + 1 + index)) + 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")], + ("bitmap", "bitmaps/option_bkds"), ("color", "#FF2896FF"), ("platform", platform)], [f'', f'', '', f'']) @@ -405,7 +435,7 @@ def _setting_screen(folder: str, spec: dict) -> list: # (the help of the row whose label is string n is n + 1: the buttons' is 0) extra += _strings(f"{base}/help_strings", [""] + [help_text.replace("\n", "\\n") for _, _, _, help_text, *_ in spec["rows"]] + - [help_text.replace("\n", "\\n") for _, _, help_text in spec.get("categories", ())]) + [help_text.replace("\n", "\\n") for _, _, help_text, *_ in spec.get("categories", ())]) return _screen(folder, spec, rows, ["port settings help"], [], extra) @@ -830,8 +860,8 @@ def _server_settings() -> list: whether the server browser lists it (PUBLIC or PRIVATE: an internet game's)""" base = f"{MT}/server_settings" - # (eleven rows: closer together, the help lower) - spec = {"screen": "server_settings_screen", "spacing": 26, "help_top": 366, + # (twelve rows: closer together, the help lower) + spec = {"screen": "server_settings_screen", "spacing": 24, "help_top": 366, "header": ("header_server_settings", f"{base}/header_server_settings")} rows, extra = [], [] extra += _value_row(base, "server_name", 0, "ss edit server name") @@ -878,6 +908,22 @@ def _server_settings() -> list: # which players from the server browser must type extra += _value_row(base, "password", 10 + len(COOP_SETUP_SCREENS), "ss edit server password") rows.append((f"{base}/op_password", None)) + # MAP SHARING (map_share.c), a screen of its own as co-op's options are, + # for any game: SETUP_OPTIONS_PLACE below it + key, _, _, _ = MAP_SHARE_SETUP + target = f"{base}/{key}/{key}_screen" + extra += _widget(f"{base}/op_{key}", [("width", 512), ("height", 28), + ("flags", "pass_unhandled_to_focused_child"), + ("bitmap", "bitmaps/option_bkds"), ("color", "#FF2896FF")], + [f'', f'', + '', + f'']) + extra += _widget(f"{base}/{key}_label", [("type", "text"), ("controller", 1), ("width", 300), ("height", 22), + ("string_list", f"{base}/labels"), + ("string_index", 11 + len(COOP_SETUP_SCREENS)), + ("font", "ui\\large_ui"), ("color", "#FF2896FF"), ("text_x", 13), + ("text_y", 4)], []) + rows.append((f"{base}/op_{key}", None, SETUP_OPTIONS_PLACE - 1)) # co-op's options, in the place of the gametype's rows, which co-op hides # as multiplayer hides co-op's (menu_functions.c's server_settings_update): # rows opening their screens, as the gametype's options' do @@ -894,7 +940,7 @@ def _server_settings() -> list: ("string_list", f"{base}/labels"), ("string_index", 10 + index), ("font", "ui\\large_ui"), ("color", "#FF2896FF"), ("text_x", 13), ("text_y", 4)], []) - rows.append((f"{base}/op_{key}", None, 5 + index)) + rows.append((f"{base}/op_{key}", None, SETUP_OPTIONS_PLACE + index)) # the gametype's options for this game (the gametype editor's screens, # editing a copy of the gametype chosen: "port setup edit") for index, (key, screen) in enumerate(SETUP_OPTION_SCREENS): @@ -913,7 +959,7 @@ def _server_settings() -> list: extra += _widget(f"{base}/{key}_value", [("type", "text"), ("controller", 1), ("width", 280), ("height", 22), ("font", "ui\\small_ui"), ("color", "#FF2896FF"), ("align", "center"), ("text_y", 5), ("text_flags", "no_focus_test")], []) - rows.append((f"{base}/op_{key}", None, 5 + index)) + rows.append((f"{base}/op_{key}", None, SETUP_OPTIONS_PLACE + index)) extra += _button(f"{base}/button_defaults", 3, []) extra += _widget(f"{base}/button_ok", [("type", "text"), ("width", 128), ("height", 24), ("bitmap", "bitmaps/text_button_background"), ("text", "START GAME"), @@ -925,7 +971,8 @@ def _server_settings() -> list: extra += _strings(f"{base}/labels", ["GAME NAME:", "MAXIMUM PLAYERS:", "INVITE LINK:", "GAME TYPE:", "PLAYER OPTIONS:", "ITEM OPTIONS:", "VEHICLE OPTIONS:", "INDICATOR OPTIONS:", "TEAMPLAY OPTIONS:", "LISTING:", - *(f"{title}:" for _, title, _, _ in COOP_SETUP_SCREENS), "PASSWORD:"]) + *(f"{title}:" for _, title, _, _ in COOP_SETUP_SCREENS), "PASSWORD:", + f"{MAP_SHARE_SETUP[1]}:"]) extra += _strings(f"{base}/help_strings", [ "", "The name the game shows in the lists of games.\\nEnter changes it.", @@ -944,11 +991,13 @@ def _server_settings() -> list: *(help_text for _, _, help_text, _ in COOP_SETUP_SCREENS), # (PASSWORD's) "Players from the Server Browser must type it to\\njoin (an invite link needs none). Enter sets it.", + # (MAP SHARING's) + MAP_SHARE_SETUP[2], ]) lines = _screen(base, spec, rows, ["server settings update"], [''], extra) - # (co-op's options' screens) - for key, title, _, screen_rows in COOP_SETUP_SCREENS: + # (co-op's options' screens, and map sharing's) + for key, title, _, screen_rows in [*COOP_SETUP_SCREENS, MAP_SHARE_SETUP]: lines += _setup_option_screen(f"{base}/{key}", key, screen_rows) # (no Defaults: the bar is START and CANCEL; B cancels the name's edit # first) @@ -994,8 +1043,26 @@ def _server_settings() -> list: ]), ("voice_options", "VOICE AND VOTING", "Voice chat and votes to kick a player, for this\\ngame.", TEAMPLAY_ROWS), ] +# Server Setup's map sharing (port/linux/game/map_share.c), for any game, +# laid out as co-op's options are (its key, its title, its row's help and +# its rows: menu_functions.c's gametype_options give their settings' values) +MAP_SHARE_UPLOADS = (256, 512, 1024, 2048, 4096, 8192) +MAP_SHARE_SETUP = ("map_share_options", "MAP SHARING", + "Sending players who join without the game's\\nCustom Edition map the map, and how fast.", [ + ("map_share", "SHARE MAPS:", ["ON", "OFF"], [ + "Players who join without the map download it\\nfrom you and the other players.", + "Players who join without the map cannot play.", + ]), + ("map_share_upload", "UPLOAD LIMIT:", [f"{kb} KB/S" if kb < 1024 else f"{kb // 1024} MB/S" + for kb in MAP_SHARE_UPLOADS], + ["The most you send of maps, so that the game\\nkeeps the rest of your upload."] * len(MAP_SHARE_UPLOADS)), +]) +# (Server Setup's place of its options' rows, co-op's or the gametype's: after +# MAP SHARING's) +SETUP_OPTIONS_PLACE = 6 # (their screens' titles) -TITLES.update({f"{MT}/server_settings/{key}/header_{key}": title for key, title, _, _ in COOP_SETUP_SCREENS}) +TITLES.update({f"{MT}/server_settings/{key}/header_{key}": title + for key, title, _, _ in [*COOP_SETUP_SCREENS, MAP_SHARE_SETUP]}) # (the rows of a co-op options screen in one place, the one shown: PER # PLAYER's and MULTIPLIER's, by EXTRA ENEMIES') SETUP_OPTION_SAME_PLACE = {"coop_enemies_multiplier"} diff --git a/tools/test_cache_file_formats.py b/tools/test_cache_file_formats.py index 53d53ec38e..1740aac4f1 100644 --- a/tools/test_cache_file_formats.py +++ b/tools/test_cache_file_formats.py @@ -649,6 +649,33 @@ def test_minimal_custom_edition_cache_loads_with_every_resource(report_tool, tmp assert report["convert"] == "ok" +def test_the_resource_maps_a_map_needs_come_from_its_tag_index(report_tool, tmp_path): + """the tags whose index entry says a resource map holds them (word 0x18): + a bitmap bitmaps.map, a sound sounds.map, strings, a font and HUD text + loc.map (map sharing sends those with the map)""" + _, report = report_one(report_tool, Map().write(tmp_path)) + assert report["resource_maps_needed"] == "bitmaps, sounds, loc" + + +def test_a_map_whose_tags_are_its_own_needs_none_of_their_resource_maps(report_tool, tmp_path): + """the external bitmap and sound marked as held in the map: only + loc.map is needed (the load then fails, as their addresses are not the + map's; the scan reads the index alone)""" + path = Map().write(tmp_path) + _, report = report_one(report_tool, path) + data = bytearray(path.read_bytes()) + tag_data_offset = int(report["tag_data"].split("+")[0], 16) + index_instances = struct.unpack_from(" +#include "port_config.h" +void platform_log(const char *format, ...) { (void)format; } +int main(int argc, char **argv) { + const char *key = "input.mouse_sensitivity"; + double value = 0.225; + if (argc > 1 && !config_write_numbers(&key, &value, 1)) return 2; + printf("%.3f\n", config_real("input.mouse_sensitivity")); + return 0; +} +''' + +class AppleDefaults(unittest.TestCase): + def test_defaults_and_reload(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + (directory/'prefix.h').write_text(PREFIX) + (directory/'probe.c').write_text(HARNESS) + for platform in ('mac', 'ios'): + with self.subTest(platform=platform): + binary = directory/platform + flags = ['-DHALO_ANDROID=1', '-DHALO_MACOS=1'] + if platform == 'ios': flags += ['-DHALO_IOS=1'] + subprocess.run(['clang', '-std=gnu11', *flags, '-include', str(directory/'prefix.h'), + '-I'+str(PORT), '-I'+str(SDL/'include'), '-I'+str(ROOT/'port/third_party/tomlc17'), + str(directory/'probe.c'), str(PORT/'port_config.c'), str(ROOT/'port/third_party/tomlc17/tomlc17.c'), + '-L'+str(SDL/'lib'), '-lSDL3', '-o', str(binary)], check=True) + saves = directory/(platform+'-saves'); saves.mkdir() + env = {k:v for k,v in os.environ.items() if not k.startswith('HALO_')} + env.update(HALO_SAVE_ROOT=str(saves), HALO_CONFIG_ROOT=str(saves)) + def run(*args): + return subprocess.check_output([str(binary), *args], env=env, text=True).strip() + self.assertEqual(run(), '0.100') + data = tomllib.loads((saves/'config.toml').read_text()) + self.assertFalse(data['display']['viewmodel_shield']) + self.assertEqual(run('save'), '0.225') + self.assertEqual(run(), '0.225') diff --git a/tools/test_ios_controller_routing.py b/tools/test_ios_controller_routing.py new file mode 100644 index 0000000000..115d3ef3c2 --- /dev/null +++ b/tools/test_ios_controller_routing.py @@ -0,0 +1,108 @@ +"""Run production iOS routing with deterministic controller discovery/input.""" +import subprocess +from pathlib import Path +from harness import function + +ROOT = Path(__file__).resolve().parents[1] + + +def test_ios_touch_and_hardware_ports(tmp_path): + source = (ROOT / "port/ios/host/host_controls.m").read_text() + code = r'''#import +#include +#include +#include +#include +struct SDL_Gamepad { SDL_JoystickID id; }; +static struct SDL_Gamepad pads[]={{0},{1},{2},{3}}; +static SDL_JoystickID touch_id=1, primary_hardware; +static SDL_Gamepad *primary_gamepad; +static BOOL separate_players; +static NSString *const controllerChanged=@"test"; +static int attached=3, modal; +static int axes[4], buttons[4]; +static void host_logf(int level,const char *format,...) {} +#define HOST_LOG_INFO 1 +static int halo_debug_is_presented(void) {return modal;} +SDL_JoystickID *SDL_GetGamepads(int *count) { + *count=attached; SDL_JoystickID *ids=calloc(attached+1,sizeof(*ids)); + for(int i=0;iid;} +SDL_GamepadType SDL_GetGamepadType(SDL_Gamepad *pad) {return SDL_GAMEPAD_TYPE_STANDARD;} +Sint16 SDL_GetGamepadAxis(SDL_Gamepad *pad,SDL_GamepadAxis axis) {return axes[pad->id];} +bool SDL_GetGamepadButton(SDL_Gamepad *pad,SDL_GamepadButton button) {return buttons[pad->id];} +''' + for name in ("host_ios_gamepads", "host_ios_gamepad_type", "host_ios_gamepad_axis", "host_ios_gamepad_button"): + code += function(source, name) + "\n" + code += r''' +int main(void) {@autoreleasepool { + uint32_t out[4]={99,99,99,99}; + // Default: touch and first hardware share P1; second hardware gets P2. + assert(host_ios_gamepads(out,4)==2 && out[0]==1 && out[1]==3); + axes[1]=100; axes[2]=200; buttons[2]=1; + assert(host_ios_gamepad_axis(&pads[1],0)==200); + assert(host_ios_gamepad_button(&pads[1],0)); + // Opt-in split: touch P1, first hardware P2, second hardware P3. + separate_players=YES; + assert(host_ios_gamepads(out,4)==3 && out[0]==1 && out[1]==2 && out[2]==3); + assert(host_ios_gamepad_axis(&pads[1],0)==100); + assert(!host_ios_gamepad_button(&pads[1],0)); + assert(host_ios_gamepad_axis(&pads[2],0)==200); + assert(host_ios_gamepad_button(&pads[2],0)); + modal=1; + assert(!host_ios_gamepad_axis(&pads[2],0) && !host_ios_gamepad_button(&pads[2],0)); + modal=0; + assert(host_ios_gamepads(out,0)==0); + out[1]=99; assert(host_ios_gamepads(out,1)==1 && out[1]==99); + // Disconnect and reconnect keep touch first, without a ghost shared pad. + attached=1; + assert(host_ios_gamepads(out,4)==1 && out[0]==1 && !primary_hardware); + attached=2; + assert(host_ios_gamepads(out,4)==2 && out[1]==2); +}} +''' + src=tmp_path / "routing.m"; src.write_text(code) + exe=tmp_path / "routing" + subprocess.run(["clang", "-fobjc-arc", "-fblocks", "-framework", "Foundation", + "-fsanitize=address,undefined", "-I/opt/homebrew/opt/sdl3/include", + str(src), "-o", str(exe)], check=True) + subprocess.run([str(exe)], check=True) + + +def test_touch_never_moves_to_keyboard_port(tmp_path): + source=(ROOT / "port/linux/src/xinput_sdl.c").read_text() + code=""" +#include +#include +typedef int BOOL; +typedef int SDL_Gamepad; +#define PORT_COUNT 4 +static int lone_gamepad_split, split; +static int pc_menu_split_players(void) {return split;} +static int gamepad_idle(SDL_Gamepad *p) {return 1;} +""" + function(source,"port_gamepad") + """ +int main(void) { + int p1=1,p2=2; SDL_Gamepad *pads[4]={&p1,&p2}; + assert(port_gamepad(pads,1,0)==&p1); + split=1; +#ifdef HALO_IOS + assert(port_gamepad(pads,1,0)==&p1); + assert(port_gamepad(pads,1,1)==NULL); +#else + assert(port_gamepad(pads,1,0)==NULL); + assert(port_gamepad(pads,1,1)==&p1); +#endif + assert(port_gamepad(pads,2,0)==&p1 && port_gamepad(pads,2,1)==&p2); +} +""" + src=tmp_path / "ports.c";src.write_text(code) + for ios in (False,True): + exe=tmp_path / ("ios" if ios else "desktop") + subprocess.run(["clang", *(["-DHALO_IOS=1"] if ios else []), + "-fsanitize=address,undefined", str(src), "-o", str(exe)],check=True) + subprocess.run([str(exe)],check=True) diff --git a/tools/test_ios_runtime.py b/tools/test_ios_runtime.py index 713f2d4c7d..e8dd6dce38 100644 --- a/tools/test_ios_runtime.py +++ b/tools/test_ios_runtime.py @@ -13,7 +13,7 @@ def run(*args): def normalized_ir(path, bias): text = path.read_text().split('\n', 1)[1] # ModuleID contains the temporary filename. for offset in (0, 0xff0000): - text = text.replace(str(bias + offset), str(0x400000000 + offset)) + text = text.replace(str(bias + offset), str(0x7000000000 + offset)) return text def main(): @@ -28,7 +28,7 @@ def main(): run(sys.executable, 'tools/ios_guest_cc.py', '--mac-check', '--target=arm64_32-apple-watchos', '-mcpu=cortex-a53', '-O2', '-ffreestanding', '-fno-stack-protector', '-fno-unwind-tables', '-fno-asynchronous-unwind-tables', '-S', source, '-o', str(base) + '.darwin.s') - if normalized_ir(Path(str(device) + '.darwin.rebased.ll'), 0x400000000) != normalized_ir(Path(str(base) + '.darwin.rebased.ll'), 0x10000000000): + if normalized_ir(Path(str(device) + '.darwin.rebased.ll'), 0x7000000000) != normalized_ir(Path(str(base) + '.darwin.rebased.ll'), 0x10000000000): raise RuntimeError('Mac signed-image lowering differs from device IR beyond the arena address') run(sys.executable, 'tools/android_asm_convert.py', str(base) + '.darwin.s', str(base) + '.s') run(LLVM / 'clang', '--target=aarch64-linux-android', '-c', str(base) + '.s', '-o', str(base) + '.o') @@ -36,6 +36,7 @@ def main(): run('build/macos/toolchain/bin/ld.lld', '-m', 'aarch64linux', '-static', '-nostdlib', '-Ttext=0x88000000', '-e', 'guest_test', *objects, '-o', OUT / 'guest.elf') run(sys.executable, 'tools/ios_embed.py', OUT / 'guest.elf', OUT / 'embed') + run('clang', '-fsyntax-only', f'-I{OUT / "embed"}', 'port/ios/tests/signed_image.c') run('clang', '-arch', 'arm64', '-O2', '-DHALO_IOS_MAC_CHECK=1', f'-I{OUT / "embed"}', 'port/ios/tests/signed_image.c', OUT / 'embed/guest_image.s', '-o', OUT / 'signed_image') run('codesign', '--force', '--sign', '-', OUT / 'signed_image') diff --git a/tools/test_linux_port.py b/tools/test_linux_port.py index f85072824f..af55e4e6ec 100644 --- a/tools/test_linux_port.py +++ b/tools/test_linux_port.py @@ -229,6 +229,9 @@ def test_menus_are_well_formed(): root = MENUS.parent.parent.parent tags = root / "port/linux/game/menu_tags.c" + string_limit = int(re.search(r"#define MAXIMUM_STRINGS (\d+)", tags.read_text())[1]) + functions_text = (root / "port/linux/game/menu_functions.c").read_text() + assert string_limit == int(re.search(r"#define MAXIMUM_STRINGS (\d+)", functions_text)[1]) 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",', "}")) @@ -249,9 +252,9 @@ def test_menus_are_well_formed(): 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) + # menu_files.c accepts one platform name, not a whitespace list. assert element.get("platform") is None or \ - set(element.get("platform").split()) <= {"desktop", "android", "web"}, where + element.get("platform") in {"desktop", "android"}, where # (menu_files.c's whole numbers, which the tags keep in shorts, # its true/false attributes, and no text but whitespace outside # s) @@ -287,6 +290,8 @@ def test_menus_are_well_formed(): 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("strings"): + assert len(element.get("strings").split("|")) <= string_limit, 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"): diff --git a/tools/test_lobby_ranking.py b/tools/test_lobby_ranking.py new file mode 100644 index 0000000000..2916cb191c --- /dev/null +++ b/tools/test_lobby_ranking.py @@ -0,0 +1,47 @@ +"""Exercise the real browser ranking, including a truncated caller buffer.""" +import subprocess +from pathlib import Path +from harness import function + +ROOT = Path(__file__).resolve().parents[1] + + +def test_joinable_busy_games_survive_truncation(tmp_path): + source = (ROOT / 'port/linux/src/p2p_lobby.c').read_text() + code = ''' +#include +#include +#include +#include +#include "p2p.h" +#define MAXIMUM_GAMES 12 +static pthread_mutex_t p2p_lock = PTHREAD_MUTEX_INITIALIZER; +static struct {struct {int used; struct p2p_listing listing;} games[MAXIMUM_GAMES];} lobby; +static int identifier_failed(const unsigned char *id) {return id[0] == 7;} +''' + code += function(source, 'listing_order') + '\n' + function(source, 'p2p_lobby_games') + code += ''' +int main(void) { + struct p2p_listing result[2]; + assert(p2p_lobby_games(result, 2) == 0); + for (int i=0;i<12;i++) { + lobby.games[i].used=1; + struct p2p_listing *g=&lobby.games[i].listing; + g->identifier[0]=i;g->open=1;g->maximum_player_count=128;g->player_count=i; + } + lobby.games[0].listing.player_count=128; // full despite open flag + lobby.games[1].listing.player_count=100;lobby.games[1].listing.open=0; + lobby.games[7].listing.player_count=90; // previously unreachable + assert(p2p_lobby_games(result,2)==2); + assert(result[0].identifier[0]==11 && result[1].identifier[0]==10); + assert(p2p_lobby_games(result,0)==0); + assert(p2p_lobby_games(result,-1)==0); + assert(result[0].identifier[0]==11); // no write for zero capacity + return 0; +} +''' + c = tmp_path / 'ranking.c'; c.write_text(code) + exe = tmp_path / 'ranking' + subprocess.run(['clang', '-Wall', '-Wextra', '-Werror', '-fsanitize=address,undefined', + '-I', str(ROOT / 'port/linux/src'), str(c), '-o', str(exe)], check=True) + subprocess.run([str(exe)], check=True)